diff --git a/packages/datasource-customizer/package.json b/packages/datasource-customizer/package.json index fee87c1963..de6070728f 100644 --- a/packages/datasource-customizer/package.json +++ b/packages/datasource-customizer/package.json @@ -29,9 +29,15 @@ }, "dependencies": { "@forestadmin/datasource-toolkit": "1.7.0", + "ajv": "^8.12.0", + "ajv-errors": "^3.0.0", + "ajv-keywords": "^5.1.0", "file-type": "^16.5.4", "luxon": "^3.2.1", "object-hash": "^3.0.0", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "zod": "^3.21.4", + "zod-error": "^1.5.0", + "zod-validation-error": "^1.3.1" } } diff --git a/packages/datasource-customizer/src/collection-customizer.ts b/packages/datasource-customizer/src/collection-customizer.ts index c0d87ae3b0..f7a2b5fe86 100644 --- a/packages/datasource-customizer/src/collection-customizer.ts +++ b/packages/datasource-customizer/src/collection-customizer.ts @@ -18,10 +18,15 @@ import { RelationDefinition } from './decorators/relation/types'; import { SearchDefinition } from './decorators/search/types'; import { SegmentDefinition } from './decorators/segment/types'; import { WriteDefinition } from './decorators/write/write-replace/types'; +import { + ActionConfigurationValidationError, + CollectionCustomizationValidationError, +} from './errors'; import addExternalRelation from './plugins/add-external-relation'; import importField from './plugins/import-field'; import { TCollectionName, TColumnName, TFieldName, TSchema, TSortClause } from './templates'; import { OneToManyEmbeddedDefinition, Plugin } from './types'; +import ActionValidator from './validators/action'; export default class CollectionCustomizer< S extends TSchema = TSchema, @@ -124,6 +129,17 @@ export default class CollectionCustomizer< * }) */ addAction(name: string, definition: ActionDefinition): this { + try { + ActionValidator.validateActionConfiguration( + name, + definition as unknown as ActionDefinition, + ); + } catch (error) { + if (error instanceof ActionConfigurationValidationError) { + throw new CollectionCustomizationValidationError(this.name, error.message); + } + } + return this.pushCustomization(async () => { this.stack.action .getCollection(this.name) diff --git a/packages/datasource-customizer/src/decorators/actions/types/actions.ts b/packages/datasource-customizer/src/decorators/actions/types/actions.ts index 68ef4128c2..5e8ce95699 100644 --- a/packages/datasource-customizer/src/decorators/actions/types/actions.ts +++ b/packages/datasource-customizer/src/decorators/actions/types/actions.ts @@ -1,4 +1,6 @@ -import { ActionResult, ActionScope } from '@forestadmin/datasource-toolkit'; +import { ActionResult, ActionScope, ActionScopeEnum } from '@forestadmin/datasource-toolkit'; +import { ENUM } from 'sequelize'; +import { z } from 'zod'; import { DynamicField } from './fields'; import { TCollectionName, TSchema } from '../../../templates'; @@ -7,38 +9,138 @@ import ActionContextSingle from '../context/single'; import ResultBuilder from '../result-builder'; export { ActionContext, ActionContextSingle }; +const scope = [ + ...Object.values(ActionScopeEnum), + ...Object.values(ActionScopeEnum).map(scope => scope.toLowerCase()), +]; -export interface BaseAction< +// export const actionSchema = { +// type: 'object', +// properties: { +// generateFile: { type: 'boolean' }, +// scope: { +// type: 'string', +// enum: [ +// ...Object.values(ActionScopeEnum), +// ...Object.values(ActionScopeEnum).map(scope => scope.toLowerCase()), +// ], +// }, +// form: { type: 'array' }, // validated in fieldActionSchema +// execute: { typeof: 'function' }, +// }, +// required: ['scope', 'execute'], +// additionalProperties: false, +// }; + +function actionSchema< S extends TSchema, N extends TCollectionName, Scope extends ActionScope, Context extends ActionContext, -> { - generateFile?: boolean; - scope: Scope; - form?: DynamicField[]; - execute( - context: Context, - resultBuilder: ResultBuilder, - ): void | ActionResult | Promise | Promise; +>() { + const returnType = z.union([z.void(), z.any() as z.ZodSchema]); + + return z.object({ + generateFile: z.boolean().optional(), + scope: z.enum(['Bulk', 'Global', 'Single']), + form: (z.any() as z.ZodSchema>).array().optional(), + execute: z + .function() + .args(z.any() as z.ZodSchema, z.any() as z.ZodSchema) + .returns(z.union([returnType, returnType.promise()])), + }); } -export type ActionGlobal< - S extends TSchema = TSchema, - N extends TCollectionName = TCollectionName, -> = BaseAction>; +// function actionSchema( +// fieldValueType: ResultOrValueType, +// ) { +// return z.object({ +// generateFile: z.boolean().optional(), +// scope: z.enum([scope[0], ...scope.slice(1)]), +// form: z.object({}).array().optional(), +// execute: z.function(), +// }); +// } -export type ActionBulk< - S extends TSchema = TSchema, - N extends TCollectionName = TCollectionName, -> = BaseAction>; - -export type ActionSingle< - S extends TSchema = TSchema, - N extends TCollectionName = TCollectionName, -> = BaseAction>; +type t = ReturnType< + typeof actionSchema< + TSchema, + TCollectionName, + 'Global', + ActionContext> + > +>; export type ActionDefinition< S extends TSchema = TSchema, N extends TCollectionName = TCollectionName, -> = ActionSingle | ActionBulk | ActionGlobal; +> = z.infer; + +const action: ActionDefinition = { + scope: 'Bulk', + form: [{ type: 'Boolean', label: 'aaa' }], + execute: async (toto, baby) => { + console.log('aa'); + }, +}; + +const action2: DynamicField = { + type: 'Collection', +}; + +// export interface BaseAction< +// S extends TSchema, +// N extends TCollectionName, +// Scope extends ActionScope, +// Context extends ActionContext, +// > { +// generateFile?: boolean; +// scope: Scope; +// form?: DynamicField[]; +// execute( +// context: Context, +// resultBuilder: ResultBuilder, +// ): void | ActionResult | Promise | Promise; +// } + +// export type ActionGlobal< +// S extends TSchema = TSchema, +// N extends TCollectionName = TCollectionName, +// > = BaseAction>; +// type t = ReturnType< +// typeof actionSchema< +// TSchema, +// TCollectionName, +// 'Global', +// ActionContext> +// > +// >; +// export type ActionGlobal = z.infer; +// const ag: ActionGlobal = { +// scope: 'Toto', +// }; + +// const action: z.infer> = { +// scope: 'bulk', +// generateFile: true, +// form: [], +// execute: async (toto, baby) => { +// console.log('aa'); +// }, +// }; +// + +// export type ActionBulk< +// S extends TSchema = TSchema, +// N extends TCollectionName = TCollectionName, +// > = BaseAction>; +// +// export type ActionSingle< +// S extends TSchema = TSchema, +// N extends TCollectionName = TCollectionName, +// > = BaseAction>; +// +// export type ActionDefinition< +// S extends TSchema = TSchema, +// N extends TCollectionName = TCollectionName, +// > = ActionSingle | ActionBulk | ActionGlobal; diff --git a/packages/datasource-customizer/src/decorators/actions/types/fields.ts b/packages/datasource-customizer/src/decorators/actions/types/fields.ts index 43dda6cdec..8b610c9091 100644 --- a/packages/datasource-customizer/src/decorators/actions/types/fields.ts +++ b/packages/datasource-customizer/src/decorators/actions/types/fields.ts @@ -1,61 +1,160 @@ -import { CompositeId, Json } from '@forestadmin/datasource-toolkit'; +import { ActionFieldTypeEnum, CompositeId, Json } from '@forestadmin/datasource-toolkit'; +import { z } from 'zod'; -export type ValueOrHandler = - | ((context: Context) => Promise) - | ((context: Context) => Result) - | Promise - | Result; +// const typesEnumSchema = z.enum([ +// Object.values(ActionFieldTypeEnum)[0], +// ...Object.values(ActionFieldTypeEnum).slice(1), +// ]); -interface BaseDynamicField { - type: Type; - label: string; - description?: string; - isRequired?: ValueOrHandler; - isReadOnly?: ValueOrHandler; +function valueOrHandlerSchema< + HandlerArgType extends z.ZodTypeAny, + ResultOrValueType extends z.ZodTypeAny, +>(returnTypeSchema: ResultOrValueType) { + const valueOrPromiseSchema = returnTypeSchema.or(returnTypeSchema.promise()); - if?: ((context: Context) => Promise) | ((context: Context) => unknown); - value?: ValueOrHandler; - defaultValue?: ValueOrHandler; + return valueOrPromiseSchema.or( + z + .function() + .args(z.any() as unknown as HandlerArgType) + .returns(valueOrPromiseSchema), + ); } -interface CollectionDynamicField - extends BaseDynamicField<'Collection', Context, CompositeId> { - collectionName: ValueOrHandler; +function baseSchema( + fieldValueType: ResultOrValueType, +) { + return z.object({ + type: z.nativeEnum(ActionFieldTypeEnum), + label: z.string().nonempty(), + description: z.string().optional(), + isRequired: valueOrHandlerSchema(z.boolean()).optional(), + isReadOnly: valueOrHandlerSchema(z.boolean()).optional(), + if: z + .function() + .args(z.any() as unknown as HandlerArgType) + .returns(z.boolean()) + .or( + z + .function() + .args(z.any() as unknown as HandlerArgType) + .returns(z.boolean().promise()), + ) + .optional(), + value: valueOrHandlerSchema(fieldValueType).optional(), + defaultValue: valueOrHandlerSchema( + fieldValueType, + ).optional(), + }); } -interface EnumDynamicField extends BaseDynamicField<'Enum', Context, string> { - enumValues: ValueOrHandler; +function getSchemaCollection() { + return baseSchema(z.any() as unknown as R).extend({ + collectionName: valueOrHandlerSchema(z.string() as unknown as R), + type: z.literal('Collection'), + }); } -interface EnumListDynamicField extends BaseDynamicField<'EnumList', Context, string[]> { - enumValues: ValueOrHandler; +function getSchemaFile() { + return baseSchema(z.any() as unknown as R).extend({ + type: z.literal('File'), + }); } -type BooleanDynamicField = BaseDynamicField<'Boolean', Context, boolean>; -type FileDynamicField = BaseDynamicField<'File', Context, File>; -type FileListDynamicField = BaseDynamicField<'FileList', Context, File[]>; -type JsonDynamicField = BaseDynamicField<'Json', Context, Json>; -type NumberDynamicField = BaseDynamicField<'Number', Context, number>; +function getSchemaFileList() { + return baseSchema(z.any() as unknown as R).extend({ + type: z.literal('FileList'), + }); +} -type NumberListDynamicField = BaseDynamicField<'NumberList', Context, number[]>; +function getSchemaJson() { + return baseSchema(z.any() as unknown as R).extend({ + type: z.literal('Json'), + }); +} -type StringDynamicField = BaseDynamicField< - 'Date' | 'Dateonly' | 'String', - Context, - string ->; +function getSchemaBoolean() { + return baseSchema(z.boolean()).extend({ + type: z.literal('Boolean'), + }); +} + +function getSchemaNumber() { + return baseSchema(z.number() as R).extend({ + type: z.literal('Number'), + }); +} + +function getSchemaNumberList() { + return baseSchema(z.number().array()).extend({ + type: z.literal('NumberList'), + }); +} + +function getSchemaStringList() { + return baseSchema(z.string().array()).extend({ + type: z.literal('StringList'), + }); +} + +function getSchemaStringDateOrDateOnly() { + return baseSchema(z.string()).extend({ + type: z.enum(['String', 'Date', 'Dateonly']), + }); +} + +function getSchemaEnum() { + const valueOrPromiseSchema = z.string().array().or(z.string().array().promise()); + + return baseSchema(z.any() as unknown as R).extend({ + type: z.literal('Enum'), + enumValues: valueOrPromiseSchema.or( + z + .function() + .args(z.any() as unknown as C) + .returns(valueOrPromiseSchema), + ), + }); +} + +function getSchemaEnumList() { + const valueOrPromiseSchema = z.string().array().or(z.string().array().promise()); + + return baseSchema(z.string().array()).extend({ + type: z.literal('EnumList'), + enumValues: valueOrPromiseSchema.or( + z + .function() + .args(z.any() as unknown as C) + .returns(valueOrPromiseSchema), + ), + }); +} -type StringListDynamicField = BaseDynamicField<'StringList', Context, string[]>; +export type DynamicField = + | z.infer, z.ZodSchema>>> + | z.infer, z.ZodString>>> + | z.infer, z.ZodArray>>> + | z.infer>>> + | z.infer, z.ZodSchema>>> + | z.infer, z.ZodArray>>>> + | z.infer, z.ZodSchema>>> + | z.infer, z.ZodNumber>>> + | z.infer>>> + | z.infer>>> + | z.infer>>>; -export type DynamicField = - | BooleanDynamicField - | CollectionDynamicField - | EnumDynamicField - | EnumListDynamicField - | FileDynamicField - | FileListDynamicField - | JsonDynamicField - | NumberDynamicField - | NumberListDynamicField - | StringDynamicField - | StringListDynamicField; +export const fieldValidator: { [key in ActionFieldTypeEnum]: z.ZodSchema } = { + [ActionFieldTypeEnum.Collection]: getSchemaCollection(), + [ActionFieldTypeEnum.Boolean]: getSchemaBoolean(), + [ActionFieldTypeEnum.Enum]: getSchemaEnum(), + [ActionFieldTypeEnum.EnumList]: getSchemaEnumList(), + [ActionFieldTypeEnum.File]: getSchemaFile(), + [ActionFieldTypeEnum.FileList]: getSchemaFileList(), + [ActionFieldTypeEnum.Json]: getSchemaJson(), + [ActionFieldTypeEnum.Number]: getSchemaNumber(), + [ActionFieldTypeEnum.NumberList]: getSchemaNumberList(), + [ActionFieldTypeEnum.Date]: getSchemaStringDateOrDateOnly(), + [ActionFieldTypeEnum.Dateonly]: getSchemaStringDateOrDateOnly(), + [ActionFieldTypeEnum.String]: getSchemaStringDateOrDateOnly(), + [ActionFieldTypeEnum.StringList]: getSchemaStringList(), +}; diff --git a/packages/datasource-customizer/src/errors.ts b/packages/datasource-customizer/src/errors.ts new file mode 100644 index 0000000000..427066ecdf --- /dev/null +++ b/packages/datasource-customizer/src/errors.ts @@ -0,0 +1,18 @@ +/* eslint-disable max-classes-per-file */ +import { ValidationError } from '@forestadmin/datasource-toolkit'; + +export class CollectionCustomizationValidationError extends ValidationError { + constructor(name: string, errorMessage: string) { + super(`Error in collection '${name}' customization:\n${errorMessage}`); + } +} +export class ActionConfigurationValidationError extends ValidationError { + constructor(name: string, errorMessage: string) { + super(`Error in action '${name}' configuration:\n${errorMessage}`); + } +} +export class ActionFieldConfigurationValidationError extends ValidationError { + constructor(label: string, errorMessage: string) { + super(`Error in action form configuration, field '${label}': ${errorMessage}`); + } +} diff --git a/packages/datasource-customizer/src/validators/action.ts b/packages/datasource-customizer/src/validators/action.ts new file mode 100644 index 0000000000..a611b1feb5 --- /dev/null +++ b/packages/datasource-customizer/src/validators/action.ts @@ -0,0 +1,69 @@ +import { ValidationError } from '@forestadmin/datasource-toolkit'; +import Ajv from 'ajv'; // A library for validating JSON objects +import ajvErrors from 'ajv-errors'; +import ajvKeywords from 'ajv-keywords'; +import { z } from 'zod'; +import errorMap from 'zod/lib/locales/en'; +import { ErrorMessageOptions, generateError, generateErrorMessage } from 'zod-error'; +import { fromZodError } from 'zod-validation-error'; + +import { ActionDefinition, actionSchema } from '../decorators/actions/types/actions'; +import { DynamicField, fieldValidator } from '../decorators/actions/types/fields'; +import { + ActionConfigurationValidationError, + ActionFieldConfigurationValidationError, +} from '../errors'; + +const ajv = new Ajv({ allErrors: true }); +ajvErrors(ajv); // NOTICE: this library adds support for custom invalidity error messages. +// ex: errorMessage: 'should either be an array of string or a function' ; +ajvKeywords(ajv); // NOTICE: this library adds support for 'typeof' validation keyword, which allows +// to test if objects are functions (ex: {typeof: 'function'}) + +export default class ActionValidator { + static validateActionConfiguration(name: string, action: ActionDefinition) { + const validate = ajv.compile(actionSchema); + // if (!validate(action)) + // throw new ActionConfigurationValidationError( + // name, + // this.getValidationErrorMessage(validate.errors), + // ); + + try { + action.form?.forEach(field => { + this.validateActionFieldConfiguration(field as DynamicField); + }); + } catch (error) { + if (error instanceof ActionFieldConfigurationValidationError) { + throw new ActionConfigurationValidationError(name, error.message); + } + + throw error; + } + } + + private static validateActionFieldConfiguration(field: DynamicField) { + if (!field.type || !fieldValidator[field.type]) + throw new ActionFieldConfigurationValidationError( + field.label, + 'Invalid or missing action field type', + ); + + try { + return fieldValidator[field.type].parse(field); + } catch (error) { + throw new ActionFieldConfigurationValidationError(field.label, fromZodError(error).message); + } + } + + private static getValidationErrorMessage(errors) { + if (!errors || !errors.length) return ''; + + const error = errors[0]; + const params = ['additionalProperty', 'allowedValues'] + .map(key => (error.params[key] ? ` (${error.params[key]})` : '')) + .filter(Boolean); + + return `\n${error.instancePath ? `${error.instancePath} ` : ''}${error.message}:${params}`; + } +} diff --git a/packages/datasource-customizer/test/validators/action.test.ts b/packages/datasource-customizer/test/validators/action.test.ts new file mode 100644 index 0000000000..150a5d2c06 --- /dev/null +++ b/packages/datasource-customizer/test/validators/action.test.ts @@ -0,0 +1,440 @@ +/* eslint-disable max-len */ +import { ActionDefinition } from '../../src/decorators/actions/types/actions'; +import { DynamicField } from '../../src/decorators/actions/types/fields'; +import ActionValidator from '../../src/validators/action'; + +describe('ActionValidator', () => { + describe('validateActionConfiguration', () => { + describe('success cases', () => { + test('it should validate an action with a form', () => { + const action: ActionDefinition = { + scope: 'Single', + form: [ + { + label: 'PDF', + description: 'DJHBD', + type: 'File', + }, + { + label: 'amount', + description: 'The amount (USD) to charge the credit card. Example: 42.50', + type: 'Number', + }, + { + label: 'description', + description: 'Explain the reason why you want to charge manually the customer here', + isRequired: true, + type: 'String', + if: context => Number(context.formValues.Amount) > 4, + }, + { + label: 'stripe_id', + type: 'String', + if: () => false, + }, + ], + execute: async (context, resultBuilder) => { + return resultBuilder.success(`Well well done ${context.caller.email}!`); + }, + }; + expect(() => ActionValidator.validateActionConfiguration('TheName', action)).not.toThrow(); + }); + test('it should validate an action without a form', () => { + const action: ActionDefinition = { + scope: 'Single', + execute: async (context, resultBuilder) => { + return resultBuilder.success(`Well well done ${context.caller.email}!`); + }, + }; + expect(() => ActionValidator.validateActionConfiguration('TheName', action)).not.toThrow(); + }); + test('it should validate an action with an empty form', () => { + const action: ActionDefinition = { + scope: 'Bulk', + form: [], + execute: () => {}, + }; + expect(() => ActionValidator.validateActionConfiguration('TheName', action)).not.toThrow(); + }); + test('it should validate an action with correct fields types', () => { + const action: ActionDefinition = { + scope: 'Single', + form: [ + { + label: 'field1', + type: 'Enum', + enumValues: ['1', '2', '3'], + defaultValue: async () => '1', + }, + { + label: 'field1', + type: 'EnumList', + enumValues: ['1', '2', '3'], + defaultValue: async context => ['2', '3', context.toString()], + }, + { label: 'field2', type: 'NumberList', defaultValue: [12] }, + { label: 'field8', type: 'Number', defaultValue: async () => 12 }, + { label: 'field3', type: 'NumberList', value: () => [1, 2, 1000] }, + { + label: 'field34', + type: 'File', + defaultValue: { name: 'string', lastModified: 'aa' } as unknown as File, + }, + { + label: 'field15', + type: 'FileList', + defaultValue: () => { + return [{ name: 'string', lastModified: 'aa' } as unknown as File]; + }, + }, + { label: 'field26', type: 'Json', if: context => Boolean(context) }, + { label: 'field46', type: 'Date', description: 'The date' }, + { label: 'field66', type: 'Dateonly', description: 'the date only' }, + { label: 'field7', type: 'String', defaultValue: 'a' }, + { label: 'field4', type: 'StringList', defaultValue: ['1', '2'] }, + { + label: 'field5', + type: 'Boolean', + isReadOnly: true, + isRequired: () => false, + defaultValue: false, + }, + { label: 'field6', type: 'Collection', collectionName: () => ['Users'] }, + ], + execute: () => {}, + }; + expect(() => ActionValidator.validateActionConfiguration('TheName', action)).not.toThrow(); + }); + + test('it should validate an action with lower case scope', () => { + const action = { + scope: 'single', + execute: async (context, resultBuilder) => { + return resultBuilder.success(`Well well done !`); + }, + }; + expect(() => + ActionValidator.validateActionConfiguration( + 'TheName', + action as unknown as ActionDefinition, + ), + ).not.toThrow(); + }); + }); + + describe('documentation samples', () => { + test('it should validate a simple action example', () => { + // https://docs.forestadmin.com/developer-guide-agents-nodejs/agent-customization/actions#in-your-code + const action: ActionDefinition = { + scope: 'Single', + // eslint-disable-next-line @typescript-eslint/no-unused-vars + execute: async context => { + // Perform work here. + }, + }; + expect(() => + ActionValidator.validateActionConfiguration('Mark as live', action), + ).not.toThrow(); + }); + test('it should validate an action getting data from the context', () => { + // https://docs.forestadmin.com/developer-guide-agents-nodejs/agent-customization/actions/scope-context#example-1-getting-data-from-the-selected-records + const action: ActionDefinition = { + scope: 'Single', + execute: async context => { + // use getRecords() for bulk and global actions + const { firstName } = await context.getRecord(['firstName']); + + if (firstName === 'John') { + // eslint-disable-next-line no-console + console.log('Hi John!'); + } else { + console.error('You are not John!'); + } + }, + }; + expect(() => + ActionValidator.validateActionConfiguration('Mark as live', action), + ).not.toThrow(); + }); + test('it should validate an action with a static form field', () => { + // https://docs.forestadmin.com/developer-guide-agents-nodejs/agent-customization/actions/forms-static#field-configuration' + const action: ActionDefinition = { + scope: 'Single', + form: [ + { + label: 'amount', + description: 'The amount (USD) to charge the credit card. Example: 42.50', + type: 'Number', + isRequired: true, + }, + ], + execute: async (context, resultBuilder) => { + // Retrieve values entered in the form and columns from the selected record. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { amount } = context.formValues; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { stripeId, address } = await context.getRecord(['stripeId', 'address:country']); + + /* ... Charge the credit card here ... */ + return resultBuilder.success('Amount charged!'); + }, + }; + expect(() => + ActionValidator.validateActionConfiguration('Mark as live', action), + ).not.toThrow(); + }); + // eslint-disable-next-line max-len + test('it should validate an action with a static form field that gets data from the context to execute', () => { + // https://docs.forestadmin.com/developer-guide-agents-nodejs/agent-customization/actions/forms-static#references-to-records + const action: ActionDefinition = { + scope: 'Single', + form: [ + { + label: 'Assignee', + description: 'The user to assign the ticket to', + type: 'Collection', + collectionName: ['user'], + isRequired: true, + }, + ], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + execute: async (context, resultBuilder) => { + // Retrieve user id from the form + // (assuming the user collection has a single primary key) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [userId] = context.formValues.Assignee; + }, + }; + expect(() => + ActionValidator.validateActionConfiguration('Mark as live', action), + ).not.toThrow(); + }); + test('it should validate an action with a dynamic field', () => { + // https://docs.forestadmin.com/developer-guide-agents-nodejs/agent-customization/actions/forms-dynamic + const action: ActionDefinition = { + scope: 'Single', + form: [ + { + label: 'amount', + type: 'Number', + description: 'The amount (USD) to charge the credit card. Example: 42.50', + isRequired: true, + }, + { + label: 'description', + type: 'String', + description: 'Explain why you want to charge the customer manually', + + /** + * The field will only be required if the function returns true. + */ + isRequired: context => context.formValues.amount > 1000, + }, + ], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + execute: async (context, resultBuilder) => { + // ... + }, + }; + expect(() => + ActionValidator.validateActionConfiguration('Mark as live', action), + ).not.toThrow(); + }); + test('it should validate an with a dynamic field dependant on record data', () => { + // https://docs.forestadmin.com/developer-guide-agents-nodejs/agent-customization/actions/forms-dynamic#example-2-conditional-field-display-based-on-record-data + const action: ActionDefinition = { + scope: 'Single', + form: [ + { label: 'Rating', type: 'Enum', enumValues: ['1', '2', '3', '4', '5'] }, + + // Only display this field if the rating is 4 or 5 + { + label: 'Put a comment', + type: 'String', + if: context => Number(context.formValues.Rating) >= 4, + }, + ], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + execute: async context => { + /* ... perform work here ... */ + }, + }; + expect(() => + ActionValidator.validateActionConfiguration('Mark as live', action), + ).not.toThrow(); + }); + test('it should validate a complex dynamic action with dynamic enum', () => { + // https://docs.forestadmin.com/developer-guide-agents-nodejs/agent-customization/actions/forms-dynamic#example-3-conditional-enum-values-based-on-both-record-data-and-form-values + const action: ActionDefinition = { + scope: 'Single', + form: [ + { + label: 'How should we refer to you?', + type: 'Enum', + enumValues: async context => { + let gender; + // Enum values are computed based on the record data + // Use an async function to fetch the record data + const user = await context.getRecord(['firstName', 'lastName', 'gender']); + const base = [user.firstName, `${user.firstName} ${user.lastName}`]; + + if (gender === 'Female') { + return [...base, `Mrs. ${user.lastName}`, `Miss ${user.lastName}`]; + } + + return [...base, `Mr. ${user.lastName}`]; + }, + }, + { + label: 'How loud should we say it?', + type: 'Enum', + enumValues: context => { + // Enum values are computed based on another form field value + // (no need to use an async function, but doing so would not be a problem) + const denomination = context.formValues['How should we refer to you?']; + + return denomination === 'Morgan Freeman' + ? ['Whispering', 'Softly', 'Loudly'] + : ['Softly', 'Loudly', 'Very Loudly']; + }, + }, + ], + execute: async (context, resultBuilder) => { + const denomination = context.formValues['How should we refer to you?']; + const loudness = context.formValues['How loud should we say it?']; + + let text = `Hello ${denomination}`; + + if (loudness === 'Whispering') { + text = text.toLowerCase(); + } else if (loudness === 'Loudly') { + text = text.toUpperCase(); + } else if (loudness === 'Very Loudly') { + text = `${text.toUpperCase()}!!!`; + } + + return resultBuilder.success(text); + }, + }; + expect(() => + ActionValidator.validateActionConfiguration('Mark as live', action), + ).not.toThrow(); + }); + test('it should validate an action using changeField to reset a value', () => { + // https://docs.forestadmin.com/developer-guide-agents-nodejs/agent-customization/actions/forms-dynamic#example-4-using-changedfield-to-reset-value + const action: ActionDefinition = { + scope: 'Single', + form: [ + { + label: 'Bank Name', + type: 'Enum', + enumValues: ['CE', 'BP'], + isRequired: true, + }, + { + label: 'BIC', + type: 'String', + value: context => { + if (context.changedField === 'Bank Name') { + return context.formValues['Bank Name'] === 'CE' ? 'CEPAFRPPXXX' : 'CCBPFRPPXXX'; + } + + return 'CEPAFRPPXXX'; + }, + }, + ], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + execute: async (context, resultBuilder) => { + // ... + }, + }; + expect(() => + ActionValidator.validateActionConfiguration('Mark as live', action), + ).not.toThrow(); + }); + }); + + describe('error cases', () => { + test('it should reject an action with incorrect scope', () => { + const action = { + scope: 'Total', + execute: () => {}, + }; + expect(() => + ActionValidator.validateActionConfiguration( + 'TheName', + action as unknown as ActionDefinition, + ), + ).toThrow('scope must be equal to one of the allowed values: (Single,Bulk,Global'); + }); + test('it should reject an action with missing required scope property', () => { + const action = { + execute: () => {}, + }; + expect(() => + ActionValidator.validateActionConfiguration( + 'TheName', + action as unknown as ActionDefinition, + ), + ).toThrow(`must have required property 'scope'`); + }); + + describe('action field error cases', () => { + [ + { + test: 'it should display the field label in the error message', + field: { label: 'field1' } as unknown as DynamicField, + error: `Error in action form configuration, field 'field1'`, + }, + { + test: 'it should display the action name in the error message', + field: { label: 'field1' } as unknown as DynamicField, + error: `Error in action 'TheName' configuration:`, + }, + { + field: { label: 'field1' } as unknown as DynamicField, + error: `Invalid or missing action field type`, + }, + { + field: { label: 'field1', type: 'Boolean', isRequired: {} } as unknown as DynamicField, + error: `Validation error: Expected boolean, received object at "isRequired"`, + }, + { + field: { + label: 'field1', + type: 'Collection', + collectionName: {}, + } as unknown as DynamicField, + error: `Validation error: Expected string, received object at "collectionName"`, + }, + { + field: { label: 123, type: 'String' } as unknown as DynamicField, + error: `Validation error: Expected string, received number at "label"`, + }, + { + field: { label: 'field1', type: 'tartiflette' } as unknown as DynamicField, + error: `Invalid or missing action field type`, + }, + ].forEach(wrongField => { + test( + // eslint-disable-next-line jest/valid-title + wrongField.test + ? (wrongField.test as string) + : `it should reject with a helpful message if a field is incorrect: + ${JSON.stringify(wrongField.field)}`, + () => { + const action: ActionDefinition = { + scope: 'Single', + form: [wrongField.field, { label: 'field2', type: 'Number' }], + execute: async () => {}, + }; + expect(() => ActionValidator.validateActionConfiguration('TheName', action)).toThrow( + wrongField.error, + ); + }, + ); + }); + }); + }); + }); +}); diff --git a/packages/datasource-toolkit/src/interfaces/action.ts b/packages/datasource-toolkit/src/interfaces/action.ts index 3bfcca29fd..393065a390 100644 --- a/packages/datasource-toolkit/src/interfaces/action.ts +++ b/packages/datasource-toolkit/src/interfaces/action.ts @@ -21,20 +21,22 @@ export interface ActionField { collectionName?: string; // When type === 'Collection' } -export type ActionFieldType = - | 'Boolean' - | 'Collection' - | 'Date' - | 'Dateonly' - | 'Enum' - | 'File' - | 'Json' - | 'Number' - | 'String' - | 'EnumList' - | 'FileList' - | 'NumberList' - | 'StringList'; +export type ActionFieldType = `${ActionFieldTypeEnum}`; +export enum ActionFieldTypeEnum { + Boolean = 'Boolean', + Collection = 'Collection', + Enum = 'Enum', + EnumList = 'EnumList', + File = 'File', + FileList = 'FileList', + Json = 'Json', + Number = 'Number', + NumberList = 'NumberList', + Date = 'Date', + Dateonly = 'Dateonly', + String = 'String', + StringList = 'StringList', +} export type SuccessResult = { type: 'Success'; diff --git a/packages/datasource-toolkit/src/interfaces/schema.ts b/packages/datasource-toolkit/src/interfaces/schema.ts index d07dbf4378..7e147f0742 100644 --- a/packages/datasource-toolkit/src/interfaces/schema.ts +++ b/packages/datasource-toolkit/src/interfaces/schema.ts @@ -1,6 +1,12 @@ import { Operator } from './query/condition-tree/nodes/operators'; -export type ActionScope = 'Single' | 'Bulk' | 'Global'; +export type ActionScope = `${ActionScopeEnum}`; + +export enum ActionScopeEnum { + Single = 'Single', + Bulk = 'Bulk', + Global = 'Global', +} export type ActionSchema = { scope: ActionScope; diff --git a/yarn.lock b/yarn.lock index 28aeb76b89..5963cea924 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3676,6 +3676,11 @@ aggregate-error@^3.0.0: clean-stack "^2.0.0" indent-string "^4.0.0" +ajv-errors@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-3.0.0.tgz#e54f299f3a3d30fe144161e5f0d8d51196c527bc" + integrity sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ== + ajv-formats@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" @@ -3683,6 +3688,13 @@ ajv-formats@^2.1.1: dependencies: ajv "^8.0.0" +ajv-keywords@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-5.1.0.tgz#69d4d385a4733cdbeab44964a1170a88f87f0e16" + integrity sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw== + dependencies: + fast-deep-equal "^3.1.3" + ajv@^6.10.0, ajv@^6.10.2, ajv@^6.11.0, ajv@^6.12.0, ajv@^6.12.4, ajv@^6.12.6: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" @@ -3693,7 +3705,7 @@ ajv@^6.10.0, ajv@^6.10.2, ajv@^6.11.0, ajv@^6.12.0, ajv@^6.12.4, ajv@^6.12.6: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ajv@^8.0.0, ajv@^8.1.0, ajv@^8.10.0, ajv@^8.11.0: +ajv@^8.0.0, ajv@^8.1.0, ajv@^8.10.0, ajv@^8.11.0, ajv@^8.12.0: version "8.12.0" resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.12.0.tgz#d1a0527323e22f53562c567c00991577dfbe19d1" integrity sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==