fix(editor): Use display option's @Version specifier (#7351)

Nodes can have properties that have a displayOption which specifies a
version
for which node versions that property applies to. We should take this
into
account when forming the action types for a Node in the NodeList.

For example Notion node has 2 version which have different Page
operations.
This commit is contained in:
Tomi Turtiainen 2023-10-05 15:57:47 +03:00 committed by GitHub
parent aa1bf95136
commit afbf0c3d5e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 413 additions and 6 deletions

View file

@ -0,0 +1,390 @@
import type { INodeProperties, INodeTypeDescription } from 'n8n-workflow';
import { useActionsGenerator } from '../composables/useActionsGeneration';
describe('useActionsGenerator', () => {
const { generateMergedNodesAndActions } = useActionsGenerator();
const NODE_NAME = 'n8n-nodes-base.test';
const baseV2NodeWoProps: INodeTypeDescription = {
name: NODE_NAME,
displayName: 'Test',
description: 'Test Node',
defaultVersion: 2,
version: 2,
group: ['output'],
defaults: {
name: 'Test',
},
inputs: ['main'],
outputs: ['main'],
properties: [],
};
describe('App actions for resource category', () => {
const resourcePropertyWithUser: INodeProperties = {
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'User',
value: 'user',
},
],
default: 'user',
};
const resourcePropertyWithUserAndPage: INodeProperties = {
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'User',
value: 'user',
},
{
name: 'Page',
value: 'page',
},
],
default: 'user',
};
it('returns single action for single resource & single operation without resource filter', () => {
const node: INodeTypeDescription = {
...baseV2NodeWoProps,
properties: [
resourcePropertyWithUser,
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {},
options: [
{
name: 'Get',
value: 'get',
description: 'Get description',
},
],
default: 'get',
},
],
};
const { actions } = generateMergedNodesAndActions([node]);
expect(actions).toEqual({
[NODE_NAME]: [
expect.objectContaining({
actionKey: 'get',
description: 'Get description',
displayName: 'User Get',
codex: {
label: 'User Actions',
categories: ['Actions'],
},
}),
],
});
});
it('returns single action for single resource & single operation with matching resource filter', () => {
const node: INodeTypeDescription = {
...baseV2NodeWoProps,
properties: [
resourcePropertyWithUser,
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['user'],
},
},
options: [
{
name: 'Get',
value: 'get',
description: 'Get description',
},
],
default: 'get',
},
],
};
const { actions } = generateMergedNodesAndActions([node]);
expect(actions).toEqual({
[NODE_NAME]: [
expect.objectContaining({
actionKey: 'get',
description: 'Get description',
displayName: 'User Get',
codex: {
label: 'User Actions',
categories: ['Actions'],
},
}),
],
});
});
it('returns nothing for multiple resources & single operation without resource filter', () => {
const node: INodeTypeDescription = {
...baseV2NodeWoProps,
properties: [
resourcePropertyWithUserAndPage,
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {},
options: [
{
name: 'Get',
value: 'get',
description: 'Get description',
},
],
default: 'get',
},
],
};
const { actions } = generateMergedNodesAndActions([node]);
expect(actions).toEqual({
[NODE_NAME]: [],
});
});
it('returns single action for multiple resources & single operation with resource filter', () => {
const node: INodeTypeDescription = {
...baseV2NodeWoProps,
properties: [
resourcePropertyWithUserAndPage,
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['user'],
},
},
options: [
{
name: 'Get',
value: 'get',
description: 'Get description',
},
],
default: 'get',
},
],
};
const { actions } = generateMergedNodesAndActions([node]);
expect(actions).toEqual({
[NODE_NAME]: [
expect.objectContaining({
actionKey: 'get',
description: 'Get description',
displayName: 'User Get',
codex: {
label: 'User Actions',
categories: ['Actions'],
},
}),
],
});
});
it('returns multiple actions for multiple resources & multiple operations with resource filters', () => {
const node: INodeTypeDescription = {
...baseV2NodeWoProps,
properties: [
resourcePropertyWithUserAndPage,
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['user'],
},
},
options: [
{
name: 'Get',
value: 'get',
description: 'Get description',
},
],
default: 'get',
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['page'],
},
},
options: [
{
name: 'Get',
value: 'get',
description: 'Get description',
},
],
default: 'get',
},
],
};
const { actions } = generateMergedNodesAndActions([node]);
expect(actions).toEqual({
[NODE_NAME]: [
expect.objectContaining({
actionKey: 'get',
description: 'Get description',
displayName: 'User Get',
codex: {
label: 'User Actions',
categories: ['Actions'],
},
}),
expect.objectContaining({
actionKey: 'get',
description: 'Get description',
displayName: 'Page Get',
codex: {
label: 'Page Actions',
categories: ['Actions'],
},
}),
],
});
});
it('returns correct action for single resource & multiple operations with different versions', () => {
const node: INodeTypeDescription = {
...baseV2NodeWoProps,
properties: [
resourcePropertyWithUser,
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'@version': [1],
resource: ['user'],
},
},
options: [
{
name: 'Get Version 1',
value: 'getv1',
description: 'Get version 1',
},
],
default: 'getv1',
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'@version': [2],
resource: ['user'],
},
},
options: [
{
name: 'Get Version 2',
value: 'getv2',
description: 'Get version 2',
},
],
default: 'getv2',
},
],
};
const { actions } = generateMergedNodesAndActions([node]);
expect(actions).toEqual({
[NODE_NAME]: [
expect.objectContaining({
actionKey: 'getv2',
description: 'Get version 2',
displayName: 'User Get Version 2',
codex: {
label: 'User Actions',
categories: ['Actions'],
},
}),
],
});
});
it('returns correct action for single resource & single operation with multiple versions', () => {
const node: INodeTypeDescription = {
...baseV2NodeWoProps,
properties: [
resourcePropertyWithUser,
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'@version': [1, 2],
resource: ['user'],
},
},
options: [
{
name: 'Get Version 2',
value: 'getv2',
description: 'Get version 2',
},
],
default: 'getv2',
},
],
};
const { actions } = generateMergedNodesAndActions([node]);
expect(actions).toEqual({
[NODE_NAME]: [
expect.objectContaining({
actionKey: 'getv2',
description: 'Get version 2',
displayName: 'User Get Version 2',
codex: {
label: 'User Actions',
categories: ['Actions'],
},
}),
],
});
});
});
});

View file

@ -15,6 +15,7 @@ const PLACEHOLDER_RECOMMENDED_ACTION_KEY = 'placeholder_recommended';
function translate(...args: Parameters<typeof i18n.baseText>) {
return i18n.baseText(...args);
}
// Memoize the translation function so we don't have to re-translate the same string
// multiple times when generating the actions
const cachedBaseText = memoize(translate, (...args) => JSON.stringify(args));
@ -159,12 +160,26 @@ function resourceCategories(nodeTypeDescription: INodeTypeDescription): ActionTy
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),
);
const operations = nodeTypeDescription.properties.find((operation) => {
const isOperation = operation.name === 'operation';
const isMatchingResource =
operation.displayOptions?.show?.resource?.includes(resourceOption.value) ||
isSingleResource;
// If the operation doesn't have a version defined, it should be
// available for all versions. Otherwise, make sure the node type
// version matches the operation version
const operationVersions = operation.displayOptions?.show?.['@version'];
const nodeTypeVersions = Array.isArray(nodeTypeDescription.version)
? nodeTypeDescription.version
: [nodeTypeDescription.version];
const isMatchingVersion = operationVersions
? operationVersions.some((version) => nodeTypeVersions.includes(version))
: true;
return isOperation && isMatchingResource && isMatchingVersion;
});
if (!operations?.options) return;

View file

@ -1116,6 +1116,8 @@ export interface IDisplayOptions {
[key: string]: NodeParameterValue[] | undefined;
};
show?: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'@version'?: number[];
[key: string]: NodeParameterValue[] | undefined;
};