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,
};