diff --git a/package.json b/package.json index 1d098e1774..7c12bec8ca 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "react-hook-form": "^5.7.2", "react-redux": "^7.2.0", "react-router-dom": "^5.1.2", + "react-router-navigation-prompt": "^1.8.11", "react-select": "^3.0.8", "sanitize-filename": "^1.6.3", "sharp": "^0.25.2", diff --git a/src/client/components/forms/Form/index.js b/src/client/components/forms/Form/index.js index 2e91108e4f..5f1d9746ac 100644 --- a/src/client/components/forms/Form/index.js +++ b/src/client/components/forms/Form/index.js @@ -1,4 +1,6 @@ -import React, { useState, useReducer, useCallback } from 'react'; +import React, { + useState, useReducer, useCallback, useEffect, +} from 'react'; import { useHistory } from 'react-router-dom'; import PropTypes from 'prop-types'; import { unflatten } from 'flatley'; @@ -31,6 +33,7 @@ const Form = (props) => { const [fields, dispatchFields] = useReducer(fieldReducer, {}); const [submitted, setSubmitted] = useState(false); + const [modified, setModified] = useState(false); const [processing, setProcessing] = useState(false); const history = useHistory(); const locale = useLocale(); @@ -233,6 +236,10 @@ const Form = (props) => { refreshToken(); }, 15000, [fields]); + useEffect(() => { + setModified(false); + }, [locale]); + const classes = [ className, baseClass, @@ -255,6 +262,8 @@ const Form = (props) => { countRows, getData, validateForm, + modified, + setModified, }} > { const path = pathFromProps || name; - const { value, onChange } = useFieldType({ + const { value, setValue } = useFieldType({ path, required, initialData, @@ -25,7 +25,7 @@ const HiddenInput = (props) => { diff --git a/src/client/components/forms/field-types/Repeater/index.js b/src/client/components/forms/field-types/Repeater/index.js index 90d961a1fd..bc9e39d213 100644 --- a/src/client/components/forms/field-types/Repeater/index.js +++ b/src/client/components/forms/field-types/Repeater/index.js @@ -53,7 +53,6 @@ const Repeater = (props) => { const { showError, errorMessage, - setValue, value, } = useFieldType({ path, @@ -130,16 +129,6 @@ const Repeater = (props) => { useEffect(updateRowCountOnParentRowModified, [parentRowsModified]); - useEffect(() => { - let i; - const newValue = []; - for (i = 0; i < rowCount; i += 1) { - newValue.push({}); - } - - setValue(newValue); - }, [rowCount, setValue]); - return ( diff --git a/src/client/components/forms/useFieldType/index.js b/src/client/components/forms/useFieldType/index.js index f7a9438805..e1e6aa7921 100644 --- a/src/client/components/forms/useFieldType/index.js +++ b/src/client/components/forms/useFieldType/index.js @@ -13,23 +13,36 @@ const useFieldType = (options) => { required, initialData: data, defaultValue, - onChange, validate, disableFormData, } = options; + // Determine what the initial data is to be used + // If initialData is defined, that means that data has been provided + // via the API and should override any default values present. + // If no initialData, use default value const initialData = data !== undefined ? data : defaultValue; + const locale = useLocale(); const formContext = useContext(FormContext); - const [internalValue, setInternalValue] = useState(initialData); - const debouncedValue = useDebounce(internalValue, 100); - const { - dispatchFields, submitted, processing, getField, + dispatchFields, submitted, processing, getField, setModified, modified, } = formContext; + // Maintain an internal initial value AND value to ensure that the form + // can successfully differentiate between updates sent from fields + // that are meant to be initial values vs. values that are deliberately changed + const [internalInitialValue, setInternalInitialValue] = useState(initialData); + const [internalValue, setInternalValue] = useState(initialData); + + // Debounce internal values to update form state only every 100ms + const debouncedValue = useDebounce(internalValue, 100); + + // Get field by path const field = getField(path); const fieldExists = Boolean(field); + + // Valid could be a string equal to an error message const valid = (field && typeof field.valid === 'boolean') ? field.valid : true; const valueFromForm = field?.value; const showError = valid === false && submitted; @@ -58,14 +71,16 @@ const useFieldType = (options) => { // update internal field values from field component(s) // as fast as they arrive. NOTE - this method is NOT debounced const setValue = useCallback((e) => { - if (e && e.target) { - setInternalValue(e.target.value); - } else { - setInternalValue(e); - } + const value = (e && e.target) ? e.target.value : e; - if (onChange && typeof onChange === 'function') onChange(e); - }, [onChange, setInternalValue]); + if (!modified) setModified(true); + + setInternalValue(value); + }, [setModified, modified]); + + const setInitialValue = useCallback((value) => { + setInternalInitialValue(value); + }, []); // Remove field from state on "unmount" // This is mostly used for repeater / flex content row modifications @@ -86,15 +101,24 @@ const useFieldType = (options) => { // update internal value as well useEffect(() => { if (valueFromForm !== undefined) { - setValue(valueFromForm); + setInternalInitialValue(valueFromForm); } - }, [valueFromForm, setValue]); + }, [valueFromForm, setInternalInitialValue]); + // When locale changes, and / or initialData changes, + // reset internal initial value useEffect(() => { if (initialData !== undefined) { - setValue(initialData); + setInternalInitialValue(initialData); } - }, [initialData, setValue, locale]); + }, [initialData, setInternalInitialValue, locale]); + + + // When internal initial value changes, set internal value + // This is necessary to bypass changing the form to a modified:true state + useEffect(() => { + setInternalValue(internalInitialValue); + }, [setInternalValue, internalInitialValue]); return { ...options, @@ -105,6 +129,7 @@ const useFieldType = (options) => { formSubmitted: submitted, formProcessing: processing, setValue, + setInitialValue, }; }; diff --git a/src/client/components/modals/LeaveWithoutSaving/index.js b/src/client/components/modals/LeaveWithoutSaving/index.js new file mode 100644 index 0000000000..824616fce4 --- /dev/null +++ b/src/client/components/modals/LeaveWithoutSaving/index.js @@ -0,0 +1,39 @@ +import React from 'react'; +import NavigationPrompt from 'react-router-navigation-prompt'; +import useForm from '../../forms/Form/useForm'; +import MinimalTemplate from '../../templates/Minimal'; +import Button from '../../elements/Button'; + +import './index.scss'; + +const modalSlug = 'leave-without-saving'; + +const LeaveWithoutSaving = () => { + const { modified } = useForm(); + + return ( + + {({ onConfirm, onCancel }) => { + return ( +
+ +

Leave without saving

+

Your changes have not been saved. If you leave now, you will lose your changes.

+ + +
+
+ ); + }} +
+ ); +}; + +export default LeaveWithoutSaving; diff --git a/src/client/components/modals/LeaveWithoutSaving/index.scss b/src/client/components/modals/LeaveWithoutSaving/index.scss new file mode 100644 index 0000000000..d699d66643 --- /dev/null +++ b/src/client/components/modals/LeaveWithoutSaving/index.scss @@ -0,0 +1,15 @@ +@import '../../../scss/styles.scss'; + +.leave-without-saving { + background-color: rgba(white, .95); + position: fixed; + z-index: $z-modal; + top: 0; + right: 0; + bottom: 0; + left: 0; + + .btn { + margin-right: $baseline; + } +} diff --git a/src/client/components/views/collections/Edit/Default.js b/src/client/components/views/collections/Edit/Default.js index a77fd34c47..0ed9c180b7 100644 --- a/src/client/components/views/collections/Edit/Default.js +++ b/src/client/components/views/collections/Edit/Default.js @@ -13,6 +13,7 @@ import DuplicateDocument from '../../../elements/DuplicateDocument'; import DeleteDocument from '../../../elements/DeleteDocument'; import * as fieldTypes from '../../../forms/field-types'; import RenderTitle from './RenderTitle'; +import LeaveWithoutSaving from '../../../modals/LeaveWithoutSaving'; import './index.scss'; @@ -55,6 +56,7 @@ const DefaultEditView = (props) => { >
+

diff --git a/src/fields/validations.js b/src/fields/validations.js index b44e22fa8a..3b91d6c32f 100644 --- a/src/fields/validations.js +++ b/src/fields/validations.js @@ -107,7 +107,7 @@ const optionsToValidatorMap = { return `This field requires at least ${options.minRows} row(s).`; } - if (options.maxRows && value.length < options.maxRows) { + if (options.maxRows && value.length > options.maxRows) { return `This field requires no more than ${options.maxRows} row(s).`; } diff --git a/yarn.lock b/yarn.lock index 87459a77a5..8ae3b790c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8877,6 +8877,11 @@ react-router-dom@^5.1.2: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" +react-router-navigation-prompt@^1.8.11: + version "1.8.11" + resolved "https://registry.yarnpkg.com/react-router-navigation-prompt/-/react-router-navigation-prompt-1.8.11.tgz#2dd2ebdc2a479369305d269d5fe54d2a1b21c6a8" + integrity sha512-5laI/ITp4reJZA333qffGcNwbpPD8n6OM3ezvIRBYWKCb1MjkN0SZ73sXVc7gUYmJ+gzCF9GwAP/MjG+ddZUCg== + react-router@5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.1.2.tgz#6ea51d789cb36a6be1ba5f7c0d48dd9e817d3418"