From b628f495f6a34a9be834f3c258442882f78e496e Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Thu, 12 Sep 2024 10:42:07 +0200 Subject: [PATCH 01/18] feat: add separator in action forms --- .../src/forest/customizations/card.ts | 156 +++++++++++++++++- .../src/routes/modification/action/action.ts | 9 +- .../utils/forest-schema/generator-actions.ts | 93 ++++++++--- .../context/relaxed-wrappers/collection.ts | 8 +- .../src/decorators/actions/collection.ts | 81 ++++++--- .../src/decorators/actions/types/actions.ts | 8 +- .../src/decorators/actions/types/fields.ts | 10 +- .../src/decorators/collection-decorator.ts | 4 +- .../src/interfaces/action.ts | 142 ++++++++++------ .../src/interfaces/collection.ts | 4 +- .../forestadmin-client/src/schema/types.ts | 14 ++ 11 files changed, 415 insertions(+), 114 deletions(-) diff --git a/packages/_example/src/forest/customizations/card.ts b/packages/_example/src/forest/customizations/card.ts index e45432a8c9..4724266437 100644 --- a/packages/_example/src/forest/customizations/card.ts +++ b/packages/_example/src/forest/customizations/card.ts @@ -1,4 +1,158 @@ import { CardCustomizer } from '../typings'; export default (collection: CardCustomizer) => - collection.addManyToOneRelation('customer', 'customer', { foreignKey: 'customer_id' }); + collection + .addManyToOneRelation('customer', 'customer', { foreignKey: 'customer_id' }) + .addAction('action with form', { + scope: 'Bulk', + execute: (context, resultBuilder) => { + resultBuilder.success('ok'); + }, + form: [ + // { + // type: 'Layout', + // component: 'Page', + // nextButtonLabel: '==>', + // previousButtonLabel: '<==', + // elements: [ + { + type: 'StringList', + label: 'first_name', + widget: 'Dropdown', + search: 'dynamic', + options: (ctx, searchValue) => ['first Option', 'second option', 'third option', 'test'], + }, + { + type: 'Layout', + component: 'Separator', + if: ctx => ctx.formValues.first_name === 'test', + }, + // { + // type: 'Layout', + // component: 'Row', + // fields: [ + { + type: 'Enum', + label: 'Gender', + enumValues: ['M', 'F', 'other'], + if: ctx => + ctx.formValues.first_name?.[0] === 'test' && ctx.formValues.Gender_other === 'test', + }, + { + type: 'String', + label: 'Gender_other', + }, + // ], + // }, + // ], + // }, + // { + // type: 'Layout', + // component: 'Page', + // // "if_": lambda ctx: ctx.form_values.get("Number of children") != 0, + // elements: [ + { type: 'Number', label: 'Number of children' }, + // { + // type: 'Layout', + // component: 'Row', + // fields: [ + { type: 'Number', label: 'Age of older child' }, + { type: 'Number', label: 'Age of younger child' }, + // ], + // }, + { type: 'Boolean', label: 'Are they wise' }, + // ], + // nextButtonLabel: '==>', + // previousButtonLabel: '<==', + // }, + // { + // type: 'Layout', + // component: 'Page', + // // "if_": lambda ctx: ctx.form_values.get("Are they wise") is False, + // elements: [ + // { + // type: 'Layout', + // component: 'Row', + // fields: [ + { type: 'StringList', label: 'Why_its_your_fault' }, + { type: 'String', label: 'Why_its_their_fault', widget: 'TextArea' }, + // ], + // }, + // ], + // nextButtonLabel: '==>', + // previousButtonLabel: '<==', + // }, + ], + }) + .addAction('static action with form', { + scope: 'Bulk', + execute: (context, resultBuilder) => { + resultBuilder.success('ok'); + }, + form: [ + // { + // type: 'Layout', + // component: 'Page', + // nextButtonLabel: '==>', + // previousButtonLabel: '<==', + // elements: [ + // { + // type: 'Layout', + // component: 'Separator', + // if: ctx => ctx.formValues.first_name === 'test', + // }, + // { + // type: 'Layout', + // component: 'Row', + // fields: [ + { + type: 'Enum', + label: 'Gender', + enumValues: ['M', 'F', 'other'], + }, + { + type: 'String', + label: 'Gender_other', + }, + // ], + // }, + // ], + // }, + // { + // type: 'Layout', + // component: 'Page', + // // "if_": lambda ctx: ctx.form_values.get("Number of children") != 0, + // elements: [ + { type: 'Number', label: 'Number of children' }, + // { + // type: 'Layout', + // component: 'Row', + // fields: [ + { type: 'Number', label: 'Age of older child' }, + { type: 'Number', label: 'Age of younger child' }, + // ], + // }, + { type: 'Boolean', label: 'Are they wise' }, + // ], + // nextButtonLabel: '==>', + // previousButtonLabel: '<==', + // }, + // { + // type: 'Layout', + // component: 'Page', + // // "if_": lambda ctx: ctx.form_values.get("Are they wise") is False, + // elements: [ + // { + // type: 'Layout', + // component: 'Row', + // fields: [ + { type: 'StringList', label: 'Why_its_your_fault' }, + { type: 'String', label: 'Why_its_their_fault', widget: 'TextArea' }, + // ], + // }, + // ], + // nextButtonLabel: '==>', + // previousButtonLabel: '<==', + // }, + ], + }); diff --git a/packages/agent/src/routes/modification/action/action.ts b/packages/agent/src/routes/modification/action/action.ts index 3ce2006837..f74b747d48 100644 --- a/packages/agent/src/routes/modification/action/action.ts +++ b/packages/agent/src/routes/modification/action/action.ts @@ -93,7 +93,7 @@ export default class ActionRoute extends CollectionRoute { // As forms are dynamic, we don't have any way to ensure that we're parsing the data correctly // => better send invalid data to the getForm() customer handler than to the execute() one. const unsafeData = ForestValueConverter.makeFormDataUnsafe(rawData); - const fields = await this.collection.getForm( + const form = await this.collection.getForm( caller, this.actionName, unsafeData, @@ -101,6 +101,8 @@ export default class ActionRoute extends CollectionRoute { { includeHiddenFields: true }, // during execute, we need all possible fields ); + const { fields } = SchemaGeneratorActions.extractFieldsAndLayout(form); + // Now that we have the field list, we can parse the data again. const data = ForestValueConverter.makeFormData(dataSource, rawData, fields); const result = await this.collection.execute(caller, this.actionName, data, filterForCaller); @@ -158,17 +160,20 @@ export default class ActionRoute extends CollectionRoute { const caller = QueryStringParser.parseCaller(context); const filter = await this.getRecordSelection(context); - const fields = await this.collection.getForm(caller, this.actionName, data, filter, { + const form = await this.collection.getForm(caller, this.actionName, data, filter, { changedField: body.data.attributes.changed_field, searchField: body.data.attributes.search_field, searchValues, includeHiddenFields: false, }); + const { fields, layout } = SchemaGeneratorActions.extractFieldsAndLayout(form); + context.response.body = { fields: fields.map(field => SchemaGeneratorActions.buildFieldSchema(this.collection.dataSource, field), ), + layout: layout.map(layoutElement => SchemaGeneratorActions.buildLayoutSchema(layoutElement)), }; } diff --git a/packages/agent/src/utils/forest-schema/generator-actions.ts b/packages/agent/src/utils/forest-schema/generator-actions.ts index f533671be4..69f04255e1 100644 --- a/packages/agent/src/utils/forest-schema/generator-actions.ts +++ b/packages/agent/src/utils/forest-schema/generator-actions.ts @@ -1,13 +1,18 @@ import { ActionField, - ActionSchema, + ActionFormElement, + ActionLayoutElement, Collection, ColumnSchema, DataSource, PrimitiveTypes, SchemaUtils, } from '@forestadmin/datasource-toolkit'; -import { ForestServerAction, ForestServerActionField } from '@forestadmin/forestadmin-client'; +import { + ForestServerAction, + ForestServerActionField, + ForestServerActionFormLayoutElement, +} from '@forestadmin/forestadmin-client'; import path from 'path'; import ActionFields from './action-fields'; @@ -46,7 +51,25 @@ export default class SchemaGeneratorActions { // Generate url-safe friendly name (which won't be unique, but that's OK). const slug = SchemaGeneratorActions.getActionSlug(name); - const fields = await SchemaGeneratorActions.buildFields(collection, name, schema); + let fields = SchemaGeneratorActions.defaultFields; + let layout = []; + + if (schema.staticForm) { + const form = await collection.getForm(null, name, null, null); + const fieldsAndLayout = SchemaGeneratorActions.extractFieldsAndLayout(form); + + fields = fieldsAndLayout.fields.map(field => { + const newField = SchemaGeneratorActions.buildFieldSchema(collection.dataSource, field); + newField.defaultValue = newField.value; + delete newField.value; + + return newField; + }); + + layout = fieldsAndLayout.layout.map(layoutElement => + SchemaGeneratorActions.buildLayoutSchema(layoutElement), + ); + } return { id: `${collection.name}-${actionIndex}-${slug}`, @@ -58,6 +81,7 @@ export default class SchemaGeneratorActions { redirect: null, // frontend ignores this attribute download: Boolean(schema.generateFile), fields, + layout, hooks: { load: !schema.staticForm, @@ -99,31 +123,50 @@ export default class SchemaGeneratorActions { return output as ForestServerActionField; } - private static async buildFields( - collection: Collection, - name: string, - schema: ActionSchema, - ): Promise { - // We want the schema to be generated on usage => send dummy schema - if (!schema.staticForm) { - return SchemaGeneratorActions.defaultFields; + static buildLayoutSchema(element: ActionLayoutElement): ForestServerActionFormLayoutElement { + switch (element.component) { + case 'Input': + return { + component: 'input', + fieldId: element.fieldId, + }; + case 'Separator': + default: + return { + component: 'separator', + }; } + } - // Ask the action to generate a form - const fields = await collection.getForm(null, name); - - if (fields) { - // When sending to server, we need to rename 'value' into 'defaultValue' - // otherwise, it does not gets applied 🤷‍♂️ - return fields.map(field => { - const newField = SchemaGeneratorActions.buildFieldSchema(collection.dataSource, field); - newField.defaultValue = newField.value; - delete newField.value; - - return newField; - }); + static extractFieldsAndLayout(formElements: ActionFormElement[]): { + fields: ActionField[]; + layout: ActionLayoutElement[]; + } { + let hasLayout = false; + const fields: ActionField[] = []; + let layout: ActionLayoutElement[] = []; + + formElements.forEach(element => { + if (element.type === 'Layout') { + hasLayout = true; + + if (element.component === 'Separator') { + layout.push(element); + } + } else { + fields.push(element); + layout.push({ + type: 'Layout', + component: 'Input', + fieldId: element.label, + }); + } + }); + + if (!hasLayout) { + layout = []; } - return []; + return { fields, layout }; } } diff --git a/packages/datasource-customizer/src/context/relaxed-wrappers/collection.ts b/packages/datasource-customizer/src/context/relaxed-wrappers/collection.ts index aeed4393ba..d1ed63e5ca 100644 --- a/packages/datasource-customizer/src/context/relaxed-wrappers/collection.ts +++ b/packages/datasource-customizer/src/context/relaxed-wrappers/collection.ts @@ -1,5 +1,5 @@ import { - ActionField, + ActionFormElement, ActionResult, Aggregation, Caller, @@ -73,7 +73,11 @@ export default class RelaxedCollection< return this.collection.execute(this.caller, name, formValues, filterInstance); } - getForm(name: string, formValues?: RecordData, filter?: TFilter): Promise { + getForm( + name: string, + formValues?: RecordData, + filter?: TFilter, + ): Promise { const filterInstance = this.buildFilter(filter); return this.collection.getForm(this.caller, name, formValues, filterInstance); diff --git a/packages/datasource-customizer/src/decorators/actions/collection.ts b/packages/datasource-customizer/src/decorators/actions/collection.ts index fbd6ba22e5..3a0be4aba2 100644 --- a/packages/datasource-customizer/src/decorators/actions/collection.ts +++ b/packages/datasource-customizer/src/decorators/actions/collection.ts @@ -1,5 +1,6 @@ import { ActionField, + ActionFormElement, ActionResult, Caller, CollectionDecorator, @@ -15,7 +16,13 @@ import ActionContext from './context/base'; import ActionContextSingle from './context/single'; import ResultBuilder from './result-builder'; import { ActionBulk, ActionDefinition, ActionGlobal, ActionSingle } from './types/actions'; -import { DynamicField, Handler, SearchOptionsHandler, ValueOrHandler } from './types/fields'; +import { + DynamicField, + DynamicFormElement, + Handler, + SearchOptionsHandler, + ValueOrHandler, +} from './types/fields'; import { TSchema } from '../../templates'; export default class ActionCollectionDecorator extends CollectionDecorator { @@ -57,7 +64,7 @@ export default class ActionCollectionDecorator extends CollectionDecorator { data?: RecordData, filter?: Filter, metas?: GetFormMetas, - ): Promise { + ): Promise { const action = this.actions[name]; if (!action) return this.childCollection.getForm(caller, name, data, filter, metas); if (!action.form) return []; @@ -67,14 +74,18 @@ export default class ActionCollectionDecorator extends CollectionDecorator { const context = this.getContext(caller, action, formValues, filter, used, metas?.changedField); // Convert DynamicField to ActionField in successive steps. - let dynamicFields: DynamicField[] = this.isHandler(action.form) - ? await (action.form as (context: ActionContext) => DynamicField[])(context) + let dynamicFields: DynamicFormElement[] = this.isHandler(action.form) + ? await (action.form as (context: ActionContext) => DynamicFormElement[])( + context, + ) : action.form.map(c => ({ ...c })); 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.label === metas.searchField)]; + dynamicFields = [ + dynamicFields.find(field => field.type !== 'Layout' && field.label === metas.searchField), + ]; } dynamicFields = await this.dropDefaults(context, dynamicFields, formValues); @@ -83,11 +94,13 @@ export default class ActionCollectionDecorator extends CollectionDecorator { const fields = await this.dropDeferred(context, metas?.searchValues, dynamicFields); for (const field of fields) { - // customer did not define a handler to rewrite the previous value => reuse current one. - if (field.value === undefined) field.value = formValues[field.label]; + 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); + // fields that were accessed through the context.formValues.X getter should be watched. + field.watchChanges = used.has(field.label); + } } return fields; @@ -103,6 +116,7 @@ export default class ActionCollectionDecorator extends CollectionDecorator { this.isHandler(form) || form?.some( field => + field.type === 'Layout' || Object.values(field).some(value => this.isHandler(value)) || // A field with a hardcoded file should not be sent to the apimap. it is marked dynamic (field.type.includes('File') && field.defaultValue), @@ -131,24 +145,37 @@ export default class ActionCollectionDecorator extends CollectionDecorator { private async dropDefaults( context: ActionContext, - fields: DynamicField[], + fields: DynamicFormElement[], data: Record, - ): Promise { - const unvaluedFields = fields.filter(field => data[field.label] === undefined); - const defaults = await Promise.all( - unvaluedFields.map(field => this.evaluate(context, null, field.defaultValue)), - ); + ): Promise { + const promises = fields.map(async field => { + if (field.type === 'Layout') return field; - unvaluedFields.forEach((field, index) => { - data[field.label] = defaults[index]; + return this.dropDefault(context, field, data); }); - fields.forEach(field => delete field.defaultValue); + return Promise.all(promises); + } - return fields; + private async dropDefault( + context: ActionContext, + field: DynamicField, + data: Record, + ): Promise { + if (field.label === undefined) { + const defaultValue = await this.evaluate(context, null, field.defaultValue); + data[field.label] = defaultValue; + } + + delete field.defaultValue; + + return field; } - private async dropIfs(context: ActionContext, fields: DynamicField[]): Promise { + private async dropIfs( + context: ActionContext, + fields: DynamicFormElement[], + ): Promise { // Remove fields which have falsy if const ifValues = await Promise.all( fields.map(field => !field.if || this.evaluate(context, null, field.if)), @@ -162,14 +189,16 @@ export default class ActionCollectionDecorator extends CollectionDecorator { private async dropDeferred( context: ActionContext, searchValues: Record | null, - fields: DynamicField[], - ): Promise { - const newFields = fields.map(async (field): Promise => { + fields: DynamicFormElement[], + ): Promise { + const newFields = fields.map(async (field): Promise => { const keys = Object.keys(field); const values = await Promise.all( - Object.values(field).map(value => - this.evaluate(context, searchValues?.[field.label], value), - ), + Object.values(field).map(value => { + const searchValue = field.type === 'Layout' ? null : searchValues?.[field.label]; + + return this.evaluate(context, searchValue, value); + }), ); return keys.reduce( diff --git a/packages/datasource-customizer/src/decorators/actions/types/actions.ts b/packages/datasource-customizer/src/decorators/actions/types/actions.ts index aaf89047a3..c8962ccdab 100644 --- a/packages/datasource-customizer/src/decorators/actions/types/actions.ts +++ b/packages/datasource-customizer/src/decorators/actions/types/actions.ts @@ -1,6 +1,6 @@ import { ActionResult, ActionScope } from '@forestadmin/datasource-toolkit'; -import { DynamicField } from './fields'; +import { DynamicFormElement } from './fields'; import { TCollectionName, TSchema } from '../../../templates'; import ActionContext from '../context/base'; import ActionContextSingle from '../context/single'; @@ -17,8 +17,10 @@ export interface BaseAction< generateFile?: boolean; scope: Scope; form?: - | DynamicField[] - | ((context: Context) => Promise[]> | DynamicField[]); + | DynamicFormElement[] + | (( + context: Context, + ) => Promise[]> | DynamicFormElement[]); execute( context: Context, resultBuilder: ResultBuilder, diff --git a/packages/datasource-customizer/src/decorators/actions/types/fields.ts b/packages/datasource-customizer/src/decorators/actions/types/fields.ts index 36eb4e97c8..3530b80d09 100644 --- a/packages/datasource-customizer/src/decorators/actions/types/fields.ts +++ b/packages/datasource-customizer/src/decorators/actions/types/fields.ts @@ -1,4 +1,4 @@ -import { CompositeId, File, Json } from '@forestadmin/datasource-toolkit'; +import { ActionLayoutElement, CompositeId, File, Json } from '@forestadmin/datasource-toolkit'; type UnionKeys = T extends T ? keyof T : never; type StrictUnionHelper = T extends any @@ -240,3 +240,11 @@ export type DynamicField = StrictUnion< | (StringListDynamicField & UserDropdownFieldConfiguration) | (FileListDynamicField & FileListPickerFieldConfiguration) >; + +export type DynamicLayoutElement = ActionLayoutElement & { + if?: ((context: Context) => Promise) | ((context: Context) => unknown); +}; + +export type DynamicFormElement = + | DynamicField + | DynamicLayoutElement; diff --git a/packages/datasource-toolkit/src/decorators/collection-decorator.ts b/packages/datasource-toolkit/src/decorators/collection-decorator.ts index a1c33a22a8..319e87c0f8 100644 --- a/packages/datasource-toolkit/src/decorators/collection-decorator.ts +++ b/packages/datasource-toolkit/src/decorators/collection-decorator.ts @@ -1,4 +1,4 @@ -import { ActionField, ActionResult } from '../interfaces/action'; +import { ActionFormElement, ActionResult } from '../interfaces/action'; import { Caller } from '../interfaces/caller'; import { Chart } from '../interfaces/chart'; import { Collection, DataSource, GetFormMetas } from '../interfaces/collection'; @@ -70,7 +70,7 @@ export default class CollectionDecorator implements Collection { data?: RecordData, filter?: Filter, metas?: GetFormMetas, - ): Promise { + ): Promise { const refinedFilter = await this.refineFilter(caller, filter); return this.childCollection.getForm(caller, name, data, refinedFilter, metas); diff --git a/packages/datasource-toolkit/src/interfaces/action.ts b/packages/datasource-toolkit/src/interfaces/action.ts index d18f0a135f..61fec26fd5 100644 --- a/packages/datasource-toolkit/src/interfaces/action.ts +++ b/packages/datasource-toolkit/src/interfaces/action.ts @@ -19,15 +19,20 @@ export type File = { charset?: string; }; -export type ActionFieldBase = { +export type ActionFormElementBase = { + type: ActionFieldType | LayoutElementType; +}; + +export interface ActionFieldBase extends ActionFormElementBase { type: ActionFieldType; + widget?: ActionFieldWidget; label: string; description?: string; isRequired?: boolean; isReadOnly?: boolean; value?: unknown; watchChanges: boolean; -}; +} export const ActionFieldTypeList = [ 'Boolean', @@ -46,110 +51,125 @@ export const ActionFieldTypeList = [ 'StringList', ] as const; -export type ActionFieldType = (typeof ActionFieldTypeList)[number]; - -type ActionFieldLimitedValue< - TWidget extends string, +export type ActionFieldType = + | 'Boolean' + | 'Collection' + | 'Date' + | 'Dateonly' + | 'Time' + | 'Enum' + | 'File' + | 'Json' + | 'Number' + | 'String' + | 'EnumList' + | 'FileList' + | 'NumberList' + | 'StringList'; + +export type LayoutElementType = 'Layout'; + +interface ActionFieldLimitedValue< + TWidget extends ActionFieldWidget, TType extends ActionFieldType = ActionFieldType, TValue = unknown, -> = ActionFieldBase & { +> extends ActionFieldBase { widget: TWidget; type: TType; options?: LimitedValuesOption[]; -}; +} -export type ActionFieldDropdown< +export interface ActionFieldDropdown< TType extends ActionFieldType = ActionFieldType, TValue = unknown, -> = ActionFieldBase & - ActionFieldLimitedValue<'Dropdown', TType, TValue> & { - search?: 'static' | 'disabled' | 'dynamic'; - placeholder?: string; - }; +> extends ActionFieldLimitedValue<'Dropdown', TType, TValue> { + search?: 'static' | 'disabled' | 'dynamic'; + placeholder?: string; +} -export type ActionFieldCheckbox = ActionFieldBase & { +export interface ActionFieldCheckbox extends ActionFieldBase { type: 'Boolean'; widget: 'Checkbox'; -}; +} -export type ActionFieldEnum = ActionFieldBase & { +export interface ActionFieldEnum extends ActionFieldBase { type: 'Enum'; enumValues: string[]; -}; +} -export type ActionFieldEnumList = ActionFieldBase & { +export interface ActionFieldEnumList extends ActionFieldBase { type: 'EnumList'; enumValues: string[]; -}; +} -export type ActionFieldCollection = ActionFieldBase & { +export interface ActionFieldCollection extends ActionFieldBase { type: 'Collection'; collectionName: string; -}; +} -export type ActionFieldTextInput = ActionFieldBase & { +export interface ActionFieldTextInput extends ActionFieldBase { type: 'String'; widget: 'TextInput'; placeholder?: string; -}; +} -export type ActionFieldDatePickerInput = ActionFieldBase & { +export interface ActionFieldDatePickerInput extends ActionFieldBase { type: 'Date' | 'Dateonly' | 'String'; widget: 'DatePicker'; format?: string; min?: Date; max?: Date; placeholder?: string; -}; +} -export type ActionFieldFilePicker = ActionFieldBase & { +export interface ActionFieldFilePicker extends ActionFieldBase { type: 'File' | 'FileList'; widget: 'FilePicker'; maxCount?: number; extensions?: string[]; maxSizeMb?: number; -}; +} -export type ActionFieldTextInputList = ActionFieldBase & { +export interface ActionFieldTextInputList extends ActionFieldBase { type: 'StringList'; widget: 'TextInputList'; placeholder?: string; enableReorder?: boolean; allowEmptyValues?: boolean; allowDuplicates?: boolean; -}; +} -export type ActionFieldTextArea = ActionFieldBase & { +export interface ActionFieldTextArea extends ActionFieldBase { type: 'String'; widget: 'TextArea'; placeholder?: string; rows?: number; -}; +} -export type ActionFieldRichText = ActionFieldBase & { +export interface ActionFieldRichText extends ActionFieldBase { type: 'String'; widget: 'RichText'; placeholder?: string; -}; +} -export type ActionFieldNumberInput = ActionFieldBase & { +export interface ActionFieldNumberInput extends ActionFieldBase { type: 'Number'; widget: 'NumberInput'; placeholder?: string; min?: number; max?: number; step?: number; -}; +} -export type ActionFieldColorPicker = ActionFieldBase & { +export interface ActionFieldColorPicker extends ActionFieldBase { type: 'String'; widget: 'ColorPicker'; placeholder?: string; enableOpacity?: boolean; quickPalette?: string[]; -}; +} -export type ActionFieldNumberInputList = ActionFieldBase & { +export interface ActionFieldNumberInputList extends ActionFieldBase { widget: 'NumberInputList'; type: 'NumberList'; placeholder?: string; @@ -158,9 +178,9 @@ export type ActionFieldNumberInputList = ActionFieldBase & { min?: number; max?: number; step?: number; -}; +} -export type ActionFieldCurrencyInput = ActionFieldBase & { +export interface ActionFieldCurrencyInput extends ActionFieldBase { type: 'Number'; widget: 'CurrencyInput'; placeholder?: string; @@ -169,29 +189,29 @@ export type ActionFieldCurrencyInput = ActionFieldBase & { step?: number; currency: string; base?: 'Unit' | 'Cent'; -}; +} -export type ActionFieldUserDropdown = ActionFieldBase & { +export interface ActionFieldUserDropdown extends ActionFieldBase { type: 'String'; widget: 'UserDropdown'; placeholder?: string; -}; +} -export type ActionFieldTimePicker = ActionFieldBase & { +export interface ActionFieldTimePicker extends ActionFieldBase { type: 'Time'; widget: 'TimePicker'; -}; +} -export type ActionFieldJsonEditor = ActionFieldBase & { +export interface ActionFieldJsonEditor extends ActionFieldBase { type: 'Json'; widget: 'JsonEditor'; -}; +} -export type ActionFieldAddressAutocomplete = ActionFieldBase & { +export interface ActionFieldAddressAutocomplete extends ActionFieldBase { type: 'String'; widget: 'AddressAutocomplete'; placeholder?: string; -}; +} export type ActionFieldDropdownAll = | ActionFieldDropdown<'Date' | 'Dateonly' | 'Number' | 'String' | 'StringList', string> @@ -242,6 +262,7 @@ export type ActionFieldWidget = | 'RichText' | 'NumberInput' | 'NumberInputList' + | 'CurrencyInput' | 'ColorPicker' | 'DatePicker' | 'AddressAutocomplete' @@ -250,6 +271,27 @@ export type ActionFieldWidget = | 'FilePicker' | 'JsonEditor'; +type LayoutElementComponentType = 'Input' | 'Separator'; + +interface ActionLayoutElementBase extends ActionFormElementBase { + type: 'Layout'; + component: LayoutElementComponentType; +} +interface LayoutElementSeparator extends ActionLayoutElementBase { + component: 'Separator'; +} + +interface LayoutElementInput extends ActionLayoutElementBase { + component: 'Input'; + fieldId: string; +} + +export type ActionLayoutElement = LayoutElementSeparator | LayoutElementInput; + +export type ActionFormElement = ActionLayoutElement | ActionField; + +export type ActionForm = { fields: ActionField[]; layout: ActionLayoutElement[] }; + export type SuccessResult = { type: 'Success'; message: string; diff --git a/packages/datasource-toolkit/src/interfaces/collection.ts b/packages/datasource-toolkit/src/interfaces/collection.ts index b4b00ee5fe..f53530b3d0 100644 --- a/packages/datasource-toolkit/src/interfaces/collection.ts +++ b/packages/datasource-toolkit/src/interfaces/collection.ts @@ -1,4 +1,4 @@ -import { ActionField, ActionResult } from './action'; +import { ActionFormElement, ActionResult } from './action'; import { Caller } from './caller'; import { Chart } from './chart'; import Aggregation, { AggregateResult } from './query/aggregation'; @@ -43,7 +43,7 @@ export interface Collection { formValues?: RecordData, filter?: Filter, metas?: GetFormMetas, - ): Promise; + ): Promise; create(caller: Caller, data: RecordData[]): Promise; diff --git a/packages/forestadmin-client/src/schema/types.ts b/packages/forestadmin-client/src/schema/types.ts index 165327ed3b..3a60189e55 100644 --- a/packages/forestadmin-client/src/schema/types.ts +++ b/packages/forestadmin-client/src/schema/types.ts @@ -42,6 +42,7 @@ export type ForestServerAction = { redirect: unknown; download: boolean; fields: ForestServerActionField[]; + layout?: ForestServerActionFormLayoutElement[]; hooks: { load: boolean; change: Array; @@ -253,6 +254,19 @@ export type ForestServerActionFieldCheckboxGroup = | ForestServerActionFieldCommon<['String'], ForestServerActionFieldCheckboxGroupOptions> | ForestServerActionFieldCommon<['Number'], ForestServerActionFieldCheckboxGroupOptions>; +type ForestServerActionFormElementSeparator = { + component: 'separator'; +}; + +type ForestServerActionFormElementFieldReference = { + component: 'input'; + fieldId: string; +}; + +export type ForestServerActionFormLayoutElement = + | ForestServerActionFormElementSeparator + | ForestServerActionFormElementFieldReference; + export type ForestServerActionField = | ForestServerActionFieldDropdown | ForestServerActionFieldRadioGroup From b0995573cd04021157b7a2af641b24bb5f257a25 Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Thu, 12 Sep 2024 10:59:52 +0200 Subject: [PATCH 02/18] fix: schema serialization --- .../src/forest/customizations/card.ts | 2 +- .../utils/forest-schema/generator-actions.ts | 21 +++++++++---------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/_example/src/forest/customizations/card.ts b/packages/_example/src/forest/customizations/card.ts index 4724266437..bce52786e3 100644 --- a/packages/_example/src/forest/customizations/card.ts +++ b/packages/_example/src/forest/customizations/card.ts @@ -25,7 +25,7 @@ export default (collection: CardCustomizer) => { type: 'Layout', component: 'Separator', - if: ctx => ctx.formValues.first_name === 'test', + if: ctx => ctx.formValues.first_name?.[0] === 'test', }, // { // type: 'Layout', diff --git a/packages/agent/src/utils/forest-schema/generator-actions.ts b/packages/agent/src/utils/forest-schema/generator-actions.ts index 69f04255e1..10783bd8b2 100644 --- a/packages/agent/src/utils/forest-schema/generator-actions.ts +++ b/packages/agent/src/utils/forest-schema/generator-actions.ts @@ -51,24 +51,24 @@ export default class SchemaGeneratorActions { // Generate url-safe friendly name (which won't be unique, but that's OK). const slug = SchemaGeneratorActions.getActionSlug(name); - let fields = SchemaGeneratorActions.defaultFields; - let layout = []; + const form: { + fields: ForestServerActionField[]; + layout?: ForestServerActionFormLayoutElement[]; + } = { + fields: SchemaGeneratorActions.defaultFields, + }; if (schema.staticForm) { - const form = await collection.getForm(null, name, null, null); - const fieldsAndLayout = SchemaGeneratorActions.extractFieldsAndLayout(form); + const rawForm = await collection.getForm(null, name, null, null); + const fieldsAndLayout = SchemaGeneratorActions.extractFieldsAndLayout(rawForm); - fields = fieldsAndLayout.fields.map(field => { + form.fields = fieldsAndLayout.fields.map(field => { const newField = SchemaGeneratorActions.buildFieldSchema(collection.dataSource, field); newField.defaultValue = newField.value; delete newField.value; return newField; }); - - layout = fieldsAndLayout.layout.map(layoutElement => - SchemaGeneratorActions.buildLayoutSchema(layoutElement), - ); } return { @@ -80,8 +80,7 @@ export default class SchemaGeneratorActions { httpMethod: 'POST', redirect: null, // frontend ignores this attribute download: Boolean(schema.generateFile), - fields, - layout, + ...form, hooks: { load: !schema.staticForm, From 7ccb8f6a1e7be71791d1a069ef45a4bf5c7dd522 Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Thu, 12 Sep 2024 16:11:10 +0200 Subject: [PATCH 03/18] fix: default values --- .../agent/src/utils/forest-schema/generator-actions.ts | 2 ++ .../test/routes/modification/action/action.test.ts | 10 ++++++++-- .../generator-action-field-widget.test.ts | 3 --- .../src/decorators/actions/collection.ts | 2 +- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/agent/src/utils/forest-schema/generator-actions.ts b/packages/agent/src/utils/forest-schema/generator-actions.ts index 10783bd8b2..866a738edb 100644 --- a/packages/agent/src/utils/forest-schema/generator-actions.ts +++ b/packages/agent/src/utils/forest-schema/generator-actions.ts @@ -145,6 +145,8 @@ export default class SchemaGeneratorActions { const fields: ActionField[] = []; let layout: ActionLayoutElement[] = []; + if (!formElements) return { fields: [], layout: [] }; + formElements.forEach(element => { if (element.type === 'Layout') { hasLayout = true; diff --git a/packages/agent/test/routes/modification/action/action.test.ts b/packages/agent/test/routes/modification/action/action.test.ts index 4bec432366..789111ea6b 100644 --- a/packages/agent/test/routes/modification/action/action.test.ts +++ b/packages/agent/test/routes/modification/action/action.test.ts @@ -573,7 +573,10 @@ describe('ActionRoute', () => { }, ); - expect(context.response.body).toEqual({ fields: [{ field: 'firstname', type: 'String' }] }); + expect(context.response.body).toEqual({ + fields: [{ field: 'firstname', type: 'String' }], + layout: [], + }); }); test('handleHook should generate the form if called with changehook params', async () => { @@ -620,7 +623,10 @@ describe('ActionRoute', () => { }, ); - expect(context.response.body).toEqual({ fields: [{ field: 'firstname', type: 'String' }] }); + expect(context.response.body).toEqual({ + fields: [{ field: 'firstname', type: 'String' }], + layout: [], + }); }); }); diff --git a/packages/agent/test/utils/forest-schema/generator-action-field-widget.test.ts b/packages/agent/test/utils/forest-schema/generator-action-field-widget.test.ts index 62866e02f6..055611737e 100644 --- a/packages/agent/test/utils/forest-schema/generator-action-field-widget.test.ts +++ b/packages/agent/test/utils/forest-schema/generator-action-field-widget.test.ts @@ -13,7 +13,6 @@ describe('GeneratorActionFieldWidget', () => { }); it('should return null when the field type is Collection', () => { - // @ts-expect-error Collection type does not support widget const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'Collection', label: 'Label', @@ -25,7 +24,6 @@ describe('GeneratorActionFieldWidget', () => { }); it('should return null when the field type is Enum', () => { - // @ts-expect-error Collection type does not support widget const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'Enum', label: 'Label', @@ -38,7 +36,6 @@ describe('GeneratorActionFieldWidget', () => { }); it('should return null when the field type is EnumList', () => { - // @ts-expect-error Collection type does not support widget const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'EnumList', label: 'Label', diff --git a/packages/datasource-customizer/src/decorators/actions/collection.ts b/packages/datasource-customizer/src/decorators/actions/collection.ts index 3a0be4aba2..1ba1ecc77f 100644 --- a/packages/datasource-customizer/src/decorators/actions/collection.ts +++ b/packages/datasource-customizer/src/decorators/actions/collection.ts @@ -162,7 +162,7 @@ export default class ActionCollectionDecorator extends CollectionDecorator { field: DynamicField, data: Record, ): Promise { - if (field.label === undefined) { + if (data[field.label] === undefined) { const defaultValue = await this.evaluate(context, null, field.defaultValue); data[field.label] = defaultValue; } From e50894ed0c3c4ae50bc91d4b2979d2ecff15f22a Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Thu, 12 Sep 2024 16:52:19 +0200 Subject: [PATCH 04/18] test: add tests --- .../src/forest/customizations/card.ts | 3 - .../src/routes/modification/action/action.ts | 12 ++-- .../utils/forest-schema/generator-actions.ts | 36 ++++++----- .../routes/modification/action/action.test.ts | 17 +++++ .../forest-schema/generator-actions.test.ts | 62 +++++++++++++++++++ .../test/__factories__/collection.ts | 8 ++- 6 files changed, 109 insertions(+), 29 deletions(-) diff --git a/packages/_example/src/forest/customizations/card.ts b/packages/_example/src/forest/customizations/card.ts index bce52786e3..6ee2ab2f7b 100644 --- a/packages/_example/src/forest/customizations/card.ts +++ b/packages/_example/src/forest/customizations/card.ts @@ -99,7 +99,6 @@ export default (collection: CardCustomizer) => // { // type: 'Layout', // component: 'Separator', - // if: ctx => ctx.formValues.first_name === 'test', // }, // { // type: 'Layout', @@ -121,7 +120,6 @@ export default (collection: CardCustomizer) => // { // type: 'Layout', // component: 'Page', - // // "if_": lambda ctx: ctx.form_values.get("Number of children") != 0, // elements: [ { type: 'Number', label: 'Number of children' }, // { @@ -140,7 +138,6 @@ export default (collection: CardCustomizer) => // { // type: 'Layout', // component: 'Page', - // // "if_": lambda ctx: ctx.form_values.get("Are they wise") is False, // elements: [ // { // type: 'Layout', diff --git a/packages/agent/src/routes/modification/action/action.ts b/packages/agent/src/routes/modification/action/action.ts index f74b747d48..3e05340ff8 100644 --- a/packages/agent/src/routes/modification/action/action.ts +++ b/packages/agent/src/routes/modification/action/action.ts @@ -167,14 +167,10 @@ export default class ActionRoute extends CollectionRoute { includeHiddenFields: false, }); - const { fields, layout } = SchemaGeneratorActions.extractFieldsAndLayout(form); - - context.response.body = { - fields: fields.map(field => - SchemaGeneratorActions.buildFieldSchema(this.collection.dataSource, field), - ), - layout: layout.map(layoutElement => SchemaGeneratorActions.buildLayoutSchema(layoutElement)), - }; + context.response.body = SchemaGeneratorActions.buildFieldsAndLayout( + this.collection.dataSource, + form, + ); } private async middlewareCustomActionApprovalRequestData(context: Context, next: Next) { diff --git a/packages/agent/src/utils/forest-schema/generator-actions.ts b/packages/agent/src/utils/forest-schema/generator-actions.ts index 866a738edb..cade1379e5 100644 --- a/packages/agent/src/utils/forest-schema/generator-actions.ts +++ b/packages/agent/src/utils/forest-schema/generator-actions.ts @@ -51,24 +51,13 @@ export default class SchemaGeneratorActions { // Generate url-safe friendly name (which won't be unique, but that's OK). const slug = SchemaGeneratorActions.getActionSlug(name); - const form: { - fields: ForestServerActionField[]; - layout?: ForestServerActionFormLayoutElement[]; - } = { - fields: SchemaGeneratorActions.defaultFields, - }; + let fields = SchemaGeneratorActions.defaultFields; if (schema.staticForm) { const rawForm = await collection.getForm(null, name, null, null); - const fieldsAndLayout = SchemaGeneratorActions.extractFieldsAndLayout(rawForm); - - form.fields = fieldsAndLayout.fields.map(field => { - const newField = SchemaGeneratorActions.buildFieldSchema(collection.dataSource, field); - newField.defaultValue = newField.value; - delete newField.value; + fields = SchemaGeneratorActions.buildFieldsAndLayout(collection.dataSource, rawForm).fields; - return newField; - }); + SchemaGeneratorActions.setFieldsDefaultValue(fields); } return { @@ -80,16 +69,31 @@ export default class SchemaGeneratorActions { httpMethod: 'POST', redirect: null, // frontend ignores this attribute download: Boolean(schema.generateFile), - ...form, + fields, hooks: { load: !schema.staticForm, - // Always registering the change hook has no consequences, even if we don't use it. change: ['changeHook'], }, }; } + static buildFieldsAndLayout(dataSource: DataSource, form: ActionFormElement[]) { + const { fields, layout } = SchemaGeneratorActions.extractFieldsAndLayout(form); + + return { + fields: fields.map(field => SchemaGeneratorActions.buildFieldSchema(dataSource, field)), + layout: layout.map(layoutElement => SchemaGeneratorActions.buildLayoutSchema(layoutElement)), + }; + } + + static setFieldsDefaultValue(fields: ForestServerActionField[]) { + fields.forEach(field => { + field.defaultValue = field.value; + delete field.value; + }); + } + /** Build schema for given field */ static buildFieldSchema(dataSource: DataSource, field: ActionField): ForestServerActionField { const { label, description, isRequired, isReadOnly, watchChanges, type } = field; diff --git a/packages/agent/test/routes/modification/action/action.test.ts b/packages/agent/test/routes/modification/action/action.test.ts index 789111ea6b..ce6d921c5a 100644 --- a/packages/agent/test/routes/modification/action/action.test.ts +++ b/packages/agent/test/routes/modification/action/action.test.ts @@ -579,6 +579,23 @@ describe('ActionRoute', () => { }); }); + test('handleHook should generate a form with layout if some layout elements are present', async () => { + const context = createMockContext(baseContext); + + dataSource.getCollection('books').getForm = jest.fn().mockResolvedValue([ + { type: 'String', label: 'firstname' }, + { type: 'Layout', component: 'Separator' }, + ]); + + // @ts-expect-error: test private method + await route.handleHook(context); + + expect(context.response.body).toEqual({ + fields: [{ field: 'firstname', type: 'String' }], + layout: [{ component: 'input', fieldId: 'firstname' }, { component: 'separator' }], + }); + }); + test('handleHook should generate the form if called with changehook params', async () => { const context = createMockContext({ ...baseContext, 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 8436ab1546..b697a9ffee 100644 --- a/packages/agent/test/utils/forest-schema/generator-actions.test.ts +++ b/packages/agent/test/utils/forest-schema/generator-actions.test.ts @@ -100,6 +100,7 @@ describe('SchemaGeneratorActions', () => { test('should include a reference to the change hook', async () => { const schema = await SchemaGeneratorActions.buildSchema(collection, 'Send email'); expect(schema.fields[0].hook).toEqual('changeHook'); + expect(schema.layout).toEqual(undefined); }); }); @@ -132,6 +133,10 @@ describe('SchemaGeneratorActions', () => { value: null, watchChanges: false, }, + { + type: 'Layout', + component: 'Separator', + }, { label: 'inclusive gender', description: 'Choose None, Male, Female or Both', @@ -177,6 +182,9 @@ describe('SchemaGeneratorActions', () => { type: ['Enum'], enums: ['Male', 'Female'], }); + + // no layout in schema, only in hooks response + expect(schema.layout).toEqual(undefined); }); }); @@ -269,4 +277,58 @@ describe('SchemaGeneratorActions', () => { }); }); }); + + describe('buildFieldsAndLayout', () => { + it('should compute the schema of layout elements', async () => { + const dataSource = factories.dataSource.buildWithCollections([ + factories.collection.buildWithAction( + 'Update title', + { + scope: 'Single', + generateFile: false, + staticForm: true, + }, + [ + { + label: 'title', + description: 'updated title', + type: 'String', + isRequired: true, + isReadOnly: false, + value: null, + watchChanges: false, + }, + { + type: 'Layout', + component: 'Separator', + }, + { + label: 'description', + type: 'String', + watchChanges: false, + }, + ], + ), + ]); + + const collection = dataSource.getCollection('books'); + + const form = await collection.getForm(null, 'Update title'); + + const schema = SchemaGeneratorActions.buildFieldsAndLayout(collection.dataSource, form); + + expect(schema.fields.length).toEqual(2); + expect(schema.layout).toEqual([ + { + component: 'input', + fieldId: 'title', + }, + { component: 'separator' }, + { + component: 'input', + fieldId: 'description', + }, + ]); + }); + }); }); diff --git a/packages/datasource-toolkit/test/__factories__/collection.ts b/packages/datasource-toolkit/test/__factories__/collection.ts index 2ff33bdc2e..f48da5727f 100644 --- a/packages/datasource-toolkit/test/__factories__/collection.ts +++ b/packages/datasource-toolkit/test/__factories__/collection.ts @@ -2,12 +2,16 @@ import { Factory } from 'fishery'; import collectionSchemaFactory from './schema/collection-schema'; -import { ActionField } from '../../src/interfaces/action'; +import { ActionFormElement } from '../../src/interfaces/action'; import { Collection } from '../../src/interfaces/collection'; import { ActionSchema } from '../../src/interfaces/schema'; export class CollectionFactory extends Factory { - buildWithAction(name: string, schema: ActionSchema, fields: ActionField[] = null): Collection { + buildWithAction( + name: string, + schema: ActionSchema, + fields: ActionFormElement[] = null, + ): Collection { return this.build({ name: 'books', schema: collectionSchemaFactory.build({ From 17fc6d576c89e855e96349f088d4b187f95e208d Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Fri, 13 Sep 2024 09:55:02 +0200 Subject: [PATCH 05/18] feat: add html block in action forms --- .../src/forest/customizations/card.ts | 16 ++++++++++++- .../utils/forest-schema/generator-actions.ts | 9 +++++--- .../src/decorators/actions/types/fields.ts | 23 +++++++++++++++---- .../src/interfaces/action.ts | 12 ++++++++-- .../forestadmin-client/src/schema/types.ts | 6 +++++ 5 files changed, 56 insertions(+), 10 deletions(-) diff --git a/packages/_example/src/forest/customizations/card.ts b/packages/_example/src/forest/customizations/card.ts index 6ee2ab2f7b..5370837fd6 100644 --- a/packages/_example/src/forest/customizations/card.ts +++ b/packages/_example/src/forest/customizations/card.ts @@ -6,7 +6,9 @@ export default (collection: CardCustomizer) => .addAction('action with form', { scope: 'Bulk', execute: (context, resultBuilder) => { - resultBuilder.success('ok'); + return resultBuilder.success('ok', { + html: `test`, + }); }, form: [ // { @@ -27,6 +29,18 @@ export default (collection: CardCustomizer) => component: 'Separator', if: ctx => ctx.formValues.first_name?.[0] === 'test', }, + { + type: 'Layout', + component: 'HtmlBlock', + content: ctx => { + return `This block displays value of first name: ${ctx.formValues.first_name}`; + }, + }, + { + type: 'Layout', + component: 'HtmlBlock', + content: `Other block`, + }, // { // type: 'Layout', // component: 'Row', diff --git a/packages/agent/src/utils/forest-schema/generator-actions.ts b/packages/agent/src/utils/forest-schema/generator-actions.ts index cade1379e5..c3468d2114 100644 --- a/packages/agent/src/utils/forest-schema/generator-actions.ts +++ b/packages/agent/src/utils/forest-schema/generator-actions.ts @@ -133,6 +133,11 @@ export default class SchemaGeneratorActions { component: 'input', fieldId: element.fieldId, }; + case 'HtmlBlock': + return { + component: 'htmlBlock', + content: element.content, + }; case 'Separator': default: return { @@ -155,9 +160,7 @@ export default class SchemaGeneratorActions { if (element.type === 'Layout') { hasLayout = true; - if (element.component === 'Separator') { - layout.push(element); - } + layout.push(element); } else { fields.push(element); layout.push({ diff --git a/packages/datasource-customizer/src/decorators/actions/types/fields.ts b/packages/datasource-customizer/src/decorators/actions/types/fields.ts index 3530b80d09..1780e09aef 100644 --- a/packages/datasource-customizer/src/decorators/actions/types/fields.ts +++ b/packages/datasource-customizer/src/decorators/actions/types/fields.ts @@ -1,4 +1,4 @@ -import { ActionLayoutElement, CompositeId, File, Json } from '@forestadmin/datasource-toolkit'; +import { CompositeId, File, Json } from '@forestadmin/datasource-toolkit'; type UnionKeys = T extends T ? keyof T : never; type StrictUnionHelper = T extends any @@ -26,7 +26,7 @@ type BaseDynamicField = { description?: ValueOrHandler; isRequired?: ValueOrHandler; isReadOnly?: ValueOrHandler; - if?: ((context: Context) => Promise) | ((context: Context) => unknown); + if?: Handler; value?: ValueOrHandler; defaultValue?: ValueOrHandler; }; @@ -241,10 +241,25 @@ export type DynamicField = StrictUnion< | (FileListDynamicField & FileListPickerFieldConfiguration) >; -export type DynamicLayoutElement = ActionLayoutElement & { - if?: ((context: Context) => Promise) | ((context: Context) => unknown); +type DynamicLayoutElementBase = { + type: 'Layout'; + if?: Handler; }; +type DynamicLayoutElementSeparator = DynamicLayoutElementBase & { + component: 'Separator'; +}; + +type DynamicLayoutElementHtmlBlock = DynamicLayoutElementBase & { + component: 'HtmlBlock'; + content: ValueOrHandler; +}; + +export type DynamicLayoutElement = + | DynamicLayoutElementBase + | DynamicLayoutElementSeparator + | DynamicLayoutElementHtmlBlock; + export type DynamicFormElement = | DynamicField | DynamicLayoutElement; diff --git a/packages/datasource-toolkit/src/interfaces/action.ts b/packages/datasource-toolkit/src/interfaces/action.ts index 61fec26fd5..8b0227048c 100644 --- a/packages/datasource-toolkit/src/interfaces/action.ts +++ b/packages/datasource-toolkit/src/interfaces/action.ts @@ -271,7 +271,7 @@ export type ActionFieldWidget = | 'FilePicker' | 'JsonEditor'; -type LayoutElementComponentType = 'Input' | 'Separator'; +type LayoutElementComponentType = 'Input' | 'Separator' | 'HtmlBlock'; interface ActionLayoutElementBase extends ActionFormElementBase { type: 'Layout'; @@ -281,12 +281,20 @@ interface LayoutElementSeparator extends ActionLayoutElementBase { component: 'Separator'; } +interface LayoutElementHtmlBlock extends ActionLayoutElementBase { + component: 'HtmlBlock'; + content: string; +} + interface LayoutElementInput extends ActionLayoutElementBase { component: 'Input'; fieldId: string; } -export type ActionLayoutElement = LayoutElementSeparator | LayoutElementInput; +export type ActionLayoutElement = + | LayoutElementSeparator + | LayoutElementHtmlBlock + | LayoutElementInput; export type ActionFormElement = ActionLayoutElement | ActionField; diff --git a/packages/forestadmin-client/src/schema/types.ts b/packages/forestadmin-client/src/schema/types.ts index 3a60189e55..157ea48143 100644 --- a/packages/forestadmin-client/src/schema/types.ts +++ b/packages/forestadmin-client/src/schema/types.ts @@ -258,6 +258,11 @@ type ForestServerActionFormElementSeparator = { component: 'separator'; }; +type ForestServerActionFormElementHtmlBlock = { + component: 'htmlBlock'; + content: string; +}; + type ForestServerActionFormElementFieldReference = { component: 'input'; fieldId: string; @@ -265,6 +270,7 @@ type ForestServerActionFormElementFieldReference = { export type ForestServerActionFormLayoutElement = | ForestServerActionFormElementSeparator + | ForestServerActionFormElementHtmlBlock | ForestServerActionFormElementFieldReference; export type ForestServerActionField = From 35f0c4293299d9e39adf306a4497471ac1d83d2a Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Fri, 13 Sep 2024 16:08:12 +0200 Subject: [PATCH 06/18] feat: add row in action forms --- .../src/forest/customizations/card.ts | 99 ++++++++++++------- .../utils/forest-schema/generator-actions.ts | 66 +++++++++++-- .../src/decorators/actions/collection.ts | 76 ++++++++++++-- .../src/decorators/actions/types/fields.ts | 7 +- .../src/interfaces/action.ts | 10 +- .../forestadmin-client/src/schema/types.ts | 8 +- 6 files changed, 211 insertions(+), 55 deletions(-) diff --git a/packages/_example/src/forest/customizations/card.ts b/packages/_example/src/forest/customizations/card.ts index 5370837fd6..28af290041 100644 --- a/packages/_example/src/forest/customizations/card.ts +++ b/packages/_example/src/forest/customizations/card.ts @@ -18,62 +18,93 @@ export default (collection: CardCustomizer) => // previousButtonLabel: '<==', // elements: [ { - type: 'StringList', - label: 'first_name', + type: 'String', + label: 'display fields', widget: 'Dropdown', search: 'dynamic', - options: (ctx, searchValue) => ['first Option', 'second option', 'third option', 'test'], - }, - { - type: 'Layout', - component: 'Separator', - if: ctx => ctx.formValues.first_name?.[0] === 'test', + options: (ctx, searchValue) => ['config 1', 'config 2', 'config 3'], }, { type: 'Layout', component: 'HtmlBlock', content: ctx => { - return `This block displays value of first name: ${ctx.formValues.first_name}`; + switch (ctx.formValues['display fields']) { + case 'config 1': + return `

Should display:

+
    +
  • separator
  • +
  • field 0
  • +
  • row 1, fields 1 and 2
  • +
`; + case 'config 2': + return `

Should display:

+
    +
  • row 1, fields 2 and 3
  • +
  • row 2, fields 4 and 5
  • +
`; + case 'config 3': + return `

Should display:

+
    +
  • row 1, field 1
  • +
`; + default: + return `Select a fields configuration`; + } }, }, { type: 'Layout', - component: 'HtmlBlock', - content: `Other block`, + component: 'Separator', + if: ctx => ['config 1'].includes(ctx.formValues['display fields']), }, - // { - // type: 'Layout', - // component: 'Row', - // fields: [ { - type: 'Enum', - label: 'Gender', - enumValues: ['M', 'F', 'other'], - if: ctx => - ctx.formValues.first_name?.[0] === 'test' && ctx.formValues.Gender_other === 'test', + type: 'String', + label: 'field 0', + if: ctx => ['config 1'].includes(ctx.formValues['display fields']), }, { - type: 'String', - label: 'Gender_other', + type: 'Layout', + component: 'Row', + fields: [ + { + type: 'Number', + label: 'field 1', + if: ctx => ['config 1', 'config 3'].includes(ctx.formValues['display fields']), + }, + { + type: 'Number', + label: 'field 2', + if: ctx => ['config 1', 'config 2'].includes(ctx.formValues['display fields']), + }, + { + type: 'Number', + label: 'field 3', + if: ctx => ['config 1', 'config 2'].includes(ctx.formValues['display fields']), + }, + ], + }, + { + type: 'Layout', + component: 'Row', + fields: [ + { + type: 'Number', + label: 'field 4', + if: ctx => ['config 2'].includes(ctx.formValues['display fields']), + }, + { + type: 'Number', + label: 'field 5', + if: ctx => ['config 2'].includes(ctx.formValues['display fields']), + }, + ], }, - // ], - // }, - // ], - // }, // { // type: 'Layout', // component: 'Page', // // "if_": lambda ctx: ctx.form_values.get("Number of children") != 0, // elements: [ { type: 'Number', label: 'Number of children' }, - // { - // type: 'Layout', - // component: 'Row', - // fields: [ - { type: 'Number', label: 'Age of older child' }, - { type: 'Number', label: 'Age of younger child' }, - // ], - // }, { type: 'Boolean', label: 'Are they wise' }, // ], // nextButtonLabel: '==>', diff --git a/packages/agent/src/utils/forest-schema/generator-actions.ts b/packages/agent/src/utils/forest-schema/generator-actions.ts index c3468d2114..2b439690a1 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'; @@ -55,6 +57,7 @@ export default class SchemaGeneratorActions { if (schema.staticForm) { const rawForm = await collection.getForm(null, name, null, null); + console.log('rawForm', rawForm); fields = SchemaGeneratorActions.buildFieldsAndLayout(collection.dataSource, rawForm).fields; SchemaGeneratorActions.setFieldsDefaultValue(fields); @@ -81,6 +84,8 @@ export default class SchemaGeneratorActions { static buildFieldsAndLayout(dataSource: DataSource, form: ActionFormElement[]) { const { fields, layout } = SchemaGeneratorActions.extractFieldsAndLayout(form); + console.log({ fields, layout }); + return { fields: fields.map(field => SchemaGeneratorActions.buildFieldSchema(dataSource, field)), layout: layout.map(layoutElement => SchemaGeneratorActions.buildLayoutSchema(layoutElement)), @@ -126,7 +131,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 +150,18 @@ export default class SchemaGeneratorActions { component: 'htmlBlock', content: element.content, }; + case 'Row': + console.log('buildLayoutSchema (row)', element); + + return { + component: 'row', + fields: element.fields.map( + field => + SchemaGeneratorActions.buildLayoutSchema(field, { + forceInput: true, + }) as ForestServerActionFormElementFieldReference, + ), + }; case 'Separator': default: return { @@ -159,16 +183,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 +194,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/datasource-customizer/src/decorators/actions/collection.ts b/packages/datasource-customizer/src/decorators/actions/collection.ts index 1ba1ecc77f..36b0bf653f 100644 --- a/packages/datasource-customizer/src/decorators/actions/collection.ts +++ b/packages/datasource-customizer/src/decorators/actions/collection.ts @@ -78,13 +78,13 @@ export default class ActionCollectionDecorator extends CollectionDecorator { ? await (action.form as (context: ActionContext) => DynamicFormElement[])( context, ) - : action.form.map(c => ({ ...c })); + : 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), ]; } @@ -143,15 +143,48 @@ export default class ActionCollectionDecorator extends CollectionDecorator { }[action.scope](this, caller, formValues, filter as unknown as PlainFilter, used, changedField); } + private getSubElementsAttributeKey(field: DynamicFormElement): string | null { + if (field.type === 'Layout' && field.component === 'Row') return 'fields'; + + return null; + } + + private async executeOnSubFields( + field: DynamicFormElement, + handler: (subFields: DynamicFormElement[]) => 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); + } - return this.dropDefault(context, field, data); + await this.executeOnSubFields(field, subfields => + this.dropDefaults(context, subfields, data), + ); + + return field; }); return Promise.all(promises); @@ -178,8 +211,33 @@ 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 => { + console.log(field); + + if ((await this.evaluate(context, null, field.if)) === false) { + // drop element if condition returns false + console.log('hide element', JSON.stringify(field)); + + 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) { + console.log('hide element because no child', JSON.stringify(field)); + + return false; + } + } + + return true; + }), ); + const newFields = fields.filter((_, index) => ifValues[index]); newFields.forEach(field => delete field.if); @@ -192,6 +250,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,9 +263,9 @@ export default class ActionCollectionDecorator extends CollectionDecorator { }), ); - return keys.reduce( + return keys.reduce( (memo, key, index) => ({ ...memo, [key]: values[index] }), - {} as ActionField, + {} as ActionFormElement, ); }); diff --git a/packages/datasource-customizer/src/decorators/actions/types/fields.ts b/packages/datasource-customizer/src/decorators/actions/types/fields.ts index 1780e09aef..682dd7044f 100644 --- a/packages/datasource-customizer/src/decorators/actions/types/fields.ts +++ b/packages/datasource-customizer/src/decorators/actions/types/fields.ts @@ -255,9 +255,14 @@ type DynamicLayoutElementHtmlBlock = DynamicLayoutElementBase content: ValueOrHandler; }; +type DynamicLayoutElementRow = DynamicLayoutElementBase & { + component: 'Row'; + fields: DynamicField[]; +}; + export type DynamicLayoutElement = - | DynamicLayoutElementBase | DynamicLayoutElementSeparator + | DynamicLayoutElementRow | DynamicLayoutElementHtmlBlock; export type DynamicFormElement = diff --git a/packages/datasource-toolkit/src/interfaces/action.ts b/packages/datasource-toolkit/src/interfaces/action.ts index 8b0227048c..e66f57147a 100644 --- a/packages/datasource-toolkit/src/interfaces/action.ts +++ b/packages/datasource-toolkit/src/interfaces/action.ts @@ -271,7 +271,7 @@ export type ActionFieldWidget = | 'FilePicker' | 'JsonEditor'; -type LayoutElementComponentType = 'Input' | 'Separator' | 'HtmlBlock'; +type LayoutElementComponentType = 'Input' | 'Separator' | 'HtmlBlock' | 'Row'; interface ActionLayoutElementBase extends ActionFormElementBase { type: 'Layout'; @@ -286,7 +286,12 @@ interface LayoutElementHtmlBlock extends ActionLayoutElementBase { content: string; } -interface LayoutElementInput extends ActionLayoutElementBase { +interface LayoutElementRow extends ActionLayoutElementBase { + component: 'Row'; + fields: LayoutElementInput[]; +} + +export interface LayoutElementInput extends ActionLayoutElementBase { component: 'Input'; fieldId: string; } @@ -294,6 +299,7 @@ interface LayoutElementInput extends ActionLayoutElementBase { export type ActionLayoutElement = | LayoutElementSeparator | LayoutElementHtmlBlock + | LayoutElementRow | LayoutElementInput; export type ActionFormElement = ActionLayoutElement | ActionField; 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 = From 786931c286e70f274390f85ca4082b652306b17a Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Fri, 13 Sep 2024 16:13:26 +0200 Subject: [PATCH 07/18] fix: types --- .../src/interfaces/action.ts | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/packages/datasource-toolkit/src/interfaces/action.ts b/packages/datasource-toolkit/src/interfaces/action.ts index e66f57147a..02fefdef41 100644 --- a/packages/datasource-toolkit/src/interfaces/action.ts +++ b/packages/datasource-toolkit/src/interfaces/action.ts @@ -271,28 +271,22 @@ export type ActionFieldWidget = | 'FilePicker' | 'JsonEditor'; -type LayoutElementComponentType = 'Input' | 'Separator' | 'HtmlBlock' | 'Row'; - -interface ActionLayoutElementBase extends ActionFormElementBase { +interface ActionLayoutElementBase extends ActionFormElementBase { type: 'Layout'; - component: LayoutElementComponentType; -} -interface LayoutElementSeparator extends ActionLayoutElementBase { - component: 'Separator'; + component: T; } -interface LayoutElementHtmlBlock extends ActionLayoutElementBase { - component: 'HtmlBlock'; +type LayoutElementSeparator = ActionLayoutElementBase<'Separator'>; + +interface LayoutElementHtmlBlock extends ActionLayoutElementBase<'HtmlBlock'> { content: string; } -interface LayoutElementRow extends ActionLayoutElementBase { - component: 'Row'; +interface LayoutElementRow extends ActionLayoutElementBase<'Row'> { fields: LayoutElementInput[]; } -export interface LayoutElementInput extends ActionLayoutElementBase { - component: 'Input'; +export interface LayoutElementInput extends ActionLayoutElementBase<'Input'> { fieldId: string; } From 1953b498400de0e0c1801a3d60c5ca16777b5372 Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Tue, 17 Sep 2024 10:44:31 +0200 Subject: [PATCH 08/18] fix: remove console --- .../agent/src/utils/forest-schema/generator-actions.ts | 5 ----- .../src/decorators/actions/collection.ts | 7 ------- 2 files changed, 12 deletions(-) diff --git a/packages/agent/src/utils/forest-schema/generator-actions.ts b/packages/agent/src/utils/forest-schema/generator-actions.ts index 2b439690a1..b9b206b883 100644 --- a/packages/agent/src/utils/forest-schema/generator-actions.ts +++ b/packages/agent/src/utils/forest-schema/generator-actions.ts @@ -57,7 +57,6 @@ export default class SchemaGeneratorActions { if (schema.staticForm) { const rawForm = await collection.getForm(null, name, null, null); - console.log('rawForm', rawForm); fields = SchemaGeneratorActions.buildFieldsAndLayout(collection.dataSource, rawForm).fields; SchemaGeneratorActions.setFieldsDefaultValue(fields); @@ -84,8 +83,6 @@ export default class SchemaGeneratorActions { static buildFieldsAndLayout(dataSource: DataSource, form: ActionFormElement[]) { const { fields, layout } = SchemaGeneratorActions.extractFieldsAndLayout(form); - console.log({ fields, layout }); - return { fields: fields.map(field => SchemaGeneratorActions.buildFieldSchema(dataSource, field)), layout: layout.map(layoutElement => SchemaGeneratorActions.buildLayoutSchema(layoutElement)), @@ -151,8 +148,6 @@ export default class SchemaGeneratorActions { content: element.content, }; case 'Row': - console.log('buildLayoutSchema (row)', element); - return { component: 'row', fields: element.fields.map( diff --git a/packages/datasource-customizer/src/decorators/actions/collection.ts b/packages/datasource-customizer/src/decorators/actions/collection.ts index 36b0bf653f..0e00b5885e 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, @@ -212,12 +211,8 @@ export default class ActionCollectionDecorator extends CollectionDecorator { // Remove fields which have falsy if const ifValues = await Promise.all( fields.map(async field => { - console.log(field); - if ((await this.evaluate(context, null, field.if)) === false) { // drop element if condition returns false - console.log('hide element', JSON.stringify(field)); - return false; } @@ -228,8 +223,6 @@ export default class ActionCollectionDecorator extends CollectionDecorator { // drop element if no subElement if (field[subElementsKey].length === 0) { - console.log('hide element because no child', JSON.stringify(field)); - return false; } } From d9588376bcba1552b280a8576d882a00b752ac2a Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Tue, 17 Sep 2024 16:29:50 +0200 Subject: [PATCH 09/18] fix: watchChanges --- .../src/forest/customizations/card.ts | 4 +- .../src/decorators/actions/collection.ts | 65 ++++++++++++++----- 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/packages/_example/src/forest/customizations/card.ts b/packages/_example/src/forest/customizations/card.ts index 28af290041..7f126a3681 100644 --- a/packages/_example/src/forest/customizations/card.ts +++ b/packages/_example/src/forest/customizations/card.ts @@ -79,7 +79,9 @@ export default (collection: CardCustomizer) => { type: 'Number', label: 'field 3', - if: ctx => ['config 1', 'config 2'].includes(ctx.formValues['display fields']), + if: ctx => + ['config 1', 'config 2'].includes(ctx.formValues['display fields']) && + ctx.formValues['field 2'] === 3, }, ], }, diff --git a/packages/datasource-customizer/src/decorators/actions/collection.ts b/packages/datasource-customizer/src/decorators/actions/collection.ts index 0e00b5885e..df42661b8c 100644 --- a/packages/datasource-customizer/src/decorators/actions/collection.ts +++ b/packages/datasource-customizer/src/decorators/actions/collection.ts @@ -77,7 +77,8 @@ export default class ActionCollectionDecorator extends CollectionDecorator { ? await (action.form as (context: ActionContext) => DynamicFormElement[])( context, ) - : await this.copyFields(action.form); + : // copy fields to keep original object unchanged + await this.copyFields(action.form); if (metas?.searchField) { // in the case of a search hook, @@ -92,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; } @@ -142,16 +135,18 @@ export default class ActionCollectionDecorator extends CollectionDecorator { }[action.scope](this, caller, formValues, filter as unknown as PlainFilter, used, changedField); } - private getSubElementsAttributeKey(field: DynamicFormElement): string | null { + private getSubElementsAttributeKey( + field: T, + ): string | null { if (field.type === 'Layout' && field.component === 'Row') return 'fields'; return null; } - private async executeOnSubFields( - field: DynamicFormElement, - handler: (subFields: DynamicFormElement[]) => T[] | Promise, - ) { + 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] || []); @@ -265,6 +260,46 @@ export default class ActionCollectionDecorator extends CollectionDecorator { 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, From 31b8feecb1d2c2a024bd6e9b92f7e7bccfaf6bff Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Tue, 17 Sep 2024 17:28:51 +0200 Subject: [PATCH 10/18] fix: tests --- .../_example/src/forest/customizations/card.ts | 8 ++++++++ .../forest-schema/generator-actions.test.ts | 17 +++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/_example/src/forest/customizations/card.ts b/packages/_example/src/forest/customizations/card.ts index 7f126a3681..97846f29fd 100644 --- a/packages/_example/src/forest/customizations/card.ts +++ b/packages/_example/src/forest/customizations/card.ts @@ -62,6 +62,14 @@ export default (collection: CardCustomizer) => label: 'field 0', if: ctx => ['config 1'].includes(ctx.formValues['display fields']), }, + { + type: 'Layout', + component: 'Separator', + }, + { + type: 'Layout', + component: 'Separator', + }, { type: 'Layout', component: 'Row', 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 1ccfc8d15f..5ba26a097f 100644 --- a/packages/agent/test/utils/forest-schema/generator-actions.test.ts +++ b/packages/agent/test/utils/forest-schema/generator-actions.test.ts @@ -313,7 +313,7 @@ describe('SchemaGeneratorActions', () => { watchChanges: false, }, { - label: 'description', + label: 'address', type: 'String', watchChanges: false, }, @@ -329,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', @@ -338,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', + }, + ], }, ]); }); From ab8a371b4be49a7af144f121d59f2a98473e3100 Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Wed, 18 Sep 2024 10:28:40 +0200 Subject: [PATCH 11/18] test: add collection tests --- .../src/forest/customizations/card.ts | 201 ++++++------------ .../decorators/actions/collection.test.ts | 91 ++++++++ 2 files changed, 151 insertions(+), 141 deletions(-) diff --git a/packages/_example/src/forest/customizations/card.ts b/packages/_example/src/forest/customizations/card.ts index 97846f29fd..89a5aee4c0 100644 --- a/packages/_example/src/forest/customizations/card.ts +++ b/packages/_example/src/forest/customizations/card.ts @@ -3,93 +3,109 @@ import { CardCustomizer } from '../typings'; export default (collection: CardCustomizer) => collection .addManyToOneRelation('customer', 'customer', { foreignKey: 'customer_id' }) - .addAction('action with form', { - scope: 'Bulk', + .addAction('create new card', { + scope: 'Global', execute: (context, resultBuilder) => { return resultBuilder.success('ok', { html: `test`, }); }, form: [ - // { - // type: 'Layout', - // component: 'Page', - // nextButtonLabel: '==>', - // previousButtonLabel: '<==', - // elements: [ { - type: 'String', - label: 'display fields', + type: 'Number', + label: 'Customer', widget: 'Dropdown', search: 'dynamic', - options: (ctx, searchValue) => ['config 1', 'config 2', 'config 3'], + options: async (ctx, searchValue) => { + const results = await ctx.dataSource.getCollection('customer').list( + { + conditionTree: { + aggregator: 'Or', + conditions: [ + { field: 'name', operator: 'Match', value: searchValue }, + { field: 'firstName', operator: 'Match', 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['display fields']) { - case 'config 1': - return `

Should display:

+ switch (ctx.formValues.Plan) { + case 'Base': + return `

Should setup:

  • separator
  • -
  • field 0
  • -
  • row 1, fields 1 and 2
  • +
  • price
  • +
  • max withdraw / max payment
`; - case 'config 2': - return `

Should display:

+ case 'Gold': + return `

Should setup:

    -
  • row 1, fields 2 and 3
  • -
  • row 2, fields 4 and 5
  • +
  • max payment / Systematic check
  • +
  • discount / discount months
`; - case 'config 3': - return `

Should display:

+ case 'Back': + return `

Should setup:

    -
  • row 1, field 1
  • +
  • max withdraw
`; default: - return `Select a fields configuration`; + return `Select a card plan`; } }, }, { type: 'Layout', component: 'Separator', - if: ctx => ['config 1'].includes(ctx.formValues['display fields']), - }, - { - type: 'String', - label: 'field 0', - if: ctx => ['config 1'].includes(ctx.formValues['display fields']), + if: ctx => ['Base'].includes(ctx.formValues.Plan), }, { - type: 'Layout', - component: 'Separator', + type: 'Number', + label: 'price', + defaultValue: 40, + if: ctx => ['Base'].includes(ctx.formValues.Plan), }, { - type: 'Layout', - component: 'Separator', + 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: 'field 1', - if: ctx => ['config 1', 'config 3'].includes(ctx.formValues['display fields']), + label: 'Max withdraw', + if: ctx => ['config 1', 'config 3'].includes(ctx.formValues.Plan), }, { type: 'Number', - label: 'field 2', - if: ctx => ['config 1', 'config 2'].includes(ctx.formValues['display fields']), + label: 'Max payment', + if: ctx => ['config 1', 'config 2'].includes(ctx.formValues.Plan), }, { - type: 'Number', - label: 'field 3', + type: 'Boolean', + label: 'Systematic check', if: ctx => - ['config 1', 'config 2'].includes(ctx.formValues['display fields']) && - ctx.formValues['field 2'] === 3, + ['config 1', 'config 2'].includes(ctx.formValues.Plan) && + ctx.formValues['Max payment'] > 1000, }, ], }, @@ -99,112 +115,15 @@ export default (collection: CardCustomizer) => fields: [ { type: 'Number', - label: 'field 4', + label: 'Discount', if: ctx => ['config 2'].includes(ctx.formValues['display fields']), }, { type: 'Number', - label: 'field 5', + label: 'Discount months', if: ctx => ['config 2'].includes(ctx.formValues['display fields']), }, ], }, - // { - // type: 'Layout', - // component: 'Page', - // // "if_": lambda ctx: ctx.form_values.get("Number of children") != 0, - // elements: [ - { type: 'Number', label: 'Number of children' }, - { type: 'Boolean', label: 'Are they wise' }, - // ], - // nextButtonLabel: '==>', - // previousButtonLabel: '<==', - // }, - // { - // type: 'Layout', - // component: 'Page', - // // "if_": lambda ctx: ctx.form_values.get("Are they wise") is False, - // elements: [ - // { - // type: 'Layout', - // component: 'Row', - // fields: [ - { type: 'StringList', label: 'Why_its_your_fault' }, - { type: 'String', label: 'Why_its_their_fault', widget: 'TextArea' }, - // ], - // }, - // ], - // nextButtonLabel: '==>', - // previousButtonLabel: '<==', - // }, - ], - }) - .addAction('static action with form', { - scope: 'Bulk', - execute: (context, resultBuilder) => { - resultBuilder.success('ok'); - }, - form: [ - // { - // type: 'Layout', - // component: 'Page', - // nextButtonLabel: '==>', - // previousButtonLabel: '<==', - // elements: [ - // { - // type: 'Layout', - // component: 'Separator', - // }, - // { - // type: 'Layout', - // component: 'Row', - // fields: [ - { - type: 'Enum', - label: 'Gender', - enumValues: ['M', 'F', 'other'], - }, - { - type: 'String', - label: 'Gender_other', - }, - // ], - // }, - // ], - // }, - // { - // type: 'Layout', - // component: 'Page', - // elements: [ - { type: 'Number', label: 'Number of children' }, - // { - // type: 'Layout', - // component: 'Row', - // fields: [ - { type: 'Number', label: 'Age of older child' }, - { type: 'Number', label: 'Age of younger child' }, - // ], - // }, - { type: 'Boolean', label: 'Are they wise' }, - // ], - // nextButtonLabel: '==>', - // previousButtonLabel: '<==', - // }, - // { - // type: 'Layout', - // component: 'Page', - // elements: [ - // { - // type: 'Layout', - // component: 'Row', - // fields: [ - { type: 'StringList', label: 'Why_its_your_fault' }, - { type: 'String', label: 'Why_its_their_fault', widget: 'TextArea' }, - // ], - // }, - // ], - // nextButtonLabel: '==>', - // previousButtonLabel: '<==', - // }, ], }); 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', + }, + ]), + ); + }); + }); }); From 79f503bd4e796f3d94db40e834f105509334fb3c Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Wed, 18 Sep 2024 10:49:56 +0200 Subject: [PATCH 12/18] fix: example --- .../src/forest/customizations/card.ts | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/_example/src/forest/customizations/card.ts b/packages/_example/src/forest/customizations/card.ts index 89a5aee4c0..fac56f115e 100644 --- a/packages/_example/src/forest/customizations/card.ts +++ b/packages/_example/src/forest/customizations/card.ts @@ -19,13 +19,17 @@ export default (collection: CardCustomizer) => options: async (ctx, searchValue) => { const results = await ctx.dataSource.getCollection('customer').list( { - conditionTree: { - aggregator: 'Or', - conditions: [ - { field: 'name', operator: 'Match', value: searchValue }, - { field: 'firstName', operator: 'Match', value: searchValue }, - ], - }, + ...(searchValue + ? { + conditionTree: { + aggregator: 'Or', + conditions: [ + { field: 'name', operator: 'Contains', value: searchValue }, + { field: 'firstName', operator: 'Contains', value: searchValue }, + ], + }, + } + : {}), }, ['id'], ); @@ -54,7 +58,7 @@ export default (collection: CardCustomizer) => case 'Gold': return `

Should setup:

    -
  • max payment / Systematic check
  • +
  • max payment / Systematic check (if max payment > 1000)
  • discount / discount months
`; case 'Back': @@ -93,18 +97,18 @@ export default (collection: CardCustomizer) => { type: 'Number', label: 'Max withdraw', - if: ctx => ['config 1', 'config 3'].includes(ctx.formValues.Plan), + if: ctx => ['Base', 'Black'].includes(ctx.formValues.Plan), }, { type: 'Number', label: 'Max payment', - if: ctx => ['config 1', 'config 2'].includes(ctx.formValues.Plan), + if: ctx => ['Base', 'Gold'].includes(ctx.formValues.Plan), }, { type: 'Boolean', label: 'Systematic check', if: ctx => - ['config 1', 'config 2'].includes(ctx.formValues.Plan) && + ['Base', 'Gold'].includes(ctx.formValues.Plan) && ctx.formValues['Max payment'] > 1000, }, ], @@ -116,12 +120,12 @@ export default (collection: CardCustomizer) => { type: 'Number', label: 'Discount', - if: ctx => ['config 2'].includes(ctx.formValues['display fields']), + if: ctx => ['Gold'].includes(ctx.formValues['display fields']), }, { type: 'Number', label: 'Discount months', - if: ctx => ['config 2'].includes(ctx.formValues['display fields']), + if: ctx => ['Gold'].includes(ctx.formValues['display fields']), }, ], }, From a6363d9aefe4999d894b2c863004b329dbd0545b Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Wed, 18 Sep 2024 11:36:16 +0200 Subject: [PATCH 13/18] feat: add optional id in action form fields --- .../src/forest/customizations/card.ts | 5 +++ .../src/utils/forest-schema/action-values.ts | 2 +- .../utils/forest-schema/generator-actions.ts | 8 ++-- .../src/decorators/actions/collection.ts | 42 +++++++++++-------- .../src/decorators/actions/types/fields.ts | 7 ++++ .../src/interfaces/action.ts | 1 + .../forestadmin-client/src/schema/types.ts | 1 + .../plugin-export-advanced/test/index.test.ts | 3 ++ 8 files changed, 46 insertions(+), 23 deletions(-) diff --git a/packages/_example/src/forest/customizations/card.ts b/packages/_example/src/forest/customizations/card.ts index fac56f115e..28cfdcacbe 100644 --- a/packages/_example/src/forest/customizations/card.ts +++ b/packages/_example/src/forest/customizations/card.ts @@ -88,6 +88,11 @@ export default (collection: CardCustomizer) => defaultValue: 80, if: ctx => ['Gold'].includes(ctx.formValues.Plan), }, + { + type: 'Number', + id: 'test-price', + label: 'price', + }, { type: 'Layout', component: 'Separator' }, { type: 'Layout', component: 'HtmlBlock', content: '

constraints:

' }, { diff --git a/packages/agent/src/utils/forest-schema/action-values.ts b/packages/agent/src/utils/forest-schema/action-values.ts index 147816e1a9..b75d85a5be 100644 --- a/packages/agent/src/utils/forest-schema/action-values.ts +++ b/packages/agent/src/utils/forest-schema/action-values.ts @@ -19,7 +19,7 @@ export default class ForestValueConverter { const data = {}; for (const [key, value] of Object.entries(rawData)) { - const field = fields.find(f => f.label === key); + const field = fields.find(f => f.id === key); // Skip fields from the default form if (!SchemaGeneratorActions.defaultFields.map(f => f.field).includes(key)) { diff --git a/packages/agent/src/utils/forest-schema/generator-actions.ts b/packages/agent/src/utils/forest-schema/generator-actions.ts index b9b206b883..f0ede969c6 100644 --- a/packages/agent/src/utils/forest-schema/generator-actions.ts +++ b/packages/agent/src/utils/forest-schema/generator-actions.ts @@ -29,6 +29,7 @@ export default class SchemaGeneratorActions { static defaultFields: ForestServerActionField[] = [ { field: 'Loading...', + label: 'Loading...', type: 'String', isReadOnly: true, defaultValue: 'Form is loading', @@ -98,10 +99,9 @@ export default class SchemaGeneratorActions { /** Build schema for given field */ static buildFieldSchema(dataSource: DataSource, field: ActionField): ForestServerActionField { - const { label, description, isRequired, isReadOnly, watchChanges, type } = field; - const output = { description, isRequired, isReadOnly } as Record; + const { id, label, description, isRequired, isReadOnly, watchChanges, type } = field; + const output = { id, label, description, isRequired, isReadOnly } as Record; - output.field = label; output.value = ForestValueConverter.valueToForest(field, field.value); if (watchChanges) output.hook = 'changeHook'; @@ -215,7 +215,7 @@ export default class SchemaGeneratorActions { return { type: 'Layout', component: 'Input', - fieldId: element.label, + fieldId: element.id, }; } } diff --git a/packages/datasource-customizer/src/decorators/actions/collection.ts b/packages/datasource-customizer/src/decorators/actions/collection.ts index d84f9172a3..b2d8b655f5 100644 --- a/packages/datasource-customizer/src/decorators/actions/collection.ts +++ b/packages/datasource-customizer/src/decorators/actions/collection.ts @@ -16,8 +16,9 @@ import ActionContextSingle from './context/single'; import ResultBuilder from './result-builder'; import { ActionBulk, ActionDefinition, ActionGlobal, ActionSingle } from './types/actions'; import { - DynamicField, + DynamicFieldWithId, DynamicFormElement, + DynamicFormElementWithId, Handler, SearchOptionsHandler, ValueOrHandler, @@ -88,10 +89,13 @@ export default class ActionCollectionDecorator extends CollectionDecorator { ]; } - dynamicFields = await this.dropDefaults(context, dynamicFields, formValues); - if (!metas?.includeHiddenFields) dynamicFields = await this.dropIfs(context, dynamicFields); + let dynamicFieldsWithId = await this.dropDefaultsAndSetId(context, dynamicFields, formValues); - const fields = await this.dropDeferred(context, metas?.searchValues, dynamicFields); + if (!metas?.includeHiddenFields) { + dynamicFieldsWithId = await this.dropIfs(context, dynamicFieldsWithId); + } + + const fields = await this.dropDeferred(context, metas?.searchValues, dynamicFieldsWithId); this.setWatchChangesOnFields(formValues, used, fields); @@ -164,18 +168,20 @@ export default class ActionCollectionDecorator extends CollectionDecorator { ); } - private async dropDefaults( + private async dropDefaultsAndSetId( context: ActionContext, fields: DynamicFormElement[], data: Record, - ): Promise { + ): Promise { const promises = fields.map(async field => { if (field.type !== 'Layout') { - return this.dropDefault(context, field, data); + field.id = field.id || field.label; + + return this.dropDefault(context, field as DynamicFieldWithId, data); } await this.executeOnSubFields(field, subfields => - this.dropDefaults(context, subfields, data), + this.dropDefaultsAndSetId(context, subfields, data), ); return field; @@ -186,12 +192,12 @@ export default class ActionCollectionDecorator extends CollectionDecorator { private async dropDefault( context: ActionContext, - field: DynamicField, + field: DynamicFieldWithId, data: Record, - ): Promise { - if (data[field.label] === undefined) { + ): Promise { + if (data[field.id] === undefined) { const defaultValue = await this.evaluate(context, null, field.defaultValue); - data[field.label] = defaultValue; + data[field.id] = defaultValue; } delete field.defaultValue; @@ -201,8 +207,8 @@ export default class ActionCollectionDecorator extends CollectionDecorator { private async dropIfs( context: ActionContext, - fields: DynamicFormElement[], - ): Promise { + fields: DynamicFormElementWithId[], + ): Promise { // Remove fields which have falsy if const ifValues = await Promise.all( fields.map(async field => { @@ -235,7 +241,7 @@ export default class ActionCollectionDecorator extends CollectionDecorator { private async dropDeferred( context: ActionContext, searchValues: Record | null, - fields: DynamicFormElement[], + fields: DynamicFormElementWithId[], ): Promise { const newFields = fields.map(async (field): Promise => { await this.executeOnSubFields(field, subfields => @@ -245,7 +251,7 @@ export default class ActionCollectionDecorator extends CollectionDecorator { const keys = Object.keys(field); const values = await Promise.all( Object.values(field).map(value => { - const searchValue = field.type === 'Layout' ? null : searchValues?.[field.label]; + const searchValue = field.type === 'Layout' ? null : searchValues?.[field.id]; return this.evaluate(context, searchValue, value); }), @@ -291,10 +297,10 @@ export default class ActionCollectionDecorator extends CollectionDecorator { ) { 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]; + if (field.value === undefined) field.value = formValues[field.id]; // fields that were accessed through the context.formValues.X getter should be watched. - field.watchChanges = used.has(field.label); + field.watchChanges = used.has(field.id); } return field; diff --git a/packages/datasource-customizer/src/decorators/actions/types/fields.ts b/packages/datasource-customizer/src/decorators/actions/types/fields.ts index 682dd7044f..908d120af5 100644 --- a/packages/datasource-customizer/src/decorators/actions/types/fields.ts +++ b/packages/datasource-customizer/src/decorators/actions/types/fields.ts @@ -22,6 +22,7 @@ export type ValueOrHandler = type BaseDynamicField = { type: Type; + id?: string; label: string; description?: ValueOrHandler; isRequired?: ValueOrHandler; @@ -268,3 +269,9 @@ export type DynamicLayoutElement = export type DynamicFormElement = | DynamicField | DynamicLayoutElement; + +export type DynamicFieldWithId = DynamicField & { id: string }; + +export type DynamicFormElementWithId = + | DynamicFieldWithId + | DynamicLayoutElement; diff --git a/packages/datasource-toolkit/src/interfaces/action.ts b/packages/datasource-toolkit/src/interfaces/action.ts index 40dc65161c..a5dbc97fea 100644 --- a/packages/datasource-toolkit/src/interfaces/action.ts +++ b/packages/datasource-toolkit/src/interfaces/action.ts @@ -26,6 +26,7 @@ export type ActionFormElementBase = { export interface ActionFieldBase extends ActionFormElementBase { type: ActionFieldType; widget?: ActionFieldWidget; + id: string; label: string; description?: string; isRequired?: boolean; diff --git a/packages/forestadmin-client/src/schema/types.ts b/packages/forestadmin-client/src/schema/types.ts index 664d308ccf..6ee32bad27 100644 --- a/packages/forestadmin-client/src/schema/types.ts +++ b/packages/forestadmin-client/src/schema/types.ts @@ -69,6 +69,7 @@ export type ForestServerActionFieldCommon< defaultValue: unknown; description: string | null; field: string; + label: string; hook: string; isReadOnly: boolean; isRequired: boolean; diff --git a/packages/plugin-export-advanced/test/index.test.ts b/packages/plugin-export-advanced/test/index.test.ts index 4d175febbf..c596e2ac1c 100644 --- a/packages/plugin-export-advanced/test/index.test.ts +++ b/packages/plugin-export-advanced/test/index.test.ts @@ -77,6 +77,7 @@ describe('plugin-export-advanced', () => { expect(form).toEqual([ { + id: 'Filename', label: 'Filename', type: 'String', value: `books - ${new Date().toISOString().substring(0, 10)}`, @@ -84,6 +85,7 @@ describe('plugin-export-advanced', () => { }, { enumValues: ['.csv', '.xlsx', '.json'], + id: 'Format', label: 'Format', type: 'Enum', value: '.csv', @@ -91,6 +93,7 @@ describe('plugin-export-advanced', () => { }, { enumValues: ['id', 'title', 'author:id', 'author:fullname'], + id: 'Fields', label: 'Fields', type: 'EnumList', value: ['id', 'title', 'author:id', 'author:fullname'], From 4b34434600a992f83ad9568aa057bda2b6f95b9a Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Wed, 18 Sep 2024 12:09:16 +0200 Subject: [PATCH 14/18] fix: tests --- .../decorators/actions/collection.test.ts | 119 ++++++++++++++---- 1 file changed, 96 insertions(+), 23 deletions(-) diff --git a/packages/datasource-customizer/test/decorators/actions/collection.test.ts b/packages/datasource-customizer/test/decorators/actions/collection.test.ts index de430fc2ef..a5b0f4a19e 100644 --- a/packages/datasource-customizer/test/decorators/actions/collection.test.ts +++ b/packages/datasource-customizer/test/decorators/actions/collection.test.ts @@ -135,8 +135,8 @@ describe('ActionDecorator', () => { return resultBuilder.error('meeh'); }, form: [ - { label: 'firstname', type: 'String' }, - { label: 'lastname', type: 'String' }, + { id: 'firstname', label: 'firstname', type: 'String' }, + { id: 'lastname', label: 'lastname', type: 'String' }, ], }); }); @@ -153,8 +153,13 @@ describe('ActionDecorator', () => { const fields = await newBooks.getForm(factories.caller.build(), 'make photocopy', {}); expect(fields).toEqual([ - { label: 'firstname', type: 'String', watchChanges: false }, - { label: 'lastname', type: 'String', watchChanges: false }, + { + id: 'firstname', + label: 'firstname', + type: 'String', + watchChanges: false, + }, + { id: 'lastname', label: 'lastname', type: 'String', watchChanges: false }, ]); }); }); @@ -265,12 +270,19 @@ describe('ActionDecorator', () => { expect(fields).toEqual([ { + id: 'firstname', label: 'firstname', type: 'String', watchChanges: true, value: 'DynamicDefault', }, - { label: 'lastname', type: 'String', isReadOnly: true, watchChanges: false }, + { + id: 'lastname', + label: 'lastname', + type: 'String', + isReadOnly: true, + watchChanges: false, + }, ]); }); @@ -281,12 +293,14 @@ describe('ActionDecorator', () => { expect(fields).toEqual([ { + id: 'firstname', label: 'firstname', type: 'String', watchChanges: true, value: 'DynamicDefault', }, { + id: 'lastname', label: 'lastname', type: 'String', value: 'value', @@ -302,8 +316,14 @@ describe('ActionDecorator', () => { }); expect(fields).toEqual([ - { label: 'firstname', type: 'String', watchChanges: true, value: null }, - { label: 'lastname', type: 'String', isReadOnly: false, watchChanges: false }, + { id: 'firstname', label: 'firstname', type: 'String', watchChanges: true, value: null }, + { + id: 'lastname', + label: 'lastname', + type: 'String', + isReadOnly: false, + watchChanges: false, + }, ]); }); @@ -313,8 +333,14 @@ describe('ActionDecorator', () => { }); expect(fields).toEqual([ - { label: 'firstname', type: 'String', watchChanges: true, value: 'John' }, - { label: 'lastname', type: 'String', isReadOnly: true, watchChanges: false }, + { id: 'firstname', label: 'firstname', type: 'String', watchChanges: true, value: 'John' }, + { + id: 'lastname', + label: 'lastname', + type: 'String', + isReadOnly: true, + watchChanges: false, + }, ]); }); @@ -331,8 +357,20 @@ describe('ActionDecorator', () => { ); expect(fields).toEqual([ - { label: 'firstname', type: 'String', watchChanges: true, value: 'John' }, - { label: 'lastname', type: 'String', isReadOnly: true, watchChanges: false }, + { + id: 'firstname', + label: 'firstname', + type: 'String', + watchChanges: true, + value: 'John', + }, + { + id: 'lastname', + label: 'lastname', + type: 'String', + isReadOnly: true, + watchChanges: false, + }, ]); }); }); @@ -376,12 +414,13 @@ describe('ActionDecorator', () => { expect(fields).toHaveLength(2); expect(fields).toEqual([ { + id: 'id', label: 'id', type: 'String', watchChanges: false, value: undefined, }, - { label: 'title', type: 'String', watchChanges: false, value: undefined }, + { id: 'title', label: 'title', type: 'String', watchChanges: false, value: undefined }, ]); }); @@ -392,12 +431,14 @@ describe('ActionDecorator', () => { expect(fields).toEqual([ { + id: 'id', label: 'id', type: 'String', watchChanges: false, value: undefined, }, { + id: 'title', label: 'title', type: 'String', value: 'value', @@ -443,6 +484,7 @@ describe('ActionDecorator', () => { ); expect(fields).toEqual([ { + id: 'firstname', label: 'firstname', type: 'String', value: 'DynamicDefault', @@ -550,6 +592,7 @@ describe('ActionDecorator', () => { const fields = await newBooks.getForm(factories.caller.build(), 'make photocopy'); expect(fields).toEqual([ { + id: 'lastname', label: 'lastname', type: 'String', watchChanges: false, @@ -566,12 +609,14 @@ describe('ActionDecorator', () => { execute: () => {}, form: [ { + id: 'default', label: 'default', type: 'String', defaultValue: 'hello', value: 'hello', }, { + id: 'dynamic search', label: 'dynamic search', type: 'String', widget: 'Dropdown', @@ -596,6 +641,7 @@ describe('ActionDecorator', () => { ); expect(fields).toStrictEqual([ { + id: 'dynamic search', label: 'dynamic search', type: 'String', options: expect.arrayContaining(['123', 'user@domain.com']), @@ -671,11 +717,13 @@ describe('ActionDecorator', () => { const fields = await newBooks.getForm(factories.caller.build(), 'make photocopy'); expect(fields).toEqual([ { + id: 'change', label: 'change', type: 'String', watchChanges: true, }, { + id: 'to change', label: 'to change', type: 'String', isReadOnly: true, @@ -717,18 +765,19 @@ describe('ActionDecorator', () => { type: 'Layout', component: 'Row', fields: [ - { label: 'firstname', type: 'String' }, - { label: 'lastname', type: 'String' }, + { id: 'firstname', label: 'firstname', type: 'String' }, + { id: 'lastname', label: 'lastname', type: 'String' }, ], }, { type: 'Layout', component: 'Separator' }, - { label: 'age', type: 'Number' }, + { id: 'age', label: 'age', type: 'Number' }, { type: 'Layout', component: 'Row', fields: [ - { label: 'tel', type: 'Number', if: ctx => ctx.formValues.age > 18 }, + { id: 'tel', label: 'tel', type: 'Number', if: ctx => ctx.formValues.age > 18 }, { + id: 'email', label: 'email', type: 'String', defaultValue: ctx => `${ctx.formValues.firstname}.${ctx.formValues.lastname}@`, @@ -742,17 +791,35 @@ describe('ActionDecorator', () => { { component: 'Row', fields: [ - { label: 'firstname', type: 'String', value: undefined, watchChanges: true }, - { label: 'lastname', type: 'String', value: undefined, watchChanges: true }, + { + id: 'firstname', + label: 'firstname', + type: 'String', + value: undefined, + watchChanges: true, + }, + { + id: 'lastname', + label: 'lastname', + type: 'String', + value: undefined, + watchChanges: true, + }, ], type: 'Layout', }, { component: 'Separator', type: 'Layout' }, - { label: 'age', type: 'Number', value: undefined, watchChanges: true }, + { id: 'age', label: 'age', type: 'Number', value: undefined, watchChanges: true }, { component: 'Row', fields: [ - { label: 'email', type: 'String', value: 'undefined.undefined@', watchChanges: false }, + { + id: 'email', + label: 'email', + type: 'String', + value: 'undefined.undefined@', + watchChanges: false, + }, ], type: 'Layout', }, @@ -762,12 +829,18 @@ describe('ActionDecorator', () => { await newBooks.getForm(null, 'make photocopy', { age: 25, lastname: 'smith' }, null), ).toEqual( expect.arrayContaining([ - { label: 'age', type: 'Number', value: 25, watchChanges: true }, + { id: 'age', 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 }, + { id: 'tel', label: 'tel', type: 'Number', value: undefined, watchChanges: false }, + { + id: 'email', + label: 'email', + type: 'String', + value: 'undefined.smith@', + watchChanges: false, + }, ], type: 'Layout', }, From 07b6ee7d2d794cb241bb2085d7c9c0f794048bbc Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Wed, 18 Sep 2024 12:12:23 +0200 Subject: [PATCH 15/18] fix: tests --- .../test/decorators/actions/collection.test.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/datasource-customizer/test/decorators/actions/collection.test.ts b/packages/datasource-customizer/test/decorators/actions/collection.test.ts index a5b0f4a19e..877fa33185 100644 --- a/packages/datasource-customizer/test/decorators/actions/collection.test.ts +++ b/packages/datasource-customizer/test/decorators/actions/collection.test.ts @@ -135,8 +135,8 @@ describe('ActionDecorator', () => { return resultBuilder.error('meeh'); }, form: [ - { id: 'firstname', label: 'firstname', type: 'String' }, - { id: 'lastname', label: 'lastname', type: 'String' }, + { label: 'firstname', type: 'String' }, + { label: 'lastname', type: 'String' }, ], }); }); @@ -609,14 +609,12 @@ describe('ActionDecorator', () => { execute: () => {}, form: [ { - id: 'default', label: 'default', type: 'String', defaultValue: 'hello', value: 'hello', }, { - id: 'dynamic search', label: 'dynamic search', type: 'String', widget: 'Dropdown', @@ -765,19 +763,19 @@ describe('ActionDecorator', () => { type: 'Layout', component: 'Row', fields: [ - { id: 'firstname', label: 'firstname', type: 'String' }, - { id: 'lastname', label: 'lastname', type: 'String' }, + { label: 'firstname', type: 'String' }, + { label: 'lastname', type: 'String' }, ], }, { type: 'Layout', component: 'Separator' }, - { id: 'age', label: 'age', type: 'Number' }, + { label: 'age', type: 'Number' }, + { id: 'id-age', label: 'age', type: 'Number' }, { type: 'Layout', component: 'Row', fields: [ - { id: 'tel', label: 'tel', type: 'Number', if: ctx => ctx.formValues.age > 18 }, + { label: 'tel', type: 'Number', if: ctx => ctx.formValues.age > 18 }, { - id: 'email', label: 'email', type: 'String', defaultValue: ctx => `${ctx.formValues.firstname}.${ctx.formValues.lastname}@`, @@ -810,6 +808,7 @@ describe('ActionDecorator', () => { }, { component: 'Separator', type: 'Layout' }, { id: 'age', label: 'age', type: 'Number', value: undefined, watchChanges: true }, + { id: 'id-age', label: 'age', type: 'Number', value: undefined, watchChanges: false }, { component: 'Row', fields: [ From de97703bb9f8b19c1cde5aecf815da9db2699dea Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Wed, 18 Sep 2024 13:38:42 +0200 Subject: [PATCH 16/18] fix: tests --- .../utils/forest-schema/generator-actions.ts | 5 +- .../utils/forest-schema/action-fields.test.ts | 44 +++++++++++++++ .../utils/forest-schema/action-values.test.ts | 24 +++++++-- .../generator-action-field-widget.test.ts | 53 +++++++++++++++++++ .../forest-schema/generator-actions.test.ts | 12 +++++ 5 files changed, 132 insertions(+), 6 deletions(-) diff --git a/packages/agent/src/utils/forest-schema/generator-actions.ts b/packages/agent/src/utils/forest-schema/generator-actions.ts index f0ede969c6..eb838f1a13 100644 --- a/packages/agent/src/utils/forest-schema/generator-actions.ts +++ b/packages/agent/src/utils/forest-schema/generator-actions.ts @@ -100,7 +100,10 @@ export default class SchemaGeneratorActions { /** Build schema for given field */ static buildFieldSchema(dataSource: DataSource, field: ActionField): ForestServerActionField { const { id, label, description, isRequired, isReadOnly, watchChanges, type } = field; - const output = { id, label, description, isRequired, isReadOnly } as Record; + const output = { field: id, label, description, isRequired, isReadOnly } as Record< + string, + unknown + >; output.value = ForestValueConverter.valueToForest(field, field.value); diff --git a/packages/agent/test/utils/forest-schema/action-fields.test.ts b/packages/agent/test/utils/forest-schema/action-fields.test.ts index fa1d939f6b..8b0705ca09 100644 --- a/packages/agent/test/utils/forest-schema/action-fields.test.ts +++ b/packages/agent/test/utils/forest-schema/action-fields.test.ts @@ -22,6 +22,7 @@ describe('ActionFields', () => { it('should return true when the field type is Collection', () => { const result = ActionFields.isCollectionField({ type: 'Collection', + id: 'Label', label: 'Label', watchChanges: false, }); @@ -34,6 +35,7 @@ describe('ActionFields', () => { type => { const result = ActionFields.isCollectionField({ type, + id: 'Label', label: 'Label', watchChanges: false, }); @@ -53,6 +55,7 @@ describe('ActionFields', () => { it('should return true when the field type is Enum', () => { const result = ActionFields.isEnumField({ type: 'Enum', + id: 'Label', label: 'Label', watchChanges: false, enumValues: ['value1', 'value2'], @@ -66,6 +69,7 @@ describe('ActionFields', () => { type => { const result = ActionFields.isEnumField({ type, + id: 'Label', label: 'Label', watchChanges: false, }); @@ -85,6 +89,7 @@ describe('ActionFields', () => { it('should return true when the field type is EnumList', () => { const result = ActionFields.isEnumListField({ type: 'EnumList', + id: 'Label', label: 'Label', watchChanges: false, enumValues: ['value1', 'value2'], @@ -98,6 +103,7 @@ describe('ActionFields', () => { type => { const result = ActionFields.isEnumListField({ type, + id: 'Label', label: 'Label', watchChanges: false, }); @@ -117,6 +123,7 @@ describe('ActionFields', () => { it('should return true when the field type is File', () => { const result = ActionFields.isFileField({ type: 'File', + id: 'Label', label: 'Label', watchChanges: false, }); @@ -129,6 +136,7 @@ describe('ActionFields', () => { type => { const result = ActionFields.isFileField({ type, + id: 'Label', label: 'Label', watchChanges: false, }); @@ -148,6 +156,7 @@ describe('ActionFields', () => { it('should return true when the field type is FileList', () => { const result = ActionFields.isFileListField({ type: 'FileList', + id: 'Label', label: 'Label', watchChanges: false, }); @@ -160,6 +169,7 @@ describe('ActionFields', () => { type => { const result = ActionFields.isFileListField({ type, + id: 'Label', label: 'Label', watchChanges: false, }); @@ -179,6 +189,7 @@ describe('ActionFields', () => { it('should return true when the field type is Dropdown', () => { const result = ActionFields.isDropdownField({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'Dropdown', @@ -190,6 +201,7 @@ describe('ActionFields', () => { it('should return false when the field type is not Dropdown', () => { const result = ActionFields.isDropdownField({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, }); @@ -208,6 +220,7 @@ describe('ActionFields', () => { it('should return true when the field type is RadioGroup', () => { const result = ActionFields.isRadioGroupField({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'RadioGroup', @@ -219,6 +232,7 @@ describe('ActionFields', () => { it('should return false when the field type is not RadioGroup', () => { const result = ActionFields.isRadioGroupField({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, }); @@ -237,6 +251,7 @@ describe('ActionFields', () => { it('should return true when the field type is CheckboxGroup', () => { const result = ActionFields.isCheckboxGroupField({ type: 'StringList', + id: 'Label', label: 'Label', watchChanges: false, widget: 'CheckboxGroup', @@ -248,6 +263,7 @@ describe('ActionFields', () => { it('should return false when the field type is not CheckboxGroup', () => { const result = ActionFields.isCheckboxGroupField({ type: 'StringList', + id: 'Label', label: 'Label', watchChanges: false, }); @@ -266,6 +282,7 @@ describe('ActionFields', () => { it('should return true if the field is a checkbox', () => { const result = ActionFields.isCheckboxField({ type: 'Boolean', + id: 'Label', label: 'Label', watchChanges: false, widget: 'Checkbox', @@ -277,6 +294,7 @@ describe('ActionFields', () => { it('should return false if the field is not a checkbox', () => { const result = ActionFields.isCheckboxField({ type: 'Boolean', + id: 'Label', label: 'Label', watchChanges: false, widget: 'TextInput' as any, @@ -290,6 +308,7 @@ describe('ActionFields', () => { it('should return true if the field is a text input', () => { const result = ActionFields.isTextInputField({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'TextInput', @@ -301,6 +320,7 @@ describe('ActionFields', () => { it('should return false if the field is not a text input', () => { const result = ActionFields.isTextInputField({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'Dropdown', @@ -315,6 +335,7 @@ describe('ActionFields', () => { it('should return true if the field is a text input list', () => { const result = ActionFields.isTextInputListField({ type: 'StringList', + id: 'Label', label: 'Label', watchChanges: false, widget: 'TextInputList', @@ -326,6 +347,7 @@ describe('ActionFields', () => { it('should return false if the field is not a text input list', () => { const result = ActionFields.isTextInputListField({ type: 'StringList', + id: 'Label', label: 'Label', watchChanges: false, widget: 'Dropdown', @@ -340,6 +362,7 @@ describe('ActionFields', () => { it('should return true if the field is a text area', () => { const result = ActionFields.isTextAreaField({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'TextArea', @@ -351,6 +374,7 @@ describe('ActionFields', () => { it('should return false if the field is not a text area', () => { const result = ActionFields.isTextAreaField({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'Dropdown', @@ -371,6 +395,7 @@ describe('ActionFields', () => { it('should return true if the field is a rich text', () => { const result = ActionFields.isRichTextField({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'RichText', @@ -382,6 +407,7 @@ describe('ActionFields', () => { it('should return false if the field is not a rich text', () => { const result = ActionFields.isRichTextField({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'Dropdown', @@ -402,6 +428,7 @@ describe('ActionFields', () => { it('should return true if the field is a number input', () => { const result = ActionFields.isNumberInputField({ type: 'Number', + id: 'Label', label: 'Label', watchChanges: false, widget: 'NumberInput', @@ -413,6 +440,7 @@ describe('ActionFields', () => { it('should return false if the field is not a number input', () => { const result = ActionFields.isNumberInputField({ type: 'Number', + id: 'Label', label: 'Label', watchChanges: false, widget: 'Dropdown', @@ -433,6 +461,7 @@ describe('ActionFields', () => { it('should return true if the field is a number list input', () => { const result = ActionFields.isNumberInputListField({ type: 'NumberList', + id: 'Label', label: 'Label', watchChanges: false, widget: 'NumberInputList', @@ -444,6 +473,7 @@ describe('ActionFields', () => { it('should return false if the field is not a number list input', () => { const result = ActionFields.isNumberInputListField({ type: 'NumberList', + id: 'Label', label: 'Label', watchChanges: false, widget: 'Dropdown', @@ -464,6 +494,7 @@ describe('ActionFields', () => { it('should return true if the field is a color input', () => { const result = ActionFields.isColorPickerField({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'ColorPicker', @@ -475,6 +506,7 @@ describe('ActionFields', () => { it('should return false if the field is not a color input', () => { const result = ActionFields.isColorPickerField({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'Dropdown', @@ -495,6 +527,7 @@ describe('ActionFields', () => { it('should return true if the field is a currency input', () => { const result = ActionFields.isCurrencyInputField({ type: 'Number', + id: 'Label', label: 'Label', watchChanges: false, widget: 'CurrencyInput', @@ -507,6 +540,7 @@ describe('ActionFields', () => { it('should return false if the field is not a currency input', () => { const result = ActionFields.isCurrencyInputField({ type: 'Number', + id: 'Label', label: 'Label', watchChanges: false, widget: 'Dropdown', @@ -527,6 +561,7 @@ describe('ActionFields', () => { it('should return true if the field is a json editor', () => { const result = ActionFields.isJsonEditorField({ type: 'Json', + id: 'Label', label: 'Label', watchChanges: false, widget: 'JsonEditor', @@ -538,6 +573,7 @@ describe('ActionFields', () => { it('should return false if the field is not a json editor', () => { const result = ActionFields.isJsonEditorField({ type: 'Json', + id: 'Label', label: 'Label', watchChanges: false, widget: 'Dropdown' as any, @@ -557,6 +593,7 @@ describe('ActionFields', () => { it('should return true if the field is a user input', () => { const result = ActionFields.isUserDropdownField({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'UserDropdown', @@ -569,6 +606,7 @@ describe('ActionFields', () => { it('should return false if the field is not a user input', () => { const result = ActionFields.isUserDropdownField({ type: 'Number', + id: 'Label', label: 'Label', watchChanges: false, widget: 'Dropdown', @@ -589,6 +627,7 @@ describe('ActionFields', () => { it('should return true if the field is an address autocomplete', () => { const result = ActionFields.isAddressAutocompleteField({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'AddressAutocomplete', @@ -600,6 +639,7 @@ describe('ActionFields', () => { it('should return false if the field is not an address autocomplete', () => { const result = ActionFields.isAddressAutocompleteField({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'Dropdown' as any, @@ -619,6 +659,7 @@ describe('ActionFields', () => { it('should return true if the field is a file picker', () => { const result = ActionFields.isFilePickerField({ type: 'File', + id: 'Label', label: 'Label', watchChanges: false, widget: 'FilePicker', @@ -636,6 +677,7 @@ describe('ActionFields', () => { it('should return false when passing another type of widget', () => { const result = ActionFields.isFilePickerField({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'Dropdown' as any, @@ -649,6 +691,7 @@ describe('ActionFields', () => { it('should return true when the field has a widget', () => { const result = ActionFields.hasWidget({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, // @ts-expect-error widget could be anything at this point @@ -661,6 +704,7 @@ describe('ActionFields', () => { it('should return false when the field has no widget', () => { const result = ActionFields.hasWidget({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, }); diff --git a/packages/agent/test/utils/forest-schema/action-values.test.ts b/packages/agent/test/utils/forest-schema/action-values.test.ts index 51a2ad3232..b78bbb102d 100644 --- a/packages/agent/test/utils/forest-schema/action-values.test.ts +++ b/packages/agent/test/utils/forest-schema/action-values.test.ts @@ -25,6 +25,7 @@ describe('ForestValueConverter', () => { test('should unserialize ids', () => { const fields = [ { + id: 'review', label: 'review', type: 'Collection' as const, watchChanges: false, @@ -39,9 +40,9 @@ describe('ForestValueConverter', () => { test('should unserialize files when relevant', () => { const fields = [ - { label: 'file', type: 'File' as const, watchChanges: false }, - { label: 'file2', type: 'String' as const, watchChanges: false }, - { label: 'file3', type: 'FileList' as const, watchChanges: false }, + { id: 'file', label: 'file', type: 'File' as const, watchChanges: false }, + { id: 'file2', label: 'file2', type: 'String' as const, watchChanges: false }, + { id: 'file3', label: 'file3', type: 'FileList' as const, watchChanges: false }, ]; const data = { @@ -188,6 +189,7 @@ describe('ForestValueConverter', () => { describe('valueToForest', () => { test('should check enums', () => { const field = { + id: 'label', label: 'label', type: 'Enum' as const, enumValues: ['a', 'b', 'c'], @@ -200,6 +202,7 @@ describe('ForestValueConverter', () => { test('should check enum lists', () => { const field = { + id: 'label', label: 'label', type: 'EnumList' as const, enumValues: ['a', 'b', 'c'], @@ -212,6 +215,7 @@ describe('ForestValueConverter', () => { test('should pack ids', () => { const field = { + id: 'label', label: 'label', type: 'Collection' as const, collectionName: 'reviews', @@ -222,7 +226,12 @@ describe('ForestValueConverter', () => { }); test('should make data uris', () => { - const field = { label: 'label', type: 'File' as const, watchChanges: false }; + const field = { + id: 'label', + label: 'label', + type: 'File' as const, + watchChanges: false, + }; expect( ForestValueConverter.valueToForest(field, { @@ -233,7 +242,12 @@ describe('ForestValueConverter', () => { }); test('should make data uris in lists', () => { - const field = { label: 'label', type: 'FileList' as const, watchChanges: false }; + const field = { + id: 'label', + label: 'label', + type: 'FileList' as const, + watchChanges: false, + }; expect( ForestValueConverter.valueToForest(field, [ diff --git a/packages/agent/test/utils/forest-schema/generator-action-field-widget.test.ts b/packages/agent/test/utils/forest-schema/generator-action-field-widget.test.ts index 055611737e..995888c730 100644 --- a/packages/agent/test/utils/forest-schema/generator-action-field-widget.test.ts +++ b/packages/agent/test/utils/forest-schema/generator-action-field-widget.test.ts @@ -5,6 +5,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return null when field has no widget', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, }); @@ -15,6 +16,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return null when the field type is Collection', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'Collection', + id: 'Label', label: 'Label', watchChanges: false, widget: 'Dropdown', @@ -26,6 +28,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return null when the field type is Enum', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'Enum', + id: 'Label', label: 'Label', watchChanges: false, widget: 'Dropdown', @@ -38,6 +41,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return null when the field type is EnumList', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'EnumList', + id: 'Label', label: 'Label', watchChanges: false, widget: 'Dropdown', @@ -51,6 +55,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid widget edit', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'Dropdown', @@ -81,6 +86,7 @@ describe('GeneratorActionFieldWidget', () => { it('should include the searchType="dynamic"', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'Dropdown', @@ -105,6 +111,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid configuration with default values', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'Dropdown', @@ -128,6 +135,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid widget edit', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'RadioGroup', @@ -153,6 +161,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid configuration with default values', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'RadioGroup', @@ -173,6 +182,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid widget edit', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'StringList', + id: 'Label', label: 'Label', watchChanges: false, widget: 'CheckboxGroup', @@ -198,6 +208,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid configuration with default values', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'StringList', + id: 'Label', label: 'Label', watchChanges: false, widget: 'CheckboxGroup', @@ -218,6 +229,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid widget edit', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'Boolean', + id: 'Label', label: 'Label', watchChanges: false, widget: 'Checkbox', @@ -234,6 +246,7 @@ describe('GeneratorActionFieldWidget', () => { it('should generate a default text input', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'TextInput', @@ -250,6 +263,7 @@ describe('GeneratorActionFieldWidget', () => { it('should add the placeholder if present', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'TextInput', @@ -269,6 +283,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid widget edit with default values', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'StringList', + id: 'Label', label: 'Label', watchChanges: false, widget: 'TextInputList', @@ -288,6 +303,7 @@ describe('GeneratorActionFieldWidget', () => { it('should pass the options to the widget', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'StringList', + id: 'Label', label: 'Label', watchChanges: false, widget: 'TextInputList', @@ -313,6 +329,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid and empty widget edit', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'TextArea', @@ -330,6 +347,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid widget edit with placeholder', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'TextArea', @@ -347,6 +365,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid widget edit with rows', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'TextArea', @@ -365,6 +384,7 @@ describe('GeneratorActionFieldWidget', () => { rows => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'TextArea', @@ -382,6 +402,7 @@ describe('GeneratorActionFieldWidget', () => { it('should round the rows value', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'TextArea', @@ -401,6 +422,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid widget edit', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'RichText', @@ -417,6 +439,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid widget edit with placeholder', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'RichText', @@ -435,6 +458,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid widget edit with default values', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'Number', + id: 'Label', label: 'Label', watchChanges: false, widget: 'NumberInput', @@ -454,6 +478,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid widget edit with placeholder', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'Number', + id: 'Label', label: 'Label', watchChanges: false, widget: 'NumberInput', @@ -471,6 +496,7 @@ describe('GeneratorActionFieldWidget', () => { it.each([undefined, null, 'foo'])(`should return null when ${parameter} is %s`, value => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'Number', + id: 'Label', label: 'Label', watchChanges: false, widget: 'NumberInput', @@ -490,6 +516,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid widget edit with default values', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'NumberList', + id: 'Label', label: 'Label', watchChanges: false, widget: 'NumberInputList', @@ -520,6 +547,7 @@ describe('GeneratorActionFieldWidget', () => { ({ property, value, resultProperty }) => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'NumberList', + id: 'Label', label: 'Label', watchChanges: false, widget: 'NumberInputList', @@ -539,6 +567,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid widget edit with default values', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'ColorPicker', @@ -559,6 +588,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid widget edit with default values', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'Number', + id: 'Label', label: 'Label', watchChanges: false, widget: 'CurrencyInput', @@ -582,6 +612,7 @@ describe('GeneratorActionFieldWidget', () => { it('should copy a valid value in parameters', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'Number', + id: 'Label', label: 'Label', watchChanges: false, widget: 'CurrencyInput', @@ -600,6 +631,7 @@ describe('GeneratorActionFieldWidget', () => { it('should ignore an invalid value and replace it by null', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'Number', + id: 'Label', label: 'Label', watchChanges: false, widget: 'CurrencyInput', @@ -619,6 +651,7 @@ describe('GeneratorActionFieldWidget', () => { it('should copy the placeholder', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'Number', + id: 'Label', label: 'Label', watchChanges: false, widget: 'CurrencyInput', @@ -640,6 +673,7 @@ describe('GeneratorActionFieldWidget', () => { base => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'Number', + id: 'Label', label: 'Label', watchChanges: false, widget: 'CurrencyInput', @@ -662,6 +696,7 @@ describe('GeneratorActionFieldWidget', () => { base => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'Number', + id: 'Label', label: 'Label', watchChanges: false, widget: 'CurrencyInput', @@ -682,6 +717,7 @@ describe('GeneratorActionFieldWidget', () => { it('should replace invalid values by Unit', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'Number', + id: 'Label', label: 'Label', watchChanges: false, widget: 'CurrencyInput', @@ -703,6 +739,7 @@ describe('GeneratorActionFieldWidget', () => { it('should copy a valid value in parameters', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'Number', + id: 'Label', label: 'Label', watchChanges: false, widget: 'CurrencyInput', @@ -720,6 +757,7 @@ describe('GeneratorActionFieldWidget', () => { it('should uppercase the currency', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'Number', + id: 'Label', label: 'Label', watchChanges: false, widget: 'CurrencyInput', @@ -739,6 +777,7 @@ describe('GeneratorActionFieldWidget', () => { currency => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'Number', + id: 'Label', label: 'Label', watchChanges: false, widget: 'CurrencyInput', @@ -760,6 +799,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid widget edit with default values', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'Date', + id: 'Label', label: 'Label', watchChanges: false, widget: 'DatePicker', @@ -780,6 +820,7 @@ describe('GeneratorActionFieldWidget', () => { it('should copy a valid value in parameters', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'Date', + id: 'Label', label: 'Label', watchChanges: false, widget: 'DatePicker', @@ -800,6 +841,7 @@ describe('GeneratorActionFieldWidget', () => { const date = new Date('2000-02-01T00:01:01.001Z'); const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'Date', + id: 'Label', label: 'Label', watchChanges: false, widget: 'DatePicker', @@ -817,6 +859,7 @@ describe('GeneratorActionFieldWidget', () => { it('should ignore an invalid value and replace it by null', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'Date', + id: 'Label', label: 'Label', watchChanges: false, widget: 'DatePicker', @@ -837,6 +880,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid widget edit with default values', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'Time', + id: 'Label', label: 'Label', watchChanges: false, widget: 'TimePicker', @@ -853,6 +897,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid widget edit with default values', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'Json', + id: 'Label', label: 'Label', watchChanges: false, widget: 'JsonEditor', @@ -869,6 +914,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid widget edit with default values', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'UserDropdown', @@ -885,6 +931,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid widget edit with placeholder mapped in parameters', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, placeholder: 'abc', @@ -904,6 +951,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid widget edit with default values', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'AddressAutocomplete', @@ -920,6 +968,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid widget edit with placeholder mapped in parameters', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, placeholder: 'abc', @@ -939,6 +988,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid widget edit with default values', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'File', + id: 'Label', label: 'Label', watchChanges: false, widget: 'FilePicker', @@ -958,6 +1008,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid widget edit with placeholder mapped in parameters', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'File', + id: 'Label', label: 'Label', watchChanges: false, widget: 'FilePicker', @@ -982,6 +1033,7 @@ describe('GeneratorActionFieldWidget', () => { expect(() => { GeneratorActionFieldWidget.buildWidgetOptions({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, // @ts-expect-error Unsupported widget @@ -993,6 +1045,7 @@ describe('GeneratorActionFieldWidget', () => { it('should return a valid widget with all the properties', () => { const result = GeneratorActionFieldWidget.buildWidgetOptions({ type: 'String', + id: 'Label', label: 'Label', watchChanges: false, widget: 'ColorPicker', 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 5ba26a097f..65b4b42201 100644 --- a/packages/agent/test/utils/forest-schema/generator-actions.test.ts +++ b/packages/agent/test/utils/forest-schema/generator-actions.test.ts @@ -43,6 +43,7 @@ describe('SchemaGeneratorActions', () => { }, [ { + id: 'label', label: 'label', description: 'email', type: 'String', @@ -86,6 +87,7 @@ describe('SchemaGeneratorActions', () => { }, [ { + id: 'label', label: 'label', description: 'email', type: 'String', @@ -115,6 +117,7 @@ describe('SchemaGeneratorActions', () => { }, [ { + id: 'author', label: 'author', description: 'choose an author', type: 'Collection', @@ -125,6 +128,7 @@ describe('SchemaGeneratorActions', () => { collectionName: 'authors', }, { + id: 'avatar', label: 'avatar', description: 'choose an avatar', type: 'File', @@ -134,6 +138,7 @@ describe('SchemaGeneratorActions', () => { watchChanges: false, }, { + id: 'inclusive gender', label: 'inclusive gender', description: 'Choose None, Male, Female or Both', type: 'EnumList', @@ -162,6 +167,7 @@ describe('SchemaGeneratorActions', () => { // Relation to other collection expect(schema.fields[0]).toMatchObject({ field: 'author', + label: 'author', reference: 'authors.primaryId', type: 'Uuid', // type of the pk }); @@ -196,6 +202,7 @@ describe('SchemaGeneratorActions', () => { }, [ { + id: 'title', label: 'title', description: 'updated title', type: 'String', @@ -214,6 +221,7 @@ describe('SchemaGeneratorActions', () => { expect(schema.fields[0]).toEqual({ field: 'title', + label: 'title', defaultValue: null, description: 'updated title', isReadOnly: false, @@ -233,6 +241,7 @@ describe('SchemaGeneratorActions', () => { }, [ { + id: 'format', label: 'format', description: 'new format', type: 'String', @@ -286,6 +295,7 @@ describe('SchemaGeneratorActions', () => { }, [ { + id: 'title', label: 'title', description: 'updated title', type: 'String', @@ -308,11 +318,13 @@ describe('SchemaGeneratorActions', () => { component: 'Row', fields: [ { + id: 'description', label: 'description', type: 'String', watchChanges: false, }, { + id: 'address', label: 'address', type: 'String', watchChanges: false, From c7f8d9e2ddec31661e7bc585a7572c699af63fe1 Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Wed, 18 Sep 2024 15:55:22 +0200 Subject: [PATCH 17/18] fix: tests --- packages/_example/src/forest/customizations/card.ts | 6 ++++++ .../test/routes/modification/action/action.test.ts | 12 +++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/_example/src/forest/customizations/card.ts b/packages/_example/src/forest/customizations/card.ts index 28cfdcacbe..98cd72ba98 100644 --- a/packages/_example/src/forest/customizations/card.ts +++ b/packages/_example/src/forest/customizations/card.ts @@ -43,6 +43,12 @@ export default (collection: CardCustomizer) => widget: 'Dropdown', options: ['Base', 'Gold', 'Black'], }, + { + type: 'String', + label: 'Plan', + widget: 'Dropdown', + options: ['Base', 'Gold', 'Black'], + }, { type: 'Layout', component: 'HtmlBlock', diff --git a/packages/agent/test/routes/modification/action/action.test.ts b/packages/agent/test/routes/modification/action/action.test.ts index ce6d921c5a..55c634a309 100644 --- a/packages/agent/test/routes/modification/action/action.test.ts +++ b/packages/agent/test/routes/modification/action/action.test.ts @@ -299,7 +299,9 @@ describe('ActionRoute', () => { actions: { MySingleAction: { scope: 'Single' } }, fields: { id: factories.columnSchema.uuidPrimaryKey().build() }, }, - getForm: jest.fn().mockResolvedValue([{ type: 'String', label: 'firstname' }]), + getForm: jest + .fn() + .mockResolvedValue([{ type: 'String', label: 'firstname', id: 'firstname' }]), execute: jest.fn().mockResolvedValue({ type: 'Error', message: 'the result does not matter', @@ -574,7 +576,7 @@ describe('ActionRoute', () => { ); expect(context.response.body).toEqual({ - fields: [{ field: 'firstname', type: 'String' }], + fields: [{ field: 'firstname', type: 'String', label: 'firstname' }], layout: [], }); }); @@ -583,7 +585,7 @@ describe('ActionRoute', () => { const context = createMockContext(baseContext); dataSource.getCollection('books').getForm = jest.fn().mockResolvedValue([ - { type: 'String', label: 'firstname' }, + { type: 'String', id: 'firstname', label: 'firstname' }, { type: 'Layout', component: 'Separator' }, ]); @@ -591,7 +593,7 @@ describe('ActionRoute', () => { await route.handleHook(context); expect(context.response.body).toEqual({ - fields: [{ field: 'firstname', type: 'String' }], + fields: [{ field: 'firstname', type: 'String', label: 'firstname' }], layout: [{ component: 'input', fieldId: 'firstname' }, { component: 'separator' }], }); }); @@ -641,7 +643,7 @@ describe('ActionRoute', () => { ); expect(context.response.body).toEqual({ - fields: [{ field: 'firstname', type: 'String' }], + fields: [{ field: 'firstname', type: 'String', label: 'firstname' }], layout: [], }); }); From 047f2810f43df78dc9d9000f5b02733d7d54d920 Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Thu, 19 Sep 2024 11:05:05 +0200 Subject: [PATCH 18/18] fix: don't export local types --- .../src/decorators/actions/collection.ts | 10 ++++++++-- .../src/decorators/actions/types/fields.ts | 6 ------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/datasource-customizer/src/decorators/actions/collection.ts b/packages/datasource-customizer/src/decorators/actions/collection.ts index b2d8b655f5..b94e49d85a 100644 --- a/packages/datasource-customizer/src/decorators/actions/collection.ts +++ b/packages/datasource-customizer/src/decorators/actions/collection.ts @@ -16,15 +16,21 @@ import ActionContextSingle from './context/single'; import ResultBuilder from './result-builder'; import { ActionBulk, ActionDefinition, ActionGlobal, ActionSingle } from './types/actions'; import { - DynamicFieldWithId, + DynamicField, DynamicFormElement, - DynamicFormElementWithId, + DynamicLayoutElement, Handler, SearchOptionsHandler, ValueOrHandler, } from './types/fields'; import { TSchema } from '../../templates'; +type DynamicFieldWithId = DynamicField & { id: string }; + +type DynamicFormElementWithId = + | DynamicFieldWithId + | DynamicLayoutElement; + export default class ActionCollectionDecorator extends CollectionDecorator { override readonly dataSource: DataSourceDecorator; diff --git a/packages/datasource-customizer/src/decorators/actions/types/fields.ts b/packages/datasource-customizer/src/decorators/actions/types/fields.ts index 908d120af5..4c8811754d 100644 --- a/packages/datasource-customizer/src/decorators/actions/types/fields.ts +++ b/packages/datasource-customizer/src/decorators/actions/types/fields.ts @@ -269,9 +269,3 @@ export type DynamicLayoutElement = export type DynamicFormElement = | DynamicField | DynamicLayoutElement; - -export type DynamicFieldWithId = DynamicField & { id: string }; - -export type DynamicFormElementWithId = - | DynamicFieldWithId - | DynamicLayoutElement;