feat: add resource locator parameter (#3932)

*  Added resource locator interfaces to `n8n-workflow` package

*  Updating Trello node to use resource locator property type

*  Added resource locator prop to Delete Board` Trello operation

* ✔️ Fiixing linting errors in Trello node

*  Added list mode to Trello test node

*  Updating resource locator modes interface

*  Updating Trello test node validation messages and placeholders

* N8N-4175 resource locator component (#3812)

*  Implemented initial version of resource locator component

*  Implemented front-end validation for resource locator component. Improved responsiveness. Minor refactoring.

*  Setting resource locator default state to list. Updating hover states and expand icon.

* 🔨 Moving resource locator component to `ParameterInput` from `ParameterInputFull

* 🔨 Moving `ResourceLocator` to a separate Vue component

* 🔨 Implementing expression and drag'n'drop support in ResourceLocator` component

* 🔨 Cleaning up `ResourceLocator` component code

*  Implemented resource locator selected mode persistance

* 💄 Minor refactoring and fixes in `ResourceLocator`

* 🔨 Updating `ResourceLocator` front-end validation logic

*  Saving resource locator mode in node parameters

* 💄 Updating the `ResourceLocator` component based on the design review

* 🐛 Fixing resource locator mode parameters handling when loading node parameter values on front-end

* 💄 Removing leftover unused CSS

*  Updating interfaces to support resource locator value types

*  Updating `ResourceLocator` component to work with object parameter values

* 🔨 Cleaning up `ResourceLocator` and related components code

*  Preventing `DraggableTarget` to be sticky if disabled

* 🐛 Fixing a bug with resource locator value parameter

* 👌 Adding new type alias for all possible node parameter value types

* 👌 Updating `ResourceLocator` and related components based on PR review feedback

*  Adding disabled mode to `ResourceLocator` component, fixing expression handling, minor refactoring.

* 💄 Updating disabled state styling in `ResourceLocator` component

*  Setting correct default value for test node and removing unnecessary logic

* 💄 Added regex URL validation to Trello test node

*  Updating Trello test node with another (list mode only) test case

* ✔️ Fixing linting error in Trello node

* 🔨 Removing hardcoded custom modes and modes order

* Add value extractor to routing node (#3777)

*  add value extractor to routing node

*  add value extractor to property modes

* 🔊 improve error logging for value extractor

* 🔥 remove old extractValue methods from RoutingNode

*  extractValue inside getNodeParameter

* 🔥 remove extract value test from RoutingNode

*  make value extraction optional

* 🥅 move extract value so proper error messages are sent

* 🚨 readd accidentally removed eslint-disable

*  add resource locator support extractValue

* 🚨 remove unused import

* 🐛 fix getting value of resource locator

* 💄 Updating resource locator component styling and handling reset value action

*  create v2 of Trello node for resource locator

* 💄 Updating ResourceLocator droppable & activeDrop classes and removing input padding-right

*  Updating Trello test node with single-mode test case

*  Updating field names in Trello node to avoid name clash

* 💄 Updating test Trello node mode order and board:update parameter name

* 💄 Updating test node parameter names and display options

* List mode search endpoint (#3936)

* 🚧 super basic version of the search endpoint

This version is built using a hacked up version of the Google Drive
node. I need to properly create a v2 for it but it's does work.

* 🚧 fixed up type errors and return urls

*  add v3 of Google Drive node with RLC

*  add RLC to Google Drive Shared Drive operations

* ♻️ address some small changes requested in review

* 🐛 move list search out of /nodes/ and add check for required param

*  google drive folder search

*  google drive search sort by name

*  add searchable flag for RLC

* ✏️ fix google drive wording for v3

* Trello and Airtable search backend (#3974)

*  add search to Trello boards

*  add RLC to Trello cards

* ♻️ use new versioning system for Trello v2

* 🐛 move list search out of /nodes/ and add check for required param

*  re-add trello search methods

* 🥅 throw error if RLC search isn't sent a method name

This will likely be removed when the declarative style of search has
been added.

*  add requires filter field to RLC search

*  add searchable flag to Trello searches

*  add RLC for cardId and boardId on all operations

*  add ID and URL RLC to Airtable

* N8 n 4179 resource locator list mode (#3933)

*  Implemented initial version of list mode dropdown

*  Handling mode switching and expression support in list mode

* 🔨 Removing `sortedModes` references

*  Fixing list mode UI after latest mege

* 💄 Updating padding-right for input fields with suffix slots

*  Minor fixes to validation, mode switching logic and styling

* update error

* 2 or more regex

* update regex to be more strict

* remove expr colors

* update hint

* 🚧 super basic version of the search endpoint

This version is built using a hacked up version of the Google Drive
node. I need to properly create a v2 for it but it's does work.

* 🚧 fixed up type errors and return urls

* begin list impl

*  add v3 of Google Drive node with RLC

* fix ts issue

* introduce dropdown

* add more behavior

* update design

* show search

* add filtering

* push up selected

* add keyboard nav

* add loading

* add caching

* remove console

* fix build issues

* add debounce

* fix click

* keep event on focus

* fix input size bug

* add resource locator type

* update type

* update interface

* update resource locator types

*  add search to Trello boards

*  add RLC to Google Drive Shared Drive operations

* update

* update name

* add package

* use stringify pckg

* handle long vals

* fix bug in url id modes

* remove console log

* add lazy loading

* add lazy loading on filtering

* clean up

* make search clearable

* add error state

*  add RLC to Trello cards

* ♻️ address some small changes requested in review

* ♻️ use new versioning system for Trello v2

* refactor a bit

* fix how loading happens

* clear after blur

* update api

* comment out test code

* update api

* relaod in case of error

* update endpoint

* 🐛 move list search out of /nodes/ and add check for required param

* 🐛 move list search out of /nodes/ and add check for required param

* update req handling

* update endpoint

*  re-add trello search methods

* 🥅 throw error if RLC search isn't sent a method name

This will likely be removed when the declarative style of search has
been added.

* get api to work

* update scroll handling

*  google drive folder search

*  add requires filter field to RLC search

*  google drive search sort by name

* remove console

*  add searchable flag for RLC

*  add searchable flag to Trello searches

* update searchable

*  add RLC for cardId and boardId on all operations

*  add ID and URL RLC to Airtable

* fix up search

* remove extra padding

* add link button

* update popper pos

* format

* fix formating

* update mode change

* add name urls

* update regex and errors

* upate error

* update errors

* update airtable regex

* update trello regex rules

* udpate param name

* update

* update param

* update param

* update drive node

* update params

* add keyboard nav

* fix bug

* update airtable default mode

* fix default value issue

* hide long selected value

* update duplicate reqs

* update node

* clean up impl

* dedupe resources

* fix up nv

* resort params

* update icon

* set placeholders

* default to id mode

* add telemetry

* add refresh opt

* clean up tmp val

* revert test change

* make placeholder optional

* update validation

* remove description as param hint

* support more general values

* fix links on long names

* update resource item styles

* update pos

* update icon color

* update link alt

* check if required

* move validation to workflow

* update naming

* only show warning at param level

* show right border on focus

* fix hover on all item

* fix long  names bug

* fix expr bug

* add expr

* update legacy mode

* fix up impl

* clean up node types

* clean up types

* remove unnessary type

* clean up types

* clean up types

* clean up types

* clea n up localizaiton

* remove unused key

* clean up helpers

* clean up paraminput

* clean up paraminputfull

* refactor into one loop

* update component

* update class names

* update prop types

* update name cases

* update casing

* clean up classes

* clean up resource locator

* update drop handling

* update mode

* add url for link mode

* clear value by default

* add placeholder

* remove legacy hint

* handle expr in legacy

* fix typos

* revert padding change

* fix up spacing

* update to link component

* support urls for id

* fix replacement

* build

Co-authored-by: Milorad Filipovic <milorad@n8n.io>
Co-authored-by: Valya Bullions <valya@n8n.io>

* refactor: Resource locator review changes (#4109)

*  Implemented initial version of list mode dropdown

*  Handling mode switching and expression support in list mode

* 🔨 Removing `sortedModes` references

*  Fixing list mode UI after latest mege

* 💄 Updating padding-right for input fields with suffix slots

*  Minor fixes to validation, mode switching logic and styling

* update error

* 2 or more regex

* update regex to be more strict

* remove expr colors

* update hint

* 🚧 super basic version of the search endpoint

This version is built using a hacked up version of the Google Drive
node. I need to properly create a v2 for it but it's does work.

* 🚧 fixed up type errors and return urls

* begin list impl

*  add v3 of Google Drive node with RLC

* fix ts issue

* introduce dropdown

* add more behavior

* update design

* show search

* add filtering

* push up selected

* add keyboard nav

* add loading

* add caching

* remove console

* fix build issues

* add debounce

* fix click

* keep event on focus

* fix input size bug

* add resource locator type

* update type

* update interface

* update resource locator types

*  add search to Trello boards

*  add RLC to Google Drive Shared Drive operations

* update

* update name

* add package

* use stringify pckg

* handle long vals

* fix bug in url id modes

* remove console log

* add lazy loading

* add lazy loading on filtering

* clean up

* make search clearable

* add error state

*  add RLC to Trello cards

* ♻️ address some small changes requested in review

* ♻️ use new versioning system for Trello v2

* refactor a bit

* fix how loading happens

* clear after blur

* update api

* comment out test code

* update api

* relaod in case of error

* update endpoint

* 🐛 move list search out of /nodes/ and add check for required param

* 🐛 move list search out of /nodes/ and add check for required param

* update req handling

* update endpoint

*  re-add trello search methods

* 🥅 throw error if RLC search isn't sent a method name

This will likely be removed when the declarative style of search has
been added.

* get api to work

* update scroll handling

*  google drive folder search

*  add requires filter field to RLC search

*  google drive search sort by name

* remove console

*  add searchable flag for RLC

*  add searchable flag to Trello searches

* update searchable

*  add RLC for cardId and boardId on all operations

*  add ID and URL RLC to Airtable

* fix up search

* remove extra padding

* add link button

* update popper pos

* format

* fix formating

* update mode change

* add name urls

* update regex and errors

* upate error

* update errors

* update airtable regex

* update trello regex rules

* udpate param name

* update

* update param

* update param

* update drive node

* update params

* add keyboard nav

* fix bug

* update airtable default mode

* fix default value issue

* hide long selected value

* update duplicate reqs

* update node

* clean up impl

* dedupe resources

* fix up nv

* resort params

* update icon

* set placeholders

* default to id mode

* add telemetry

* add refresh opt

* clean up tmp val

* revert test change

* make placeholder optional

* update validation

* remove description as param hint

* support more general values

* fix links on long names

* update resource item styles

* update pos

* update icon color

* update link alt

* check if required

* move validation to workflow

* update naming

* only show warning at param level

* show right border on focus

* fix hover on all item

* fix long  names bug

* ♻️ refactor extractValue to allow multiple props with same name

* ♻️ use correct import for displayParameterPath

* fix expr bug

* add expr

* update legacy mode

* fix up impl

* clean up node types

* clean up types

* ♻️ remove new version of google drive node

* ♻️ removed versioned Trello node for RLC

* remove unnessary type

* ♻️ remove versioned Airtable not for RLC

* clean up types

* clean up types

* clean up types

* clea n up localizaiton

* remove unused key

* clean up helpers

* clean up paraminput

* clean up paraminputfull

* refactor into one loop

* update component

* update class names

* update prop types

* update name cases

* update casing

* clean up classes

* 💬 updated RLC URL regex error wording

* clean up resource locator

* update drop handling

* update mode

* 💬 reword value extractor errors

* 🚨 remove unneeded eslint ignores for RLC modes

* 💬 update Trello 400 error message

* 🚨 re-add removed types in editor-ui

Also ts-ignore something that was clean up in another commit. I've added
a comment to fix after someone else can look at it.

* 💬 remove hints from Google Drive RLCs

* 🥅 rethrow correct errors in Trello node

*  add url for id mode on Google Drive

* 🔥 remove unused Google Drive file

* 🔊 change console.error to use logger instead

* 🔀 fix bad merges

* ♻️ small changes from review

* ♻️ remove ts-ignore

Co-authored-by: Milorad Filipovic <milorad@n8n.io>
Co-authored-by: Mutasem <mutdmour@gmail.com>

* fix build

* update tests

* fix bug with credential card

* update popover component

* fix expressions url

* fix type issue

* format

* update alt

* fix lint issues

* fix eslint issues

Co-authored-by: Milorad Filipovic <milorad@n8n.io>
Co-authored-by: Milorad FIlipović <miloradfilipovic19@gmail.com>
Co-authored-by: Valya <68596159+valya@users.noreply.github.com>
Co-authored-by: Valya Bullions <valya@n8n.io>
This commit is contained in:
Mutasem Aldmour
2022-09-21 15:44:45 +02:00
committed by GitHub
parent a71f3622e2
commit ad73f8995c
58 changed files with 3151 additions and 703 deletions

View File

@@ -23,8 +23,9 @@ import {
import mixins from "vue-typed-mixins";
import { genericHelpers } from "@/components/mixins/genericHelpers";
import { debounceHelper } from "./mixins/debounce";
export default mixins(genericHelpers).extend({
export default mixins(genericHelpers, debounceHelper).extend({
name: "BreakpointsObserver",
props: [
"valueXS",
@@ -98,4 +99,4 @@ export default mixins(genericHelpers).extend({
},
},
});
</script>
</script>

View File

@@ -112,7 +112,7 @@ export default mixins(
},
methods: {
async onClick() {
this.$store.dispatch('ui/openExisitngCredential', { id: this.data.id});
this.$store.dispatch('ui/openExistingCredential', { id: this.data.id});
},
async onAction(action: string) {
if (action === CREDENTIAL_LIST_ITEM_ACTIONS.OPEN) {

View File

@@ -107,7 +107,7 @@ export default mixins(
},
editCredential (credential: ICredentialsResponse) {
this.$store.dispatch('ui/openExisitngCredential', { id: credential.id});
this.$store.dispatch('ui/openExistingCredential', { id: credential.id});
this.$telemetry.track('User opened Credential modal', { credential_type: credential.type, source: 'primary_menu', new_credential: false, workflow_id: this.$store.getters.workflowId });
},

View File

@@ -59,7 +59,7 @@ export default Vue.extend({
this.hovering = e.clientX >= dim.left && e.clientX <= dim.right && e.clientY >= dim.top && e.clientY <= dim.bottom;
if (this.sticky && this.hovering) {
if (!this.disabled && this.sticky && this.hovering) {
this.$store.commit('ui/setDraggableStickyPos', [dim.left + this.stickyOffset, dim.top + this.stickyOffset]);
}
}

View File

@@ -53,10 +53,12 @@ import { genericHelpers } from '@/components/mixins/genericHelpers';
import mixins from 'vue-typed-mixins';
import { hasExpressionMapping } from './helpers';
import { debounceHelper } from './mixins/debounce';
export default mixins(
externalHooks,
genericHelpers,
debounceHelper,
).extend({
name: 'ExpressionEdit',
props: [

View File

@@ -636,7 +636,7 @@ export default mixins(
height: 35px;
line-height: 35px;
color: $--custom-dialog-text-color;
--menu-item-hover-fill: var(--color-primary-tint-3);
--menu-item-hover-fill: var(--color-background-base);
.item-title {
position: absolute;
@@ -656,7 +656,7 @@ export default mixins(
.el-menu {
border: none;
font-size: 14px;
--menu-item-hover-fill: var(--color-primary-tint-3);
--menu-item-hover-fill: var(--color-background-base);
.el-menu--collapse {
width: 75px;

View File

@@ -314,7 +314,7 @@ export default mixins(
editCredential(credentialType: string): void {
const { id } = this.node.credentials[credentialType];
this.$store.dispatch('ui/openExisitngCredential', { id });
this.$store.dispatch('ui/openExistingCredential', { id });
this.$telemetry.track('User opened Credential modal', { credential_type: credentialType, source: 'node', new_credential: false, workflow_id: this.$store.getters.workflowId });

View File

@@ -2,7 +2,7 @@
<div @keydown.stop :class="parameterInputClasses">
<expression-edit
:dialogVisible="expressionEditDialogVisible"
:value="value"
:value="isResourceLocatorParameter ? (value ? value.value : '') : value"
:parameter="parameter"
:path="path"
:eventSource="eventSource || 'ndv'"
@@ -14,8 +14,26 @@
:style="parameterInputWrapperStyle"
@click="openExpressionEdit"
>
<resource-locator
v-if="isResourceLocatorParameter"
ref="resourceLocator"
:parameter="parameter"
:value="value"
:displayTitle="displayTitle"
:expressionDisplayValue="expressionDisplayValue"
:isValueExpression="isValueExpression"
:isReadOnly="isReadOnly"
:parameterIssues="getIssues"
:droppable="droppable"
:node="node"
:path="path"
@input="valueChanged"
@focus="setFocus"
@blur="onBlur"
@drop="onResourceLocatorDrop"
/>
<n8n-input
v-if="isValueExpression || droppable || forceShowExpression"
v-else-if="isValueExpression || droppable || forceShowExpression"
:size="inputSize"
:type="getStringInputType"
:rows="getArgument('rows')"
@@ -23,7 +41,6 @@
:title="displayTitle"
@keydown.stop
/>
<div
v-else-if="
['json', 'string'].includes(parameter.type) ||
@@ -80,7 +97,7 @@
<div slot="suffix" class="expand-input-icon-container">
<font-awesome-icon
v-if="!isReadOnly"
icon="external-link-alt"
icon="expand-alt"
class="edit-window-button clickable"
:title="$locale.baseText('parameterInput.openEditWindow')"
@click="displayEditDialog()"
@@ -260,7 +277,7 @@
/>
</div>
<parameter-issues v-if="parameter.type !== 'credentialsSelect'" :issues="getIssues" />
<parameter-issues v-if="parameter.type !== 'credentialsSelect' && !isResourceLocatorParameter" :issues="getIssues" />
</div>
</template>
@@ -274,7 +291,6 @@ import {
import {
NodeHelpers,
NodeParameterValue,
IHttpRequestOptions,
ILoadOptions,
INodeParameters,
INodePropertyOptions,
@@ -288,6 +304,7 @@ import NodeCredentials from '@/components/NodeCredentials.vue';
import ScopesNotice from '@/components/ScopesNotice.vue';
import ParameterOptions from '@/components/ParameterOptions.vue';
import ParameterIssues from '@/components/ParameterIssues.vue';
import ResourceLocator from '@/components/ResourceLocator/ResourceLocator.vue';
// @ts-ignore
import PrismEditor from 'vue-prism-editor';
import TextEdit from '@/components/TextEdit.vue';
@@ -299,7 +316,8 @@ import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import mixins from 'vue-typed-mixins';
import { CUSTOM_API_CALL_KEY } from '@/constants';
import { mapGetters } from 'vuex';
import { hasExpressionMapping } from './helpers';
import { hasExpressionMapping, isValueExpression } from './helpers';
import { isResourceLocatorValue } from '@/typeGuards';
export default mixins(
externalHooks,
@@ -308,7 +326,7 @@ export default mixins(
workflowHelpers,
)
.extend({
name: 'ParameterInput',
name: 'parameter-input',
components: {
CodeEdit,
ExpressionEdit,
@@ -318,6 +336,7 @@ export default mixins(
ScopesNotice,
ParameterOptions,
ParameterIssues,
ResourceLocator,
TextEdit,
},
props: [
@@ -395,6 +414,9 @@ export default mixins(
},
computed: {
...mapGetters('credentials', ['allCredentialTypes']),
isValueExpression(): boolean {
return isValueExpression(this.parameter, this.value);
},
areExpressionsDisabled(): boolean {
return this.$store.getters['ui/areExpressionsDisabled'];
},
@@ -459,7 +481,7 @@ export default mixins(
let returnValue;
if (this.isValueExpression === false) {
returnValue = this.value;
returnValue = this.isResourceLocatorParameter ? (this.value ? this.value.value: '') : this.value;
} else {
returnValue = this.expressionValueComputed;
}
@@ -518,7 +540,7 @@ export default mixins(
let computedValue: NodeParameterValue;
try {
computedValue = this.resolveExpression(this.value) as NodeParameterValue;
computedValue = this.resolveExpression(this.value.value || this.value) as NodeParameterValue;
} catch (error) {
computedValue = `[${this.$locale.baseText('parameterInput.error')}}: ${error.message}]`;
}
@@ -610,15 +632,6 @@ export default mixins(
isEditor (): boolean {
return ['code', 'json'].includes(this.editorType);
},
isValueExpression () {
if (this.parameter.noDataExpression === true) {
return false;
}
if (typeof this.value === 'string' && this.value.charAt(0) === '=') {
return true;
}
return false;
},
editorType (): string {
return this.getArgument('editor') as string;
},
@@ -659,7 +672,7 @@ export default mixins(
const styles = {
width: '100%',
};
if (this.parameter.type === 'credentialsSelect') {
if (this.parameter.type === 'credentialsSelect' || this.isResourceLocatorParameter) {
return styles;
}
if (this.getIssues.length) {
@@ -683,6 +696,9 @@ export default mixins(
workflow (): Workflow {
return this.getCurrentWorkflow();
},
isResourceLocatorParameter (): boolean {
return this.parameter.type === 'resourceLocator';
},
},
methods: {
isRemoteParameterOption(option: INodePropertyOptions) {
@@ -730,9 +746,9 @@ export default mixins(
this.remoteParameterOptions.length = 0;
// Get the resolved parameter values of the current node
const currentNodeParameters = this.$store.getters.activeNode.parameters;
try {
const currentNodeParameters = (this.$store.getters.activeNode as INodeUi).parameters;
const resolvedNodeParameters = this.resolveParameter(currentNodeParameters) as INodeParameters;
const loadOptionsMethod = this.getArgument('loadOptionsMethod') as string | undefined;
const loadOptions = this.getArgument('loadOptions') as ILoadOptions | undefined;
@@ -803,7 +819,8 @@ export default mixins(
return this.parameter.typeOptions[argumentName];
},
expressionUpdated (value: string) {
this.valueChanged(value);
const val = this.isResourceLocatorParameter ? { value, mode: this.value.mode } : value;
this.valueChanged(val);
},
openExpressionEdit() {
if (this.areExpressionsDisabled) {
@@ -819,6 +836,9 @@ export default mixins(
onBlur () {
this.$emit('blur');
},
onResourceLocatorDrop(data: string) {
this.$emit('drop', data);
},
setFocus () {
if (this.isValueExpression) {
this.expressionEditDialogVisible = true;
@@ -844,7 +864,7 @@ export default mixins(
// Set focus on field
setTimeout(() => {
// @ts-ignore
if (this.$refs.inputField) {
if (this.$refs.inputField && this.$refs.inputField.$el) {
// @ts-ignore
this.$refs.inputField.focus();
}
@@ -871,7 +891,7 @@ export default mixins(
this.$emit('textInput', parameterData);
},
valueChanged (value: string[] | string | number | boolean | Date | null) {
valueChanged (value: string[] | string | number | boolean | Date | {} | null) {
if (this.parameter.name === 'nodeCredentialType') {
this.activeCredentialType = value as string;
}
@@ -916,9 +936,14 @@ export default mixins(
this.expressionEditDialogVisible = true;
} else if (command === 'addExpression') {
if (this.parameter.type === 'number' || this.parameter.type === 'boolean') {
this.valueChanged(`={{${this.value}}}`);
}
else {
this.valueChanged({ value: `={{${this.value}}}`, mode: this.value.mode });
} else if (this.isResourceLocatorParameter) {
if (isResourceLocatorValue(this.value)) {
this.valueChanged({ value: `=${this.value.value}`, mode: this.value.mode });
} else {
this.valueChanged({ value: `=${this.value}`, mode: '' });
}
} else {
this.valueChanged(`=${this.value}`);
}
@@ -934,8 +959,18 @@ export default mixins(
.filter((value) => (this.parameterOptions || []).find((option) => option.value === value));
}
this.valueChanged(typeof value !== 'undefined' ? value : null);
if (this.isResourceLocatorParameter) {
this.valueChanged({ value, mode: this.value.mode });
} else {
this.valueChanged(typeof value !== 'undefined' ? value : null);
}
} else if (command === 'refreshOptions') {
if (this.isResourceLocatorParameter) {
const resourceLocator = this.$refs.resourceLocator;
if (resourceLocator) {
(resourceLocator as Vue).$emit('refreshList');
}
}
this.loadRemoteParameterOptions();
}

View File

@@ -12,6 +12,7 @@
:value="value"
:isReadOnly="false"
:showOptions="true"
:isValueExpression="isValueExpression"
@optionSelected="optionSelected"
@menu-expanded="onMenuExpanded"
/>
@@ -29,6 +30,7 @@
:errorHighlight="showRequiredErrors"
:isForCredential="true"
:eventSource="eventSource"
:isValueExpression="isValueExpression"
@focus="onFocus"
@blur="onBlur"
@textInput="valueChanged"
@@ -53,6 +55,8 @@ import ParameterInput from './ParameterInput.vue';
import ParameterOptions from './ParameterOptions.vue';
import InputHint from './ParameterInputHint.vue';
import Vue from 'vue';
import { isValueExpression } from './helpers';
import { INodeParameterResourceLocator, INodeProperties } from 'n8n-workflow';
export default Vue.extend({
name: 'ParameterInputExpanded',
@@ -63,6 +67,7 @@ export default Vue.extend({
},
props: {
parameter: {
type: Object as () => INodeProperties,
},
value: {
},
@@ -101,6 +106,9 @@ export default Vue.extend({
return false;
},
isValueExpression (): boolean {
return isValueExpression(this.parameter, this.value as string | INodeParameterResourceLocator);
},
},
methods: {
onFocus() {

View File

@@ -13,12 +13,19 @@
:value="value"
:isReadOnly="isReadOnly"
:showOptions="displayOptions"
:showExpressionSelector="showExpressionSelector"
@optionSelected="optionSelected"
@menu-expanded="onMenuExpanded"
/>
</template>
<template>
<DraggableTarget type="mapping" :disabled="parameter.noDataExpression || isReadOnly" :sticky="true" :stickyOffset="4" @drop="onDrop">
<draggable-target
type="mapping"
:disabled="isDropDisabled"
:sticky="true"
:stickyOffset="4"
@drop="onDrop"
>
<template v-slot="{ droppable, activeDrop }">
<parameter-input
ref="param"
@@ -33,9 +40,10 @@
@valueChanged="valueChanged"
@focus="onFocus"
@blur="onBlur"
@drop="onDrop"
inputSize="small" />
</template>
</DraggableTarget>
</draggable-target>
<input-hint :class="$style.hint" :hint="$locale.nodeText().hint(parameter, path)" />
</template>
</n8n-input-label>
@@ -57,12 +65,15 @@ import mixins from 'vue-typed-mixins';
import { showMessage } from './mixins/showMessage';
import { LOCAL_STORAGE_MAPPING_FLAG } from '@/constants';
import { hasExpressionMapping } from './helpers';
import { hasOnlyListMode } from './ResourceLocator/helpers';
import { INodePropertyMode } from 'n8n-workflow';
import { isResourceLocatorValue } from '@/typeGuards';
export default mixins(
showMessage,
)
.extend({
name: 'ParameterInputFull',
name: 'parameter-input-full',
components: {
ParameterInput,
InputHint,
@@ -88,6 +99,15 @@ export default mixins(
node (): INodeUi | null {
return this.$store.getters.activeNode;
},
isResourceLocator (): boolean {
return this.parameter.type === 'resourceLocator';
},
isDropDisabled (): boolean {
return this.parameter.noDataExpression || this.isReadOnly || this.isResourceLocator;
},
showExpressionSelector (): boolean {
return this.isResourceLocator ? !hasOnlyListMode(this.parameter): true;
},
},
methods: {
onFocus() {
@@ -117,7 +137,7 @@ export default mixins(
this.forceShowExpression = true;
setTimeout(() => {
if (this.node) {
const prevValue = this.value;
const prevValue = this.isResourceLocator ? this.value.value : this.value;
let updatedValue: string;
if (typeof prevValue === 'string' && prevValue.startsWith('=') && prevValue.length > 1) {
updatedValue = `${prevValue} ${data}`;
@@ -126,11 +146,43 @@ export default mixins(
updatedValue = `=${data}`;
}
const parameterData = {
node: this.node.name,
name: this.path,
value: updatedValue,
};
let parameterData;
if (this.isResourceLocator) {
if (!isResourceLocatorValue(this.value)) {
parameterData = {
node: this.node.name,
name: this.path,
value: { 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: { value: updatedValue, mode: mode ? mode.name : '' },
};
}
else {
parameterData = {
node: this.node.name,
name: this.path,
value: { value: updatedValue, mode: this.value.mode },
};
}
} else {
parameterData = {
node: this.node.name,
name: this.path,
value: updatedValue,
};
}
this.$emit('valueChanged', parameterData);

View File

@@ -11,7 +11,7 @@
@visible-change="onMenuToggle"
/>
<n8n-radio-buttons
v-if="parameter.noDataExpression !== true"
v-if="parameter.noDataExpression !== true && showExpressionSelector"
size="small"
:value="selectedView"
:disabled="isReadOnly"
@@ -25,20 +25,39 @@
</template>
<script lang="ts">
import Vue from 'vue';
import { isResourceLocatorValue } from '@/typeGuards';
import { NodeParameterValueType } from 'n8n-workflow';
import Vue, { PropType } from 'vue';
import { isValueExpression } from './helpers';
export default Vue.extend({
name: 'ParameterOptions',
props: [
'parameter',
'isReadOnly',
'value',
'showOptions',
],
name: 'parameter-options',
props: {
parameter: {
type: Object,
},
isReadOnly: {
type: Boolean,
},
value: {
type: [Object, String, Number, Boolean] as PropType<NodeParameterValueType>,
},
showOptions: {
type: Boolean,
default: true,
},
showExpressionSelector: {
type: Boolean,
default: true,
},
},
computed: {
isDefault (): boolean {
return this.parameter.default === this.value;
},
isValueExpression(): boolean {
return isValueExpression(this.parameter, this.value);
},
shouldShowOptions (): boolean {
if (this.isReadOnly === true) {
return false;
@@ -61,15 +80,6 @@ export default Vue.extend({
return 'fixed';
},
isValueExpression () {
if (this.parameter.noDataExpression === true) {
return false;
}
if (typeof this.value === 'string' && this.value.charAt(0) === '=') {
return true;
}
return false;
},
hasRemoteMethod (): boolean {
return !!this.getArgument('loadOptionsMethod') || !!this.getArgument('loadOptions');
},
@@ -82,7 +92,7 @@ export default Vue.extend({
},
];
if (this.hasRemoteMethod) {
if (this.hasRemoteMethod || (this.parameter.type === 'resourceLocator' && isResourceLocatorValue(this.value) && this.value.mode === 'list')) {
return [
{
label: this.$locale.baseText('parameterInput.refreshList'),

View File

@@ -0,0 +1,766 @@
<template>
<div class="resource-locator">
<resource-locator-dropdown
:value="value ? value.value: ''"
:show="showResourceDropdown"
:filterable="isSearchable"
:filterRequired="requiresSearchFilter"
:resources="currentQueryResults"
:loading="currentQueryLoading"
:filter="searchFilter"
:hasMore="currentQueryHasMore"
:errorView="currentQueryError"
@input="onListItemSelected"
@hide="onDropdownHide"
@filter="onSearchFilter"
@loadMore="loadResourcesDebounced"
ref="dropdown"
>
<template #error>
<div :class="$style.error">
<n8n-text color="text-dark" align="center" tag="div">
{{ $locale.baseText('resourceLocator.mode.list.error.title') }}
</n8n-text>
<n8n-text size="small" color="text-base" v-if="hasCredential">
{{ $locale.baseText('resourceLocator.mode.list.error.description.part1') }}
<a @click="openCredential">{{
$locale.baseText('resourceLocator.mode.list.error.description.part2')
}}</a>
{{ $locale.baseText('resourceLocator.mode.list.error.description.part3') }}
</n8n-text>
</div>
</template>
<div
:class="{
[$style.resourceLocator]: true,
[$style.multipleModes]: hasMultipleModes,
}"
>
<div v-if="hasMultipleModes" :class="$style.modeSelector">
<n8n-select
:value="selectedMode"
filterable
:size="inputSize"
:disabled="isReadOnly"
@change="onModeSelected"
:placeholder="$locale.baseText('resourceLocator.modeSelector.placeholder')"
>
<n8n-option
v-for="mode in parameter.modes"
:key="mode.name"
:label="$locale.baseText(getModeLabel(mode.name)) || mode.displayName"
:value="mode.name"
:disabled="isValueExpression && mode.name === 'list'"
:title="
isValueExpression && mode.name === 'list'
? $locale.baseText('resourceLocator.mode.list.disabled.title')
: ''
"
>
</n8n-option>
</n8n-select>
</div>
<div :class="$style.inputContainer">
<draggable-target
type="mapping"
:disabled="hasOnlyListMode"
:sticky="true"
:stickyOffset="4"
@drop="onDrop"
>
<template v-slot="{ droppable, activeDrop }">
<div
:class="{
[$style.listModeInputContainer]: isListMode,
[$style.droppable]: droppable,
[$style.activeDrop]: activeDrop,
}"
@keydown.stop="onKeyDown"
>
<n8n-input
v-if="isValueExpression || droppable || forceShowExpression"
type="text"
:size="inputSize"
:value="activeDrop || forceShowExpression ? '' : expressionDisplayValue"
:title="displayTitle"
@keydown.stop
ref="input"
/>
<n8n-input
v-else
:class="{[$style.selectInput]: isListMode}"
:size="inputSize"
:value="valueToDisplay"
:disabled="isReadOnly"
:readonly="isListMode"
:title="displayTitle"
:placeholder="inputPlaceholder"
type="text"
ref="input"
@input="onInputChange"
@focus="onInputFocus"
@blur="onInputBlur"
>
<div
v-if="isListMode"
slot="suffix"
>
<i
:class="{
['el-input__icon']: true,
['el-icon-arrow-down']: true,
[$style.selectIcon]: true,
[$style.isReverse]: showResourceDropdown,
}"
></i>
</div>
</n8n-input>
</div>
</template>
</draggable-target>
<parameter-issues
v-if="parameterIssues && parameterIssues.length"
:issues="parameterIssues"
/>
<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>
</resource-locator-dropdown>
<parameter-input-hint v-if="infoText" class="mt-4xs" :hint="infoText" />
</div>
</template>
<script lang="ts">
import mixins from 'vue-typed-mixins';
import {
ILoadOptions,
INode,
INodeCredentials,
INodeListSearchItems,
INodeListSearchResult,
INodeParameterResourceLocator,
INodeParameters,
INodeProperties,
INodePropertyMode,
NodeParameterValue,
} from 'n8n-workflow';
import {
hasOnlyListMode,
} from './helpers';
import DraggableTarget from '@/components/DraggableTarget.vue';
import ExpressionEdit from '@/components/ExpressionEdit.vue';
import ParameterIssues from '@/components/ParameterIssues.vue';
import ParameterInputHint from '@/components/ParameterInputHint.vue';
import ResourceLocatorDropdown from './ResourceLocatorDropdown.vue';
import Vue, { PropType } from 'vue';
import { INodeUi, IResourceLocatorReqParams, IResourceLocatorResultExpanded } from '@/Interface';
import { debounceHelper } from '../mixins/debounce';
import stringify from 'fast-json-stable-stringify';
import { workflowHelpers } from '../mixins/workflowHelpers';
import { nodeHelpers } from '../mixins/nodeHelpers';
import { getAppNameFromNodeName } from '../helpers';
import { type } from 'os';
interface IResourceLocatorQuery {
results: INodeListSearchItems[];
nextPageToken: unknown;
error: boolean;
loading: boolean;
}
export default mixins(debounceHelper, workflowHelpers, nodeHelpers).extend({
name: 'resource-locator',
components: {
DraggableTarget,
ExpressionEdit,
ParameterIssues,
ParameterInputHint,
ResourceLocatorDropdown,
},
props: {
parameter: {
type: Object as PropType<INodeProperties>,
required: true,
},
value: {
type: [Object, String] as PropType<INodeParameterResourceLocator | NodeParameterValue | undefined>,
},
mode: {
type: String,
default: '',
},
inputSize: {
type: String,
default: 'small',
validator: (size) => {
return ['mini', 'small', 'medium', 'large', 'xlarge'].includes(size);
},
},
parameterIssues: {
type: Array as PropType<string[]>,
default() {
return [];
},
},
displayTitle: {
type: String,
default: '',
},
expressionDisplayValue: {
type: String,
default: '',
},
isReadOnly: {
type: Boolean,
default: false,
},
forceShowExpression: {
type: Boolean,
default: false,
},
isValueExpression: {
type: Boolean,
default: false,
},
expressionEditDialogVisible: {
type: Boolean,
default: false,
},
node: {
type: Object as PropType<INode>,
},
path: {
type: String,
},
loadOptionsMethod: {
type: String,
},
},
data() {
return {
showResourceDropdown: false,
searchFilter: '',
cachedResponses: {} as { [key: string]: IResourceLocatorQuery },
hasCompletedASearch: false,
};
},
computed: {
appName(): string {
if (!this.node) {
return '';
}
const nodeType = this.$store.getters['nodeTypes/getNodeType'](this.node.type);
return getAppNameFromNodeName(nodeType.displayName);
},
selectedMode(): string {
if (typeof this.value !== 'object') { // legacy mode
return '';
}
if (!this.value) {
return this.parameter.modes? this.parameter.modes[0].name : '';
}
return this.value.mode;
},
isListMode(): boolean {
return this.selectedMode === 'list';
},
hasCredential(): boolean {
const node = this.$store.getters.activeNode as INodeUi | null;
if (!node) {
return false;
}
return !!(node && node.credentials && Object.keys(node.credentials).length === 1);
},
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] || '';
},
infoText(): string {
return this.currentMode.hint ? this.currentMode.hint : '';
},
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.value !== 'object') {
return this.value;
}
if (this.isListMode) {
return this.value? (this.value.cachedResultName || this.value.value) : '';
}
return this.value ? this.value.value : '';
},
urlValue(): string | null {
if (this.isListMode && typeof this.value === 'object') {
return (this.value && this.value.cachedResultUrl) || null;
}
if (this.selectedMode === 'url') {
if (this.isValueExpression && typeof this.expressionDisplayValue === 'string' && this.expressionDisplayValue.startsWith('http')) {
return this.expressionDisplayValue;
}
if (typeof this.valueToDisplay === 'string' && this.valueToDisplay.startsWith('http')) {
return this.valueToDisplay;
}
}
if (this.currentMode.url) {
const value = this.isValueExpression? this.expressionDisplayValue : this.valueToDisplay;
if (typeof value === 'string') {
const expression = this.currentMode.url.replace(/\{\{\$value\}\}/g, value);
const resolved = this.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 && 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 ? this.currentResponse.results : [];
return results.map((result: INodeListSearchItems): IResourceLocatorResultExpanded => ({
...result,
...(
(result.name && result.url)? { linkAlt: this.getLinkAlt(result.name) } : {}
),
}));
},
currentQueryHasMore(): boolean {
return !!(this.currentResponse && 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.showResourceDropdown && curr && !prev) {
const input = this.$refs.input;
if (input) {
(input as HTMLElement).focus();
}
}
},
isValueExpression(newValue: boolean) {
if (newValue === true) {
this.switchFromListMode();
}
},
},
mounted() {
this.$on('refreshList', this.refreshList);
},
methods: {
getLinkAlt(entity: string) {
if (this.selectedMode === 'list' && entity) {
return this.$locale.baseText('resourceLocator.openSpecificResource', { interpolate: { entity, 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: MouseEvent) {
const dropdown = this.$refs.dropdown;
if (dropdown && this.showResourceDropdown && !this.isSearchable) {
(dropdown as Vue).$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.$store.getters.activeNode as INodeUi | null;
if (!node || !node.credentials) {
return;
}
const credentialKey = Object.keys(node.credentials)[0];
if (!credentialKey) {
return;
}
const id = node.credentials[credentialKey].id;
this.$store.dispatch('ui/openExistingCredential', { id });
},
findModeByName(name: string): INodePropertyMode | null {
if (this.parameter.modes) {
return this.parameter.modes.find((mode: INodePropertyMode) => mode.name === name) || null;
}
return null;
},
getModeLabel(name: string): string | null {
if (name === 'id' || name === 'url' || name === 'list') {
return this.$locale.baseText(`resourceLocator.mode.${name}`);
}
return null;
},
onInputChange(value: string): void {
const params: INodeParameterResourceLocator = { value, mode: this.selectedMode };
if (this.isListMode) {
const resource = this.currentQueryResults.find((resource) => resource.value === value);
if (resource && resource.name) {
params.cachedResultName = resource.name;
}
if (resource && resource.url) {
params.cachedResultUrl = resource.url;
}
}
this.$emit('input', params);
},
onModeSelected(value: string): void {
if (typeof this.value !== 'object') {
this.$emit('input', { value: this.value, mode: value });
} else if (value === 'url' && this.value && this.value.cachedResultUrl) {
this.$emit('input', { mode: value, value: this.value.cachedResultUrl });
} else if (value === 'id' && this.selectedMode === 'list' && this.value && this.value.value) {
this.$emit('input', { mode: value, value: this.value.value });
} else {
this.$emit('input', { 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.$store.getters.instanceId,
workflow_id: this.$store.getters.workflowId,
node_type: this.node && this.node.type,
resource: this.node && this.node.parameters && this.node.parameters.resource,
operation: this.node && 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 = '';
this.loadResources();
}
},
loadResourcesDebounced() {
this.callDebounced('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.requiresSearchFilter && !params.filter) {
return;
}
let paginationToken: unknown = null;
try {
if (cachedResponse) {
const nextPageToken = cachedResponse.nextPageToken;
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.resolveParameter(params.parameters) as INodeParameters;
const loadOptionsMethod = this.getPropertyArgument(this.currentMode, 'searchListMethod') as
| string
| undefined;
const searchList = this.getPropertyArgument(this.currentMode, 'searchList') as
| ILoadOptions
| undefined;
const requestParams: IResourceLocatorReqParams = {
nodeTypeAndVersion: {
name: this.node.type,
version: this.node.typeVersion,
},
path: this.path,
methodName: loadOptionsMethod,
searchList,
currentNodeParameters: resolvedNodeParameters,
credentials: this.node.credentials,
...(params.filter ? { filter: params.filter } : {}),
...(paginationToken ? { paginationToken } : {}),
};
const response: INodeListSearchResult = await this.$store.dispatch(
'nodeTypes/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.showResourceDropdown) {
return;
}
this.loadInitialResources();
this.showResourceDropdown = true;
},
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('input', { value: ((this.value && typeof this.value === 'object')? this.value.value: ''), mode: mode.name });
}
}
},
onDropdownHide() {
if (!this.currentQueryError) {
this.showResourceDropdown = false;
}
},
onListItemSelected(value: string) {
this.onInputChange(value);
this.showResourceDropdown = false;
},
onInputBlur() {
if (!this.isSearchable || this.currentQueryError) {
this.showResourceDropdown = false;
}
},
},
});
</script>
<style lang="scss" module>
$--mode-selector-width: 92px;
.modeSelector {
--input-background-color: initial;
--input-font-color: initial;
--input-border-color: initial;
flex-basis: $--mode-selector-width;
input {
border-radius: var(--border-radius-base) 0 0 var(--border-radius-base);
border-right: none;
overflow: hidden;
&:focus {
border-right: var(--border-base);
}
&:disabled {
cursor: not-allowed !important;
}
}
}
.resourceLocator {
display: flex;
flex-wrap: wrap;
.inputContainer {
display: flex;
align-items: center;
width: 100%;
div:first-child {
display: flex;
flex-grow: 1;
}
}
&.multipleModes {
.inputContainer {
display: flex;
align-items: center;
flex-basis: calc(100% - $--mode-selector-width);
flex-grow: 1;
input {
border-radius: 0 var(--border-radius-base) var(--border-radius-base) 0;
}
}
}
}
.droppable {
--input-border-color: var(--color-secondary-tint-1);
--input-background-color: var(--color-secondary-tint-2);
--input-border-style: dashed;
}
.activeDrop {
--input-border-color: var(--color-success);
--input-background-color: var(--color-success-tint-2);
--input-border-style: solid;
textarea,
input {
cursor: grabbing !important;
}
}
.selectInput input {
padding-right: 30px !important;
overflow: hidden;
text-overflow: ellipsis;
}
.selectIcon {
cursor: pointer;
font-size: 14px;
transition: transform 0.3s, -webkit-transform 0.3s;
-webkit-transform: rotateZ(0);
transform: rotateZ(0);
&.isReverse {
-webkit-transform: rotateZ(180deg);
transform: rotateZ(180deg);
}
}
.listModeInputContainer * {
cursor: pointer;
}
.error {
max-width: 170px;
word-break: normal;
text-align: center;
}
.openResourceLink {
margin-left: var(--spacing-2xs);
}
</style>

View File

@@ -0,0 +1,327 @@
<template>
<n8n-popover
placement="bottom"
width="318"
:popper-class="$style.popover"
:value="show"
trigger="manual"
>
<div :class="$style.messageContainer" v-if="errorView">
<slot name="error"></slot>
</div>
<div :class="$style.searchInput" v-if="filterable && !errorView" @keydown="onKeyDown">
<n8n-input size="medium" :value="filter" :clearable="true" @input="onFilterInput" @blur="onSearchBlur" ref="search" :placeholder="$locale.baseText('resourceLocator.search.placeholder')">
<font-awesome-icon :class="$style.searchIcon" icon="search" slot="prefix" />
</n8n-input>
</div>
<div v-if="filterRequired && !filter && !errorView && !loading" :class="$style.searchRequired">
{{ $locale.baseText('resourceLocator.mode.list.searchRequired') }}
</div>
<div :class="$style.messageContainer" v-else-if="!errorView && sortedResources.length === 0 && !loading">
{{ $locale.baseText('resourceLocator.mode.list.noResults') }}
</div>
<div v-else-if="!errorView" ref="resultsContainer" :class="{[$style.container]: true, [$style.pushDownResults]: filterable}" @scroll="onResultsEnd">
<div
v-for="(result, i) in sortedResources"
:key="result.value"
:class="{ [$style.resourceItem]: true, [$style.selected]: result.value === value, [$style.hovering]: hoverIndex === i }"
@click="() => onItemClick(result.value)"
@mouseenter="() => onItemHover(i)"
@mouseleave="() => onItemHoverLeave()"
:ref="`item-${i}`"
>
<div :class="$style.resourceNameContainer">
<span>{{ result.name }}</span>
</div>
<div :class="$style.urlLink">
<font-awesome-icon
v-if="showHoverUrl && result.url && hoverIndex === i"
icon="external-link-alt"
:title="result.linkAlt || $locale.baseText('resourceLocator.mode.list.openUrl')"
@click="openUrl($event, result.url)"
/>
</div>
</div>
<div v-if="loading && !errorView">
<div v-for="(_, i) in 3" :key="i" :class="$style.loadingItem">
<n8n-loading :class="$style.loader" variant="p" :rows="1" />
</div>
</div>
</div>
<slot slot="reference" />
</n8n-popover>
</template>
<script lang="ts">
import { IResourceLocatorResultExpanded } from '@/Interface';
import Vue, { PropType } from 'vue';
const SEARCH_BAR_HEIGHT_PX = 40;
const SCROLL_MARGIN_PX = 10;
export default Vue.extend({
name: 'resource-locator-dropdown',
props: {
value: {
type: [String, Number],
},
show: {
type: Boolean,
default: false,
},
resources: {
type: Array as PropType<IResourceLocatorResultExpanded[]>,
},
filterable: {
type: Boolean,
},
loading: {
type: Boolean,
},
filter: {
type: String,
},
hasMore: {
type: Boolean,
},
errorView: {
type: Boolean,
},
filterRequired: {
type: Boolean,
},
},
data() {
return {
hoverIndex: 0,
showHoverUrl: false,
};
},
mounted() {
this.$on('keyDown', this.onKeyDown);
},
computed: {
sortedResources(): IResourceLocatorResultExpanded[] {
const seen = new Set();
const { selected, notSelected } = this.resources.reduce((acc, item: IResourceLocatorResultExpanded) => {
if (seen.has(item.value)) {
return acc;
}
seen.add(item.value);
if (this.value && item.value === this.value) {
acc.selected = item;
} else {
acc.notSelected.push(item);
}
return acc;
}, { selected: null as IResourceLocatorResultExpanded | null, notSelected: [] as IResourceLocatorResultExpanded[] });
if (selected) {
return [
selected,
...notSelected,
];
}
return notSelected;
},
},
methods: {
openUrl(event: MouseEvent ,url: string) {
event.preventDefault();
event.stopPropagation();
window.open(url, '_blank');
},
onKeyDown(e: KeyboardEvent) {
const container = this.$refs.resultsContainer as HTMLElement;
if (e.key === 'ArrowDown') {
if (this.hoverIndex < this.sortedResources.length - 1) {
this.hoverIndex++;
const items = this.$refs[`item-${this.hoverIndex}`] as HTMLElement[];
if (container && Array.isArray(items) && items.length === 1) {
const item = items[0];
if ((item.offsetTop + item.clientHeight) > (container.scrollTop + container.offsetHeight)) {
const top = item.offsetTop - container.offsetHeight + item.clientHeight;
container.scrollTo({ top });
}
}
}
}
else if (e.key === 'ArrowUp') {
if (this.hoverIndex > 0) {
this.hoverIndex--;
const searchOffset = this.filterable ? SEARCH_BAR_HEIGHT_PX : 0;
const items = this.$refs[`item-${this.hoverIndex}`] as HTMLElement[];
if (container && Array.isArray(items) && items.length === 1) {
const item = items[0];
if (item.offsetTop <= container.scrollTop + searchOffset) {
container.scrollTo({ top: item.offsetTop - searchOffset });
}
}
}
}
else if (e.key === 'Enter') {
this.$emit('input', this.sortedResources[this.hoverIndex].value);
}
},
onFilterInput(value: string) {
this.$emit('filter', value);
},
onSearchBlur() {
this.$emit('hide');
},
onItemClick(selected: string) {
this.$emit('input', selected);
},
onItemHover(index: number) {
this.hoverIndex = index;
setTimeout(() => {
if (this.hoverIndex === index) {
this.showHoverUrl = true;
}
}, 250);
},
onItemHoverLeave() {
this.showHoverUrl = false;
},
onResultsEnd() {
if (this.loading || !this.hasMore) {
return;
}
const container = this.$refs.resultsContainer as HTMLElement;
if (container) {
const diff = container.offsetHeight - (container.scrollHeight - container.scrollTop);
if (diff > -(SCROLL_MARGIN_PX) && diff < SCROLL_MARGIN_PX) {
this.$emit('loadMore');
}
}
},
},
watch: {
show(toShow) {
if (toShow) {
this.hoverIndex = 0;
this.showHoverUrl = false;
}
setTimeout(() => {
if (toShow && this.filterable && this.$refs.search) {
(this.$refs.search as HTMLElement).focus();
}
}, 0);
},
loading() {
setTimeout(this.onResultsEnd, 0); // in case of filtering
},
},
});
</script>
<style lang="scss" module>
.popover {
padding: 0;
border: var(--border-base);
}
.pushDownResults {
padding-top: 36px;
}
.container {
position: relative;
max-height: 236px;
overflow: scroll;
}
.messageContainer {
height: 236px;
display: flex;
align-items: center;
justify-content: center;
}
.searchInput {
border-bottom: var(--border-base);
--input-border-color: none;
--input-font-size: var(--font-size-2xs);
position: absolute;
top: 0;
width: 316px;
z-index: 1;
}
.selected {
color: var(--color-primary);
}
.resourceItem {
display: flex;
padding: 0 var(--spacing-xs);
white-space: nowrap;
height: 32px;
cursor: pointer;
&:hover {
background-color: var(--color-background-base);
}
}
.loadingItem {
padding: 10px var(--spacing-xs);
}
.loader {
max-width: 120px;
* {
margin-top: 0 !important;
max-height: 12px;
}
}
.hovering {
background-color: var(--color-background-base);
}
.searchRequired {
height: 50px;
margin-top: 40px;
padding-left: var(--spacing-xs);
font-size: var(--font-size-xs);
color: var(--color-text-base);
display: flex;
align-items: center;
}
.urlLink {
display: flex;
align-items: center;
font-size: var(--font-size-3xs);
color: var(--color-text-base);
margin-left: var(--spacing-2xs);
&:hover {
color: var(--color-primary);
}
}
.resourceNameContainer {
font-size: var(--font-size-2xs);
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
align-self: center;
}
.searchIcon {
color: var(--color-text-light);
}
</style>

View File

@@ -0,0 +1,7 @@
import {
INodeProperties,
} from 'n8n-workflow';
export const hasOnlyListMode = (parameter: INodeProperties) : boolean => {
return parameter.modes !== undefined && parameter.modes.length === 1 && parameter.modes[0].name === 'list';
};

View File

@@ -1,9 +1,11 @@
import { CORE_NODES_CATEGORY, ERROR_TRIGGER_NODE_TYPE, MAPPING_PARAMS, TEMPLATES_NODES_FILTER } from '@/constants';
import { INodeUi, ITemplatesNode } from '@/Interface';
import { isResourceLocatorValue } from '@/typeGuards';
import dateformat from 'dateformat';
import {IDataObject, INodeTypeDescription} from 'n8n-workflow';
import {IDataObject, INodeProperties, INodeTypeDescription, NodeParameterValueType} from 'n8n-workflow';
const KEYWORDS_TO_FILTER = ['API', 'OAuth1', 'OAuth2'];
const CRED_KEYWORDS_TO_FILTER = ['API', 'OAuth1', 'OAuth2'];
const NODE_KEYWORDS_TO_FILTER = ['Trigger'];
const SI_SYMBOL = ['', 'k', 'M', 'G', 'T', 'P', 'E'];
const COMMUNITY_PACKAGE_NAME_REGEX = /(@\w+\/)?n8n-nodes-(?!base\b)\b\w+/g;
@@ -29,7 +31,11 @@ export function convertToHumanReadableDate (epochTime: number) {
}
export function getAppNameFromCredType(name: string) {
return name.split(' ').filter((word) => !KEYWORDS_TO_FILTER.includes(word)).join(' ');
return name.split(' ').filter((word) => !CRED_KEYWORDS_TO_FILTER.includes(word)).join(' ');
}
export function getAppNameFromNodeName(name: string) {
return name.split(' ').filter((word) => !NODE_KEYWORDS_TO_FILTER.includes(word)).join(' ');
}
export function getStyleTokenValue(name: string): string {
@@ -99,3 +105,16 @@ export function shorten(s: string, limit: number, keep: number) {
export function hasExpressionMapping(value: unknown) {
return typeof value === 'string' && !!MAPPING_PARAMS.find((param) => value.includes(param));
}
export function isValueExpression (parameter: INodeProperties, paramValue: NodeParameterValueType): boolean {
if (parameter.noDataExpression === true) {
return false;
}
if (typeof paramValue === 'string' && paramValue.charAt(0) === '=') {
return true;
}
if (isResourceLocatorValue(paramValue) && paramValue.value && paramValue.value.toString().charAt(0) === '=') {
return true;
}
return false;
}

View File

@@ -0,0 +1,24 @@
import { debounce } from 'lodash';
import Vue from 'vue';
export const debounceHelper = Vue.extend({
data () {
return {
debouncedFunctions: [] as any[], // tslint:disable-line:no-any
};
},
methods: {
async callDebounced (...inputParameters: any[]): Promise<void> { // tslint:disable-line:no-any
const functionName = inputParameters.shift() as string;
const { trailing, debounceTime } = inputParameters.shift();
// @ts-ignore
if (this.debouncedFunctions[functionName] === undefined) {
// @ts-ignore
this.debouncedFunctions[functionName] = debounce(this[functionName], debounceTime, trailing ? { trailing } : { leading: true } );
}
// @ts-ignore
await this.debouncedFunctions[functionName].apply(this, inputParameters);
},
},
});

View File

@@ -1,6 +1,5 @@
import { showMessage } from '@/components/mixins/showMessage';
import { VIEWS } from '@/constants';
import { debounce } from 'lodash';
import mixins from 'vue-typed-mixins';
@@ -8,7 +7,6 @@ export const genericHelpers = mixins(showMessage).extend({
data () {
return {
loadingService: null as any | null, // tslint:disable-line:no-any
debouncedFunctions: [] as any[], // tslint:disable-line:no-any
};
},
computed: {
@@ -71,18 +69,5 @@ export const genericHelpers = mixins(showMessage).extend({
this.loadingService = null;
}
},
async callDebounced (...inputParameters: any[]): Promise<void> { // tslint:disable-line:no-any
const functionName = inputParameters.shift() as string;
const { trailing, debounceTime } = inputParameters.shift();
// @ts-ignore
if (this.debouncedFunctions[functionName] === undefined) {
// @ts-ignore
this.debouncedFunctions[functionName] = debounce(this[functionName], debounceTime, trailing ? { trailing } : { leading: true } );
}
// @ts-ignore
await this.debouncedFunctions[functionName].apply(this, inputParameters);
},
},
});