@shopgate/engage
Version:
Shopgate's ENGAGE library.
315 lines (297 loc) • 12.7 kB
JavaScript
import _inheritsLoose from "@babel/runtime/helpers/inheritsLoose";
import _camelCase from "lodash/camelCase";
var _Builder;
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import { logger } from '@shopgate/pwa-core/helpers';
import Portal from '@shopgate/pwa-common/components/Portal';
import { BEFORE, AFTER } from '@shopgate/pwa-common/constants/Portals';
import { I18n } from '@shopgate/engage/components';
import { Form } from '..';
import ActionListener from "./classes/ActionListener";
import { ELEMENT_TYPE_EMAIL, ELEMENT_TYPE_PASSWORD, ELEMENT_TYPE_TEXT, ELEMENT_TYPE_NUMBER, ELEMENT_TYPE_SELECT, ELEMENT_TYPE_COUNTRY, ELEMENT_TYPE_PROVINCE, ELEMENT_TYPE_CHECKBOX, ELEMENT_TYPE_RADIO, ELEMENT_TYPE_DATE, ELEMENT_TYPE_PHONE, ELEMENT_TYPE_PHONE_PICKER, ELEMENT_TYPE_MULTISELECT } from "./Builder.constants";
import ElementText from "./ElementText";
import ElementSelect from "./ElementSelect";
import ElementMultiSelect from "./ElementMultiSelect";
import ElementRadio from "./ElementRadio";
import ElementCheckbox from "./ElementCheckbox";
import ElementPhoneNumber from "./ElementPhoneNumber";
import buildFormElements from "./helpers/buildFormElements";
import buildFormDefaults from "./helpers/buildFormDefaults";
import buildCountryList from "./helpers/buildCountryList";
import buildProvinceList from "./helpers/buildProvinceList";
import buildValidationErrorList from "./helpers/buildValidationErrorList";
import { sanitizePortalName } from "./helpers/common";
/**
* Optional select element
* @type {Object}
*/
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
const emptySelectOption = {
'': ''
};
/**
* Takes a form configuration and handles rendering and updates of the form fields.
* Note: Only one country and one province element is supported per FormBuilder instance.
*/
let Builder = /*#__PURE__*/function (_Component) {
/**
* Initializes the component.
* @param {Object} props The components props.
*/
function Builder(props) {
var _this;
_this = _Component.call(this, props) || this;
// Prepare internal state
/**
* Retrieves a form element REACT component by the given type or null if the type is unknown.
* @param {string} type The type value of the element to return.
* @returns {*|ElementText|ElementSelect|ElementCheckbox|ElementRadio|null}
*/
_this.getFormElementComponent = type => _this.props.elements[type] || Builder.defaultElements[type] || null;
/**
* Sorts the elements by "sortOrder" property
*
* @typedef {Object} FormElement
* @property {number} sortOrder
*
* @param {FormElement} element1 First element
* @param {FormElement} element2 Second element
* @returns {number}
*/
_this.elementSortFunc = (element1, element2) => {
// Keep current sort order when no specific sort order was set for both
if (element1.sortOrder === undefined || element2.sortOrder === undefined) {
return 0;
}
// Sort in ascending order of sortOrder otherwise
return element1.sortOrder - element2.sortOrder;
};
/**
* Element change handler based on it's type. It takes a state change and performs form actions on
* in to allow customization. The final result is then written to the component state.
* @param {string} elementId Element to create the handler for
* @param {string} value Element value
*/
_this.elementChangeHandler = (elementId, value) => {
// "newState" is the state changes before any form actions have been applied
const newState = {
..._this.state,
formData: {
..._this.state.formData,
[elementId]: value
}
};
// Handle context sensitive functionality by via "action" listener and use the "new" state
const updatedNewState = _this.actionListener.notify(elementId, _this.state, newState);
// Form actions can append validation errors by adding that field to the new state
// Split out validation errors from final state and
const {
validationErrors = {},
...finalState
} = updatedNewState;
// "hasErrors" is true, when a visible + required field is empty or validation errors appeared!
let hasErrors = Object.keys(validationErrors).length > 0;
// Check "required" fields for all visible elements and enable rendering on changes
_this.formElements.forEach(formElement => {
if (!finalState.elementVisibility[formElement.id] || !formElement.required) {
return;
}
const tmpVal = finalState.formData[formElement.id];
const tmpResult = tmpVal === null || tmpVal === undefined || tmpVal === '' || tmpVal === false;
hasErrors = hasErrors || tmpResult;
});
// Handle state internally and send an "onChange" event to parent if this finished
_this.setState(finalState);
// Transform to external structure (unavailable ones will be set undefined)
const updateData = {};
_this.formElements.forEach(el => {
if (el.custom) {
if (updateData.customAttributes === undefined) {
updateData.customAttributes = {};
}
updateData.customAttributes[el.id] = finalState.formData[el.id];
} else {
updateData[el.id] = finalState.formData[el.id];
}
});
// Trigger the given update action of the parent and provide all new validation errors to it
_this.props.handleUpdate(updateData, hasErrors,
// Output validation errors in the same structure (array) as the component takes them
hasErrors ? Object.keys(validationErrors).map(k => ({
path: k,
message: validationErrors[k]
})) : []);
};
/**
* Takes an element of any type and renders it depending on type.
* Also puts portals around the element.
* @param {string} formName Name of the form
* @param {Object} element The data of the element to be rendered
* @param {string} elementErrorText The error text to be shown for this specific element
* @returns {JSX.Element}
*/
_this.renderElement = (formName, element, elementErrorText) => {
const {
formData
} = _this.state;
const elementName = `${_this.props.name}_${element.id}`;
const elementValue = formData[element.id];
const elementVisible = _this.state.elementVisibility[element.id] || false;
// Take a dynamic REACT element based on its type
const Element = _this.getFormElementComponent(element.type);
if (!Element) {
logger.error(`Unknown form element type: ${element.type}`);
return null;
}
// Country and province elements have their data injected, if not already present
const elementData = element;
switch (element.type) {
case ELEMENT_TYPE_COUNTRY:
{
elementData.options = element.options || _this.countryList;
break;
}
case ELEMENT_TYPE_PROVINCE:
{
// Province selection only makes sense with a country being selected, or from custom options
const countryElement = _this.formElements.find(el => el.type === ELEMENT_TYPE_COUNTRY);
elementData.options = countryElement && formData[countryElement.id] ? buildProvinceList(formData[countryElement.id],
// Auto-select with "empty" when not required
element.required ? null : emptySelectOption) : {};
break;
}
default:
break;
}
return /*#__PURE__*/_jsx(Element, {
name: elementName,
element: elementData,
errorText: elementErrorText,
value: elementValue,
visible: elementVisible,
formName: formName
});
};
_this.state = {
elementVisibility: {},
formData: {}
};
// Reorganize form elements into a structure that can be easily rendered
const formElements = buildFormElements(props.config, _this.elementChangeHandler);
// Compute defaults
const formDefaults = buildFormDefaults(formElements, props.defaults);
// Assign defaults to state
_this.state.formData = formDefaults;
// Handle fixed visibilities
formElements.forEach(element => {
// Assume as visible except it's explicitly set to "false"
_this.state.elementVisibility[element.id] = element.visible !== false;
});
_this.actionListener = new ActionListener(buildProvinceList, formDefaults);
_this.actionListener.attachAll(formElements);
// Sort the elements after attaching action listeners to keep action hierarchy same as creation
_this.formElements = formElements.sort(_this.elementSortFunc);
// Assemble combined country/province list based on the config element
const _countryElement = _this.formElements.find(el => el.type === ELEMENT_TYPE_COUNTRY);
if (_countryElement) {
_this.countryList = buildCountryList(_countryElement, emptySelectOption);
const provinceElement = _this.formElements.find(el => el.type === ELEMENT_TYPE_PROVINCE);
if (provinceElement && provinceElement.required && !!formDefaults[_countryElement.id] && !formDefaults[provinceElement.id]) {
// Set default for province field for given country
const [first] = Object.keys(buildProvinceList(formDefaults[_countryElement.id]));
if (first) {
_this.state.formData[provinceElement.id] = first;
}
}
}
// Final form initialization, by triggering actionListeners and enable rendering for elements
let _newState = _this.state;
_this.formElements.forEach(element => {
_newState = _this.actionListener.notify(element.id, _this.state, _newState);
});
_this.state = _newState;
return _this;
}
_inheritsLoose(Builder, _Component);
var _proto = Builder.prototype;
/**
* Renders the component based on the given config
* @return {JSX.Element}
*/
_proto.render = function render() {
const {
name,
className,
onSubmit
} = this.props;
// Convert validation errors for easier handling
const validationErrors = buildValidationErrorList(this.props.validationErrors);
const validationErrorsAmount = Object.entries(validationErrors).length;
return /*#__PURE__*/_jsxs(Form, {
className: _camelCase(name),
onSubmit: onSubmit,
children: [validationErrorsAmount > 0 && /*#__PURE__*/_jsx("div", {
className: "sr-only",
children: /*#__PURE__*/_jsx(I18n.Text, {
string: "login.errorAmount",
params: {
amount: validationErrorsAmount
}
})
}), /*#__PURE__*/_jsx("div", {
className: className,
children: this.formElements.map(element => /*#__PURE__*/_jsxs(Fragment, {
children: [/*#__PURE__*/_jsx(Portal, {
name: `${sanitizePortalName(name)}.${sanitizePortalName(element.id)}.${BEFORE}`,
props: {
formName: name,
errorText: validationErrors[element.id] || '',
element
}
}), /*#__PURE__*/_jsx(Portal, {
name: `${sanitizePortalName(name)}.${sanitizePortalName(element.id)}`,
props: {
formName: name,
errorText: validationErrors[element.id] || '',
element
},
children: this.renderElement(name, element, validationErrors[element.id] || '')
}), /*#__PURE__*/_jsx(Portal, {
name: `${sanitizePortalName(name)}.${sanitizePortalName(element.id)}.${AFTER}`,
props: {
formName: name,
errorText: validationErrors[element.id] || '',
element
}
})]
}, `${name}_${element.id}`))
})]
});
};
return Builder;
}(Component);
_Builder = Builder;
Builder.defaultElements = {
[ELEMENT_TYPE_EMAIL]: ElementText,
[ELEMENT_TYPE_PASSWORD]: ElementText,
[ELEMENT_TYPE_TEXT]: ElementText,
[ELEMENT_TYPE_NUMBER]: ElementText,
[ELEMENT_TYPE_SELECT]: ElementSelect,
[ELEMENT_TYPE_MULTISELECT]: ElementMultiSelect,
[ELEMENT_TYPE_COUNTRY]: ElementSelect,
[ELEMENT_TYPE_PROVINCE]: ElementSelect,
[ELEMENT_TYPE_CHECKBOX]: ElementCheckbox,
[ELEMENT_TYPE_RADIO]: ElementRadio,
[ELEMENT_TYPE_DATE]: ElementText,
[ELEMENT_TYPE_PHONE]: ElementText,
[ELEMENT_TYPE_PHONE_PICKER]: ElementPhoneNumber
};
Builder.defaultProps = {
className: null,
defaults: {},
elements: _Builder.defaultElements,
onSubmit: () => {},
validationErrors: []
};
export default Builder;