feat(editor): Implement Resource Mapper component (#6207)

*  scaffolding
*  finished scaffolding
*  renamed types
*  updated subtitle
*  renamed functions file, UI updates
*  query parameters fixes, ui updates, refactoring
*  fixes for credentials test, setup for error parsing
*  rlc for schema and table, error handling tweaks
*  delete operation, new options
*  columns loader
*  linter fixes
*  where clauses setup
*  logic for processing where clauses
*  select operation
*  refactoring
*  data mode for insert and update, wip
*  data mapping, insert update, skip on conflict option
*  select columns with spaces fix
*  update operation update, wip
*  finished update operation
*  upsert operation
*  ui fixes
* Copy updates.
* Copy updates.
*  option to convert empty strings to nulls, schema checks
*  UI requested updates
*  ssh setup WIP
*  fixes, ssh WIP
*  ssh fixes, credentials
*  credentials testing update
*  uncaught error fix
*  clean up
*  address in use fix
*  improved error message
*  tests setup
*  unit tests wip
*  config files clean up
*  utils unit tests
*  refactoring
*  setup for testing operations, tests for deleteTable operation
*  executeQuery and insert operations tests
*  select, update, upsert operations tests
*  runQueries tests setup
*  hint to query
* Copy updates.
*  ui fixes
*  clean up
*  error message update
*  ui update
* Minor tweaks to query params decription.
* feat(Google Sheets Node): Implement Resource mapper in Google Sheets node (#5752)
*  Added initial resource mapping support in google sheets node
*  Wired mapping API endpoint with node-specific logic for fetching mapping fields
*  Implementing mapping fields logic for google sheets
*  Updating Google Sheets execute methods to support resource mapper fields
* 🚧 Added initial version of `ResourceLocator` component
* 👌 Added `update` mode to resource mapper modes
* 👌 Addressing PR feedback
* 👌 Removing leftover const reference
* 👕 Fixing lint errors
*  singlton for conections
*  credentials test fix, clean up
* feat(Postgres Node): Add resource mapper to new version of Postgres node (#5814)
*  scaffolding
*  finished scaffolding
*  renamed types
*  updated subtitle
*  renamed functions file, UI updates
*  query parameters fixes, ui updates, refactoring
*  fixes for credentials test, setup for error parsing
*  rlc for schema and table, error handling tweaks
*  delete operation, new options
*  columns loader
*  linter fixes
*  where clauses setup
*  logic for processing where clauses
*  select operation
*  refactoring
*  data mode for insert and update, wip
*  data mapping, insert update, skip on conflict option
*  select columns with spaces fix
*  update operation update, wip
*  finished update operation
*  upsert operation
*  ui fixes
* Copy updates.
* Copy updates.
*  option to convert empty strings to nulls, schema checks
*  UI requested updates
*  ssh setup WIP
*  fixes, ssh WIP
*  ssh fixes, credentials
*  credentials testing update
*  uncaught error fix
*  clean up
*  address in use fix
*  improved error message
*  tests setup
*  unit tests wip
*  config files clean up
*  utils unit tests
*  refactoring
*  setup for testing operations, tests for deleteTable operation
*  executeQuery and insert operations tests
*  select, update, upsert operations tests
*  runQueries tests setup
*  hint to query
* Copy updates.
*  ui fixes
*  clean up
*  error message update
*  ui update
* Minor tweaks to query params decription.
*  Updated Postgres node to use resource mapper component
*  Implemented postgres <-> resource mapper type mapping
*  Updated Postgres node execution to use resource mapper fields in v3
* 🔥 Removing unused import
---------
Co-authored-by: Michael Kret <michael.k@radency.com>
Co-authored-by: Giulio Andreini <g.andreini@gmail.com>

* feat(core): Resource editor componend P0 (#5970)
*  Added inital value of mapping mode dropdown
*  Finished mapping mode selector
*  Finished implementing mapping mode selector
*  Implemented 'Columns to match on' dropdown
*  Implemented `loadOptionsDependOn` support in resource mapper
*  Implemented initial version of mapping fields
*  Implementing dependant fields watcher in new component setup
*  Generating correct resource mapper field types. Added `supportAutoMap` to node specification and UI. Not showing fields with `display=false`. Pre-selecting matching columns if it's the only one
*  Handling matching columns correctly in UI
*  Saving and loading resourceMapper values in component
*  Implemented proper data saving and loading
*  ResourceMapper component refactor, fixing value save/load
*  Refactoring MatchingColumnSelect component. Updating Sheets node to use single key match and Postgres to use multi key
*  Updated Google Sheets node to work with the new UI
*  Updating Postgres Node to work with new UI
*  Additional loading indicator that shown if there is no mapping mode selector
*  Removing hard-coded values, fixing matching columns ordering, refactoring
*  Updating field names in nodes
*  Fixing minor UI issues
*  Implemented matching fields filter logic
*  Moving loading label outside of fields list
*  Added initial unit tests for resource mapper
*  Finished default rendering test
*  Test refactoring
*  Finished unit tests
* 🔨 Updating the way i18n is used in resource mapper components
* ✔️ Fixing value to match on logic for postgres node
*  Hiding mapping fields when auto-map mode is selected
*  Syncing selected mapping mode between components
*  Fixing dateTime input rendering and adding update check to Postgres node
*  Properly handling database connections. Sending null for empty string values.
* 💄 Updated wording in the error message for non-existing rows
*  Fixing issues with selected matching values
* ✔️ Updating unit tests after matching logic update
*  Updating matching columns when new fields are loaded
*  Defaulting to null for empty parameter values
*  Allowing zero as valid value for number imputs
*  Updated list of types that use datepicker as widger
*  Using text inputs for time types
*  Initial mapping field rework
*  Added new component for mapping fields, moved bit of logic from root component to matching selector, fixing some lint errors
*  Added tooltip for columns that cannot be deleted
*  Saving deleted values in parameter value
*  Implemented control to add/remove mapping fields
*  Syncing field list with add field dropdown when changing dependent values
*  Not showing removed fields in matching columns selector. Updating wording in matching columns selector description
*  Implementing disabled states for add/remove all fields options
*  Saving removed columns separately, updating copy
*  Implemented resource mapper values validation
*  Updated validation logic and error input styling
*  Validating resource mapper fields when new nodes are added
*  Using node field words in validation, refactoring resource mapper component
*  Implemented schema syncing and add/remove all fields
*  Implemented custom parameter actions
*  Implemented loading indicator in parameter options
* 🔨 Removing unnecessary constants and vue props
*  Handling default values properly
*  Fixing validation logic
* 👕 Fixing lint errors
*  Fixing type issues
*  Not showing fields by default if `addAllFields` is set to `false`
*  Implemented field type validation in resource mapper
*  Updated casing in copy, removed all/remove all option from bottom menu
*  Added auto mapping mode notice
*  Added support for more types in validation
*  Added support for enumerated values
*  Fixing imports after merging
*  Not showing removed fields in matching columns selector. Refactoring validation logic.
* 👕 Fixing imports
* ✔️ Updating unit tests
*  Added resource mapper schema tests
*  Removing `match` from resource mapper field definition, fixing matching columns loading
*  Fixed schema merging
*  update operation return data fix
*  review
* 🐛 Added missing import
* 💄 Updating parameter actions icon based on the ui review
* 💄 Updating word capitalisation in tooltips
* 💄 Added empty state to mapping fields list
* 💄 Removing asterisk from fields, updating tooltips for matching fields
*  Preventing matching fields from being removed by 'Remove All option'
*  Not showing hidden fields in the `Add field` dropdown
*  Added support for custom matching columns labels
*  query optimization
*  fix
*  Optimizing Postgres node enumeration logic
*  Added empty state for matching columns
*  Only fully loading fields if there is no schema fetched
*  Hiding mapping fields if there is no matching columns available in the schema
* ✔️ Fixing minor issues
*  Implemented runtime type validation
* 🔨 Refactoring validation logic
*  Implemented required check, added more custom messages
*  Skipping boolean type in required check
* Type check improvements
*  Only reloading fields if dependent values actually change
*  Adding item index to validation error title
*  Updating Postgres fetching logic, using resource mapper mode to determine if a field can be deleted
*  Resetting field values when adding them via the addAll option
*  Using minor version (2.2) for new Postgres node
*  Implemented proper date validation and type casting
* 👕 Consolidating typing
*  Added unit tests for type validations
* 👌 Addressing front-end review comments
*  More refactoring to address review changes
*  Updating leftover props
*  Added fallback for ISO dates with invalid timezones
* Added timestamp to datetime test cases
*  Reseting matching columns if operation changes
*  Not forcing auto-increment fields to be filled in in Postgres node. Handling null values
* 💄 Added a custom message for invalid dates
*  Better handling of JSON values
*  Updating codemirror readonly stauts based on component property, handling objects in json validation
* Deleting leftover console.log
*  Better time validation
*  Fixing build error after merging
* 👕 Fixing lint error
*  Updating node configuration values
*  Handling postgres arrays better
*  Handling SQL array syntax
*  Updating time validation rules to include timezone
*  Sending expressions that resolve to `null` or `undefined` by the resource mapper to delete cell content in Google Sheets
*  Allowing removed fields to be selected for match
*  Updated the query for fetching unique columns and primary keys
*  Optimizing the unique query
*  Setting timezone to all parsed dates
*  Addressing PR review feedback
*  Configuring Sheets node for production, minor vue component update
* New cases added to the TypeValidation test.
*  Tweaking validation rules for arrays/objects and updating test cases
---------
Co-authored-by: Michael Kret <michael.k@radency.com>
Co-authored-by: Giulio Andreini <g.andreini@gmail.com>
This commit is contained in:
Milorad FIlipović
2023-05-31 11:56:09 +02:00
committed by GitHub
parent 5ae1124106
commit 04cfa548af
57 changed files with 3436 additions and 183 deletions

View File

@@ -6,7 +6,7 @@
import { defineComponent } from 'vue';
import { mapStores } from 'pinia';
import { EditorView, keymap } from '@codemirror/view';
import { EditorState, Prec } from '@codemirror/state';
import { Compartment, EditorState, Prec } from '@codemirror/state';
import { history, redo } from '@codemirror/commands';
import { acceptCompletion, autocompletion, completionStatus } from '@codemirror/autocomplete';
@@ -19,6 +19,8 @@ import { inputTheme } from './theme';
import { n8nLang } from '@/plugins/codemirror/n8nLang';
import { completionManager } from '@/mixins/completionManager';
const editableConf = new Compartment();
export default defineComponent({
name: 'InlineExpressionEditorInput',
mixins: [completionManager, expressionManager, workflowHelpers],
@@ -39,6 +41,11 @@ export default defineComponent({
},
},
watch: {
isReadOnly(newValue: boolean) {
this.editor?.dispatch({
effects: editableConf.reconfigure(EditorView.editable.of(!newValue)),
});
},
value(newValue) {
const isInternalChange = newValue === this.editor?.state.doc.toString();
@@ -97,7 +104,7 @@ export default defineComponent({
history(),
expressionInputHandler(),
EditorView.lineWrapping,
EditorView.editable.of(!this.isReadOnly),
editableConf.of(EditorView.editable.of(!this.isReadOnly)),
EditorView.contentAttributes.of({ 'data-gramm': 'false' }), // disable grammarly
EditorView.domEventHandlers({
focus: () => {

View File

@@ -362,6 +362,7 @@ import type {
INodeProperties,
INodePropertyCollection,
NodeParameterValueType,
IParameterLabel,
EditorType,
CodeNodeEditorLanguage,
} from 'n8n-workflow';
@@ -380,7 +381,6 @@ import { externalHooks } from '@/mixins/externalHooks';
import { nodeHelpers } from '@/mixins/nodeHelpers';
import { workflowHelpers } from '@/mixins/workflowHelpers';
import { hasExpressionMapping, isValueExpression, isResourceLocatorValue } from '@/utils';
import { CODE_NODE_TYPE, CUSTOM_API_CALL_KEY, HTML_NODE_TYPE } from '@/constants';
import type { PropType } from 'vue';
import { debounceHelper } from '@/mixins/debounce';
@@ -457,6 +457,12 @@ export default defineComponent({
expressionEvaluated: {
type: String as PropType<string | undefined>,
},
label: {
type: Object as PropType<IParameterLabel>,
default: () => ({
size: 'small',
}),
},
},
data() {
return {

View File

@@ -10,6 +10,7 @@
>
<template #options>
<parameter-options
v-if="displayOptions"
:parameter="parameter"
:value="value"
:isReadOnly="isReadOnly"
@@ -54,6 +55,7 @@
:forceShowExpression="forceShowExpression"
:hint="hint"
:hide-issues="hideIssues"
:label="label"
@valueChanged="valueChanged"
@textInput="onTextInput"
@focus="onFocus"

View File

@@ -81,7 +81,16 @@
@valueChanged="valueChanged"
/>
</div>
<resource-mapper
v-else-if="parameter.type === 'resourceMapper'"
:parameter="parameter"
:node="node"
:path="getPath(parameter.name)"
:dependentParametersValues="getDependentParametersValues(parameter)"
inputSize="small"
labelSize="small"
@valueChanged="valueChanged"
/>
<div v-else-if="displayNodeParameter(parameter)" class="parameter-item">
<div
class="delete-option clickable"
@@ -100,9 +109,11 @@
:parameter="parameter"
:hide-issues="hiddenIssuesInputs.includes(parameter.name)"
:value="getParameterValue(nodeValues, parameter.name, path)"
:displayOptions="true"
:displayOptions="shouldShowOptions(parameter)"
:path="getPath(parameter.name)"
:isReadOnly="isReadOnly"
:hideLabel="false"
:nodeValues="nodeValues"
@valueChanged="valueChanged"
@blur="onParameterBlur(parameter.name)"
/>
@@ -127,7 +138,7 @@ import MultipleParameter from '@/components/MultipleParameter.vue';
import { workflowHelpers } from '@/mixins/workflowHelpers';
import ParameterInputFull from '@/components/ParameterInputFull.vue';
import ImportParameter from '@/components/ImportParameter.vue';
import ResourceMapper from '@/components/ResourceMapper/ResourceMapper.vue';
import { get, set } from 'lodash-es';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
@@ -143,6 +154,7 @@ export default defineComponent({
FixedCollectionParameter: async () => import('./FixedCollectionParameter.vue'),
CollectionParameter: async () => import('./CollectionParameter.vue'),
ImportParameter,
ResourceMapper,
},
props: {
nodeValues: {
@@ -398,6 +410,33 @@ export default defineComponent({
// since there is no such case, omitting it to avoid additional computation
return isAuthRelatedParameter(this.nodeAuthFields, parameter);
},
shouldShowOptions(parameter: INodeProperties): boolean {
return parameter.type !== 'resourceMapper';
},
getDependentParametersValues(parameter: INodeProperties): string | null {
const loadOptionsDependsOn = this.getArgument('loadOptionsDependsOn', parameter) as
| string[]
| undefined;
if (loadOptionsDependsOn === undefined) {
return null;
}
// Get the resolved parameter values of the current node
const currentNodeParameters = this.ndvStore.activeNode?.parameters;
try {
const resolvedNodeParameters = this.resolveParameter(currentNodeParameters);
const returnValues: string[] = [];
for (const parameterPath of loadOptionsDependsOn) {
returnValues.push(get(resolvedNodeParameters, parameterPath) as string);
}
return returnValues.join('|');
} catch (error) {
return null;
}
},
},
watch: {
filteredParameterNames(newValue, oldValue) {

View File

@@ -16,6 +16,7 @@
:isForCredential="isForCredential"
:eventSource="eventSource"
:expressionEvaluated="expressionValueComputed"
:label="label"
:data-test-id="`parameter-input-${parameter.name}`"
@focus="onFocus"
@blur="onBlur"
@@ -51,6 +52,7 @@ import InputHint from '@/components/ParameterInputHint.vue';
import type {
INodeProperties,
INodePropertyMode,
IParameterLabel,
NodeParameterValue,
NodeParameterValueType,
} from 'n8n-workflow';
@@ -116,6 +118,12 @@ export default defineComponent({
eventSource: {
type: String,
},
label: {
type: Object as PropType<IParameterLabel>,
default: () => ({
size: 'small',
}),
},
},
computed: {
...mapStores(useNDVStore),

View File

@@ -1,26 +1,41 @@
<template>
<div :class="$style.container">
<n8n-action-toggle
v-if="shouldShowOptions"
placement="bottom-end"
size="small"
color="foreground-xdark"
iconSize="small"
:actions="actions"
@action="(action) => $emit('optionSelected', action)"
@visible-change="onMenuToggle"
/>
<n8n-radio-buttons
v-if="parameter.noDataExpression !== true && showExpressionSelector"
size="small"
:value="selectedView"
:disabled="isReadOnly"
@input="onViewSelected"
:options="[
{ label: $locale.baseText('parameterInput.fixed'), value: 'fixed' },
{ label: $locale.baseText('parameterInput.expression'), value: 'expression' },
]"
/>
<div v-if="loading" :class="$style.loader">
<n8n-text v-if="loading" size="small">
<n8n-icon icon="sync-alt" size="xsmall" :spin="true" />
{{ loadingMessage }}
</n8n-text>
</div>
<div v-else :class="$style.controlsContainer">
<div
:class="{
[$style.noExpressionSelector]: !shouldShowExpressionSelector,
}"
>
<n8n-action-toggle
v-if="shouldShowOptions"
placement="bottom-end"
size="small"
color="foreground-xdark"
iconSize="small"
:actions="actions"
:iconOrientation="iconOrientation"
@action="(action) => $emit('optionSelected', action)"
@visible-change="onMenuToggle"
/>
</div>
<n8n-radio-buttons
v-if="shouldShowExpressionSelector"
size="small"
:value="selectedView"
:disabled="isReadOnly"
@input="onViewSelected"
:options="[
{ label: $locale.baseText('parameterInput.fixed'), value: 'fixed' },
{ label: $locale.baseText('parameterInput.expression'), value: 'expression' },
]"
/>
</div>
</div>
</template>
@@ -50,6 +65,25 @@ export default defineComponent({
type: Boolean,
default: true,
},
customActions: {
type: Array as PropType<Array<{ label: string; value: string; disabled?: boolean }>>,
default: () => [],
},
iconOrientation: {
type: String,
default: 'vertical',
validator: (value: string): boolean => ['horizontal', 'vertical'].includes(value),
},
loading: {
type: Boolean,
default: false,
},
loadingMessage: {
type: String,
default() {
return this.$locale.baseText('genericHelpers.loading');
},
},
},
computed: {
isDefault(): boolean {
@@ -61,6 +95,9 @@ export default defineComponent({
isHtmlEditor(): boolean {
return this.getArgument('editor') === 'htmlEditor';
},
shouldShowExpressionSelector(): boolean {
return this.parameter.noDataExpression !== true && this.showExpressionSelector;
},
shouldShowOptions(): boolean {
if (this.isReadOnly === true) {
return false;
@@ -91,6 +128,10 @@ export default defineComponent({
return !!this.getArgument('loadOptionsMethod') || !!this.getArgument('loadOptions');
},
actions(): Array<{ label: string; value: string; disabled?: boolean }> {
if (Array.isArray(this.customActions) && this.customActions.length > 0) {
return this.customActions;
}
if (this.isHtmlEditor && !this.isValueExpression) {
return [
{
@@ -158,4 +199,19 @@ export default defineComponent({
.container {
display: flex;
}
.loader > span {
line-height: 1em;
}
.controlsContainer {
display: flex;
}
.noExpressionSelector {
margin-bottom: var(--spacing-4xs);
span {
padding-right: 0 !important;
}
}
</style>

View File

@@ -0,0 +1,392 @@
<script setup lang="ts">
import type { IUpdateInformation } from '@/Interface';
import type {
FieldType,
INodeIssues,
INodeParameters,
INodeProperties,
NodePropertyTypes,
ResourceMapperField,
ResourceMapperValue,
} from 'n8n-workflow';
import ParameterInputFull from '@/components/ParameterInputFull.vue';
import ParameterIssues from '../ParameterIssues.vue';
import ParameterOptions from '../ParameterOptions.vue';
import { computed } from 'vue';
import { i18n as locale } from '@/plugins/i18n';
import { useNDVStore } from '@/stores';
import { fieldCannotBeDeleted, isMatchingField, parseResourceMapperFieldName } from '@/utils';
import { useNodeSpecificationValues } from '@/composables';
interface Props {
parameter: INodeProperties;
path: string;
nodeValues: INodeParameters | undefined;
fieldsToMap: ResourceMapperField[];
paramValue: ResourceMapperValue;
labelSize: string;
showMatchingColumnsSelector: boolean;
showMappingModeSelect: boolean;
loading: boolean;
refreshInProgress: boolean;
}
const props = defineProps<Props>();
const FORCE_TEXT_INPUT_FOR_TYPES: FieldType[] = ['time', 'object', 'array'];
const {
resourceMapperTypeOptions,
singularFieldWord,
singularFieldWordCapitalized,
pluralFieldWord,
pluralFieldWordCapitalized,
} = useNodeSpecificationValues(props.parameter.typeOptions);
const emit = defineEmits<{
(event: 'fieldValueChanged', value: IUpdateInformation): void;
(event: 'removeField', field: string): void;
(event: 'addField', field: string): void;
(event: 'refreshFieldList'): void;
}>();
const ndvStore = useNDVStore();
const fieldsUi = computed<INodeProperties[]>(() => {
return props.fieldsToMap
.filter((field) => field.display !== false && field.removed !== true)
.map((field) => {
return {
displayName: getFieldLabel(field),
// Set part of the path to each param name so value can be fetched properly by input parameter list component
name: `value["${field.id}"]`,
type: getParamType(field),
default: field.type === 'boolean' ? false : '',
required: field.required,
description: getFieldDescription(field),
options: field.options,
};
});
});
const orderedFields = computed<INodeProperties[]>(() => {
// Sort so that matching columns are first
if (props.paramValue.matchingColumns) {
fieldsUi.value.forEach((field, i) => {
const fieldName = parseResourceMapperFieldName(field.name);
if (fieldName) {
if (props.paramValue.matchingColumns.includes(fieldName)) {
fieldsUi.value.splice(i, 1);
fieldsUi.value.unshift(field);
}
}
});
}
return fieldsUi.value;
});
const removedFields = computed<ResourceMapperField[]>(() => {
return props.fieldsToMap.filter((field) => field.removed === true && field.display !== false);
});
const addFieldOptions = computed<Array<{ name: string; value: string; disabled?: boolean }>>(() => {
return removedFields.value.map((field) => {
return {
name: field.displayName,
value: field.id,
disabled: false,
};
});
});
const parameterActions = computed<Array<{ label: string; value: string; disabled?: boolean }>>(
() => {
return [
{
label: locale.baseText('resourceMapper.refreshFieldList', {
interpolate: { fieldWord: singularFieldWordCapitalized.value },
}),
value: 'refreshFieldList',
},
{
label: locale.baseText('resourceMapper.addAllFields', {
interpolate: { fieldWord: pluralFieldWordCapitalized.value },
}),
value: 'addAllFields',
disabled: removedFields.value.length === 0,
},
{
label: locale.baseText('resourceMapper.removeAllFields', {
interpolate: { fieldWord: pluralFieldWordCapitalized.value },
}),
value: 'removeAllFields',
disabled: isRemoveAllAvailable.value === false,
},
];
},
);
const isRemoveAllAvailable = computed<boolean>(() => {
return (
removedFields.value.length !== props.fieldsToMap.length &&
props.fieldsToMap.some((field) => {
return (
field.removed !== true &&
!fieldCannotBeDeleted(
field,
props.showMatchingColumnsSelector,
resourceMapperMode.value,
props.paramValue.matchingColumns,
)
);
})
);
});
const resourceMapperMode = computed<string | undefined>(() => {
return resourceMapperTypeOptions.value?.mode;
});
const valuesLabel = computed<string>(() => {
if (resourceMapperMode.value && resourceMapperMode.value === 'update') {
return locale.baseText('resourceMapper.valuesToUpdate.label');
}
return locale.baseText('resourceMapper.valuesToSend.label');
});
const fetchingFieldsLabel = computed<string>(() => {
return locale.baseText('resourceMapper.fetchingFields.message', {
interpolate: {
fieldWord: pluralFieldWord.value,
},
});
});
function getFieldLabel(field: ResourceMapperField): string {
if (
isMatchingField(field.id, props.paramValue.matchingColumns, props.showMatchingColumnsSelector)
) {
const suffix = locale.baseText('resourceMapper.usingToMatch') || '';
return `${field.displayName} ${suffix}`;
}
return field.displayName;
}
function getFieldDescription(field: ResourceMapperField): string {
if (
isMatchingField(field.id, props.paramValue.matchingColumns, props.showMatchingColumnsSelector)
) {
return (
locale.baseText('resourceMapper.usingToMatch.description', {
interpolate: {
fieldWord: singularFieldWord.value,
},
}) || ''
);
}
return '';
}
function getParameterValue(parameterName: string) {
const fieldName = parseResourceMapperFieldName(parameterName);
if (fieldName && props.paramValue.value) {
return props.paramValue.value[fieldName];
}
return null;
}
function getFieldIssues(field: INodeProperties): string[] {
if (!ndvStore.activeNode) return [];
const nodeIssues = ndvStore.activeNode.issues || ({} as INodeIssues);
const fieldName = parseResourceMapperFieldName(field.name);
if (!fieldName) return [];
let fieldIssues: string[] = [];
const key = `${props.parameter.name}.${fieldName}`;
if (nodeIssues['parameters'] && key in nodeIssues['parameters']) {
fieldIssues = fieldIssues.concat(nodeIssues['parameters'][key]);
}
return fieldIssues;
}
function getParamType(field: ResourceMapperField): NodePropertyTypes {
if (field.type && !FORCE_TEXT_INPUT_FOR_TYPES.includes(field.type)) {
return field.type as NodePropertyTypes;
}
return 'string';
}
function onValueChanged(value: IUpdateInformation): void {
emit('fieldValueChanged', value);
}
function removeField(fieldName: string) {
emit('removeField', fieldName);
}
function addField(fieldName: string) {
emit('addField', fieldName);
}
function onParameterActionSelected(action: string): void {
switch (action) {
case 'addAllFields':
emit('addField', action);
break;
case 'removeAllFields':
emit('removeField', action);
break;
case 'refreshFieldList':
emit('refreshFieldList');
break;
default:
break;
}
}
defineExpose({
orderedFields,
});
</script>
<template>
<div class="mt-xs" data-test-id="mapping-fields-container">
<n8n-input-label
:label="valuesLabel"
:underline="true"
:size="labelSize"
:showOptions="true"
:showExpressionSelector="false"
color="text-dark"
>
<template #options>
<parameter-options
:parameter="parameter"
:customActions="parameterActions"
:loading="props.refreshInProgress"
:loadingMessage="fetchingFieldsLabel"
@optionSelected="onParameterActionSelected"
/>
</template>
</n8n-input-label>
<div v-if="orderedFields.length === 0" class="mt-3xs mb-xs">
<n8n-text size="small">{{
$locale.baseText('fixedCollectionParameter.currentlyNoItemsExist')
}}</n8n-text>
</div>
<div
v-for="field in orderedFields"
:key="field.name"
:class="{
['parameter-item']: true,
[$style.parameterItem]: true,
[$style.hasIssues]: getFieldIssues(field).length > 0,
}"
>
<div
v-if="resourceMapperMode === 'add' && field.required"
:class="['delete-option', 'mt-5xs', $style.parameterTooltipIcon]"
>
<n8n-tooltip placement="top">
<template #content>
<span>{{
locale.baseText('resourceMapper.mandatoryField.title', {
interpolate: { fieldWord: singularFieldWord },
})
}}</span>
</template>
<font-awesome-icon icon="question-circle" />
</n8n-tooltip>
</div>
<div
v-else-if="
!isMatchingField(
field.name,
props.paramValue.matchingColumns,
props.showMatchingColumnsSelector,
)
"
:class="['delete-option', 'clickable', 'mt-5xs']"
>
<font-awesome-icon
icon="trash"
:title="
locale.baseText('resourceMapper.removeField', {
interpolate: {
fieldWord: singularFieldWordCapitalized,
},
})
"
data-test-id="remove-field-button"
@click="removeField(field.name)"
/>
</div>
<div :class="$style.parameterInput">
<parameter-input-full
:parameter="field"
:value="getParameterValue(field.name)"
:displayOptions="true"
:path="`${props.path}.${field.name}`"
:isReadOnly="refreshInProgress"
:hideIssues="true"
:nodeValues="nodeValues"
:class="$style.parameterInputFull"
@valueChanged="onValueChanged"
/>
</div>
<parameter-issues
v-if="getFieldIssues(field).length > 0"
:issues="getFieldIssues(field)"
:class="[$style.parameterIssues, 'ml-5xs']"
/>
</div>
<div class="add-option" data-test-id="add-fields-select">
<n8n-select
:placeholder="
locale.baseText('resourceMapper.addFieldToSend', {
interpolate: { fieldWord: singularFieldWordCapitalized },
})
"
size="small"
:disabled="addFieldOptions.length == 0"
@change="addField"
>
<n8n-option
v-for="item in addFieldOptions"
:key="item.value"
:label="item.name"
:value="item.value"
:disabled="item.disabled"
>
</n8n-option>
</n8n-select>
</div>
</div>
</template>
<style module lang="scss">
.parameterItem {
display: flex;
padding: 0 0 0 1em;
.parameterInput {
width: 100%;
}
&.hasIssues {
.parameterIssues {
float: none;
padding-top: var(--spacing-xl);
}
input,
input:focus {
--input-border-color: var(--color-danger);
border-color: var(--color-danger);
}
}
}
.parameterTooltipIcon {
color: var(--color-text-light) !important;
}
</style>

View File

@@ -0,0 +1,153 @@
<script setup lang="ts">
import type { INodePropertyTypeOptions, ResourceMapperFields } from 'n8n-workflow';
import { computed, ref, watch } from 'vue';
import { i18n as locale } from '@/plugins/i18n';
import { useNodeSpecificationValues } from '@/composables';
interface Props {
initialValue: string;
fieldsToMap: ResourceMapperFields['fields'];
inputSize: string;
labelSize: string;
typeOptions: INodePropertyTypeOptions | undefined;
serviceName: string;
loading: boolean;
loadingError: boolean;
}
const props = defineProps<Props>();
const { resourceMapperTypeOptions, pluralFieldWord, singularFieldWord } =
useNodeSpecificationValues(props.typeOptions);
// Mapping mode options: Labels here use field words defined in parameter type options
const mappingModeOptions = [
{
name: locale.baseText('resourceMapper.mappingMode.defineBelow.name'),
value: 'defineBelow',
description: locale.baseText('resourceMapper.mappingMode.defineBelow.description', {
interpolate: {
fieldWord: singularFieldWord.value,
},
}),
},
{
name: locale.baseText('resourceMapper.mappingMode.autoMapInputData.name'),
value: 'autoMapInputData',
description: locale.baseText('resourceMapper.mappingMode.autoMapInputData.description', {
interpolate: {
fieldWord: pluralFieldWord.value,
serviceName: props.serviceName,
},
}),
},
];
const emit = defineEmits<{
(event: 'modeChanged', value: string): void;
(event: 'retryFetch'): void;
}>();
const selected = ref(props.initialValue);
watch(
() => props.initialValue,
() => {
selected.value = props.initialValue;
},
);
const errorMessage = computed<string>(() => {
if (selected.value === 'defineBelow') {
// Loading error message
if (props.loadingError) {
return locale.baseText('resourceMapper.fetchingFields.errorMessage', {
interpolate: {
fieldWord: pluralFieldWord.value,
},
});
}
// No data error message
if (props.fieldsToMap.length === 0) {
return (
// Use custom error message if defined
resourceMapperTypeOptions.value?.noFieldsError ||
locale.baseText('resourceMapper.fetchingFields.noFieldsFound', {
interpolate: {
fieldWord: pluralFieldWord.value,
serviceName: props.serviceName,
},
})
);
}
return '';
}
return '';
});
function onModeChanged(value: string): void {
selected.value = value;
emit('modeChanged', value);
}
function onRetryClick(): void {
emit('retryFetch');
}
defineExpose({
selected,
onModeChanged,
mappingModeOptions,
});
</script>
<template>
<div data-test-id="mapping-mode-select">
<n8n-input-label
:label="locale.baseText('resourceMapper.mappingMode.label')"
:bold="false"
:required="false"
:size="labelSize"
color="text-dark"
>
<template>
<div class="mt-5xs">
<n8n-select :value="selected" :size="props.inputSize" @change="onModeChanged">
<n8n-option
v-for="option in mappingModeOptions"
:key="option.value"
:value="option.value"
:label="option.name"
description="sadasd"
>
<div class="list-option">
<div class="option-headline">
{{ option.name }}
</div>
<div class="option-description" v-html="option.description" />
</div>
</n8n-option>
</n8n-select>
</div>
<div class="mt-5xs">
<n8n-text v-if="loading" size="small">
<n8n-icon icon="sync-alt" size="xsmall" :spin="true" />
{{
locale.baseText('resourceMapper.fetchingFields.message', {
interpolate: {
fieldWord: pluralFieldWord,
},
})
}}
</n8n-text>
<n8n-text v-else-if="errorMessage !== ''" size="small" color="danger">
<n8n-icon icon="exclamation-triangle" size="xsmall" />
{{ errorMessage }}
<n8n-link size="small" theme="danger" :underline="true" @click="onRetryClick">
{{ locale.baseText('generic.retry') }}
</n8n-link>
</n8n-text>
</div>
</template>
</n8n-input-label>
</div>
</template>

View File

@@ -0,0 +1,155 @@
<script setup lang="ts">
import type {
INodePropertyTypeOptions,
ResourceMapperField,
ResourceMapperFields,
} from 'n8n-workflow';
import { computed, reactive, watch } from 'vue';
import { i18n as locale } from '@/plugins/i18n';
import { useNodeSpecificationValues } from '@/composables';
interface Props {
initialValue: string[];
fieldsToMap: ResourceMapperFields['fields'];
typeOptions: INodePropertyTypeOptions | undefined;
labelSize: string;
inputSize: string;
loading: boolean;
serviceName: string;
}
const props = defineProps<Props>();
const {
resourceMapperTypeOptions,
singularFieldWord,
singularFieldWordCapitalized,
pluralFieldWord,
pluralFieldWordCapitalized,
} = useNodeSpecificationValues(props.typeOptions);
// Depending on the mode (multiple/singe key column), the selected value can be a string or an array of strings
const state = reactive({
selected: props.initialValue as string[] | string,
});
watch(
() => props.initialValue,
() => {
state.selected =
resourceMapperTypeOptions.value?.multiKeyMatch === true
? props.initialValue
: props.initialValue[0];
},
);
const emit = defineEmits<{
(event: 'matchingColumnsChanged', value: string[]): void;
}>();
const availableMatchingFields = computed<ResourceMapperField[]>(() => {
return props.fieldsToMap.filter((field) => {
return field.canBeUsedToMatch !== false && field.display !== false;
});
});
// Field label, description and tooltip: Labels here use content and field words defined in parameter type options
const fieldLabel = computed<string>(() => {
if (resourceMapperTypeOptions.value?.matchingFieldsLabels?.title) {
return resourceMapperTypeOptions.value.matchingFieldsLabels.title;
}
const fieldWord =
resourceMapperTypeOptions.value?.multiKeyMatch === true
? pluralFieldWordCapitalized.value
: singularFieldWordCapitalized.value;
return locale.baseText('resourceMapper.columnsToMatchOn.label', {
interpolate: {
fieldWord,
},
});
});
const fieldDescription = computed<string>(() => {
if (resourceMapperTypeOptions.value?.matchingFieldsLabels?.hint) {
return resourceMapperTypeOptions.value.matchingFieldsLabels.hint;
}
const labeli18nKey =
resourceMapperTypeOptions.value?.multiKeyMatch === true
? 'resourceMapper.columnsToMatchOn.multi.description'
: 'resourceMapper.columnsToMatchOn.single.description';
return locale.baseText(labeli18nKey, {
interpolate: {
fieldWord:
resourceMapperTypeOptions.value?.multiKeyMatch === true
? `${pluralFieldWord.value}`
: `${singularFieldWord.value}`,
},
});
});
const fieldTooltip = computed<string>(() => {
if (resourceMapperTypeOptions.value?.matchingFieldsLabels?.description) {
return resourceMapperTypeOptions.value.matchingFieldsLabels.description;
}
return locale.baseText('resourceMapper.columnsToMatchOn.tooltip', {
interpolate: {
fieldWord:
resourceMapperTypeOptions.value?.multiKeyMatch === true
? `${pluralFieldWord.value}`
: `${singularFieldWord.value}`,
},
});
});
function onSelectionChange(value: string | string[]) {
if (resourceMapperTypeOptions.value?.multiKeyMatch === true) {
state.selected = value as string[];
} else {
state.selected = value as string;
}
emitValueChanged();
}
function emitValueChanged() {
emit('matchingColumnsChanged', Array.isArray(state.selected) ? state.selected : [state.selected]);
}
defineExpose({
state,
});
</script>
<template>
<div class="mt-2xs" data-test-id="matching-column-select">
<n8n-input-label
v-if="availableMatchingFields.length > 0"
:label="fieldLabel"
:tooltipText="fieldTooltip"
:bold="false"
:required="false"
:size="labelSize"
color="text-dark"
>
<n8n-select
:multiple="resourceMapperTypeOptions?.multiKeyMatch === true"
:value="state.selected"
:size="props.inputSize"
:disabled="loading"
@change="onSelectionChange"
>
<n8n-option v-for="field in availableMatchingFields" :key="field.id" :value="field.id">
{{ field.displayName }}
</n8n-option>
</n8n-select>
<n8n-text size="small">
{{ fieldDescription }}
</n8n-text>
</n8n-input-label>
<n8n-notice v-else>
{{
locale.baseText('resourceMapper.columnsToMatchOn.noFieldsFound', {
interpolate: { fieldWord: singularFieldWord, serviceName: props.serviceName },
})
}}
</n8n-notice>
</div>
</template>

View File

@@ -0,0 +1,500 @@
<script setup lang="ts">
import type { IUpdateInformation, ResourceMapperReqParams } from '@/Interface';
import { resolveParameter } from '@/mixins/workflowHelpers';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import type {
INode,
INodeParameters,
INodeProperties,
INodeTypeDescription,
ResourceMapperField,
ResourceMapperValue,
} from 'n8n-workflow';
import { NodeHelpers } from 'n8n-workflow';
import { computed, onMounted, reactive, watch } from 'vue';
import MappingModeSelect from './MappingModeSelect.vue';
import MatchingColumnsSelect from './MatchingColumnsSelect.vue';
import MappingFields from './MappingFields.vue';
import { fieldCannotBeDeleted, isResourceMapperValue, parseResourceMapperFieldName } from '@/utils';
import { i18n as locale } from '@/plugins/i18n';
import { useNDVStore } from '@/stores/ndv.store';
interface Props {
parameter: INodeProperties;
node: INode | null;
path: string;
inputSize: string;
labelSize: string;
dependentParametersValues: string | null;
}
const nodeTypesStore = useNodeTypesStore();
const ndvStore = useNDVStore();
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'valueChanged', value: IUpdateInformation): void;
}>();
const state = reactive({
paramValue: {
mappingMode: 'defineBelow',
value: {},
matchingColumns: [] as string[],
schema: [] as ResourceMapperField[],
} as ResourceMapperValue,
parameterValues: {} as INodeParameters,
loading: false,
refreshInProgress: false, // Shows inline loader when refreshing fields
loadingError: false,
});
// Reload fields to map when dependent parameters change
watch(
() => props.dependentParametersValues,
async (currentValue, oldValue) => {
if (oldValue !== null && currentValue !== null && oldValue !== currentValue) {
state.paramValue = {
...state.paramValue,
value: null,
schema: [],
};
emitValueChanged();
await initFetching();
setDefaultFieldValues(true);
}
},
);
onMounted(async () => {
if (props.node) {
state.parameterValues = {
...state.parameterValues,
parameters: props.node.parameters,
};
}
const params = state.parameterValues.parameters as INodeParameters;
const parameterName = props.parameter.name;
if (!(parameterName in params)) {
return;
}
let hasSchema = false;
const nodeValues = params[parameterName] as unknown as ResourceMapperValue;
state.paramValue = {
...state.paramValue,
...nodeValues,
};
if (!state.paramValue.schema) {
state.paramValue = {
...state.paramValue,
schema: [],
};
} else {
hasSchema = state.paramValue.schema.length > 0;
}
Object.keys(state.paramValue.value || {}).forEach((key) => {
if (state.paramValue.value && state.paramValue.value[key] === '') {
state.paramValue = {
...state.paramValue,
value: {
...state.paramValue.value,
[key]: null,
},
};
}
});
if (nodeValues.matchingColumns) {
state.paramValue = {
...state.paramValue,
matchingColumns: nodeValues.matchingColumns,
};
}
await initFetching(hasSchema);
// Set default values if this is the first time the parameter is being set
if (!state.paramValue.value) {
setDefaultFieldValues();
}
updateNodeIssues();
});
const resourceMapperMode = computed<string | undefined>(() => {
return props.parameter.typeOptions?.resourceMapper?.mode;
});
const nodeType = computed<INodeTypeDescription | null>(() => {
if (props.node) {
return nodeTypesStore.getNodeType(props.node.type, props.node.typeVersion);
}
return null;
});
const showMappingModeSelect = computed<boolean>(() => {
return props.parameter.typeOptions?.resourceMapper?.supportAutoMap !== false;
});
const showMatchingColumnsSelector = computed<boolean>(() => {
return (
!state.loading &&
props.parameter.typeOptions?.resourceMapper?.mode !== 'add' &&
state.paramValue.schema.length > 0
);
});
const showMappingFields = computed<boolean>(() => {
return (
state.paramValue.mappingMode === 'defineBelow' &&
!state.loading &&
!state.loadingError &&
state.paramValue.schema.length > 0 &&
hasAvailableMatchingColumns.value
);
});
const matchingColumns = computed<string[]>(() => {
if (!showMatchingColumnsSelector.value) {
return [];
}
if (state.paramValue.matchingColumns) {
return state.paramValue.matchingColumns;
}
return defaultSelectedMatchingColumns.value;
});
const hasAvailableMatchingColumns = computed<boolean>(() => {
if (resourceMapperMode.value !== 'add') {
return (
state.paramValue.schema.filter(
(field) =>
field.canBeUsedToMatch !== false && field.display !== false && field.removed !== true,
).length > 0
);
}
return true;
});
const defaultSelectedMatchingColumns = computed<string[]>(() => {
return state.paramValue.schema.length === 1
? [state.paramValue.schema[0].id]
: state.paramValue.schema.reduce((acc, field) => {
if (field.defaultMatch && field.canBeUsedToMatch === true) {
acc.push(field.id);
}
return acc;
}, [] as string[]);
});
const pluralFieldWord = computed<string>(() => {
return (
props.parameter.typeOptions?.resourceMapper?.fieldWords?.plural ||
locale.baseText('generic.fields')
);
});
async function initFetching(inlineLading = false): Promise<void> {
state.loadingError = false;
if (inlineLading) {
state.refreshInProgress = true;
} else {
state.loading = true;
}
try {
await loadFieldsToMap();
if (!state.paramValue.matchingColumns || state.paramValue.matchingColumns.length === 0) {
onMatchingColumnsChanged(defaultSelectedMatchingColumns.value);
}
} catch (error) {
state.loadingError = true;
} finally {
state.loading = false;
state.refreshInProgress = false;
}
}
async function loadFieldsToMap(): Promise<void> {
if (!props.node) {
return;
}
const requestParams: ResourceMapperReqParams = {
nodeTypeAndVersion: {
name: props.node?.type,
version: props.node.typeVersion,
},
currentNodeParameters: resolveParameter(props.node.parameters) as INodeParameters,
path: props.path,
methodName: props.parameter.typeOptions?.resourceMapper?.resourceMapperMethod,
credentials: props.node.credentials,
};
const fetchedFields = await nodeTypesStore.getResourceMapperFields(requestParams);
if (fetchedFields !== null) {
const newSchema = fetchedFields.fields.map((field) => {
const existingField = state.paramValue.schema.find((f) => f.id === field.id);
if (existingField) {
field.removed = existingField.removed;
} else if (state.paramValue.value !== null && !(field.id in state.paramValue.value)) {
// New fields are shown by default
field.removed = false;
}
return field;
});
state.paramValue = {
...state.paramValue,
schema: newSchema,
};
emitValueChanged();
}
}
async function onModeChanged(mode: string): Promise<void> {
state.paramValue.mappingMode = mode;
if (mode === 'defineBelow') {
await initFetching();
} else {
state.loadingError = false;
}
emitValueChanged();
}
function setDefaultFieldValues(forceMatchingFieldsUpdate = false): void {
state.paramValue.value = {};
const hideAllFields = props.parameter.typeOptions?.resourceMapper?.addAllFields === false;
state.paramValue.schema.forEach((field) => {
if (state.paramValue.value) {
// Hide all non-required fields if it's configured in node definition
if (hideAllFields) {
field.removed = !field.required;
}
if (field.type === 'boolean') {
state.paramValue.value = {
...state.paramValue.value,
[field.id]: false,
};
} else {
state.paramValue.value = {
...state.paramValue.value,
[field.id]: null,
};
}
}
});
emitValueChanged();
if (!state.paramValue.matchingColumns || forceMatchingFieldsUpdate) {
state.paramValue.matchingColumns = defaultSelectedMatchingColumns.value;
emitValueChanged();
}
}
function updateNodeIssues(): void {
if (props.node) {
const parameterIssues = NodeHelpers.getNodeParametersIssues(
nodeType.value?.properties || [],
props.node,
);
if (parameterIssues) {
ndvStore.updateNodeParameterIssues(parameterIssues);
}
}
}
function onMatchingColumnsChanged(matchingColumns: string[]): void {
state.paramValue = {
...state.paramValue,
matchingColumns,
};
// Set all matching fields to be visible
state.paramValue.schema.forEach((field) => {
if (state.paramValue.matchingColumns?.includes(field.id)) {
field.removed = false;
state.paramValue.schema.splice(state.paramValue.schema.indexOf(field), 1, field);
}
});
if (!state.loading) {
emitValueChanged();
}
}
function fieldValueChanged(updateInfo: IUpdateInformation): void {
let newValue = null;
if (
updateInfo.value !== undefined &&
updateInfo.value !== '' &&
updateInfo.value !== null &&
isResourceMapperValue(updateInfo.value)
) {
newValue = updateInfo.value;
}
const fieldName = parseResourceMapperFieldName(updateInfo.name);
if (fieldName && state.paramValue.value) {
state.paramValue.value = {
...state.paramValue.value,
[fieldName]: newValue,
};
emitValueChanged();
}
}
function removeField(name: string): void {
if (name === 'removeAllFields') {
return removeAllFields();
}
const fieldName = parseResourceMapperFieldName(name);
if (fieldName) {
if (state.paramValue.value) {
delete state.paramValue.value[fieldName];
const field = state.paramValue.schema.find((f) => f.id === fieldName);
if (field) {
field.removed = true;
state.paramValue.schema.splice(state.paramValue.schema.indexOf(field), 1, field);
}
emitValueChanged();
}
}
}
function addField(name: string): void {
if (name === 'addAllFields') {
return addAllFields();
}
if (name === 'removeAllFields') {
return removeAllFields();
}
state.paramValue.value = {
...state.paramValue.value,
[name]: null,
};
const field = state.paramValue.schema.find((f) => f.id === name);
if (field) {
field.removed = false;
state.paramValue.schema.splice(state.paramValue.schema.indexOf(field), 1, field);
}
emitValueChanged();
}
function addAllFields(): void {
const newValues: { [name: string]: null } = {};
state.paramValue.schema.forEach((field) => {
if (field.display && field.removed) {
newValues[field.id] = null;
field.removed = false;
state.paramValue.schema.splice(state.paramValue.schema.indexOf(field), 1, field);
}
});
state.paramValue.value = {
...state.paramValue.value,
...newValues,
};
emitValueChanged();
}
function removeAllFields(): void {
state.paramValue.schema.forEach((field) => {
if (
!fieldCannotBeDeleted(
field,
showMatchingColumnsSelector.value,
resourceMapperMode.value,
matchingColumns.value,
)
) {
field.removed = true;
state.paramValue.schema.splice(state.paramValue.schema.indexOf(field), 1, field);
}
});
emitValueChanged();
}
function emitValueChanged(): void {
pruneParamValues();
emit('valueChanged', {
name: `${props.path}`,
value: state.paramValue,
node: props.node?.name,
});
updateNodeIssues();
}
function pruneParamValues(): void {
if (!state.paramValue.value) {
return;
}
const valueKeys = Object.keys(state.paramValue.value);
valueKeys.forEach((key) => {
if (state.paramValue.value && state.paramValue.value[key] === null) {
delete state.paramValue.value[key];
}
});
}
defineExpose({
state,
});
</script>
<template>
<div class="mt-4xs" data-test-id="resource-mapper-container">
<mapping-mode-select
v-if="showMappingModeSelect"
:inputSize="inputSize"
:labelSize="labelSize"
:initialValue="state.paramValue.mappingMode || 'defineBelow'"
:typeOptions="props.parameter.typeOptions"
:serviceName="nodeType?.displayName || locale.baseText('generic.service')"
:loading="state.loading"
:loadingError="state.loadingError"
:fieldsToMap="state.paramValue.schema"
@modeChanged="onModeChanged"
@retryFetch="initFetching"
/>
<matching-columns-select
v-if="showMatchingColumnsSelector"
:label-size="labelSize"
:fieldsToMap="state.paramValue.schema"
:typeOptions="props.parameter.typeOptions"
:inputSize="inputSize"
:loading="state.loading"
:initialValue="matchingColumns"
:serviceName="nodeType?.displayName || locale.baseText('generic.service')"
@matchingColumnsChanged="onMatchingColumnsChanged"
/>
<n8n-text v-if="!showMappingModeSelect && state.loading" size="small">
<n8n-icon icon="sync-alt" size="xsmall" :spin="true" />
{{
locale.baseText('resourceMapper.fetchingFields.message', {
interpolate: {
fieldWord: pluralFieldWord,
},
})
}}
</n8n-text>
<mapping-fields
v-if="showMappingFields"
:parameter="props.parameter"
:path="props.path"
:nodeValues="state.parameterValues"
:fieldsToMap="state.paramValue.schema"
:paramValue="state.paramValue"
:labelSize="labelSize"
:showMatchingColumnsSelector="showMatchingColumnsSelector"
:showMappingModeSelect="showMappingModeSelect"
:loading="state.loading"
:refreshInProgress="state.refreshInProgress"
@fieldValueChanged="fieldValueChanged"
@removeField="removeField"
@addField="addField"
@refreshFieldList="initFetching(true)"
/>
<n8n-notice
v-if="state.paramValue.mappingMode === 'autoMapInputData' && hasAvailableMatchingColumns"
>
{{
locale.baseText('resourceMapper.autoMappingNotice', {
interpolate: {
fieldWord: pluralFieldWord,
serviceName: nodeType?.displayName || locale.baseText('generic.service'),
},
})
}}
</n8n-notice>
</div>
</template>

View File

@@ -0,0 +1,232 @@
import { PiniaVuePlugin } from 'pinia';
import { render, within } from '@testing-library/vue';
import { merge } from 'lodash-es';
import {
DEFAULT_SETUP,
MAPPING_COLUMNS_RESPONSE,
NODE_PARAMETER_VALUES,
UPDATED_SCHEMA,
} from './utils/ResourceMapper.utils';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { waitAllPromises } from '@/__tests__/utils';
import * as workflowHelpers from '@/mixins/workflowHelpers';
import ResourceMapper from '@/components/ResourceMapper/ResourceMapper.vue';
import userEvent from '@testing-library/user-event';
let nodeTypeStore: ReturnType<typeof useNodeTypesStore>;
const renderComponent = (renderOptions: Parameters<typeof render>[1] = {}) =>
render(ResourceMapper, merge(DEFAULT_SETUP, renderOptions), (vue) => {
vue.use(PiniaVuePlugin);
});
describe('ResourceMapper.vue', () => {
beforeEach(() => {
nodeTypeStore = useNodeTypesStore();
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(NODE_PARAMETER_VALUES);
vi.spyOn(nodeTypeStore, 'getResourceMapperFields').mockResolvedValue(MAPPING_COLUMNS_RESPONSE);
});
afterEach(() => {
vi.clearAllMocks();
});
it('renders default configuration properly', async () => {
const { getByTestId } = renderComponent();
await waitAllPromises();
expect(getByTestId('resource-mapper-container')).toBeInTheDocument();
expect(getByTestId('mapping-mode-select')).toBeInTheDocument();
expect(getByTestId('matching-column-select')).toBeInTheDocument();
expect(getByTestId('mapping-fields-container')).toBeInTheDocument();
// Should render one parameter input for each fetched column
expect(
getByTestId('mapping-fields-container').querySelectorAll('.parameter-input').length,
).toBe(MAPPING_COLUMNS_RESPONSE.fields.length);
});
it('renders add mode properly', async () => {
const { getByTestId, queryByTestId } = renderComponent({
props: {
parameter: {
typeOptions: {
resourceMapper: {
mode: 'add',
},
},
},
},
});
await waitAllPromises();
expect(getByTestId('resource-mapper-container')).toBeInTheDocument();
// This mode doesn't render matching column selector
expect(queryByTestId('matching-column-select')).not.toBeInTheDocument();
});
it('renders multi-key match selector properly', async () => {
const { container, getByTestId } = renderComponent({
props: {
parameter: {
typeOptions: {
resourceMapper: {
mode: 'upsert',
multiKeyMatch: true,
},
},
},
},
});
await waitAllPromises();
expect(getByTestId('resource-mapper-container')).toBeInTheDocument();
expect(container.querySelector('.el-select__tags')).toBeInTheDocument();
});
it('does not render mapping mode selector if it is disabled', async () => {
const { getByTestId, queryByTestId } = renderComponent({
props: {
parameter: {
typeOptions: {
resourceMapper: {
supportAutoMap: false,
},
},
},
},
});
await waitAllPromises();
expect(getByTestId('resource-mapper-container')).toBeInTheDocument();
expect(queryByTestId('mapping-mode-select')).not.toBeInTheDocument();
});
it('renders field on top of the list when they are selected for matching', async () => {
const { container, getByTestId } = renderComponent({
props: {
parameter: {
typeOptions: {
resourceMapper: {
supportAutoMap: true,
mode: 'upsert',
multiKeyMatch: false,
},
},
},
},
});
await waitAllPromises();
expect(getByTestId('resource-mapper-container')).toBeInTheDocument();
// Id should be the first field in the list
expect(container.querySelector('.parameter-item')).toContainHTML('id (using to match)');
// // Select Last Name as matching column
await userEvent.click(getByTestId('matching-column-select'));
const matchingColumnDropdown = getByTestId('matching-column-select');
await userEvent.click(within(matchingColumnDropdown).getByText('Last Name'));
// // Now, last name should be the first field in the list
expect(container.querySelector('.parameter-item')).toContainHTML('Last Name (using to match)');
});
it('renders selected matching columns properly when multiple key matching is enabled', async () => {
const { getByTestId, getByText, queryByText } = renderComponent({
props: {
parameter: {
typeOptions: {
resourceMapper: {
supportAutoMap: true,
mode: 'upsert',
multiKeyMatch: true,
},
},
},
},
});
await waitAllPromises();
expect(getByTestId('resource-mapper-container')).toBeInTheDocument();
const matchingColumnDropdown = getByTestId('matching-column-select');
await userEvent.click(matchingColumnDropdown);
await userEvent.click(within(matchingColumnDropdown).getByText('Username'));
// Both matching columns should be rendered in the dropdown
expect(getByTestId('matching-column-select')).toContainHTML(
'<span class="el-select__tags-text">id</span>',
);
expect(getByTestId('matching-column-select')).toContainHTML(
'<span class="el-select__tags-text">Username</span>',
);
// All selected columns should have correct labels
expect(getByText('id (using to match)')).toBeInTheDocument();
expect(getByText('Username (using to match)')).toBeInTheDocument();
expect(queryByText('First Name (using to match)')).not.toBeInTheDocument();
});
it('uses field words defined in node definition', async () => {
const { getByText } = renderComponent({
props: {
parameter: {
typeOptions: {
resourceMapper: {
fieldWords: {
singular: 'foo',
plural: 'foos',
},
},
},
},
},
});
await waitAllPromises();
expect(getByText('Set the value for each foo')).toBeInTheDocument();
expect(
getByText('Look for incoming data that matches the foos in the service'),
).toBeInTheDocument();
expect(getByText('Foos to Match On')).toBeInTheDocument();
expect(getByText('The foos that identify the row(s) to modify')).toBeInTheDocument();
});
it('should render correct fields based on saved schema', async () => {
const { getByTestId, queryAllByTestId } = renderComponent({
props: {
node: {
parameters: {
columns: {
schema: UPDATED_SCHEMA,
},
},
},
parameter: {
typeOptions: {
resourceMapper: {
mode: 'add',
},
},
},
},
});
await waitAllPromises();
// There should be 5 fields rendered and only 2 of them should have remove button
expect(
getByTestId('mapping-fields-container').querySelectorAll('.parameter-input').length,
).toBe(5);
expect(queryAllByTestId('remove-field-button').length).toBe(2);
});
it('should render correct options based on saved schema', async () => {
const { getByTestId } = renderComponent({
props: {
node: {
parameters: {
columns: {
schema: UPDATED_SCHEMA,
},
},
},
parameter: {
typeOptions: {
resourceMapper: {
mode: 'add',
},
},
},
},
});
await waitAllPromises();
// Should have one option in the bottom dropdown for one removed field
expect(getByTestId('add-fields-select').querySelectorAll('li').length).toBe(1);
});
});

View File

@@ -0,0 +1,214 @@
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import { STORES } from '@/constants';
import { createTestingPinia } from '@pinia/testing';
import { merge } from 'lodash-es';
import type { ResourceMapperFields } from 'n8n-workflow';
export const NODE_PARAMETER_VALUES = {
authentication: 'oAuth2',
resource: 'sheet',
operation: 'appendOrUpdate',
documentId: {
__rl: true,
value:
'https://docs.google.com/spreadsheets/d/1BAjxEhlUu5tXDCMQcjqjguIZDFuct3FYkdo7flxl3yc/edit#gid=0',
mode: 'url',
__regex: 'https:\\/\\/(?:drive|docs)\\.google\\.com\\/\\w+\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)',
},
sheetName: {
__rl: true,
value: 'gid=0',
mode: 'list',
cachedResultName: 'Users',
cachedResultUrl:
'https://docs.google.com/spreadsheets/d/1BAjxEhlUu5tXDCMQcjqjguIZDFuct3FYkdo7flxl3yc/edit#gid=0',
},
columns: {
mappingMode: 'defineBelow',
value: null,
},
options: {},
};
export const UPDATED_SCHEMA = [
{
id: 'First name',
displayName: 'First name',
match: false,
required: true,
defaultMatch: false,
display: true,
type: 'string',
canBeUsedToMatch: true,
removed: false,
},
{
id: 'Last name',
displayName: 'Last name',
match: false,
required: true,
defaultMatch: false,
display: true,
type: 'string',
canBeUsedToMatch: true,
removed: false,
},
{
id: 'Username',
displayName: 'Username',
match: false,
required: false,
defaultMatch: false,
display: true,
type: 'string',
canBeUsedToMatch: true,
removed: false,
},
{
id: 'Address',
displayName: 'Address',
match: false,
required: false,
defaultMatch: false,
display: true,
type: 'string',
canBeUsedToMatch: true,
removed: true,
},
{
id: 'id',
displayName: 'id',
match: false,
required: true,
defaultMatch: true,
display: true,
type: 'string',
canBeUsedToMatch: true,
removed: false,
},
];
export const DEFAULT_SETUP = {
pinia: createTestingPinia({
initialState: {
[STORES.SETTINGS]: {
settings: merge({}, SETTINGS_STORE_DEFAULT_STATE.settings),
},
},
}),
props: {
path: 'parameters.columns',
dependentParametersValues: 'gid=0',
inputSize: 'small',
labelSize: 'small',
node: {
parameters: NODE_PARAMETER_VALUES,
id: 'f63efb2d-3cc5-4500-89f9-b39aab19baf5',
name: 'Google Sheets',
type: 'n8n-nodes-base.googleSheets',
typeVersion: 4,
position: [1120, 380],
credentials: {},
disabled: false,
},
parameter: {
displayName: 'Columns',
name: 'columns',
type: 'resourceMapper',
default: {
mappingMode: 'defineBelow',
value: {},
},
required: true,
typeOptions: {
loadOptionsDependsOn: ['sheetName.value'],
resourceMapper: {
resourceMapperMethod: 'getMappingColumns',
mode: 'upsert',
addAllFields: true,
noFieldsError: 'No columns found in sheet.',
multiKeyMatch: false,
fieldWords: {
singular: 'column',
plural: 'columns',
},
},
},
displayOptions: {
show: {
resource: ['sheet'],
operation: ['appendOrUpdate'],
'@version': [4],
},
hide: {
sheetName: [''],
},
},
},
},
};
export const MAPPING_COLUMNS_RESPONSE: ResourceMapperFields = {
fields: [
{
id: 'First name',
displayName: 'First name',
match: false,
required: true,
defaultMatch: false,
display: true,
type: 'string',
canBeUsedToMatch: true,
},
{
id: 'Last name',
displayName: 'Last name',
match: false,
required: true,
defaultMatch: false,
display: true,
type: 'string',
canBeUsedToMatch: true,
},
{
id: 'Username',
displayName: 'Username',
match: false,
required: false,
defaultMatch: false,
display: true,
type: 'string',
canBeUsedToMatch: true,
},
{
id: 'Address',
displayName: 'Address',
match: false,
required: false,
defaultMatch: false,
display: true,
type: 'string',
canBeUsedToMatch: true,
},
{
id: 'id',
displayName: 'id',
match: true,
required: true,
defaultMatch: true,
display: true,
type: 'string',
canBeUsedToMatch: true,
},
{
id: 'Last Name',
displayName: 'Last Name',
match: false,
required: false,
defaultMatch: false,
display: true,
type: 'string',
canBeUsedToMatch: true,
},
],
};