UNPKG

@shopgate/engage

Version:
315 lines (297 loc) • 12.7 kB
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;