diff --git a/src/components/Field.jsx b/src/components/Field.jsx index 8e52cc9e..d2c69e5d 100644 --- a/src/components/Field.jsx +++ b/src/components/Field.jsx @@ -26,32 +26,61 @@ const messages = defineMessages({ }, }); +const widgetMapping = { + single_choice: RadioWidget, + checkbox: CheckboxWidget, +}; + /** * Field class. * @class View * @extends Component */ -const Field = ({ - label, - description, - name, - field_type, - required, - input_values, - value, - onChange, - isOnEdit, - valid, - disabled = false, - formHasErrors = false, - id, -}) => { +const Field = (props) => { + const { + label, + description, + name, + field_type, + required, + input_values, + value, + onChange, + isOnEdit, + valid, + disabled = false, + formHasErrors = false, + id, + widget, + } = props; const intl = useIntl(); const isInvalid = () => { return !isOnEdit && !valid; }; + if (widget) { + const Widget = widgetMapping[widget]; + const valueList = + field_type === 'yes_no' + ? [ + { value: true, label: 'Yes' }, + { value: false, label: 'No' }, + ] + : [...(input_values?.map((v) => ({ value: v, label: v })) ?? [])]; + + return ( + + ); + } + return (
{field_type === 'text' && ( @@ -136,7 +165,7 @@ const Field = ({ {...(isInvalid() ? { className: 'is-invalid' } : {})} /> )} - {field_type === 'checkbox' && ( + {(field_type === 'yes_no' || field_type === 'checkbox') && ( { +export const FromSchemaExtender = ({ intl }) => { return { fields: ['use_as_reply_to', 'use_as_bcc'], properties: { diff --git a/src/components/FieldTypeSchemaExtenders/HiddenSchemaExtender.js b/src/components/FieldTypeSchemaExtenders/HiddenSchemaExtender.js index 48e89c6a..714096f6 100644 --- a/src/components/FieldTypeSchemaExtenders/HiddenSchemaExtender.js +++ b/src/components/FieldTypeSchemaExtenders/HiddenSchemaExtender.js @@ -6,7 +6,7 @@ const messages = defineMessages({ }, }); -export const HiddenSchemaExtender = (intl) => { +export const HiddenSchemaExtender = ({ intl }) => { return { fields: ['value'], properties: { diff --git a/src/components/FieldTypeSchemaExtenders/SelectionSchemaExtender.js b/src/components/FieldTypeSchemaExtenders/SelectionSchemaExtender.js index 98488aa2..567f1316 100644 --- a/src/components/FieldTypeSchemaExtenders/SelectionSchemaExtender.js +++ b/src/components/FieldTypeSchemaExtenders/SelectionSchemaExtender.js @@ -6,7 +6,7 @@ const messages = defineMessages({ }, }); -export const SelectionSchemaExtender = (intl) => { +export const SelectionSchemaExtender = ({ intl }) => { return { fields: ['input_values'], properties: { diff --git a/src/components/FieldTypeSchemaExtenders/YesNoSchemaExtender.js b/src/components/FieldTypeSchemaExtenders/YesNoSchemaExtender.js new file mode 100644 index 00000000..9bde3931 --- /dev/null +++ b/src/components/FieldTypeSchemaExtenders/YesNoSchemaExtender.js @@ -0,0 +1,66 @@ +import { defineMessages } from 'react-intl'; +const messages = defineMessages({ + field_widget: { + id: 'form_field_widget', + defaultMessage: 'Widget', + }, + display_values_title: { + id: 'form_field_display_values_title', + defaultMessage: 'Display values as', + }, + display_values_description: { + id: 'form_field_display_values_description', + defaultMessage: + 'Change how values appear in forms and emails. Data stores and sent, such as CSV exports and XML attachments, will remain unchanged.', + }, +}); + +function InternalValueSchema() { + return { + title: 'Test', + fieldsets: [ + { + id: 'default', + title: 'Default', + fields: ['yes', 'no'], + }, + ], + properties: { + yes: { + title: 'True', + placeholder: 'Yes', + default: 'Yes', + }, + no: { + title: 'False', + placeholder: 'No', + default: 'No', + }, + }, + }; +} + +export const YesNoSchemaExtender = ({ intl, formData }) => { + return { + fields: ['widget', 'display_values'], + properties: { + widget: { + title: intl.formatMessage(messages.field_widget), + type: 'string', + choices: [ + ['checkbox', 'Checkbox'], + ['single_choice', 'Radio'], + ], + default: 'checkbox', + }, + display_values: { + title: 'Display values as', + description: '', + widget: 'object', + schema: InternalValueSchema(), + collapsible: true, + }, + }, + required: ['widget'], + }; +}; diff --git a/src/components/FieldTypeSchemaExtenders/index.js b/src/components/FieldTypeSchemaExtenders/index.js index 81e0302f..a8874e6b 100644 --- a/src/components/FieldTypeSchemaExtenders/index.js +++ b/src/components/FieldTypeSchemaExtenders/index.js @@ -1,3 +1,4 @@ export { SelectionSchemaExtender } from './SelectionSchemaExtender'; export { FromSchemaExtender } from './FromSchemaExtender'; export { HiddenSchemaExtender } from './HiddenSchemaExtender'; +export { YesNoSchemaExtender } from './YesNoSchemaExtender'; diff --git a/src/components/FormView.jsx b/src/components/FormView.jsx index db65cea1..3f13bc6e 100644 --- a/src/components/FormView.jsx +++ b/src/components/FormView.jsx @@ -10,6 +10,7 @@ import { } from 'semantic-ui-react'; import { getFieldName } from 'volto-form-block/components/utils'; import Field from 'volto-form-block/components/Field'; +import { showWhenValidator } from 'volto-form-block/helpers/show_when'; import config from '@plone/volto/registry'; /* Style */ @@ -54,7 +55,7 @@ const FormView = ({ const FieldSchema = config.blocks.blocksConfig.form.fieldSchema; const isValidField = (field) => { - return formErrors?.indexOf(field) < 0; + return !formErrors.hasOwnProperty(field); }; return ( @@ -108,8 +109,8 @@ const FormView = ({ value={field.value} onChange={() => {}} disabled - valid - formHasErrors={formErrors?.length > 0} + valid={isValidField} + formHasErrors={!!formErrors[field.name]} /> @@ -134,6 +135,30 @@ const FormView = ({ }), ); + const value = + subblock.field_type === 'static_text' + ? subblock.value + : formData[name]?.value; + const { show_when, target_value } = subblock; + + const shouldShowValidator = showWhenValidator[show_when]; + const shouldShowTargetValue = + formData[subblock.target_field]?.value; + + // Only checking for false here to preserve backwards compatibility with blocks that haven't been updated and so have a value of 'undefined' or 'null' + const shouldShow = shouldShowValidator + ? shouldShowValidator({ + value: shouldShowTargetValue, + target_value: target_value, + }) !== false + : true; + + const shouldHide = __CLIENT__ && !shouldShow; + + if (shouldHide) { + return

Empty

; + } + return ( @@ -148,20 +173,17 @@ const FormView = ({ fields_to_send_with_value, ) } - value={ - subblock.field_type === 'static_text' - ? subblock.value - : formData[name]?.value - } + value={value} valid={isValidField(name)} - formHasErrors={formErrors?.length > 0} + errors={formErrors[name]} + formHasErrors={Object.keys(formErrors).length > 0} // TODO: Deprecate legacy prop /> ); })} {captcha.render()} - {formErrors.length > 0 && ( + {Object.keys(formErrors).length > 0 && ( {intl.formatMessage(messages.error)} diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index 7edf0b2f..e2696cd3 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -191,7 +191,7 @@ const Sidebar = ({ { var update_values = {}; diff --git a/src/components/View.jsx b/src/components/View.jsx index f4930570..d263ffe0 100644 --- a/src/components/View.jsx +++ b/src/components/View.jsx @@ -1,19 +1,29 @@ -import React, { useState, useEffect, useReducer, useRef } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; +import { formatDate } from '@plone/volto/helpers/Utils/Date'; +import config from '@plone/volto/registry'; import PropTypes from 'prop-types'; -import { useIntl, defineMessages } from 'react-intl'; +import React, { useEffect, useReducer, useRef, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useDispatch, useSelector } from 'react-redux'; import { submitForm } from 'volto-form-block/actions'; -import { getFieldName } from 'volto-form-block/components/utils'; import FormView from 'volto-form-block/components/FormView'; -import { formatDate } from '@plone/volto/helpers/Utils/Date'; -import config from '@plone/volto/registry'; import { Captcha } from 'volto-form-block/components/Widget'; +import { getFieldName } from 'volto-form-block/components/utils'; + +import { showWhenValidator } from 'volto-form-block/helpers/show_when'; const messages = defineMessages({ formSubmitted: { id: 'formSubmitted', defaultMessage: 'Form successfully submitted', }, + field_is_required: { + id: 'field_is_required', + defaultMessage: '{fieldLabel} is required', + }, + error: { + id: 'There is a problem submitting your form', + defaultMessage: 'There is a problem submitting your form', + }, }); const initialState = { @@ -95,23 +105,33 @@ const View = ({ data, id, path }) => { }, getInitialData(data)); const [formState, setFormState] = useReducer(formStateReducer, initialState); - const [formErrors, setFormErrors] = useState([]); + const [formErrors, setFormErrors] = useState({}); const submitResults = useSelector((state) => state.submitForm); const captchaToken = useRef(); + const formid = `form-${id}`; const onChangeFormData = (field_id, field, value, extras) => { - setFormData({ field, value: { field_id, value, ...extras } }); + setFormData({ + field, + value: { + field_id, + value, + ...(data[field_id] && { custom_field_id: data[field_id] }), // Conditionally add the key. Nicer to work with than having a key with a null value + ...extras, + }, + }); }; useEffect(() => { - if (formErrors.length > 0) { + // We have a form state updater already going on, why do we need to separately update the state again? + if (Object.keys(formErrors).length > 0) { isValidForm(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [formData]); const isValidForm = () => { - const v = []; + const v = {}; data.subblocks.forEach((subblock, index) => { const name = getFieldName(subblock.label, subblock.id); const fieldType = subblock.field_type; @@ -119,34 +139,118 @@ const View = ({ data, id, path }) => { config.blocks.blocksConfig.form.additionalFields?.filter( (f) => f.id === fieldType && f.isValid !== undefined, )?.[0] ?? null; - if ( - subblock.required && - additionalField && - !additionalField?.isValid(formData, name) - ) { - v.push(name); - } else if ( - subblock.required && - fieldType === 'checkbox' && - !formData[name]?.value - ) { - v.push(name); - } else if ( - subblock.required && - (!formData[name] || - formData[name]?.value?.length === 0 || - JSON.stringify(formData[name]?.value ?? {}) === '{}') - ) { - v.push(name); + const fieldErrors = v[name] ? { required: v[name] } : {}; + + debugger; + + // TODO: Below 'show when' logic copied from `FormView.jsx`. Should wrap this in a single function. + const { show_when_when, show_when_is, show_when_to } = subblock; + const targetField = data.subblocks.find( + (block) => block.id === show_when_when, + ); + const targetFieldName = targetField + ? getFieldName(targetField.label, targetField.id) + : null; + const shouldShowValidator = + show_when_when === 'always' + ? showWhenValidator['always'] + : showWhenValidator[show_when_is]; + const shouldShowTargetValue = formData[targetFieldName]?.value; + + // Only checking for false here to preserve backwards compatibility with blocks that haven't been updated and so have a value of 'undefined' or 'null' + const shouldShow = shouldShowValidator + ? shouldShowValidator({ + value: shouldShowTargetValue, + target_value: show_when_to, + }) !== false + : true; + const hasDynamicVisibility = + shouldShowValidator && targetField && show_when_to; + + // TODO: Abstract all of this into a single 'field' definition where each fields defines it's own rules. + if (subblock.required) { + let fieldIsRequired = true; + const fieldData = formData[name]; + // Required field has a value + if (fieldData) { + if (fieldData?.hasOwnProperty('value')) { + if (![undefined, null].includes(fieldData.value)) { + fieldIsRequired = false; + } + } else { + fieldIsRequired = false; + } + } else if (hasDynamicVisibility && !shouldShow) { + fieldIsRequired = false; + } + // Some field types can't be required. TODO: Make these field types use `isValid` + else if (fieldType === 'static_text' || fieldType === 'hidden') { + fieldIsRequired = false; + } + // Additional fields have their own validation + else if (additionalField && !additionalField?.isValid(formData, name)) { + fieldIsRequired = false; + } + // Checkboxes have their value stored slightly differently + else if (fieldType === 'checkbox' && !fieldData?.value) { + fieldIsRequired = false; + } + // List/ multi-option handling + else if ( + (fieldData?.value && fieldData.value.length === 0) || + (typeof fieldData?.value === 'object' && + JSON.stringify(fieldData?.value) === '{}') + ) { + fieldIsRequired = false; + } + // Required yes/ no fields with a radio widget should still be able to select "No" as the value, unlike single-checkbox widgets + else if ( + fieldType === 'yes_no' && + subblock.widget === 'single_choice' && + !fieldData + ) { + fieldIsRequired = true; + } + // Default value handling. Boolean check is for Yes/ no fields + if ( + Boolean( + !fieldData && + (subblock.default_value || + typeof subblock.default_value === 'boolean'), + ) + ) { + fieldIsRequired = false; + } + + if (fieldIsRequired) { + fieldErrors['required'] = intl.formatMessage( + messages.field_is_required, + { + fieldLabel: subblock.label, + }, + ); + } + } + // Bit messy to look at the error response here, we should really abstract away the client vs server error handling to make it more seamless to work with + if (submitResults?.error?.error?.[subblock.id]) { + Object.assign(fieldErrors, submitResults?.error?.error?.[subblock.id]); + } + if (Object.keys(fieldErrors).length > 0) { + v[name] = fieldErrors; } }); if (data.captcha && !captchaToken.current) { - v.push('captcha'); + v['captcha'] = intl.formatMessage(messages.field_is_required, { + fieldLabel: 'Captcha', + }); } - setFormErrors(v); - return v.length === 0; + setFormErrors({ ...v }); + // TODO: This is hard-coded for required being the only client-side validation + return Object.values(v).every((validation) => + [undefined, null].includes(validation.required), + ); }; const submit = (e) => { @@ -164,7 +268,22 @@ const View = ({ data, id, path }) => { captcha.value = formData[data.captcha_props.id]?.value ?? ''; } - let formattedFormData = { ...formData }; + let formattedFormData = data.subblocks.reduce( + (returnValue, field) => { + if (field.field_type === 'static_text') { + return returnValue; + } + const fieldName = getFieldName(field.label, field.id); + const dataToAdd = formData[fieldName] ?? { + field_id: field.id, + label: field.label, + value: field.default_value, + ...(data[field.id] && { custom_field_id: data[field.id] }), // Conditionally add the key. Nicer to work with than having a key with a null value + }; + return { ...returnValue, [fieldName]: dataToAdd }; + }, + {}, + ); data.subblocks.forEach((subblock) => { let name = getFieldName(subblock.label, subblock.id); if (formattedFormData[name]?.value) { @@ -172,20 +291,11 @@ const View = ({ data, id, path }) => { const isAttachment = config.blocks.blocksConfig.form.attachment_fields.includes( subblock.field_type, ); - const isDate = subblock.field_type === 'date'; if (isAttachment) { attachments[name] = formattedFormData[name].value; delete formattedFormData[name]; } - - if (isDate) { - formattedFormData[name].value = formatDate({ - date: formattedFormData[name].value, - format: 'DD-MM-YYYY', - locale: intl.locale, - }); - } } }); dispatch( @@ -201,6 +311,10 @@ const View = ({ data, id, path }) => { ); setFormState({ type: FORM_STATES.loading }); } else { + const errorBox = document.getElementById(`${formid}-errors`); + if (errorBox) { + errorBox.scrollIntoView({ behavior: 'smooth' }); + } setFormState({ type: FORM_STATES.error }); } }) @@ -225,8 +339,6 @@ const View = ({ data, id, path }) => { onChangeFormData, }); - const formid = `form-${id}`; - useEffect(() => { if (submitResults?.loaded) { setFormState({ @@ -244,12 +356,45 @@ const View = ({ data, id, path }) => { behavior: 'smooth', }); } - } else if (submitResults?.error) { - let errorDescription = `${ - JSON.parse(submitResults.error.response?.text ?? '{}')?.message - }`; - - setFormState({ type: FORM_STATES.error, error: errorDescription }); + } + // TODO: The general form state handling is a mess and needs refactoring. + else if (submitResults?.error) { + const errorType = submitResults.error.type; + if (errorType === 'response') { + let errorDescription = `${ + JSON.parse(submitResults.error.error ?? '{}')?.message + }`; + setFormState({ type: FORM_STATES.error, error: errorDescription }); + } else if (errorType === 'validation') { + setFormState({ type: FORM_STATES.normal }); + + const errors = submitResults.error?.error ?? {}; + const erroredFieldIds = Object.keys(errors); + const errorMapping = {}; + data.subblocks.forEach((field) => { + const name = getFieldName(field.label, field.id); + // Adding an errored field + if (erroredFieldIds.includes(field.id)) { + errorMapping[name] = errors[field.id]; + } + // Keep track of previously-errored fields incase we want to display a message to tell users it passes validation now + else if (!!formErrors[name]) { + errorMapping[name] = null; + } + }); + + setFormErrors(errorMapping); + } else { + let errorMessage = 'Unknown error'; + // Handle an edge case where the reducer state is still the old-style without the error type in it + if (submitResults.error.error) { + errorMessage = `${ + JSON.parse(submitResults.error.error ?? '{}')?.message + }`; + } + // TODO: i18n for unknown error type + setFormState({ type: FORM_STATES.error, error: errorMessage }); + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [submitResults]); diff --git a/src/components/Widget/RadioWidget.jsx b/src/components/Widget/RadioWidget.jsx index d1b50cc8..230ce12c 100644 --- a/src/components/Widget/RadioWidget.jsx +++ b/src/components/Widget/RadioWidget.jsx @@ -36,7 +36,7 @@ const RadioWidget = ({ id={id} title={title} description={description} - required={required || null} + // required={required || null} error={error} fieldSet={fieldSet} wrapped={wrapped} diff --git a/src/fieldSchema.js b/src/fieldSchema.js index 83925fb4..5c7c1f23 100644 --- a/src/fieldSchema.js +++ b/src/fieldSchema.js @@ -1,6 +1,5 @@ import config from '@plone/volto/registry'; -import { defineMessages } from 'react-intl'; -import { useIntl } from 'react-intl'; +import { defineMessages, useIntl } from 'react-intl'; const messages = defineMessages({ field_label: { @@ -15,6 +14,10 @@ const messages = defineMessages({ id: 'form_field_required', defaultMessage: 'Required', }, + field_default: { + id: 'form_field_default', + defaultMessage: 'Default', + }, field_type: { id: 'form_field_type', defaultMessage: 'Field type', @@ -39,9 +42,9 @@ const messages = defineMessages({ id: 'form_field_type_multiple_choice', defaultMessage: 'Multiple choice', }, - field_type_checkbox: { - id: 'form_field_type_checkbox', - defaultMessage: 'Checkbox', + field_type_yes_no: { + id: 'field_type_yes_no', + defaultMessage: 'Yes/ No', }, field_type_date: { id: 'form_field_type_date', @@ -67,8 +70,62 @@ const messages = defineMessages({ id: 'form_field_type_hidden', defaultMessage: 'Hidden', }, + field_validation_title: { + id: 'form_field_validations', + defaultMessage: 'Validations', + }, + field_validation_item: { + id: 'form_field_validation', + defaultMessage: 'Validation', + }, + field_validation_type: { + id: 'form_field_validation', + defaultMessage: 'Validation', + }, + field_show_when_when: { + id: 'form_field_show_when', + defaultMessage: 'Show when', + }, + field_show_when_is: { + id: 'form_field_show_is', + defaultMessage: 'Is', + }, + field_show_when_to: { + id: 'form_field_show_to', + defaultMessage: 'To', + }, + field_show_when_option_always: { + id: 'form_field_show_when_option_', + defaultMessage: 'Always', + }, + field_show_when_option_value_is: { + id: 'form_field_show_when_option_value_is', + defaultMessage: 'equal', + }, + field_show_when_option_value_is_not: { + id: 'form_field_show_when_option_value_is_not', + defaultMessage: 'not equal', + }, }); +const choiceTypes = ['select', 'single_choice', 'multiple_choice']; + +// TODO: Anyway to inrospect this? +const fieldTypeDefaultValueTypeMapping = { + yes_no: 'boolean', + multiple_choice: 'array', + date: 'date', +}; + +function getTypeForValidationSetting(setting) { + if (Array.isArray(setting)) { + return 'array'; + } + + // Volto widgets mostly match this result + return typeof setting; +} + export default (props) => { var intl = useIntl(); const baseFieldTypeChoices = [ @@ -80,7 +137,7 @@ export default (props) => { 'multiple_choice', intl.formatMessage(messages.field_type_multiple_choice), ], - ['checkbox', intl.formatMessage(messages.field_type_checkbox)], + ['yes_no', intl.formatMessage(messages.field_type_yes_no)], ['date', intl.formatMessage(messages.field_type_date)], ['attachment', intl.formatMessage(messages.field_type_attachment)], ['from', intl.formatMessage(messages.field_type_from)], @@ -99,8 +156,33 @@ export default (props) => { var schemaExtender = config.blocks.blocksConfig.form.fieldTypeSchemaExtenders[props?.field_type]; const schemaExtenderValues = schemaExtender - ? schemaExtender(intl) + ? schemaExtender({ intl, ...props }) : { properties: [], fields: [], required: [] }; + + const show_when_when_field = + props.show_when_when && props.show_when_when + ? props.formData?.subblocks?.find( + (field) => field.field_id === props.show_when_when, + ) + : undefined; + + const validationIds = props.validations ?? []; + const allValidationSettings = props.formData?.validationSettings || {}; + const settingsWithValidations = Object.entries(allValidationSettings).reduce( + (settings, [validationId, validationSettings]) => { + if (!validationIds.includes(validationId.split('-')[0])) { + return settings; + } + settings[validationId] = validationSettings; + return settings; + }, + {}, + ); + + const showValidations = ['text', 'textarea', 'from'].includes( + props.field_type, + ); + return { title: props?.label || '', fieldsets: [ @@ -113,6 +195,22 @@ export default (props) => { 'field_type', ...schemaExtenderValues.fields, 'required', + ...(showValidations ? ['validations'] : []), + ...(showValidations && validationIds.length > 0 + ? ['validationSettings'] + : []), + ...(!['attachment', 'static_text', 'hidden'].includes( + props.field_type, + ) + ? ['default_value'] + : []), + 'show_when_when', + ...(props.show_when_when && props.show_when_when !== 'always' + ? ['show_when_is'] + : []), + ...(props.show_when_when && props.show_when_when !== 'always' + ? ['show_when_to'] + : []), ], }, ], @@ -141,12 +239,142 @@ export default (props) => { type: 'boolean', default: false, }, + validations: { + title: intl.formatMessage(messages.field_validation_title), + isMulti: true, + vocabulary: { + '@id': 'collective.volto.formsupport.Validators', + }, + }, + default_value: { + title: intl.formatMessage(messages.field_default), + type: fieldTypeDefaultValueTypeMapping[props?.field_type] + ? fieldTypeDefaultValueTypeMapping[props?.field_type] + : 'string', + ...(props?.field_type === 'yes_no' && { + choices: [ + [true, 'Yes'], + [false, 'No'], + ], + noValueOption: false, + }), + ...(['select', 'single_choice', 'multiple_choice'].includes( + props?.field_type, + ) && { + choices: props?.formData?.subblocks + .filter((block) => block.field_id === props.field_id)?.[0] + ?.input_values?.map((input_value) => { + return [input_value, input_value]; + }), + noValueOption: false, + }), + }, + show_when_when: { + title: intl.formatMessage(messages.field_show_when_when), + type: 'string', + choices: [ + [ + 'always', + intl.formatMessage(messages.field_show_when_option_always), + ], + ...(props?.formData?.subblocks + ? props.formData.subblocks.reduce((choices, subblock, index) => { + const currentFieldIndex = props.formData.subblocks.findIndex( + (field) => field.field_id === props.field_id, + ); + if (index > currentFieldIndex) { + if (props.show_when_when === subblock.field_id) { + choices.push([subblock.field_id, subblock.label]); + } + return choices; + } + if (subblock.field_id === props.field_id) { + return choices; + } + choices.push([subblock.field_id, subblock.label]); + return choices; + }, []) + : []), + ], + default: 'always', + }, + show_when_is: { + title: intl.formatMessage(messages.field_show_when_is), + type: 'string', + choices: [ + [ + 'value_is', + intl.formatMessage(messages.field_show_when_option_value_is), + ], + [ + 'value_is_not', + intl.formatMessage(messages.field_show_when_option_value_is_not), + ], + ], + noValueOption: false, + required: true, + }, + show_when_to: { + title: intl.formatMessage(messages.field_show_when_to), + type: 'array', + required: true, + creatable: true, + noValueOption: false, + ...(show_when_when_field && + choiceTypes.includes(show_when_when_field.field_type) && { + choices: show_when_when_field.input_values, + }), + ...(show_when_when_field && + show_when_when_field.field_type === 'yes_no' && { + choices: [ + [true, 'Yes'], + [false, 'No'], + ], + }), + }, + validationSettings: { + title: 'Validation settings', + widget: 'object', + schema: { + fieldsets: [ + { + id: 'default', + title: 'Default', + fields: Object.keys(settingsWithValidations), + }, + ], + properties: Object.entries(settingsWithValidations).reduce( + (properties, [validationAndSettingId, validationSettings]) => { + const [validationId, settingId] = validationAndSettingId.split( + '-', + ); + // We shouldn't get any responses with invalid validation-setting mappings from the backend, but you never know... + if (!validationId || !settingId) { + return properties; + } + properties[validationAndSettingId] = { + title: `${ + validationSettings.validation_title ?? validationId + }: ${validationSettings.title ?? settingId}`, + default: validationSettings.default, + type: validationSettings.type || 'string', + }; + return properties; + }, + {}, + ), + required: [], + }, + }, ...schemaExtenderValues.properties, }, required: [ 'label', 'field_type', 'input_values', + ...(props.show_when_when && props.show_when_when !== 'always' + ? ['show_when_is', 'show_when_to'] + : []), ...schemaExtenderValues.required, ], }; diff --git a/src/formSchema.js b/src/formSchema.js index b4645035..ab842a1a 100644 --- a/src/formSchema.js +++ b/src/formSchema.js @@ -1,5 +1,4 @@ -import { defineMessages } from 'react-intl'; -import { useIntl } from 'react-intl'; +import { defineMessages, useIntl } from 'react-intl'; const messages = defineMessages({ form: { @@ -34,7 +33,14 @@ const messages = defineMessages({ id: 'captcha', defaultMessage: 'Captcha provider', }, - + headers: { + id: 'Headers', + defaultMessage: 'Headers', + }, + headersDescription: { + id: 'Headers Description', + defaultMessage: "These headers aren't included in the sent email by default. Use this dropdown to include them in the sent email", + }, store: { id: 'form_save_persistent_data', defaultMessage: 'Store compiled data', @@ -45,32 +51,73 @@ const messages = defineMessages({ }, send: { id: 'form_send_email', - defaultMessage: 'Send email to recipient', + defaultMessage: 'Send email to', + }, + attachXml: { + id: 'form_attach_xml', + defaultMessage: 'Attach XML to email', + }, + storedDataIds: { + id: 'form_stored_data_ids', + defaultMessage: 'Data ID mapping', + }, + email_format: { + id: 'form_email_format', + defaultMessage: 'Email format', }, }); -export default () => { +export default (formData) => { var intl = useIntl(); + const emailFields = + formData?.subblocks?.reduce((acc, field) => { + return ['from', 'email'].includes(field.field_type) + ? [...acc, [field.id, field.label]] + : acc; + }, []) ?? []; + + const fieldsets = [ + { + id: 'default', + title: 'Default', + fields: [ + 'title', + 'description', + 'default_to', + 'default_from', + 'default_subject', + 'submit_label', + 'captcha', + 'store', + 'send', + ...(formData?.send && + Array.isArray(formData.send) && + formData.send.includes('acknowledgement') + ? ['acknowledgementFields', 'acknowledgementMessage'] + : []), + ], + }, + ]; + + if (formData?.send) { + fieldsets.push({ + id: 'sendingOptions', + title: 'Sending options', + fields: ['attachXml', 'httpHeaders', 'email_format'], + }); + } + + if (formData?.send || formData?.store) { + fieldsets.push({ + id: 'storedDataIds', + title: intl.formatMessage(messages.storedDataIds), + fields: formData?.subblocks?.map((subblock) => subblock.field_id), + }); + } return { title: intl.formatMessage(messages.form), - fieldsets: [ - { - id: 'default', - title: 'Default', - fields: [ - 'title', - 'description', - 'default_to', - 'default_from', - 'default_subject', - 'submit_label', - 'captcha', - 'store', - 'send', - ], - }, - ], + fieldsets: fieldsets, properties: { title: { title: intl.formatMessage(messages.title), @@ -103,9 +150,68 @@ export default () => { title: intl.formatMessage(messages.store), }, send: { - type: 'boolean', title: intl.formatMessage(messages.send), - description: intl.formatMessage(messages.attachmentSendEmail), + isMulti: 'true', + default: 'recipient', + choices: [ + ['recipient', 'Recipient'], + ['acknowledgement', 'Acknowledgement'], + ], + }, + acknowledgementMessage: { + // TODO: i18n + title: 'Acknowledgement message', + widget: 'richtext', + }, + acknowledgementFields: { + // TODO: i18n + title: 'Acknowledgement field', + decription: + 'Select which fields will contain an email address to send an acknowledgement to.', + isMulti: false, + noValueOption: false, + choices: formData?.subblocks ? emailFields : [], + ...(emailFields.length === 1 && { default: emailFields[0][0] }), + }, + attachXml: { + type: 'boolean', + title: intl.formatMessage(messages.attachXml), + }, + // Add properties for each of the fields for use in the data mapping + ...(formData?.subblocks + ? Object.assign( + {}, + ...formData?.subblocks?.map((subblock) => { + return { [subblock.field_id]: { title: subblock.label } }; + }), + ) + : {}), + httpHeaders: { + type: 'boolean', + title: intl.formatMessage(messages.headers), + description: intl.formatMessage(messages.headersDescription), + type: 'string', + factory: 'Choice', + default: '', + isMulti: true, + noValueOption: false, + choices: [ + ['HTTP_X_FORWARDED_FOR','HTTP_X_FORWARDED_FOR'], + ['HTTP_X_FORWARDED_PORT','HTTP_X_FORWARDED_PORT'], + ['REMOTE_ADDR','REMOTE_ADDR'], + ['PATH_INFO','PATH_INFO'], + ['HTTP_USER_AGENT','HTTP_USER_AGENT'], + ['HTTP_REFERER','HTTP_REFERER'], + ], + }, + email_format: { + title: intl.formatMessage(messages.email_format), + type: 'string', + choices: [ + ['list', 'List'], + ['table', 'Table'], + ], + noValueOption: false, }, }, required: ['default_to', 'default_from', 'default_subject'], diff --git a/src/helpers/show_when.js b/src/helpers/show_when.js new file mode 100644 index 00000000..d49e4de3 --- /dev/null +++ b/src/helpers/show_when.js @@ -0,0 +1,20 @@ +const always = () => true; +const value_is = ({ value, target_value }) => { + if (Array.isArray(target_value)) { + return target_value.includes(value); + } + return value === target_value; +}; +const value_is_not = ({ value, target_value }) => { + if (Array.isArray(target_value)) { + return !target_value.includes(value); + } + return value !== target_value; +}; + +export const showWhenValidator = { + '': always, + always: always, + value_is: value_is, + value_is_not: value_is_not, +}; diff --git a/src/helpers/validators.js b/src/helpers/validators.js new file mode 100644 index 00000000..cc1ff1df --- /dev/null +++ b/src/helpers/validators.js @@ -0,0 +1,36 @@ +function hasValue(value) { + return !value === null || !value === undefined; +} + +export const validations = { + minLength: { + fields: ['validation_value'], + properties: { + validation_value: { + title: 'Minimum', + type: 'number', + }, + }, + validator: function ({ value, validation_value }) { + if (!hasValue(value)) { + return true; + } + return value.toString().length >= validation_value; + }, + }, + maxLength: { + fields: ['validation_value'], + properties: { + validation_value: { + title: 'Maximum', + type: 'number', + }, + }, + validator: function ({ value, validation_value }) { + if (!hasValue(value)) { + return true; + } + return value.toString().length < validation_value; + }, + }, +}; diff --git a/src/index.js b/src/index.js index e7f614c7..68a04006 100644 --- a/src/index.js +++ b/src/index.js @@ -19,6 +19,7 @@ import { SelectionSchemaExtender, FromSchemaExtender, HiddenSchemaExtender, + YesNoSchemaExtender, } from './components/FieldTypeSchemaExtenders'; export { submitForm, @@ -45,6 +46,7 @@ const applyConfig = (config) => { multiple_choice: SelectionSchemaExtender, from: FromSchemaExtender, hidden: HiddenSchemaExtender, + yes_no: YesNoSchemaExtender, }, attachment_fields: ['attachment'], restricted: false, diff --git a/src/reducers/index.js b/src/reducers/index.js index a976d8c0..ddbdfe9b 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -56,9 +56,21 @@ export const submitForm = (state = initialState, action = {}) => { loading: false, }; case `${SUBMIT_FORM_ACTION}_FAIL`: + const responseBody = action?.error?.response?.body; + const isValidationError = responseBody.error?.type === 'Invalid'; return { ...state, - error: action.error, + error: isValidationError + ? { type: 'validation', error: responseBody.error.errors } + : { type: 'response', error: action.error }, + loaded: false, + loading: false, + }; + // Needed to handle the case where we change page and need to reset the server-sent errors + case '@@router/LOCATION_CHANGE': + return { + ...state, + error: null, loaded: false, loading: false, };