mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(editor): Enhance Node Creator actions view (#5954)
* WIP * WIP * Extract actions into composable * WIP: Preserve categories when searching * WIP * WIP: Tweak styles * WIP: Refactor node creator * WIP: Finish Node Creator node view/subcategories refactor * WIP: Finished actions refactor * Cleanup & Lintfix * WIP: Improve memory managment * Fix interactions * WIP * WIP: Keyboard navigation * Improve keyboard navigation and memory managment * Finished view refactor * FIx custom api calls and activation callouts * Fix actions tracking and cleanup * Product review fixes * Telemetry fixes * Fix node creator e2es * Set action name font size and actionsEmpty font weight * Fix failing credentials spec * Make sure to select first action item when switching from nodes panel to actions panel * Add actions panel e2e tests * Cleanup * Fix actions generation and cleanup * Add correct Learn More link and adjust displaying of trigger icon * Change trigger icon condition to use nodeType group * Cleanup nodeTypesUtils and snapshots and lintfixes * Lint fixes * Refine logic to show trigger icon in node creator * Add unit tests & clean up * Add `003_auto_insert_action` experiment, hide empty sections for opposite root view * Lintfix * Do not show empty category tooltips and only show activation callout in triger root view * Fix no-results node creator view * Spacings tweaks and root rendering logic adjustment * Add unit tests * Lint and e2e fixes * Revert CLI changes, fix unit tests * Remove useless comments * Sync master, replace $externalHooks mixin * Lint fix * Focus first action when panel slides in, not category * Address PR comments * Lint fix * Remove `setAddedNodeActionParameters` optional track param * Further simplify setAddedNodeActionParameters * Fix pnpn lock file * Fix types imports * Fix 13-pinning spec
This commit is contained in:
parent
6335e0938d
commit
390841bbf0
|
@ -69,6 +69,6 @@ describe('Inline expression editor', () => {
|
|||
WorkflowPage.getters.inlineExpressionEditorInput().clear();
|
||||
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
|
||||
WorkflowPage.getters.inlineExpressionEditorInput().type('$parameter["operation"]');
|
||||
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^getAll$/);
|
||||
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^get$/);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import {
|
||||
HTTP_REQUEST_NODE_NAME,
|
||||
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
|
||||
MANUAL_TRIGGER_NODE_NAME,
|
||||
PIPEDRIVE_NODE_NAME,
|
||||
SET_NODE_NAME,
|
||||
} from '../constants';
|
||||
|
@ -75,7 +75,7 @@ describe('Data pinning', () => {
|
|||
});
|
||||
|
||||
it('Should be able to reference paired items in a node located before pinned data', () => {
|
||||
workflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_DISPLAY_NAME);
|
||||
workflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
workflowPage.actions.addNodeToCanvas(HTTP_REQUEST_NODE_NAME, true, true);
|
||||
ndv.actions.setPinnedData([{ http: 123 }]);
|
||||
ndv.actions.close();
|
||||
|
|
|
@ -58,8 +58,8 @@ describe('Node Creator', () => {
|
|||
nodeCreatorFeature.getters.getCreatorItem('On app event').click();
|
||||
|
||||
nodeCreatorFeature.getters.searchBar().find('input').clear().type('edit image');
|
||||
nodeCreatorFeature.getters.getCreatorItem('Results in other categories (1)').should('exist');
|
||||
nodeCreatorFeature.getters.creatorItem().should('have.length', 2);
|
||||
nodeCreatorFeature.getters.getCategoryItem('Results in other categories').should('exist');
|
||||
nodeCreatorFeature.getters.creatorItem().should('have.length', 1);
|
||||
nodeCreatorFeature.getters.getCreatorItem('Edit Image').should('exist');
|
||||
nodeCreatorFeature.getters.searchBar().find('input').clear().type('edit image123123');
|
||||
nodeCreatorFeature.getters.creatorItem().should('have.length', 0);
|
||||
|
@ -101,7 +101,7 @@ describe('Node Creator', () => {
|
|||
nodeCreatorFeature.getters.activeSubcategory().should('have.text', 'FTP');
|
||||
nodeCreatorFeature.getters.searchBar().find('input').clear().type('file');
|
||||
// Navigate to rename action which should be the 4th item
|
||||
nodeCreatorFeature.getters.searchBar().find('input').type('{downarrow} {downarrow} {downarrow} {rightarrow}');
|
||||
nodeCreatorFeature.getters.searchBar().find('input').type('{uparrow}{uparrow}{rightarrow}');
|
||||
NDVModal.getters.parameterInput('operation').should('contain.text', 'Rename');
|
||||
})
|
||||
|
||||
|
@ -127,9 +127,107 @@ describe('Node Creator', () => {
|
|||
})
|
||||
nodeCreatorFeature.getters.searchBar().find('input').clear().type(doubleActionNode);
|
||||
nodeCreatorFeature.getters.getCreatorItem(doubleActionNode).click();
|
||||
nodeCreatorFeature.getters.creatorItem().should('have.length', 2);
|
||||
nodeCreatorFeature.getters.creatorItem().should('have.length', 4);
|
||||
})
|
||||
|
||||
it('should have "Actions" section collapsed when opening actions view from Trigger root view', () => {
|
||||
nodeCreatorFeature.actions.openNodeCreator();
|
||||
nodeCreatorFeature.getters.searchBar().find('input').clear().type('ActiveCampaign');
|
||||
nodeCreatorFeature.getters.getCreatorItem('ActiveCampaign').click();
|
||||
nodeCreatorFeature.getters.getCategoryItem('Actions').should('exist');
|
||||
nodeCreatorFeature.getters.getCategoryItem('Triggers').should('exist');
|
||||
|
||||
nodeCreatorFeature.getters.getCategoryItem('Triggers').parent().should('not.have.attr', 'data-category-collapsed');
|
||||
nodeCreatorFeature.getters.getCategoryItem('Actions').parent().should('have.attr', 'data-category-collapsed', 'true');
|
||||
nodeCreatorFeature.getters.getCategoryItem('Actions').click()
|
||||
nodeCreatorFeature.getters.getCategoryItem('Actions').parent().should('not.have.attr', 'data-category-collapsed');
|
||||
});
|
||||
|
||||
it('should have "Triggers" section collapsed when opening actions view from Regular root view', () => {
|
||||
nodeCreatorFeature.actions.openNodeCreator();
|
||||
nodeCreatorFeature.getters.getCreatorItem('Manually').click();
|
||||
|
||||
nodeCreatorFeature.actions.openNodeCreator();
|
||||
nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n');
|
||||
nodeCreatorFeature.getters.getCreatorItem('n8n').click();
|
||||
|
||||
nodeCreatorFeature.getters.getCategoryItem('Actions').parent().should('not.have.attr', 'data-category-collapsed');
|
||||
nodeCreatorFeature.getters.getCategoryItem('Actions').click()
|
||||
nodeCreatorFeature.getters.getCategoryItem('Actions').parent().should('have.attr', 'data-category-collapsed');
|
||||
nodeCreatorFeature.getters.getCategoryItem('Triggers').parent().should('have.attr', 'data-category-collapsed');
|
||||
nodeCreatorFeature.getters.getCategoryItem('Triggers').click()
|
||||
nodeCreatorFeature.getters.getCategoryItem('Triggers').parent().should('not.have.attr', 'data-category-collapsed');
|
||||
});
|
||||
|
||||
it('should show callout and two suggested nodes if node has no trigger actions', () => {
|
||||
nodeCreatorFeature.actions.openNodeCreator();
|
||||
nodeCreatorFeature.getters.searchBar().find('input').clear().type('Customer Datastore (n8n training)');
|
||||
nodeCreatorFeature.getters.getCreatorItem('Customer Datastore (n8n training)').click();
|
||||
|
||||
cy.getByTestId('actions-panel-no-triggers-callout').should('be.visible');
|
||||
nodeCreatorFeature.getters.getCreatorItem('On a Schedule').should('be.visible');
|
||||
nodeCreatorFeature.getters.getCreatorItem('On a Webhook call').should('be.visible');
|
||||
});
|
||||
|
||||
it('should show intro callout if user has not made a production execution', () => {
|
||||
nodeCreatorFeature.actions.openNodeCreator();
|
||||
nodeCreatorFeature.getters.searchBar().find('input').clear().type('Customer Datastore (n8n training)');
|
||||
nodeCreatorFeature.getters.getCreatorItem('Customer Datastore (n8n training)').click();
|
||||
|
||||
cy.getByTestId('actions-panel-activation-callout').should('be.visible');
|
||||
nodeCreatorFeature.getters.activeSubcategory().find('button').click();
|
||||
nodeCreatorFeature.getters.searchBar().find('input').clear()
|
||||
|
||||
nodeCreatorFeature.getters.getCreatorItem('On a schedule').click();
|
||||
|
||||
// Setup 1s interval execution
|
||||
cy.getByTestId('parameter-input-field').click();
|
||||
cy.getByTestId('parameter-input-field')
|
||||
.find('.el-select-dropdown')
|
||||
.find('.option-headline')
|
||||
.contains('Seconds')
|
||||
.click();
|
||||
cy.getByTestId('parameter-input-secondsInterval').clear().type('1');
|
||||
|
||||
NDVModal.actions.close();
|
||||
|
||||
nodeCreatorFeature.actions.openNodeCreator();
|
||||
nodeCreatorFeature.getters.searchBar().find('input').clear().type('Customer Datastore (n8n training)');
|
||||
nodeCreatorFeature.getters.getCreatorItem('Customer Datastore (n8n training)').click();
|
||||
nodeCreatorFeature.getters.getCreatorItem('Get All People').click();
|
||||
NDVModal.actions.close();
|
||||
|
||||
WorkflowPage.actions.saveWorkflowOnButtonClick();
|
||||
WorkflowPage.actions.activateWorkflow();
|
||||
WorkflowPage.getters.activatorSwitch().should('have.class', 'is-checked');
|
||||
|
||||
// Wait for schedule 1s execution to mark user as having made a production execution
|
||||
cy.wait(1500);
|
||||
cy.reload()
|
||||
|
||||
// Action callout should not be visible after user has made a production execution
|
||||
nodeCreatorFeature.actions.openNodeCreator();
|
||||
nodeCreatorFeature.getters.searchBar().find('input').clear().type('Customer Datastore (n8n training)');
|
||||
nodeCreatorFeature.getters.getCreatorItem('Customer Datastore (n8n training)').click();
|
||||
|
||||
cy.getByTestId('actions-panel-activation-callout').should('not.exist');
|
||||
});
|
||||
|
||||
it('should show Trigger and Actions sections during search', () => {
|
||||
nodeCreatorFeature.actions.openNodeCreator();
|
||||
|
||||
nodeCreatorFeature.getters.searchBar().find('input').clear().type('Customer Datastore (n8n training)');
|
||||
nodeCreatorFeature.getters.getCreatorItem('Customer Datastore (n8n training)').click();
|
||||
|
||||
nodeCreatorFeature.getters.searchBar().find('input').clear().type('Non existent action name');
|
||||
|
||||
nodeCreatorFeature.getters.getCategoryItem('Triggers').should('be.visible');
|
||||
nodeCreatorFeature.getters.getCategoryItem('Actions').should('be.visible');
|
||||
cy.getByTestId('actions-panel-no-triggers-callout').should('be.visible');
|
||||
nodeCreatorFeature.getters.getCreatorItem('On a Schedule').should('be.visible');
|
||||
nodeCreatorFeature.getters.getCreatorItem('On a Webhook call').should('be.visible');
|
||||
});
|
||||
|
||||
describe('should correctly append manual trigger for regular actions', () => {
|
||||
// For these sources, manual node should be added
|
||||
const sourcesWithAppend = [
|
||||
|
@ -152,6 +250,7 @@ describe('Node Creator', () => {
|
|||
source.handler()
|
||||
nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n');
|
||||
nodeCreatorFeature.getters.getCreatorItem('n8n').click();
|
||||
nodeCreatorFeature.getters.getCategoryItem('Actions').click();
|
||||
nodeCreatorFeature.getters.getCreatorItem('Create a credential').click();
|
||||
NDVModal.actions.close();
|
||||
WorkflowPage.getters.canvasNodes().should('have.length', 2);
|
||||
|
@ -162,12 +261,14 @@ describe('Node Creator', () => {
|
|||
nodeCreatorFeature.getters.canvasAddButton().click();
|
||||
nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n');
|
||||
nodeCreatorFeature.getters.getCreatorItem('n8n').click();
|
||||
nodeCreatorFeature.getters.getCategoryItem('Actions').click();
|
||||
nodeCreatorFeature.getters.getCreatorItem('Create a credential').click();
|
||||
NDVModal.actions.close();
|
||||
WorkflowPage.actions.deleteNode('When clicking "Execute Workflow"')
|
||||
WorkflowPage.getters.canvasNodePlusEndpointByName('n8n').click()
|
||||
nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n');
|
||||
nodeCreatorFeature.getters.getCreatorItem('n8n').click();
|
||||
nodeCreatorFeature.getters.getCategoryItem('Actions').click();
|
||||
nodeCreatorFeature.getters.getCreatorItem('Create a credential').click();
|
||||
NDVModal.actions.close();
|
||||
WorkflowPage.getters.canvasNodes().should('have.length', 2);
|
||||
|
|
|
@ -60,6 +60,6 @@ describe('Expression editor modal', () => {
|
|||
it('should resolve $parameter[]', () => {
|
||||
WorkflowPage.getters.expressionModalInput().clear();
|
||||
WorkflowPage.getters.expressionModalInput().type('{{ $parameter["operation"]');
|
||||
WorkflowPage.getters.expressionModalOutput().contains(/^getAll$/);
|
||||
WorkflowPage.getters.expressionModalOutput().contains(/^get$/);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,6 +7,7 @@ export class NodeCreator extends BasePage {
|
|||
plusButton: () => cy.getByTestId('node-creator-plus-button'),
|
||||
canvasAddButton: () => cy.getByTestId('canvas-add-button'),
|
||||
searchBar: () => cy.getByTestId('search-bar'),
|
||||
getCategoryItem: (label: string) => cy.get(`[data-keyboard-nav-id="${label}"]`),
|
||||
getCreatorItem: (label: string) =>
|
||||
this.getters.creatorItem().contains(label).parents('[data-test-id="item-iterator-item"]'),
|
||||
getNthCreatorItem: (n: number) => this.getters.creatorItem().eq(n),
|
||||
|
@ -15,10 +16,11 @@ export class NodeCreator extends BasePage {
|
|||
selectedTab: () => this.getters.nodeCreatorTabs().find('.is-active'),
|
||||
categorizedItems: () => cy.getByTestId('categorized-items'),
|
||||
creatorItem: () => cy.getByTestId('item-iterator-item'),
|
||||
categoryItem: () => cy.getByTestId('node-creator-category-item'),
|
||||
communityNodeTooltip: () => cy.getByTestId('node-item-community-tooltip'),
|
||||
noResults: () => cy.getByTestId('categorized-no-results'),
|
||||
noResults: () => cy.getByTestId('node-creator-no-results'),
|
||||
nodeItemName: () => cy.getByTestId('node-creator-item-name'),
|
||||
activeSubcategory: () => cy.getByTestId('categorized-items-subcategory'),
|
||||
activeSubcategory: () => cy.getByTestId('nodes-list-header'),
|
||||
expandedCategories: () =>
|
||||
this.getters.creatorItem().find('>div').filter('.active').invoke('text'),
|
||||
};
|
||||
|
|
|
@ -140,7 +140,8 @@ export class WorkflowPage extends BasePage {
|
|||
if(action) {
|
||||
cy.contains(action).click()
|
||||
} else {
|
||||
cy.getByTestId('item-iterator-item').eq(1).click()
|
||||
// Select the first action
|
||||
cy.get('[data-keyboard-nav-type="action"]').eq(0).click()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div :class="classes" role="alert">
|
||||
<div :class="$style['message-section']">
|
||||
<div :class="$style.icon">
|
||||
<div :class="$style.messageSection">
|
||||
<div :class="$style.icon" v-if="!iconless">
|
||||
<n8n-icon :icon="getIcon" :size="theme === 'secondary' ? 'medium' : 'large'" />
|
||||
</div>
|
||||
<n8n-text size="small">
|
||||
|
@ -44,10 +44,21 @@ export default defineComponent({
|
|||
type: String,
|
||||
default: 'info-circle',
|
||||
},
|
||||
iconless: {
|
||||
type: Boolean,
|
||||
},
|
||||
slim: {
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
classes(): string[] {
|
||||
return ['n8n-callout', this.$style.callout, this.$style[this.theme]];
|
||||
return [
|
||||
'n8n-callout',
|
||||
this.$style.callout,
|
||||
this.$style[this.theme],
|
||||
this.slim ? this.$style.slim : '',
|
||||
];
|
||||
},
|
||||
getIcon(): string {
|
||||
if (Object.keys(CALLOUT_DEFAULT_ICONS).includes(this.theme)) {
|
||||
|
@ -70,9 +81,14 @@ export default defineComponent({
|
|||
border-radius: var(--border-radius-base);
|
||||
align-items: center;
|
||||
line-height: var(--font-line-height-loose);
|
||||
|
||||
&.slim {
|
||||
line-height: var(--font-line-height-loose);
|
||||
padding: var(--spacing-3xs) var(--spacing-2xs);
|
||||
}
|
||||
}
|
||||
|
||||
.message-section {
|
||||
.messageSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
exports[`components > N8nCallout > should render additional slots correctly 1`] = `
|
||||
"<div role=\\"alert\\" class=\\"n8n-callout callout custom\\">
|
||||
<div class=\\"message-section\\">
|
||||
<div class=\\"messageSection\\">
|
||||
<div class=\\"icon\\">
|
||||
<n8n-icon-stub icon=\\"code-branch\\" size=\\"large\\"></n8n-icon-stub>
|
||||
</div>
|
||||
|
@ -16,7 +16,7 @@ exports[`components > N8nCallout > should render additional slots correctly 1`]
|
|||
|
||||
exports[`components > N8nCallout > should render custom theme correctly 1`] = `
|
||||
"<div role=\\"alert\\" class=\\"n8n-callout callout custom\\">
|
||||
<div class=\\"message-section\\">
|
||||
<div class=\\"messageSection\\">
|
||||
<div class=\\"icon\\">
|
||||
<n8n-icon-stub icon=\\"code-branch\\" size=\\"large\\"></n8n-icon-stub>
|
||||
</div>
|
||||
|
@ -29,7 +29,7 @@ exports[`components > N8nCallout > should render custom theme correctly 1`] = `
|
|||
|
||||
exports[`components > N8nCallout > should render danger theme correctly 1`] = `
|
||||
"<div role=\\"alert\\" class=\\"n8n-callout callout danger\\">
|
||||
<div class=\\"message-section\\">
|
||||
<div class=\\"messageSection\\">
|
||||
<div class=\\"icon\\">
|
||||
<n8n-icon-stub icon=\\"times-circle\\" size=\\"large\\"></n8n-icon-stub>
|
||||
</div>
|
||||
|
@ -42,7 +42,7 @@ exports[`components > N8nCallout > should render danger theme correctly 1`] = `
|
|||
|
||||
exports[`components > N8nCallout > should render info theme correctly 1`] = `
|
||||
"<div role=\\"alert\\" class=\\"n8n-callout callout info\\">
|
||||
<div class=\\"message-section\\">
|
||||
<div class=\\"messageSection\\">
|
||||
<div class=\\"icon\\">
|
||||
<n8n-icon-stub icon=\\"info-circle\\" size=\\"large\\"></n8n-icon-stub>
|
||||
</div>
|
||||
|
@ -55,7 +55,7 @@ exports[`components > N8nCallout > should render info theme correctly 1`] = `
|
|||
|
||||
exports[`components > N8nCallout > should render secondary theme correctly 1`] = `
|
||||
"<div role=\\"alert\\" class=\\"n8n-callout callout secondary\\">
|
||||
<div class=\\"message-section\\">
|
||||
<div class=\\"messageSection\\">
|
||||
<div class=\\"icon\\">
|
||||
<n8n-icon-stub icon=\\"info-circle\\" size=\\"medium\\"></n8n-icon-stub>
|
||||
</div>
|
||||
|
@ -68,7 +68,7 @@ exports[`components > N8nCallout > should render secondary theme correctly 1`] =
|
|||
|
||||
exports[`components > N8nCallout > should render success theme correctly 1`] = `
|
||||
"<div role=\\"alert\\" class=\\"n8n-callout callout success\\">
|
||||
<div class=\\"message-section\\">
|
||||
<div class=\\"messageSection\\">
|
||||
<div class=\\"icon\\">
|
||||
<n8n-icon-stub icon=\\"check-circle\\" size=\\"large\\"></n8n-icon-stub>
|
||||
</div>
|
||||
|
@ -81,7 +81,7 @@ exports[`components > N8nCallout > should render success theme correctly 1`] = `
|
|||
|
||||
exports[`components > N8nCallout > should render warning theme correctly 1`] = `
|
||||
"<div role=\\"alert\\" class=\\"n8n-callout callout warning\\">
|
||||
<div class=\\"message-section\\">
|
||||
<div class=\\"messageSection\\">
|
||||
<div class=\\"icon\\">
|
||||
<n8n-icon-stub icon=\\"exclamation-triangle\\" size=\\"large\\"></n8n-icon-stub>
|
||||
</div>
|
||||
|
|
|
@ -92,16 +92,11 @@ defineEmits<{
|
|||
}
|
||||
.nodeIcon {
|
||||
display: flex;
|
||||
margin-right: var(--spacing-s);
|
||||
|
||||
& > :global(*) {
|
||||
min-width: 25px;
|
||||
max-width: 25px;
|
||||
}
|
||||
margin-right: var(--node-icon-margin-right, var(--spacing-s));
|
||||
}
|
||||
.name {
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: var(--font-size-s);
|
||||
font-weight: var(--node-creator-name-weight, var(--font-weight-bold));
|
||||
font-size: var(--node-creator-name-size, var(--font-size-s));
|
||||
line-height: 1.115rem;
|
||||
}
|
||||
.description {
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
<template #content>{{ nodeTypeName }}</template>
|
||||
<div v-if="type !== 'unknown'" :class="$style.icon">
|
||||
<img v-if="type === 'file'" :src="src" :class="$style.nodeIconImage" />
|
||||
<font-awesome-icon v-else :icon="name" :style="fontStyleData" />
|
||||
<font-awesome-icon v-else :icon="name" :class="$style.iconFa" :style="fontStyleData" />
|
||||
</div>
|
||||
<div v-else :class="$style.nodeIconPlaceholder">
|
||||
{{ nodeTypeName ? nodeTypeName.charAt(0) : '?' }}
|
||||
|
@ -127,6 +127,11 @@ export default defineComponent({
|
|||
justify-content: center;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
|
||||
svg {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
.nodeIconPlaceholder {
|
||||
text-align: center;
|
||||
|
|
|
@ -39,7 +39,7 @@
|
|||
"@fortawesome/fontawesome-svg-core": "^1.2.35",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.15.3",
|
||||
"@fortawesome/vue-fontawesome": "^2.0.2",
|
||||
"@fortawesome/vue-fontawesome": "^2.0.10",
|
||||
"@jsplumb/browser-ui": "^5.13.2",
|
||||
"@jsplumb/common": "^5.13.2",
|
||||
"@jsplumb/connector-bezier": "^5.13.2",
|
||||
|
@ -105,6 +105,7 @@
|
|||
"@vitejs/plugin-legacy": "^3.0.1",
|
||||
"@vitejs/plugin-vue2": "^2.2.0",
|
||||
"@vitest/coverage-c8": "^0.28.5",
|
||||
"@volar-plugins/eslint": "^0.0.4",
|
||||
"c8": "^7.12.0",
|
||||
"jshint": "^2.9.7",
|
||||
"miragejs": "^0.1.47",
|
||||
|
|
|
@ -25,7 +25,6 @@ import type {
|
|||
INodeCredentials,
|
||||
INodeListSearchItems,
|
||||
NodeParameterValueType,
|
||||
INodeActionTypeDescription,
|
||||
IDisplayOptions,
|
||||
IExecutionsSummary,
|
||||
FeatureFlags,
|
||||
|
@ -36,7 +35,11 @@ import type {
|
|||
WorkflowSettings,
|
||||
} from 'n8n-workflow';
|
||||
import type { SignInType } from './constants';
|
||||
import type { FAKE_DOOR_FEATURES, TRIGGER_NODE_FILTER, REGULAR_NODE_FILTER } from './constants';
|
||||
import type {
|
||||
FAKE_DOOR_FEATURES,
|
||||
TRIGGER_NODE_CREATOR_VIEW,
|
||||
REGULAR_NODE_CREATOR_VIEW,
|
||||
} from './constants';
|
||||
import type { BulkCommand, Undoable } from '@/models/history';
|
||||
import type { PartialBy } from '@/utils/typeHelpers';
|
||||
|
||||
|
@ -707,67 +710,85 @@ export interface ITimeoutHMS {
|
|||
|
||||
export type WorkflowTitleStatus = 'EXECUTING' | 'IDLE' | 'ERROR';
|
||||
|
||||
export interface ISubcategoryItemProps {
|
||||
subcategory: string;
|
||||
description: string;
|
||||
key?: string;
|
||||
iconType: string;
|
||||
export type ExtractActionKeys<T> = T extends SimplifiedNodeType ? T['name'] : never;
|
||||
|
||||
export type ActionsRecord<T extends SimplifiedNodeType[]> = {
|
||||
[K in ExtractActionKeys<T[number]>]: ActionTypeDescription[];
|
||||
};
|
||||
|
||||
export interface SimplifiedNodeType
|
||||
extends Pick<
|
||||
INodeTypeDescription,
|
||||
'displayName' | 'description' | 'name' | 'group' | 'icon' | 'iconUrl' | 'codex' | 'defaults'
|
||||
> {}
|
||||
export interface SubcategoryItemProps {
|
||||
description?: string;
|
||||
iconType?: string;
|
||||
icon?: string;
|
||||
title?: string;
|
||||
subcategory?: string;
|
||||
defaults?: INodeParameters;
|
||||
forceIncludeNodes?: string[];
|
||||
}
|
||||
export interface ViewItemProps {
|
||||
withTopBorder: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface INodeItemProps {
|
||||
subcategory: string;
|
||||
nodeType: INodeTypeDescription;
|
||||
export interface LabelItemProps {
|
||||
key: string;
|
||||
}
|
||||
export interface ActionTypeDescription extends SimplifiedNodeType {
|
||||
displayOptions?: IDisplayOptions;
|
||||
values?: IDataObject;
|
||||
actionKey: string;
|
||||
codex: {
|
||||
label: string;
|
||||
categories: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IActionItemProps {
|
||||
subcategory: string;
|
||||
nodeType: INodeActionTypeDescription;
|
||||
}
|
||||
|
||||
export interface ICategoryItemProps {
|
||||
expanded: boolean;
|
||||
category: string;
|
||||
export interface CategoryItemProps {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface CreateElementBase {
|
||||
uuid?: string;
|
||||
key: string;
|
||||
includedByTrigger?: boolean;
|
||||
includedByRegular?: boolean;
|
||||
}
|
||||
|
||||
export interface NodeCreateElement extends CreateElementBase {
|
||||
type: 'node';
|
||||
category?: string[];
|
||||
properties: INodeItemProps;
|
||||
subcategory: string;
|
||||
properties: SimplifiedNodeType;
|
||||
}
|
||||
|
||||
export interface CategoryCreateElement extends CreateElementBase {
|
||||
type: 'category';
|
||||
properties: ICategoryItemProps;
|
||||
subcategory: string;
|
||||
properties: CategoryItemProps;
|
||||
}
|
||||
|
||||
export interface SubcategoryCreateElement extends CreateElementBase {
|
||||
type: 'subcategory';
|
||||
properties: ISubcategoryItemProps;
|
||||
properties: SubcategoryItemProps;
|
||||
}
|
||||
export interface ViewCreateElement extends CreateElementBase {
|
||||
type: 'view';
|
||||
properties: ViewItemProps;
|
||||
}
|
||||
|
||||
export interface LabelCreateElement extends CreateElementBase {
|
||||
type: 'label';
|
||||
subcategory: string;
|
||||
properties: LabelItemProps;
|
||||
}
|
||||
|
||||
export interface ActionCreateElement extends CreateElementBase {
|
||||
type: 'action';
|
||||
category: string;
|
||||
properties: IActionItemProps;
|
||||
subcategory: string;
|
||||
properties: ActionTypeDescription;
|
||||
}
|
||||
|
||||
export type INodeCreateElement =
|
||||
|
@ -775,18 +796,12 @@ export type INodeCreateElement =
|
|||
| CategoryCreateElement
|
||||
| SubcategoryCreateElement
|
||||
| ViewCreateElement
|
||||
| LabelCreateElement
|
||||
| ActionCreateElement;
|
||||
|
||||
export interface ICategoriesWithNodes {
|
||||
[category: string]: {
|
||||
[subcategory: string]: {
|
||||
regularCount: number;
|
||||
triggerCount: number;
|
||||
nodes: INodeCreateElement[];
|
||||
};
|
||||
};
|
||||
export interface SubcategorizedNodeTypes {
|
||||
[subcategory: string]: INodeCreateElement[];
|
||||
}
|
||||
|
||||
export interface ITag {
|
||||
id: string;
|
||||
name: string;
|
||||
|
@ -1072,7 +1087,7 @@ export type IFakeDoorLocation =
|
|||
| 'credentialsModal'
|
||||
| 'workflowShareModal';
|
||||
|
||||
export type INodeFilterType = typeof REGULAR_NODE_FILTER | typeof TRIGGER_NODE_FILTER;
|
||||
export type NodeFilterType = typeof REGULAR_NODE_CREATOR_VIEW | typeof TRIGGER_NODE_CREATOR_VIEW;
|
||||
|
||||
export type NodeCreatorOpenSource =
|
||||
| ''
|
||||
|
@ -1087,8 +1102,8 @@ export type NodeCreatorOpenSource =
|
|||
export interface INodeCreatorState {
|
||||
itemsFilter: string;
|
||||
showScrim: boolean;
|
||||
rootViewHistory: INodeFilterType[];
|
||||
selectedView: INodeFilterType;
|
||||
rootViewHistory: NodeFilterType[];
|
||||
selectedView: NodeFilterType;
|
||||
openSource: NodeCreatorOpenSource;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,690 +0,0 @@
|
|||
<template>
|
||||
<transition :name="`panel-slide-${state.transitionDirection}`">
|
||||
<div
|
||||
ref="mainPanelContainer"
|
||||
tabindex="0"
|
||||
data-test-id="categorized-items"
|
||||
:class="$style.categorizedItems"
|
||||
:key="`${activeSubcategoryTitle + selectedViewType}_transition`"
|
||||
@keydown.capture="nodeFilterKeyDown"
|
||||
>
|
||||
<div
|
||||
:class="{
|
||||
[$style.header]: true,
|
||||
[$style.headerWithBackground]: activeSubcategory,
|
||||
}"
|
||||
data-test-id="categorized-items-subcategory"
|
||||
>
|
||||
<button
|
||||
:class="$style.backButton"
|
||||
@click="onBackButton"
|
||||
v-if="isViewNavigated || activeSubcategory"
|
||||
>
|
||||
<font-awesome-icon :class="$style.subcategoryBackIcon" icon="arrow-left" size="2x" />
|
||||
</button>
|
||||
<div v-if="isRootView && $slots.header">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<template v-if="activeSubcategory">
|
||||
<n8n-node-icon
|
||||
:class="$style.nodeIcon"
|
||||
v-if="showSubcategoryIcon && activeSubcategory.properties.icon"
|
||||
:type="activeSubcategory.properties.iconType || 'unknown'"
|
||||
:src="activeSubcategory.properties.icon"
|
||||
:name="activeSubcategory.properties.icon"
|
||||
:color="activeSubcategory.properties.color"
|
||||
:circle="false"
|
||||
:showTooltip="false"
|
||||
:size="16"
|
||||
/>
|
||||
<span v-text="activeSubcategoryTitle" />
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="isRootView && $slots.description"
|
||||
:class="{
|
||||
[$style.description]: true,
|
||||
[$style.descriptionOffset]: isViewNavigated || activeSubcategory,
|
||||
}"
|
||||
>
|
||||
<slot name="description" />
|
||||
</div>
|
||||
|
||||
<search-bar
|
||||
v-if="alwaysShowSearch || isSearchVisible"
|
||||
:key="nodeCreatorStore.selectedView"
|
||||
:value="searchFilter"
|
||||
:placeholder="
|
||||
searchPlaceholder
|
||||
? searchPlaceholder
|
||||
: $locale.baseText('nodeCreator.searchBar.searchNodes')
|
||||
"
|
||||
@input="onNodeFilterChange"
|
||||
/>
|
||||
|
||||
<div :class="$style.scrollable" ref="scrollableContainer">
|
||||
<item-iterator
|
||||
:elements="searchFilter.length === 0 ? renderedItems : mergedFilteredNodes"
|
||||
:activeIndex="activeSubcategory ? activeSubcategoryIndex : activeIndex"
|
||||
:with-actions-getter="withActionsGetter"
|
||||
:with-description-getter="withDescriptionGetter"
|
||||
:lazyRender="true"
|
||||
@selected="selected"
|
||||
@actionsOpen="$listeners.actionsOpen"
|
||||
@nodeTypeSelected="$listeners.nodeTypeSelected"
|
||||
/>
|
||||
<div v-if="searchFilter.length > 0 && mergedFilteredNodes.length === 0">
|
||||
<slot name="noResults" />
|
||||
</div>
|
||||
<div :class="$style.footer" v-else-if="$slots.footer">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
computed,
|
||||
reactive,
|
||||
watch,
|
||||
getCurrentInstance,
|
||||
toRefs,
|
||||
ref,
|
||||
onUnmounted,
|
||||
nextTick,
|
||||
} from 'vue';
|
||||
import { camelCase } from 'lodash-es';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import ItemIterator from './ItemIterator.vue';
|
||||
import SearchBar from './SearchBar.vue';
|
||||
import type {
|
||||
INodeCreateElement,
|
||||
ISubcategoryItemProps,
|
||||
ICategoryItemProps,
|
||||
SubcategoryCreateElement,
|
||||
NodeCreateElement,
|
||||
CategoryCreateElement,
|
||||
INodeItemProps,
|
||||
} from '@/Interface';
|
||||
import type { BaseTextKey } from '@/plugins/i18n';
|
||||
import { sublimeSearch, matchesNodeType, matchesSelectType } from '@/utils';
|
||||
import { useWorkflowsStore } from '@/stores/workflows';
|
||||
import { useRootStore } from '@/stores/n8nRootStore';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator';
|
||||
import { useExternalHooks } from '@/composables';
|
||||
|
||||
export interface Props {
|
||||
showSubcategoryIcon?: boolean;
|
||||
alwaysShowSearch?: boolean;
|
||||
hideOtherCategoryItems?: boolean;
|
||||
|
||||
lazyRender?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
withActionsGetter?: (element: NodeCreateElement) => boolean;
|
||||
withDescriptionGetter?: (element: NodeCreateElement) => boolean;
|
||||
searchItems?: INodeCreateElement[];
|
||||
firstLevelItems?: INodeCreateElement[];
|
||||
categorizedItems: INodeCreateElement[];
|
||||
subcategoryOverride?: SubcategoryCreateElement | undefined;
|
||||
allItems?: INodeCreateElement[];
|
||||
}
|
||||
|
||||
const OTHER_RESULT_CATEGORY = 'searchAll';
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
allItems: () => [],
|
||||
searchItems: () => [],
|
||||
firstLevelItems: () => [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'subcategoryClose', value: INodeCreateElement[]): void;
|
||||
(event: 'onSubcategorySelected', value: INodeCreateElement): void;
|
||||
(event: 'nodeTypeSelected', value: string[]): void;
|
||||
|
||||
(event: 'actionSelected', value: INodeCreateElement): void;
|
||||
(event: 'actionsOpen', value: INodeTypeDescription): void;
|
||||
}>();
|
||||
|
||||
const instance = getCurrentInstance();
|
||||
const externalHooks = useExternalHooks();
|
||||
|
||||
const { defaultLocale } = useRootStore();
|
||||
const { workflowId } = useWorkflowsStore();
|
||||
const nodeCreatorStore = useNodeCreatorStore();
|
||||
|
||||
const state = reactive({
|
||||
activeCategories: [] as string[],
|
||||
// Keep track of activated subcategories so we could traverse back more than one level
|
||||
activeSubcategoryHistory: [] as Array<{
|
||||
scrollPosition: number;
|
||||
subcategory: INodeCreateElement;
|
||||
activeIndex: number;
|
||||
filter: string;
|
||||
}>,
|
||||
activeIndex: 0,
|
||||
activeSubcategoryIndex: 0,
|
||||
mainPanelContainer: null as HTMLElement | null,
|
||||
transitionDirection: 'in',
|
||||
});
|
||||
const searchBar = ref<InstanceType<typeof SearchBar>>();
|
||||
const scrollableContainer = ref<InstanceType<typeof HTMLElement>>();
|
||||
|
||||
const activeSubcategory = computed<INodeCreateElement | null>(() => {
|
||||
return (
|
||||
state.activeSubcategoryHistory[state.activeSubcategoryHistory.length - 1]?.subcategory || null
|
||||
);
|
||||
});
|
||||
|
||||
const categoriesKeys = computed(() =>
|
||||
props.categorizedItems.filter((item) => item.type === 'category').map((item) => item.key),
|
||||
);
|
||||
const activeSubcategoryTitle = computed<string>(() => {
|
||||
if (!activeSubcategory.value || !activeSubcategory.value.properties) return '';
|
||||
|
||||
const subcategory = (activeSubcategory.value.properties as ISubcategoryItemProps).subcategory;
|
||||
const subcategoryName = camelCase(subcategory);
|
||||
|
||||
const titleLocaleKey = `nodeCreator.subcategoryTitles.${subcategoryName}` as BaseTextKey;
|
||||
const nameLocaleKey = `nodeCreator.subcategoryNames.${subcategoryName}` as BaseTextKey;
|
||||
|
||||
const titleLocale = instance?.proxy?.$locale.baseText(titleLocaleKey) as string;
|
||||
const nameLocale = instance?.proxy?.$locale.baseText(nameLocaleKey) as string;
|
||||
// If resolved title locale is same as the locale key it means it doesn't exist
|
||||
// so we fallback to the subcategoryName
|
||||
if (titleLocale === titleLocaleKey)
|
||||
return nameLocale === nameLocaleKey ? subcategory : nameLocale;
|
||||
|
||||
return titleLocale;
|
||||
});
|
||||
|
||||
const searchFilter = computed<string>(() => nodeCreatorStore.itemsFilter.toLowerCase().trim());
|
||||
|
||||
const selectedViewType = computed(() => nodeCreatorStore.selectedView);
|
||||
|
||||
const filteredNodeTypes = computed<INodeCreateElement[]>(() => {
|
||||
const filter = searchFilter.value;
|
||||
|
||||
let returnItems: INodeCreateElement[] = [];
|
||||
if (defaultLocale !== 'en') {
|
||||
returnItems = props.searchItems.filter((el: INodeCreateElement) => {
|
||||
return (
|
||||
filter &&
|
||||
matchesSelectType(el, nodeCreatorStore.selectedView) &&
|
||||
matchesNodeType(el, filter)
|
||||
);
|
||||
});
|
||||
} else {
|
||||
const matchingNodes =
|
||||
subcategorizedItems.value.length > 0 ? subcategorizedItems.value : props.searchItems;
|
||||
|
||||
returnItems = getFilteredNodes(matchingNodes);
|
||||
}
|
||||
return returnItems;
|
||||
});
|
||||
|
||||
const isViewNavigated = computed(() => nodeCreatorStore.rootViewHistory.length > 1);
|
||||
|
||||
const globalFilteredNodeTypes = computed<INodeCreateElement[]>(() => {
|
||||
const result = getFilteredNodes(props.allItems).reduce((acc, item) => {
|
||||
if (acc.find((el) => trimTriggerNodeName(el.key) === trimTriggerNodeName(item.key))) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
return [...acc, item];
|
||||
}, [] as INodeCreateElement[]);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const otherCategoryNodes = computed(() => {
|
||||
const nodes = [];
|
||||
|
||||
// Get diff of nodes between `globalFilteredNodeTypes` and `filteredNodeTypes`
|
||||
for (const node of globalFilteredNodeTypes.value) {
|
||||
const isNodeInFiltered = filteredNodeTypes.value.find(
|
||||
(el) => trimTriggerNodeName(el.key) === trimTriggerNodeName(node.key),
|
||||
);
|
||||
|
||||
if (!isNodeInFiltered) nodes.push(node);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
});
|
||||
|
||||
const mergedFilteredNodes = computed<INodeCreateElement[]>(() => {
|
||||
if (props.hideOtherCategoryItems) return filteredNodeTypes.value;
|
||||
|
||||
const isExpanded = state.activeCategories.includes(OTHER_RESULT_CATEGORY);
|
||||
const searchCategory: CategoryCreateElement = {
|
||||
type: 'category',
|
||||
key: OTHER_RESULT_CATEGORY,
|
||||
properties: {
|
||||
category: OTHER_RESULT_CATEGORY,
|
||||
name: `Results in other categories (${otherCategoryNodes.value.length})`,
|
||||
expanded: isExpanded,
|
||||
},
|
||||
};
|
||||
const nodeTypes = [...filteredNodeTypes.value];
|
||||
|
||||
if (otherCategoryNodes.value.length > 0) {
|
||||
nodeTypes.push(searchCategory);
|
||||
}
|
||||
if (isExpanded) {
|
||||
nodeTypes.push(...otherCategoryNodes.value);
|
||||
}
|
||||
|
||||
return nodeTypes;
|
||||
});
|
||||
|
||||
const isRootView = computed(() => activeSubcategory.value === null);
|
||||
|
||||
const subcategorizedItems = computed<INodeCreateElement[]>(() => {
|
||||
if (!activeSubcategory.value) return [];
|
||||
|
||||
const items = props.searchItems.filter((el: INodeCreateElement) => {
|
||||
if (!activeSubcategory.value) return false;
|
||||
|
||||
const subcategories = Object.values(
|
||||
(el.properties as INodeItemProps).nodeType.codex?.subcategories || {},
|
||||
).flat();
|
||||
return subcategories.includes(activeSubcategory.value.key);
|
||||
});
|
||||
|
||||
return items.filter((el: INodeCreateElement) =>
|
||||
matchesSelectType(el, nodeCreatorStore.selectedView),
|
||||
);
|
||||
});
|
||||
|
||||
const filteredCategorizedItems = computed<INodeCreateElement[]>(() => {
|
||||
let categoriesCount = 0;
|
||||
const reducedItems = props.categorizedItems.reduce(
|
||||
(acc: INodeCreateElement[], el: INodeCreateElement) => {
|
||||
if (el.type === 'category') {
|
||||
el.properties.expanded = state.activeCategories.includes(el.key);
|
||||
categoriesCount++;
|
||||
return [...acc, el];
|
||||
}
|
||||
|
||||
if (el.type === 'action' && state.activeCategories.includes(el.category)) {
|
||||
return [...acc, el];
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// If there is only one category we don't show it
|
||||
if (categoriesCount <= 1)
|
||||
return reducedItems.filter((el: INodeCreateElement) => el.type !== 'category');
|
||||
|
||||
return reducedItems;
|
||||
});
|
||||
|
||||
const renderedItems = computed<INodeCreateElement[]>(() => {
|
||||
if (props.firstLevelItems.length > 0 && activeSubcategory.value === null)
|
||||
return props.firstLevelItems;
|
||||
|
||||
// If active subcategory is * then we show all items
|
||||
if (activeSubcategory.value?.key === '*') return props.searchItems;
|
||||
// Otherwise we show only items that match the subcategory
|
||||
if (subcategorizedItems.value.length > 0) return subcategorizedItems.value;
|
||||
|
||||
// Finally if none of the above is true we show the categorized items
|
||||
return filteredCategorizedItems.value;
|
||||
});
|
||||
|
||||
const isSearchVisible = computed<boolean>(() => {
|
||||
if (subcategorizedItems.value.length === 0) return true;
|
||||
|
||||
return subcategorizedItems.value.length > 9;
|
||||
});
|
||||
|
||||
// Methods
|
||||
function trimTriggerNodeName(nodeName: string) {
|
||||
return nodeName.toLowerCase().replace('trigger', '');
|
||||
}
|
||||
function getFilteredNodes(items: INodeCreateElement[]) {
|
||||
// In order to support the old search we need to remove the 'trigger' part
|
||||
const trimmedFilter = searchFilter.value.toLowerCase().replace('trigger', '');
|
||||
return (
|
||||
sublimeSearch<INodeCreateElement>(trimmedFilter, items, [
|
||||
{ key: 'properties.nodeType.displayName', weight: 2 },
|
||||
{ key: 'properties.nodeType.codex.alias', weight: 1 },
|
||||
]) || []
|
||||
).map(({ item }) => item);
|
||||
}
|
||||
function getScrollTop() {
|
||||
return scrollableContainer.value?.scrollTop || 0;
|
||||
}
|
||||
function setScrollTop(scrollTop: number) {
|
||||
if (scrollableContainer.value) {
|
||||
scrollableContainer.value.scrollTop = scrollTop;
|
||||
}
|
||||
}
|
||||
function onNodeFilterChange(filter: string) {
|
||||
nodeCreatorStore.setFilter(filter);
|
||||
}
|
||||
|
||||
function nodeFilterKeyDown(e: KeyboardEvent) {
|
||||
// We only want to propagate 'Escape' as it closes the node-creator and
|
||||
// 'Tab' which toggles it
|
||||
if (!['Escape', 'Tab'].includes(e.key)) e.stopPropagation();
|
||||
|
||||
// Prevent cursors position change
|
||||
if (['ArrowUp', 'ArrowDown'].includes(e.key)) e.preventDefault();
|
||||
|
||||
if (activeSubcategory.value) {
|
||||
const activeList =
|
||||
searchFilter.value.length > 0 ? filteredNodeTypes.value : renderedItems.value;
|
||||
const activeNodeType = activeList[state.activeSubcategoryIndex];
|
||||
|
||||
if (e.key === 'ArrowDown' && activeSubcategory.value) {
|
||||
state.activeSubcategoryIndex++;
|
||||
state.activeSubcategoryIndex = Math.min(state.activeSubcategoryIndex, activeList.length - 1);
|
||||
} else if (e.key === 'ArrowUp' && activeSubcategory.value) {
|
||||
state.activeSubcategoryIndex--;
|
||||
state.activeSubcategoryIndex = Math.max(state.activeSubcategoryIndex, 0);
|
||||
} else if (e.key === 'Enter') {
|
||||
selected(activeNodeType);
|
||||
} else if (
|
||||
e.key === 'ArrowLeft' &&
|
||||
activeNodeType?.type === 'category' &&
|
||||
(activeNodeType.properties as ICategoryItemProps).expanded
|
||||
) {
|
||||
selected(activeNodeType);
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
onBackButton();
|
||||
} else if (
|
||||
e.key === 'ArrowRight' &&
|
||||
activeNodeType?.type === 'category' &&
|
||||
!(activeNodeType.properties as ICategoryItemProps).expanded
|
||||
) {
|
||||
selected(activeNodeType);
|
||||
} else if (e.key === 'ArrowRight' && ['node', 'action'].includes(activeNodeType?.type)) {
|
||||
selected(activeNodeType);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const activeList = searchFilter.value.length > 0 ? filteredNodeTypes.value : renderedItems.value;
|
||||
const activeNodeType = activeList[state.activeIndex];
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
state.activeIndex++;
|
||||
// Make sure that we stop at the last nodeType
|
||||
state.activeIndex = Math.min(state.activeIndex, activeList.length - 1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
state.activeIndex--;
|
||||
// Make sure that we do not get before the first nodeType
|
||||
state.activeIndex = Math.max(state.activeIndex, 0);
|
||||
} else if (e.key === 'Enter' && activeNodeType) {
|
||||
selected(activeNodeType);
|
||||
} else if (e.key === 'ArrowRight' && activeNodeType?.type === 'subcategory') {
|
||||
selected(activeNodeType);
|
||||
} else if (e.key === 'ArrowRight' && activeNodeType?.type === 'view') {
|
||||
selected(activeNodeType);
|
||||
} else if (
|
||||
e.key === 'ArrowRight' &&
|
||||
activeNodeType?.type === 'category' &&
|
||||
!(activeNodeType.properties as ICategoryItemProps).expanded
|
||||
) {
|
||||
selected(activeNodeType);
|
||||
} else if (
|
||||
e.key === 'ArrowLeft' &&
|
||||
activeNodeType?.type === 'category' &&
|
||||
(activeNodeType.properties as ICategoryItemProps).expanded
|
||||
) {
|
||||
selected(activeNodeType);
|
||||
} else if (e.key === 'ArrowLeft' && isViewNavigated.value) {
|
||||
onBackButton();
|
||||
} else if (e.key === 'ArrowRight' && ['node', 'action'].includes(activeNodeType?.type)) {
|
||||
selected(activeNodeType);
|
||||
}
|
||||
}
|
||||
function selected(element: INodeCreateElement) {
|
||||
const typeHandler = {
|
||||
category: () => onCategorySelected(element),
|
||||
subcategory: () => onSubcategorySelected(element),
|
||||
node: () => onNodeSelected(element as NodeCreateElement),
|
||||
action: () => onActionSelected(element),
|
||||
view: () => onViewSelected(element),
|
||||
};
|
||||
|
||||
typeHandler[element.type]();
|
||||
}
|
||||
function onViewSelected(view: Record<string, any>) {
|
||||
state.transitionDirection = 'in';
|
||||
state.activeIndex = 0;
|
||||
nodeCreatorStore.setSelectedView(view.key);
|
||||
nodeCreatorStore.setFilter('');
|
||||
}
|
||||
|
||||
function onNodeSelected(element: NodeCreateElement) {
|
||||
const hasActions = (element.properties.nodeType?.actions?.length || 0) > 0;
|
||||
if (props.withActionsGetter && props.withActionsGetter(element) === true && hasActions) {
|
||||
emit('actionsOpen', element.properties.nodeType);
|
||||
return;
|
||||
}
|
||||
emit('nodeTypeSelected', [element.key]);
|
||||
}
|
||||
|
||||
function onCategorySelected(element: CategoryCreateElement) {
|
||||
const categoryKey = element.properties.category;
|
||||
if (state.activeCategories.includes(categoryKey)) {
|
||||
state.activeCategories = state.activeCategories.filter(
|
||||
(active: string) => active !== categoryKey,
|
||||
);
|
||||
} else {
|
||||
state.activeCategories = [...state.activeCategories, categoryKey];
|
||||
instance?.proxy.$telemetry.trackNodesPanel('nodeCreateList.onCategoryExpanded', {
|
||||
category_name: categoryKey,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
}
|
||||
}
|
||||
function onActionSelected(element: INodeCreateElement) {
|
||||
emit('actionSelected', element);
|
||||
}
|
||||
|
||||
function onSubcategorySelected(selected: INodeCreateElement, track = true) {
|
||||
state.transitionDirection = 'in';
|
||||
// Store the current subcategory UI details in the state
|
||||
// so we could revert it when the user closes the subcategory
|
||||
state.activeSubcategoryHistory.push({
|
||||
subcategory: selected,
|
||||
activeIndex: state.activeSubcategoryIndex,
|
||||
scrollPosition: getScrollTop(),
|
||||
filter: nodeCreatorStore.itemsFilter,
|
||||
});
|
||||
nodeCreatorStore.setFilter('');
|
||||
emit('onSubcategorySelected', selected);
|
||||
state.activeSubcategoryIndex = 0;
|
||||
|
||||
if (track) {
|
||||
instance?.proxy.$telemetry.trackNodesPanel('nodeCreateList.onSubcategorySelected', {
|
||||
selected,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function onBackButton() {
|
||||
state.transitionDirection = 'out';
|
||||
// Switching views
|
||||
if (isRootView.value && isViewNavigated.value) {
|
||||
nodeCreatorStore.closeCurrentView();
|
||||
return;
|
||||
}
|
||||
|
||||
const poppedSubCategory = state.activeSubcategoryHistory.pop();
|
||||
onNodeFilterChange(poppedSubCategory?.filter || '');
|
||||
await nextTick();
|
||||
emit(
|
||||
'subcategoryClose',
|
||||
state.activeSubcategoryHistory.map((el) => el.subcategory),
|
||||
);
|
||||
await nextTick();
|
||||
setScrollTop(poppedSubCategory?.scrollPosition || 0);
|
||||
state.activeSubcategoryIndex = poppedSubCategory?.activeIndex || 0;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.subcategoryOverride,
|
||||
(subcategory) => {
|
||||
if (subcategory) onSubcategorySelected(subcategory, false);
|
||||
},
|
||||
);
|
||||
watch(
|
||||
() => props.categorizedItems,
|
||||
() => {
|
||||
state.activeCategories = [...categoriesKeys.value, OTHER_RESULT_CATEGORY];
|
||||
},
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
nodeCreatorStore.setFilter('');
|
||||
});
|
||||
|
||||
watch(filteredNodeTypes, (returnItems) => {
|
||||
externalHooks.run('nodeCreateList.filteredNodeTypesComputed', {
|
||||
nodeFilter: nodeCreatorStore.itemsFilter,
|
||||
result: returnItems,
|
||||
selectedType: nodeCreatorStore.selectedView,
|
||||
});
|
||||
});
|
||||
|
||||
watch(isSearchVisible, (isVisible) => {
|
||||
if (isVisible === false) {
|
||||
// Focus the root container when search is hidden to make sure
|
||||
// keyboard navigation still works
|
||||
nextTick(() => state.mainPanelContainer?.focus());
|
||||
}
|
||||
});
|
||||
watch(
|
||||
() => nodeCreatorStore.itemsFilter,
|
||||
(newValue, oldValue) => {
|
||||
// Reset the index whenver the filter-value changes
|
||||
state.activeIndex = 0;
|
||||
state.activeSubcategoryIndex = 0;
|
||||
externalHooks.run('nodeCreateList.nodeFilterChanged', {
|
||||
oldValue,
|
||||
newValue,
|
||||
selectedType: nodeCreatorStore.selectedView,
|
||||
filteredNodes: filteredNodeTypes.value,
|
||||
});
|
||||
instance?.proxy.$telemetry.trackNodesPanel('nodeCreateList.nodeFilterChanged', {
|
||||
oldValue,
|
||||
newValue,
|
||||
selectedType: nodeCreatorStore.selectedView,
|
||||
filteredNodes: filteredNodeTypes.value,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const { activeSubcategoryIndex, activeIndex, mainPanelContainer } = toRefs(state);
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
:global(.panel-slide-in-leave-active),
|
||||
:global(.panel-slide-in-enter-active),
|
||||
:global(.panel-slide-out-leave-active),
|
||||
:global(.panel-slide-out-enter-active) {
|
||||
transition: transform 300ms ease;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
:global(.panel-slide-out-enter),
|
||||
:global(.panel-slide-in-leave-to) {
|
||||
transform: translateX(0);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
:global(.panel-slide-out-leave-to),
|
||||
:global(.panel-slide-in-enter) {
|
||||
transform: translateX(100%);
|
||||
// Make sure the leaving panel stays on top
|
||||
// for the slide-out panel effect
|
||||
z-index: 1;
|
||||
}
|
||||
.nodeIcon {
|
||||
--node-icon-size: 16px;
|
||||
margin-right: var(--spacing-s);
|
||||
}
|
||||
.categorizedItems {
|
||||
background: white;
|
||||
height: 100%;
|
||||
background-color: $node-creator-background-color;
|
||||
&:before {
|
||||
box-sizing: border-box;
|
||||
content: '';
|
||||
border-left: 1px solid $node-creator-border-color;
|
||||
width: 1px;
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
.footer {
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-text-base);
|
||||
margin: 0 var(--spacing-xs) 0;
|
||||
padding: var(--spacing-4xs) 0;
|
||||
line-height: var(--font-line-height-regular);
|
||||
border-top: 1px solid #dbdfe7;
|
||||
z-index: 1;
|
||||
margin-top: -1px;
|
||||
}
|
||||
.header {
|
||||
font-size: var(--font-size-l);
|
||||
font-weight: var(--font-weight-bold);
|
||||
line-height: var(--font-line-height-compact);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--spacing-s) var(--spacing-s) var(--spacing-2xs);
|
||||
|
||||
&.headerWithBackground {
|
||||
border-bottom: $node-creator-border-color solid 1px;
|
||||
height: 50px;
|
||||
background-color: $node-creator-subcategory-panel-header-bacground-color;
|
||||
padding: var(--spacing-s) var(--spacing-s);
|
||||
}
|
||||
}
|
||||
.description {
|
||||
padding: 0 var(--spacing-s) var(--spacing-2xs) var(--spacing-s);
|
||||
margin-top: -4px;
|
||||
}
|
||||
.descriptionOffset {
|
||||
margin-left: calc(var(--spacing-xl) + var(--spacing-4xs));
|
||||
}
|
||||
.backButton {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0 var(--spacing-xs) 0 0;
|
||||
}
|
||||
|
||||
.subcategoryBackIcon {
|
||||
color: $node-creator-arrow-color;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.scrollable {
|
||||
height: calc(100% - 120px);
|
||||
padding-top: 1px;
|
||||
padding-bottom: var(--spacing-xl);
|
||||
overflow-y: auto;
|
||||
overflow-x: visible;
|
||||
|
||||
scrollbar-width: none; /* Firefox 64 */
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,41 +0,0 @@
|
|||
<template>
|
||||
<div :class="$style.category">
|
||||
<span :class="$style.name" v-text="item.name" />
|
||||
<font-awesome-icon v-if="item.expanded" icon="chevron-down" :class="$style.arrow" />
|
||||
<font-awesome-icon :class="$style.arrow" icon="chevron-up" v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { ICategoryItemProps } from '@/Interface';
|
||||
|
||||
export interface Props {
|
||||
item: ICategoryItemProps;
|
||||
}
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.category {
|
||||
font-size: 11px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
letter-spacing: 1px;
|
||||
line-height: 11px;
|
||||
padding: 10px 0;
|
||||
margin: 0 var(--spacing-xs);
|
||||
border-bottom: 1px solid $node-creator-border-color;
|
||||
display: flex;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: var(--font-size-2xs);
|
||||
width: 12px;
|
||||
color: $node-creator-arrow-color;
|
||||
}
|
||||
</style>
|
|
@ -1,210 +0,0 @@
|
|||
<template>
|
||||
<div
|
||||
:class="$style.itemIterator"
|
||||
name="accordion"
|
||||
@before-enter="beforeEnter"
|
||||
@enter="enter"
|
||||
@before-leave="beforeLeave"
|
||||
@leave="leave"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in renderedItems"
|
||||
:key="`${item.key}-${index}`"
|
||||
data-test-id="item-iterator-item"
|
||||
:class="{
|
||||
clickable: !disabled,
|
||||
[$style[item.type]]: true,
|
||||
[$style.active]: activeIndex === index && !disabled,
|
||||
[$style.iteratorItem]: true,
|
||||
}"
|
||||
ref="iteratorItems"
|
||||
@click="wrappedEmit('selected', item)"
|
||||
>
|
||||
<category-item v-if="item.type === 'category'" :item="item.properties" />
|
||||
|
||||
<subcategory-item v-else-if="item.type === 'subcategory'" :item="item.properties" />
|
||||
|
||||
<node-item
|
||||
v-else-if="item.type === 'node'"
|
||||
:nodeType="item.properties.nodeType"
|
||||
:allow-actions="withActionsGetter && withActionsGetter(item)"
|
||||
:allow-description="withDescriptionGetter && withDescriptionGetter(item)"
|
||||
@dragstart="wrappedEmit('dragstart', item, $event)"
|
||||
@dragend="wrappedEmit('dragend', item, $event)"
|
||||
@nodeTypeSelected="$listeners.nodeTypeSelected"
|
||||
@actionsOpen="$listeners.actionsOpen"
|
||||
/>
|
||||
|
||||
<action-item
|
||||
v-else-if="item.type === 'action'"
|
||||
:nodeType="item.properties.nodeType"
|
||||
:action="item.properties.nodeType"
|
||||
@dragstart="wrappedEmit('dragstart', item, $event)"
|
||||
@dragend="wrappedEmit('dragend', item, $event)"
|
||||
/>
|
||||
|
||||
<view-item v-else-if="item.type === 'view'" :view="item.properties" />
|
||||
</div>
|
||||
<aside
|
||||
v-for="item in elements.length"
|
||||
v-show="renderedItems.length < item"
|
||||
:key="item"
|
||||
:class="$style.loadingItem"
|
||||
>
|
||||
<n8n-loading :loading="true" :rows="1" variant="p" />
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { INodeCreateElement, NodeCreateElement } from '@/Interface';
|
||||
import NodeItem from './NodeItem.vue';
|
||||
import SubcategoryItem from './SubcategoryItem.vue';
|
||||
import CategoryItem from './CategoryItem.vue';
|
||||
import ActionItem from './ActionItem.vue';
|
||||
import ViewItem from './ViewItem.vue';
|
||||
import { reactive, toRefs, onMounted, watch, onUnmounted, ref } from 'vue';
|
||||
|
||||
export interface Props {
|
||||
elements: INodeCreateElement[];
|
||||
activeIndex?: number;
|
||||
disabled?: boolean;
|
||||
lazyRender?: boolean;
|
||||
withActionsGetter?: (element: NodeCreateElement) => boolean;
|
||||
withDescriptionGetter?: (element: NodeCreateElement) => boolean;
|
||||
enableGlobalCategoriesCounter?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
elements: () => [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'selected', element: INodeCreateElement, $e?: Event): void;
|
||||
(event: 'dragstart', element: INodeCreateElement, $e: Event): void;
|
||||
(event: 'dragend', element: INodeCreateElement, $e: Event): void;
|
||||
}>();
|
||||
|
||||
const state = reactive({
|
||||
renderedItems: [] as INodeCreateElement[],
|
||||
renderAnimationRequest: 0,
|
||||
});
|
||||
const iteratorItems = ref<HTMLElement[]>([]);
|
||||
|
||||
function wrappedEmit(
|
||||
event: 'selected' | 'dragstart' | 'dragend',
|
||||
element: INodeCreateElement,
|
||||
$e?: Event,
|
||||
) {
|
||||
if (props.disabled) return;
|
||||
|
||||
emit((event as 'selected') || 'dragstart' || 'dragend', element, $e);
|
||||
}
|
||||
|
||||
// Lazy render large items lists to prevent the browser from freezing
|
||||
// when loading many items.
|
||||
function renderItems() {
|
||||
if (props.elements.length <= 20 || props.lazyRender === false) {
|
||||
state.renderedItems = props.elements;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.renderedItems.length < props.elements.length) {
|
||||
state.renderedItems.push(
|
||||
...props.elements.slice(state.renderedItems.length, state.renderedItems.length + 10),
|
||||
);
|
||||
state.renderAnimationRequest = window.requestAnimationFrame(renderItems);
|
||||
}
|
||||
}
|
||||
|
||||
function beforeEnter(el: HTMLElement) {
|
||||
el.style.height = '0';
|
||||
}
|
||||
|
||||
function enter(el: HTMLElement) {
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}
|
||||
|
||||
function beforeLeave(el: HTMLElement) {
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}
|
||||
|
||||
function leave(el: HTMLElement) {
|
||||
el.style.height = '0';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
renderItems();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.cancelAnimationFrame(state.renderAnimationRequest);
|
||||
state.renderedItems = [];
|
||||
});
|
||||
|
||||
// Make sure the active item is always visible
|
||||
// scroll if needed
|
||||
watch(
|
||||
() => props.activeIndex,
|
||||
async () => {
|
||||
if (props.activeIndex === undefined) return;
|
||||
iteratorItems.value[props.activeIndex]?.scrollIntoView({ block: 'nearest' });
|
||||
},
|
||||
);
|
||||
|
||||
// Trigger elements re-render when they change
|
||||
watch(
|
||||
() => props.elements,
|
||||
async () => {
|
||||
window.cancelAnimationFrame(state.renderAnimationRequest);
|
||||
state.renderedItems = [];
|
||||
renderItems();
|
||||
},
|
||||
);
|
||||
|
||||
const { renderedItems } = toRefs(state);
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.loadingItem {
|
||||
height: 48px;
|
||||
margin: 0 var(--search-margin, var(--spacing-s));
|
||||
}
|
||||
.iteratorItem {
|
||||
// Make sure border is fully visible
|
||||
margin-left: 1px;
|
||||
position: relative;
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
&:hover::before {
|
||||
border-color: $node-creator-item-hover-border-color;
|
||||
}
|
||||
|
||||
&.active::before {
|
||||
border-color: $color-primary !important;
|
||||
}
|
||||
|
||||
&.category.singleCategory {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.itemIterator {
|
||||
> *:last-child {
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
}
|
||||
.action {
|
||||
&:last-of-type {
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
}
|
||||
.node + .category {
|
||||
margin-top: var(--spacing-s);
|
||||
}
|
||||
</style>
|
|
@ -1,13 +1,12 @@
|
|||
<template>
|
||||
<n8n-node-creator-node
|
||||
:key="`${action.actionKey}_${action.displayName}`"
|
||||
@click="onActionClick(action)"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
draggable
|
||||
:class="$style.action"
|
||||
:title="action.displayName"
|
||||
:isTrigger="isTriggerAction(action)"
|
||||
data-keyboard-nav="true"
|
||||
>
|
||||
<template #dragContent>
|
||||
<div :class="$style.draggableDataTransfer" ref="draggableDataTransfer" />
|
||||
|
@ -23,22 +22,27 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { reactive, computed, toRefs, getCurrentInstance } from 'vue';
|
||||
import type { INodeTypeDescription, INodeActionTypeDescription } from 'n8n-workflow';
|
||||
import type { ActionTypeDescription, SimplifiedNodeType } from '@/Interface';
|
||||
import { WEBHOOK_NODE_TYPE } from '@/constants';
|
||||
|
||||
import { getNewNodePosition, NODE_SIZE } from '@/utils/nodeViewUtils';
|
||||
import type { IUpdateInformation } from '@/Interface';
|
||||
import NodeIcon from '@/components/NodeIcon.vue';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator';
|
||||
|
||||
import { useViewStacks } from '../composables/useViewStacks';
|
||||
import { useActions } from '../composables/useActions';
|
||||
|
||||
export interface Props {
|
||||
nodeType: INodeTypeDescription;
|
||||
action: INodeActionTypeDescription;
|
||||
nodeType: SimplifiedNodeType;
|
||||
action: ActionTypeDescription;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const instance = getCurrentInstance();
|
||||
const telemetry = instance?.proxy.$telemetry;
|
||||
const { getActionData, getNodeTypesWithManualTrigger, setAddedNodeActionParameters } =
|
||||
useNodeCreatorStore();
|
||||
|
||||
const { getActionData, getNodeTypesWithManualTrigger, setAddedNodeActionParameters } = useActions();
|
||||
const { activeViewStack } = useViewStacks();
|
||||
|
||||
const state = reactive({
|
||||
dragging: false,
|
||||
|
@ -49,11 +53,6 @@ const state = reactive({
|
|||
storeWatcher: null as Function | null,
|
||||
draggableDataTransfer: null as Element | null,
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
(event: 'actionSelected', action: IUpdateInformation): void;
|
||||
(event: 'dragstart', $e: DragEvent): void;
|
||||
(event: 'dragend', $e: DragEvent): void;
|
||||
}>();
|
||||
|
||||
const draggableStyle = computed<{ top: string; left: string }>(() => ({
|
||||
top: `${state.draggablePosition.y}px`,
|
||||
|
@ -62,11 +61,8 @@ const draggableStyle = computed<{ top: string; left: string }>(() => ({
|
|||
|
||||
const actionData = computed(() => getActionData(props.action));
|
||||
|
||||
const isTriggerAction = (action: INodeActionTypeDescription) =>
|
||||
action.name?.toLowerCase().includes('trigger');
|
||||
function onActionClick(actionItem: INodeActionTypeDescription) {
|
||||
emit('actionSelected', getActionData(actionItem));
|
||||
}
|
||||
const isTriggerAction = (action: ActionTypeDescription) =>
|
||||
action.name?.toLowerCase().includes('trigger') || action.name === WEBHOOK_NODE_TYPE;
|
||||
|
||||
function onDragStart(event: DragEvent): void {
|
||||
/**
|
||||
|
@ -84,14 +80,18 @@ function onDragStart(event: DragEvent): void {
|
|||
'nodeTypeName',
|
||||
getNodeTypesWithManualTrigger(actionData.value?.key).join(','),
|
||||
);
|
||||
|
||||
state.storeWatcher = setAddedNodeActionParameters(actionData.value, telemetry);
|
||||
if (telemetry) {
|
||||
state.storeWatcher = setAddedNodeActionParameters(
|
||||
actionData.value,
|
||||
telemetry,
|
||||
activeViewStack.rootView,
|
||||
);
|
||||
}
|
||||
document.body.addEventListener('dragend', onDragEnd);
|
||||
}
|
||||
|
||||
state.dragging = true;
|
||||
state.draggablePosition = { x, y };
|
||||
emit('dragstart', event);
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent): void {
|
||||
|
@ -109,8 +109,6 @@ function onDragEnd(event: DragEvent): void {
|
|||
document.body.removeEventListener('dragend', onDragEnd);
|
||||
document.body.removeEventListener('dragover', onDragOver);
|
||||
|
||||
emit('dragend', event);
|
||||
|
||||
state.dragging = false;
|
||||
setTimeout(() => {
|
||||
state.draggablePosition = { x: -100, y: -100 };
|
||||
|
@ -121,25 +119,19 @@ const { draggableDataTransfer, dragging } = toRefs(state);
|
|||
|
||||
<style lang="scss" module>
|
||||
.action {
|
||||
margin-left: 15px;
|
||||
margin-right: 12px;
|
||||
|
||||
--node-creator-name-size: var(--font-size-2xs);
|
||||
--node-creator-name-weight: var(--font-weight-normal);
|
||||
--trigger-icon-background-color: #{$trigger-icon-background-color};
|
||||
--trigger-icon-border-color: #{$trigger-icon-border-color};
|
||||
--node-icon-size: 20px;
|
||||
--node-icon-margin-right: var(--spacing-xs);
|
||||
|
||||
margin-left: var(--spacing-s);
|
||||
margin-right: var(--spacing-s);
|
||||
padding: var(--spacing-2xs) 0;
|
||||
}
|
||||
.nodeIcon {
|
||||
margin-right: var(--spacing-s);
|
||||
}
|
||||
|
||||
.apiHint {
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-text-base);
|
||||
padding-top: var(--spacing-s);
|
||||
line-height: var(--font-line-height-regular);
|
||||
border-top: 1px solid #dbdfe7;
|
||||
z-index: 1;
|
||||
// Prevent double borders when the last category is collapsed
|
||||
margin-top: -1px;
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.draggable {
|
|
@ -0,0 +1,81 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
export interface Props {
|
||||
expanded?: boolean;
|
||||
active?: boolean;
|
||||
count?: number;
|
||||
name: string;
|
||||
isTrigger?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
expanded: true,
|
||||
});
|
||||
|
||||
const categoryName = computed(() => {
|
||||
const itemsCount = props.count || 0;
|
||||
return itemsCount > 0 ? `${props.name} (${itemsCount})` : props.name;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="$style.categoryWrapper"
|
||||
v-on="$listeners"
|
||||
data-keyboard-nav="true"
|
||||
data-test-id="node-creator-category-item"
|
||||
>
|
||||
<div :class="{ [$style.category]: true, [$style.active]: active }">
|
||||
<span :class="$style.name">
|
||||
<span v-text="categoryName" />
|
||||
<font-awesome-icon icon="bolt" v-if="isTrigger" size="xs" :class="$style.triggerIcon" />
|
||||
<slot />
|
||||
</span>
|
||||
<font-awesome-icon v-if="expanded" icon="chevron-down" :class="$style.arrow" />
|
||||
<font-awesome-icon :class="$style.arrow" icon="chevron-up" v-else />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.triggerIcon {
|
||||
color: var(--color-primary);
|
||||
margin-left: var(--spacing-3xs);
|
||||
}
|
||||
.category {
|
||||
font-size: var(--font-size-s);
|
||||
font-weight: var(--font-weight-bold);
|
||||
line-height: var(--font-line-height-compact);
|
||||
padding: var(--spacing-2xs) var(--spacing-s);
|
||||
border-bottom: 1px solid $node-creator-border-color;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
|
||||
position: relative;
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
&:hover::before {
|
||||
border-color: $node-creator-item-hover-border-color;
|
||||
}
|
||||
&.active::before {
|
||||
border-color: $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
flex-grow: 1;
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: var(--font-size-2xs);
|
||||
width: 12px;
|
||||
color: $node-creator-arrow-color;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,31 @@
|
|||
<template>
|
||||
<div :class="$style.label">
|
||||
<span :class="$style.name" v-text="item.key" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { LabelItemProps } from '@/Interface';
|
||||
|
||||
export interface Props {
|
||||
item: LabelItemProps;
|
||||
}
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.label {
|
||||
margin-left: var(--spacing-s);
|
||||
margin-right: var(--spacing-s);
|
||||
margin-bottom: var(--spacing-4xs);
|
||||
letter-spacing: 1px;
|
||||
padding-top: var(--spacing-s);
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-size: 10px;
|
||||
line-height: 12px;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-base);
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
|
@ -4,12 +4,11 @@
|
|||
:draggable="!showActionArrow"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@click.stop="onClick"
|
||||
:class="$style.nodeItem"
|
||||
:description="allowDescription ? description : ''"
|
||||
:description="subcategory !== DEFAULT_SUBCATEGORY ? description : ''"
|
||||
:title="displayName"
|
||||
:isTrigger="!allowActions && isTriggerNode"
|
||||
:show-action-arrow="showActionArrow"
|
||||
:is-trigger="isTrigger"
|
||||
>
|
||||
<template #icon>
|
||||
<node-icon :nodeType="nodeType" />
|
||||
|
@ -39,62 +38,59 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, computed, toRefs, getCurrentInstance } from 'vue';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import { computed, ref, getCurrentInstance } from 'vue';
|
||||
import type { SimplifiedNodeType } from '@/Interface';
|
||||
import { COMMUNITY_NODES_INSTALLATION_DOCS_URL, DEFAULT_SUBCATEGORY } from '@/constants';
|
||||
|
||||
import { getNewNodePosition, NODE_SIZE } from '@/utils/nodeViewUtils';
|
||||
import { isCommunityPackageName } from '@/utils';
|
||||
import { COMMUNITY_NODES_INSTALLATION_DOCS_URL } from '@/constants';
|
||||
import { getNewNodePosition, NODE_SIZE } from '@/utils/nodeViewUtils';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator';
|
||||
|
||||
import NodeIcon from '@/components/NodeIcon.vue';
|
||||
|
||||
import { useActions } from '../composables/useActions';
|
||||
|
||||
export interface Props {
|
||||
nodeType: INodeTypeDescription;
|
||||
nodeType: SimplifiedNodeType;
|
||||
subcategory?: string;
|
||||
active?: boolean;
|
||||
allowActions?: boolean;
|
||||
allowDescription?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
active: false,
|
||||
allowActions: false,
|
||||
allowDescription: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'dragstart', $e: DragEvent): void;
|
||||
(event: 'dragend', $e: DragEvent): void;
|
||||
(event: 'nodeTypeSelected', value: string[]): void;
|
||||
(event: 'actionsOpen', value: INodeTypeDescription): void;
|
||||
}>();
|
||||
|
||||
const { actions } = useNodeCreatorStore();
|
||||
const { getNodeTypesWithManualTrigger } = useActions();
|
||||
const instance = getCurrentInstance();
|
||||
const state = reactive({
|
||||
dragging: false,
|
||||
draggablePosition: {
|
||||
x: -100,
|
||||
y: -100,
|
||||
},
|
||||
draggableDataTransfer: null as Element | null,
|
||||
});
|
||||
|
||||
const dragging = ref(false);
|
||||
const draggablePosition = ref({ x: -100, y: -100 });
|
||||
const draggableDataTransfer = ref(null as Element | null);
|
||||
|
||||
const description = computed<string>(() => {
|
||||
return instance?.proxy.$locale.headerText({
|
||||
key: `headers.${shortNodeType.value}.description`,
|
||||
fallback: props.nodeType.description,
|
||||
}) as string;
|
||||
});
|
||||
const showActionArrow = computed(() => props.allowActions && hasActions.value);
|
||||
const showActionArrow = computed(() => hasActions.value);
|
||||
|
||||
const hasActions = computed<boolean>(() => (props.nodeType.actions?.length || 0) > 0);
|
||||
const hasActions = computed(() => {
|
||||
return nodeActions.value.length > 1;
|
||||
});
|
||||
|
||||
const nodeActions = computed(() => {
|
||||
const nodeActions = actions[props.nodeType.name] || [];
|
||||
return nodeActions;
|
||||
});
|
||||
|
||||
const shortNodeType = computed<string>(
|
||||
() => instance?.proxy.$locale.shortNodeType(props.nodeType.name) || '',
|
||||
);
|
||||
|
||||
const draggableStyle = computed<{ top: string; left: string }>(() => ({
|
||||
top: `${state.draggablePosition.y}px`,
|
||||
left: `${state.draggablePosition.x}px`,
|
||||
top: `${draggablePosition.value.y}px`,
|
||||
left: `${draggablePosition.value.x}px`,
|
||||
}));
|
||||
|
||||
const isCommunityNode = computed<boolean>(() => isCommunityPackageName(props.nodeType.name));
|
||||
|
@ -105,21 +101,13 @@ const displayName = computed<any>(() => {
|
|||
|
||||
return instance?.proxy.$locale.headerText({
|
||||
key: `headers.${shortNodeType}.displayName`,
|
||||
fallback:
|
||||
props.allowActions && props.nodeType.actions?.length
|
||||
? displayName.replace('Trigger', '')
|
||||
: displayName,
|
||||
fallback: hasActions.value ? displayName.replace('Trigger', '') : displayName,
|
||||
});
|
||||
});
|
||||
|
||||
const isTriggerNode = computed<boolean>(() =>
|
||||
props.nodeType.displayName.toLowerCase().includes('trigger'),
|
||||
);
|
||||
|
||||
function onClick() {
|
||||
if (hasActions.value && props.allowActions) emit('actionsOpen', props.nodeType);
|
||||
else emit('nodeTypeSelected', [props.nodeType.name]);
|
||||
}
|
||||
const isTrigger = computed<boolean>(() => {
|
||||
return props.nodeType.group.includes('trigger') && !hasActions.value;
|
||||
});
|
||||
function onDragStart(event: DragEvent): void {
|
||||
/**
|
||||
* Workaround for firefox, that doesn't attach the pageX and pageY coordinates to "ondrag" event.
|
||||
|
@ -133,36 +121,33 @@ function onDragStart(event: DragEvent): void {
|
|||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
event.dataTransfer.setDragImage(state.draggableDataTransfer as Element, 0, 0);
|
||||
event.dataTransfer.setDragImage(draggableDataTransfer.value as Element, 0, 0);
|
||||
event.dataTransfer.setData(
|
||||
'nodeTypeName',
|
||||
useNodeCreatorStore().getNodeTypesWithManualTrigger(props.nodeType.name).join(','),
|
||||
getNodeTypesWithManualTrigger(props.nodeType.name).join(','),
|
||||
);
|
||||
}
|
||||
|
||||
state.dragging = true;
|
||||
state.draggablePosition = { x, y };
|
||||
emit('dragstart', event);
|
||||
dragging.value = true;
|
||||
draggablePosition.value = { x, y };
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent): void {
|
||||
if (!state.dragging || (event.pageX === 0 && event.pageY === 0)) {
|
||||
if (!dragging.value || (event.pageX === 0 && event.pageY === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [x, y] = getNewNodePosition([], [event.pageX - NODE_SIZE / 2, event.pageY - NODE_SIZE / 2]);
|
||||
|
||||
state.draggablePosition = { x, y };
|
||||
draggablePosition.value = { x, y };
|
||||
}
|
||||
|
||||
function onDragEnd(event: DragEvent): void {
|
||||
document.body.removeEventListener('dragover', onDragOver);
|
||||
|
||||
emit('dragend', event);
|
||||
|
||||
state.dragging = false;
|
||||
dragging.value = false;
|
||||
setTimeout(() => {
|
||||
state.draggablePosition = { x: -100, y: -100 };
|
||||
draggablePosition.value = { x: -100, y: -100 };
|
||||
}, 300);
|
||||
}
|
||||
|
||||
|
@ -171,11 +156,6 @@ function onCommunityNodeTooltipClick(event: MouseEvent) {
|
|||
instance?.proxy.$telemetry.track('user clicked cnr docs link', { source: 'nodes panel node' });
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
onClick,
|
||||
});
|
||||
const { dragging, draggableDataTransfer } = toRefs(state);
|
||||
</script>
|
||||
<style lang="scss" module>
|
||||
.nodeItem {
|
|
@ -13,15 +13,15 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ISubcategoryItemProps } from '@/Interface';
|
||||
import type { SubcategoryItemProps } from '@/Interface';
|
||||
import { camelCase } from 'lodash-es';
|
||||
import { computed } from 'vue';
|
||||
export interface Props {
|
||||
item: ISubcategoryItemProps;
|
||||
item: SubcategoryItemProps;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const subcategoryName = computed(() => camelCase(props.item.subcategory));
|
||||
const subcategoryName = computed(() => camelCase(props.item.subcategory || props.item.title));
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
@ -30,9 +30,4 @@ const subcategoryName = computed(() => camelCase(props.item.subcategory));
|
|||
margin-left: 15px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.withTopBorder {
|
||||
border-top: 1px solid var(--color-foreground-base);
|
||||
margin-top: var(--spacing-m);
|
||||
padding-top: var(--spacing-l);
|
||||
}
|
||||
</style>
|
|
@ -1,21 +1,13 @@
|
|||
<template>
|
||||
<n8n-node-creator-node
|
||||
:class="{
|
||||
[$style.view]: true,
|
||||
[$style.withTopBorder]: view.withTopBorder,
|
||||
}"
|
||||
:class="$style.view"
|
||||
:title="view.title"
|
||||
:isTrigger="false"
|
||||
:description="view.description"
|
||||
:showActionArrow="true"
|
||||
>
|
||||
<template #icon>
|
||||
<n8n-node-icon
|
||||
type="icon"
|
||||
:name="view.icon"
|
||||
:circle="false"
|
||||
:showTooltip="false"
|
||||
></n8n-node-icon>
|
||||
<n8n-node-icon type="icon" :name="view.icon" :circle="false" :showTooltip="false" />
|
||||
</template>
|
||||
</n8n-node-creator-node>
|
||||
</template>
|
||||
|
@ -33,13 +25,7 @@ defineProps<Props>();
|
|||
<style lang="scss" module>
|
||||
.view {
|
||||
--action-arrow-color: var(--color-text-light);
|
||||
margin-left: 15px;
|
||||
margin-right: 12px;
|
||||
padding: 11px 4px 11px 0;
|
||||
}
|
||||
.withTopBorder {
|
||||
border-top: 1px solid var(--color-foreground-base);
|
||||
margin-top: var(--spacing-xs);
|
||||
padding-top: var(--spacing-l);
|
||||
margin-left: var(--spacing-s);
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
</style>
|
|
@ -1,405 +0,0 @@
|
|||
<template>
|
||||
<div :class="{ [$style.mainPanel]: true, [$style.isRoot]: isRoot }">
|
||||
<CategorizedItems
|
||||
:subcategoryOverride="nodeAppSubcategory"
|
||||
:alwaysShowSearch="isActionsActive"
|
||||
:hideOtherCategoryItems="isActionsActive"
|
||||
:categorizedItems="computedCategorizedItems"
|
||||
:searchItems="searchItems"
|
||||
:withActionsGetter="shouldShowNodeActions"
|
||||
:withDescriptionGetter="shouldShowNodeDescription"
|
||||
:firstLevelItems="firstLevelItems"
|
||||
:showSubcategoryIcon="isActionsActive"
|
||||
:allItems="transformCreateElements(mergedAppNodes)"
|
||||
:searchPlaceholder="searchPlaceholder"
|
||||
@subcategoryClose="onSubcategoryClose"
|
||||
@onSubcategorySelected="onSubcategorySelected"
|
||||
@nodeTypeSelected="onNodeTypeSelected"
|
||||
@actionsOpen="setActiveActionsNodeType"
|
||||
@actionSelected="onActionSelected"
|
||||
>
|
||||
<template #noResults>
|
||||
<no-results
|
||||
data-test-id="categorized-no-results"
|
||||
:showRequest="!isActionsActive"
|
||||
:show-icon="!isActionsActive"
|
||||
>
|
||||
<template #title v-if="!isActionsActive">
|
||||
<p v-text="$locale.baseText('nodeCreator.noResults.weDidntMakeThatYet')" />
|
||||
</template>
|
||||
|
||||
<template v-if="isActionsActive" #action>
|
||||
<p
|
||||
v-if="containsAPIAction"
|
||||
v-html="getCustomAPICallHintLocale('apiCallNoResult')"
|
||||
class="clickable"
|
||||
@click.stop="addHttpNode(true)"
|
||||
/>
|
||||
<p v-else v-text="$locale.baseText('nodeCreator.noResults.noMatchingActions')" />
|
||||
</template>
|
||||
|
||||
<template v-else #action>
|
||||
{{ $locale.baseText('nodeCreator.noResults.dontWorryYouCanProbablyDoItWithThe') }}
|
||||
<n8n-link v-if="[REGULAR_NODE_FILTER].includes(selectedView)" @click="addHttpNode">
|
||||
{{ $locale.baseText('nodeCreator.noResults.httpRequest') }}
|
||||
</n8n-link>
|
||||
|
||||
<n8n-link v-if="[TRIGGER_NODE_FILTER].includes(selectedView)" @click="addWebHookNode()">
|
||||
{{ $locale.baseText('nodeCreator.noResults.webhook') }}
|
||||
</n8n-link>
|
||||
{{ $locale.baseText('nodeCreator.noResults.node') }}
|
||||
</template>
|
||||
</no-results>
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<p
|
||||
v-if="isRoot && activeView && activeView.title"
|
||||
v-text="activeView.title"
|
||||
:class="$style.title"
|
||||
/>
|
||||
</template>
|
||||
<template #description>
|
||||
<p
|
||||
v-if="isRoot && activeView && activeView.description"
|
||||
v-text="activeView.description"
|
||||
:class="$style.description"
|
||||
/>
|
||||
</template>
|
||||
<template #footer v-if="activeNodeActions && containsAPIAction">
|
||||
<span
|
||||
v-html="getCustomAPICallHintLocale('apiCall')"
|
||||
class="clickable"
|
||||
@click.stop="addHttpNode(true)"
|
||||
/>
|
||||
</template>
|
||||
</CategorizedItems>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, toRefs, getCurrentInstance, computed, onUnmounted } from 'vue';
|
||||
import type { INodeTypeDescription, INodeActionTypeDescription } from 'n8n-workflow';
|
||||
import type {
|
||||
INodeCreateElement,
|
||||
NodeCreateElement,
|
||||
IActionItemProps,
|
||||
SubcategoryCreateElement,
|
||||
IUpdateInformation,
|
||||
} from '@/Interface';
|
||||
import {
|
||||
CORE_NODES_CATEGORY,
|
||||
WEBHOOK_NODE_TYPE,
|
||||
EMAIL_IMAP_NODE_TYPE,
|
||||
CUSTOM_API_CALL_NAME,
|
||||
HTTP_REQUEST_NODE_TYPE,
|
||||
STICKY_NODE_TYPE,
|
||||
REGULAR_NODE_FILTER,
|
||||
TRIGGER_NODE_FILTER,
|
||||
} from '@/constants';
|
||||
import CategorizedItems from './CategorizedItems.vue';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator';
|
||||
import { getCategoriesWithNodes, getCategorizedList } from '@/utils';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes';
|
||||
import type { BaseTextKey } from '@/plugins/i18n';
|
||||
import NoResults from './NoResults.vue';
|
||||
import { useRootStore } from '@/stores/n8nRootStore';
|
||||
import useMainPanelView from './useMainPanelView';
|
||||
import { useExternalHooks } from '@/composables';
|
||||
|
||||
const instance = getCurrentInstance();
|
||||
|
||||
const emit = defineEmits({
|
||||
nodeTypeSelected: (nodeTypes: string[]) => true,
|
||||
});
|
||||
|
||||
const state = reactive({
|
||||
isRoot: true,
|
||||
selectedSubcategory: '',
|
||||
activeNodeActions: null as INodeTypeDescription | null,
|
||||
});
|
||||
const { baseUrl } = useRootStore();
|
||||
const externalHooks = useExternalHooks();
|
||||
|
||||
const {
|
||||
mergedAppNodes,
|
||||
getActionData,
|
||||
getNodeTypesWithManualTrigger,
|
||||
setAddedNodeActionParameters,
|
||||
} = useNodeCreatorStore();
|
||||
const { activeView } = useMainPanelView();
|
||||
const telemetry = instance?.proxy.$telemetry;
|
||||
const { isTriggerNode } = useNodeTypesStore();
|
||||
const containsAPIAction = computed(
|
||||
() =>
|
||||
state.activeNodeActions?.properties.some((p) =>
|
||||
p.options?.find((o) => o.name === CUSTOM_API_CALL_NAME),
|
||||
) === true,
|
||||
);
|
||||
|
||||
const selectedView = computed(() => useNodeCreatorStore().selectedView);
|
||||
const computedCategorizedItems = computed(() => {
|
||||
if (isActionsActive.value) {
|
||||
return sortActions(getCategorizedList(computedCategoriesWithNodes.value, true));
|
||||
}
|
||||
|
||||
return getCategorizedList(computedCategoriesWithNodes.value, true);
|
||||
});
|
||||
|
||||
const nodeAppSubcategory = computed<SubcategoryCreateElement | undefined>(() => {
|
||||
if (!state.activeNodeActions) return undefined;
|
||||
|
||||
const icon = state.activeNodeActions.iconUrl
|
||||
? `${baseUrl}${state.activeNodeActions.iconUrl}`
|
||||
: state.activeNodeActions.icon?.split(':')[1];
|
||||
|
||||
return {
|
||||
type: 'subcategory',
|
||||
key: state.activeNodeActions.name,
|
||||
properties: {
|
||||
subcategory: state.activeNodeActions.displayName,
|
||||
description: '',
|
||||
iconType: state.activeNodeActions.iconUrl ? 'file' : 'icon',
|
||||
icon,
|
||||
color: state.activeNodeActions.defaults.color,
|
||||
},
|
||||
};
|
||||
});
|
||||
const searchPlaceholder = computed(() => {
|
||||
const nodeNameTitle = state.activeNodeActions?.displayName?.trim() as string;
|
||||
const actionsSearchPlaceholder = instance?.proxy.$locale.baseText(
|
||||
'nodeCreator.actionsCategory.searchActions',
|
||||
{ interpolate: { nodeNameTitle } },
|
||||
);
|
||||
|
||||
return isActionsActive.value ? actionsSearchPlaceholder : undefined;
|
||||
});
|
||||
|
||||
const filteredMergedAppNodes = computed(() => {
|
||||
const WHITELISTED_APP_CORE_NODES = [EMAIL_IMAP_NODE_TYPE, WEBHOOK_NODE_TYPE];
|
||||
|
||||
if (isAppEventSubcategory.value)
|
||||
return mergedAppNodes.filter((node) => {
|
||||
const isTrigger = isTriggerNode(node.name);
|
||||
const isRegularNode = !isTrigger;
|
||||
const isStickyNode = node.name === STICKY_NODE_TYPE;
|
||||
const isCoreNode =
|
||||
node.codex?.categories?.includes(CORE_NODES_CATEGORY) &&
|
||||
!WHITELISTED_APP_CORE_NODES.includes(node.name);
|
||||
const hasActions = (node.actions || []).length > 0;
|
||||
|
||||
// Never show core nodes and sticky node in the Apps subcategory
|
||||
if (isCoreNode || isStickyNode) return false;
|
||||
|
||||
// Only show nodes without action within their view
|
||||
if (!hasActions) {
|
||||
return isRegularNode
|
||||
? selectedView.value === REGULAR_NODE_FILTER
|
||||
: selectedView.value === TRIGGER_NODE_FILTER;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return mergedAppNodes;
|
||||
});
|
||||
|
||||
const computedCategoriesWithNodes = computed(() => {
|
||||
if (!state.activeNodeActions) return getCategoriesWithNodes(filteredMergedAppNodes.value);
|
||||
|
||||
return getCategoriesWithNodes(selectedNodeActions.value, state.activeNodeActions.displayName);
|
||||
});
|
||||
|
||||
const selectedNodeActions = computed<INodeActionTypeDescription[]>(
|
||||
() => state.activeNodeActions?.actions ?? [],
|
||||
);
|
||||
const isAppEventSubcategory = computed(() => state.selectedSubcategory === '*');
|
||||
const isActionsActive = computed(() => state.activeNodeActions !== null);
|
||||
const firstLevelItems = computed(() => (isRoot.value ? activeView.value.items : []));
|
||||
|
||||
const searchItems = computed<INodeCreateElement[]>(() => {
|
||||
return state.activeNodeActions
|
||||
? transformCreateElements(selectedNodeActions.value, 'action')
|
||||
: transformCreateElements(filteredMergedAppNodes.value);
|
||||
});
|
||||
|
||||
// If the user is in the root view, we want to show trigger nodes first
|
||||
// otherwise we want to show them last
|
||||
function sortActions(nodeCreateElements: INodeCreateElement[]): INodeCreateElement[] {
|
||||
const elements = {
|
||||
trigger: [] as INodeCreateElement[],
|
||||
regular: [] as INodeCreateElement[],
|
||||
};
|
||||
|
||||
nodeCreateElements.forEach((el) => {
|
||||
const isTriggersCategory = el.type === 'category' && el.key === 'Triggers';
|
||||
const isTriggerAction = el.type === 'action' && el.category === 'Triggers';
|
||||
|
||||
elements[isTriggersCategory || isTriggerAction ? 'trigger' : 'regular'].push(el);
|
||||
});
|
||||
|
||||
if (selectedView.value === TRIGGER_NODE_FILTER) {
|
||||
return [...elements.trigger, ...elements.regular];
|
||||
}
|
||||
|
||||
return [...elements.regular, ...elements.trigger];
|
||||
}
|
||||
|
||||
function transformCreateElements(
|
||||
createElements: Array<INodeTypeDescription | INodeActionTypeDescription>,
|
||||
type: 'node' | 'action' = 'node',
|
||||
): INodeCreateElement[] {
|
||||
const sorted = [...createElements];
|
||||
|
||||
sorted.sort((a, b) => {
|
||||
const textA = a.displayName.toLowerCase();
|
||||
const textB = b.displayName.toLowerCase();
|
||||
return textA < textB ? -1 : textA > textB ? 1 : 0;
|
||||
});
|
||||
|
||||
return sorted.map((nodeType) => {
|
||||
const hasTriggerActions = nodeType.actions?.find((action) => action.name.includes('trigger'));
|
||||
const hasRgeularActions = nodeType.actions?.find((action) => !action.name.includes('trigger'));
|
||||
|
||||
return {
|
||||
type,
|
||||
category: nodeType.codex?.categories,
|
||||
key: nodeType.name,
|
||||
properties: {
|
||||
nodeType,
|
||||
subcategory: state.activeNodeActions?.displayName ?? '',
|
||||
},
|
||||
includedByTrigger: hasTriggerActions || nodeType.group.includes('trigger'),
|
||||
includedByRegular: hasRgeularActions || !nodeType.group.includes('trigger'),
|
||||
} as INodeCreateElement;
|
||||
});
|
||||
}
|
||||
|
||||
function onNodeTypeSelected(nodeTypes: string[]) {
|
||||
emit(
|
||||
'nodeTypeSelected',
|
||||
nodeTypes.length === 1 ? getNodeTypesWithManualTrigger(nodeTypes[0]) : nodeTypes,
|
||||
);
|
||||
}
|
||||
function getCustomAPICallHintLocale(key: string) {
|
||||
if (!state.activeNodeActions) return '';
|
||||
|
||||
const nodeNameTitle = state.activeNodeActions.displayName;
|
||||
return instance?.proxy.$locale.baseText(`nodeCreator.actionsList.${key}` as BaseTextKey, {
|
||||
interpolate: { nodeNameTitle },
|
||||
});
|
||||
}
|
||||
|
||||
function setActiveActionsNodeType(nodeType: INodeTypeDescription | null) {
|
||||
state.activeNodeActions = nodeType;
|
||||
|
||||
if (nodeType) trackActionsView();
|
||||
}
|
||||
|
||||
function onActionSelected(actionCreateElement: INodeCreateElement) {
|
||||
const action = (actionCreateElement.properties as IActionItemProps).nodeType;
|
||||
const actionUpdateData = getActionData(action);
|
||||
emit('nodeTypeSelected', getNodeTypesWithManualTrigger(actionUpdateData.key));
|
||||
setAddedNodeActionParameters(actionUpdateData, telemetry);
|
||||
}
|
||||
function addWebHookNode() {
|
||||
emit('nodeTypeSelected', [WEBHOOK_NODE_TYPE]);
|
||||
}
|
||||
|
||||
function addHttpNode(isAction: boolean) {
|
||||
const updateData = {
|
||||
name: '',
|
||||
key: HTTP_REQUEST_NODE_TYPE,
|
||||
value: {
|
||||
authentication: 'predefinedCredentialType',
|
||||
},
|
||||
} as IUpdateInformation;
|
||||
|
||||
emit('nodeTypeSelected', [HTTP_REQUEST_NODE_TYPE]);
|
||||
if (isAction) {
|
||||
setAddedNodeActionParameters(updateData, telemetry, false);
|
||||
|
||||
const app_identifier = state.activeNodeActions?.name;
|
||||
externalHooks.run('nodeCreateList.onActionsCustmAPIClicked', { app_identifier });
|
||||
telemetry?.trackNodesPanel('nodeCreateList.onActionsCustmAPIClicked', { app_identifier });
|
||||
}
|
||||
}
|
||||
|
||||
function onSubcategorySelected(subcategory: INodeCreateElement) {
|
||||
state.isRoot = false;
|
||||
state.selectedSubcategory = subcategory.key;
|
||||
}
|
||||
function onSubcategoryClose(activeSubcategories: INodeCreateElement[]) {
|
||||
if (isActionsActive.value === true) setActiveActionsNodeType(null);
|
||||
|
||||
state.isRoot = activeSubcategories.length === 0;
|
||||
state.selectedSubcategory = activeSubcategories[activeSubcategories.length - 1]?.key ?? '';
|
||||
}
|
||||
|
||||
function shouldShowNodeDescription(node: NodeCreateElement) {
|
||||
return (node.category || []).includes(CORE_NODES_CATEGORY);
|
||||
}
|
||||
|
||||
function shouldShowNodeActions(node: INodeCreateElement) {
|
||||
if (state.isRoot && useNodeCreatorStore().itemsFilter === '') return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function trackActionsView() {
|
||||
const trigger_action_count = selectedNodeActions.value.filter((action) =>
|
||||
action.name.toLowerCase().includes('trigger'),
|
||||
).length;
|
||||
|
||||
const trackingPayload = {
|
||||
app_identifier: state.activeNodeActions?.name,
|
||||
actions: selectedNodeActions.value.map((action) => action.displayName),
|
||||
regular_action_count: selectedNodeActions.value.length - trigger_action_count,
|
||||
trigger_action_count,
|
||||
};
|
||||
|
||||
externalHooks.run('nodeCreateList.onViewActions', trackingPayload);
|
||||
telemetry?.trackNodesPanel('nodeCreateList.onViewActions', trackingPayload);
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
useNodeCreatorStore().resetRootViewHistory();
|
||||
});
|
||||
const { isRoot, activeNodeActions } = toRefs(state);
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.mainPanel {
|
||||
--node-icon-color: var(--color-text-base);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// Remove node item border on the root level
|
||||
&.isRoot {
|
||||
--node-item-border: none;
|
||||
}
|
||||
}
|
||||
.itemCreator {
|
||||
height: calc(100% - 120px);
|
||||
padding-top: 1px;
|
||||
overflow-y: auto;
|
||||
overflow-x: visible;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-l);
|
||||
line-height: var(--font-line-height-xloose);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
.description {
|
||||
font-size: var(--font-size-s);
|
||||
line-height: var(--font-line-height-loose);
|
||||
color: var(--color-text-base);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,366 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, getCurrentInstance, onMounted, defineComponent } from 'vue';
|
||||
import type { VNode, PropType } from 'vue';
|
||||
import type {
|
||||
INodeCreateElement,
|
||||
ActionTypeDescription,
|
||||
NodeFilterType,
|
||||
IUpdateInformation,
|
||||
ActionCreateElement,
|
||||
} from '@/Interface';
|
||||
import {
|
||||
HTTP_REQUEST_NODE_TYPE,
|
||||
REGULAR_NODE_CREATOR_VIEW,
|
||||
TRIGGER_NODE_CREATOR_VIEW,
|
||||
CUSTOM_API_CALL_KEY,
|
||||
AUTO_INSERT_ACTION_EXPERIMENT,
|
||||
} from '@/constants';
|
||||
|
||||
import { usePostHog } from '@/stores/posthog';
|
||||
import { useUsersStore } from '@/stores/users';
|
||||
import { useWebhooksStore } from '@/stores/webhooks';
|
||||
import { runExternalHook } from '@/utils';
|
||||
|
||||
import { useActions } from '../composables/useActions';
|
||||
import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
|
||||
import { useViewStacks } from '../composables/useViewStacks';
|
||||
|
||||
import ItemsRenderer from '../Renderers/ItemsRenderer.vue';
|
||||
import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue';
|
||||
|
||||
const emit = defineEmits({
|
||||
nodeTypeSelected: (nodeTypes: string[]) => true,
|
||||
});
|
||||
const instance = getCurrentInstance();
|
||||
const telemetry = instance?.proxy.$telemetry;
|
||||
|
||||
const { userActivated } = useUsersStore();
|
||||
const { popViewStack, updateCurrentViewStack } = useViewStacks();
|
||||
const { registerKeyHook } = useKeyboardNavigation();
|
||||
const {
|
||||
getNodeTypesWithManualTrigger,
|
||||
setAddedNodeActionParameters,
|
||||
getActionData,
|
||||
getPlaceholderTriggerActions,
|
||||
parseCategoryActions,
|
||||
actionsCategoryLocales,
|
||||
} = useActions();
|
||||
|
||||
// We only inject labels if search is empty
|
||||
const parsedTriggerActions = computed(() =>
|
||||
parseActions(actions.value, actionsCategoryLocales.value.triggers, false),
|
||||
);
|
||||
const parsedActionActions = computed(() =>
|
||||
parseActions(actions.value, actionsCategoryLocales.value.actions, !search.value),
|
||||
);
|
||||
const parsedTriggerActionsBaseline = computed(() =>
|
||||
parseActions(
|
||||
useViewStacks().activeViewStack.baselineItems || [],
|
||||
actionsCategoryLocales.value.triggers,
|
||||
false,
|
||||
),
|
||||
);
|
||||
const parsedActionActionsBaseline = computed(() =>
|
||||
parseActions(
|
||||
useViewStacks().activeViewStack.baselineItems || [],
|
||||
actionsCategoryLocales.value.actions,
|
||||
!search.value,
|
||||
),
|
||||
);
|
||||
|
||||
// Because the placeholder items are inserted into the slots, we need to
|
||||
// add the placeholder count to the category name manually
|
||||
const triggerCategoryName = computed(() =>
|
||||
parsedTriggerActions.value.length || search.value
|
||||
? actionsCategoryLocales.value.triggers
|
||||
: `${actionsCategoryLocales.value.triggers} (${placeholderTriggerActions.length})`,
|
||||
);
|
||||
|
||||
const actions = computed(() => {
|
||||
return (useViewStacks().activeViewStack.items || []).filter(
|
||||
(p) => (p as ActionCreateElement).properties.actionKey !== CUSTOM_API_CALL_KEY,
|
||||
);
|
||||
});
|
||||
|
||||
const search = computed(() => useViewStacks().activeViewStack.search);
|
||||
|
||||
const subcategory = computed(() => useViewStacks().activeViewStack.subcategory);
|
||||
|
||||
const rootView = computed(() => useViewStacks().activeViewStack.rootView);
|
||||
|
||||
const placeholderTriggerActions = getPlaceholderTriggerActions(subcategory.value || '');
|
||||
|
||||
const hasNoTriggerActions = computed(
|
||||
() =>
|
||||
parseCategoryActions(
|
||||
useViewStacks().activeViewStack.baselineItems || [],
|
||||
actionsCategoryLocales.value.triggers,
|
||||
!search.value,
|
||||
).length === 0,
|
||||
);
|
||||
|
||||
const containsAPIAction = computed(() => {
|
||||
const actions = useViewStacks().activeViewStack.baselineItems || [];
|
||||
|
||||
const result = actions.some((p) => {
|
||||
return ((p as ActionCreateElement).properties.actionKey ?? '') === CUSTOM_API_CALL_KEY;
|
||||
});
|
||||
|
||||
return result === true;
|
||||
});
|
||||
|
||||
const isTriggerRootView = computed(() => rootView.value === TRIGGER_NODE_CREATOR_VIEW);
|
||||
|
||||
registerKeyHook('ActionsKeyRight', {
|
||||
keyboardKeys: ['ArrowRight', 'Enter'],
|
||||
condition: (type) => type === 'action',
|
||||
handler: onKeySelect,
|
||||
});
|
||||
|
||||
registerKeyHook('ActionsKeyLeft', {
|
||||
keyboardKeys: ['ArrowLeft'],
|
||||
condition: (type) => type === 'action',
|
||||
handler: arrowLeft,
|
||||
});
|
||||
|
||||
function parseActions(base: INodeCreateElement[], locale: string, withLabels = false) {
|
||||
return parseCategoryActions(base, locale, withLabels);
|
||||
}
|
||||
|
||||
function arrowLeft() {
|
||||
popViewStack();
|
||||
}
|
||||
|
||||
function onKeySelect(activeItemId: string) {
|
||||
const mergedActions = [...actions.value, ...placeholderTriggerActions];
|
||||
const activeAction = mergedActions.find((a) => a.uuid === activeItemId);
|
||||
|
||||
if (activeAction) onSelected(activeAction);
|
||||
}
|
||||
|
||||
function onSelected(actionCreateElement: INodeCreateElement) {
|
||||
const actionData = getActionData(actionCreateElement.properties as ActionTypeDescription);
|
||||
const isPlaceholderTriggerAction = placeholderTriggerActions.some(
|
||||
(p) => p.key === actionCreateElement.key,
|
||||
);
|
||||
const includeNodeWithPlaceholderTrigger = usePostHog().isVariantEnabled(
|
||||
AUTO_INSERT_ACTION_EXPERIMENT.name,
|
||||
AUTO_INSERT_ACTION_EXPERIMENT.variant,
|
||||
);
|
||||
|
||||
if (includeNodeWithPlaceholderTrigger && isPlaceholderTriggerAction && isTriggerRootView) {
|
||||
const actionNode = actions.value[0].key;
|
||||
|
||||
emit('nodeTypeSelected', [actionData.key as string, actionNode]);
|
||||
} else {
|
||||
emit('nodeTypeSelected', getNodeTypesWithManualTrigger(actionData.key));
|
||||
}
|
||||
|
||||
if (telemetry) setAddedNodeActionParameters(actionData, telemetry, rootView.value);
|
||||
}
|
||||
|
||||
function trackActionsView() {
|
||||
const activeViewStack = useViewStacks().activeViewStack;
|
||||
|
||||
const trigger_action_count = (activeViewStack.baselineItems || [])?.filter((action) =>
|
||||
action.key.toLowerCase().includes('trigger'),
|
||||
).length;
|
||||
|
||||
const appIdentifier = [...actions.value, ...placeholderTriggerActions][0].key;
|
||||
|
||||
const trackingPayload = {
|
||||
app_identifier: appIdentifier,
|
||||
actions: (activeViewStack.baselineItems || [])?.map(
|
||||
(action) => (action as ActionCreateElement).properties.displayName,
|
||||
),
|
||||
regular_action_count: (activeViewStack.baselineItems || [])?.length - trigger_action_count,
|
||||
trigger_action_count,
|
||||
};
|
||||
|
||||
runExternalHook('nodeCreateList.onViewActions', useWebhooksStore(), trackingPayload);
|
||||
telemetry?.trackNodesPanel('nodeCreateList.onViewActions', trackingPayload);
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
updateCurrentViewStack({ search: '' });
|
||||
}
|
||||
|
||||
function addHttpNode() {
|
||||
const updateData = {
|
||||
name: '',
|
||||
key: HTTP_REQUEST_NODE_TYPE,
|
||||
value: {
|
||||
authentication: 'predefinedCredentialType',
|
||||
},
|
||||
} as IUpdateInformation;
|
||||
|
||||
emit('nodeTypeSelected', [HTTP_REQUEST_NODE_TYPE]);
|
||||
if (telemetry) setAddedNodeActionParameters(updateData);
|
||||
|
||||
const app_identifier = actions.value[0].key;
|
||||
runExternalHook('nodeCreateList.onActionsCustmAPIClicked', useWebhooksStore(), {
|
||||
app_identifier,
|
||||
});
|
||||
telemetry?.trackNodesPanel('nodeCreateList.onActionsCustmAPIClicked', { app_identifier });
|
||||
}
|
||||
|
||||
// Anonymous component to handle triggers and actions rendering order
|
||||
const OrderSwitcher = defineComponent({
|
||||
props: {
|
||||
rootView: {
|
||||
type: String as PropType<NodeFilterType>,
|
||||
},
|
||||
},
|
||||
render(h): VNode {
|
||||
const triggers = this.$slots?.triggers?.[0];
|
||||
const actions = this.$slots?.actions?.[0];
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{},
|
||||
this.rootView === REGULAR_NODE_CREATOR_VIEW ? [actions, triggers] : [triggers, actions],
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
trackActionsView();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<OrderSwitcher :rootView="rootView">
|
||||
<template #triggers v-if="isTriggerRootView || parsedTriggerActionsBaseline.length !== 0">
|
||||
<!-- Triggers Category -->
|
||||
<CategorizedItemsRenderer
|
||||
:elements="parsedTriggerActions"
|
||||
:category="triggerCategoryName"
|
||||
:mouseOverTooltip="$locale.baseText('nodeCreator.actionsTooltip.triggersStartWorkflow')"
|
||||
isTriggerCategory
|
||||
:expanded="isTriggerRootView || parsedActionActions.length === 0"
|
||||
@selected="onSelected"
|
||||
>
|
||||
<!-- Empty state -->
|
||||
<template #empty>
|
||||
<template v-if="hasNoTriggerActions">
|
||||
<n8n-callout
|
||||
theme="info"
|
||||
iconless
|
||||
slim
|
||||
data-test-id="actions-panel-no-triggers-callout"
|
||||
>
|
||||
<span
|
||||
v-html="
|
||||
$locale.baseText('nodeCreator.actionsCallout.noTriggerItems', {
|
||||
interpolate: { nodeName: subcategory },
|
||||
})
|
||||
"
|
||||
/>
|
||||
</n8n-callout>
|
||||
<ItemsRenderer @selected="onSelected" :elements="placeholderTriggerActions" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p
|
||||
:class="$style.resetSearch"
|
||||
v-html="$locale.baseText('nodeCreator.actionsCategory.noMatchingTriggers')"
|
||||
@click="resetSearch"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</CategorizedItemsRenderer>
|
||||
</template>
|
||||
<template #actions v-if="!isTriggerRootView || parsedActionActionsBaseline.length !== 0">
|
||||
<!-- Actions Category -->
|
||||
<CategorizedItemsRenderer
|
||||
:elements="parsedActionActions"
|
||||
:category="actionsCategoryLocales.actions"
|
||||
:mouseOverTooltip="$locale.baseText('nodeCreator.actionsTooltip.actionsPerformStep')"
|
||||
:expanded="!isTriggerRootView || parsedTriggerActions.length === 0"
|
||||
@selected="onSelected"
|
||||
>
|
||||
<template>
|
||||
<n8n-callout
|
||||
theme="info"
|
||||
iconless
|
||||
v-if="!userActivated && isTriggerRootView"
|
||||
slim
|
||||
data-test-id="actions-panel-activation-callout"
|
||||
>
|
||||
<span v-html="$locale.baseText('nodeCreator.actionsCallout.triggersStartWorkflow')" />
|
||||
</n8n-callout>
|
||||
</template>
|
||||
<!-- Empty state -->
|
||||
<template #empty>
|
||||
<n8n-info-tip theme="info" type="note" v-if="!search" :class="$style.actionsEmpty">
|
||||
<span
|
||||
v-html="
|
||||
$locale.baseText('nodeCreator.actionsCallout.noActionItems', {
|
||||
interpolate: { nodeName: subcategory },
|
||||
})
|
||||
"
|
||||
/>
|
||||
</n8n-info-tip>
|
||||
<template v-else>
|
||||
<p
|
||||
:class="$style.resetSearch"
|
||||
v-html="$locale.baseText('nodeCreator.actionsCategory.noMatchingActions')"
|
||||
@click="resetSearch"
|
||||
data-test-id="actions-panel-no-matching-actions"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</CategorizedItemsRenderer>
|
||||
</template>
|
||||
</OrderSwitcher>
|
||||
<div :class="$style.apiHint" v-if="containsAPIAction">
|
||||
<span
|
||||
@click.prevent="addHttpNode"
|
||||
v-html="
|
||||
$locale.baseText('nodeCreator.actionsList.apiCall', {
|
||||
interpolate: { node: subcategory },
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: var(--spacing-3xl);
|
||||
}
|
||||
|
||||
.resetSearch {
|
||||
cursor: pointer;
|
||||
line-height: var(--font-line-height-regular);
|
||||
font-weight: var(--font-weight-regular);
|
||||
font-size: var(--font-size-2xs);
|
||||
padding: var(--spacing-2xs) var(--spacing-s) 0;
|
||||
color: var(--color-text-base);
|
||||
|
||||
i {
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-style: normal;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
.actionsEmpty {
|
||||
padding: var(--spacing-2xs) var(--spacing-xs) var(--spacing-s);
|
||||
font-weight: var(--font-weight-regular);
|
||||
|
||||
strong {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
}
|
||||
.apiHint {
|
||||
padding: 0 var(--spacing-s) var(--spacing-xl);
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-text-base);
|
||||
line-height: var(--font-line-height-regular);
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,213 @@
|
|||
<script setup lang="ts">
|
||||
import { camelCase } from 'lodash-es';
|
||||
import { getCurrentInstance, computed } from 'vue';
|
||||
import type { INodeCreateElement, NodeFilterType } from '@/Interface';
|
||||
import { TRIGGER_NODE_CREATOR_VIEW, HTTP_REQUEST_NODE_TYPE, WEBHOOK_NODE_TYPE } from '@/constants';
|
||||
|
||||
import type { BaseTextKey } from '@/plugins/i18n';
|
||||
import { useRootStore } from '@/stores/n8nRootStore';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator';
|
||||
|
||||
import { TriggerView, RegularView } from '../viewsData';
|
||||
import { transformNodeType } from '../utils';
|
||||
import { useViewStacks } from '../composables/useViewStacks';
|
||||
import { useActions } from '../composables/useActions';
|
||||
import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
|
||||
import ItemsRenderer from '../Renderers/ItemsRenderer.vue';
|
||||
import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue';
|
||||
import NoResults from '../Panel/NoResults.vue';
|
||||
|
||||
export interface Props {
|
||||
rootView: 'trigger' | 'action';
|
||||
}
|
||||
|
||||
const emit = defineEmits({
|
||||
nodeTypeSelected: (nodeTypes: string[]) => true,
|
||||
});
|
||||
|
||||
const instance = getCurrentInstance();
|
||||
const { mergedNodes, actions } = useNodeCreatorStore();
|
||||
const { baseUrl } = useRootStore();
|
||||
const { getNodeTypesWithManualTrigger } = useActions();
|
||||
const { pushViewStack, popViewStack } = useViewStacks();
|
||||
|
||||
const { registerKeyHook } = useKeyboardNavigation();
|
||||
|
||||
const activeViewStack = computed(() => useViewStacks().activeViewStack);
|
||||
const globalSearchItemsDiff = computed(() => useViewStacks().globalSearchItemsDiff);
|
||||
|
||||
function selectNodeType(nodeTypes: string[]) {
|
||||
emit(
|
||||
'nodeTypeSelected',
|
||||
nodeTypes.length === 1 ? getNodeTypesWithManualTrigger(nodeTypes[0]) : nodeTypes,
|
||||
);
|
||||
}
|
||||
|
||||
function onSelected(item: INodeCreateElement) {
|
||||
if (item.type === 'subcategory') {
|
||||
const title = instance?.proxy.$locale.baseText(
|
||||
`nodeCreator.subcategoryNames.${camelCase(item.properties.title)}` as BaseTextKey,
|
||||
);
|
||||
|
||||
pushViewStack({
|
||||
subcategory: item.key,
|
||||
title,
|
||||
mode: 'nodes',
|
||||
rootView: activeViewStack.value.rootView,
|
||||
forceIncludeNodes: item.properties.forceIncludeNodes,
|
||||
baseFilter: baseSubcategoriesFilter,
|
||||
itemsMapper: subcategoriesMapper,
|
||||
});
|
||||
|
||||
instance?.proxy.$telemetry.trackNodesPanel('nodeCreateList.onSubcategorySelected', {
|
||||
subcategory: item.key,
|
||||
});
|
||||
}
|
||||
|
||||
if (item.type === 'node') {
|
||||
const nodeActions = actions?.[item.key] || [];
|
||||
if (nodeActions.length <= 1) {
|
||||
selectNodeType([item.key]);
|
||||
return;
|
||||
}
|
||||
|
||||
const icon = item.properties.iconUrl
|
||||
? `${baseUrl}${item.properties.iconUrl}`
|
||||
: item.properties.icon?.split(':')[1];
|
||||
|
||||
const transformedActions = nodeActions?.map((a) =>
|
||||
transformNodeType(a, item.properties.displayName, 'action'),
|
||||
);
|
||||
|
||||
pushViewStack({
|
||||
subcategory: item.properties.displayName,
|
||||
title: item.properties.displayName,
|
||||
nodeIcon: {
|
||||
color: item.properties.defaults?.color || '',
|
||||
icon,
|
||||
iconType: item.properties.iconUrl ? 'file' : 'icon',
|
||||
},
|
||||
|
||||
rootView: activeViewStack.value.rootView,
|
||||
hasSearch: true,
|
||||
mode: 'actions',
|
||||
items: transformedActions,
|
||||
});
|
||||
}
|
||||
|
||||
if (item.type === 'view') {
|
||||
const view =
|
||||
item.key === TRIGGER_NODE_CREATOR_VIEW
|
||||
? TriggerView(instance?.proxy?.$locale)
|
||||
: RegularView(instance?.proxy?.$locale);
|
||||
|
||||
pushViewStack({
|
||||
title: view.title,
|
||||
subtitle: view?.subtitle ?? '',
|
||||
items: view.items as INodeCreateElement[],
|
||||
hasSearch: true,
|
||||
rootView: view.value as NodeFilterType,
|
||||
mode: 'nodes',
|
||||
// Root search should include all nodes
|
||||
searchItems: mergedNodes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function subcategoriesMapper(item: INodeCreateElement) {
|
||||
if (item.type !== 'node') return item;
|
||||
|
||||
const hasTriggerGroup = item.properties.group.includes('trigger');
|
||||
const nodeActions = actions?.[item.key] || [];
|
||||
const hasActions = nodeActions.length > 0;
|
||||
|
||||
if (hasTriggerGroup && hasActions) {
|
||||
if (item.properties?.codex) {
|
||||
// Store the original name in the alias so we can search for it
|
||||
item.properties.codex.alias = [
|
||||
...(item.properties.codex?.alias || []),
|
||||
item.properties.displayName,
|
||||
];
|
||||
}
|
||||
item.properties.displayName = item.properties.displayName.replace(' Trigger', '');
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
function baseSubcategoriesFilter(item: INodeCreateElement) {
|
||||
if (item.type !== 'node') return false;
|
||||
|
||||
const hasTriggerGroup = item.properties.group.includes('trigger');
|
||||
const nodeActions = actions?.[item.key] || [];
|
||||
const hasActions = nodeActions.length > 0;
|
||||
|
||||
const isTriggerRootView = activeViewStack.value.rootView === TRIGGER_NODE_CREATOR_VIEW;
|
||||
if (isTriggerRootView) {
|
||||
return hasActions || hasTriggerGroup;
|
||||
}
|
||||
|
||||
return hasActions || !hasTriggerGroup;
|
||||
}
|
||||
|
||||
function arrowLeft() {
|
||||
popViewStack();
|
||||
}
|
||||
|
||||
function onKeySelect(activeItemId: string) {
|
||||
const mergedItems = [
|
||||
...(activeViewStack.value.items || []),
|
||||
...(globalSearchItemsDiff.value || []),
|
||||
];
|
||||
|
||||
const item = mergedItems.find((i) => i.uuid === activeItemId);
|
||||
if (!item) return;
|
||||
|
||||
onSelected(item as INodeCreateElement);
|
||||
}
|
||||
|
||||
registerKeyHook('MainViewArrowRight', {
|
||||
keyboardKeys: ['ArrowRight', 'Enter'],
|
||||
condition: (type) => ['subcategory', 'node', 'view'].includes(type),
|
||||
handler: onKeySelect,
|
||||
});
|
||||
|
||||
registerKeyHook('MainViewArrowLeft', {
|
||||
keyboardKeys: ['ArrowLeft'],
|
||||
condition: (type) => ['subcategory', 'node', 'view'].includes(type),
|
||||
handler: arrowLeft,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span>
|
||||
<!-- Main Node Items -->
|
||||
<ItemsRenderer :elements="activeViewStack.items" @selected="onSelected" :class="$style.items">
|
||||
<template
|
||||
#empty
|
||||
v-if="(activeViewStack.items || []).length === 0 && globalSearchItemsDiff.length === 0"
|
||||
>
|
||||
<NoResults
|
||||
:rootView="activeViewStack.rootView"
|
||||
showIcon
|
||||
showRequest
|
||||
@addWebhookNode="selectNodeType([WEBHOOK_NODE_TYPE])"
|
||||
@addHttpNode="selectNodeType([HTTP_REQUEST_NODE_TYPE])"
|
||||
/>
|
||||
</template>
|
||||
</ItemsRenderer>
|
||||
<!-- Results in other categories -->
|
||||
<CategorizedItemsRenderer
|
||||
v-if="globalSearchItemsDiff.length > 0"
|
||||
:elements="globalSearchItemsDiff"
|
||||
:category="$locale.baseText('nodeCreator.categoryNames.otherCategories')"
|
||||
@selected="onSelected"
|
||||
>
|
||||
</CategorizedItemsRenderer>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.items {
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
</style>
|
|
@ -1,11 +1,10 @@
|
|||
<template>
|
||||
<div>
|
||||
<aside :class="{ 'node-creator-scrim': true, active: nodeCreatorStore.showScrim }" />
|
||||
|
||||
<aside :class="{ [$style.nodeCreatorScrim]: true, [$style.active]: showScrim }" />
|
||||
<slide-transition>
|
||||
<div
|
||||
v-if="active"
|
||||
class="node-creator"
|
||||
:class="$style.nodeCreator"
|
||||
ref="nodeCreator"
|
||||
v-click-outside="onClickOutside"
|
||||
@dragover="onDragOver"
|
||||
|
@ -14,38 +13,51 @@
|
|||
@mouseup="onMouseUp"
|
||||
data-test-id="node-creator"
|
||||
>
|
||||
<main-panel @nodeTypeSelected="$listeners.nodeTypeSelected" />
|
||||
<NodesListPanel @nodeTypeSelected="$listeners.nodeTypeSelected" />
|
||||
</div>
|
||||
</slide-transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch, reactive, toRefs } from 'vue';
|
||||
import { watch, reactive, toRefs, computed } from 'vue';
|
||||
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator';
|
||||
import SlideTransition from '@/components/transitions/SlideTransition.vue';
|
||||
|
||||
import MainPanel from './MainPanel.vue';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator';
|
||||
import { useViewStacks } from './composables/useViewStacks';
|
||||
import { useKeyboardNavigation } from './composables/useKeyboardNavigation';
|
||||
import { useActionsGenerator } from './composables/useActionsGeneration';
|
||||
import NodesListPanel from './Panel/NodesListPanel.vue';
|
||||
|
||||
export interface Props {
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const { resetViewStacks } = useViewStacks();
|
||||
const { registerKeyHook } = useKeyboardNavigation();
|
||||
const emit = defineEmits<{
|
||||
(event: 'closeNodeCreator'): void;
|
||||
(event: 'nodeTypeSelected', value: string[]): void;
|
||||
}>();
|
||||
|
||||
const nodeCreatorStore = useNodeCreatorStore();
|
||||
const { setShowScrim, setActions, setMergeNodes } = useNodeCreatorStore();
|
||||
const { generateMergedNodesAndActions } = useActionsGenerator();
|
||||
|
||||
const state = reactive({
|
||||
nodeCreator: null as HTMLElement | null,
|
||||
mousedownInsideEvent: null as MouseEvent | null,
|
||||
});
|
||||
|
||||
const showScrim = computed(() => useNodeCreatorStore().showScrim);
|
||||
|
||||
const viewStacksLength = computed(() => useViewStacks().viewStacks.length);
|
||||
|
||||
function onClickOutside(event: Event) {
|
||||
// We need to prevent cases where user would click inside the node creator
|
||||
// and try to drag undraggable element. In that case the click event would
|
||||
// and try to drag non-draggable element. In that case the click event would
|
||||
// be fired and the node creator would be closed. So we stop that if we detect
|
||||
// that the click event originated from inside the node creator. And fire click even on the
|
||||
// original target.
|
||||
|
@ -93,21 +105,49 @@ function onDrop(event: DragEvent) {
|
|||
watch(
|
||||
() => props.active,
|
||||
(isActive) => {
|
||||
if (isActive === false) nodeCreatorStore.setShowScrim(false);
|
||||
if (isActive === false) {
|
||||
setShowScrim(false);
|
||||
resetViewStacks();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Close node creator when the last view stacks is closed
|
||||
watch(viewStacksLength, (viewStacksLength) => {
|
||||
if (viewStacksLength === 0) {
|
||||
emit('closeNodeCreator');
|
||||
setShowScrim(false);
|
||||
}
|
||||
});
|
||||
|
||||
registerKeyHook('NodeCreatorCloseEscape', {
|
||||
keyboardKeys: ['Escape'],
|
||||
handler: () => emit('closeNodeCreator'),
|
||||
});
|
||||
registerKeyHook('NodeCreatorCloseTab', {
|
||||
keyboardKeys: ['Tab'],
|
||||
handler: () => emit('closeNodeCreator'),
|
||||
});
|
||||
|
||||
watch(
|
||||
() => useNodeTypesStore().visibleNodeTypes,
|
||||
(nodeTypes) => {
|
||||
const { actions, mergedNodes } = generateMergedNodesAndActions(nodeTypes);
|
||||
|
||||
setActions(actions);
|
||||
setMergeNodes(mergedNodes);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const { nodeCreator } = toRefs(state);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
::v-deep *,
|
||||
*:before,
|
||||
*:after {
|
||||
box-sizing: border-box;
|
||||
<style module lang="scss">
|
||||
:global(strong) {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.node-creator {
|
||||
.nodeCreator {
|
||||
--node-icon-color: var(--color-text-base);
|
||||
position: fixed;
|
||||
top: $header-height;
|
||||
bottom: 0;
|
||||
|
@ -117,7 +157,7 @@ const { nodeCreator } = toRefs(state);
|
|||
color: $node-creator-text-color;
|
||||
}
|
||||
|
||||
.node-creator-scrim {
|
||||
.nodeCreatorScrim {
|
||||
position: fixed;
|
||||
top: $header-height;
|
||||
right: 0;
|
||||
|
|
|
@ -1,12 +1,24 @@
|
|||
<template>
|
||||
<div :class="{ [$style.noResults]: true, [$style.iconless]: !showIcon }">
|
||||
<div
|
||||
:class="{ [$style.noResults]: true, [$style.iconless]: !showIcon }"
|
||||
data-test-id="node-creator-no-results"
|
||||
>
|
||||
<div :class="$style.icon" v-if="showIcon">
|
||||
<no-results-icon />
|
||||
</div>
|
||||
<div :class="$style.title">
|
||||
<slot name="title" />
|
||||
<p v-text="$locale.baseText('nodeCreator.noResults.weDidntMakeThatYet')" />
|
||||
<div :class="$style.action">
|
||||
<slot name="action" />
|
||||
{{ $locale.baseText('nodeCreator.noResults.dontWorryYouCanProbablyDoItWithThe') }}
|
||||
<n8n-link v-if="rootView === REGULAR_NODE_CREATOR_VIEW" @click="$emit('addHttpNode')">
|
||||
{{ $locale.baseText('nodeCreator.noResults.httpRequest') }}
|
||||
</n8n-link>
|
||||
|
||||
<n8n-link v-if="rootView === TRIGGER_NODE_CREATOR_VIEW" @click="$emit('addWebhookNode')">
|
||||
{{ $locale.baseText('nodeCreator.noResults.webhook') }}
|
||||
</n8n-link>
|
||||
{{ $locale.baseText('nodeCreator.noResults.node') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -30,12 +42,19 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { REQUEST_NODE_FORM_URL } from '@/constants';
|
||||
import {
|
||||
REQUEST_NODE_FORM_URL,
|
||||
REGULAR_NODE_CREATOR_VIEW,
|
||||
TRIGGER_NODE_CREATOR_VIEW,
|
||||
} from '@/constants';
|
||||
import type { NodeFilterType } from '@/Interface';
|
||||
|
||||
import NoResultsIcon from './NoResultsIcon.vue';
|
||||
|
||||
export interface Props {
|
||||
showIcon?: boolean;
|
||||
showRequest?: boolean;
|
||||
rootView?: NodeFilterType;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
|
@ -0,0 +1,258 @@
|
|||
<script setup lang="ts">
|
||||
import { getCurrentInstance, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import type { INodeCreateElement } from '@/Interface';
|
||||
import { TRIGGER_NODE_CREATOR_VIEW } from '@/constants';
|
||||
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator';
|
||||
|
||||
import { TriggerView, RegularView } from '../viewsData';
|
||||
import { useViewStacks } from '../composables/useViewStacks';
|
||||
import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
|
||||
import SearchBar from './SearchBar.vue';
|
||||
import ActionsRenderer from '../Modes/ActionsMode.vue';
|
||||
import NodesRenderer from '../Modes/NodesMode.vue';
|
||||
|
||||
const instance = getCurrentInstance();
|
||||
|
||||
const { mergedNodes } = useNodeCreatorStore();
|
||||
const { pushViewStack, popViewStack, updateCurrentViewStack } = useViewStacks();
|
||||
const { setActiveItemIndex, attachKeydownEvent, detachKeydownEvent } = useKeyboardNavigation();
|
||||
|
||||
const activeViewStack = computed(() => useViewStacks().activeViewStack);
|
||||
|
||||
const viewStacks = computed(() => useViewStacks().viewStacks);
|
||||
|
||||
const isActionsMode = computed(() => useViewStacks().activeViewStackMode === 'actions');
|
||||
const searchPlaceholder = computed(() =>
|
||||
isActionsMode.value
|
||||
? instance?.proxy?.$locale.baseText('nodeCreator.actionsCategory.searchActions', {
|
||||
interpolate: { node: activeViewStack.value.title as string },
|
||||
})
|
||||
: instance?.proxy?.$locale.baseText('nodeCreator.searchBar.searchNodes'),
|
||||
);
|
||||
|
||||
const nodeCreatorView = computed(() => useNodeCreatorStore().selectedView);
|
||||
|
||||
function onSearch(value: string) {
|
||||
if (activeViewStack.value.uuid) {
|
||||
updateCurrentViewStack({ search: value });
|
||||
setActiveItemIndex(activeViewStack.value.activeIndex ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
function onTransitionEnd() {
|
||||
// For actions, set the active focus to the first action, not category
|
||||
const newStackIndex = activeViewStack.value.mode === 'actions' ? 1 : 0;
|
||||
setActiveItemIndex(activeViewStack.value.activeIndex || 0 || newStackIndex);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
attachKeydownEvent();
|
||||
setActiveItemIndex(activeViewStack.value.activeIndex ?? 0);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
detachKeydownEvent();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => nodeCreatorView.value,
|
||||
(selectedView) => {
|
||||
const view =
|
||||
selectedView === TRIGGER_NODE_CREATOR_VIEW
|
||||
? TriggerView(instance?.proxy?.$locale)
|
||||
: RegularView(instance?.proxy?.$locale);
|
||||
|
||||
pushViewStack({
|
||||
title: view.title,
|
||||
subtitle: view?.subtitle ?? '',
|
||||
items: view.items as INodeCreateElement[],
|
||||
hasSearch: true,
|
||||
mode: 'nodes',
|
||||
rootView: selectedView,
|
||||
// Root search should include all nodes
|
||||
searchItems: mergedNodes,
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function onBackButton() {
|
||||
popViewStack();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition
|
||||
v-if="viewStacks.length > 0"
|
||||
:name="`panel-slide-${activeViewStack.transitionDirection}`"
|
||||
@afterLeave="onTransitionEnd"
|
||||
>
|
||||
<aside :class="$style.nodesListPanel" @keydown.capture.stop :key="`${activeViewStack.uuid}`">
|
||||
<header
|
||||
:class="{ [$style.header]: true, [$style.hasBg]: !activeViewStack.subtitle }"
|
||||
data-test-id="nodes-list-header"
|
||||
>
|
||||
<div :class="$style.top">
|
||||
<button :class="$style.backButton" @click="onBackButton" v-if="viewStacks.length > 1">
|
||||
<font-awesome-icon :class="$style.backButtonIcon" icon="arrow-left" size="2x" />
|
||||
</button>
|
||||
<n8n-node-icon
|
||||
v-if="activeViewStack.nodeIcon"
|
||||
:class="$style.nodeIcon"
|
||||
:type="activeViewStack.nodeIcon.iconType || 'unknown'"
|
||||
:src="activeViewStack.nodeIcon.icon"
|
||||
:name="activeViewStack.nodeIcon.icon"
|
||||
:color="activeViewStack.nodeIcon.color"
|
||||
:circle="false"
|
||||
:showTooltip="false"
|
||||
:size="16"
|
||||
/>
|
||||
<p :class="$style.title" v-text="activeViewStack.title" v-if="activeViewStack.title" />
|
||||
</div>
|
||||
<p
|
||||
v-if="activeViewStack.subtitle"
|
||||
:class="{ [$style.subtitle]: true, [$style.offsetSubtitle]: viewStacks.length > 1 }"
|
||||
v-text="activeViewStack.subtitle"
|
||||
/>
|
||||
</header>
|
||||
<search-bar
|
||||
v-if="activeViewStack.hasSearch"
|
||||
:class="$style.searchBar"
|
||||
:placeholder="
|
||||
searchPlaceholder
|
||||
? searchPlaceholder
|
||||
: $locale.baseText('nodeCreator.searchBar.searchNodes')
|
||||
"
|
||||
@input="onSearch"
|
||||
:value="activeViewStack.search"
|
||||
/>
|
||||
<div :class="$style.renderedItems">
|
||||
<!-- Actions mode -->
|
||||
<ActionsRenderer v-if="isActionsMode && activeViewStack.subcategory" v-on="$listeners" />
|
||||
|
||||
<!-- Nodes Mode -->
|
||||
<NodesRenderer v-else :rootView="nodeCreatorView" v-on="$listeners" />
|
||||
</div>
|
||||
</aside>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
:global(.panel-slide-in-leave-active),
|
||||
:global(.panel-slide-in-enter-active),
|
||||
:global(.panel-slide-out-leave-active),
|
||||
:global(.panel-slide-out-enter-active) {
|
||||
transition: transform 200ms ease;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
:global(.panel-slide-out-enter),
|
||||
:global(.panel-slide-in-leave-to) {
|
||||
transform: translateX(0);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
:global(.panel-slide-out-leave-to),
|
||||
:global(.panel-slide-in-enter) {
|
||||
transform: translateX(100%);
|
||||
// Make sure the leaving panel stays on top
|
||||
// for the slide-out panel effect
|
||||
z-index: 1;
|
||||
}
|
||||
.backButton {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0 var(--spacing-xs) 0 0;
|
||||
}
|
||||
|
||||
.backButtonIcon {
|
||||
color: $node-creator-arrow-color;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
}
|
||||
.nodeIcon {
|
||||
--node-icon-size: 16px;
|
||||
margin-right: var(--spacing-s);
|
||||
}
|
||||
.renderedItems {
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
scrollbar-width: none; /* Firefox 64 */
|
||||
padding-bottom: var(--spacing-xl);
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.searchBar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nodesListPanel {
|
||||
background: var(--color-background-xlight);
|
||||
height: 100%;
|
||||
background-color: $node-creator-background-color;
|
||||
width: 385px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:before {
|
||||
box-sizing: border-box;
|
||||
content: '';
|
||||
border-left: 1px solid $node-creator-border-color;
|
||||
width: 1px;
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
.footer {
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-text-base);
|
||||
margin: 0 var(--spacing-xs) 0;
|
||||
padding: var(--spacing-4xs) 0;
|
||||
line-height: var(--font-line-height-regular);
|
||||
border-top: 1px solid var(--color-foreground-base);
|
||||
z-index: 1;
|
||||
margin-top: -1px;
|
||||
}
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header {
|
||||
font-size: var(--font-size-l);
|
||||
font-weight: var(--font-weight-bold);
|
||||
line-height: var(--font-line-height-compact);
|
||||
|
||||
padding: var(--spacing-s) var(--spacing-s);
|
||||
|
||||
&.hasBg {
|
||||
border-bottom: $node-creator-border-color solid 1px;
|
||||
background-color: $node-creator-subcategory-panel-header-bacground-color;
|
||||
}
|
||||
}
|
||||
.title {
|
||||
line-height: 24px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: var(--font-size-l);
|
||||
|
||||
.hasBg & {
|
||||
font-size: var(--font-size-s-m);
|
||||
line-height: 22px;
|
||||
}
|
||||
}
|
||||
.subtitle {
|
||||
margin-top: var(--spacing-4xs);
|
||||
font-size: var(--font-size-s);
|
||||
line-height: 19px;
|
||||
color: var(--color-text-base);
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
.offsetSubtitle {
|
||||
margin-left: calc(var(--spacing-xl) + var(--spacing-4xs));
|
||||
}
|
||||
</style>
|
|
@ -25,13 +25,12 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, toRefs, onBeforeUnmount } from 'vue';
|
||||
import type { EventBus } from '@/event-bus';
|
||||
import { useExternalHooks } from '@/composables';
|
||||
import { useWebhooksStore } from '@/stores/webhooks';
|
||||
import { runExternalHook } from '@/utils';
|
||||
|
||||
export interface Props {
|
||||
placeholder: string;
|
||||
value: string;
|
||||
eventBus?: EventBus;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
|
@ -43,8 +42,6 @@ const emit = defineEmits<{
|
|||
(event: 'input', value: string): void;
|
||||
}>();
|
||||
|
||||
const externalHooks = useExternalHooks();
|
||||
|
||||
const state = reactive({
|
||||
inputRef: null as HTMLInputElement | null,
|
||||
});
|
||||
|
@ -63,7 +60,7 @@ function clear() {
|
|||
}
|
||||
|
||||
onMounted(() => {
|
||||
externalHooks.run('nodeCreator_searchBar.mount', { inputRef: state.inputRef });
|
||||
runExternalHook('nodeCreator_searchBar.mount', useWebhooksStore(), { inputRef: state.inputRef });
|
||||
setTimeout(focus, 0);
|
||||
});
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, watch, ref, getCurrentInstance } from 'vue';
|
||||
import type { INodeCreateElement } from '@/Interface';
|
||||
|
||||
import { useWorkflowsStore } from '@/stores/workflows';
|
||||
|
||||
import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
|
||||
import { useViewStacks } from '../composables/useViewStacks';
|
||||
import ItemsRenderer from './ItemsRenderer.vue';
|
||||
import CategoryItem from '../ItemTypes/CategoryItem.vue';
|
||||
|
||||
export interface Props {
|
||||
elements: INodeCreateElement[];
|
||||
category: string;
|
||||
disabled?: boolean;
|
||||
activeIndex?: number;
|
||||
isTriggerCategory?: boolean;
|
||||
mouseOverTooltip?: string;
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
elements: () => [],
|
||||
});
|
||||
|
||||
const instance = getCurrentInstance();
|
||||
|
||||
const { popViewStack } = useViewStacks();
|
||||
const { registerKeyHook } = useKeyboardNavigation();
|
||||
const { workflowId } = useWorkflowsStore();
|
||||
|
||||
const activeItemId = computed(() => useKeyboardNavigation()?.activeItemId);
|
||||
const expanded = ref(props.expanded ?? false);
|
||||
|
||||
function toggleExpanded() {
|
||||
setExpanded(!expanded.value);
|
||||
}
|
||||
|
||||
function setExpanded(isExpanded: boolean) {
|
||||
expanded.value = isExpanded;
|
||||
|
||||
if (expanded.value) {
|
||||
instance?.proxy.$telemetry.trackNodesPanel('nodeCreateList.onCategoryExpanded', {
|
||||
category_name: props.category,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function arrowRight() {
|
||||
if (expanded.value) return;
|
||||
|
||||
setExpanded(true);
|
||||
}
|
||||
|
||||
function arrowLeft() {
|
||||
if (!expanded.value) {
|
||||
popViewStack();
|
||||
return;
|
||||
}
|
||||
|
||||
setExpanded(false);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.elements,
|
||||
() => {
|
||||
setExpanded(true);
|
||||
},
|
||||
);
|
||||
|
||||
registerKeyHook(`CategoryRight_${props.category}`, {
|
||||
keyboardKeys: ['ArrowRight'],
|
||||
condition: (type, activeItemId) => type === 'category' && props.category === activeItemId,
|
||||
handler: arrowRight,
|
||||
});
|
||||
registerKeyHook(`CategoryToggle_${props.category}`, {
|
||||
keyboardKeys: ['Enter'],
|
||||
condition: (type, activeItemId) => type === 'category' && props.category === activeItemId,
|
||||
handler: toggleExpanded,
|
||||
});
|
||||
|
||||
registerKeyHook(`CategoryLeft_${props.category}`, {
|
||||
keyboardKeys: ['ArrowLeft'],
|
||||
condition: (type, activeItemId) => type === 'category' && props.category === activeItemId,
|
||||
handler: arrowLeft,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.categorizedItemsRenderer" :data-category-collapsed="!expanded">
|
||||
<CategoryItem
|
||||
:class="$style.categoryItem"
|
||||
:name="category"
|
||||
:disabled="disabled"
|
||||
:active="activeItemId === category"
|
||||
:count="elements.length"
|
||||
:expanded="expanded"
|
||||
:isTrigger="isTriggerCategory"
|
||||
data-keyboard-nav-type="category"
|
||||
:data-keyboard-nav-id="category"
|
||||
@click="toggleExpanded"
|
||||
>
|
||||
<span :class="$style.mouseOverTooltip" v-if="mouseOverTooltip">
|
||||
<n8n-tooltip placement="top" :popper-class="$style.tooltipPopper">
|
||||
<n8n-icon icon="question-circle" size="small" />
|
||||
<template #content>
|
||||
<div v-html="mouseOverTooltip" />
|
||||
</template>
|
||||
</n8n-tooltip>
|
||||
</span>
|
||||
</CategoryItem>
|
||||
<div :class="$style.contentSlot" v-if="expanded && elements.length > 0 && $slots.default">
|
||||
<slot />
|
||||
</div>
|
||||
<!-- Pass through listeners & empty slot to ItemsRenderer -->
|
||||
<ItemsRenderer
|
||||
v-if="expanded"
|
||||
:elements="elements"
|
||||
v-on="$listeners"
|
||||
:isTrigger="isTriggerCategory"
|
||||
>
|
||||
<template #default> </template>
|
||||
<template #empty>
|
||||
<slot name="empty" v-bind="{ elements }" />
|
||||
</template>
|
||||
</ItemsRenderer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.mouseOverTooltip {
|
||||
opacity: 0;
|
||||
margin-left: var(--spacing-3xs);
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.categorizedItemsRenderer:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.tooltipPopper {
|
||||
max-width: 260px;
|
||||
}
|
||||
.contentSlot {
|
||||
padding: 0 var(--spacing-s) var(--spacing-3xs);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
.categorizedItemsRenderer {
|
||||
padding-bottom: var(--spacing-s);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,215 @@
|
|||
<script setup lang="ts">
|
||||
import type { INodeCreateElement } from '@/Interface';
|
||||
import { onMounted, watch, onUnmounted, ref, computed } from 'vue';
|
||||
|
||||
import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
|
||||
import NodeItem from '../ItemTypes/NodeItem.vue';
|
||||
import SubcategoryItem from '../ItemTypes/SubcategoryItem.vue';
|
||||
import LabelItem from '../ItemTypes/LabelItem.vue';
|
||||
import ActionItem from '../ItemTypes/ActionItem.vue';
|
||||
import ViewItem from '../ItemTypes/ViewItem.vue';
|
||||
export interface Props {
|
||||
elements: INodeCreateElement[];
|
||||
activeIndex?: number;
|
||||
disabled?: boolean;
|
||||
lazyRender?: boolean;
|
||||
}
|
||||
|
||||
const LAZY_LOAD_THRESHOLD = 20;
|
||||
const LAZY_LOAD_ITEMS_PER_TICK = 5;
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
elements: () => [],
|
||||
lazyRender: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'selected', element: INodeCreateElement, $e?: Event): void;
|
||||
(event: 'dragstart', element: INodeCreateElement, $e: Event): void;
|
||||
(event: 'dragend', element: INodeCreateElement, $e: Event): void;
|
||||
}>();
|
||||
|
||||
const renderedItems = ref<INodeCreateElement[]>([]);
|
||||
const renderAnimationRequest = ref<number>(0);
|
||||
|
||||
const activeItemId = computed(() => useKeyboardNavigation()?.activeItemId);
|
||||
|
||||
// Lazy render large items lists to prevent the browser from freezing
|
||||
// when loading many items.
|
||||
function renderItems() {
|
||||
if (props.elements.length <= LAZY_LOAD_THRESHOLD || props.lazyRender === false) {
|
||||
renderedItems.value = props.elements;
|
||||
return;
|
||||
}
|
||||
|
||||
if (renderedItems.value.length < props.elements.length) {
|
||||
renderedItems.value.push(
|
||||
...props.elements.slice(
|
||||
renderedItems.value.length,
|
||||
renderedItems.value.length + LAZY_LOAD_ITEMS_PER_TICK,
|
||||
),
|
||||
);
|
||||
renderAnimationRequest.value = window.requestAnimationFrame(renderItems);
|
||||
}
|
||||
}
|
||||
|
||||
function wrappedEmit(
|
||||
event: 'selected' | 'dragstart' | 'dragend',
|
||||
element: INodeCreateElement,
|
||||
$e?: Event,
|
||||
) {
|
||||
if (props.disabled) return;
|
||||
|
||||
emit((event as 'selected') || 'dragstart' || 'dragend', element, $e);
|
||||
}
|
||||
|
||||
function beforeEnter(el: HTMLElement) {
|
||||
el.style.height = '0';
|
||||
}
|
||||
|
||||
function enter(el: HTMLElement) {
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}
|
||||
|
||||
function beforeLeave(el: HTMLElement) {
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}
|
||||
|
||||
function leave(el: HTMLElement) {
|
||||
el.style.height = '0';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
renderItems();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.cancelAnimationFrame(renderAnimationRequest.value);
|
||||
renderedItems.value = [];
|
||||
});
|
||||
|
||||
// Make sure the active item is always visible
|
||||
// scroll if needed
|
||||
watch(
|
||||
() => props.elements,
|
||||
() => {
|
||||
window.cancelAnimationFrame(renderAnimationRequest.value);
|
||||
renderedItems.value = [];
|
||||
renderItems();
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="elements.length > 0"
|
||||
:class="$style.itemsRenderer"
|
||||
name="accordion"
|
||||
@before-enter="beforeEnter"
|
||||
@enter="enter"
|
||||
@before-leave="beforeLeave"
|
||||
@leave="leave"
|
||||
>
|
||||
<slot />
|
||||
<div
|
||||
v-for="item in elements"
|
||||
:key="item.uuid"
|
||||
data-test-id="item-iterator-item"
|
||||
:class="{
|
||||
clickable: !disabled,
|
||||
[$style.active]: activeItemId === item.uuid,
|
||||
[$style.iteratorItem]: true,
|
||||
[$style[item.type]]: true,
|
||||
}"
|
||||
ref="iteratorItems"
|
||||
:data-keyboard-nav-type="item.type !== 'label' ? item.type : undefined"
|
||||
:data-keyboard-nav-id="item.uuid"
|
||||
@click="wrappedEmit('selected', item)"
|
||||
>
|
||||
<template v-if="renderedItems.includes(item)">
|
||||
<label-item v-if="item.type === 'label'" :item="item" />
|
||||
<subcategory-item v-if="item.type === 'subcategory'" :item="item.properties" />
|
||||
|
||||
<node-item
|
||||
v-if="item.type === 'node'"
|
||||
:nodeType="item.properties"
|
||||
:active="true"
|
||||
:subcategory="item.subcategory"
|
||||
/>
|
||||
|
||||
<action-item
|
||||
v-if="item.type === 'action'"
|
||||
:nodeType="item.properties"
|
||||
:action="item.properties"
|
||||
:active="true"
|
||||
/>
|
||||
|
||||
<view-item
|
||||
v-else-if="item.type === 'view'"
|
||||
:view="item.properties"
|
||||
:class="$style.viewItem"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<n8n-loading :loading="true" :rows="1" variant="p" :class="$style.itemSkeleton" v-else />
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.empty" v-else>
|
||||
<slot name="empty" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.itemSkeleton {
|
||||
height: 50px;
|
||||
}
|
||||
.iteratorItem {
|
||||
// Make sure border is fully visible
|
||||
margin-left: 1px;
|
||||
position: relative;
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
&:not(.label):not(.category):hover::before {
|
||||
border-color: $node-creator-item-hover-border-color;
|
||||
}
|
||||
|
||||
&.active:not(.category)::before {
|
||||
border-color: $color-primary;
|
||||
}
|
||||
}
|
||||
.empty {
|
||||
:global([role='alert']) {
|
||||
margin: var(--spacing-xs) var(--spacing-s);
|
||||
}
|
||||
}
|
||||
.itemsRenderer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
scrollbar-width: none; /* Firefox 64 */
|
||||
& > *::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.view {
|
||||
margin-top: var(--spacing-s);
|
||||
padding-top: var(--spacing-xs);
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: var(--spacing-s);
|
||||
right: var(--spacing-s);
|
||||
top: 0;
|
||||
margin: auto;
|
||||
bottom: 0;
|
||||
border-top: 1px solid var(--color-foreground-base);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,31 @@
|
|||
import { render, screen } from '@testing-library/vue';
|
||||
import CategoryItem from '../ItemTypes/CategoryItem.vue';
|
||||
|
||||
describe('CategoryItem', () => {
|
||||
it('should allow expand and collapse', async () => {
|
||||
const { container, updateProps } = render(CategoryItem, { props: { name: 'Category Test' } });
|
||||
|
||||
expect(container.querySelector('[data-icon="chevron-down"]')).toBeInTheDocument();
|
||||
await updateProps({ expanded: false });
|
||||
expect(container.querySelector('[data-icon="chevron-down"]')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('[data-icon="chevron-up"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show count', async () => {
|
||||
const { updateProps } = render(CategoryItem, { props: { name: 'Category Test', count: 10 } });
|
||||
|
||||
expect(screen.getByText('Category Test (10)')).toBeInTheDocument();
|
||||
await updateProps({ count: 0 });
|
||||
expect(screen.getByText('Category Test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show trigger icon', async () => {
|
||||
const { updateProps, container } = render(CategoryItem, {
|
||||
props: { name: 'Category Test', isTrigger: true },
|
||||
});
|
||||
|
||||
expect(container.querySelector('[data-icon="bolt"]')).toBeInTheDocument();
|
||||
await updateProps({ isTrigger: false });
|
||||
expect(container.querySelector('[data-icon="bolt"]')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,82 @@
|
|||
import Vue from 'vue';
|
||||
import { PiniaVuePlugin } from 'pinia';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { render, fireEvent } from '@testing-library/vue';
|
||||
import {
|
||||
mockSubcategoryCreateElement,
|
||||
mockLabelCreateElement,
|
||||
mockNodeCreateElement,
|
||||
} from './utils';
|
||||
import ItemsRenderer from '../Renderers/ItemsRenderer.vue';
|
||||
import { mockActionCreateElement } from './utils';
|
||||
import { mockViewCreateElement } from './utils';
|
||||
|
||||
describe('ItemsRenderer', () => {
|
||||
it('should render items', async () => {
|
||||
const items = [
|
||||
mockSubcategoryCreateElement({ title: 'Subcategory 1' }),
|
||||
mockLabelCreateElement('subcategory', { key: 'label1' }),
|
||||
mockNodeCreateElement('subcategory', { displayName: 'Node 1', name: 'node1' }),
|
||||
mockNodeCreateElement('subcategory', { displayName: 'Node 2', name: 'node2' }),
|
||||
mockNodeCreateElement('subcategory', { displayName: 'Node 3', name: 'node3' }),
|
||||
mockLabelCreateElement('subcategory', { key: 'label2' }),
|
||||
mockNodeCreateElement('subcategory', { displayName: 'Node 2', name: 'node2' }),
|
||||
mockNodeCreateElement('subcategory', { displayName: 'Node 3', name: 'node3' }),
|
||||
mockSubcategoryCreateElement({ title: 'Subcategory 2' }),
|
||||
];
|
||||
const { container } = render(
|
||||
ItemsRenderer,
|
||||
{
|
||||
pinia: createTestingPinia(),
|
||||
props: { elements: items },
|
||||
},
|
||||
(vue) => {
|
||||
vue.use(PiniaVuePlugin);
|
||||
},
|
||||
);
|
||||
//
|
||||
await Vue.nextTick();
|
||||
|
||||
const nodeItems = container.querySelectorAll('.iteratorItem .nodeItem');
|
||||
const labels = container.querySelectorAll('.iteratorItem .label');
|
||||
const subCategories = container.querySelectorAll('.iteratorItem .subCategory');
|
||||
|
||||
expect(nodeItems.length).toBe(5);
|
||||
expect(labels.length).toBe(2);
|
||||
expect(subCategories.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should fire selected events on click', async () => {
|
||||
const items = [
|
||||
mockSubcategoryCreateElement(),
|
||||
mockNodeCreateElement(),
|
||||
mockActionCreateElement(),
|
||||
mockViewCreateElement(),
|
||||
];
|
||||
const { container, emitted } = render(
|
||||
ItemsRenderer,
|
||||
{
|
||||
pinia: createTestingPinia(),
|
||||
props: { elements: items },
|
||||
},
|
||||
(vue) => {
|
||||
vue.use(PiniaVuePlugin);
|
||||
},
|
||||
);
|
||||
//
|
||||
await Vue.nextTick();
|
||||
|
||||
const itemTypes = {
|
||||
node: container.querySelector('.iteratorItem .nodeItem'),
|
||||
subcategory: container.querySelector('.iteratorItem .subCategory'),
|
||||
action: container.querySelector('.iteratorItem .action'),
|
||||
view: container.querySelector('.iteratorItem .view'),
|
||||
};
|
||||
|
||||
for (const [index, itemType] of Object.keys(itemTypes).entries()) {
|
||||
const itemElement = itemTypes[itemType as keyof typeof itemTypes];
|
||||
await fireEvent.click(itemElement!);
|
||||
expect(emitted().selected[index][0].type).toBe(itemType);
|
||||
}
|
||||
});
|
||||
});
|
|
@ -0,0 +1,286 @@
|
|||
import Vue, { defineComponent, watch } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import { PiniaVuePlugin, createPinia } from 'pinia';
|
||||
import { render, screen, fireEvent } from '@testing-library/vue';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator';
|
||||
import { mockSimplifiedNodeType } from './utils';
|
||||
import NodesListPanel from '../Panel/NodesListPanel.vue';
|
||||
import { REGULAR_NODE_CREATOR_VIEW } from '@/constants';
|
||||
import type { NodeFilterType } from '@/Interface';
|
||||
|
||||
function TelemetryPlugin(vue: typeof Vue): void {
|
||||
Object.defineProperty(vue, '$telemetry', {
|
||||
get() {
|
||||
return {
|
||||
trackNodesPanel: () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
Object.defineProperty(vue.prototype, '$telemetry', {
|
||||
get() {
|
||||
return {
|
||||
trackNodesPanel: () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getWrapperComponent(setup: () => void) {
|
||||
const wrapperComponent = defineComponent({
|
||||
props: {
|
||||
nodeTypes: {
|
||||
type: Array as PropType<INodeTypeDescription[]>,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
NodesListPanel,
|
||||
},
|
||||
setup,
|
||||
template: '<NodesListPanel @nodeTypeSelected="e => $emit(\'nodeTypeSelected\', e)" />',
|
||||
});
|
||||
|
||||
return render(
|
||||
wrapperComponent,
|
||||
{
|
||||
pinia: createPinia(),
|
||||
},
|
||||
(vue) => {
|
||||
vue.use(PiniaVuePlugin);
|
||||
vue.use(TelemetryPlugin);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe('NodesListPanel', () => {
|
||||
describe('should render nodes', () => {
|
||||
it('should render trigger items', async () => {
|
||||
const mockedTriggerNodes = [...Array(2).keys()].map((n) =>
|
||||
mockSimplifiedNodeType({
|
||||
name: `Trigger Node ${n}`,
|
||||
displayName: `Trigger Node ${n}`,
|
||||
group: ['trigger'],
|
||||
}),
|
||||
);
|
||||
const mockedRegularNodes = [...Array(2).keys()].map((n) =>
|
||||
mockSimplifiedNodeType({
|
||||
name: `Regular Node ${n}`,
|
||||
displayName: `Regular Node ${n}`,
|
||||
group: ['input'],
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = getWrapperComponent(() => {
|
||||
const { setMergeNodes } = useNodeCreatorStore();
|
||||
|
||||
setMergeNodes([...mockedTriggerNodes, ...mockedRegularNodes]);
|
||||
return {};
|
||||
});
|
||||
|
||||
await Vue.nextTick();
|
||||
expect(screen.getByText('Select a trigger')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('node-creator-search-bar')).toBeInTheDocument();
|
||||
screen.getByText('On app event').click();
|
||||
await Vue.nextTick();
|
||||
expect(screen.queryByTestId('node-creator-search-bar')).not.toBeInTheDocument();
|
||||
mockedTriggerNodes.forEach((n) => {
|
||||
expect(screen.queryByText(n.name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
mockedRegularNodes.forEach((n) => {
|
||||
expect(screen.queryByText(n.name)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(container.querySelector('.backButton')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(container.querySelector('.backButton')!);
|
||||
await Vue.nextTick();
|
||||
|
||||
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('should render regular nodes', async () => {
|
||||
const mockedNodes = [...Array(8).keys()].map((n) =>
|
||||
mockSimplifiedNodeType({
|
||||
name: `Node ${n}`,
|
||||
displayName: `Node ${n}`,
|
||||
group: ['input'],
|
||||
}),
|
||||
);
|
||||
|
||||
const wrapperComponent = defineComponent({
|
||||
props: {
|
||||
nodeTypes: {
|
||||
type: Array as PropType<INodeTypeDescription[]>,
|
||||
required: true,
|
||||
},
|
||||
selectedView: {
|
||||
type: String as PropType<NodeFilterType>,
|
||||
default: REGULAR_NODE_CREATOR_VIEW,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
NodesListPanel,
|
||||
},
|
||||
setup(props) {
|
||||
const { setActions, setMergeNodes, setSelectedView } = useNodeCreatorStore();
|
||||
|
||||
watch(
|
||||
() => props.nodeTypes,
|
||||
(nodeTypes: INodeTypeDescription[]) => {
|
||||
setMergeNodes([...nodeTypes]);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(
|
||||
() => props.selectedView,
|
||||
(selectedView: NodeFilterType) => {
|
||||
setSelectedView(selectedView);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
},
|
||||
template: '<NodesListPanel @nodeTypeSelected="e => $emit(\'nodeTypeSelected\', e)" />',
|
||||
});
|
||||
|
||||
render(
|
||||
wrapperComponent,
|
||||
{
|
||||
pinia: createPinia(),
|
||||
props: {
|
||||
nodeTypes: mockedNodes,
|
||||
selectedView: REGULAR_NODE_CREATOR_VIEW,
|
||||
},
|
||||
},
|
||||
(vue) => {
|
||||
vue.use(PiniaVuePlugin);
|
||||
vue.use(TelemetryPlugin);
|
||||
},
|
||||
);
|
||||
|
||||
await Vue.nextTick();
|
||||
expect(screen.getByText('What happens next?')).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(6);
|
||||
|
||||
screen.getByText('Action in an app').click();
|
||||
await Vue.nextTick();
|
||||
mockedNodes.forEach((n) => {
|
||||
expect(screen.queryByText(n.displayName)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('should search nodes', () => {
|
||||
const mockedNodes = [...Array(8).keys()].map((n) =>
|
||||
mockSimplifiedNodeType({
|
||||
name: `Node ${n}`,
|
||||
displayName: `Node ${n}`,
|
||||
group: ['trigger'],
|
||||
}),
|
||||
);
|
||||
|
||||
const wrapperComponent = defineComponent({
|
||||
props: {
|
||||
nodeTypes: {
|
||||
type: Array as PropType<INodeTypeDescription[]>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
NodesListPanel,
|
||||
},
|
||||
setup(props) {
|
||||
const { setMergeNodes } = useNodeCreatorStore();
|
||||
|
||||
watch(
|
||||
() => props.nodeTypes,
|
||||
(nodeTypes: INodeTypeDescription[]) => {
|
||||
setMergeNodes([...nodeTypes]);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
},
|
||||
template: '<NodesListPanel @nodeTypeSelected="e => $emit(\'nodeTypeSelected\', e)" />',
|
||||
});
|
||||
|
||||
function renderComponent() {
|
||||
return render(
|
||||
wrapperComponent,
|
||||
{
|
||||
pinia: createPinia(),
|
||||
props: {
|
||||
nodeTypes: mockedNodes,
|
||||
},
|
||||
},
|
||||
(vue) => {
|
||||
vue.use(PiniaVuePlugin);
|
||||
vue.use(TelemetryPlugin);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
it('should be visible in the root view', async () => {
|
||||
renderComponent();
|
||||
await Vue.nextTick();
|
||||
|
||||
expect(screen.queryByTestId('node-creator-search-bar')).toBeInTheDocument();
|
||||
});
|
||||
it('should not be visible if subcategory contains less than 9 items', async () => {
|
||||
renderComponent();
|
||||
await Vue.nextTick();
|
||||
|
||||
screen.getByText('On app event').click();
|
||||
await Vue.nextTick();
|
||||
expect(screen.queryByTestId('node-creator-search-bar')).not.toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(8);
|
||||
});
|
||||
it('should be visible if subcategory contains 9 or more items', async () => {
|
||||
const { updateProps } = renderComponent();
|
||||
await Vue.nextTick();
|
||||
|
||||
mockedNodes.push(
|
||||
mockSimplifiedNodeType({
|
||||
name: 'Ninth node',
|
||||
displayName: 'Ninth node',
|
||||
group: ['trigger'],
|
||||
}),
|
||||
);
|
||||
|
||||
await updateProps({ nodeTypes: [...mockedNodes] });
|
||||
await Vue.nextTick();
|
||||
|
||||
screen.getByText('On app event').click();
|
||||
await Vue.nextTick();
|
||||
|
||||
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(9);
|
||||
expect(screen.queryByTestId('node-creator-search-bar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should correctly handle search', async () => {
|
||||
const { container } = renderComponent();
|
||||
await Vue.nextTick();
|
||||
|
||||
screen.getByText('On app event').click();
|
||||
await Vue.nextTick();
|
||||
|
||||
fireEvent.input(screen.getByTestId('node-creator-search-bar'), {
|
||||
target: { value: 'Ninth' },
|
||||
});
|
||||
await Vue.nextTick();
|
||||
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(1);
|
||||
|
||||
fireEvent.input(screen.getByTestId('node-creator-search-bar'), {
|
||||
target: { value: 'Non sense' },
|
||||
});
|
||||
await Vue.nextTick();
|
||||
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(0);
|
||||
expect(screen.queryByText("We didn't make that... yet")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(container.querySelector('.clear')!);
|
||||
await Vue.nextTick();
|
||||
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(9);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,90 @@
|
|||
import { render } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
|
||||
import { PiniaVuePlugin, createPinia } from 'pinia';
|
||||
|
||||
const eventHookSpy = vi.fn();
|
||||
describe('useKeyboardNavigation', () => {
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
const { attachKeydownEvent, setActiveItemId, detachKeydownEvent, registerKeyHook } =
|
||||
useKeyboardNavigation();
|
||||
|
||||
setActiveItemId('item1');
|
||||
const activeItemId = computed(() => useKeyboardNavigation().activeItemId);
|
||||
|
||||
registerKeyHook('testKeys', {
|
||||
keyboardKeys: ['ArrowDown', 'ArrowUp', 'Enter', 'ArrowRight', 'ArrowLeft', 'Escape'],
|
||||
handler: eventHookSpy,
|
||||
});
|
||||
|
||||
return { attachKeydownEvent, detachKeydownEvent, setActiveItemId, activeItemId };
|
||||
},
|
||||
template: `
|
||||
<span>
|
||||
<div
|
||||
v-for="item in 3"
|
||||
:class="{'active': activeItemId === 'item' + item}"
|
||||
:key="'item' + item"
|
||||
v-text="activeItemId"
|
||||
:data-keyboard-nav-id="'item' + item"
|
||||
data-keyboard-nav-type="node"
|
||||
/>
|
||||
</span>
|
||||
`,
|
||||
mounted() {
|
||||
this.attachKeydownEvent();
|
||||
},
|
||||
});
|
||||
|
||||
const renderTestComponent = () => {
|
||||
return render(TestComponent, { pinia: createPinia() }, (vue) => {
|
||||
vue.use(PiniaVuePlugin);
|
||||
});
|
||||
};
|
||||
|
||||
afterAll(() => {
|
||||
eventHookSpy.mockClear();
|
||||
});
|
||||
|
||||
test('ArrowDown moves to the next item, cycling after last item', async () => {
|
||||
const { container } = renderTestComponent();
|
||||
|
||||
expect(container.querySelector('[data-keyboard-nav-id="item1"]')).toHaveClass('active');
|
||||
await userEvent.keyboard('{arrowdown}');
|
||||
await userEvent.keyboard('{arrowdown}');
|
||||
expect(container.querySelector('[data-keyboard-nav-id="item3"]')).toHaveClass('active');
|
||||
await userEvent.keyboard('{arrowdown}');
|
||||
expect(container.querySelector('[data-keyboard-nav-id="item1"]')).toHaveClass('active');
|
||||
});
|
||||
|
||||
test('ArrowUp moves to the previous item, cycling after firstitem', async () => {
|
||||
const { container } = renderTestComponent();
|
||||
|
||||
expect(container.querySelector('[data-keyboard-nav-id="item1"]')).toHaveClass('active');
|
||||
await userEvent.keyboard('{arrowup}');
|
||||
expect(container.querySelector('[data-keyboard-nav-id="item3"]')).toHaveClass('active');
|
||||
await userEvent.keyboard('{arrowup}');
|
||||
expect(container.querySelector('[data-keyboard-nav-id="item2"]')).toHaveClass('active');
|
||||
await userEvent.keyboard('{arrowup}');
|
||||
expect(container.querySelector('[data-keyboard-nav-id="item1"]')).toHaveClass('active');
|
||||
});
|
||||
|
||||
test('Key hooks are executed', async () => {
|
||||
renderTestComponent();
|
||||
|
||||
await userEvent.keyboard('{arrowup}');
|
||||
expect(eventHookSpy).toHaveBeenCalledWith('item3', 'ArrowUp');
|
||||
await userEvent.keyboard('{arrowdown}');
|
||||
expect(eventHookSpy).toHaveBeenCalledWith('item1', 'ArrowDown');
|
||||
await userEvent.keyboard('{arrowleft}');
|
||||
expect(eventHookSpy).toHaveBeenCalledWith('item1', 'ArrowLeft');
|
||||
await userEvent.keyboard('{arrowright}');
|
||||
expect(eventHookSpy).toHaveBeenCalledWith('item1', 'ArrowRight');
|
||||
await userEvent.keyboard('{enter}');
|
||||
expect(eventHookSpy).toHaveBeenCalledWith('item1', 'Enter');
|
||||
await userEvent.keyboard('{escape}');
|
||||
expect(eventHookSpy).toHaveBeenCalledWith('item1', 'Escape');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,126 @@
|
|||
import type {
|
||||
SimplifiedNodeType,
|
||||
ActionTypeDescription,
|
||||
SubcategoryItemProps,
|
||||
ViewItemProps,
|
||||
LabelItemProps,
|
||||
NodeCreateElement,
|
||||
SubcategoryCreateElement,
|
||||
ViewCreateElement,
|
||||
LabelCreateElement,
|
||||
ActionCreateElement,
|
||||
} from '@/Interface';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export const mockSimplifiedNodeType = (
|
||||
overrides?: Partial<SimplifiedNodeType>,
|
||||
): SimplifiedNodeType => ({
|
||||
displayName: 'Sample DisplayName',
|
||||
name: 'sampleName',
|
||||
icon: 'sampleIcon',
|
||||
iconUrl: 'https://example.com/icon.png',
|
||||
group: ['group1', 'group2'],
|
||||
description: 'Sample description',
|
||||
codex: {
|
||||
categories: ['category1', 'category2'],
|
||||
subcategories: {
|
||||
category1: ['subcategory1', 'subcategory2'],
|
||||
category2: ['subcategory3', 'subcategory4'],
|
||||
},
|
||||
alias: ['alias1', 'alias2'],
|
||||
},
|
||||
defaults: {
|
||||
color: '#ffffff',
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const mockActionTypeDescription = (
|
||||
overrides?: Partial<ActionTypeDescription>,
|
||||
): ActionTypeDescription => ({
|
||||
...mockSimplifiedNodeType(),
|
||||
values: { value1: 'test', value2: 123 },
|
||||
actionKey: 'sampleActionKey',
|
||||
codex: {
|
||||
label: 'Sample Label',
|
||||
categories: ['category1', 'category2'],
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockSubcategoryItemProps = (
|
||||
overrides?: Partial<SubcategoryItemProps>,
|
||||
): SubcategoryItemProps => ({
|
||||
description: 'Sample description',
|
||||
iconType: 'sampleIconType',
|
||||
icon: 'sampleIcon',
|
||||
title: 'Sample title',
|
||||
subcategory: 'sampleSubcategory',
|
||||
defaults: { color: '#ffffff' },
|
||||
forceIncludeNodes: ['node1', 'node2'],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockViewItemProps = (overrides?: Partial<ViewItemProps>): ViewItemProps => ({
|
||||
title: 'Sample title',
|
||||
description: 'Sample description',
|
||||
icon: 'sampleIcon',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockLabelItemProps = (overrides?: Partial<LabelItemProps>): LabelItemProps => ({
|
||||
key: uuidv4(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const mockNodeCreateElement = (
|
||||
subcategory?: string,
|
||||
overrides?: Partial<SimplifiedNodeType>,
|
||||
): NodeCreateElement => ({
|
||||
uuid: uuidv4(),
|
||||
key: uuidv4(),
|
||||
type: 'node',
|
||||
subcategory: subcategory || 'sampleSubcategory',
|
||||
properties: mockSimplifiedNodeType(overrides),
|
||||
});
|
||||
|
||||
export const mockSubcategoryCreateElement = (
|
||||
overrides?: Partial<SubcategoryItemProps>,
|
||||
): SubcategoryCreateElement => ({
|
||||
uuid: uuidv4(),
|
||||
key: uuidv4(),
|
||||
type: 'subcategory',
|
||||
properties: mockSubcategoryItemProps(overrides),
|
||||
});
|
||||
|
||||
export const mockViewCreateElement = (
|
||||
overrides?: Partial<ViewCreateElement>,
|
||||
): ViewCreateElement => ({
|
||||
uuid: uuidv4(),
|
||||
key: uuidv4(),
|
||||
type: 'view',
|
||||
properties: mockViewItemProps(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const mockLabelCreateElement = (
|
||||
subcategory?: string,
|
||||
overrides?: Partial<LabelItemProps>,
|
||||
): LabelCreateElement => ({
|
||||
uuid: uuidv4(),
|
||||
key: uuidv4(),
|
||||
type: 'label',
|
||||
subcategory: subcategory || 'sampleSubcategory',
|
||||
properties: mockLabelItemProps(overrides),
|
||||
});
|
||||
|
||||
export const mockActionCreateElement = (
|
||||
subcategory?: string,
|
||||
overrides?: Partial<ActionTypeDescription>,
|
||||
): ActionCreateElement => ({
|
||||
uuid: uuidv4(),
|
||||
key: uuidv4(),
|
||||
type: 'action',
|
||||
subcategory: subcategory || 'sampleSubcategory',
|
||||
properties: mockActionTypeDescription(overrides),
|
||||
});
|
|
@ -0,0 +1,218 @@
|
|||
import { getCurrentInstance, computed } from 'vue';
|
||||
import type { IDataObject, INodeParameters } from 'n8n-workflow';
|
||||
import type {
|
||||
ActionTypeDescription,
|
||||
INodeCreateElement,
|
||||
IUpdateInformation,
|
||||
LabelCreateElement,
|
||||
} from '@/Interface';
|
||||
import {
|
||||
MANUAL_TRIGGER_NODE_TYPE,
|
||||
NODE_CREATOR_OPEN_SOURCES,
|
||||
SCHEDULE_TRIGGER_NODE_TYPE,
|
||||
STICKY_NODE_TYPE,
|
||||
TRIGGER_NODE_CREATOR_VIEW,
|
||||
WEBHOOK_NODE_TYPE,
|
||||
} from '@/constants';
|
||||
|
||||
import type { BaseTextKey } from '@/plugins/i18n';
|
||||
import type { Telemetry } from '@/plugins/telemetry';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator';
|
||||
import { useWorkflowsStore } from '@/stores/workflows';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes';
|
||||
import { runExternalHook } from '@/utils';
|
||||
import { useWebhooksStore } from '@/stores/webhooks';
|
||||
|
||||
import { sortNodeCreateElements, transformNodeType } from '../utils';
|
||||
|
||||
export const useActions = () => {
|
||||
const nodeCreatorStore = useNodeCreatorStore();
|
||||
const instance = getCurrentInstance();
|
||||
|
||||
const actionsCategoryLocales = computed(() => {
|
||||
return {
|
||||
actions: instance?.proxy.$locale.baseText('nodeCreator.actionsCategory.actions') ?? '',
|
||||
triggers: instance?.proxy.$locale.baseText('nodeCreator.actionsCategory.triggers') ?? '',
|
||||
};
|
||||
});
|
||||
|
||||
function getPlaceholderTriggerActions(subcategory: string) {
|
||||
const nodes = [WEBHOOK_NODE_TYPE, SCHEDULE_TRIGGER_NODE_TYPE];
|
||||
|
||||
const matchedNodeTypes = nodeCreatorStore.mergedNodes
|
||||
.filter((node) => nodes.some((n) => n === node.name))
|
||||
.map((node) => {
|
||||
const transformed = transformNodeType(node, subcategory, 'action');
|
||||
|
||||
if (transformed.type === 'action') {
|
||||
const nameBase = node.name.replace('n8n-nodes-base.', '');
|
||||
const localeKey = `nodeCreator.actionsPlaceholderNode.${nameBase}` as BaseTextKey;
|
||||
const overwriteLocale = instance?.proxy.$locale.baseText(localeKey) as string;
|
||||
|
||||
// If the locale key is not the same as the node name, it means it contain a translation
|
||||
// and we should use it
|
||||
if (overwriteLocale !== localeKey) {
|
||||
transformed.properties.displayName = overwriteLocale;
|
||||
}
|
||||
}
|
||||
return transformed;
|
||||
});
|
||||
|
||||
return matchedNodeTypes;
|
||||
}
|
||||
|
||||
function filterActionsCategory(items: INodeCreateElement[], category: string) {
|
||||
return items.filter(
|
||||
(item) => item.type === 'action' && item.properties.codex.categories.includes(category),
|
||||
);
|
||||
}
|
||||
|
||||
function injectActionsLabels(items: INodeCreateElement[]): INodeCreateElement[] {
|
||||
const extendedActions = sortNodeCreateElements([...items]);
|
||||
const labelsSet = new Set<string>();
|
||||
|
||||
// Collect unique labels
|
||||
for (const action of extendedActions) {
|
||||
if (action.type !== 'action') continue;
|
||||
const label = action.properties?.codex?.label;
|
||||
labelsSet.add(label);
|
||||
}
|
||||
|
||||
if (labelsSet.size <= 1) return extendedActions;
|
||||
|
||||
// Create a map to store the first index of each label
|
||||
const firstIndexMap = new Map<string, number>();
|
||||
|
||||
// Iterate through the extendedActions to find the first index of each label
|
||||
for (let i = 0; i < extendedActions.length; i++) {
|
||||
const action = extendedActions[i];
|
||||
if (action.type !== 'action') continue;
|
||||
const label = action.properties?.codex?.label;
|
||||
if (!firstIndexMap.has(label)) {
|
||||
firstIndexMap.set(label, i);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep track of the number of inserted labels
|
||||
let insertedLabels = 0;
|
||||
|
||||
// Create and insert new label objects at the first index of each label
|
||||
for (const label of labelsSet) {
|
||||
const newLabel: LabelCreateElement = {
|
||||
uuid: label,
|
||||
type: 'label',
|
||||
key: label,
|
||||
subcategory: extendedActions[0].key,
|
||||
properties: {
|
||||
key: label,
|
||||
},
|
||||
};
|
||||
|
||||
const insertIndex = firstIndexMap.get(label)! + insertedLabels;
|
||||
extendedActions.splice(insertIndex, 0, newLabel);
|
||||
insertedLabels++;
|
||||
}
|
||||
|
||||
return extendedActions;
|
||||
}
|
||||
|
||||
function parseCategoryActions(
|
||||
actions: INodeCreateElement[],
|
||||
category: string,
|
||||
withLabels = true,
|
||||
) {
|
||||
const filteredActions = filterActionsCategory(actions, category);
|
||||
if (withLabels) return injectActionsLabels(filteredActions);
|
||||
return filteredActions;
|
||||
}
|
||||
|
||||
function getActionData(actionItem: ActionTypeDescription): IUpdateInformation {
|
||||
const displayOptions = actionItem.displayOptions;
|
||||
|
||||
const displayConditions = Object.keys(displayOptions?.show || {}).reduce(
|
||||
(acc: IDataObject, showCondition: string) => {
|
||||
acc[showCondition] = displayOptions?.show?.[showCondition]?.[0];
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return {
|
||||
name: actionItem.displayName,
|
||||
key: actionItem.name as string,
|
||||
value: { ...actionItem.values, ...displayConditions } as INodeParameters,
|
||||
};
|
||||
}
|
||||
|
||||
function getNodeTypesWithManualTrigger(nodeType?: string): string[] {
|
||||
if (!nodeType) return [];
|
||||
|
||||
const { selectedView, openSource } = useNodeCreatorStore();
|
||||
const { workflowTriggerNodes } = useWorkflowsStore();
|
||||
const isTrigger = useNodeTypesStore().isTriggerNode(nodeType);
|
||||
const workflowContainsTrigger = workflowTriggerNodes.length > 0;
|
||||
const isTriggerPanel = selectedView === TRIGGER_NODE_CREATOR_VIEW;
|
||||
const isStickyNode = nodeType === STICKY_NODE_TYPE;
|
||||
const singleNodeOpenSources = [
|
||||
NODE_CREATOR_OPEN_SOURCES.PLUS_ENDPOINT,
|
||||
NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_ACTION,
|
||||
NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_DROP,
|
||||
];
|
||||
|
||||
// If the node creator was opened from the plus endpoint, node connection action, or node connection drop
|
||||
// then we do not want to append the manual trigger
|
||||
const isSingleNodeOpenSource = singleNodeOpenSources.includes(openSource);
|
||||
const shouldAppendManualTrigger =
|
||||
!isSingleNodeOpenSource &&
|
||||
!isTrigger &&
|
||||
!workflowContainsTrigger &&
|
||||
isTriggerPanel &&
|
||||
!isStickyNode;
|
||||
|
||||
const nodeTypes = shouldAppendManualTrigger ? [MANUAL_TRIGGER_NODE_TYPE, nodeType] : [nodeType];
|
||||
|
||||
return nodeTypes;
|
||||
}
|
||||
|
||||
// Hook into addNode action to set the last node parameters & track the action selected
|
||||
function setAddedNodeActionParameters(
|
||||
action: IUpdateInformation,
|
||||
telemetry?: Telemetry,
|
||||
rootView = '',
|
||||
) {
|
||||
const { $onAction: onWorkflowStoreAction } = useWorkflowsStore();
|
||||
const storeWatcher = onWorkflowStoreAction(
|
||||
({ name, after, store: { setLastNodeParameters }, args }) => {
|
||||
if (name !== 'addNode' || args[0].type !== action.key) return;
|
||||
after(() => {
|
||||
setLastNodeParameters(action);
|
||||
if (telemetry) trackActionSelected(action, telemetry, rootView);
|
||||
// Unsubscribe from the store watcher
|
||||
storeWatcher();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return storeWatcher;
|
||||
}
|
||||
|
||||
function trackActionSelected(action: IUpdateInformation, telemetry: Telemetry, rootView: string) {
|
||||
const payload = {
|
||||
node_type: action.key,
|
||||
action: action.name,
|
||||
source_mode: rootView.toLowerCase(),
|
||||
resource: (action.value as INodeParameters).resource || '',
|
||||
};
|
||||
runExternalHook('nodeCreateList.addAction', useWebhooksStore(), payload);
|
||||
telemetry?.trackNodesPanel('nodeCreateList.addAction', payload);
|
||||
}
|
||||
|
||||
return {
|
||||
actionsCategoryLocales,
|
||||
getPlaceholderTriggerActions,
|
||||
parseCategoryActions,
|
||||
getNodeTypesWithManualTrigger,
|
||||
getActionData,
|
||||
setAddedNodeActionParameters,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,279 @@
|
|||
import { startCase } from 'lodash-es';
|
||||
import type {
|
||||
INodePropertyCollection,
|
||||
INodePropertyOptions,
|
||||
INodeProperties,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { deepCopy } from 'n8n-workflow';
|
||||
import { CUSTOM_API_CALL_KEY } from '@/constants';
|
||||
import type { ActionTypeDescription, SimplifiedNodeType, ActionsRecord } from '@/Interface';
|
||||
|
||||
import { i18n } from '@/plugins/i18n';
|
||||
|
||||
const PLACEHOLDER_RECOMMENDED_ACTION_KEY = 'placeholder_recommended';
|
||||
|
||||
const customNodeActionsParsers: {
|
||||
[key: string]: (
|
||||
matchedProperty: INodeProperties,
|
||||
nodeTypeDescription: INodeTypeDescription,
|
||||
) => ActionTypeDescription[] | undefined;
|
||||
} = {
|
||||
['n8n-nodes-base.hubspotTrigger']: (matchedProperty, nodeTypeDescription) => {
|
||||
const collection = matchedProperty?.options?.[0] as INodePropertyCollection;
|
||||
|
||||
return (collection?.values[0]?.options as INodePropertyOptions[])?.map(
|
||||
(categoryItem): ActionTypeDescription => ({
|
||||
...getNodeTypeBase(nodeTypeDescription),
|
||||
actionKey: categoryItem.value as string,
|
||||
displayName: i18n.baseText('nodeCreator.actionsCategory.onEvent', {
|
||||
interpolate: { event: startCase(categoryItem.name) },
|
||||
}),
|
||||
description: categoryItem.description || '',
|
||||
displayOptions: matchedProperty.displayOptions,
|
||||
values: { eventsUi: { eventValues: [{ name: categoryItem.value }] } },
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
function getNodeTypeBase(nodeTypeDescription: INodeTypeDescription, label?: string) {
|
||||
const isTrigger = nodeTypeDescription.group.includes('trigger');
|
||||
const category = isTrigger
|
||||
? i18n.baseText('nodeCreator.actionsCategory.triggers')
|
||||
: i18n.baseText('nodeCreator.actionsCategory.actions');
|
||||
return {
|
||||
name: nodeTypeDescription.name,
|
||||
group: nodeTypeDescription.group,
|
||||
codex: {
|
||||
label: label || '',
|
||||
categories: [category],
|
||||
},
|
||||
iconUrl: nodeTypeDescription.iconUrl,
|
||||
icon: nodeTypeDescription.icon,
|
||||
defaults: nodeTypeDescription.defaults,
|
||||
};
|
||||
}
|
||||
|
||||
function operationsCategory(nodeTypeDescription: INodeTypeDescription): ActionTypeDescription[] {
|
||||
if (!!nodeTypeDescription.properties.find((property) => property.name === 'resource')) return [];
|
||||
|
||||
const matchedProperty = nodeTypeDescription.properties.find(
|
||||
(property) => property.name?.toLowerCase() === 'operation',
|
||||
);
|
||||
|
||||
if (!matchedProperty || !matchedProperty.options) return [];
|
||||
|
||||
const filteredOutItems = (matchedProperty.options as INodePropertyOptions[]).filter(
|
||||
(categoryItem: INodePropertyOptions) => !['*', '', ' '].includes(categoryItem.name),
|
||||
);
|
||||
|
||||
const items = filteredOutItems.map((item: INodePropertyOptions) => ({
|
||||
...getNodeTypeBase(nodeTypeDescription),
|
||||
actionKey: item.value as string,
|
||||
displayName: item.action ?? startCase(item.name),
|
||||
description: item.description ?? '',
|
||||
displayOptions: matchedProperty.displayOptions,
|
||||
values: {
|
||||
[matchedProperty.name]: matchedProperty.type === 'multiOptions' ? [item.value] : item.value,
|
||||
},
|
||||
}));
|
||||
|
||||
// Do not return empty category
|
||||
if (items.length === 0) return [];
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function triggersCategory(nodeTypeDescription: INodeTypeDescription): ActionTypeDescription[] {
|
||||
const matchingKeys = ['event', 'events', 'trigger on'];
|
||||
const isTrigger = nodeTypeDescription.displayName?.toLowerCase().includes('trigger');
|
||||
const matchedProperty = nodeTypeDescription.properties.find((property) =>
|
||||
matchingKeys.includes(property.displayName?.toLowerCase()),
|
||||
);
|
||||
|
||||
if (!isTrigger) return [];
|
||||
|
||||
// Inject placeholder action if no events are available
|
||||
// so user is able to add node to the canvas from the actions panel
|
||||
if (!matchedProperty || !matchedProperty.options) {
|
||||
return [
|
||||
{
|
||||
...getNodeTypeBase(nodeTypeDescription),
|
||||
actionKey: PLACEHOLDER_RECOMMENDED_ACTION_KEY,
|
||||
displayName: i18n.baseText('nodeCreator.actionsCategory.onNewEvent', {
|
||||
interpolate: { event: nodeTypeDescription.displayName.replace('Trigger', '').trimEnd() },
|
||||
}),
|
||||
description: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const filteredOutItems = (matchedProperty.options as INodePropertyOptions[]).filter(
|
||||
(categoryItem: INodePropertyOptions) => !['*', '', ' '].includes(categoryItem.name),
|
||||
);
|
||||
|
||||
const customParsedItem = customNodeActionsParsers[nodeTypeDescription.name]?.(
|
||||
matchedProperty,
|
||||
nodeTypeDescription,
|
||||
);
|
||||
|
||||
const items =
|
||||
customParsedItem ??
|
||||
filteredOutItems.map((categoryItem: INodePropertyOptions) => ({
|
||||
...getNodeTypeBase(nodeTypeDescription),
|
||||
actionKey: categoryItem.value as string,
|
||||
displayName:
|
||||
categoryItem.action ??
|
||||
i18n.baseText('nodeCreator.actionsCategory.onEvent', {
|
||||
interpolate: { event: startCase(categoryItem.name) },
|
||||
}),
|
||||
description: categoryItem.description || '',
|
||||
displayOptions: matchedProperty.displayOptions,
|
||||
values: {
|
||||
[matchedProperty.name]:
|
||||
matchedProperty.type === 'multiOptions' ? [categoryItem.value] : categoryItem.value,
|
||||
},
|
||||
}));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function resourceCategories(nodeTypeDescription: INodeTypeDescription): ActionTypeDescription[] {
|
||||
const transformedNodes: ActionTypeDescription[] = [];
|
||||
const matchedProperties = nodeTypeDescription.properties.filter(
|
||||
(property) => property.displayName?.toLowerCase() === 'resource',
|
||||
);
|
||||
|
||||
matchedProperties.forEach((property) => {
|
||||
((property.options as INodePropertyOptions[]) || [])
|
||||
.filter((option) => option.value !== CUSTOM_API_CALL_KEY)
|
||||
.forEach((resourceOption, i, options) => {
|
||||
const isSingleResource = options.length === 1;
|
||||
|
||||
// Match operations for the resource by checking if displayOptions matches or contains the resource name
|
||||
const operations = nodeTypeDescription.properties.find(
|
||||
(operation) =>
|
||||
operation.name === 'operation' &&
|
||||
(operation.displayOptions?.show?.resource?.includes(resourceOption.value) ||
|
||||
isSingleResource),
|
||||
);
|
||||
|
||||
if (!operations?.options) return;
|
||||
|
||||
const items = ((operations.options as INodePropertyOptions[]) || []).map(
|
||||
(operationOption) => {
|
||||
const displayName =
|
||||
operationOption.action ?? `${resourceOption.name} ${startCase(operationOption.name)}`;
|
||||
|
||||
// We need to manually populate displayOptions as they are not present in the node description
|
||||
// if the resource has only one option
|
||||
const displayOptions = isSingleResource
|
||||
? { show: { resource: [(options as INodePropertyOptions[])[0]?.value] } }
|
||||
: operations?.displayOptions;
|
||||
|
||||
return {
|
||||
...getNodeTypeBase(
|
||||
nodeTypeDescription,
|
||||
`${resourceOption.name} ${i18n.baseText('nodeCreator.actionsCategory.actions')}`,
|
||||
),
|
||||
actionKey: operationOption.value as string,
|
||||
description: operationOption?.description ?? '',
|
||||
displayOptions,
|
||||
values: {
|
||||
operation:
|
||||
operations?.type === 'multiOptions'
|
||||
? [operationOption.value]
|
||||
: operationOption.value,
|
||||
},
|
||||
displayName,
|
||||
group: ['trigger'],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
transformedNodes.push(...items);
|
||||
});
|
||||
});
|
||||
|
||||
return transformedNodes;
|
||||
}
|
||||
|
||||
export function useActionsGenerator() {
|
||||
function generateNodeActions(node: INodeTypeDescription | undefined) {
|
||||
if (!node) return [];
|
||||
return [...triggersCategory(node), ...operationsCategory(node), ...resourceCategories(node)];
|
||||
}
|
||||
function filterActions(actions: ActionTypeDescription[]) {
|
||||
// Do not show single action nodes
|
||||
if (actions.length <= 1) return [];
|
||||
return actions.filter(
|
||||
(action: ActionTypeDescription, _: number, arr: ActionTypeDescription[]) => {
|
||||
const isApiCall = action.actionKey === CUSTOM_API_CALL_KEY;
|
||||
if (isApiCall) return false;
|
||||
|
||||
const isPlaceholderTriggerAction = action.actionKey === PLACEHOLDER_RECOMMENDED_ACTION_KEY;
|
||||
return !isPlaceholderTriggerAction || (isPlaceholderTriggerAction && arr.length > 1);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function getSimplifiedNodeType(node: INodeTypeDescription): SimplifiedNodeType {
|
||||
const { displayName, defaults, description, name, group, icon, iconUrl, codex } = node;
|
||||
|
||||
return {
|
||||
displayName,
|
||||
defaults,
|
||||
description,
|
||||
name,
|
||||
group,
|
||||
icon,
|
||||
iconUrl,
|
||||
codex,
|
||||
};
|
||||
}
|
||||
|
||||
function generateMergedNodesAndActions(nodeTypes: INodeTypeDescription[]) {
|
||||
const visibleNodeTypes = deepCopy(nodeTypes);
|
||||
const actions: ActionsRecord<typeof mergedNodes> = {};
|
||||
const mergedNodes: SimplifiedNodeType[] = [];
|
||||
|
||||
visibleNodeTypes
|
||||
.filter((node) => !node.group.includes('trigger'))
|
||||
.forEach((app) => {
|
||||
const appActions = generateNodeActions(app);
|
||||
actions[app.name] = appActions;
|
||||
|
||||
mergedNodes.push(getSimplifiedNodeType(app));
|
||||
});
|
||||
|
||||
visibleNodeTypes
|
||||
.filter((node) => node.group.includes('trigger'))
|
||||
.forEach((trigger) => {
|
||||
const normalizedName = trigger.name.replace('Trigger', '');
|
||||
const triggerActions = generateNodeActions(trigger);
|
||||
const appActions = actions?.[normalizedName] || [];
|
||||
const app = mergedNodes.find((node) => node.name === normalizedName);
|
||||
|
||||
if (app && appActions?.length > 0) {
|
||||
// merge triggers into regular nodes that match
|
||||
const mergedActions = filterActions([...appActions, ...triggerActions]);
|
||||
actions[normalizedName] = mergedActions;
|
||||
|
||||
app.description = trigger.description; // default to trigger description
|
||||
} else {
|
||||
actions[trigger.name] = filterActions(triggerActions);
|
||||
mergedNodes.push(getSimplifiedNodeType(trigger));
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
actions,
|
||||
mergedNodes,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
generateMergedNodesAndActions,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,156 @@
|
|||
import { ref, set } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export type KeyboardKey = (typeof WATCHED_KEYS)[number];
|
||||
interface KeyHook {
|
||||
keyboardKeys: KeyboardKey[];
|
||||
condition?: (type: string, activeItemId: string) => boolean;
|
||||
handler: (activeItemId: string, keyboardKey: KeyboardKey) => void;
|
||||
}
|
||||
|
||||
export const KEYBOARD_ID_ATTR = 'data-keyboard-nav-id';
|
||||
export const WATCHED_KEYS = [
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'Enter',
|
||||
'Escape',
|
||||
'Tab',
|
||||
];
|
||||
|
||||
export const useKeyboardNavigation = defineStore('nodeCreatorKeyboardNavigation', () => {
|
||||
const selectableItems = ref<Array<WeakRef<Element>>>([]);
|
||||
const activeItemId = ref<string | null>(null);
|
||||
// Array of objects that contains key code and handler
|
||||
const keysHooks = ref<Record<string, KeyHook>>({});
|
||||
|
||||
function getItemType(element?: Element) {
|
||||
return element?.getAttribute('data-keyboard-nav-type');
|
||||
}
|
||||
function getElementId(element?: Element) {
|
||||
return element?.getAttribute(KEYBOARD_ID_ATTR) || undefined;
|
||||
}
|
||||
function refreshSelectableItems(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
// Wait for DOM to update
|
||||
cleanupSelectableItems();
|
||||
setTimeout(() => {
|
||||
selectableItems.value = Array.from(
|
||||
document.querySelectorAll('[data-keyboard-nav-type]'),
|
||||
).map((el) => new WeakRef(el));
|
||||
resolve();
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
function executeKeyHooks(keyboardKey: KeyboardKey, activeItem: Element) {
|
||||
const flatHooks = Object.values(keysHooks.value);
|
||||
const hooks = flatHooks.filter((hook) => hook.keyboardKeys.includes(keyboardKey));
|
||||
|
||||
hooks.forEach((hook) => {
|
||||
if (!activeItemId.value) return;
|
||||
|
||||
const conditionPassed =
|
||||
hook.condition === undefined ||
|
||||
hook.condition(getItemType(activeItem) || '', activeItemId.value);
|
||||
|
||||
if (conditionPassed && activeItemId.value) {
|
||||
hook.handler(activeItemId.value, keyboardKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function onKeyDown(e: KeyboardEvent) {
|
||||
const pressedKey = e.key;
|
||||
if (!WATCHED_KEYS.includes(pressedKey)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
await refreshSelectableItems();
|
||||
const activeItemIndex = selectableItems.value.findIndex(
|
||||
(item) => getElementId(item?.deref()) === activeItemId.value,
|
||||
);
|
||||
const activeItem = selectableItems.value[activeItemIndex]?.deref();
|
||||
|
||||
const isArrowDown = pressedKey === 'ArrowDown';
|
||||
const isArrowUp = pressedKey === 'ArrowUp';
|
||||
|
||||
if (!activeItem) return;
|
||||
|
||||
if (isArrowDown) {
|
||||
const nextItemIndex =
|
||||
activeItemIndex < selectableItems.value.length - 1 ? activeItemIndex + 1 : 0;
|
||||
|
||||
setActiveItem(selectableItems.value[nextItemIndex]?.deref());
|
||||
}
|
||||
if (isArrowUp) {
|
||||
const previousIndex =
|
||||
activeItemIndex > 0 ? activeItemIndex - 1 : selectableItems.value.length - 1;
|
||||
|
||||
setActiveItem(selectableItems.value[previousIndex]?.deref());
|
||||
}
|
||||
executeKeyHooks(pressedKey, activeItem);
|
||||
}
|
||||
|
||||
function setActiveItemId(id: string) {
|
||||
activeItemId.value = id;
|
||||
}
|
||||
|
||||
function setActiveItem(item?: Element) {
|
||||
const itemId = getElementId(item);
|
||||
if (!itemId) return;
|
||||
|
||||
setActiveItemId(itemId);
|
||||
if (item?.scrollIntoView) {
|
||||
item?.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
async function setActiveItemIndex(index: number) {
|
||||
await refreshSelectableItems();
|
||||
|
||||
setActiveItem(selectableItems.value[index]?.deref());
|
||||
}
|
||||
|
||||
function attachKeydownEvent() {
|
||||
document.addEventListener('keydown', onKeyDown, { capture: true });
|
||||
}
|
||||
|
||||
function detachKeydownEvent() {
|
||||
cleanupSelectableItems();
|
||||
document.removeEventListener('keydown', onKeyDown, { capture: true });
|
||||
}
|
||||
|
||||
function registerKeyHook(name: string, hook: KeyHook) {
|
||||
hook.keyboardKeys.forEach((keyboardKey) => {
|
||||
if (WATCHED_KEYS.includes(keyboardKey)) {
|
||||
set(keysHooks.value, name, hook);
|
||||
} else {
|
||||
throw new Error(`Key ${keyboardKey} is not supported`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupSelectableItems() {
|
||||
// Cleanup to make sure DOM elements get garbage collected
|
||||
selectableItems.value = [];
|
||||
}
|
||||
|
||||
function getActiveItemIndex() {
|
||||
return selectableItems.value.findIndex(
|
||||
(item) => getElementId(item?.deref()) === activeItemId.value,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
activeItemId,
|
||||
attachKeydownEvent,
|
||||
refreshSelectableItems,
|
||||
detachKeydownEvent,
|
||||
registerKeyHook,
|
||||
getActiveItemIndex,
|
||||
setActiveItemId,
|
||||
setActiveItemIndex,
|
||||
};
|
||||
});
|
|
@ -0,0 +1,188 @@
|
|||
import { computed, ref, set } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { INodeCreateElement, NodeFilterType, SimplifiedNodeType } from '@/Interface';
|
||||
import { DEFAULT_SUBCATEGORY, TRIGGER_NODE_CREATOR_VIEW } from '@/constants';
|
||||
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator';
|
||||
|
||||
import { useKeyboardNavigation } from './useKeyboardNavigation';
|
||||
import {
|
||||
transformNodeType,
|
||||
subcategorizeItems,
|
||||
sortNodeCreateElements,
|
||||
searchNodes,
|
||||
} from '../utils';
|
||||
|
||||
interface ViewStack {
|
||||
uuid?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
search?: string;
|
||||
subcategory?: string;
|
||||
nodeIcon?: {
|
||||
iconType?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
};
|
||||
iconUrl?: string;
|
||||
rootView?: NodeFilterType;
|
||||
activeIndex?: number;
|
||||
transitionDirection?: 'in' | 'out';
|
||||
hasSearch?: boolean;
|
||||
items?: INodeCreateElement[];
|
||||
baselineItems?: INodeCreateElement[];
|
||||
searchItems?: SimplifiedNodeType[];
|
||||
forceIncludeNodes?: string[];
|
||||
mode?: 'actions' | 'nodes';
|
||||
baseFilter?: (item: INodeCreateElement) => boolean;
|
||||
itemsMapper?: (item: INodeCreateElement) => INodeCreateElement;
|
||||
}
|
||||
|
||||
export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
|
||||
const nodeCreatorStore = useNodeCreatorStore();
|
||||
const { getActiveItemIndex } = useKeyboardNavigation();
|
||||
|
||||
const viewStacks = ref<ViewStack[]>([]);
|
||||
|
||||
const activeStackItems = computed<INodeCreateElement[]>(() => {
|
||||
const stack = viewStacks.value[viewStacks.value.length - 1];
|
||||
|
||||
if (!stack?.baselineItems) {
|
||||
return stack.items ? extendItemsWithUUID(stack.items) : [];
|
||||
}
|
||||
|
||||
if (stack.search && searchBaseItems.value) {
|
||||
const searchBase =
|
||||
searchBaseItems.value.length > 0 ? searchBaseItems.value : stack.baselineItems;
|
||||
|
||||
return extendItemsWithUUID(searchNodes(stack.search || '', searchBase));
|
||||
}
|
||||
return extendItemsWithUUID(stack.baselineItems);
|
||||
});
|
||||
|
||||
const activeViewStack = computed<ViewStack>(() => {
|
||||
const stack = viewStacks.value[viewStacks.value.length - 1];
|
||||
if (!stack) return {};
|
||||
|
||||
return {
|
||||
...stack,
|
||||
items: activeStackItems.value,
|
||||
hasSearch: (stack.baselineItems || []).length > 8 || stack?.hasSearch,
|
||||
};
|
||||
});
|
||||
|
||||
const activeViewStackMode = computed(
|
||||
() => activeViewStack.value.mode || TRIGGER_NODE_CREATOR_VIEW,
|
||||
);
|
||||
|
||||
const searchBaseItems = computed<INodeCreateElement[]>(() => {
|
||||
const stack = viewStacks.value[viewStacks.value.length - 1];
|
||||
if (!stack || !stack.searchItems) return [];
|
||||
|
||||
return stack.searchItems.map((item) => transformNodeType(item, stack.subcategory));
|
||||
});
|
||||
|
||||
// Generate a delta between the global search results(all nodes) and the stack search results
|
||||
const globalSearchItemsDiff = computed<INodeCreateElement[]>(() => {
|
||||
const stack = viewStacks.value[viewStacks.value.length - 1];
|
||||
if (!stack || !stack.search) return [];
|
||||
|
||||
const allNodes = nodeCreatorStore.mergedNodes.map((item) => transformNodeType(item));
|
||||
const globalSearchResult = extendItemsWithUUID(searchNodes(stack.search || '', allNodes));
|
||||
|
||||
return globalSearchResult.filter((item) => {
|
||||
return !activeStackItems.value.find((activeItem) => activeItem.key === item.key);
|
||||
});
|
||||
});
|
||||
|
||||
function setStackBaselineItems() {
|
||||
const stack = viewStacks.value[viewStacks.value.length - 1];
|
||||
if (!stack || !activeViewStack.value.uuid) return;
|
||||
|
||||
const subcategorizedItems = subcategorizeItems(nodeCreatorStore.mergedNodes);
|
||||
let stackItems =
|
||||
stack?.items ?? subcategorizedItems[stack?.subcategory ?? DEFAULT_SUBCATEGORY] ?? [];
|
||||
|
||||
// Ensure that the nodes specified in `stack.forceIncludeNodes` are always included,
|
||||
// regardless of whether the subcategory is matched
|
||||
if ((stack.forceIncludeNodes ?? []).length > 0) {
|
||||
const matchedNodes = nodeCreatorStore.mergedNodes
|
||||
.filter((item) => stack.forceIncludeNodes?.includes(item.name))
|
||||
.map((item) => transformNodeType(item, stack.subcategory));
|
||||
|
||||
stackItems.push(...matchedNodes);
|
||||
}
|
||||
|
||||
if (stack.baseFilter) {
|
||||
stackItems = stackItems.filter(stack.baseFilter);
|
||||
}
|
||||
|
||||
if (stack.itemsMapper) {
|
||||
stackItems = stackItems.map(stack.itemsMapper);
|
||||
}
|
||||
|
||||
// Sort only if non-root view
|
||||
if (!stack.items) {
|
||||
sortNodeCreateElements(stackItems);
|
||||
}
|
||||
|
||||
updateCurrentViewStack({ baselineItems: stackItems });
|
||||
}
|
||||
|
||||
function extendItemsWithUUID(items: INodeCreateElement[]) {
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
uuid: `${item.key}-${uuid()}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function pushViewStack(stack: ViewStack) {
|
||||
if (activeViewStack.value.uuid) {
|
||||
updateCurrentViewStack({ activeIndex: getActiveItemIndex() });
|
||||
}
|
||||
|
||||
const newStackUuid = uuid();
|
||||
viewStacks.value.push({
|
||||
...stack,
|
||||
uuid: newStackUuid,
|
||||
transitionDirection: 'in',
|
||||
activeIndex: 0,
|
||||
});
|
||||
setStackBaselineItems();
|
||||
}
|
||||
|
||||
function popViewStack() {
|
||||
if (activeViewStack.value.uuid) {
|
||||
viewStacks.value.pop();
|
||||
updateCurrentViewStack({ transitionDirection: 'out' });
|
||||
}
|
||||
}
|
||||
|
||||
function updateCurrentViewStack(stack: Partial<ViewStack>) {
|
||||
const currentStack = viewStacks.value[viewStacks.value.length - 1];
|
||||
const matchedIndex = viewStacks.value.findIndex((s) => s.uuid === currentStack.uuid);
|
||||
if (!currentStack) return;
|
||||
|
||||
// For each key in the stack, update the matched stack
|
||||
Object.keys(stack).forEach((key) => {
|
||||
const typedKey = key as keyof ViewStack;
|
||||
set(viewStacks.value[matchedIndex], key, stack[typedKey]);
|
||||
});
|
||||
}
|
||||
|
||||
function resetViewStacks() {
|
||||
viewStacks.value = [];
|
||||
}
|
||||
|
||||
return {
|
||||
viewStacks,
|
||||
activeViewStack,
|
||||
activeViewStackMode,
|
||||
globalSearchItemsDiff,
|
||||
resetViewStacks,
|
||||
updateCurrentViewStack,
|
||||
pushViewStack,
|
||||
popViewStack,
|
||||
};
|
||||
});
|
|
@ -1,198 +0,0 @@
|
|||
import { getCurrentInstance, computed } from 'vue';
|
||||
import {
|
||||
CORE_NODES_CATEGORY,
|
||||
WEBHOOK_NODE_TYPE,
|
||||
OTHER_TRIGGER_NODES_SUBCATEGORY,
|
||||
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||
MANUAL_TRIGGER_NODE_TYPE,
|
||||
SCHEDULE_TRIGGER_NODE_TYPE,
|
||||
REGULAR_NODE_FILTER,
|
||||
TRANSFORM_DATA_SUBCATEGORY,
|
||||
FILES_SUBCATEGORY,
|
||||
FLOWS_CONTROL_SUBCATEGORY,
|
||||
HELPERS_SUBCATEGORY,
|
||||
TRIGGER_NODE_FILTER,
|
||||
} from '@/constants';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator';
|
||||
|
||||
export default () => {
|
||||
const instance = getCurrentInstance();
|
||||
const nodeCreatorStore = useNodeCreatorStore();
|
||||
|
||||
const VIEWS = [
|
||||
{
|
||||
value: REGULAR_NODE_FILTER,
|
||||
title: instance?.proxy.$locale.baseText('nodeCreator.triggerHelperPanel.whatHappensNext'),
|
||||
items: [
|
||||
{
|
||||
key: '*',
|
||||
type: 'subcategory',
|
||||
properties: {
|
||||
subcategory: 'App Regular Nodes',
|
||||
icon: 'globe',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'subcategory',
|
||||
key: TRANSFORM_DATA_SUBCATEGORY,
|
||||
category: CORE_NODES_CATEGORY,
|
||||
properties: {
|
||||
subcategory: TRANSFORM_DATA_SUBCATEGORY,
|
||||
icon: 'pen',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'subcategory',
|
||||
key: HELPERS_SUBCATEGORY,
|
||||
category: CORE_NODES_CATEGORY,
|
||||
properties: {
|
||||
subcategory: HELPERS_SUBCATEGORY,
|
||||
icon: 'toolbox',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'subcategory',
|
||||
key: FLOWS_CONTROL_SUBCATEGORY,
|
||||
category: CORE_NODES_CATEGORY,
|
||||
properties: {
|
||||
subcategory: FLOWS_CONTROL_SUBCATEGORY,
|
||||
icon: 'code-branch',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'subcategory',
|
||||
key: FILES_SUBCATEGORY,
|
||||
category: CORE_NODES_CATEGORY,
|
||||
properties: {
|
||||
subcategory: FILES_SUBCATEGORY,
|
||||
icon: 'file-alt',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: TRIGGER_NODE_FILTER,
|
||||
type: 'view',
|
||||
properties: {
|
||||
title: instance?.proxy.$locale.baseText(
|
||||
'nodeCreator.triggerHelperPanel.addAnotherTrigger',
|
||||
),
|
||||
icon: 'bolt',
|
||||
withTopBorder: true,
|
||||
description: instance?.proxy.$locale.baseText(
|
||||
'nodeCreator.triggerHelperPanel.addAnotherTriggerDescription',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
value: TRIGGER_NODE_FILTER,
|
||||
title: instance?.proxy.$locale.baseText('nodeCreator.triggerHelperPanel.selectATrigger'),
|
||||
description: instance?.proxy.$locale.baseText(
|
||||
'nodeCreator.triggerHelperPanel.selectATriggerDescription',
|
||||
),
|
||||
items: [
|
||||
{
|
||||
key: '*',
|
||||
type: 'subcategory',
|
||||
properties: {
|
||||
subcategory: 'App Trigger Nodes',
|
||||
icon: 'satellite-dish',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: SCHEDULE_TRIGGER_NODE_TYPE,
|
||||
type: 'node',
|
||||
category: [CORE_NODES_CATEGORY],
|
||||
properties: {
|
||||
nodeType: {
|
||||
group: [],
|
||||
name: SCHEDULE_TRIGGER_NODE_TYPE,
|
||||
displayName: instance?.proxy.$locale.baseText(
|
||||
'nodeCreator.triggerHelperPanel.scheduleTriggerDisplayName',
|
||||
),
|
||||
description: instance?.proxy.$locale.baseText(
|
||||
'nodeCreator.triggerHelperPanel.scheduleTriggerDescription',
|
||||
),
|
||||
icon: 'fa:clock',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: WEBHOOK_NODE_TYPE,
|
||||
type: 'node',
|
||||
category: [CORE_NODES_CATEGORY],
|
||||
properties: {
|
||||
nodeType: {
|
||||
group: [],
|
||||
name: WEBHOOK_NODE_TYPE,
|
||||
displayName: instance?.proxy.$locale.baseText(
|
||||
'nodeCreator.triggerHelperPanel.webhookTriggerDisplayName',
|
||||
),
|
||||
description: instance?.proxy.$locale.baseText(
|
||||
'nodeCreator.triggerHelperPanel.webhookTriggerDescription',
|
||||
),
|
||||
iconData: {
|
||||
type: 'file',
|
||||
icon: 'webhook',
|
||||
fileBuffer: '/static/webhook-icon.svg',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: MANUAL_TRIGGER_NODE_TYPE,
|
||||
type: 'node',
|
||||
category: [CORE_NODES_CATEGORY],
|
||||
properties: {
|
||||
nodeType: {
|
||||
group: [],
|
||||
name: MANUAL_TRIGGER_NODE_TYPE,
|
||||
displayName: instance?.proxy.$locale.baseText(
|
||||
'nodeCreator.triggerHelperPanel.manualTriggerDisplayName',
|
||||
),
|
||||
description: instance?.proxy.$locale.baseText(
|
||||
'nodeCreator.triggerHelperPanel.manualTriggerDescription',
|
||||
),
|
||||
icon: 'fa:mouse-pointer',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||
type: 'node',
|
||||
category: [CORE_NODES_CATEGORY],
|
||||
properties: {
|
||||
nodeType: {
|
||||
group: [],
|
||||
name: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||
displayName: instance?.proxy.$locale.baseText(
|
||||
'nodeCreator.triggerHelperPanel.workflowTriggerDisplayName',
|
||||
),
|
||||
description: instance?.proxy.$locale.baseText(
|
||||
'nodeCreator.triggerHelperPanel.workflowTriggerDescription',
|
||||
),
|
||||
icon: 'fa:sign-out-alt',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'subcategory',
|
||||
key: OTHER_TRIGGER_NODES_SUBCATEGORY,
|
||||
category: CORE_NODES_CATEGORY,
|
||||
properties: {
|
||||
subcategory: OTHER_TRIGGER_NODES_SUBCATEGORY,
|
||||
icon: 'folder-open',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const activeView = computed(() => {
|
||||
return VIEWS.find((v) => v.value === nodeCreatorStore.selectedView) || VIEWS[0];
|
||||
});
|
||||
|
||||
return {
|
||||
activeView,
|
||||
};
|
||||
};
|
73
packages/editor-ui/src/components/Node/NodeCreator/utils.ts
Normal file
73
packages/editor-ui/src/components/Node/NodeCreator/utils.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
import type {
|
||||
NodeCreateElement,
|
||||
ActionCreateElement,
|
||||
SubcategorizedNodeTypes,
|
||||
SimplifiedNodeType,
|
||||
INodeCreateElement,
|
||||
} from '@/Interface';
|
||||
import { CORE_NODES_CATEGORY, DEFAULT_SUBCATEGORY } from '@/constants';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { sublimeSearch } from '@/utils';
|
||||
|
||||
export function transformNodeType(
|
||||
node: SimplifiedNodeType,
|
||||
subcategory?: string,
|
||||
type: 'node' | 'action' = 'node',
|
||||
): NodeCreateElement | ActionCreateElement {
|
||||
const createElement = {
|
||||
uuid: uuidv4(),
|
||||
key: node.name,
|
||||
subcategory:
|
||||
subcategory ?? node.codex?.subcategories?.[CORE_NODES_CATEGORY]?.[0] ?? DEFAULT_SUBCATEGORY,
|
||||
properties: {
|
||||
...node,
|
||||
},
|
||||
type,
|
||||
};
|
||||
|
||||
return type === 'action'
|
||||
? (createElement as ActionCreateElement)
|
||||
: (createElement as NodeCreateElement);
|
||||
}
|
||||
|
||||
export function subcategorizeItems(items: SimplifiedNodeType[]) {
|
||||
return items.reduce((acc: SubcategorizedNodeTypes, item) => {
|
||||
// Only Core Nodes subcategories are valid, others are uncategorized
|
||||
const isCoreNodesCategory = item.codex?.categories?.includes(CORE_NODES_CATEGORY);
|
||||
const subcategories = isCoreNodesCategory
|
||||
? item?.codex?.subcategories?.[CORE_NODES_CATEGORY] ?? []
|
||||
: [DEFAULT_SUBCATEGORY];
|
||||
|
||||
subcategories.forEach((subcategory: string) => {
|
||||
if (!acc[subcategory]) {
|
||||
acc[subcategory] = [];
|
||||
}
|
||||
acc[subcategory].push(transformNodeType(item, subcategory));
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function sortNodeCreateElements(nodes: INodeCreateElement[]) {
|
||||
return nodes.sort((a, b) => {
|
||||
if (a.type !== 'node' || b.type !== 'node') return -1;
|
||||
const displayNameA = a.properties?.displayName?.toLowerCase() || a.key;
|
||||
const displayNameB = b.properties?.displayName?.toLowerCase() || b.key;
|
||||
|
||||
return displayNameA.localeCompare(displayNameB, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
}
|
||||
|
||||
export function searchNodes(searchFilter: string, items: INodeCreateElement[]) {
|
||||
// In order to support the old search we need to remove the 'trigger' part
|
||||
const trimmedFilter = searchFilter.toLowerCase().replace('trigger', '').trimEnd();
|
||||
const result = (
|
||||
sublimeSearch<INodeCreateElement>(trimmedFilter, items, [
|
||||
{ key: 'properties.displayName', weight: 2 },
|
||||
{ key: 'properties.codex.alias', weight: 1 },
|
||||
]) || []
|
||||
).map(({ item }) => item);
|
||||
|
||||
return result;
|
||||
}
|
168
packages/editor-ui/src/components/Node/NodeCreator/viewsData.ts
Normal file
168
packages/editor-ui/src/components/Node/NodeCreator/viewsData.ts
Normal file
|
@ -0,0 +1,168 @@
|
|||
import {
|
||||
CORE_NODES_CATEGORY,
|
||||
WEBHOOK_NODE_TYPE,
|
||||
OTHER_TRIGGER_NODES_SUBCATEGORY,
|
||||
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||
MANUAL_TRIGGER_NODE_TYPE,
|
||||
SCHEDULE_TRIGGER_NODE_TYPE,
|
||||
REGULAR_NODE_CREATOR_VIEW,
|
||||
TRANSFORM_DATA_SUBCATEGORY,
|
||||
FILES_SUBCATEGORY,
|
||||
FLOWS_CONTROL_SUBCATEGORY,
|
||||
HELPERS_SUBCATEGORY,
|
||||
TRIGGER_NODE_CREATOR_VIEW,
|
||||
EMAIL_IMAP_NODE_TYPE,
|
||||
DEFAULT_SUBCATEGORY,
|
||||
} from '@/constants';
|
||||
|
||||
export function TriggerView($locale: any) {
|
||||
return {
|
||||
value: TRIGGER_NODE_CREATOR_VIEW,
|
||||
title: $locale.baseText('nodeCreator.triggerHelperPanel.selectATrigger'),
|
||||
subtitle: $locale.baseText('nodeCreator.triggerHelperPanel.selectATriggerDescription'),
|
||||
items: [
|
||||
{
|
||||
key: DEFAULT_SUBCATEGORY,
|
||||
type: 'subcategory',
|
||||
properties: {
|
||||
forceIncludeNodes: [WEBHOOK_NODE_TYPE, EMAIL_IMAP_NODE_TYPE],
|
||||
title: 'App Trigger Nodes',
|
||||
icon: 'satellite-dish',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: SCHEDULE_TRIGGER_NODE_TYPE,
|
||||
type: 'node',
|
||||
category: [CORE_NODES_CATEGORY],
|
||||
properties: {
|
||||
group: [],
|
||||
name: SCHEDULE_TRIGGER_NODE_TYPE,
|
||||
displayName: $locale.baseText(
|
||||
'nodeCreator.triggerHelperPanel.scheduleTriggerDisplayName',
|
||||
),
|
||||
description: $locale.baseText(
|
||||
'nodeCreator.triggerHelperPanel.scheduleTriggerDescription',
|
||||
),
|
||||
icon: 'fa:clock',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: WEBHOOK_NODE_TYPE,
|
||||
type: 'node',
|
||||
category: [CORE_NODES_CATEGORY],
|
||||
properties: {
|
||||
group: [],
|
||||
name: WEBHOOK_NODE_TYPE,
|
||||
displayName: $locale.baseText('nodeCreator.triggerHelperPanel.webhookTriggerDisplayName'),
|
||||
description: $locale.baseText('nodeCreator.triggerHelperPanel.webhookTriggerDescription'),
|
||||
iconData: {
|
||||
type: 'file',
|
||||
icon: 'webhook',
|
||||
fileBuffer: '/static/webhook-icon.svg',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: MANUAL_TRIGGER_NODE_TYPE,
|
||||
type: 'node',
|
||||
category: [CORE_NODES_CATEGORY],
|
||||
properties: {
|
||||
group: [],
|
||||
name: MANUAL_TRIGGER_NODE_TYPE,
|
||||
displayName: $locale.baseText('nodeCreator.triggerHelperPanel.manualTriggerDisplayName'),
|
||||
description: $locale.baseText('nodeCreator.triggerHelperPanel.manualTriggerDescription'),
|
||||
icon: 'fa:mouse-pointer',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||
type: 'node',
|
||||
category: [CORE_NODES_CATEGORY],
|
||||
properties: {
|
||||
group: [],
|
||||
name: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||
displayName: $locale.baseText(
|
||||
'nodeCreator.triggerHelperPanel.workflowTriggerDisplayName',
|
||||
),
|
||||
description: $locale.baseText(
|
||||
'nodeCreator.triggerHelperPanel.workflowTriggerDescription',
|
||||
),
|
||||
icon: 'fa:sign-out-alt',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'subcategory',
|
||||
key: OTHER_TRIGGER_NODES_SUBCATEGORY,
|
||||
category: CORE_NODES_CATEGORY,
|
||||
properties: {
|
||||
title: OTHER_TRIGGER_NODES_SUBCATEGORY,
|
||||
icon: 'folder-open',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function RegularView($locale: any) {
|
||||
return {
|
||||
value: REGULAR_NODE_CREATOR_VIEW,
|
||||
title: $locale.baseText('nodeCreator.triggerHelperPanel.whatHappensNext'),
|
||||
items: [
|
||||
{
|
||||
key: DEFAULT_SUBCATEGORY,
|
||||
type: 'subcategory',
|
||||
properties: {
|
||||
title: 'App Regular Nodes',
|
||||
icon: 'globe',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'subcategory',
|
||||
key: TRANSFORM_DATA_SUBCATEGORY,
|
||||
category: CORE_NODES_CATEGORY,
|
||||
properties: {
|
||||
title: TRANSFORM_DATA_SUBCATEGORY,
|
||||
icon: 'pen',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'subcategory',
|
||||
key: HELPERS_SUBCATEGORY,
|
||||
category: CORE_NODES_CATEGORY,
|
||||
properties: {
|
||||
title: HELPERS_SUBCATEGORY,
|
||||
icon: 'toolbox',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'subcategory',
|
||||
key: FLOWS_CONTROL_SUBCATEGORY,
|
||||
category: CORE_NODES_CATEGORY,
|
||||
properties: {
|
||||
title: FLOWS_CONTROL_SUBCATEGORY,
|
||||
icon: 'code-branch',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'subcategory',
|
||||
key: FILES_SUBCATEGORY,
|
||||
category: CORE_NODES_CATEGORY,
|
||||
properties: {
|
||||
title: FILES_SUBCATEGORY,
|
||||
icon: 'file-alt',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: TRIGGER_NODE_CREATOR_VIEW,
|
||||
type: 'view',
|
||||
properties: {
|
||||
title: $locale.baseText('nodeCreator.triggerHelperPanel.addAnotherTrigger'),
|
||||
icon: 'bolt',
|
||||
description: $locale.baseText(
|
||||
'nodeCreator.triggerHelperPanel.addAnotherTriggerDescription',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
|
@ -15,7 +15,7 @@ export default defineComponent({
|
|||
<style lang="scss" scoped>
|
||||
.slide-leave-active,
|
||||
.slide-enter-active {
|
||||
transition: 0.3s ease;
|
||||
transition: 200ms ease;
|
||||
}
|
||||
.slide-leave-to,
|
||||
.slide-enter {
|
||||
|
|
|
@ -165,28 +165,12 @@ export const NODE_CREATOR_OPEN_SOURCES: Record<
|
|||
NODE_CONNECTION_DROP: 'node_connection_drop',
|
||||
'': '',
|
||||
};
|
||||
|
||||
export const CORE_NODES_CATEGORY = 'Core Nodes';
|
||||
export const COMMUNICATION_CATEGORY = 'Communication';
|
||||
export const CUSTOM_NODES_CATEGORY = 'Custom Nodes';
|
||||
export const RECOMMENDED_CATEGORY = 'Recommended';
|
||||
export const SUBCATEGORY_DESCRIPTIONS: {
|
||||
[category: string]: { [subcategory: string]: string };
|
||||
} = {
|
||||
'Core Nodes': {
|
||||
// this - all subkeys are set from codex
|
||||
Flow: 'Branches, core triggers, merge data',
|
||||
Files: 'Work with CSV, XML, text, images etc.',
|
||||
'Data Transformation': 'Manipulate data fields, run code',
|
||||
Helpers: 'HTTP Requests (API calls), date and time, scrape HTML',
|
||||
},
|
||||
};
|
||||
export const REGULAR_NODE_FILTER = 'Regular';
|
||||
export const TRIGGER_NODE_FILTER = 'Trigger';
|
||||
export const ALL_NODE_FILTER = 'All';
|
||||
export const DEFAULT_SUBCATEGORY = '*';
|
||||
export const REGULAR_NODE_CREATOR_VIEW = 'Regular';
|
||||
export const TRIGGER_NODE_CREATOR_VIEW = 'Trigger';
|
||||
export const UNCATEGORIZED_CATEGORY = 'Miscellaneous';
|
||||
export const UNCATEGORIZED_SUBCATEGORY = 'Helpers';
|
||||
export const PERSONALIZED_CATEGORY = 'Suggested Nodes';
|
||||
export const OTHER_TRIGGER_NODES_SUBCATEGORY = 'Other Trigger Nodes';
|
||||
export const TRANSFORM_DATA_SUBCATEGORY = 'Data Transformation';
|
||||
export const FILES_SUBCATEGORY = 'Files';
|
||||
|
@ -355,11 +339,6 @@ export const HIRING_BANNER = `
|
|||
Love n8n? Help us build the future of automation! https://n8n.io/careers?utm_source=n8n_user&utm_medium=console_output
|
||||
`;
|
||||
|
||||
export const NODE_TYPE_COUNT_MAPPER = {
|
||||
[REGULAR_NODE_FILTER]: ['regularCount'],
|
||||
[TRIGGER_NODE_FILTER]: ['triggerCount'],
|
||||
[ALL_NODE_FILTER]: ['triggerCount', 'regularCount'],
|
||||
};
|
||||
export const TEMPLATES_NODES_FILTER = ['n8n-nodes-base.start', 'n8n-nodes-base.respondToWebhook'];
|
||||
|
||||
export const enum VIEWS {
|
||||
|
@ -538,12 +517,18 @@ export const KEEP_AUTH_IN_NDV_FOR_NODES = [HTTP_REQUEST_NODE_TYPE, WEBHOOK_NODE_
|
|||
export const MAIN_AUTH_FIELD_NAME = 'authentication';
|
||||
export const NODE_RESOURCE_FIELD_NAME = 'resource';
|
||||
|
||||
export const AUTO_INSERT_ACTION_EXPERIMENT = {
|
||||
name: '003_auto_insert_action',
|
||||
control: 'control',
|
||||
variant: 'variant',
|
||||
};
|
||||
|
||||
export const TEMPLATE_EXPERIMENT = {
|
||||
name: '002_remove_templates',
|
||||
control: 'control',
|
||||
variant: 'variant',
|
||||
};
|
||||
|
||||
export const EXPERIMENTS_TO_TRACK = [TEMPLATE_EXPERIMENT.name];
|
||||
export const EXPERIMENTS_TO_TRACK = [TEMPLATE_EXPERIMENT.name, AUTO_INSERT_ACTION_EXPERIMENT.name];
|
||||
|
||||
export const NODE_TYPES_EXCLUDED_FROM_OUTPUT_NAME_APPEND = [FILTER_NODE_TYPE];
|
||||
|
|
|
@ -7,6 +7,7 @@ import 'vue-json-pretty/lib/styles.css';
|
|||
import '@jsplumb/browser-ui/css/jsplumbtoolkit.css';
|
||||
import 'n8n-design-system/css/index.scss';
|
||||
import './n8n-theme.scss';
|
||||
|
||||
import './styles/autocomplete-theme.scss';
|
||||
|
||||
import '@fontsource/open-sans/latin-400.css';
|
||||
|
|
|
@ -720,29 +720,22 @@
|
|||
"node.discovery.pinData.canvas": "You can pin this output instead of waiting for a test event. Open node to do so.",
|
||||
"node.discovery.pinData.ndv": "You can pin this output instead of waiting for a test event.",
|
||||
"nodeBase.clickToAddNodeOrDragToConnect": "Click to add node<br />or drag to connect",
|
||||
"nodeCreator.actionsPlaceholderNode.scheduleTrigger": "On a Schedule",
|
||||
"nodeCreator.actionsPlaceholderNode.webhook": "On a Webhook call",
|
||||
"nodeCreator.actionsCategory.actions": "Actions",
|
||||
"nodeCreator.actionsCategory.onNewEvent": "On new {event} event",
|
||||
"nodeCreator.actionsCategory.onEvent": "On {event}",
|
||||
"nodeCreator.actionsCategory.triggers": "Triggers",
|
||||
"nodeCreator.actionsCategory.searchActions": "Search {nodeNameTitle} Actions...",
|
||||
"nodeCreator.actionsList.apiCall": "Didn't find the right event? Make a <a data-action='addHttpNode'>custom {nodeNameTitle} API call</a>",
|
||||
"nodeCreator.actionsList.apiCallNoResult": "Nothing found — try making a <a data-action='addHttpNode'>custom {nodeNameTitle} API call</a>",
|
||||
"nodeCreator.categoryNames.analytics": "Analytics",
|
||||
"nodeCreator.categoryNames.communication": "Communication",
|
||||
"nodeCreator.categoryNames.coreNodes": "Core Nodes",
|
||||
"nodeCreator.categoryNames.customNodes": "Custom Nodes",
|
||||
"nodeCreator.categoryNames.dataStorage": "Data & Storage",
|
||||
"nodeCreator.categoryNames.development": "Development",
|
||||
"nodeCreator.categoryNames.financeAccounting": "Finance & Accounting",
|
||||
"nodeCreator.categoryNames.marketingContent": "Marketing & Content",
|
||||
"nodeCreator.categoryNames.miscellaneous": "Miscellaneous",
|
||||
"nodeCreator.categoryNames.productivity": "Productivity",
|
||||
"nodeCreator.categoryNames.sales": "Sales",
|
||||
"nodeCreator.categoryNames.suggestedNodes": "Suggested Nodes ✨",
|
||||
"nodeCreator.categoryNames.utility": "Utility",
|
||||
"nodeCreator.mainPanel.all": "All",
|
||||
"nodeCreator.mainPanel.regular": "Regular",
|
||||
"nodeCreator.mainPanel.trigger": "Trigger",
|
||||
"nodeCreator.actionsCategory.searchActions": "Search {node} Actions...",
|
||||
"nodeCreator.actionsCategory.noMatchingActions": "No matching Actions. <i>Reset search</i>",
|
||||
"nodeCreator.actionsCategory.noMatchingTriggers": "No matching Triggers. <i>Reset search</i>",
|
||||
"nodeCreator.actionsList.apiCall": "Didn't find the right event? Make a <a data-action='addHttpNode'>custom {node} API call</a>",
|
||||
"nodeCreator.actionsCallout.noActionItems": "We don't have <strong>{nodeName}</strong> actions yet. Have one in mind? Make a <a target=\"_blank\" href=\"https://community.n8n.io/c/feature-requests/5\"> request in our community</a>",
|
||||
"nodeCreator.actionsCallout.triggersStartWorkflow": "Actions need to be triggered by another node, e.g. at regular intervals with the <strong>Schedule</strong> node. <a target=\"_blank\" href=\"https://docs.n8n.io/integrations/builtin/\"> Learn more</a>",
|
||||
"nodeCreator.actionsTooltip.triggersStartWorkflow": "A trigger is a step that starts your workflow. <a target=\"_blank\" href=\"https://docs.n8n.io/integrations/builtin/\"> Learn more</a>",
|
||||
"nodeCreator.actionsTooltip.actionsPerformStep": "Actions perform a step once your workflow has already started. <a target=\"_blank\" href=\"https://docs.n8n.io/integrations/builtin/\"> Learn more</a>",
|
||||
"nodeCreator.actionsCallout.noTriggerItems": "No <strong>{nodeName}</strong> Triggers available. Users often combine the following Triggers with <strong>{nodeName}</strong> Actions.",
|
||||
"nodeCreator.categoryNames.otherCategories": "Results in other categories",
|
||||
"nodeCreator.noResults.dontWorryYouCanProbablyDoItWithThe": "Don’t worry, you can probably do it with the",
|
||||
"nodeCreator.noResults.httpRequest": "HTTP Request",
|
||||
"nodeCreator.noResults.node": "node",
|
||||
|
@ -750,8 +743,6 @@
|
|||
"nodeCreator.noResults.requestTheNode": "Request the node",
|
||||
"nodeCreator.noResults.wantUsToMakeItFaster": "Want us to make it faster?",
|
||||
"nodeCreator.noResults.weDidntMakeThatYet": "We didn't make that... yet",
|
||||
"nodeCreator.noResults.noMatchingActions": "No actions matching your results",
|
||||
"nodeCreator.noResults.clickToSeeResults": "To see all results, <a data-action='showAllNodeCreatorNodes'>click here</a>",
|
||||
"nodeCreator.noResults.webhook": "Webhook",
|
||||
"nodeCreator.searchBar.searchNodes": "Search nodes...",
|
||||
"nodeCreator.subcategoryDescriptions.appTriggerNodes": "Runs the flow when something happens in an app like Telegram, Notion or Airtable",
|
||||
|
@ -768,7 +759,6 @@
|
|||
"nodeCreator.subcategoryNames.flow": "Flow",
|
||||
"nodeCreator.subcategoryNames.helpers": "Helpers",
|
||||
"nodeCreator.subcategoryNames.otherTriggerNodes": "Other ways...",
|
||||
"nodeCreator.subcategoryTitles.otherTriggerNodes": "Other Trigger Nodes",
|
||||
"nodeCreator.triggerHelperPanel.addAnotherTrigger": "Add another trigger",
|
||||
"nodeCreator.triggerHelperPanel.addAnotherTriggerDescription": "Triggers start your workflow. Workflows can have multiple triggers.",
|
||||
"nodeCreator.triggerHelperPanel.title": "When should this workflow run?",
|
||||
|
|
|
@ -136,10 +136,6 @@ export class Telemetry {
|
|||
this.track('User opened nodes panel', properties);
|
||||
}
|
||||
break;
|
||||
case 'nodeCreateList.selectedTypeChanged':
|
||||
this.userNodesPanelSession.data.filterMode = properties.new_filter as string;
|
||||
this.track('User changed nodes panel filter', properties);
|
||||
break;
|
||||
case 'nodeCreateList.destroyed':
|
||||
if (
|
||||
this.userNodesPanelSession.data.nodeFilter.length > 0 &&
|
||||
|
@ -183,10 +179,7 @@ export class Telemetry {
|
|||
this.track('User added action', properties);
|
||||
break;
|
||||
case 'nodeCreateList.onSubcategorySelected':
|
||||
const selectedProperties = (properties.selected as IDataObject).properties as IDataObject;
|
||||
if (selectedProperties && selectedProperties.subcategory) {
|
||||
properties.category_name = selectedProperties.subcategory;
|
||||
}
|
||||
properties.category_name = properties.subcategory;
|
||||
properties.is_subcategory = true;
|
||||
properties.nodes_panel_session_id = this.userNodesPanelSession.sessionId;
|
||||
delete properties.selected;
|
||||
|
|
|
@ -1,401 +1,52 @@
|
|||
import { startCase } from 'lodash-es';
|
||||
import { defineStore } from 'pinia';
|
||||
import { STORES, TRIGGER_NODE_CREATOR_VIEW } from '@/constants';
|
||||
import type {
|
||||
INodePropertyCollection,
|
||||
INodePropertyOptions,
|
||||
IDataObject,
|
||||
INodeProperties,
|
||||
INodeTypeDescription,
|
||||
INodeParameters,
|
||||
INodeActionTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { deepCopy } from 'n8n-workflow';
|
||||
import {
|
||||
STORES,
|
||||
MANUAL_TRIGGER_NODE_TYPE,
|
||||
TRIGGER_NODE_FILTER,
|
||||
STICKY_NODE_TYPE,
|
||||
NODE_CREATOR_OPEN_SOURCES,
|
||||
} from '@/constants';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes';
|
||||
import { useWorkflowsStore } from './workflows';
|
||||
import { CUSTOM_API_CALL_KEY } from '@/constants';
|
||||
import type { INodeCreatorState, INodeFilterType, IUpdateInformation } from '@/Interface';
|
||||
import { i18n } from '@/plugins/i18n';
|
||||
import type { Telemetry } from '@/plugins/telemetry';
|
||||
import { runExternalHook } from '@/utils';
|
||||
import { useWebhooksStore } from '@/stores/webhooks';
|
||||
NodeFilterType,
|
||||
NodeCreatorOpenSource,
|
||||
SimplifiedNodeType,
|
||||
ActionsRecord,
|
||||
} from '@/Interface';
|
||||
|
||||
const PLACEHOLDER_RECOMMENDED_ACTION_KEY = 'placeholder_recommended';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const customNodeActionsParsers: {
|
||||
[key: string]: (
|
||||
matchedProperty: INodeProperties,
|
||||
nodeTypeDescription: INodeTypeDescription,
|
||||
) => INodeActionTypeDescription[] | undefined;
|
||||
} = {
|
||||
['n8n-nodes-base.hubspotTrigger']: (matchedProperty, nodeTypeDescription) => {
|
||||
const collection = matchedProperty?.options?.[0] as INodePropertyCollection;
|
||||
export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
|
||||
const selectedView = ref<NodeFilterType>(TRIGGER_NODE_CREATOR_VIEW);
|
||||
const mergedNodes = ref<SimplifiedNodeType[]>([]);
|
||||
const actions = ref<ActionsRecord<typeof mergedNodes.value>>({});
|
||||
|
||||
return (collection?.values[0]?.options as INodePropertyOptions[])?.map(
|
||||
(categoryItem): INodeActionTypeDescription => ({
|
||||
...getNodeTypeBase(
|
||||
nodeTypeDescription,
|
||||
i18n.baseText('nodeCreator.actionsCategory.triggers'),
|
||||
),
|
||||
actionKey: categoryItem.value as string,
|
||||
displayName: i18n.baseText('nodeCreator.actionsCategory.onEvent', {
|
||||
interpolate: { event: startCase(categoryItem.name) },
|
||||
}),
|
||||
description: categoryItem.description || '',
|
||||
displayOptions: matchedProperty.displayOptions,
|
||||
values: { eventsUi: { eventValues: [{ name: categoryItem.value }] } },
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
const showScrim = ref(false);
|
||||
const openSource = ref<NodeCreatorOpenSource>('');
|
||||
|
||||
function filterActions(actions: INodeActionTypeDescription[]) {
|
||||
// Do not show single action nodes
|
||||
if (actions.length <= 1) return [];
|
||||
return actions.filter(
|
||||
(action: INodeActionTypeDescription, _: number, arr: INodeActionTypeDescription[]) => {
|
||||
const isApiCall = action.actionKey === CUSTOM_API_CALL_KEY;
|
||||
if (isApiCall) return false;
|
||||
|
||||
const isPlaceholderTriggerAction = action.actionKey === PLACEHOLDER_RECOMMENDED_ACTION_KEY;
|
||||
return !isPlaceholderTriggerAction || (isPlaceholderTriggerAction && arr.length > 1);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function getNodeTypeBase(nodeTypeDescription: INodeTypeDescription, category: string) {
|
||||
return {
|
||||
name: nodeTypeDescription.name,
|
||||
group: ['trigger'],
|
||||
codex: {
|
||||
categories: [category],
|
||||
subcategories: {
|
||||
[nodeTypeDescription.displayName]: [category],
|
||||
},
|
||||
},
|
||||
iconUrl: nodeTypeDescription.iconUrl,
|
||||
icon: nodeTypeDescription.icon,
|
||||
version: [1],
|
||||
defaults: {
|
||||
...nodeTypeDescription.defaults,
|
||||
},
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: [],
|
||||
};
|
||||
}
|
||||
|
||||
function operationsCategory(
|
||||
nodeTypeDescription: INodeTypeDescription,
|
||||
): INodeActionTypeDescription[] {
|
||||
if (!!nodeTypeDescription.properties.find((property) => property.name === 'resource')) return [];
|
||||
|
||||
const matchedProperty = nodeTypeDescription.properties.find(
|
||||
(property) => property.name?.toLowerCase() === 'operation',
|
||||
);
|
||||
|
||||
if (!matchedProperty || !matchedProperty.options) return [];
|
||||
|
||||
const filteredOutItems = (matchedProperty.options as INodePropertyOptions[]).filter(
|
||||
(categoryItem: INodePropertyOptions) => !['*', '', ' '].includes(categoryItem.name),
|
||||
);
|
||||
|
||||
const items = filteredOutItems.map((item: INodePropertyOptions) => ({
|
||||
...getNodeTypeBase(nodeTypeDescription, i18n.baseText('nodeCreator.actionsCategory.actions')),
|
||||
actionKey: item.value as string,
|
||||
displayName: item.action ?? startCase(item.name),
|
||||
description: item.description ?? '',
|
||||
displayOptions: matchedProperty.displayOptions,
|
||||
values: {
|
||||
[matchedProperty.name]: matchedProperty.type === 'multiOptions' ? [item.value] : item.value,
|
||||
},
|
||||
}));
|
||||
|
||||
// Do not return empty category
|
||||
if (items.length === 0) return [];
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function triggersCategory(nodeTypeDescription: INodeTypeDescription): INodeActionTypeDescription[] {
|
||||
const matchingKeys = ['event', 'events', 'trigger on'];
|
||||
const isTrigger = nodeTypeDescription.displayName?.toLowerCase().includes('trigger');
|
||||
const matchedProperty = nodeTypeDescription.properties.find((property) =>
|
||||
matchingKeys.includes(property.displayName?.toLowerCase()),
|
||||
);
|
||||
|
||||
if (!isTrigger) return [];
|
||||
|
||||
// Inject placeholder action if no events are available
|
||||
// so user is able to add node to the canvas from the actions panel
|
||||
if (!matchedProperty || !matchedProperty.options) {
|
||||
return [
|
||||
{
|
||||
...getNodeTypeBase(
|
||||
nodeTypeDescription,
|
||||
i18n.baseText('nodeCreator.actionsCategory.triggers'),
|
||||
),
|
||||
actionKey: PLACEHOLDER_RECOMMENDED_ACTION_KEY,
|
||||
displayName: i18n.baseText('nodeCreator.actionsCategory.onNewEvent', {
|
||||
interpolate: { event: nodeTypeDescription.displayName.replace('Trigger', '').trimEnd() },
|
||||
}),
|
||||
description: '',
|
||||
},
|
||||
];
|
||||
function setMergeNodes(nodes: SimplifiedNodeType[]) {
|
||||
mergedNodes.value = nodes;
|
||||
}
|
||||
|
||||
const filteredOutItems = (matchedProperty.options as INodePropertyOptions[]).filter(
|
||||
(categoryItem: INodePropertyOptions) => !['*', '', ' '].includes(categoryItem.name),
|
||||
);
|
||||
function setActions(nodes: ActionsRecord<typeof mergedNodes.value>) {
|
||||
actions.value = nodes;
|
||||
}
|
||||
|
||||
const customParsedItem = customNodeActionsParsers[nodeTypeDescription.name]?.(
|
||||
matchedProperty,
|
||||
nodeTypeDescription,
|
||||
);
|
||||
function setShowScrim(isVisible: boolean) {
|
||||
showScrim.value = isVisible;
|
||||
}
|
||||
|
||||
const items =
|
||||
customParsedItem ??
|
||||
filteredOutItems.map((categoryItem: INodePropertyOptions) => ({
|
||||
...getNodeTypeBase(
|
||||
nodeTypeDescription,
|
||||
i18n.baseText('nodeCreator.actionsCategory.triggers'),
|
||||
),
|
||||
actionKey: categoryItem.value as string,
|
||||
displayName:
|
||||
categoryItem.action ??
|
||||
i18n.baseText('nodeCreator.actionsCategory.onEvent', {
|
||||
interpolate: { event: startCase(categoryItem.name) },
|
||||
}),
|
||||
description: categoryItem.description || '',
|
||||
displayOptions: matchedProperty.displayOptions,
|
||||
values: {
|
||||
[matchedProperty.name]:
|
||||
matchedProperty.type === 'multiOptions' ? [categoryItem.value] : categoryItem.value,
|
||||
},
|
||||
}));
|
||||
function setSelectedView(view: NodeFilterType) {
|
||||
selectedView.value = view;
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
function setOpenSource(view: NodeCreatorOpenSource) {
|
||||
openSource.value = view;
|
||||
}
|
||||
|
||||
function resourceCategories(
|
||||
nodeTypeDescription: INodeTypeDescription,
|
||||
): INodeActionTypeDescription[] {
|
||||
const transformedNodes: INodeActionTypeDescription[] = [];
|
||||
const matchedProperties = nodeTypeDescription.properties.filter(
|
||||
(property) => property.displayName?.toLowerCase() === 'resource',
|
||||
);
|
||||
|
||||
matchedProperties.forEach((property) => {
|
||||
((property.options as INodePropertyOptions[]) || [])
|
||||
.filter((option) => option.value !== CUSTOM_API_CALL_KEY)
|
||||
.forEach((resourceOption, i, options) => {
|
||||
const isSingleResource = options.length === 1;
|
||||
|
||||
// Match operations for the resource by checking if displayOptions matches or contains the resource name
|
||||
const operations = nodeTypeDescription.properties.find(
|
||||
(operation) =>
|
||||
operation.name === 'operation' &&
|
||||
(operation.displayOptions?.show?.resource?.includes(resourceOption.value) ||
|
||||
isSingleResource),
|
||||
);
|
||||
|
||||
if (!operations?.options) return;
|
||||
|
||||
const items = ((operations.options as INodePropertyOptions[]) || []).map(
|
||||
(operationOption) => {
|
||||
const displayName =
|
||||
operationOption.action ?? `${resourceOption.name} ${startCase(operationOption.name)}`;
|
||||
|
||||
// We need to manually populate displayOptions as they are not present in the node description
|
||||
// if the resource has only one option
|
||||
const displayOptions = isSingleResource
|
||||
? { show: { resource: [(options as INodePropertyOptions[])[0]?.value] } }
|
||||
: operations?.displayOptions;
|
||||
|
||||
return {
|
||||
...getNodeTypeBase(nodeTypeDescription, resourceOption.name),
|
||||
actionKey: operationOption.value as string,
|
||||
description: operationOption?.description ?? '',
|
||||
displayOptions,
|
||||
values: {
|
||||
operation:
|
||||
operations?.type === 'multiOptions'
|
||||
? [operationOption.value]
|
||||
: operationOption.value,
|
||||
},
|
||||
displayName,
|
||||
group: ['trigger'],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
transformedNodes.push(...items);
|
||||
});
|
||||
});
|
||||
|
||||
return transformedNodes;
|
||||
}
|
||||
|
||||
export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, {
|
||||
state: (): INodeCreatorState => ({
|
||||
itemsFilter: '',
|
||||
showScrim: false,
|
||||
selectedView: TRIGGER_NODE_FILTER,
|
||||
rootViewHistory: [],
|
||||
openSource: '',
|
||||
}),
|
||||
actions: {
|
||||
setShowScrim(isVisible: boolean) {
|
||||
this.showScrim = isVisible;
|
||||
},
|
||||
setSelectedView(selectedNodeType: INodeFilterType) {
|
||||
this.selectedView = selectedNodeType;
|
||||
if (!this.rootViewHistory.includes(selectedNodeType)) {
|
||||
this.rootViewHistory.push(selectedNodeType);
|
||||
}
|
||||
},
|
||||
closeCurrentView() {
|
||||
this.rootViewHistory.pop();
|
||||
this.selectedView = this.rootViewHistory[this.rootViewHistory.length - 1];
|
||||
},
|
||||
resetRootViewHistory() {
|
||||
this.rootViewHistory = [];
|
||||
},
|
||||
setFilter(search: string) {
|
||||
this.itemsFilter = search;
|
||||
},
|
||||
setAddedNodeActionParameters(action: IUpdateInformation, telemetry?: Telemetry, track = true) {
|
||||
const { $onAction: onWorkflowStoreAction } = useWorkflowsStore();
|
||||
const storeWatcher = onWorkflowStoreAction(
|
||||
({ name, after, store: { setLastNodeParameters }, args }) => {
|
||||
if (name !== 'addNode' || args[0].type !== action.key) return;
|
||||
after(() => {
|
||||
setLastNodeParameters(action);
|
||||
if (track) this.trackActionSelected(action, telemetry);
|
||||
storeWatcher();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return storeWatcher;
|
||||
},
|
||||
trackActionSelected(action: IUpdateInformation, telemetry?: Telemetry) {
|
||||
const payload = {
|
||||
node_type: action.key,
|
||||
action: action.name,
|
||||
resource: (action.value as INodeParameters).resource || '',
|
||||
};
|
||||
runExternalHook('nodeCreateList.addAction', useWebhooksStore(), payload);
|
||||
telemetry?.trackNodesPanel('nodeCreateList.addAction', payload);
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
visibleNodesWithActions(): INodeTypeDescription[] {
|
||||
const nodes = deepCopy(useNodeTypesStore().visibleNodeTypes);
|
||||
const nodesWithActions = nodes.map((node) => {
|
||||
node.actions = [
|
||||
...triggersCategory(node),
|
||||
...operationsCategory(node),
|
||||
...resourceCategories(node),
|
||||
];
|
||||
|
||||
return node;
|
||||
});
|
||||
return nodesWithActions;
|
||||
},
|
||||
mergedAppNodes(): INodeTypeDescription[] {
|
||||
const triggers = this.visibleNodesWithActions.filter((node) =>
|
||||
node.group.includes('trigger'),
|
||||
);
|
||||
const apps = this.visibleNodesWithActions
|
||||
.filter((node) => !node.group.includes('trigger'))
|
||||
.map((node) => {
|
||||
const newNode = deepCopy(node);
|
||||
newNode.actions = newNode.actions || [];
|
||||
return newNode;
|
||||
});
|
||||
|
||||
triggers.forEach((node) => {
|
||||
const normalizedName = node.name.toLowerCase().replace('trigger', '');
|
||||
const app = apps.find((node) => node.name.toLowerCase() === normalizedName);
|
||||
const newNode = deepCopy(node);
|
||||
if (app && app.actions?.length) {
|
||||
// merge triggers into regular nodes that match
|
||||
app?.actions?.push(...(newNode.actions || []));
|
||||
app.description = newNode.description; // default to trigger description
|
||||
} else {
|
||||
newNode.actions = newNode.actions || [];
|
||||
apps.push(newNode);
|
||||
}
|
||||
});
|
||||
|
||||
const filteredNodes = apps.map((node) => ({
|
||||
...node,
|
||||
actions: filterActions(node.actions || []),
|
||||
}));
|
||||
|
||||
return filteredNodes;
|
||||
},
|
||||
getNodeTypesWithManualTrigger:
|
||||
() =>
|
||||
(nodeType?: string): string[] => {
|
||||
if (!nodeType) return [];
|
||||
|
||||
const { workflowTriggerNodes } = useWorkflowsStore();
|
||||
const isTrigger = useNodeTypesStore().isTriggerNode(nodeType);
|
||||
const workflowContainsTrigger = workflowTriggerNodes.length > 0;
|
||||
const isTriggerPanel = useNodeCreatorStore().selectedView === TRIGGER_NODE_FILTER;
|
||||
const isStickyNode = nodeType === STICKY_NODE_TYPE;
|
||||
const singleNodeOpenSources = [
|
||||
NODE_CREATOR_OPEN_SOURCES.PLUS_ENDPOINT,
|
||||
NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_ACTION,
|
||||
NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_DROP,
|
||||
];
|
||||
|
||||
// If the node creator was opened from the plus endpoint, node connection action, or node connection drop
|
||||
// then we do not want to append the manual trigger
|
||||
const isSingleNodeOpenSource = singleNodeOpenSources.includes(
|
||||
useNodeCreatorStore().openSource,
|
||||
);
|
||||
|
||||
const shouldAppendManualTrigger =
|
||||
!isSingleNodeOpenSource &&
|
||||
!isTrigger &&
|
||||
!workflowContainsTrigger &&
|
||||
isTriggerPanel &&
|
||||
!isStickyNode;
|
||||
|
||||
const nodeTypes = shouldAppendManualTrigger
|
||||
? [MANUAL_TRIGGER_NODE_TYPE, nodeType]
|
||||
: [nodeType];
|
||||
|
||||
return nodeTypes;
|
||||
},
|
||||
|
||||
getActionData:
|
||||
() =>
|
||||
(actionItem: INodeActionTypeDescription): IUpdateInformation => {
|
||||
const displayOptions = actionItem.displayOptions;
|
||||
|
||||
const displayConditions = Object.keys(displayOptions?.show || {}).reduce(
|
||||
(acc: IDataObject, showCondition: string) => {
|
||||
acc[showCondition] = displayOptions?.show?.[showCondition]?.[0];
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return {
|
||||
name: actionItem.displayName,
|
||||
key: actionItem.name as string,
|
||||
value: { ...actionItem.values, ...displayConditions } as INodeParameters,
|
||||
};
|
||||
},
|
||||
},
|
||||
return {
|
||||
openSource,
|
||||
selectedView,
|
||||
showScrim,
|
||||
mergedNodes,
|
||||
actions,
|
||||
setShowScrim,
|
||||
setSelectedView,
|
||||
setOpenSource,
|
||||
setActions,
|
||||
setMergeNodes,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -6,14 +6,9 @@ import {
|
|||
getResourceLocatorResults,
|
||||
} from '@/api/nodeTypes';
|
||||
import { DEFAULT_NODETYPE_VERSION, STORES } from '@/constants';
|
||||
import type {
|
||||
ICategoriesWithNodes,
|
||||
INodeCreateElement,
|
||||
INodeTypesState,
|
||||
IResourceLocatorReqParams,
|
||||
} from '@/Interface';
|
||||
import type { INodeTypesState, IResourceLocatorReqParams } from '@/Interface';
|
||||
import { addHeaders, addNodeTranslation } from '@/plugins/i18n';
|
||||
import { omit, getCategoriesWithNodes, getCategorizedList } from '@/utils';
|
||||
import { omit } from '@/utils';
|
||||
import type {
|
||||
ILoadOptions,
|
||||
INodeCredentials,
|
||||
|
@ -27,8 +22,7 @@ import { defineStore } from 'pinia';
|
|||
import Vue from 'vue';
|
||||
import { useCredentialsStore } from './credentials';
|
||||
import { useRootStore } from './n8nRootStore';
|
||||
import { useUsersStore } from './users';
|
||||
import { useNodeCreatorStore } from './nodeCreator';
|
||||
|
||||
function getNodeVersions(nodeType: INodeTypeDescription) {
|
||||
return Array.isArray(nodeType.version) ? nodeType.version : [nodeType.version];
|
||||
}
|
||||
|
@ -88,13 +82,6 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, {
|
|||
visibleNodeTypes(): INodeTypeDescription[] {
|
||||
return this.allLatestNodeTypes.filter((nodeType: INodeTypeDescription) => !nodeType.hidden);
|
||||
},
|
||||
categoriesWithNodes(): ICategoriesWithNodes {
|
||||
const usersStore = useUsersStore();
|
||||
return getCategoriesWithNodes(this.visibleNodeTypes, usersStore.personalizedNodeTypes);
|
||||
},
|
||||
categorizedItems(): INodeCreateElement[] {
|
||||
return getCategorizedList(this.categoriesWithNodes);
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
setNodeTypes(newNodeTypes: INodeTypeDescription[] = []): void {
|
||||
|
@ -128,9 +115,6 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, {
|
|||
{ ...this.nodeTypes },
|
||||
);
|
||||
Vue.set(this, 'nodeTypes', nodeTypes);
|
||||
|
||||
// Trigger compute of mergedAppNodes getter so it's ready when user opens the node creator
|
||||
useNodeCreatorStore().mergedAppNodes;
|
||||
},
|
||||
removeNodeTypes(nodeTypesToRemove: INodeTypeDescription[]): void {
|
||||
this.nodeTypes = nodeTypesToRemove.reduce(
|
||||
|
|
|
@ -4,24 +4,13 @@ import { useNodeTypesStore } from './../stores/nodeTypes';
|
|||
import type { INodeCredentialDescription } from './../../../workflow/src/Interfaces';
|
||||
import {
|
||||
CORE_NODES_CATEGORY,
|
||||
CUSTOM_NODES_CATEGORY,
|
||||
SUBCATEGORY_DESCRIPTIONS,
|
||||
UNCATEGORIZED_CATEGORY,
|
||||
UNCATEGORIZED_SUBCATEGORY,
|
||||
PERSONALIZED_CATEGORY,
|
||||
NON_ACTIVATABLE_TRIGGER_NODE_TYPES,
|
||||
TEMPLATES_NODES_FILTER,
|
||||
REGULAR_NODE_FILTER,
|
||||
TRIGGER_NODE_FILTER,
|
||||
ALL_NODE_FILTER,
|
||||
MAPPING_PARAMS,
|
||||
} from '@/constants';
|
||||
import type {
|
||||
INodeCreateElement,
|
||||
ICategoriesWithNodes,
|
||||
INodeUi,
|
||||
ITemplatesNode,
|
||||
INodeItemProps,
|
||||
NodeAuthenticationOption,
|
||||
INodeUpdatePropertiesInformation,
|
||||
} from '@/Interface';
|
||||
|
@ -30,7 +19,6 @@ import type {
|
|||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
INodeTypeDescription,
|
||||
INodeActionTypeDescription,
|
||||
NodeParameterValueType,
|
||||
INodePropertyOptions,
|
||||
INodePropertyCollection,
|
||||
|
@ -48,158 +36,6 @@ const CRED_KEYWORDS_TO_FILTER = ['API', 'OAuth1', 'OAuth2'];
|
|||
const NODE_KEYWORDS_TO_FILTER = ['Trigger'];
|
||||
const COMMUNITY_PACKAGE_NAME_REGEX = /(@\w+\/)?n8n-nodes-(?!base\b)\b\w+/g;
|
||||
|
||||
const addNodeToCategory = (
|
||||
accu: ICategoriesWithNodes,
|
||||
nodeType: INodeTypeDescription | INodeActionTypeDescription,
|
||||
category: string,
|
||||
subcategory: string,
|
||||
) => {
|
||||
if (!accu[category]) {
|
||||
accu[category] = {};
|
||||
}
|
||||
if (!accu[category][subcategory]) {
|
||||
accu[category][subcategory] = {
|
||||
triggerCount: 0,
|
||||
regularCount: 0,
|
||||
nodes: [],
|
||||
};
|
||||
}
|
||||
const isTrigger = nodeType.group.includes('trigger');
|
||||
if (isTrigger) {
|
||||
accu[category][subcategory].triggerCount++;
|
||||
}
|
||||
if (!isTrigger) {
|
||||
accu[category][subcategory].regularCount++;
|
||||
}
|
||||
accu[category][subcategory].nodes.push({
|
||||
type: nodeType.actionKey ? 'action' : 'node',
|
||||
key: `${nodeType.name}`,
|
||||
category,
|
||||
properties: {
|
||||
nodeType,
|
||||
subcategory,
|
||||
},
|
||||
includedByTrigger: isTrigger,
|
||||
includedByRegular: !isTrigger,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCategoriesWithNodes = (
|
||||
nodeTypes: INodeTypeDescription[],
|
||||
uncategorizedSubcategory = UNCATEGORIZED_SUBCATEGORY,
|
||||
): ICategoriesWithNodes => {
|
||||
const sorted = [...nodeTypes].sort((a: INodeTypeDescription, b: INodeTypeDescription) =>
|
||||
a.displayName > b.displayName ? 1 : -1,
|
||||
);
|
||||
const result = sorted.reduce((accu: ICategoriesWithNodes, nodeType: INodeTypeDescription) => {
|
||||
if (!nodeType.codex || !nodeType.codex.categories) {
|
||||
addNodeToCategory(accu, nodeType, UNCATEGORIZED_CATEGORY, uncategorizedSubcategory);
|
||||
return accu;
|
||||
}
|
||||
|
||||
nodeType.codex.categories.forEach((_category: string) => {
|
||||
const category = _category.trim();
|
||||
const subcategories = nodeType?.codex?.subcategories?.[category] ?? null;
|
||||
|
||||
if (subcategories === null || subcategories.length === 0) {
|
||||
addNodeToCategory(accu, nodeType, category, uncategorizedSubcategory);
|
||||
return;
|
||||
}
|
||||
|
||||
subcategories.forEach((subcategory) => {
|
||||
addNodeToCategory(accu, nodeType, category, subcategory);
|
||||
});
|
||||
});
|
||||
return accu;
|
||||
}, {});
|
||||
return result;
|
||||
};
|
||||
|
||||
const getCategories = (categoriesWithNodes: ICategoriesWithNodes): string[] => {
|
||||
const excludeFromSort = [
|
||||
CORE_NODES_CATEGORY,
|
||||
CUSTOM_NODES_CATEGORY,
|
||||
UNCATEGORIZED_CATEGORY,
|
||||
PERSONALIZED_CATEGORY,
|
||||
];
|
||||
const categories = Object.keys(categoriesWithNodes);
|
||||
const sorted = categories.filter((category: string) => !excludeFromSort.includes(category));
|
||||
sorted.sort();
|
||||
|
||||
return [
|
||||
CORE_NODES_CATEGORY,
|
||||
CUSTOM_NODES_CATEGORY,
|
||||
PERSONALIZED_CATEGORY,
|
||||
...sorted,
|
||||
UNCATEGORIZED_CATEGORY,
|
||||
];
|
||||
};
|
||||
|
||||
export const getCategorizedList = (
|
||||
categoriesWithNodes: ICategoriesWithNodes,
|
||||
categoryIsExpanded = false,
|
||||
): INodeCreateElement[] => {
|
||||
const categories = getCategories(categoriesWithNodes);
|
||||
|
||||
const result = categories.reduce((accu: INodeCreateElement[], category: string) => {
|
||||
if (!categoriesWithNodes[category]) {
|
||||
return accu;
|
||||
}
|
||||
|
||||
const categoryEl: INodeCreateElement = {
|
||||
type: 'category',
|
||||
key: category,
|
||||
properties: {
|
||||
category,
|
||||
name: category,
|
||||
expanded: categoryIsExpanded,
|
||||
},
|
||||
};
|
||||
|
||||
const subcategories = Object.keys(categoriesWithNodes[category]);
|
||||
if (subcategories.length === 1) {
|
||||
const subcategory = categoriesWithNodes[category][subcategories[0]];
|
||||
if (subcategory.triggerCount > 0) {
|
||||
categoryEl.includedByTrigger = subcategory.triggerCount > 0;
|
||||
}
|
||||
if (subcategory.regularCount > 0) {
|
||||
categoryEl.includedByRegular = subcategory.regularCount > 0;
|
||||
}
|
||||
return [...accu, categoryEl, ...subcategory.nodes];
|
||||
}
|
||||
|
||||
subcategories.sort();
|
||||
const subcategorized = subcategories.reduce(
|
||||
(accu: INodeCreateElement[], subcategory: string) => {
|
||||
const subcategoryEl: INodeCreateElement = {
|
||||
type: 'subcategory',
|
||||
key: `${category}_${subcategory}`,
|
||||
properties: {
|
||||
subcategory,
|
||||
description: SUBCATEGORY_DESCRIPTIONS[category][subcategory],
|
||||
},
|
||||
includedByTrigger: categoriesWithNodes[category][subcategory].triggerCount > 0,
|
||||
includedByRegular: categoriesWithNodes[category][subcategory].regularCount > 0,
|
||||
};
|
||||
|
||||
if (subcategoryEl.includedByTrigger) {
|
||||
categoryEl.includedByTrigger = true;
|
||||
}
|
||||
if (subcategoryEl.includedByRegular) {
|
||||
categoryEl.includedByRegular = true;
|
||||
}
|
||||
|
||||
accu.push(subcategoryEl);
|
||||
return accu;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return [...accu, categoryEl, ...subcategorized];
|
||||
}, []);
|
||||
return result;
|
||||
};
|
||||
|
||||
export function getAppNameFromCredType(name: string) {
|
||||
return name
|
||||
.split(' ')
|
||||
|
@ -271,35 +107,6 @@ export const executionDataToJson = (inputData: INodeExecutionData[]): IDataObjec
|
|||
[],
|
||||
);
|
||||
|
||||
export const matchesSelectType = (el: INodeCreateElement, selectedView: string) => {
|
||||
if (selectedView === REGULAR_NODE_FILTER && el.includedByRegular) {
|
||||
return true;
|
||||
}
|
||||
if (selectedView === TRIGGER_NODE_FILTER && el.includedByTrigger) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return selectedView === ALL_NODE_FILTER;
|
||||
};
|
||||
|
||||
const matchesAlias = (nodeType: INodeTypeDescription, filter: string): boolean => {
|
||||
if (!nodeType.codex || !nodeType.codex.alias) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return nodeType.codex.alias.reduce((accu: boolean, alias: string) => {
|
||||
return accu || alias.toLowerCase().indexOf(filter) > -1;
|
||||
}, false);
|
||||
};
|
||||
|
||||
export const matchesNodeType = (el: INodeCreateElement, filter: string) => {
|
||||
const nodeType = (el.properties as INodeItemProps).nodeType;
|
||||
|
||||
return (
|
||||
nodeType.displayName.toLowerCase().indexOf(filter) !== -1 || matchesAlias(nodeType, filter)
|
||||
);
|
||||
};
|
||||
|
||||
export const hasOnlyListMode = (parameter: INodeProperties): boolean => {
|
||||
return (
|
||||
parameter.modes !== undefined &&
|
||||
|
|
|
@ -196,9 +196,9 @@ import {
|
|||
STICKY_NODE_TYPE,
|
||||
VIEWS,
|
||||
WEBHOOK_NODE_TYPE,
|
||||
TRIGGER_NODE_FILTER,
|
||||
TRIGGER_NODE_CREATOR_VIEW,
|
||||
EnterpriseEditionFeature,
|
||||
REGULAR_NODE_FILTER,
|
||||
REGULAR_NODE_CREATOR_VIEW,
|
||||
MANUAL_TRIGGER_NODE_TYPE,
|
||||
NODE_CREATOR_OPEN_SOURCES,
|
||||
} from '@/constants';
|
||||
|
@ -771,7 +771,7 @@ export default mixins(
|
|||
},
|
||||
showTriggerCreator(source: NodeCreatorOpenSource) {
|
||||
if (this.createNodeActive) return;
|
||||
this.nodeCreatorStore.setSelectedView(TRIGGER_NODE_FILTER);
|
||||
this.nodeCreatorStore.setSelectedView(TRIGGER_NODE_CREATOR_VIEW);
|
||||
this.nodeCreatorStore.setShowScrim(true);
|
||||
this.onToggleNodeCreator({ source, createNodeActive: true });
|
||||
},
|
||||
|
@ -3723,15 +3723,15 @@ export default mixins(
|
|||
|
||||
// Default to the trigger tab in node creator if there's no trigger node yet
|
||||
this.nodeCreatorStore.setSelectedView(
|
||||
this.containsTrigger ? REGULAR_NODE_FILTER : TRIGGER_NODE_FILTER,
|
||||
this.containsTrigger ? REGULAR_NODE_CREATOR_VIEW : TRIGGER_NODE_CREATOR_VIEW,
|
||||
);
|
||||
|
||||
this.createNodeActive = createNodeActive;
|
||||
|
||||
const mode =
|
||||
this.nodeCreatorStore.selectedView === TRIGGER_NODE_FILTER ? 'trigger' : 'regular';
|
||||
this.nodeCreatorStore.selectedView === TRIGGER_NODE_CREATOR_VIEW ? 'trigger' : 'regular';
|
||||
|
||||
this.nodeCreatorStore.openSource = source || '';
|
||||
if (createNodeActive === true) this.nodeCreatorStore.setOpenSource(source);
|
||||
this.$externalHooks().run('nodeView.createNodeActiveChanged', {
|
||||
source,
|
||||
mode,
|
||||
|
|
|
@ -1393,12 +1393,6 @@ export interface IPostReceiveSort extends IPostReceiveBase {
|
|||
};
|
||||
}
|
||||
|
||||
export interface INodeActionTypeDescription extends INodeTypeDescription {
|
||||
displayOptions?: IDisplayOptions;
|
||||
values?: IDataObject;
|
||||
actionKey: string;
|
||||
}
|
||||
|
||||
export interface INodeTypeDescription extends INodeTypeBaseDescription {
|
||||
version: number | number[];
|
||||
defaults: INodeParameters;
|
||||
|
@ -1437,7 +1431,6 @@ export interface INodeTypeDescription extends INodeTypeBaseDescription {
|
|||
inactive: string;
|
||||
};
|
||||
};
|
||||
actions?: INodeActionTypeDescription[];
|
||||
__loadOptionsMethods?: string[]; // only for validation during build
|
||||
}
|
||||
|
||||
|
|
|
@ -872,8 +872,8 @@ importers:
|
|||
specifier: ^5.15.3
|
||||
version: 5.15.4
|
||||
'@fortawesome/vue-fontawesome':
|
||||
specifier: ^2.0.2
|
||||
version: 2.0.8(@fortawesome/fontawesome-svg-core@1.2.36)(vue@2.7.14)
|
||||
specifier: ^2.0.10
|
||||
version: 2.0.10(@fortawesome/fontawesome-svg-core@1.2.36)(vue@2.7.14)
|
||||
'@jsplumb/browser-ui':
|
||||
specifier: ^5.13.2
|
||||
version: 5.13.2
|
||||
|
@ -1064,6 +1064,9 @@ importers:
|
|||
'@vitest/coverage-c8':
|
||||
specifier: ^0.28.5
|
||||
version: 0.28.5(sass@1.55.0)(terser@5.16.1)
|
||||
'@volar-plugins/eslint':
|
||||
specifier: ^0.0.4
|
||||
version: 0.0.4(eslint@8.39.0)
|
||||
c8:
|
||||
specifier: ^7.12.0
|
||||
version: 7.12.0
|
||||
|
@ -3760,8 +3763,8 @@ packages:
|
|||
dependencies:
|
||||
'@fortawesome/fontawesome-common-types': 0.2.36
|
||||
|
||||
/@fortawesome/vue-fontawesome@2.0.8(@fortawesome/fontawesome-svg-core@1.2.36)(vue@2.7.14):
|
||||
resolution: {integrity: sha512-SRmP0q9Ox4zq8ydDR/hrH+23TVU1bdwYVnugLVaAIwklOHbf56gx6JUGlwES7zjuNYqzKgl8e39iYf6ph8qSQw==}
|
||||
/@fortawesome/vue-fontawesome@2.0.10(@fortawesome/fontawesome-svg-core@1.2.36)(vue@2.7.14):
|
||||
resolution: {integrity: sha512-OTETSXz+3ygD2OK2/vy82cmUBpuJqeOAg4gfnnv+f2Rir1tDIhQg026Q3NQxznq83ZLz8iNqGG9XJm26inpDeg==}
|
||||
peerDependencies:
|
||||
'@fortawesome/fontawesome-svg-core': ~1 || ~6
|
||||
vue: ~2
|
||||
|
@ -3999,13 +4002,6 @@ packages:
|
|||
- supports-color
|
||||
dev: true
|
||||
|
||||
/@jest/schemas@29.4.2:
|
||||
resolution: {integrity: sha512-ZrGzGfh31NtdVH8tn0mgJw4khQuNHiKqdzJAFbCaERbyCP9tHlxWuL/mnMu8P7e/+k4puWjI1NOzi/sFsjce/g==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
dependencies:
|
||||
'@sinclair/typebox': 0.25.21
|
||||
dev: true
|
||||
|
||||
/@jest/schemas@29.4.3:
|
||||
resolution: {integrity: sha512-VLYKXQmtmuEz6IxJsrZwzG9NvtkQsWNnWMsKxqWNu3+CnfzJQhp0WDDKWLVV9hLKr0l3SLLFRqcYHjhtyuDVxg==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
@ -7386,6 +7382,15 @@ packages:
|
|||
pretty-format: 27.5.1
|
||||
dev: true
|
||||
|
||||
/@volar-plugins/eslint@0.0.4(eslint@8.39.0):
|
||||
resolution: {integrity: sha512-CjvOHSPOyfMv4mgjFk0JOsnq0yWj4LaHb7n4bjNWqqhdwPWmaUEK5AbOloBRxix0736Z3OimH8GMjHNlMcbeiw==}
|
||||
peerDependencies:
|
||||
eslint: '*'
|
||||
dependencies:
|
||||
'@volar/shared': 1.0.24
|
||||
eslint: 8.39.0
|
||||
dev: true
|
||||
|
||||
/@volar/language-core@1.0.24:
|
||||
resolution: {integrity: sha512-vTN+alJiWwK0Pax6POqrmevbtFW2dXhjwWiW/MW4f48eDYPLdyURWcr8TixO7EN/nHsUBj2udT7igFKPtjyAKg==}
|
||||
dependencies:
|
||||
|
@ -7393,6 +7398,13 @@ packages:
|
|||
muggle-string: 0.1.0
|
||||
dev: true
|
||||
|
||||
/@volar/shared@1.0.24:
|
||||
resolution: {integrity: sha512-30mqmNsw49xlGhziL59z6kP6/TlBatkeOzMImUSWmn1QtqV7r2onDGgNNdCqSa1esTo4UtGup6yqqM2oUwrMSQ==}
|
||||
dependencies:
|
||||
typesafe-path: 0.2.2
|
||||
vscode-uri: 3.0.7
|
||||
dev: true
|
||||
|
||||
/@volar/source-map@1.0.24:
|
||||
resolution: {integrity: sha512-Qsv/tkplx18pgBr8lKAbM1vcDqgkGKQzbChg6NW+v0CZc3G7FLmK+WrqEPzKlN7Cwdc6XVL559Nod8WKAfKr4A==}
|
||||
dependencies:
|
||||
|
@ -17760,7 +17772,7 @@ packages:
|
|||
resolution: {integrity: sha512-qKlHR8yFVCbcEWba0H0TOC8dnLlO4vPlyEjRPw31FZ2Rupy9nLa8ZLbYny8gWEl8CkEhJqAE6IzdNELTBVcBEg==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
dependencies:
|
||||
'@jest/schemas': 29.4.2
|
||||
'@jest/schemas': 29.4.3
|
||||
ansi-styles: 5.2.0
|
||||
react-is: 18.2.0
|
||||
dev: true
|
||||
|
@ -20906,6 +20918,10 @@ packages:
|
|||
- supports-color
|
||||
dev: false
|
||||
|
||||
/typesafe-path@0.2.2:
|
||||
resolution: {integrity: sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==}
|
||||
dev: true
|
||||
|
||||
/typescript@5.0.3:
|
||||
resolution: {integrity: sha512-xv8mOEDnigb/tN9PSMTwSEqAnUvkoXMQlicOb0IUVDBSQCgBSaAAROUZYy2IcUy5qU6XajK5jjjO7TMWqBTKZA==}
|
||||
engines: {node: '>=12.20'}
|
||||
|
@ -21585,6 +21601,10 @@ packages:
|
|||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/vscode-uri@3.0.7:
|
||||
resolution: {integrity: sha512-eOpPHogvorZRobNqJGhapa0JdwaxpjVvyBp0QIUMRMSf8ZAlqOdEquKuRmw9Qwu0qXtJIWqFtMkmvJjUZmMjVA==}
|
||||
dev: true
|
||||
|
||||
/vue-agile@2.0.0:
|
||||
resolution: {integrity: sha512-5xkSLJQNRdQ7qpEnXj5FgLg33XKRHaTZKGP5qkvteOc/uGJX89MYCjPSgdNqJ1GYFGfdGAp0jvhihW8OMuXS3g==}
|
||||
dependencies:
|
||||
|
|
Loading…
Reference in a new issue