Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add separator in action forms #1167

Merged
merged 8 commits into from
Sep 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions packages/agent/src/routes/modification/action/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,16 @@ 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,
filterForCaller,
{ 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);
Expand Down Expand Up @@ -158,18 +160,17 @@ 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,
});

context.response.body = {
fields: fields.map(field =>
SchemaGeneratorActions.buildFieldSchema(this.collection.dataSource, field),
),
};
context.response.body = SchemaGeneratorActions.buildFieldsAndLayout(
this.collection.dataSource,
form,
);
}

private async middlewareCustomActionApprovalRequestData(context: Context, next: Next) {
Expand Down
100 changes: 74 additions & 26 deletions packages/agent/src/utils/forest-schema/generator-actions.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -46,7 +51,14 @@ 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;

if (schema.staticForm) {
const rawForm = await collection.getForm(null, name, null, null);
fields = SchemaGeneratorActions.buildFieldsAndLayout(collection.dataSource, rawForm).fields;

SchemaGeneratorActions.setFieldsDefaultValue(fields);
}

return {
id: `${collection.name}-${actionIndex}-${slug}`,
Expand All @@ -60,13 +72,28 @@ export default class SchemaGeneratorActions {
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;
Expand Down Expand Up @@ -99,31 +126,52 @@ export default class SchemaGeneratorActions {
return output as ForestServerActionField;
}

private static async buildFields(
collection: Collection,
name: string,
schema: ActionSchema,
): Promise<ForestServerActionField[]> {
// 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[] = [];

if (!formElements) return { fields: [], layout: [] };

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 };
}
}
27 changes: 25 additions & 2 deletions packages/agent/test/routes/modification/action/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,7 +573,27 @@ 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 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 () => {
Expand Down Expand Up @@ -620,7 +640,10 @@ describe('ActionRoute', () => {
},
);

expect(context.response.body).toEqual({ fields: [{ field: 'firstname', type: 'String' }] });
expect(context.response.body).toEqual({
fields: [{ field: 'firstname', type: 'String' }],
layout: [],
});
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand Down
62 changes: 62 additions & 0 deletions packages/agent/test/utils/forest-schema/generator-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -132,6 +133,10 @@ describe('SchemaGeneratorActions', () => {
value: null,
watchChanges: false,
},
{
type: 'Layout',
component: 'Separator',
},
{
label: 'inclusive gender',
description: 'Choose None, Male, Female or Both',
Expand Down Expand Up @@ -177,6 +182,9 @@ describe('SchemaGeneratorActions', () => {
type: ['Enum'],
enums: ['Male', 'Female'],
});

// no layout in schema, only in hooks response
expect(schema.layout).toEqual(undefined);
});
});

Expand Down Expand Up @@ -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',
},
]);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
ActionField,
ActionFormElement,
ActionResult,
Aggregation,
Caller,
Expand Down Expand Up @@ -73,7 +73,11 @@ export default class RelaxedCollection<
return this.collection.execute(this.caller, name, formValues, filterInstance);
}

getForm(name: string, formValues?: RecordData, filter?: TFilter<S, N>): Promise<ActionField[]> {
getForm(
name: string,
formValues?: RecordData,
filter?: TFilter<S, N>,
): Promise<ActionFormElement[]> {
const filterInstance = this.buildFilter(filter);

return this.collection.getForm(this.caller, name, formValues, filterInstance);
Expand Down
Loading
Loading