diff --git a/package.json b/package.json index d828a10..c1a3c69 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "volto-subblocks" ], "dependencies": { + "file-saver": "^2.0.5", "react-google-recaptcha-v3": "^1.8.0", "volto-subblocks": "collective/volto-subblocks#v1.0.1" } diff --git a/src/actions/index.js b/src/actions/index.js index 43af611..e0d8454 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -27,3 +27,51 @@ export function submitForm(path = '', block_id, data, attachments) { }, }; } + +/** + * exportCsvFormData action + * @modulee actions/exportCsvFormData + */ +export const EXPORT_CSV_FORMDATA = 'EXPORT_CSV_FORMDATA'; + +export function exportCsvFormData(path = '') { + return { + type: EXPORT_CSV_FORMDATA, + request: { + op: 'get', + path: path + '/@form-data-export', + }, + }; +} + +/** + * getFormData action + * @modulee actions/getFormData + */ +export const GET_FORM_DATA = 'GET_FORMDATA'; + +export function getFormData(path = '') { + return { + type: GET_FORM_DATA, + request: { + op: 'get', + path: path + '/@form-data', + }, + }; +} + +/** + * clearFormData action + * @modulee actions/getFormData + */ +export const CLEAR_FORM_DATA = 'CLEAR_FORM_DATA'; + +export function clearFormData(path = '') { + return { + type: CLEAR_FORM_DATA, + request: { + op: 'get', + path: path + '/@form-data-clear', + }, + }; +} diff --git a/src/components/Edit.jsx b/src/components/Edit.jsx index ac1c3b7..5944d51 100644 --- a/src/components/Edit.jsx +++ b/src/components/Edit.jsx @@ -1,15 +1,14 @@ import React from 'react'; -import EditBlock from './EditBlock'; - import { Segment, Grid, Form, Button } from 'semantic-ui-react'; import { withDNDContext, SubblocksEdit, SubblocksWrapper, } from 'volto-subblocks'; - import { SidebarPortal } from '@plone/volto/components'; -import Sidebar from './Sidebar.jsx'; + +import EditBlock from 'volto-form-block/components/EditBlock'; +import Sidebar from 'volto-form-block/components/Sidebar'; import { defineMessages } from 'react-intl'; diff --git a/src/components/EditBlock.jsx b/src/components/EditBlock.jsx index c088a30..4bae451 100644 --- a/src/components/EditBlock.jsx +++ b/src/components/EditBlock.jsx @@ -8,7 +8,7 @@ import { compose } from 'redux'; import { DNDSubblocks, SubblockEdit, Subblock } from 'volto-subblocks'; -import Field from './Field'; +import Field from 'volto-form-block/components/Field'; import { getFieldName } from './utils'; /** @@ -52,6 +52,7 @@ class EditBlock extends SubblockEdit { key={this.props.data.index} isOnEdit={true} id={id} + field_id={id} index={this.props.data.index} onChange={() => {}} /> diff --git a/src/components/Field.jsx b/src/components/Field.jsx index 9f006ad..6d0897b 100644 --- a/src/components/Field.jsx +++ b/src/components/Field.jsx @@ -8,8 +8,8 @@ import EmailWidget from '@plone/volto/components/manage/Widgets/EmailWidget'; import CheckboxWidget from '@plone/volto/components/manage/Widgets/CheckboxWidget'; import { DatetimeWidget } from '@plone/volto/components'; -import RadioWidget from './Widget/RadioWidget'; -import FileWidget from './Widget/FileWidget'; +import RadioWidget from 'volto-form-block/components/Widget/RadioWidget'; +import FileWidget from 'volto-form-block/components/Widget/FileWidget'; const messages = defineMessages({ select_a_value: { diff --git a/src/components/Form.jsx b/src/components/Form.jsx index c0d97f2..f481010 100644 --- a/src/components/Form.jsx +++ b/src/components/Form.jsx @@ -2,9 +2,9 @@ import React, { useState, useEffect, useReducer } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import PropTypes from 'prop-types'; import { useIntl, defineMessages } from 'react-intl'; -import { submitForm } from '../actions'; -import { getFieldName } from './utils'; -import FormView from './FormView'; +import { submitForm } from 'volto-form-block/actions'; +import { getFieldName } from 'volto-form-block/components/utils'; +import FormView from 'volto-form-block/components/FormView'; const messages = defineMessages({ messageSent: { @@ -60,10 +60,10 @@ const Form = ({ data, id, path }) => { const [formState, setFormState] = useReducer(formStateReducer, initialState); const [formErrors, setFormErrors] = useState([]); - const submitResults = useSelector((state) => state.sendActionForm); + const submitResults = useSelector((state) => state.submitForm); - const onChangeFormData = (field, value, label) => { - setFormData({ field: field, value: { value: value, label: label } }); + const onChangeFormData = (field_id, field, value, label) => { + setFormData({ field, value: { field_id, value, label } }); }; useEffect(() => { @@ -98,6 +98,7 @@ const Form = ({ data, id, path }) => { data.subblocks.forEach((subblock, index) => { let name = getFieldName(subblock.label); if (formData[name]?.value) { + formData[name].field_id = subblock.field_id; const isAttachment = subblock.field_type === 'attachment'; if (isAttachment) { @@ -111,7 +112,7 @@ const Form = ({ data, id, path }) => { path, id, Object.keys(formData).map((name) => ({ - id: name, + field_id: formData[name].field_id, label: formData[name].label, value: formData[name].value, })), diff --git a/src/components/FormView.jsx b/src/components/FormView.jsx index 8cae60e..1f65194 100644 --- a/src/components/FormView.jsx +++ b/src/components/FormView.jsx @@ -9,8 +9,8 @@ import { Progress, Button, } from 'semantic-ui-react'; -import { getFieldName } from './utils'; -import Field from './Field'; +import { getFieldName } from 'volto-form-block/components/utils'; +import Field from 'volto-form-block/components/Field'; const messages = defineMessages({ default_submit_label: { @@ -94,7 +94,12 @@ const FormView = ({ {...subblock} name={name} onChange={(field, value) => - onChangeFormData(field, value, subblock.label) + onChangeFormData( + subblock.id, + field, + value, + subblock.label, + ) } value={formData[name]?.value} valid={isValidField(name)} diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index 857043d..a4aff11 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -1,6 +1,16 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { Segment, Accordion, Form } from 'semantic-ui-react'; +import { useSelector, useDispatch } from 'react-redux'; +import { + Segment, + Accordion, + Form, + Button, + Grid, + Confirm, + Dimmer, + Loader, +} from 'semantic-ui-react'; import { defineMessages, useIntl, @@ -18,6 +28,14 @@ import { import upSVG from '@plone/volto/icons/up-key.svg'; import downSVG from '@plone/volto/icons/down-key.svg'; +import downloadSVG from '@plone/volto/icons/download.svg'; +import deleteSVG from '@plone/volto/icons/delete.svg'; + +import { + getFormData, + exportCsvFormData, + clearFormData, +} from 'volto-form-block/actions'; const messages = defineMessages({ default_to: { @@ -102,18 +120,31 @@ const messages = defineMessages({ defaultMessage: 'Questo indirizzo verrĂ  utilizzato come mittente della mail con i dati del form', }, - save_persistent_data: { + store: { id: 'form_save_persistent_data', defaultMessage: 'Salva i dati compilati', }, - send_email: { + send: { id: 'form_send_email', defaultMessage: 'Invia email al destinatario', }, + exportCsv: { + id: 'form_edit_exportCsv', + defaultMessage: 'Export data', + }, + clearData: { + id: 'form_clear_data', + defaultMessage: 'Clear data', + }, + formDataCount: { + id: 'form_formDataCount', + defaultMessage: '{formDataCount} item(s) stored', + }, }); const Sidebar = ({ data, + properties, block, onChangeBlock, onChangeSubBlock, @@ -122,12 +153,28 @@ const Sidebar = ({ openObjectBrowser, }) => { const intl = useIntl(); + const dispatch = useDispatch(); + const [confirmOpen, setConfirmOpen] = useState(false); + + const formData = useSelector((state) => state.formData); + const clearFormDataState = useSelector( + (state) => state.clearFormData?.loaded, + ); + useEffect(() => { + if (properties?.['@id']) dispatch(getFormData(properties['@id'])); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [clearFormDataState]); if (data.send_email === undefined) data.send_email = true; + data.subblocks && + data.subblocks.forEach((subblock) => { + subblock.field_id = subblock.id; + }); + return (
- +

@@ -186,10 +233,10 @@ const Sidebar = ({ /> { onChangeBlock(block, { ...data, @@ -198,10 +245,10 @@ const Sidebar = ({ }} /> { onChangeBlock(block, { ...data, @@ -209,6 +256,67 @@ const Sidebar = ({ }); }} /> + + {properties?.['@components']?.form_data && ( + + + + + + +

+ {intl.formatMessage(messages.formDataCount, { + formDataCount: formData?.result?.items_total ?? 0, + })} +

+
+ + + + + + + setConfirmOpen(false)} + onConfirm={() => { + dispatch(clearFormData(properties['@id'])); + setConfirmOpen(false); + }} + /> + + +
+
+ )} {data.subblocks && diff --git a/src/components/View.jsx b/src/components/View.jsx index b3adb48..48ad8d7 100644 --- a/src/components/View.jsx +++ b/src/components/View.jsx @@ -6,7 +6,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useIntl } from 'react-intl'; import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3'; -import Form from './Form'; +import Form from 'volto-form-block/components/Form'; /** * View blocks class. diff --git a/src/components/utils.js b/src/components/utils.js index 573ea20..7941035 100644 --- a/src/components/utils.js +++ b/src/components/utils.js @@ -1,3 +1,36 @@ +import { saveAs } from 'file-saver'; + export const getFieldName = (label) => { return label?.toLowerCase().replace(/[^a-zA-Z0-9]/g, '_'); }; + +/** + * Download a file using `filename` specified in `content-disposition` header + * @param {string} url - URL to request + * @param {Object} [fetchProps] - Optional addtional props to pass to `fetch` + * @example + * await downloadFile('https://example.com/myfile', { credentials: 'include' }) + */ +export async function downloadFile(url, fetchProps) { + try { + const response = await fetch(url, fetchProps); + + if (!response.ok) { + throw new Error(response); + } + + // Extract filename from header + const filename = response.headers + .get('content-disposition') + .split(';') + .find((n) => n.includes('filename=')) + .replace('filename=', '') + .trim(); + const blob = await response.blob(); + + // Download the file + saveAs(blob, filename); + } catch (error) { + throw new Error(error); + } +} diff --git a/src/index.js b/src/index.js index 338dc5c..f7bd87f 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,12 @@ import formSVG from '@plone/volto/icons/form.svg'; import FormView from './components/View'; import FormEdit from './components/Edit'; +import { + submitForm, + getFormData, + exportCsvFormData, + clearFormData, +} from './reducers'; const applyConfig = (config) => { config.blocks.blocksConfig = { @@ -22,6 +28,14 @@ const applyConfig = (config) => { }, }; + config.addonReducers = { + ...config.addonReducers, + submitForm, + formData: getFormData, + exportCsvFormData, + clearFormData, + }; + return config; }; diff --git a/src/reducers/index.js b/src/reducers/index.js new file mode 100644 index 0000000..5b01387 --- /dev/null +++ b/src/reducers/index.js @@ -0,0 +1,186 @@ +import { saveAs } from 'file-saver'; + +/** + * submitForm reducer. + * @module reducers/submitForm + */ +import { + SUBMIT_FORM_ACTION, + EXPORT_CSV_FORMDATA, + GET_FORM_DATA, + CLEAR_FORM_DATA, +} from '../actions'; + +function download(filename, text) { + var element = document.createElement('a'); + element.setAttribute( + 'href', + 'data:text/comma-separated-values;charset=utf-8,' + + encodeURIComponent(text), + ); + element.setAttribute('download', filename); + + element.style.display = 'none'; + document.body.appendChild(element); + + element.click(); + + document.body.removeChild(element); +} + +const initialState = { + error: null, + loaded: false, + loading: false, +}; + +/** + * submitForm reducer. + * @function submitForm + * @param {Object} state Current state. + * @param {Object} action Action to be handled. + * @returns {Object} New state. + */ +export const submitForm = (state = initialState, action = {}) => { + switch (action.type) { + case `${SUBMIT_FORM_ACTION}_PENDING`: + return { + ...state, + error: null, + loaded: false, + loading: true, + }; + case `${SUBMIT_FORM_ACTION}_SUCCESS`: + return { + ...state, + error: null, + loaded: true, + loading: false, + }; + case `${SUBMIT_FORM_ACTION}_FAIL`: + return { + ...state, + error: action.error, + loaded: false, + loading: false, + }; + default: + return state; + } +}; + +/** + * exportCsvFormData reducer. + * @function exportCsvFormData + * @param {Object} state Current state. + * @param {Object} action Action to be handled. + * @returns {Object} New state. + */ +export const exportCsvFormData = (state = initialState, action = {}) => { + switch (action.type) { + case `${EXPORT_CSV_FORMDATA}_PENDING`: + return { + ...state, + error: null, + result: null, + loaded: false, + loading: true, + }; + case `${EXPORT_CSV_FORMDATA}_SUCCESS`: + download( + `export-${state.content?.data?.id ?? 'form'}.csv`, + action.result, + ); + + return { + ...state, + error: null, + result: action.result, + loaded: true, + loading: false, + }; + case `${EXPORT_CSV_FORMDATA}_FAIL`: + return { + ...state, + error: action.error, + result: null, + loaded: false, + loading: false, + }; + default: + return state; + } +}; + +/** + * getFormData reducer. + * @function getFormData + * @param {Object} state Current state. + * @param {Object} action Action to be handled. + * @returns {Object} New state. + */ +export const getFormData = (state = initialState, action = {}) => { + switch (action.type) { + case `${GET_FORM_DATA}_PENDING`: + return { + ...state, + error: null, + loaded: false, + loading: true, + result: null, + }; + case `${GET_FORM_DATA}_SUCCESS`: + return { + ...state, + error: null, + loaded: true, + result: action.result, + loading: false, + }; + case `${GET_FORM_DATA}_FAIL`: + return { + ...state, + error: action.error, + result: null, + loaded: true, + loading: false, + }; + default: + return state; + } +}; + +/** + * clearFormData reducer. + * @function clearFormData + * @param {Object} state Current state. + * @param {Object} action Action to be handled. + * @returns {Object} New state. + */ +export const clearFormData = (state = initialState, action = {}) => { + switch (action.type) { + case `${CLEAR_FORM_DATA}_PENDING`: + return { + ...state, + error: null, + loaded: false, + loading: true, + }; + case `${CLEAR_FORM_DATA}_SUCCESS`: + return { + ...state, + error: null, + loaded: true, + loading: false, + }; + case `${CLEAR_FORM_DATA}_FAIL`: + return { + ...state, + error: action.error, + loaded: false, + loading: false, + }; + default: + return state; + } +};