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: support layout elements in smart action form hooks #1037

Merged
merged 14 commits into from
Oct 16, 2024
1 change: 1 addition & 0 deletions src/context/build-services.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,6 @@ module.exports.default = (context) =>
.addUsingClass('oidcClientManagerService', () => require('../services/oidc-client-manager'))
.addUsingClass('authenticationService', () => require('../services/authentication'))
.addUsingClass('smartActionFieldValidator', () => require('../services/smart-action-field-validator'))
.addUsingClass('smartActionFormLayoutService', () => require('../services/smart-action-form-layout-service'))
.addUsingClass('smartActionHookService', () => require('../services/smart-action-hook-service'))
.addUsingClass('smartActionHookDeserializer', () => require('../deserializers/smart-action-hook'));
9 changes: 4 additions & 5 deletions src/routes/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,13 @@ class Actions {
getHookLoadController(action) {
return async (request, response) => {
try {
const loadedFields = await this.smartActionHookService.getResponse(
const hookResponse = await this.smartActionHookService.getResponse(
action,
action.hooks.load,
action.fields,
request,
);

return response.status(200).send({ fields: loadedFields });
return response.status(200).send(hookResponse);
} catch (error) {
this.logger.error('Error in smart load action hook: ', error);
return response.status(500).send({ message: error.message });
Expand All @@ -58,15 +57,15 @@ class Actions {
const { fields, changedField } = data;
const fieldChanged = fields.find((field) => field.field === changedField);

const updatedFields = await this.smartActionHookService.getResponse(
const hookResponse = await this.smartActionHookService.getResponse(
action,
action.hooks.change[fieldChanged?.hook],
fields,
request,
fieldChanged,
);

return response.status(200).send({ fields: updatedFields });
return response.status(200).send(hookResponse);
} catch (error) {
this.logger.error('Error in smart action change hook: ', error);
return response.status(500).send({ message: error.message });
Expand Down
94 changes: 94 additions & 0 deletions src/services/smart-action-form-layout-service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
function lowerCaseFirstLetter(string) {
return string.charAt(0).toLowerCase() + string.slice(1);
}

const subElementsKey = { Row: 'fields', Page: 'elements' };
const validLayoutComponents = ['Row', 'Page', 'Separator', 'HtmlBlock'];
class SmartActionFormLayoutService {
static validateLayoutElement(element) {
Thenkei marked this conversation as resolved.
Show resolved Hide resolved
realSpok marked this conversation as resolved.
Show resolved Hide resolved
if (!validLayoutComponents.includes(element.component)) throw new Error(`${element.component} is not a valid component. Valid components are ${validLayoutComponents.join(' or ')}`);
if (element.component === 'Page') {
realSpok marked this conversation as resolved.
Show resolved Hide resolved
if (!Array.isArray(element.elements)) {
throw new Error('Page components must contain an array of fields or layout elements in property \'elements\'');
}
if (element.elements.some((innerElement) => innerElement.component === 'Page')) {
throw new Error('Pages cannot contain other pages');
}
}
if (element.component === 'Row') {
realSpok marked this conversation as resolved.
Show resolved Hide resolved
if (!Array.isArray(element.fields)) {
throw new Error('Row components must contain an array of fields in property \'fields\'');
}
if (element.fields.some((field) => field.type === 'Layout')) {
throw new Error('Row components can only contain fields');
}
}
}

static parseLayout(
Thenkei marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is kinda a duplicate from Agent Node.js ? Maybe it could be good to add a link to the code in the Node.js agent.

element,
allFields,
) {
if (element.type === 'Layout') {
SmartActionFormLayoutService.validateLayoutElement(element);

if (['Row', 'Page'].includes(element.component)) {
const key = subElementsKey[element.component];
const subElements = element[key].map(
(field) => SmartActionFormLayoutService.parseLayout(field, allFields),
);

return {
...element,
component: lowerCaseFirstLetter(element.component),
[key]: subElements,
};
}

return {
...element,
component: lowerCaseFirstLetter(element.component),
};
}

allFields.push(element);

return {
type: 'Layout',
component: 'input',
fieldId: element.field,
};
}

// eslint-disable-next-line class-methods-use-this
extractFieldsAndLayout(formElements) {
// same logic as in v2 agent
// https://github.com/ForestAdmin/agent-nodejs/blob/chore/demo-form-customization/packages/agent/src/utils/forest-schema/generator-actions.ts#L188
let hasLayout = false;
const fields = [];
let layout = [];

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

const isFirstElementPage = formElements[0].component === 'Page';

formElements.forEach((element) => {
if (element.type === 'Layout') {
hasLayout = true;
}
if ((isFirstElementPage && element.component !== 'Page')
|| (!isFirstElementPage && element.component === 'Page')) {
throw new Error('You cannot use pages and other elements at the same level');
}
layout.push(SmartActionFormLayoutService.parseLayout(element, fields));
});

if (!hasLayout) {
layout = [];
}

return { fields, layout };
}
}

module.exports = SmartActionFormLayoutService;
14 changes: 9 additions & 5 deletions src/services/smart-action-hook-service.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
class SmartActionHookService {
constructor({ setFieldWidget, smartActionFieldValidator }) {
constructor({ setFieldWidget, smartActionFieldValidator, smartActionFormLayoutService }) {
this.setFieldWidget = setFieldWidget;
this.smartActionFieldValidator = smartActionFieldValidator;
this.smartActionFormLayoutService = smartActionFormLayoutService;
}

/**
Expand Down Expand Up @@ -32,13 +33,16 @@ class SmartActionHookService {
if (typeof hook !== 'function') throw new Error('hook must be a function');

// Call the user-defined load hook.
const result = await hook({ request, fields: fieldsForUser, changedField });
const hookResult = await hook({ request, fields: fieldsForUser, changedField });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the hookResult json form ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the result looks like that:
{field: [...], layout: [...]}


if (!(result && Array.isArray(result))) {
if (!(hookResult && Array.isArray(hookResult))) {
throw new Error('hook must return an array');
}

return result.map((field) => {
const { fields: fieldHookResult, layout } = this.smartActionFormLayoutService
.extractFieldsAndLayout(hookResult);

const validFields = fieldHookResult.map((field) => {
this.smartActionFieldValidator.validateField(field, action.name);
this.smartActionFieldValidator
.validateFieldChangeHook(field, action.name, action.hooks?.change);
Expand All @@ -59,9 +63,9 @@ class SmartActionHookService {
return { ...field, value: null };
}
}

return field;
});
return { fields: validFields, layout };
}
}

Expand Down
4 changes: 2 additions & 2 deletions test/routes/actions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ describe('routes > actions', () => {
const newFields = [{ field: 'invoice number', type: 'String', value: 'hello from load' }];

const load = jest.fn();
const smartActionHookGetResponse = jest.fn(() => newFields);
const smartActionHookGetResponse = jest.fn(() => ({ fields: newFields }));
const { send, response } = await callHook({ load }, smartActionHookGetResponse);

expect(response.status).toHaveBeenNthCalledWith(1, 200);
Expand Down Expand Up @@ -234,7 +234,7 @@ describe('routes > actions', () => {
previousValue: 'a',
}];

const smartActionHookGetResponse = jest.fn(() => newFields);
const smartActionHookGetResponse = jest.fn(() => ({ fields: newFields }));
const field = {
field: 'foo',
type: 'String',
Expand Down
Loading
Loading