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:
committed by
GitHub
parent
5ae1124106
commit
04cfa548af
@@ -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: () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user