Skip to content

Commit

Permalink
feat: support layout elements in smart action form hooks (#1037)
Browse files Browse the repository at this point in the history
* feat: support layout elements in smart action form hooks

* test: fix action test

* fix: validation

* fix: test

* test: add tests

* refactor: improve code

* refactor: improve code

* feat: prevent pages in pages

* refactor: review suggestions

* style: review suggestion

Co-authored-by: Morgan PERRE <morganperre@gmail.com>

* chore: document logic

* test: improve test

* fix: review suggestions

* refactor: remove useless spread

---------

Co-authored-by: Nicolas Moreau <nicolas.moreau76@gmail.com>
Co-authored-by: Morgan PERRE <morganperre@gmail.com>
  • Loading branch information
3 people authored Oct 16, 2024
1 parent 92a05cb commit 20635ab
Show file tree
Hide file tree
Showing 7 changed files with 513 additions and 21 deletions.
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) {
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') {
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') {
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(
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 });

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

0 comments on commit 20635ab

Please sign in to comment.