refactor: Refactor input components to composition API (no-changelog) (#9744)

This commit is contained in:
Elias Meire
2024-06-18 15:04:08 +02:00
committed by GitHub
parent 8f94dcc0e9
commit e3cbce5028
12 changed files with 1007 additions and 1178 deletions

View File

@@ -21,72 +21,49 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { defineComponent } from 'vue';
import { useToast } from '@/composables/useToast';
import { i18n } from '@/plugins/i18n';
import { useClipboard } from '@/composables/useClipboard'; import { useClipboard } from '@/composables/useClipboard';
import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast';
export default defineComponent({ type Props = {
props: { label?: string;
label: { hint?: string;
type: String, value?: string;
}, copyButtonText: string;
hint: { toastTitle?: string;
type: String, toastMessage?: string;
}, size?: 'medium' | 'large';
value: { collapse?: boolean;
type: String, redactValue?: boolean;
}, };
copyButtonText: {
type: String,
default(): string {
return i18n.baseText('generic.copy');
},
},
toastTitle: {
type: String,
default(): string {
return i18n.baseText('generic.copiedToClipboard');
},
},
toastMessage: {
type: String,
},
collapse: {
type: Boolean,
default: false,
},
size: {
type: String,
default: 'large',
},
redactValue: {
type: Boolean,
default: false,
},
},
setup() {
const clipboard = useClipboard();
return { const props = withDefaults(defineProps<Props>(), {
clipboard, value: '',
...useToast(), placeholder: '',
}; label: '',
}, hint: '',
methods: { size: 'medium',
copy(): void { copyButtonText: useI18n().baseText('generic.copy'),
this.$emit('copy'); toastTitle: useI18n().baseText('generic.copiedToClipboard'),
void this.clipboard.copy(this.value ?? '');
this.showMessage({
title: this.toastTitle,
message: this.toastMessage,
type: 'success',
});
},
},
}); });
const emit = defineEmits<{
(event: 'copy'): void;
}>();
const clipboard = useClipboard();
const { showMessage } = useToast();
function copy() {
emit('copy');
void clipboard.copy(props.value ?? '');
showMessage({
title: props.toastTitle,
message: props.toastMessage,
type: 'success',
});
}
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View File

@@ -144,7 +144,12 @@ import { defineComponent } from 'vue';
import type { PropType } from 'vue'; import type { PropType } from 'vue';
import { mapStores } from 'pinia'; import { mapStores } from 'pinia';
import type { ICredentialType, INodeProperties, INodeTypeDescription } from 'n8n-workflow'; import type {
ICredentialDataDecryptedObject,
ICredentialType,
INodeProperties,
INodeTypeDescription,
} from 'n8n-workflow';
import { getAppNameFromCredType, isCommunityPackageName } from '@/utils/nodeTypesUtils'; import { getAppNameFromCredType, isCommunityPackageName } from '@/utils/nodeTypesUtils';
import Banner from '../Banner.vue'; import Banner from '../Banner.vue';
@@ -161,7 +166,7 @@ import { useRootStore } from '@/stores/n8nRoot.store';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { useCredentialsStore } from '@/stores/credentials.store'; import { useCredentialsStore } from '@/stores/credentials.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import type { ICredentialsResponse } from '@/Interface'; import type { ICredentialsResponse, IUpdateInformation } from '@/Interface';
import AuthTypeSelector from '@/components/CredentialEdit/AuthTypeSelector.vue'; import AuthTypeSelector from '@/components/CredentialEdit/AuthTypeSelector.vue';
import GoogleAuthButton from './GoogleAuthButton.vue'; import GoogleAuthButton from './GoogleAuthButton.vue';
import EnterpriseEdition from '@/components/EnterpriseEdition.ee.vue'; import EnterpriseEdition from '@/components/EnterpriseEdition.ee.vue';
@@ -190,7 +195,10 @@ export default defineComponent({
type: Array as PropType<string[]>, type: Array as PropType<string[]>,
default: () => [], default: () => [],
}, },
credentialData: {}, credentialData: {
type: Object as PropType<ICredentialDataDecryptedObject>,
required: true,
},
credentialId: { credentialId: {
type: String, type: String,
default: '', default: '',
@@ -351,7 +359,7 @@ export default defineComponent({
getCredentialOptions(type: string): ICredentialsResponse[] { getCredentialOptions(type: string): ICredentialsResponse[] {
return this.credentialsStore.allUsableCredentialsByType[type]; return this.credentialsStore.allUsableCredentialsByType[type];
}, },
onDataChange(event: { name: string; value: string | number | boolean | Date | null }): void { onDataChange(event: IUpdateInformation): void {
this.$emit('update', event); this.$emit('update', event);
}, },
onDocumentationUrlClick(): void { onDocumentationUrlClick(): void {

View File

@@ -12,10 +12,10 @@
<ParameterInputExpanded <ParameterInputExpanded
v-else v-else
:parameter="parameter" :parameter="parameter"
:value="credentialData[parameter.name]" :value="credentialDataValues[parameter.name]"
:documentation-url="documentationUrl" :documentation-url="documentationUrl"
:show-validation-warnings="showValidationWarnings" :show-validation-warnings="showValidationWarnings"
:label="label" :label="{ size: 'medium' }"
event-source="credentials" event-source="credentials"
@update="valueChanged" @update="valueChanged"
/> />
@@ -23,41 +23,41 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { defineComponent } from 'vue'; import type {
import type { IParameterLabel } from 'n8n-workflow'; ICredentialDataDecryptedObject,
INodeProperties,
NodeParameterValueType,
} from 'n8n-workflow';
import type { IUpdateInformation } from '@/Interface'; import type { IUpdateInformation } from '@/Interface';
import ParameterInputExpanded from '../ParameterInputExpanded.vue'; import ParameterInputExpanded from '../ParameterInputExpanded.vue';
import { computed } from 'vue';
export default defineComponent({ type Props = {
name: 'CredentialsInput', credentialProperties: INodeProperties[];
components: { credentialData: ICredentialDataDecryptedObject;
ParameterInputExpanded, documentationUrl: string;
}, showValidationWarnings?: boolean;
props: [ };
'credentialProperties',
'credentialData', // ICredentialsDecryptedResponse
'documentationUrl',
'showValidationWarnings',
],
data(): { label: IParameterLabel } {
return {
label: {
size: 'medium',
},
};
},
methods: {
valueChanged(parameterData: IUpdateInformation) {
const name = parameterData.name.split('.').pop();
this.$emit('update', { const props = defineProps<Props>();
name,
value: parameterData.value, const credentialDataValues = computed(
}); () => props.credentialData as Record<string, NodeParameterValueType>,
}, );
},
}); const emit = defineEmits<{
(event: 'update', value: IUpdateInformation): void;
}>();
function valueChanged(parameterData: IUpdateInformation) {
const name = parameterData.name.split('.').pop() ?? parameterData.name;
emit('update', {
name,
value: parameterData.value,
});
}
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View File

@@ -5,23 +5,24 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { defineComponent } from 'vue'; import { computed } from 'vue';
export default defineComponent({ type Props = {
name: 'ExpandableInputBase', modelValue: string;
props: ['modelValue', 'placeholder', 'staticSize'], placeholder?: string;
computed: { staticSize?: boolean;
hiddenValue() { };
let value = (this.modelValue as string).replace(/\s/g, '.'); // force input to expand on space chars
if (!value) {
// @ts-ignore
value = this.placeholder;
}
return `${value}`; // adjust for padding const props = withDefaults(defineProps<Props>(), { staticSize: false, placeholder: '' });
},
}, const hiddenValue = computed(() => {
let value = props.modelValue.replace(/\s/g, '.'); // force input to expand on space chars
if (!value) {
value = props.placeholder;
}
return `${value}`; // adjust for padding
}); });
</script> </script>

View File

@@ -1,7 +1,7 @@
<template> <template>
<ExpandableInputBase :model-value="modelValue" :placeholder="placeholder"> <ExpandableInputBase :model-value="modelValue" :placeholder="placeholder">
<input <input
ref="input" ref="inputRef"
v-on-click-outside="onClickOutside" v-on-click-outside="onClickOutside"
class="el-input__inner" class="el-input__inner"
:value="modelValue" :value="modelValue"
@@ -15,58 +15,66 @@
</ExpandableInputBase> </ExpandableInputBase>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { defineComponent } from 'vue';
import ExpandableInputBase from './ExpandableInputBase.vue';
import type { PropType } from 'vue';
import type { EventBus } from 'n8n-design-system'; import type { EventBus } from 'n8n-design-system';
import { onBeforeUnmount, onMounted, ref } from 'vue';
import ExpandableInputBase from './ExpandableInputBase.vue';
export default defineComponent({ type Props = {
name: 'ExpandableInputEdit', modelValue: string;
components: { ExpandableInputBase }, placeholder: string;
props: { maxlength?: number;
modelValue: { autofocus?: boolean;
type: String, eventBus?: EventBus;
required: true, };
},
placeholder: { type: String, required: true }, const props = defineProps<Props>();
maxlength: { type: Number }, const emit = defineEmits<{
autofocus: { type: Boolean }, (event: 'update:model-value', value: string): void;
eventBus: { (event: 'enter', value: string): void;
type: Object as PropType<EventBus>, (event: 'blur', value: string): void;
}, (event: 'esc'): void;
}, }>();
emits: ['update:modelValue', 'enter', 'blur', 'esc'],
mounted() { const inputRef = ref<HTMLInputElement>();
// autofocus on input element is not reliable
if (this.autofocus && this.$refs.input) { onMounted(() => {
this.focus(); // autofocus on input element is not reliable
} if (props.autofocus && inputRef.value) {
this.eventBus?.on('focus', this.focus); focus();
}, }
beforeUnmount() { props.eventBus?.on('focus', focus);
this.eventBus?.off('focus', this.focus);
},
methods: {
focus() {
if (this.$refs.input) {
(this.$refs.input as HTMLInputElement).focus();
}
},
onInput() {
this.$emit('update:modelValue', (this.$refs.input as HTMLInputElement).value);
},
onEnter() {
this.$emit('enter', (this.$refs.input as HTMLInputElement).value);
},
onClickOutside(e: Event) {
if (e.type === 'click') {
this.$emit('blur', (this.$refs.input as HTMLInputElement).value);
}
},
onEscape() {
this.$emit('esc');
},
},
}); });
onBeforeUnmount(() => {
props.eventBus?.off('focus', focus);
});
function focus() {
if (inputRef.value) {
inputRef.value.focus();
}
}
function onInput() {
if (inputRef.value) {
emit('update:model-value', inputRef.value.value);
}
}
function onEnter() {
if (inputRef.value) {
emit('enter', inputRef.value.value);
}
}
function onClickOutside(e: Event) {
if (e.type === 'click' && inputRef.value) {
emit('blur', inputRef.value.value);
}
}
function onEscape() {
emit('esc');
}
</script> </script>

View File

@@ -9,15 +9,14 @@
</ExpandableInputBase> </ExpandableInputBase>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { defineComponent } from 'vue';
import ExpandableInputBase from './ExpandableInputBase.vue'; import ExpandableInputBase from './ExpandableInputBase.vue';
export default defineComponent({ type Props = {
name: 'ExpandableInputPreview', modelValue: string;
components: { ExpandableInputBase }, };
props: ['modelValue'],
}); defineProps<Props>();
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -105,13 +105,13 @@ const rightParameter = computed<INodeProperties>(() => {
const debouncedEmitUpdate = debounce(() => emit('update', condition.value), { debounceTime: 500 }); const debouncedEmitUpdate = debounce(() => emit('update', condition.value), { debounceTime: 500 });
const onLeftValueChange = (update: IUpdateInformation<NodeParameterValue>): void => { const onLeftValueChange = (update: IUpdateInformation): void => {
condition.value.leftValue = update.value; condition.value.leftValue = update.value as NodeParameterValue;
debouncedEmitUpdate(); debouncedEmitUpdate();
}; };
const onRightValueChange = (update: IUpdateInformation<NodeParameterValue>): void => { const onRightValueChange = (update: IUpdateInformation): void => {
condition.value.rightValue = update.value; condition.value.rightValue = update.value as NodeParameterValue;
debouncedEmitUpdate(); debouncedEmitUpdate();
}; };

View File

@@ -54,124 +54,114 @@
</n8n-input-label> </n8n-input-label>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import type { IUpdateInformation } from '@/Interface'; import type { IUpdateInformation } from '@/Interface';
import ParameterOptions from './ParameterOptions.vue'; import { useI18n } from '@/composables/useI18n';
import { defineComponent } from 'vue'; import { useTelemetry } from '@/composables/useTelemetry';
import type { PropType } from 'vue'; import { useWorkflowsStore } from '@/stores/workflows.store';
import ParameterInputWrapper from './ParameterInputWrapper.vue'; import { isValueExpression as isValueExpressionUtil } from '@/utils/nodeTypesUtils';
import { isValueExpression } from '@/utils/nodeTypesUtils'; import { createEventBus } from 'n8n-design-system/utils';
import type { import type {
INodeParameterResourceLocator, INodeParameterResourceLocator,
INodeProperties, INodeProperties,
IParameterLabel, IParameterLabel,
NodeParameterValueType, NodeParameterValueType,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { mapStores } from 'pinia'; import { computed, ref } from 'vue';
import { useWorkflowsStore } from '@/stores/workflows.store'; import ParameterInputWrapper from './ParameterInputWrapper.vue';
import { createEventBus } from 'n8n-design-system/utils'; import ParameterOptions from './ParameterOptions.vue';
export default defineComponent({ type Props = {
name: 'ParameterInputExpanded', parameter: INodeProperties;
components: { value: NodeParameterValueType;
ParameterOptions, showValidationWarnings?: boolean;
ParameterInputWrapper, documentationUrl?: string;
}, eventSource?: string;
props: { label?: IParameterLabel;
parameter: { };
type: Object as PropType<INodeProperties>,
required: true, const props = withDefaults(defineProps<Props>(), {
}, label: () => ({ size: 'small' }),
value: { });
type: Object as PropType<NodeParameterValueType>, const emit = defineEmits<{
}, (event: 'update', value: IUpdateInformation): void;
showValidationWarnings: { }>();
type: Boolean,
}, const focused = ref(false);
documentationUrl: { const blurredEver = ref(false);
type: String, const menuExpanded = ref(false);
}, const eventBus = ref(createEventBus());
eventSource: {
type: String, const workflowsStore = useWorkflowsStore();
},
label: { const i18n = useI18n();
type: Object as PropType<IParameterLabel>, const telemetry = useTelemetry();
default: () => ({
size: 'small', const showRequiredErrors = computed(() => {
}), if (!props.parameter.required) {
}, return false;
}, }
data() {
return { if (blurredEver.value || props.showValidationWarnings) {
focused: false, if (props.parameter.type === 'string') {
blurredEver: false, return !props.value;
menuExpanded: false, }
eventBus: createEventBus(),
}; if (props.parameter.type === 'number') {
}, if (typeof props.value === 'string' && props.value.startsWith('=')) {
computed: {
...mapStores(useWorkflowsStore),
showRequiredErrors(): boolean {
if (!this.parameter.required) {
return false; return false;
} }
if (this.blurredEver || this.showValidationWarnings) { return typeof props.value !== 'number';
if (this.parameter.type === 'string') { }
return !this.value; }
}
if (this.parameter.type === 'number') { return false;
if (typeof this.value === 'string' && this.value.startsWith('=')) {
return false;
}
return typeof this.value !== 'number';
}
}
return false;
},
hint(): string | null {
if (this.isValueExpression) {
return null;
}
return this.$locale.credText().hint(this.parameter);
},
isValueExpression(): boolean {
return isValueExpression(
this.parameter,
this.value as string | INodeParameterResourceLocator,
);
},
},
methods: {
onFocus() {
this.focused = true;
},
onBlur() {
this.blurredEver = true;
this.focused = false;
},
onMenuExpanded(expanded: boolean) {
this.menuExpanded = expanded;
},
optionSelected(command: string) {
this.eventBus.emit('optionSelected', command);
},
valueChanged(parameterData: IUpdateInformation) {
this.$emit('update', parameterData);
},
onDocumentationUrlClick(): void {
this.$telemetry.track('User clicked credential modal docs link', {
docs_link: this.documentationUrl,
source: 'field',
workflow_id: this.workflowsStore.workflowId,
});
},
},
}); });
const hint = computed(() => {
if (isValueExpression.value) {
return null;
}
return i18n.credText().hint(props.parameter);
});
const isValueExpression = computed(() => {
return isValueExpressionUtil(
props.parameter,
props.value as string | INodeParameterResourceLocator,
);
});
function onFocus() {
focused.value = true;
}
function onBlur() {
blurredEver.value = true;
focused.value = false;
}
function onMenuExpanded(expanded: boolean) {
menuExpanded.value = expanded;
}
function optionSelected(command: string) {
eventBus.value.emit('optionSelected', command);
}
function valueChanged(parameterData: IUpdateInformation) {
emit('update', parameterData);
}
function onDocumentationUrlClick(): void {
telemetry.track('User clicked credential modal docs link', {
docs_link: props.documentationUrl,
source: 'field',
workflow_id: workflowsStore.workflowId,
});
}
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View File

@@ -25,7 +25,7 @@
type="mapping" type="mapping"
:disabled="isDropDisabled" :disabled="isDropDisabled"
:sticky="true" :sticky="true"
:sticky-offset="isValueExpression ? [26, 3] : [3, 3]" :sticky-offset="isExpression ? [26, 3] : [3, 3]"
@drop="onDrop" @drop="onDrop"
> >
<template #default="{ droppable, activeDrop }"> <template #default="{ droppable, activeDrop }">
@@ -77,276 +77,208 @@
</n8n-input-label> </n8n-input-label>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { defineComponent } from 'vue'; import { computed, ref } from 'vue';
import type { PropType } from 'vue';
import { mapStores } from 'pinia';
import type { INodeUi, IRunDataDisplayMode, IUpdateInformation } from '@/Interface'; import type { IUpdateInformation } from '@/Interface';
import ParameterOptions from '@/components/ParameterOptions.vue';
import DraggableTarget from '@/components/DraggableTarget.vue'; import DraggableTarget from '@/components/DraggableTarget.vue';
import ParameterInputWrapper from '@/components/ParameterInputWrapper.vue';
import ParameterOptions from '@/components/ParameterOptions.vue';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { hasExpressionMapping, hasOnlyListMode, isValueExpression } from '@/utils/nodeTypesUtils';
import { isResourceLocatorValue } from '@/utils/typeGuards';
import ParameterInputWrapper from '@/components/ParameterInputWrapper.vue';
import type {
INodeProperties,
INodePropertyMode,
IParameterLabel,
NodeParameterValueType,
} from 'n8n-workflow';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { useSegment } from '@/stores/segment.store'; import { useSegment } from '@/stores/segment.store';
import { getMappedResult } from '@/utils/mappingUtils'; import { getMappedResult } from '@/utils/mappingUtils';
import { hasExpressionMapping, hasOnlyListMode, isValueExpression } from '@/utils/nodeTypesUtils';
import { isResourceLocatorValue } from '@/utils/typeGuards';
import { createEventBus } from 'n8n-design-system/utils'; import { createEventBus } from 'n8n-design-system/utils';
import type { INodeProperties, IParameterLabel, NodeParameterValueType } from 'n8n-workflow';
import InlineExpressionTip from './InlineExpressionEditor/InlineExpressionTip.vue'; import InlineExpressionTip from './InlineExpressionEditor/InlineExpressionTip.vue';
export default defineComponent({ type Props = {
name: 'ParameterInputFull', parameter: INodeProperties;
components: { path: string;
ParameterOptions, value: NodeParameterValueType;
DraggableTarget, label?: IParameterLabel;
ParameterInputWrapper, displayOptions?: boolean;
InlineExpressionTip, optionsPosition?: 'bottom' | 'top';
}, hideHint?: boolean;
props: { isReadOnly?: boolean;
displayOptions: { rows?: number;
type: Boolean, isAssignment?: boolean;
default: false, hideLabel?: boolean;
}, hideIssues?: boolean;
optionsPosition: { entryIndex?: number;
type: String as PropType<'bottom' | 'top'>, };
default: 'top',
},
hideHint: {
type: Boolean,
default: false,
},
isReadOnly: {
type: Boolean,
default: false,
},
rows: {
type: Number,
default: 5,
},
isAssignment: {
type: Boolean,
default: false,
},
hideLabel: {
type: Boolean,
default: false,
},
hideIssues: {
type: Boolean,
default: false,
},
parameter: {
type: Object as PropType<INodeProperties>,
required: true,
},
path: {
type: String,
required: true,
},
value: {
type: [Number, String, Boolean, Array, Object] as PropType<NodeParameterValueType>,
},
label: {
type: Object as PropType<IParameterLabel>,
default: () => ({
size: 'small',
}),
},
entryIndex: {
type: Number,
default: undefined,
},
},
setup() {
const eventBus = createEventBus();
const i18n = useI18n();
return { const props = withDefaults(defineProps<Props>(), {
i18n, optionsPosition: 'top',
eventBus, hideHint: false,
...useToast(), isReadOnly: false,
}; rows: 5,
}, hideLabel: false,
data() { hideIssues: false,
return { label: () => ({ size: 'small' }),
focused: false,
menuExpanded: false,
forceShowExpression: false,
};
},
computed: {
...mapStores(useNDVStore),
node(): INodeUi | null {
return this.ndvStore.activeNode;
},
hint(): string {
return this.i18n.nodeText().hint(this.parameter, this.path);
},
isInputTypeString(): boolean {
return this.parameter.type === 'string';
},
isInputTypeNumber(): boolean {
return this.parameter.type === 'number';
},
isResourceLocator(): boolean {
return this.parameter.type === 'resourceLocator';
},
isDropDisabled(): boolean {
return this.parameter.noDataExpression || this.isReadOnly || this.isResourceLocator;
},
isValueExpression(): boolean {
return isValueExpression(this.parameter, this.value);
},
showExpressionSelector(): boolean {
return this.isResourceLocator ? !hasOnlyListMode(this.parameter) : true;
},
isInputDataEmpty(): boolean {
return this.ndvStore.isNDVDataEmpty('input');
},
displayMode(): IRunDataDisplayMode {
return this.ndvStore.inputPanelDisplayMode;
},
showDragnDropTip(): boolean {
return (
this.focused &&
(this.isInputTypeString || this.isInputTypeNumber) &&
!this.isValueExpression &&
!this.isDropDisabled &&
(!this.ndvStore.hasInputData || !this.isInputDataEmpty) &&
!this.ndvStore.isMappingOnboarded &&
this.ndvStore.isInputParentOfActiveNode
);
},
},
methods: {
onFocus() {
this.focused = true;
if (!this.parameter.noDataExpression) {
this.ndvStore.setMappableNDVInputFocus(this.parameter.displayName);
}
this.ndvStore.setFocusedInputPath(this.path ?? '');
},
onBlur() {
this.focused = false;
if (
!this.parameter.noDataExpression &&
this.ndvStore.focusedMappableInput === this.parameter.displayName
) {
this.ndvStore.setMappableNDVInputFocus('');
}
this.ndvStore.setFocusedInputPath('');
this.$emit('blur');
},
onMenuExpanded(expanded: boolean) {
this.menuExpanded = expanded;
},
optionSelected(command: string) {
this.eventBus.emit('optionSelected', command);
},
valueChanged(parameterData: IUpdateInformation) {
this.$emit('update', parameterData);
},
onTextInput(parameterData: IUpdateInformation) {
if (isValueExpression(this.parameter, parameterData.value)) {
this.eventBus.emit('optionSelected', 'addExpression');
}
},
onDrop(newParamValue: string) {
const value = this.value;
const updatedValue = getMappedResult(this.parameter, newParamValue, value);
const prevValue =
this.isResourceLocator && isResourceLocatorValue(value) ? value.value : value;
if (updatedValue.startsWith('=')) {
this.forceShowExpression = true;
}
setTimeout(() => {
if (this.node) {
let parameterData;
if (this.isResourceLocator) {
if (!isResourceLocatorValue(this.value)) {
parameterData = {
node: this.node.name,
name: this.path,
value: { __rl: true, value: updatedValue, mode: '' },
};
} else if (
this.value.mode === 'list' &&
this.parameter.modes &&
this.parameter.modes.length > 1
) {
let mode =
this.parameter.modes.find((mode: INodePropertyMode) => mode.name === 'id') || null;
if (!mode) {
mode = this.parameter.modes.filter(
(mode: INodePropertyMode) => mode.name !== 'list',
)[0];
}
parameterData = {
node: this.node.name,
name: this.path,
value: { __rl: true, value: updatedValue, mode: mode ? mode.name : '' },
};
} else {
parameterData = {
node: this.node.name,
name: this.path,
value: { __rl: true, value: updatedValue, mode: this.value.mode },
};
}
} else {
parameterData = {
node: this.node.name,
name: this.path,
value: updatedValue,
};
}
this.valueChanged(parameterData);
this.eventBus.emit('drop', updatedValue);
if (!this.ndvStore.isMappingOnboarded) {
this.showMessage({
title: this.i18n.baseText('dataMapping.success.title'),
message: this.i18n.baseText('dataMapping.success.moreInfo'),
type: 'success',
dangerouslyUseHTMLString: true,
});
this.ndvStore.setMappingOnboarded();
}
this.ndvStore.setMappingTelemetry({
dest_node_type: this.node.type,
dest_parameter: this.path,
dest_parameter_mode:
typeof prevValue === 'string' && prevValue.startsWith('=') ? 'expression' : 'fixed',
dest_parameter_empty: prevValue === '' || prevValue === undefined,
dest_parameter_had_mapping:
typeof prevValue === 'string' &&
prevValue.startsWith('=') &&
hasExpressionMapping(prevValue),
success: true,
});
const segment = useSegment();
segment.track(segment.EVENTS.MAPPED_DATA);
}
this.forceShowExpression = false;
}, 200);
},
},
}); });
const emit = defineEmits<{
(event: 'blur'): void;
(event: 'update', value: IUpdateInformation): void;
}>();
const i18n = useI18n();
const toast = useToast();
const eventBus = ref(createEventBus());
const focused = ref(false);
const menuExpanded = ref(false);
const forceShowExpression = ref(false);
const ndvStore = useNDVStore();
const node = computed(() => ndvStore.activeNode);
const hint = computed(() => i18n.nodeText().hint(props.parameter, props.path));
const isInputTypeString = computed(() => props.parameter.type === 'string');
const isInputTypeNumber = computed(() => props.parameter.type === 'number');
const isResourceLocator = computed(() => props.parameter.type === 'resourceLocator');
const isDropDisabled = computed(
() => props.parameter.noDataExpression || props.isReadOnly || isResourceLocator.value,
);
const isExpression = computed(() => isValueExpression(props.parameter, props.value));
const showExpressionSelector = computed(() =>
isResourceLocator.value ? !hasOnlyListMode(props.parameter) : true,
);
const isInputDataEmpty = computed(() => ndvStore.isNDVDataEmpty('input'));
const showDragnDropTip = computed(
() =>
focused.value &&
(isInputTypeString.value || isInputTypeNumber.value) &&
!isExpression.value &&
!isDropDisabled.value &&
(!ndvStore.hasInputData || !isInputDataEmpty.value) &&
!ndvStore.isMappingOnboarded &&
ndvStore.isInputParentOfActiveNode,
);
function onFocus() {
focused.value = true;
if (!props.parameter.noDataExpression) {
ndvStore.setMappableNDVInputFocus(props.parameter.displayName);
}
ndvStore.setFocusedInputPath(props.path ?? '');
}
function onBlur() {
focused.value = false;
if (
!props.parameter.noDataExpression &&
ndvStore.focusedMappableInput === props.parameter.displayName
) {
ndvStore.setMappableNDVInputFocus('');
}
ndvStore.setFocusedInputPath('');
emit('blur');
}
function onMenuExpanded(expanded: boolean) {
menuExpanded.value = expanded;
}
function optionSelected(command: string) {
eventBus.value.emit('optionSelected', command);
}
function valueChanged(parameterData: IUpdateInformation) {
emit('update', parameterData);
}
function onTextInput(parameterData: IUpdateInformation) {
if (isValueExpression(props.parameter, parameterData.value)) {
eventBus.value.emit('optionSelected', 'addExpression');
}
}
function onDrop(newParamValue: string) {
const value = props.value;
const updatedValue = getMappedResult(props.parameter, newParamValue, value);
const prevValue = isResourceLocator.value && isResourceLocatorValue(value) ? value.value : value;
if (updatedValue.startsWith('=')) {
forceShowExpression.value = true;
}
setTimeout(() => {
if (node.value) {
let parameterData;
if (isResourceLocator.value) {
if (!isResourceLocatorValue(props.value)) {
parameterData = {
node: node.value.name,
name: props.path,
value: { __rl: true, value: updatedValue, mode: '' },
};
} else if (
props.value.mode === 'list' &&
props.parameter.modes &&
props.parameter.modes.length > 1
) {
let mode = props.parameter.modes.find((m) => m.name === 'id') ?? null;
if (!mode) {
mode = props.parameter.modes.filter((m) => m.name !== 'list')[0];
}
parameterData = {
node: node.value.name,
name: props.path,
value: { __rl: true, value: updatedValue, mode: mode ? mode.name : '' },
};
} else {
parameterData = {
node: node.value.name,
name: props.path,
value: { __rl: true, value: updatedValue, mode: props.value?.mode },
};
}
} else {
parameterData = {
node: node.value.name,
name: props.path,
value: updatedValue,
};
}
valueChanged(parameterData);
eventBus.value.emit('drop', updatedValue);
if (!ndvStore.isMappingOnboarded) {
toast.showMessage({
title: i18n.baseText('dataMapping.success.title'),
message: i18n.baseText('dataMapping.success.moreInfo'),
type: 'success',
dangerouslyUseHTMLString: true,
});
ndvStore.setMappingOnboarded();
}
ndvStore.setMappingTelemetry({
dest_node_type: node.value.type,
dest_parameter: props.path,
dest_parameter_mode:
typeof prevValue === 'string' && prevValue.startsWith('=') ? 'expression' : 'fixed',
dest_parameter_empty: prevValue === '' || prevValue === undefined,
dest_parameter_had_mapping:
typeof prevValue === 'string' &&
prevValue.startsWith('=') &&
hasExpressionMapping(prevValue),
success: true,
});
const segment = useSegment();
segment.track(segment.EVENTS.MAPPED_DATA);
}
forceShowExpression.value = false;
}, 200);
}
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View File

@@ -1,64 +1,59 @@
<template> <template>
<n8n-text v-if="hint" size="small" color="text-base" tag="div"> <n8n-text v-if="hint" size="small" color="text-base" tag="div">
<div v-if="!renderHTML" :class="classes"><span v-html="simplyText"></span></div> <div
v-if="!renderHTML"
:class="{
[$style.singleline]: singleLine,
[$style.highlight]: highlight,
}"
>
<span v-html="simplyText"></span>
</div>
<div <div
v-else v-else
ref="hint" ref="hintTextRef"
:class="{ [$style.singleline]: singleLine, [$style.highlight]: highlight }" :class="{ [$style.singleline]: singleLine, [$style.highlight]: highlight }"
v-html="sanitizeHtml(hint)" v-html="sanitizeHtml(hint)"
></div> ></div>
</n8n-text> </n8n-text>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { defineComponent } from 'vue';
import { sanitizeHtml } from '@/utils/htmlUtils'; import { sanitizeHtml } from '@/utils/htmlUtils';
import { computed, onMounted, ref } from 'vue';
export default defineComponent({ type Props = {
name: 'InputHint', hint: string;
props: { highlight?: boolean;
hint: { singleLine?: boolean;
type: String, renderHTML?: boolean;
}, };
highlight: {
type: Boolean,
},
singleLine: {
type: Boolean,
},
renderHTML: {
type: Boolean,
default: false,
},
},
computed: {
classes() {
return {
[this.$style.singleline]: this.singleLine,
[this.$style.highlight]: this.highlight,
};
},
simplyText(): string {
if (this.hint) {
return String(this.hint)
.replace(/&/g, '&amp;') // allows us to keep spaces at the beginning of an expression
.replace(/</g, '&lt;') // prevent XSS exploits since we are rendering HTML
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/ /g, '&nbsp;');
}
return ''; const hintTextRef = ref<HTMLDivElement>();
},
}, const props = withDefaults(defineProps<Props>(), {
mounted() { highlight: false,
if (this.$refs.hint) { singleLine: false,
(this.$refs.hint as Element).querySelectorAll('a').forEach((a) => (a.target = '_blank')); renderHTML: false,
} });
},
methods: { onMounted(() => {
sanitizeHtml, if (hintTextRef.value) {
}, hintTextRef.value.querySelectorAll('a').forEach((a) => (a.target = '_blank'));
}
});
const simplyText = computed(() => {
if (props.hint) {
return String(props.hint)
.replace(/&/g, '&amp;') // allows us to keep spaces at the beginning of an expression
.replace(/</g, '&lt;') // prevent XSS exploits since we are rendering HTML
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/ /g, '&nbsp;');
}
return '';
}); });
</script> </script>

View File

@@ -161,27 +161,26 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import type { import type {
INodeParameters, INodeParameters,
INodeProperties, INodeProperties,
INodeTypeDescription,
NodeParameterValue, NodeParameterValue,
NodeParameterValueType, NodeParameterValueType,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { deepCopy } from 'n8n-workflow'; import { deepCopy } from 'n8n-workflow';
import { mapStores } from 'pinia'; import { computed, defineAsyncComponent, onErrorCaptured, ref, watch } from 'vue';
import type { PropType } from 'vue';
import { defineAsyncComponent, defineComponent, onErrorCaptured, ref } from 'vue';
import type { INodeUi, IUpdateInformation } from '@/Interface'; import type { IUpdateInformation } from '@/Interface';
import AssignmentCollection from '@/components/AssignmentCollection/AssignmentCollection.vue';
import FilterConditions from '@/components/FilterConditions/FilterConditions.vue';
import ImportCurlParameter from '@/components/ImportCurlParameter.vue'; import ImportCurlParameter from '@/components/ImportCurlParameter.vue';
import MultipleParameter from '@/components/MultipleParameter.vue'; import MultipleParameter from '@/components/MultipleParameter.vue';
import ParameterInputFull from '@/components/ParameterInputFull.vue'; import ParameterInputFull from '@/components/ParameterInputFull.vue';
import ResourceMapper from '@/components/ResourceMapper/ResourceMapper.vue'; import ResourceMapper from '@/components/ResourceMapper/ResourceMapper.vue';
import FilterConditions from '@/components/FilterConditions/FilterConditions.vue'; import { useNodeHelpers } from '@/composables/useNodeHelpers';
import AssignmentCollection from '@/components/AssignmentCollection/AssignmentCollection.vue'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { KEEP_AUTH_IN_NDV_FOR_NODES } from '@/constants'; import { KEEP_AUTH_IN_NDV_FOR_NODES } from '@/constants';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
@@ -191,9 +190,7 @@ import {
isAuthRelatedParameter, isAuthRelatedParameter,
} from '@/utils/nodeTypesUtils'; } from '@/utils/nodeTypesUtils';
import { get, set } from 'lodash-es'; import { get, set } from 'lodash-es';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
const FixedCollectionParameter = defineAsyncComponent( const FixedCollectionParameter = defineAsyncComponent(
async () => await import('./FixedCollectionParameter.vue'), async () => await import('./FixedCollectionParameter.vue'),
@@ -202,382 +199,344 @@ const CollectionParameter = defineAsyncComponent(
async () => await import('./CollectionParameter.vue'), async () => await import('./CollectionParameter.vue'),
); );
export default defineComponent({ type Props = {
name: 'ParameterInputList', nodeValues: INodeParameters;
components: { parameters: INodeProperties[];
MultipleParameter, path?: string;
ParameterInputFull, hideDelete?: boolean;
FixedCollectionParameter, indent?: boolean;
CollectionParameter, isReadOnly?: boolean;
ImportCurlParameter, hiddenIssuesInputs?: string[];
ResourceMapper, entryIndex?: number;
FilterConditions, };
AssignmentCollection,
},
props: {
nodeValues: {
type: Object as PropType<INodeParameters>,
required: true,
},
parameters: {
type: Array as PropType<INodeProperties[]>,
required: true,
},
path: {
type: String,
default: '',
},
hideDelete: {
type: Boolean,
default: false,
},
indent: {
type: Boolean,
default: false,
},
isReadOnly: {
type: Boolean,
default: false,
},
hiddenIssuesInputs: {
type: Array as PropType<string[]>,
default: () => [],
},
entryIndex: {
type: Number,
default: undefined,
},
},
setup() {
const nodeHelpers = useNodeHelpers();
const asyncLoadingError = ref(false);
const router = useRouter();
const workflowHelpers = useWorkflowHelpers({ router });
// This will catch errors in async components const props = withDefaults(defineProps<Props>(), { path: '', hiddenIssuesInputs: () => [] });
onErrorCaptured((e, component) => { const emit = defineEmits<{
if ( (event: 'activate'): void;
!['FixedCollectionParameter', 'CollectionParameter'].includes( (event: 'valueChanged', value: IUpdateInformation): void;
component?.$options.name as string, (event: 'parameterBlur', value: string): void;
) }>();
) {
return;
}
asyncLoadingError.value = true;
console.error(e);
window?.Sentry?.captureException(e, {
tags: {
asyncLoadingError: true,
},
});
// Don't propagate the error further
return false;
});
return { const nodeTypesStore = useNodeTypesStore();
nodeHelpers, const ndvStore = useNDVStore();
asyncLoadingError,
workflowHelpers,
};
},
computed: {
...mapStores(useNodeTypesStore, useNDVStore),
nodeTypeVersion(): number | null {
if (this.node) {
return this.node.typeVersion;
}
return null;
},
nodeTypeName(): string {
if (this.node) {
return this.node.type;
}
return '';
},
nodeType(): INodeTypeDescription | null {
if (this.node) {
return this.nodeTypesStore.getNodeType(this.node.type, this.node.typeVersion);
}
return null;
},
filteredParameters(): INodeProperties[] {
return this.parameters.filter((parameter: INodeProperties) =>
this.displayNodeParameter(parameter),
);
},
filteredParameterNames(): string[] {
return this.filteredParameters.map((parameter) => parameter.name);
},
node(): INodeUi | null {
return this.ndvStore.activeNode;
},
nodeAuthFields(): INodeProperties[] {
return getNodeAuthFields(this.nodeType);
},
credentialsParameterIndex(): number {
return this.filteredParameters.findIndex((parameter) => parameter.type === 'credentials');
},
indexToShowSlotAt(): number {
const credentialsParameterIndex = this.credentialsParameterIndex;
if (credentialsParameterIndex !== -1) { const nodeHelpers = useNodeHelpers();
return credentialsParameterIndex; const asyncLoadingError = ref(false);
} const router = useRouter();
const workflowHelpers = useWorkflowHelpers({ router });
let index = 0; onErrorCaptured((e, component) => {
// For nodes that use old credentials UI, keep credentials below authentication field in NDV if (
// otherwise credentials will use auth filed position since the auth field is moved to credentials modal !['FixedCollectionParameter', 'CollectionParameter'].includes(
const fieldOffset = KEEP_AUTH_IN_NDV_FOR_NODES.includes(this.nodeType?.name || '') ? 1 : 0; component?.$options.name as string,
const credentialsDependencies = this.getCredentialsDependencies(); )
) {
return;
}
asyncLoadingError.value = true;
console.error(e);
window?.Sentry?.captureException(e, {
tags: {
asyncLoadingError: true,
},
});
// Don't propagate the error further
return false;
});
this.filteredParameters.forEach((prop, propIndex) => { const nodeType = computed(() => {
if (credentialsDependencies.has(prop.name)) { if (node.value) {
index = propIndex + fieldOffset; return nodeTypesStore.getNodeType(node.value.type, node.value.typeVersion);
} }
}); return null;
});
return Math.min(index, this.filteredParameters.length - 1); const filteredParameters = computed(() => {
}, return props.parameters.filter((parameter: INodeProperties) => displayNodeParameter(parameter));
mainNodeAuthField(): INodeProperties | null { });
return getMainAuthField(this.nodeType || null);
},
},
watch: {
filteredParameterNames(newValue, oldValue) {
if (newValue === undefined) {
return;
}
// After a parameter does not get displayed anymore make sure that its value gets removed
// Is only needed for the edge-case when a parameter gets displayed depending on another field
// which contains an expression.
for (const parameter of oldValue) {
if (!newValue.includes(parameter)) {
const parameterData = {
name: `${this.path}.${parameter}`,
node: this.ndvStore.activeNode?.name || '',
value: undefined,
};
this.$emit('valueChanged', parameterData);
}
}
},
},
methods: {
onParameterBlur(parameterName: string) {
this.$emit('parameterBlur', parameterName);
},
getCredentialsDependencies() {
const dependencies = new Set();
const nodeType = this.nodeTypesStore.getNodeType(
this.node?.type || '',
this.node?.typeVersion,
);
// Get names of all fields that credentials rendering depends on (using displayOptions > show) const filteredParameterNames = computed(() => {
if (nodeType?.credentials) { return filteredParameters.value.map((parameter) => parameter.name);
for (const cred of nodeType.credentials) { });
if (cred.displayOptions?.show) {
Object.keys(cred.displayOptions.show).forEach((fieldName) =>
dependencies.add(fieldName),
);
}
}
}
return dependencies;
},
multipleValues(parameter: INodeProperties): boolean {
return this.getArgument('multipleValues', parameter) === true;
},
getArgument(
argumentName: string,
parameter: INodeProperties,
): string | string[] | number | boolean | undefined {
if (parameter.typeOptions === undefined) {
return undefined;
}
if (parameter.typeOptions[argumentName] === undefined) { const node = computed(() => ndvStore.activeNode);
return undefined;
}
return parameter.typeOptions[argumentName]; const nodeAuthFields = computed(() => {
}, return getNodeAuthFields(nodeType.value);
getPath(parameterName: string): string { });
return (this.path ? `${this.path}.` : '') + parameterName;
}, const credentialsParameterIndex = computed(() => {
deleteOption(optionName: string): void { return filteredParameters.value.findIndex((parameter) => parameter.type === 'credentials');
});
const indexToShowSlotAt = computed(() => {
if (credentialsParameterIndex.value !== -1) {
return credentialsParameterIndex.value;
}
let index = 0;
// For nodes that use old credentials UI, keep credentials below authentication field in NDV
// otherwise credentials will use auth filed position since the auth field is moved to credentials modal
const fieldOffset = KEEP_AUTH_IN_NDV_FOR_NODES.includes(nodeType.value?.name || '') ? 1 : 0;
const credentialsDependencies = getCredentialsDependencies();
filteredParameters.value.forEach((prop, propIndex) => {
if (credentialsDependencies.has(prop.name)) {
index = propIndex + fieldOffset;
}
});
return Math.min(index, filteredParameters.value.length - 1);
});
const mainNodeAuthField = computed(() => {
return getMainAuthField(nodeType.value || null);
});
watch(filteredParameterNames, (newValue, oldValue) => {
if (newValue === undefined) {
return;
}
// After a parameter does not get displayed anymore make sure that its value gets removed
// Is only needed for the edge-case when a parameter gets displayed depending on another field
// which contains an expression.
for (const parameter of oldValue) {
if (!newValue.includes(parameter)) {
const parameterData = { const parameterData = {
name: this.getPath(optionName), name: `${props.path}.${parameter}`,
node: ndvStore.activeNode?.name || '',
value: undefined, value: undefined,
}; };
emit('valueChanged', parameterData);
// TODO: If there is only one option it should delete the whole one }
}
this.$emit('valueChanged', parameterData);
},
mustHideDuringCustomApiCall(parameter: INodeProperties, nodeValues: INodeParameters): boolean {
if (parameter?.displayOptions?.hide) return true;
const MUST_REMAIN_VISIBLE = [
'authentication',
'resource',
'operation',
...Object.keys(nodeValues),
];
return !MUST_REMAIN_VISIBLE.includes(parameter.name);
},
displayNodeParameter(parameter: INodeProperties): boolean {
if (parameter.type === 'hidden') {
return false;
}
if (
this.nodeHelpers.isCustomApiCallSelected(this.nodeValues) &&
this.mustHideDuringCustomApiCall(parameter, this.nodeValues)
) {
return false;
}
// Hide authentication related fields since it will now be part of credentials modal
if (
!KEEP_AUTH_IN_NDV_FOR_NODES.includes(this.node?.type || '') &&
this.mainNodeAuthField &&
(parameter.name === this.mainNodeAuthField?.name ||
this.shouldHideAuthRelatedParameter(parameter))
) {
return false;
}
if (parameter.displayOptions === undefined) {
// If it is not defined no need to do a proper check
return true;
}
const nodeValues: INodeParameters = {};
let rawValues = this.nodeValues;
if (this.path) {
rawValues = get(this.nodeValues, this.path) as INodeParameters;
}
if (!rawValues) {
return false;
}
// Resolve expressions
const resolveKeys = Object.keys(rawValues);
let key: string;
let i = 0;
let parameterGotResolved = false;
do {
key = resolveKeys.shift() as string;
const value = rawValues[key];
if (typeof value === 'string' && value?.charAt(0) === '=') {
// Contains an expression that
if (
value.includes('$parameter') &&
resolveKeys.some((parameterName) => value.includes(parameterName))
) {
// Contains probably an expression of a missing parameter so skip
resolveKeys.push(key);
continue;
} else {
// Contains probably no expression with a missing parameter so resolve
try {
nodeValues[key] = this.workflowHelpers.resolveExpression(
value,
nodeValues,
) as NodeParameterValue;
} catch (e) {
// If expression is invalid ignore
nodeValues[key] = '';
}
parameterGotResolved = true;
}
} else {
// Does not contain an expression, add directly
nodeValues[key] = rawValues[key];
}
// TODO: Think about how to calculate this best
if (i++ > 50) {
// Make sure we do not get caught
break;
}
} while (resolveKeys.length !== 0);
if (parameterGotResolved) {
if (this.path) {
rawValues = deepCopy(this.nodeValues);
set(rawValues, this.path, nodeValues);
return this.nodeHelpers.displayParameter(rawValues, parameter, this.path, this.node);
} else {
return this.nodeHelpers.displayParameter(nodeValues, parameter, '', this.node);
}
}
return this.nodeHelpers.displayParameter(this.nodeValues, parameter, this.path, this.node);
},
valueChanged(parameterData: IUpdateInformation): void {
this.$emit('valueChanged', parameterData);
},
onNoticeAction(action: string) {
if (action === 'activate') {
this.$emit('activate');
}
},
/**
* Handles default node button parameter type actions
* @param parameter
*/
onButtonAction(parameter: INodeProperties) {
const action: string | undefined = parameter.typeOptions?.action;
switch (action) {
default:
return;
}
},
isNodeAuthField(name: string): boolean {
return this.nodeAuthFields.find((field) => field.name === name) !== undefined;
},
shouldHideAuthRelatedParameter(parameter: INodeProperties): boolean {
// TODO: For now, hide all fields that are used in authentication fields displayOptions
// Ideally, we should check if any non-auth field depends on it before hiding it but
// 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.workflowHelpers.resolveParameter(currentNodeParameters);
const returnValues: string[] = [];
for (const parameterPath of loadOptionsDependsOn) {
returnValues.push(get(resolvedNodeParameters, parameterPath) as string);
}
return returnValues.join('|');
} catch (error) {
return null;
}
},
getParameterValue<T extends NodeParameterValueType = NodeParameterValueType>(name: string): T {
return this.nodeHelpers.getParameterValue(this.nodeValues, name, this.path) as T;
},
},
}); });
function onParameterBlur(parameterName: string) {
emit('parameterBlur', parameterName);
}
function getCredentialsDependencies() {
const dependencies = new Set();
// Get names of all fields that credentials rendering depends on (using displayOptions > show)
if (nodeType.value?.credentials) {
for (const cred of nodeType.value.credentials) {
if (cred.displayOptions?.show) {
Object.keys(cred.displayOptions.show).forEach((fieldName) => dependencies.add(fieldName));
}
}
}
return dependencies;
}
function multipleValues(parameter: INodeProperties): boolean {
return getArgument('multipleValues', parameter) === true;
}
function getArgument(
argumentName: string,
parameter: INodeProperties,
): string | string[] | number | boolean | undefined {
if (parameter.typeOptions === undefined) {
return undefined;
}
if (parameter.typeOptions[argumentName] === undefined) {
return undefined;
}
return parameter.typeOptions[argumentName];
}
function getPath(parameterName: string): string {
return (props.path ? `${props.path}.` : '') + parameterName;
}
function deleteOption(optionName: string): void {
const parameterData = {
name: getPath(optionName),
value: undefined,
};
// TODO: If there is only one option it should delete the whole one
emit('valueChanged', parameterData);
}
function mustHideDuringCustomApiCall(
parameter: INodeProperties,
nodeValues: INodeParameters,
): boolean {
if (parameter?.displayOptions?.hide) return true;
const MUST_REMAIN_VISIBLE = [
'authentication',
'resource',
'operation',
...Object.keys(nodeValues),
];
return !MUST_REMAIN_VISIBLE.includes(parameter.name);
}
function displayNodeParameter(parameter: INodeProperties): boolean {
if (parameter.type === 'hidden') {
return false;
}
if (
nodeHelpers.isCustomApiCallSelected(props.nodeValues) &&
mustHideDuringCustomApiCall(parameter, props.nodeValues)
) {
return false;
}
// Hide authentication related fields since it will now be part of credentials modal
if (
!KEEP_AUTH_IN_NDV_FOR_NODES.includes(node.value?.type || '') &&
mainNodeAuthField.value &&
(parameter.name === mainNodeAuthField.value?.name || shouldHideAuthRelatedParameter(parameter))
) {
return false;
}
if (parameter.displayOptions === undefined) {
// If it is not defined no need to do a proper check
return true;
}
const nodeValues: INodeParameters = {};
let rawValues = props.nodeValues;
if (props.path) {
rawValues = get(props.nodeValues, props.path) as INodeParameters;
}
if (!rawValues) {
return false;
}
// Resolve expressions
const resolveKeys = Object.keys(rawValues);
let key: string;
let i = 0;
let parameterGotResolved = false;
do {
key = resolveKeys.shift() as string;
const value = rawValues[key];
if (typeof value === 'string' && value?.charAt(0) === '=') {
// Contains an expression that
if (
value.includes('$parameter') &&
resolveKeys.some((parameterName) => value.includes(parameterName))
) {
// Contains probably an expression of a missing parameter so skip
resolveKeys.push(key);
continue;
} else {
// Contains probably no expression with a missing parameter so resolve
try {
nodeValues[key] = workflowHelpers.resolveExpression(
value,
nodeValues,
) as NodeParameterValue;
} catch (e) {
// If expression is invalid ignore
nodeValues[key] = '';
}
parameterGotResolved = true;
}
} else {
// Does not contain an expression, add directly
nodeValues[key] = rawValues[key];
}
// TODO: Think about how to calculate this best
if (i++ > 50) {
// Make sure we do not get caught
break;
}
} while (resolveKeys.length !== 0);
if (parameterGotResolved) {
if (props.path) {
rawValues = deepCopy(props.nodeValues);
set(rawValues, props.path, nodeValues);
return nodeHelpers.displayParameter(rawValues, parameter, props.path, node.value);
} else {
return nodeHelpers.displayParameter(nodeValues, parameter, '', node.value);
}
}
return nodeHelpers.displayParameter(props.nodeValues, parameter, props.path, node.value);
}
function valueChanged(parameterData: IUpdateInformation): void {
emit('valueChanged', parameterData);
}
function onNoticeAction(action: string) {
if (action === 'activate') {
emit('activate');
}
}
/**
* Handles default node button parameter type actions
* @param parameter
*/
function onButtonAction(parameter: INodeProperties) {
const action: string | undefined = parameter.typeOptions?.action;
switch (action) {
default:
return;
}
}
function isNodeAuthField(name: string): boolean {
return nodeAuthFields.value.find((field) => field.name === name) !== undefined;
}
function shouldHideAuthRelatedParameter(parameter: INodeProperties): boolean {
// TODO: For now, hide all fields that are used in authentication fields displayOptions
// Ideally, we should check if any non-auth field depends on it before hiding it but
// since there is no such case, omitting it to avoid additional computation
return isAuthRelatedParameter(nodeAuthFields.value, parameter);
}
function shouldShowOptions(parameter: INodeProperties): boolean {
return parameter.type !== 'resourceMapper';
}
function getDependentParametersValues(parameter: INodeProperties): string | null {
const loadOptionsDependsOn = getArgument('loadOptionsDependsOn', parameter) as
| string[]
| undefined;
if (loadOptionsDependsOn === undefined) {
return null;
}
// Get the resolved parameter values of the current node
const currentNodeParameters = ndvStore.activeNode?.parameters;
try {
const resolvedNodeParameters = workflowHelpers.resolveParameter(currentNodeParameters);
const returnValues: string[] = [];
for (const parameterPath of loadOptionsDependsOn) {
returnValues.push(get(resolvedNodeParameters, parameterPath) as string);
}
return returnValues.join('|');
} catch (error) {
return null;
}
}
function getParameterValue<T extends NodeParameterValueType = NodeParameterValueType>(
name: string,
): T {
return nodeHelpers.getParameterValue(props.nodeValues, name, props.path) as T;
}
</script> </script>
<style lang="scss"> <style lang="scss">

View File

@@ -45,234 +45,194 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { mapStores } from 'pinia'; import type { IUpdateInformation, InputSize } from '@/Interface';
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
import type { INodeUi, IUpdateInformation, InputSize, TargetItem } from '@/Interface';
import ParameterInput from '@/components/ParameterInput.vue'; import ParameterInput from '@/components/ParameterInput.vue';
import InputHint from '@/components/ParameterInputHint.vue'; import InputHint from '@/components/ParameterInputHint.vue';
import { useEnvironmentsStore } from '@/stores/environments.ee.store'; import {
isResourceLocatorValue,
type IDataObject,
type INodeProperties,
type INodePropertyMode,
type IParameterLabel,
type NodeParameterValueType,
type Result,
} from 'n8n-workflow';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import useEnvironmentsStore from '@/stores/environments.ee.store';
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store'; import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { stringifyExpressionResult } from '@/utils/expressions';
import { isValueExpression, parseResourceMapperFieldName } from '@/utils/nodeTypesUtils'; import { isValueExpression, parseResourceMapperFieldName } from '@/utils/nodeTypesUtils';
import type {
IDataObject,
INodeProperties,
INodePropertyMode,
IParameterLabel,
NodeParameterValueType,
Result,
} from 'n8n-workflow';
import { isResourceLocatorValue } from 'n8n-workflow';
import type { EventBus } from 'n8n-design-system/utils'; import type { EventBus } from 'n8n-design-system/utils';
import { createEventBus } from 'n8n-design-system/utils'; import { createEventBus } from 'n8n-design-system/utils';
import { computed } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { stringifyExpressionResult } from '@/utils/expressions';
export default defineComponent({ type Props = {
name: 'ParameterInputWrapper', parameter: INodeProperties;
components: { path: string;
ParameterInput, modelValue: NodeParameterValueType;
InputHint, additionalExpressionData?: IDataObject;
}, rows?: number;
props: { isReadOnly?: boolean;
additionalExpressionData: { isAssignment?: boolean;
type: Object as PropType<IDataObject>, droppable?: boolean;
default: () => ({}), activeDrop?: boolean;
}, forceShowExpression?: boolean;
isReadOnly: { hint?: string;
type: Boolean, hideHint?: boolean;
}, inputSize?: InputSize;
rows: { hideIssues?: boolean;
type: Number, documentationUrl?: string;
default: 5, errorHighlight?: boolean;
}, isForCredential?: boolean;
isAssignment: { eventSource?: string;
type: Boolean, label?: IParameterLabel;
}, eventBus?: EventBus;
parameter: { };
type: Object as PropType<INodeProperties>,
required: true,
},
path: {
type: String,
required: true,
},
modelValue: {
type: [String, Number, Boolean, Array, Object] as PropType<NodeParameterValueType>,
},
droppable: {
type: Boolean,
},
activeDrop: {
type: Boolean,
},
forceShowExpression: {
type: Boolean,
},
hint: {
type: String,
required: false,
},
hideHint: {
type: Boolean,
required: false,
},
inputSize: {
type: String as PropType<InputSize>,
},
hideIssues: {
type: Boolean,
},
documentationUrl: {
type: String as PropType<string | undefined>,
},
errorHighlight: {
type: Boolean,
},
isForCredential: {
type: Boolean,
},
eventSource: {
type: String,
},
label: {
type: Object as PropType<IParameterLabel>,
default: () => ({
size: 'small',
}),
},
eventBus: {
type: Object as PropType<EventBus>,
default: () => createEventBus(),
},
},
setup() {
const router = useRouter();
const workflowHelpers = useWorkflowHelpers({ router });
return { const props = withDefaults(defineProps<Props>(), {
workflowHelpers, additionalExpressionData: () => ({}),
}; rows: 5,
}, label: () => ({ size: 'small' }),
computed: { eventBus: () => createEventBus(),
...mapStores(useNDVStore, useExternalSecretsStore, useEnvironmentsStore),
isValueExpression() {
return isValueExpression(this.parameter, this.modelValue);
},
activeNode(): INodeUi | null {
return this.ndvStore.activeNode;
},
selectedRLMode(): INodePropertyMode | undefined {
if (
typeof this.modelValue !== 'object' ||
this.parameter.type !== 'resourceLocator' ||
!isResourceLocatorValue(this.modelValue)
) {
return undefined;
}
const mode = this.modelValue.mode;
if (mode) {
return this.parameter.modes?.find((m: INodePropertyMode) => m.name === mode);
}
return undefined;
},
parameterHint(): string | undefined {
if (this.isValueExpression) {
return undefined;
}
if (this.selectedRLMode?.hint) {
return this.selectedRLMode.hint;
}
return this.hint;
},
targetItem(): TargetItem | null {
return this.ndvStore.hoveringItem;
},
isInputParentOfActiveNode(): boolean {
return this.ndvStore.isInputParentOfActiveNode;
},
evaluatedExpression(): Result<unknown, Error> {
const value = isResourceLocatorValue(this.modelValue)
? this.modelValue.value
: this.modelValue;
if (!this.activeNode || !this.isValueExpression || typeof value !== 'string') {
return { ok: false, error: new Error() };
}
try {
let opts: Parameters<typeof this.workflowHelpers.resolveExpression>[2] = {
isForCredential: this.isForCredential,
};
if (this.ndvStore.isInputParentOfActiveNode) {
opts = {
...opts,
targetItem: this.targetItem ?? undefined,
inputNodeName: this.ndvStore.ndvInputNodeName,
inputRunIndex: this.ndvStore.ndvInputRunIndex,
inputBranchIndex: this.ndvStore.ndvInputBranchIndex,
additionalKeys: this.resolvedAdditionalExpressionData,
};
}
return { ok: true, result: this.workflowHelpers.resolveExpression(value, undefined, opts) };
} catch (error) {
return { ok: false, error };
}
},
evaluatedExpressionValue(): unknown {
const evaluated = this.evaluatedExpression;
return evaluated.ok ? evaluated.result : null;
},
evaluatedExpressionString(): string | null {
return stringifyExpressionResult(this.evaluatedExpression);
},
expressionOutput(): string | null {
if (this.isValueExpression && this.evaluatedExpressionString) {
return this.evaluatedExpressionString;
}
return null;
},
resolvedAdditionalExpressionData() {
return {
$vars: this.environmentsStore.variablesAsObject,
...(this.externalSecretsStore.isEnterpriseExternalSecretsEnabled && this.isForCredential
? { $secrets: this.externalSecretsStore.secretsAsObject }
: {}),
...this.additionalExpressionData,
};
},
parsedParameterName() {
return parseResourceMapperFieldName(this.parameter?.name ?? '');
},
},
methods: {
onFocus() {
this.$emit('focus');
},
onBlur() {
this.$emit('blur');
},
onDrop(data: string) {
this.$emit('drop', data);
},
onValueChanged(parameterData: IUpdateInformation) {
this.$emit('update', parameterData);
},
onTextInput(parameterData: IUpdateInformation) {
this.$emit('textInput', parameterData);
},
},
}); });
const emit = defineEmits<{
(event: 'focus'): void;
(event: 'blur'): void;
(event: 'drop', value: string): void;
(event: 'update', value: IUpdateInformation): void;
(event: 'textInput', value: IUpdateInformation): void;
}>();
const router = useRouter();
const workflowHelpers = useWorkflowHelpers({ router });
const ndvStore = useNDVStore();
const externalSecretsStore = useExternalSecretsStore();
const environmentsStore = useEnvironmentsStore();
const isExpression = computed(() => {
return isValueExpression(props.parameter, props.modelValue);
});
const activeNode = computed(() => ndvStore.activeNode);
const selectedRLMode = computed(() => {
if (
typeof props.modelValue !== 'object' ||
props.parameter.type !== 'resourceLocator' ||
!isResourceLocatorValue(props.modelValue)
) {
return undefined;
}
const mode = props.modelValue.mode;
if (mode) {
return props.parameter.modes?.find((m: INodePropertyMode) => m.name === mode);
}
return undefined;
});
const parameterHint = computed(() => {
if (isExpression.value) {
return undefined;
}
if (selectedRLMode.value?.hint) {
return selectedRLMode.value.hint;
}
return props.hint;
});
const targetItem = computed(() => ndvStore.hoveringItem);
const isInputParentOfActiveNode = computed(() => ndvStore.isInputParentOfActiveNode);
const evaluatedExpression = computed<Result<unknown, Error>>(() => {
const value = isResourceLocatorValue(props.modelValue)
? props.modelValue.value
: props.modelValue;
if (!activeNode.value || !isExpression.value || typeof value !== 'string') {
return { ok: false, error: new Error() };
}
try {
let opts: Parameters<typeof workflowHelpers.resolveExpression>[2] = {
isForCredential: props.isForCredential,
};
if (ndvStore.isInputParentOfActiveNode) {
opts = {
...opts,
targetItem: targetItem.value ?? undefined,
inputNodeName: ndvStore.ndvInputNodeName,
inputRunIndex: ndvStore.ndvInputRunIndex,
inputBranchIndex: ndvStore.ndvInputBranchIndex,
additionalKeys: resolvedAdditionalExpressionData.value,
};
}
return { ok: true, result: workflowHelpers.resolveExpression(value, undefined, opts) };
} catch (error) {
return { ok: false, error };
}
});
const evaluatedExpressionValue = computed(() => {
const evaluated = evaluatedExpression.value;
return evaluated.ok ? evaluated.result : null;
});
const evaluatedExpressionString = computed(() => {
return stringifyExpressionResult(evaluatedExpression.value);
});
const expressionOutput = computed(() => {
if (isExpression.value && evaluatedExpressionString.value) {
return evaluatedExpressionString.value;
}
return null;
});
const resolvedAdditionalExpressionData = computed(() => {
return {
$vars: environmentsStore.variablesAsObject,
...(externalSecretsStore.isEnterpriseExternalSecretsEnabled && props.isForCredential
? { $secrets: externalSecretsStore.secretsAsObject }
: {}),
...props.additionalExpressionData,
};
});
const parsedParameterName = computed(() => {
return parseResourceMapperFieldName(props.parameter?.name ?? '');
});
function onFocus() {
emit('focus');
}
function onBlur() {
emit('blur');
}
function onDrop(data: string) {
emit('drop', data);
}
function onValueChanged(parameterData: IUpdateInformation) {
emit('update', parameterData);
}
function onTextInput(parameterData: IUpdateInformation) {
emit('textInput', parameterData);
}
</script> </script>
<style lang="scss" module> <style lang="scss" module>