diff --git a/packages/_example/src/forest/customizations/card.ts b/packages/_example/src/forest/customizations/card.ts index e45432a8c9..fac56f115e 100644 --- a/packages/_example/src/forest/customizations/card.ts +++ b/packages/_example/src/forest/customizations/card.ts @@ -1,4 +1,133 @@ import { CardCustomizer } from '../typings'; export default (collection: CardCustomizer) => - collection.addManyToOneRelation('customer', 'customer', { foreignKey: 'customer_id' }); + collection + .addManyToOneRelation('customer', 'customer', { foreignKey: 'customer_id' }) + .addAction('create new card', { + scope: 'Global', + execute: (context, resultBuilder) => { + return resultBuilder.success('ok', { + html: `test`, + }); + }, + form: [ + { + type: 'Number', + label: 'Customer', + widget: 'Dropdown', + search: 'dynamic', + options: async (ctx, searchValue) => { + const results = await ctx.dataSource.getCollection('customer').list( + { + ...(searchValue + ? { + conditionTree: { + aggregator: 'Or', + conditions: [ + { field: 'name', operator: 'Contains', value: searchValue }, + { field: 'firstName', operator: 'Contains', value: searchValue }, + ], + }, + } + : {}), + }, + ['id'], + ); + + return results.map(({ id }) => id); + }, + }, + { + type: 'String', + label: 'Plan', + widget: 'Dropdown', + options: ['Base', 'Gold', 'Black'], + }, + { + type: 'Layout', + component: 'HtmlBlock', + content: ctx => { + switch (ctx.formValues.Plan) { + case 'Base': + return `

Should setup:

+ `; + case 'Gold': + return `

Should setup:

+ `; + case 'Back': + return `

Should setup:

+ `; + default: + return `Select a card plan`; + } + }, + }, + { + type: 'Layout', + component: 'Separator', + if: ctx => ['Base'].includes(ctx.formValues.Plan), + }, + { + type: 'Number', + label: 'price', + defaultValue: 40, + if: ctx => ['Base'].includes(ctx.formValues.Plan), + }, + { + type: 'Number', + label: 'price', + defaultValue: 80, + if: ctx => ['Gold'].includes(ctx.formValues.Plan), + }, + { type: 'Layout', component: 'Separator' }, + { type: 'Layout', component: 'HtmlBlock', content: '

constraints:

' }, + { + type: 'Layout', + component: 'Row', + fields: [ + { + type: 'Number', + label: 'Max withdraw', + if: ctx => ['Base', 'Black'].includes(ctx.formValues.Plan), + }, + { + type: 'Number', + label: 'Max payment', + if: ctx => ['Base', 'Gold'].includes(ctx.formValues.Plan), + }, + { + type: 'Boolean', + label: 'Systematic check', + if: ctx => + ['Base', 'Gold'].includes(ctx.formValues.Plan) && + ctx.formValues['Max payment'] > 1000, + }, + ], + }, + { + type: 'Layout', + component: 'Row', + fields: [ + { + type: 'Number', + label: 'Discount', + if: ctx => ['Gold'].includes(ctx.formValues['display fields']), + }, + { + type: 'Number', + label: 'Discount months', + if: ctx => ['Gold'].includes(ctx.formValues['display fields']), + }, + ], + }, + ], + }); diff --git a/packages/agent/src/utils/forest-schema/generator-actions.ts b/packages/agent/src/utils/forest-schema/generator-actions.ts index c3468d2114..b9b206b883 100644 --- a/packages/agent/src/utils/forest-schema/generator-actions.ts +++ b/packages/agent/src/utils/forest-schema/generator-actions.ts @@ -5,12 +5,14 @@ import { Collection, ColumnSchema, DataSource, + LayoutElementInput, PrimitiveTypes, SchemaUtils, } from '@forestadmin/datasource-toolkit'; import { ForestServerAction, ForestServerActionField, + ForestServerActionFormElementFieldReference, ForestServerActionFormLayoutElement, } from '@forestadmin/forestadmin-client'; import path from 'path'; @@ -126,7 +128,14 @@ export default class SchemaGeneratorActions { return output as ForestServerActionField; } - static buildLayoutSchema(element: ActionLayoutElement): ForestServerActionFormLayoutElement { + static buildLayoutSchema( + element: ActionLayoutElement, + options?: { forceInput?: boolean }, + ): ForestServerActionFormLayoutElement { + if (options?.forceInput) { + element.component = 'Input'; + } + switch (element.component) { case 'Input': return { @@ -138,6 +147,16 @@ export default class SchemaGeneratorActions { component: 'htmlBlock', content: element.content, }; + case 'Row': + return { + component: 'row', + fields: element.fields.map( + field => + SchemaGeneratorActions.buildLayoutSchema(field, { + forceInput: true, + }) as ForestServerActionFormElementFieldReference, + ), + }; case 'Separator': default: return { @@ -159,16 +178,9 @@ export default class SchemaGeneratorActions { formElements.forEach(element => { if (element.type === 'Layout') { hasLayout = true; - - layout.push(element); - } else { - fields.push(element); - layout.push({ - type: 'Layout', - component: 'Input', - fieldId: element.label, - }); } + + layout.push(SchemaGeneratorActions.parseLayout(element, fields)); }); if (!hasLayout) { @@ -177,4 +189,33 @@ export default class SchemaGeneratorActions { return { fields, layout }; } + + private static parseLayout( + element: ActionFormElement, + allFields: ActionField[], + ): ActionLayoutElement { + if (element.type === 'Layout') { + if (element.component === 'Row') { + const fields = element.fields.map( + field => SchemaGeneratorActions.parseLayout(field, allFields) as LayoutElementInput, + ); + + return { + type: 'Layout', + component: 'Row', + fields, + }; + } + + return element; + } + + allFields.push(element); + + return { + type: 'Layout', + component: 'Input', + fieldId: element.label, + }; + } } diff --git a/packages/agent/test/utils/forest-schema/generator-actions.test.ts b/packages/agent/test/utils/forest-schema/generator-actions.test.ts index fd16731e12..5ba26a097f 100644 --- a/packages/agent/test/utils/forest-schema/generator-actions.test.ts +++ b/packages/agent/test/utils/forest-schema/generator-actions.test.ts @@ -133,15 +133,6 @@ describe('SchemaGeneratorActions', () => { value: null, watchChanges: false, }, - { - type: 'Layout', - component: 'Separator', - }, - { - type: 'Layout', - component: 'HtmlBlock', - content: 'some text content', - }, { label: 'inclusive gender', description: 'Choose None, Male, Female or Both', @@ -313,9 +304,20 @@ describe('SchemaGeneratorActions', () => { content: 'some text content', }, { - label: 'description', - type: 'String', - watchChanges: false, + type: 'Layout', + component: 'Row', + fields: [ + { + label: 'description', + type: 'String', + watchChanges: false, + }, + { + label: 'address', + type: 'String', + watchChanges: false, + }, + ], }, ], ), @@ -327,7 +329,7 @@ describe('SchemaGeneratorActions', () => { const schema = SchemaGeneratorActions.buildFieldsAndLayout(collection.dataSource, form); - expect(schema.fields.length).toEqual(2); + expect(schema.fields.length).toEqual(3); expect(schema.layout).toEqual([ { component: 'input', @@ -336,8 +338,17 @@ describe('SchemaGeneratorActions', () => { { component: 'separator' }, { component: 'htmlBlock', content: 'some text content' }, { - component: 'input', - fieldId: 'description', + component: 'row', + fields: [ + { + component: 'input', + fieldId: 'description', + }, + { + component: 'input', + fieldId: 'address', + }, + ], }, ]); }); diff --git a/packages/datasource-customizer/src/decorators/actions/collection.ts b/packages/datasource-customizer/src/decorators/actions/collection.ts index 27011ba880..d84f9172a3 100644 --- a/packages/datasource-customizer/src/decorators/actions/collection.ts +++ b/packages/datasource-customizer/src/decorators/actions/collection.ts @@ -1,5 +1,4 @@ import { - ActionField, ActionFormElement, ActionResult, Caller, @@ -78,13 +77,14 @@ export default class ActionCollectionDecorator extends CollectionDecorator { ? await (action.form as (context: ActionContext) => DynamicFormElement[])( context, ) - : action.form.map(c => ({ ...c })); + : // copy fields to keep original object unchanged + await this.copyFields(action.form); if (metas?.searchField) { // in the case of a search hook, // we don't want to rebuild all the fields. only the one searched dynamicFields = [ - dynamicFields.find(field => field.type !== 'Layout' && field.label === metas.searchField), + dynamicFields.find(field => field.type === 'Layout' || field.label === metas.searchField), ]; } @@ -93,15 +93,7 @@ export default class ActionCollectionDecorator extends CollectionDecorator { const fields = await this.dropDeferred(context, metas?.searchValues, dynamicFields); - for (const field of fields) { - if (field.type !== 'Layout') { - // customer did not define a handler to rewrite the previous value => reuse current one. - if (field.value === undefined) field.value = formValues[field.label]; - - // fields that were accessed through the context.formValues.X getter should be watched. - field.watchChanges = used.has(field.label); - } - } + this.setWatchChangesOnFields(formValues, used, fields); return fields; } @@ -143,15 +135,50 @@ export default class ActionCollectionDecorator extends CollectionDecorator { }[action.scope](this, caller, formValues, filter as unknown as PlainFilter, used, changedField); } + private getSubElementsAttributeKey( + field: T, + ): string | null { + if (field.type === 'Layout' && field.component === 'Row') return 'fields'; + + return null; + } + + private async executeOnSubFields< + T extends DynamicFormElement | ActionFormElement, + U extends DynamicFormElement | ActionFormElement, + >(field: U, handler: (subFields: U[]) => T[] | Promise) { + const subElementsKey = this.getSubElementsAttributeKey(field); + if (!subElementsKey) return; + field[subElementsKey] = await handler(field[subElementsKey] || []); + } + + private async copyFields(fields: DynamicFormElement[]) { + return Promise.all( + fields.map(async field => { + const fieldCopy: DynamicFormElement = { ...field }; + + await this.executeOnSubFields(field, subFields => this.copyFields(subFields)); + + return fieldCopy; + }), + ); + } + private async dropDefaults( context: ActionContext, fields: DynamicFormElement[], data: Record, ): Promise { const promises = fields.map(async field => { - if (field.type === 'Layout') return field; + if (field.type !== 'Layout') { + return this.dropDefault(context, field, data); + } + + await this.executeOnSubFields(field, subfields => + this.dropDefaults(context, subfields, data), + ); - return this.dropDefault(context, field, data); + return field; }); return Promise.all(promises); @@ -178,8 +205,27 @@ export default class ActionCollectionDecorator extends CollectionDecorator { ): Promise { // Remove fields which have falsy if const ifValues = await Promise.all( - fields.map(field => !field.if || this.evaluate(context, null, field.if)), + fields.map(async field => { + if ((await this.evaluate(context, null, field.if)) === false) { + // drop element if condition returns false + return false; + } + + const subElementsKey = this.getSubElementsAttributeKey(field); + + if (subElementsKey) { + field[subElementsKey] = await this.dropIfs(context, field[subElementsKey] || []); + + // drop element if no subElement + if (field[subElementsKey].length === 0) { + return false; + } + } + + return true; + }), ); + const newFields = fields.filter((_, index) => ifValues[index]); newFields.forEach(field => delete field.if); @@ -192,6 +238,10 @@ export default class ActionCollectionDecorator extends CollectionDecorator { fields: DynamicFormElement[], ): Promise { const newFields = fields.map(async (field): Promise => { + await this.executeOnSubFields(field, subfields => + this.dropDeferred(context, searchValues, subfields), + ); + const keys = Object.keys(field); const values = await Promise.all( Object.values(field).map(value => { @@ -201,15 +251,55 @@ export default class ActionCollectionDecorator extends CollectionDecorator { }), ); - return keys.reduce( + return keys.reduce( (memo, key, index) => ({ ...memo, [key]: values[index] }), - {} as ActionField, + {} as ActionFormElement, ); }); return Promise.all(newFields); } + private async setWatchChangesOnFields( + formValues: { + [x: string]: unknown; + }, + used: Set, + fields: ActionFormElement[], + ) { + return Promise.all( + fields.map(async field => { + if (field.type !== 'Layout') { + return this.setWatchChangesOnField(formValues, used, field); + } + + await this.executeOnSubFields(field, subfields => + this.setWatchChangesOnFields(formValues, used, subfields), + ); + + return field; + }), + ); + } + + private setWatchChangesOnField( + formValues: { + [x: string]: unknown; + }, + used: Set, + field: ActionFormElement, + ) { + if (field.type !== 'Layout') { + // customer did not define a handler to rewrite the previous value => reuse current one. + if (field.value === undefined) field.value = formValues[field.label]; + + // fields that were accessed through the context.formValues.X getter should be watched. + field.watchChanges = used.has(field.label); + } + + return field; + } + private async evaluate( context: ActionContext, searchValue: string | null, diff --git a/packages/datasource-customizer/src/decorators/actions/types/fields.ts b/packages/datasource-customizer/src/decorators/actions/types/fields.ts index b0f85d08f8..682dd7044f 100644 --- a/packages/datasource-customizer/src/decorators/actions/types/fields.ts +++ b/packages/datasource-customizer/src/decorators/actions/types/fields.ts @@ -255,8 +255,14 @@ type DynamicLayoutElementHtmlBlock = DynamicLayoutElementBase content: ValueOrHandler; }; +type DynamicLayoutElementRow = DynamicLayoutElementBase & { + component: 'Row'; + fields: DynamicField[]; +}; + export type DynamicLayoutElement = | DynamicLayoutElementSeparator + | DynamicLayoutElementRow | DynamicLayoutElementHtmlBlock; export type DynamicFormElement = diff --git a/packages/datasource-customizer/test/decorators/actions/collection.test.ts b/packages/datasource-customizer/test/decorators/actions/collection.test.ts index a0dcff658d..de430fc2ef 100644 --- a/packages/datasource-customizer/test/decorators/actions/collection.test.ts +++ b/packages/datasource-customizer/test/decorators/actions/collection.test.ts @@ -684,4 +684,95 @@ describe('ActionDecorator', () => { ]); }); }); + + describe('with a form with layout elements', () => { + test('should be flagged as dynamic form', () => { + newBooks.addAction('make photocopy', { + scope: 'Single', + execute: (context, resultBuilder) => { + return resultBuilder.error('meeh'); + }, + form: [ + { label: 'firstname', type: 'String' }, + { type: 'Layout', component: 'Separator' }, + { label: 'lastname', type: 'String' }, + ], + }); + + expect(newBooks.schema.actions['make photocopy']).toEqual({ + scope: 'Single', + generateFile: false, + staticForm: false, + }); + }); + + test('should compute the form recursively', async () => { + newBooks.addAction('make photocopy', { + scope: 'Single', + execute: (context, resultBuilder) => { + return resultBuilder.error('meeh'); + }, + form: [ + { + type: 'Layout', + component: 'Row', + fields: [ + { label: 'firstname', type: 'String' }, + { label: 'lastname', type: 'String' }, + ], + }, + { type: 'Layout', component: 'Separator' }, + { label: 'age', type: 'Number' }, + { + type: 'Layout', + component: 'Row', + fields: [ + { label: 'tel', type: 'Number', if: ctx => ctx.formValues.age > 18 }, + { + label: 'email', + type: 'String', + defaultValue: ctx => `${ctx.formValues.firstname}.${ctx.formValues.lastname}@`, + }, + ], + }, + ], + }); + + expect(await newBooks.getForm(null, 'make photocopy', null, null)).toEqual([ + { + component: 'Row', + fields: [ + { label: 'firstname', type: 'String', value: undefined, watchChanges: true }, + { label: 'lastname', type: 'String', value: undefined, watchChanges: true }, + ], + type: 'Layout', + }, + { component: 'Separator', type: 'Layout' }, + { label: 'age', type: 'Number', value: undefined, watchChanges: true }, + { + component: 'Row', + fields: [ + { label: 'email', type: 'String', value: 'undefined.undefined@', watchChanges: false }, + ], + type: 'Layout', + }, + ]); + + expect( + await newBooks.getForm(null, 'make photocopy', { age: 25, lastname: 'smith' }, null), + ).toEqual( + expect.arrayContaining([ + { label: 'age', type: 'Number', value: 25, watchChanges: true }, + { + component: 'Row', + fields: [ + { label: 'tel', type: 'Number', value: undefined, watchChanges: false }, + { label: 'email', type: 'String', value: 'undefined.smith@', watchChanges: false }, + ], + type: 'Layout', + }, + ]), + ); + }); + }); }); diff --git a/packages/datasource-toolkit/src/interfaces/action.ts b/packages/datasource-toolkit/src/interfaces/action.ts index 1f181b709f..40dc65161c 100644 --- a/packages/datasource-toolkit/src/interfaces/action.ts +++ b/packages/datasource-toolkit/src/interfaces/action.ts @@ -20,7 +20,7 @@ export type File = { }; export type ActionFormElementBase = { - type: ActionFieldType | LayoutElementType; + type: ActionFieldType | 'Layout'; }; export interface ActionFieldBase extends ActionFormElementBase { @@ -50,8 +50,6 @@ type ActionFieldType = | 'NumberList' | 'StringList'; -export type LayoutElementType = 'Layout'; - interface ActionFieldLimitedValue< TWidget extends ActionFieldWidget, TType extends ActionFieldType = ActionFieldType, @@ -267,7 +265,17 @@ interface LayoutElementHtmlBlock extends ActionLayoutElementBase { content: string; } -interface LayoutElementInput extends ActionLayoutElementBase { +interface LayoutElementRow extends ActionLayoutElementBase { + component: 'Row'; + fields: LayoutElementInput[]; +} + +interface LayoutElementRowRecursive extends ActionLayoutElementBase { + component: 'Row'; + fields: ActionField[]; +} + +export interface LayoutElementInput extends ActionLayoutElementBase { component: 'Input'; fieldId: string; } @@ -275,9 +283,16 @@ interface LayoutElementInput extends ActionLayoutElementBase { export type ActionLayoutElement = | LayoutElementSeparator | LayoutElementHtmlBlock + | LayoutElementRow + | LayoutElementInput; + +export type ActionLayoutElementRecursive = + | LayoutElementSeparator + | LayoutElementHtmlBlock + | LayoutElementRowRecursive | LayoutElementInput; -export type ActionFormElement = ActionLayoutElement | ActionField; +export type ActionFormElement = ActionLayoutElementRecursive | ActionField; export type ActionForm = { fields: ActionField[]; layout: ActionLayoutElement[] }; diff --git a/packages/forestadmin-client/src/schema/types.ts b/packages/forestadmin-client/src/schema/types.ts index 157ea48143..664d308ccf 100644 --- a/packages/forestadmin-client/src/schema/types.ts +++ b/packages/forestadmin-client/src/schema/types.ts @@ -263,7 +263,12 @@ type ForestServerActionFormElementHtmlBlock = { content: string; }; -type ForestServerActionFormElementFieldReference = { +type ForestServerActionFormElementRow = { + component: 'row'; + fields: ForestServerActionFormElementFieldReference[]; +}; + +export type ForestServerActionFormElementFieldReference = { component: 'input'; fieldId: string; }; @@ -271,6 +276,7 @@ type ForestServerActionFormElementFieldReference = { export type ForestServerActionFormLayoutElement = | ForestServerActionFormElementSeparator | ForestServerActionFormElementHtmlBlock + | ForestServerActionFormElementRow | ForestServerActionFormElementFieldReference; export type ForestServerActionField =