Files
n8n-enterprise-unlocked/packages/editor-ui/src/components/ResourceLocator/ResourceLocator.vue

833 lines
24 KiB
Vue

<template>
<div
ref="container"
class="resource-locator"
:data-test-id="`resource-locator-${parameter.name}`"
>
<OnClickOutside @trigger="hideResourceDropdown">
<ResourceLocatorDropdown
ref="dropdown"
:model-value="modelValue ? modelValue.value : ''"
:show="resourceDropdownVisible"
:filterable="isSearchable"
:filter-required="requiresSearchFilter"
:resources="currentQueryResults"
:loading="currentQueryLoading"
:filter="searchFilter"
:has-more="currentQueryHasMore"
:error-view="currentQueryError"
:width="width"
:event-bus="eventBus"
@update:model-value="onListItemSelected"
@filter="onSearchFilter"
@load-more="loadResourcesDebounced"
>
<template #error>
<div :class="$style.error" data-test-id="rlc-error-container">
<n8n-text color="text-dark" align="center" tag="div">
{{ $locale.baseText('resourceLocator.mode.list.error.title') }}
</n8n-text>
<n8n-text v-if="hasCredential || credentialsNotSet" size="small" color="text-base">
{{ $locale.baseText('resourceLocator.mode.list.error.description.part1') }}
<a v-if="credentialsNotSet" @click="createNewCredential">{{
$locale.baseText('resourceLocator.mode.list.error.description.part2.noCredentials')
}}</a>
<a v-else-if="hasCredential" @click="openCredential">{{
$locale.baseText('resourceLocator.mode.list.error.description.part2.hasCredentials')
}}</a>
</n8n-text>
</div>
</template>
<div
:class="{
[$style.resourceLocator]: true,
[$style.multipleModes]: hasMultipleModes,
}"
>
<div :class="$style.background"></div>
<div v-if="hasMultipleModes" :class="$style.modeSelector">
<n8n-select
:model-value="selectedMode"
:size="inputSize"
:disabled="isReadOnly"
:placeholder="$locale.baseText('resourceLocator.modeSelector.placeholder')"
data-test-id="rlc-mode-selector"
@update:model-value="onModeSelected"
>
<n8n-option
v-for="mode in parameter.modes"
:key="mode.name"
:value="mode.name"
:label="getModeLabel(mode)"
:disabled="isValueExpression && mode.name === 'list'"
:title="
isValueExpression && mode.name === 'list'
? $locale.baseText('resourceLocator.mode.list.disabled.title')
: ''
"
>
{{ getModeLabel(mode) }}
</n8n-option>
</n8n-select>
</div>
<div :class="$style.inputContainer" data-test-id="rlc-input-container">
<DraggableTarget
type="mapping"
:disabled="hasOnlyListMode"
:sticky="true"
:sticky-offset="isValueExpression ? [26, 3] : [3, 3]"
@drop="onDrop"
>
<template #default="{ droppable, activeDrop }">
<div
:class="{
[$style.listModeInputContainer]: isListMode,
[$style.droppable]: droppable,
[$style.activeDrop]: activeDrop,
}"
@keydown.stop="onKeyDown"
>
<ExpressionParameterInput
v-if="isValueExpression || forceShowExpression"
ref="input"
:model-value="expressionDisplayValue"
:path="path"
:rows="3"
@update:model-value="onInputChange"
@modal-opener-click="$emit('modalOpenerClick')"
/>
<n8n-input
v-else
ref="input"
:class="{ [$style.selectInput]: isListMode }"
:size="inputSize"
:model-value="valueToDisplay"
:disabled="isReadOnly"
:readonly="isListMode"
:title="displayTitle"
:placeholder="inputPlaceholder"
type="text"
data-test-id="rlc-input"
@update:model-value="onInputChange"
@focus="onInputFocus"
@blur="onInputBlur"
>
<template v-if="isListMode" #suffix>
<i
:class="{
['el-input__icon']: true,
['el-icon-arrow-down']: true,
[$style.selectIcon]: true,
[$style.isReverse]: resourceDropdownVisible,
}"
/>
</template>
</n8n-input>
</div>
</template>
</DraggableTarget>
<ParameterIssues
v-if="parameterIssues && parameterIssues.length"
:issues="parameterIssues"
:class="$style['parameter-issues']"
/>
<div v-else-if="urlValue" :class="$style.openResourceLink">
<n8n-link theme="text" @click.stop="openResource(urlValue)">
<font-awesome-icon icon="external-link-alt" :title="getLinkAlt(valueToDisplay)" />
</n8n-link>
</div>
</div>
</div>
</ResourceLocatorDropdown>
</OnClickOutside>
</div>
</template>
<script lang="ts">
import type { DynamicNodeParameters, IResourceLocatorResultExpanded } from '@/Interface';
import DraggableTarget from '@/components/DraggableTarget.vue';
import ExpressionParameterInput from '@/components/ExpressionParameterInput.vue';
import ParameterIssues from '@/components/ParameterIssues.vue';
import { useRootStore } from '@/stores/root.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { getAppNameFromNodeName, getMainAuthField, hasOnlyListMode } from '@/utils/nodeTypesUtils';
import { isResourceLocatorValue } from '@/utils/typeGuards';
import stringify from 'fast-json-stable-stringify';
import type { EventBus } from 'n8n-design-system/utils';
import { createEventBus } from 'n8n-design-system/utils';
import type {
INode,
INodeCredentials,
INodeListSearchItems,
INodeParameterResourceLocator,
INodeParameters,
INodeProperties,
INodePropertyMode,
NodeParameterValue,
} from 'n8n-workflow';
import { mapStores } from 'pinia';
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
import ResourceLocatorDropdown from './ResourceLocatorDropdown.vue';
import { useDebounce } from '@/composables/useDebounce';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useRouter } from 'vue-router';
import { ndvEventBus } from '@/event-bus';
import { OnClickOutside } from '@vueuse/components';
interface IResourceLocatorQuery {
results: INodeListSearchItems[];
nextPageToken: unknown;
error: boolean;
loading: boolean;
}
export default defineComponent({
name: 'ResourceLocator',
components: {
DraggableTarget,
ExpressionParameterInput,
ParameterIssues,
ResourceLocatorDropdown,
OnClickOutside,
},
props: {
parameter: {
type: Object as PropType<INodeProperties>,
required: true,
},
modelValue: {
type: Object as PropType<INodeParameterResourceLocator>,
},
inputSize: {
type: String,
default: 'small',
validator: (size: string) => {
return ['mini', 'small', 'medium', 'large', 'xlarge'].includes(size);
},
},
parameterIssues: {
type: Array as PropType<string[]>,
default: () => [],
},
dependentParametersValues: {
type: [String, null] as PropType<string | null>,
default: null,
},
displayTitle: {
type: String,
default: '',
},
expressionComputedValue: {
type: {} as PropType<unknown>,
},
isReadOnly: {
type: Boolean,
default: false,
},
expressionDisplayValue: {
type: String,
default: '',
},
forceShowExpression: {
type: Boolean,
default: false,
},
isValueExpression: {
type: Boolean,
default: false,
},
expressionEditDialogVisible: {
type: Boolean,
default: false,
},
node: {
type: Object as PropType<INode>,
},
path: {
type: String,
required: true,
},
loadOptionsMethod: {
type: String,
},
eventBus: {
type: Object as PropType<EventBus>,
default: () => createEventBus(),
},
},
setup() {
const router = useRouter();
const workflowHelpers = useWorkflowHelpers({ router });
const { callDebounced } = useDebounce();
return { callDebounced, workflowHelpers };
},
data() {
return {
resourceDropdownVisible: false,
resourceDropdownHiding: false,
searchFilter: '',
cachedResponses: {} as { [key: string]: IResourceLocatorQuery },
hasCompletedASearch: false,
width: 0,
};
},
computed: {
...mapStores(useNodeTypesStore, useNDVStore, useRootStore, useUIStore, useWorkflowsStore),
appName(): string {
if (!this.node) {
return '';
}
const nodeType = this.nodeTypesStore.getNodeType(this.node.type);
return getAppNameFromNodeName(nodeType?.displayName || '');
},
selectedMode(): string {
if (typeof this.modelValue !== 'object') {
// legacy mode
return '';
}
if (!this.modelValue) {
return this.parameter.modes ? this.parameter.modes[0].name : '';
}
return this.modelValue.mode;
},
isListMode(): boolean {
return this.selectedMode === 'list';
},
hasCredential(): boolean {
const node = this.ndvStore.activeNode;
if (!node) {
return false;
}
return !!(node?.credentials && Object.keys(node.credentials).length === 1);
},
credentialsNotSet(): boolean {
if (!this.node) return false;
const nodeType = this.nodeTypesStore.getNodeType(this.node.type);
if (nodeType) {
const usesCredentials =
nodeType.credentials !== undefined && nodeType.credentials.length > 0;
if (usesCredentials && !this.node?.credentials) {
return true;
}
}
return false;
},
inputPlaceholder(): string {
if (this.currentMode.placeholder) {
return this.currentMode.placeholder;
}
const defaults: { [key: string]: string } = {
list: this.$locale.baseText('resourceLocator.mode.list.placeholder'),
id: this.$locale.baseText('resourceLocator.id.placeholder'),
url: this.$locale.baseText('resourceLocator.url.placeholder'),
};
return defaults[this.selectedMode] || '';
},
currentMode(): INodePropertyMode {
return this.findModeByName(this.selectedMode) || ({} as INodePropertyMode);
},
hasMultipleModes(): boolean {
return this.parameter.modes && this.parameter.modes.length > 1 ? true : false;
},
hasOnlyListMode(): boolean {
return hasOnlyListMode(this.parameter);
},
valueToDisplay(): NodeParameterValue {
if (typeof this.modelValue !== 'object') {
return this.modelValue;
}
if (this.isListMode) {
return this.modelValue ? this.modelValue.cachedResultName || this.modelValue.value : '';
}
return this.modelValue ? this.modelValue.value : '';
},
urlValue(): string | null {
if (this.isListMode && typeof this.modelValue === 'object') {
return this.modelValue?.cachedResultUrl || null;
}
if (this.selectedMode === 'url') {
if (
this.isValueExpression &&
typeof this.expressionComputedValue === 'string' &&
this.expressionComputedValue.startsWith('http')
) {
return this.expressionComputedValue;
}
if (typeof this.valueToDisplay === 'string' && this.valueToDisplay.startsWith('http')) {
return this.valueToDisplay;
}
}
if (this.currentMode.url) {
const value = this.isValueExpression ? this.expressionComputedValue : this.valueToDisplay;
if (typeof value === 'string') {
const expression = this.currentMode.url.replace(/\{\{\$value\}\}/g, value);
const resolved = this.workflowHelpers.resolveExpression(expression);
return typeof resolved === 'string' ? resolved : null;
}
}
return null;
},
currentRequestParams(): {
parameters: INodeParameters;
credentials: INodeCredentials | undefined;
filter: string;
} {
return {
parameters: this.node?.parameters ?? {},
credentials: this.node?.credentials ?? {},
filter: this.searchFilter,
};
},
currentRequestKey(): string {
const cacheKeys = { ...this.currentRequestParams };
cacheKeys.parameters = Object.keys(this.node ? this.node.parameters : {}).reduce(
(accu: INodeParameters, param) => {
if (param !== this.parameter.name && this.node?.parameters) {
accu[param] = this.node.parameters[param];
}
return accu;
},
{},
);
return stringify(cacheKeys);
},
currentResponse(): IResourceLocatorQuery | null {
return this.cachedResponses[this.currentRequestKey] || null;
},
currentQueryResults(): IResourceLocatorResultExpanded[] {
const results = this.currentResponse?.results ?? [];
return results.map(
(result: INodeListSearchItems): IResourceLocatorResultExpanded => ({
...result,
...(result.name && result.url ? { linkAlt: this.getLinkAlt(result.name) } : {}),
}),
);
},
currentQueryHasMore(): boolean {
return !!this.currentResponse?.nextPageToken;
},
currentQueryLoading(): boolean {
if (this.requiresSearchFilter && this.searchFilter === '') {
return false;
}
if (!this.currentResponse) {
return true;
}
return !!(this.currentResponse && this.currentResponse.loading);
},
currentQueryError(): boolean {
return !!(this.currentResponse && this.currentResponse.error);
},
isSearchable(): boolean {
return !!this.getPropertyArgument(this.currentMode, 'searchable');
},
requiresSearchFilter(): boolean {
return !!this.getPropertyArgument(this.currentMode, 'searchFilterRequired');
},
},
watch: {
currentQueryError(curr: boolean, prev: boolean) {
if (this.resourceDropdownVisible && curr && !prev) {
const inputRef = this.$refs.input as HTMLInputElement | undefined;
if (inputRef) {
inputRef.focus();
}
}
},
isValueExpression(newValue: boolean) {
if (newValue) {
this.switchFromListMode();
}
},
currentMode(mode: INodePropertyMode) {
if (
mode.extractValue?.regex &&
isResourceLocatorValue(this.modelValue) &&
this.modelValue.__regex !== mode.extractValue.regex
) {
this.$emit('update:modelValue', { ...this.modelValue, __regex: mode.extractValue.regex });
}
},
dependentParametersValues(currentValue, oldValue) {
const isUpdated = oldValue !== null && currentValue !== null && oldValue !== currentValue;
// Reset value if dependent parameters change
if (
isUpdated &&
this.modelValue &&
isResourceLocatorValue(this.modelValue) &&
this.modelValue.value !== ''
) {
this.$emit('update:modelValue', {
...this.modelValue,
cachedResultName: '',
cachedResultUrl: '',
value: '',
});
}
},
},
mounted() {
this.eventBus.on('refreshList', this.refreshList);
window.addEventListener('resize', this.setWidth);
useNDVStore().$subscribe((_mutation, _state) => {
// Update the width when main panel dimension change
this.setWidth();
});
setTimeout(() => {
this.setWidth();
}, 0);
},
beforeUnmount() {
this.eventBus.off('refreshList', this.refreshList);
window.removeEventListener('resize', this.setWidth);
},
methods: {
setWidth() {
const containerRef = this.$refs.container as HTMLElement | undefined;
if (containerRef) {
this.width = containerRef?.offsetWidth;
}
},
getLinkAlt(entity: NodeParameterValue) {
if (this.selectedMode === 'list' && entity) {
return this.$locale.baseText('resourceLocator.openSpecificResource', {
interpolate: { entity: entity.toString(), appName: this.appName },
});
}
return this.$locale.baseText('resourceLocator.openResource', {
interpolate: { appName: this.appName },
});
},
refreshList() {
this.cachedResponses = {};
this.trackEvent('User refreshed resource locator list');
},
onKeyDown(e: KeyboardEvent) {
if (this.resourceDropdownVisible && !this.isSearchable) {
this.eventBus.emit('keyDown', e);
}
},
openResource(url: string) {
window.open(url, '_blank');
this.trackEvent('User clicked resource locator link');
},
getPropertyArgument(
parameter: INodePropertyMode,
argumentName: string,
): string | number | boolean | undefined {
if (parameter.typeOptions === undefined) {
return undefined;
}
// @ts-ignore
if (parameter.typeOptions[argumentName] === undefined) {
return undefined;
}
// @ts-ignore
return parameter.typeOptions[argumentName];
},
openCredential(): void {
const node = this.ndvStore.activeNode;
if (!node?.credentials) {
return;
}
const credentialKey = Object.keys(node.credentials)[0];
if (!credentialKey) {
return;
}
const id = node.credentials[credentialKey].id;
if (!id) {
return;
}
this.uiStore.openExistingCredential(id);
},
createNewCredential(): void {
if (!this.node) return;
const nodeType = this.nodeTypesStore.getNodeType(this.node.type);
if (!nodeType) {
return;
}
const defaultCredentialType = nodeType.credentials?.[0].name ?? '';
const mainAuthType = getMainAuthField(nodeType);
const showAuthOptions =
mainAuthType !== null &&
Array.isArray(mainAuthType.options) &&
mainAuthType.options?.length > 0;
ndvEventBus.emit('credential.createNew', {
type: defaultCredentialType,
showAuthOptions,
});
},
findModeByName(name: string): INodePropertyMode | null {
if (this.parameter.modes) {
return this.parameter.modes.find((mode: INodePropertyMode) => mode.name === name) || null;
}
return null;
},
getModeLabel(mode: INodePropertyMode): string | null {
if (mode.name === 'id' || mode.name === 'url' || mode.name === 'list') {
return this.$locale.baseText(`resourceLocator.mode.${mode.name}`);
}
return mode.displayName;
},
onInputChange(value: string): void {
const params: INodeParameterResourceLocator = { __rl: true, value, mode: this.selectedMode };
if (this.isListMode) {
const resource = this.currentQueryResults.find((resource) => resource.value === value);
if (resource?.name) {
params.cachedResultName = resource.name;
}
if (resource?.url) {
params.cachedResultUrl = resource.url;
}
}
this.$emit('update:modelValue', params);
},
onModeSelected(value: string): void {
if (typeof this.modelValue !== 'object') {
this.$emit('update:modelValue', { __rl: true, value: this.modelValue, mode: value });
} else if (value === 'url' && this.modelValue?.cachedResultUrl) {
this.$emit('update:modelValue', {
__rl: true,
mode: value,
value: this.modelValue.cachedResultUrl,
});
} else if (
value === 'id' &&
this.selectedMode === 'list' &&
this.modelValue &&
this.modelValue.value
) {
this.$emit('update:modelValue', { __rl: true, mode: value, value: this.modelValue.value });
} else {
this.$emit('update:modelValue', { __rl: true, mode: value, value: '' });
}
this.trackEvent('User changed resource locator mode', { mode: value });
},
trackEvent(event: string, params?: { [key: string]: string }): void {
this.$telemetry.track(event, {
instance_id: this.rootStore.instanceId,
workflow_id: this.workflowsStore.workflowId,
node_type: this.node?.type,
resource: this.node?.parameters && this.node.parameters.resource,
operation: this.node?.parameters && this.node.parameters.operation,
field_name: this.parameter.name,
...params,
});
},
onDrop(data: string) {
this.switchFromListMode();
this.$emit('drop', data);
},
onSearchFilter(filter: string) {
this.searchFilter = filter;
this.loadResourcesDebounced();
},
async loadInitialResources(): Promise<void> {
if (!this.currentResponse || (this.currentResponse && this.currentResponse.error)) {
this.searchFilter = '';
await this.loadResources();
}
},
loadResourcesDebounced() {
if (this.currentResponse?.error) {
// Clear error response immediately when retrying to show loading state
delete this.cachedResponses[this.currentRequestKey];
}
void this.callDebounced(this.loadResources, {
debounceTime: 1000,
trailing: true,
});
},
setResponse(paramsKey: string, props: Partial<IResourceLocatorQuery>) {
this.cachedResponses = {
...this.cachedResponses,
[paramsKey]: { ...this.cachedResponses[paramsKey], ...props },
};
},
async loadResources() {
const params = this.currentRequestParams;
const paramsKey = this.currentRequestKey;
const cachedResponse = this.cachedResponses[paramsKey];
if (this.credentialsNotSet) {
this.setResponse(paramsKey, { error: true });
return;
}
if (this.requiresSearchFilter && !params.filter) {
return;
}
if (!this.node) {
return;
}
let paginationToken: string | undefined;
try {
if (cachedResponse) {
const nextPageToken = cachedResponse.nextPageToken as string;
if (nextPageToken) {
paginationToken = nextPageToken;
this.setResponse(paramsKey, { loading: true });
} else if (cachedResponse.error) {
this.setResponse(paramsKey, { error: false, loading: true });
} else {
return; // end of results
}
} else {
this.setResponse(paramsKey, {
loading: true,
error: false,
results: [],
nextPageToken: null,
});
}
const resolvedNodeParameters = this.workflowHelpers.resolveRequiredParameters(
this.parameter,
params.parameters,
) as INodeParameters;
const loadOptionsMethod = this.getPropertyArgument(
this.currentMode,
'searchListMethod',
) as string;
const requestParams: DynamicNodeParameters.ResourceLocatorResultsRequest = {
nodeTypeAndVersion: {
name: this.node.type,
version: this.node.typeVersion,
},
path: this.path,
methodName: loadOptionsMethod,
currentNodeParameters: resolvedNodeParameters,
credentials: this.node.credentials,
};
if (params.filter) {
requestParams.filter = params.filter;
}
if (paginationToken) {
requestParams.paginationToken = paginationToken;
}
const response = await this.nodeTypesStore.getResourceLocatorResults(requestParams);
this.setResponse(paramsKey, {
results: (cachedResponse ? cachedResponse.results : []).concat(response.results),
nextPageToken: response.paginationToken || null,
loading: false,
error: false,
});
if (params.filter && !this.hasCompletedASearch) {
this.hasCompletedASearch = true;
this.trackEvent('User searched resource locator list');
}
} catch (e) {
this.setResponse(paramsKey, {
loading: false,
error: true,
});
}
},
onInputFocus(): void {
if (!this.isListMode || this.resourceDropdownVisible) {
return;
}
void this.loadInitialResources();
this.showResourceDropdown();
},
switchFromListMode(): void {
if (this.isListMode && this.parameter.modes && this.parameter.modes.length > 1) {
let mode = this.findModeByName('id');
if (!mode) {
mode = this.parameter.modes.filter((mode) => mode.name !== 'list')[0];
}
if (mode) {
this.$emit('update:modelValue', {
__rl: true,
value:
this.modelValue && typeof this.modelValue === 'object' ? this.modelValue.value : '',
mode: mode.name,
});
}
}
},
onDropdownHide() {
if (!this.currentQueryError) {
this.hideResourceDropdown();
}
},
hideResourceDropdown() {
if (!this.resourceDropdownVisible) {
return;
}
this.resourceDropdownVisible = false;
const inputRef = this.$refs.input as HTMLInputElement | undefined;
this.resourceDropdownHiding = true;
void this.$nextTick(() => {
inputRef?.blur?.();
this.resourceDropdownHiding = false;
});
},
showResourceDropdown() {
if (this.resourceDropdownVisible || this.resourceDropdownHiding) {
return;
}
this.resourceDropdownVisible = true;
},
onListItemSelected(value: string) {
this.onInputChange(value);
this.hideResourceDropdown();
},
onInputBlur() {
if (!this.isSearchable || this.currentQueryError) {
this.hideResourceDropdown();
}
this.$emit('blur');
},
},
});
</script>
<style lang="scss" module>
@import './resourceLocator.scss';
</style>