diff --git a/src/components/Field.jsx b/src/components/Field.jsx index 8e52cc9..d2c69e5 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 48e89c6..714096f 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 98488aa..567f131 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 0000000..9bde393 --- /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 81e0302..a8874e6 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 db65cea..b44569f 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 */ @@ -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,11 +173,7 @@ 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} /> diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index 7edf0b2..e2696cd 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 f493057..da6641a 100644 --- a/src/components/View.jsx +++ b/src/components/View.jsx @@ -1,13 +1,13 @@ -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'; const messages = defineMessages({ formSubmitted: { @@ -98,9 +98,18 @@ const View = ({ data, id, path }) => { 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(() => { @@ -119,25 +128,25 @@ 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] || + if (subblock.required) { + let fieldIsValid = true; + if (additionalField && !additionalField?.isValid(formData, name)) { + fieldIsValid = false; + } else if (fieldType === 'checkbox' && !formData[name]?.value) { + fieldIsValid = false; + } else if ( + !formData[name] || formData[name]?.value?.length === 0 || - JSON.stringify(formData[name]?.value ?? {}) === '{}') - ) { - v.push(name); + JSON.stringify(formData[name]?.value ?? {}) === '{}' + ) { + fieldIsValid = false; + } + if (Boolean(!formData[name] && subblock.default_value)) { + fieldIsValid = true; + } + if (!fieldIsValid) { + v.push(name); + } } }); @@ -164,7 +173,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 +196,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 +216,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 +244,6 @@ const View = ({ data, id, path }) => { onChangeFormData, }); - const formid = `form-${id}`; - useEffect(() => { if (submitResults?.loaded) { setFormState({ diff --git a/src/components/Widget/RadioWidget.jsx b/src/components/Widget/RadioWidget.jsx index d1b50cc..230ce12 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 83925fb..942682a 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,41 @@ const messages = defineMessages({ id: 'form_field_type_hidden', defaultMessage: 'Hidden', }, + 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', +}; + export default (props) => { var intl = useIntl(); const baseFieldTypeChoices = [ @@ -80,7 +116,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 +135,16 @@ 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; + return { title: props?.label || '', fieldsets: [ @@ -113,6 +157,18 @@ export default (props) => { 'field_type', ...schemaExtenderValues.fields, 'required', + ...(!['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 +197,101 @@ export default (props) => { type: 'boolean', default: false, }, + 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'], + ], + }), + }, ...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 b464503..ab842a1 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 0000000..1e6ad93 --- /dev/null +++ b/src/helpers/show_when.js @@ -0,0 +1,10 @@ +const always = () => true; +const value_is = ({ value, target_value }) => value === target_value; +const value_is_not = ({ value, target_value }) => value !== target_value; + +export const showWhenValidator = { + '': always, + always: always, + value_is: value_is, + value_is_not: value_is_not, +}; diff --git a/src/index.js b/src/index.js index e7f614c..68a0400 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,