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:
+
+ - separator
+ - price
+ - max withdraw / max payment
+
`;
+ case 'Gold':
+ return `Should setup:
+
+ - max payment / Systematic check (if max payment > 1000)
+ - discount / discount months
+
`;
+ 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 =