diff --git a/package.json b/package.json index 4bf878afe..10a2d9e87 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "classnames": "^2.3.1", "expr-eval": "^2.0.2", "final-form": "^4.20.6", + "final-form-set-field-data": "^1.0.2", "history": "^5.1.0", "html-react-parser": "^1.4.8", "idb-keyval": "^6.1.0", diff --git a/src/data-workspace/data-entry-cell/data-entry-field.js b/src/data-workspace/data-entry-cell/data-entry-field.js index 7ed6bc5cf..5bb1e46bd 100644 --- a/src/data-workspace/data-entry-cell/data-entry-field.js +++ b/src/data-workspace/data-entry-cell/data-entry-field.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types' -import React, { useState } from 'react' +import React from 'react' import { useLockedContext } from '../../shared/index.js' import { getFieldId } from '../get-field-id.js' import { EntryFieldInput } from './entry-field-input.js' @@ -15,12 +15,6 @@ export const DataEntryField = React.memo(function DataEntryField({ // { [deId]: { [cocId]: value } } const fieldname = getFieldId(de.id, coc.id) - // todo: perhaps this could be refactored to use DV mutation state - // See https://dhis2.atlassian.net/browse/TECH-1316 - const [syncStatus, setSyncStatus] = useState({ - syncing: false, - synced: false, - }) const { locked } = useLockedContext() return ( @@ -29,7 +23,6 @@ export const DataEntryField = React.memo(function DataEntryField({ fieldname={fieldname} deId={de.id} cocId={coc.id} - syncStatus={syncStatus} disabled={disabled} locked={locked} > @@ -37,7 +30,6 @@ export const DataEntryField = React.memo(function DataEntryField({ fieldname={fieldname} dataElement={de} categoryOptionCombo={coc} - setSyncStatus={setSyncStatus} disabled={disabled} locked={locked} /> diff --git a/src/data-workspace/data-entry-cell/entry-field-input.js b/src/data-workspace/data-entry-cell/entry-field-input.js index 9169813c4..ba30b0695 100644 --- a/src/data-workspace/data-entry-cell/entry-field-input.js +++ b/src/data-workspace/data-entry-cell/entry-field-input.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types' import React, { useCallback, useMemo } from 'react' +import { useForm } from 'react-final-form' import { useSetRightHandPanel } from '../../right-hand-panel/index.js' import { VALUE_TYPES, @@ -59,7 +60,6 @@ export function EntryFieldInput({ fieldname, dataElement: de, categoryOptionCombo: coc, - setSyncStatus, disabled, locked, }) { @@ -68,7 +68,7 @@ export function EntryFieldInput({ // used so we don't consume the "id" which // would cause this component to rerender const setRightHandPanel = useSetRightHandPanel() - + const form = useForm() // todo: maybe move to InnerWrapper? // See https://dhis2.atlassian.net/browse/TECH-1296 const onKeyDown = useCallback( @@ -97,24 +97,15 @@ export function EntryFieldInput({ const sharedProps = useMemo( () => ({ fieldname, + form, deId: de.id, cocId: coc.id, disabled, locked, - setSyncStatus, onFocus, onKeyDown, }), - [ - fieldname, - de, - coc, - disabled, - locked, - setSyncStatus, - onFocus, - onKeyDown, - ] + [fieldname, form, de, coc, disabled, locked, onFocus, onKeyDown] ) return @@ -135,5 +126,4 @@ EntryFieldInput.propTypes = { disabled: PropTypes.bool, fieldname: PropTypes.string, locked: PropTypes.bool, - setSyncStatus: PropTypes.func, } diff --git a/src/data-workspace/data-entry-cell/inner-wrapper.js b/src/data-workspace/data-entry-cell/inner-wrapper.js index ab97d4f0d..ad8d9e876 100644 --- a/src/data-workspace/data-entry-cell/inner-wrapper.js +++ b/src/data-workspace/data-entry-cell/inner-wrapper.js @@ -2,8 +2,8 @@ import { IconMore16, colors } from '@dhis2/ui' import { useIsMutating } from '@tanstack/react-query' import cx from 'classnames' import PropTypes from 'prop-types' -import React from 'react' -import { useField } from 'react-final-form' +import React, { useEffect } from 'react' +import { useField, useForm } from 'react-final-form' import { mutationKeys as dataValueMutationKeys, useDataValueParams, @@ -50,7 +50,6 @@ export function InnerWrapper({ fieldname, deId, cocId, - syncStatus, }) { const hasComment = useValueStore((state) => state.hasComment({ @@ -61,9 +60,34 @@ export function InnerWrapper({ const { item } = useHighlightedFieldIdContext() const highlighted = item && deId === item.de.id && cocId === item.coc.id const { - meta: { invalid, active }, - } = useField(fieldname, { subscription: { invalid: true, active: true } }) + input: { value }, + meta: { invalid, active, data, dirty }, + } = useField(fieldname, { + // by default undefined is formatted to '' + // this preserves the format used in the input-component + format: (v) => v, + subscription: { + value: true, + invalid: true, + active: true, + data: true, + dirty: true, + }, + }) + const form = useForm() + + // initalize lastSyncedValue + useEffect( + () => { + form.mutators.setFieldData(fieldname, { + lastSyncedValue: value, + }) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ) + const synced = dirty && data.lastSyncedValue === value // Detect if this field is sending data const dataValueParams = useDataValueParams({ deId, cocId }) const activeMutations = useIsMutating({ @@ -74,7 +98,7 @@ export function InnerWrapper({ // see https://dhis2.atlassian.net/browse/TECH-1316 const cellStateClassName = invalid ? styles.invalid - : activeMutations === 0 && syncStatus.synced + : activeMutations === 0 && synced ? styles.synced : null @@ -90,7 +114,7 @@ export function InnerWrapper({ {children} 0} - isSynced={syncStatus.synced} + isSynced={synced} /> @@ -103,8 +127,4 @@ InnerWrapper.propTypes = { disabled: PropTypes.bool, fieldname: PropTypes.string, locked: PropTypes.bool, - syncStatus: PropTypes.shape({ - synced: PropTypes.bool, - syncing: PropTypes.bool, - }), } diff --git a/src/data-workspace/final-form-wrapper.js b/src/data-workspace/final-form-wrapper.js index c078ffb44..7d3c549ab 100644 --- a/src/data-workspace/final-form-wrapper.js +++ b/src/data-workspace/final-form-wrapper.js @@ -1,3 +1,4 @@ +import setFieldData from 'final-form-set-field-data' import PropTypes from 'prop-types' import React, { useState } from 'react' import { Form } from 'react-final-form' @@ -37,6 +38,7 @@ export function FinalFormWrapper({ children, dataValueSet }) { initialValues={initialValues} subscriptions={subscriptions} keepDirtyOnReinitialize + mutators={{ setFieldData }} > {() => children} diff --git a/src/data-workspace/inputs/boolean-radios.js b/src/data-workspace/inputs/boolean-radios.js index 8b7e767d1..1d264b0ba 100644 --- a/src/data-workspace/inputs/boolean-radios.js +++ b/src/data-workspace/inputs/boolean-radios.js @@ -1,7 +1,7 @@ import i18n from '@dhis2/d2-i18n' import { Button, Radio } from '@dhis2/ui' import cx from 'classnames' -import React, { useState } from 'react' +import React from 'react' import { useField } from 'react-final-form' import { useSetDataValueMutation } from '../../shared/index.js' import styles from './inputs.module.css' @@ -15,11 +15,11 @@ import { convertCallbackSignatures, InputPropTypes } from './utils.js' // does `isEqual` prop help make 1/true and 0/false/'' equal? export const BooleanRadios = ({ fieldname, + form, deId, cocId, disabled, locked, - setSyncStatus, onKeyDown, onFocus, }) => { @@ -57,9 +57,10 @@ export const BooleanRadios = ({ const { input: { value: fieldvalue }, - meta, - } = useField(fieldname) - const [lastSyncedValue, setLastSyncedValue] = useState(fieldvalue) + meta: { valid, data }, + } = useField(fieldname, { + subscription: { value: true, valid: true, data: true }, + }) const { mutate } = useSetDataValueMutation({ deId, cocId }) const syncData = (value) => { @@ -69,8 +70,9 @@ export const BooleanRadios = ({ { value: value || '' }, { onSuccess: () => { - setLastSyncedValue(value) - setSyncStatus({ syncing: false, synced: true }) + form.mutators.setFieldData(fieldname, { + lastSyncedValue: value, + }) }, } ) @@ -80,9 +82,8 @@ export const BooleanRadios = ({ delete clearButtonProps.type const handleChange = (value) => { - const { valid } = meta // If this value has changed, sync it to server if valid - if (valid && value !== lastSyncedValue) { + if (valid && value !== data.lastSyncedValue) { syncData(value) } } diff --git a/src/data-workspace/inputs/file-inputs.js b/src/data-workspace/inputs/file-inputs.js index dd215358e..e7a3d1e7d 100644 --- a/src/data-workspace/inputs/file-inputs.js +++ b/src/data-workspace/inputs/file-inputs.js @@ -19,12 +19,12 @@ const formatFileSize = (size) => { export const FileResourceInput = ({ fieldname, + form, image, deId, cocId, disabled, locked, - setSyncStatus, onKeyDown, onFocus, }) => { @@ -65,15 +65,17 @@ export const FileResourceInput = ({ const handleChange = ({ files }) => { const newFile = files[0] - input.onChange({ name: newFile.name, size: newFile.size }) + const newFileValue = { name: newFile.name, size: newFile.size } + input.onChange(newFileValue) input.onBlur() if (newFile instanceof File) { - setSyncStatus({ syncing: true, synced: false }) uploadFile( { file: newFile }, { onSuccess: () => { - setSyncStatus({ syncing: false, synced: true }) + form.mutators.setFieldData(fieldname, { + lastSyncedValue: newFileValue, + }) }, } ) @@ -83,10 +85,11 @@ export const FileResourceInput = ({ const handleDelete = () => { input.onChange('') input.onBlur() - setSyncStatus({ syncing: true, synced: false }) deleteFile(null, { onSuccess: () => { - setSyncStatus({ syncing: false, synced: true }) + form.mutators.setFieldData(fieldname, { + lastSyncedValue: null, + }) }, }) } diff --git a/src/data-workspace/inputs/generic-input.js b/src/data-workspace/inputs/generic-input.js index 20ecced82..65be412a7 100644 --- a/src/data-workspace/inputs/generic-input.js +++ b/src/data-workspace/inputs/generic-input.js @@ -1,6 +1,6 @@ import cx from 'classnames' import PropTypes from 'prop-types' -import React, { useState } from 'react' +import React from 'react' import { useField } from 'react-final-form' import { NUMBER_TYPES, @@ -23,9 +23,9 @@ const htmlTypeAttrsByValueType = { export const GenericInput = ({ fieldname, + form, deId, cocId, - setSyncStatus, valueType, onKeyDown, onFocus, @@ -48,14 +48,14 @@ export const GenericInput = ({ } const { input, meta } = useField(fieldname, { validate: validateByValueTypeWithLimits(valueType, limits), - subscription: { value: true, dirty: true, valid: true }, + subscription: { value: true, dirty: true, valid: true, data: true }, format: formatValue, formatOnBlur: true, - // This is require to ensure form is validated on first page load + // This is required to ensure form is validated on first page load // this is because the validate prop doesn't rerender when limits change data: limits, }) - const [lastSyncedValue, setLastSyncedValue] = useState(input.value) + const { mutate } = useSetDataValueMutation({ deId, cocId }) const syncData = (value) => { // todo: Here's where an error state could be set: ('onError') @@ -64,8 +64,9 @@ export const GenericInput = ({ { value: value || '' }, { onSuccess: () => { - setLastSyncedValue(value) - setSyncStatus({ syncing: false, synced: true }) + form.mutators.setFieldData(fieldname, { + lastSyncedValue: value, + }) }, } ) @@ -75,7 +76,7 @@ export const GenericInput = ({ const { value } = input const formattedValue = formatValue(value) const { valid } = meta - if (valid && formattedValue !== lastSyncedValue) { + if (valid && formattedValue !== meta.data.lastSyncedValue) { syncData(formattedValue) } } diff --git a/src/data-workspace/inputs/long-text.js b/src/data-workspace/inputs/long-text.js index 4b7aa38a5..15f0baf39 100644 --- a/src/data-workspace/inputs/long-text.js +++ b/src/data-workspace/inputs/long-text.js @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React from 'react' import { useField } from 'react-final-form' import { useSetDataValueMutation } from '../../shared/index.js' import styles from './inputs.module.css' @@ -6,19 +6,21 @@ import { InputPropTypes } from './utils.js' export const LongText = ({ fieldname, + form, deId, cocId, - setSyncStatus, onKeyDown, onFocus, disabled, locked, }) => { - const { input, meta } = useField(fieldname, { - subscription: { value: true, dirty: true, valid: true }, + const { + input, + meta: { valid, data }, + } = useField(fieldname, { + subscription: { value: true, dirty: true, valid: true, data: true }, }) - const [lastSyncedValue, setLastSyncedValue] = useState(input.value) const { mutate } = useSetDataValueMutation({ deId, cocId }) const syncData = (value) => { // todo: Here's where an error state could be set: ('onError') @@ -27,8 +29,9 @@ export const LongText = ({ { value: value || '' }, { onSuccess: () => { - setLastSyncedValue(value) - setSyncStatus({ syncing: false, synced: true }) + form.mutators.setFieldData(fieldname, { + lastSyncedValue: value, + }) }, } ) @@ -36,8 +39,7 @@ export const LongText = ({ const handleBlur = () => { const { value } = input - const { valid } = meta - if (valid && value !== lastSyncedValue) { + if (valid && value !== data.lastSyncedValue) { syncData(value) } } diff --git a/src/data-workspace/inputs/option-set.js b/src/data-workspace/inputs/option-set.js index 90bd4700e..210895b45 100644 --- a/src/data-workspace/inputs/option-set.js +++ b/src/data-workspace/inputs/option-set.js @@ -1,7 +1,7 @@ import i18n from '@dhis2/d2-i18n' import { Button, SingleSelect, SingleSelectOption } from '@dhis2/ui' import PropTypes from 'prop-types' -import React, { useState } from 'react' +import React from 'react' import { useField } from 'react-final-form' import { useMetadata, @@ -13,19 +13,23 @@ import { InputPropTypes } from './utils.js' export const OptionSet = ({ fieldname, + form, optionSetId, deId, cocId, - setSyncStatus, onKeyDown, onFocus, disabled, locked, }) => { - const { input } = useField(fieldname, { subscription: { value: true } }) + const { + input, + meta: { data }, + } = useField(fieldname, { + subscription: { value: true, data: true }, + }) const { data: metadata } = useMetadata() - const [lastSyncedValue, setLastSyncedValue] = useState(input.value) const { mutate } = useSetDataValueMutation({ deId, cocId }) const syncData = (value) => { // todo: Here's where an error state could be set: ('onError') @@ -34,8 +38,9 @@ export const OptionSet = ({ { value: value || '' }, { onSuccess: () => { - setLastSyncedValue(value) - setSyncStatus({ syncing: false, synced: true }) + form.mutators.setFieldData(fieldname, { + lastSyncedValue: value, + }) }, } ) @@ -43,7 +48,7 @@ export const OptionSet = ({ const handleChange = (value) => { // For a select using onChange, don't need to check valid or dirty, respectively - if (value !== lastSyncedValue) { + if (value !== data.lastSyncedValue) { syncData(value) } } diff --git a/src/data-workspace/inputs/true-only-checkbox.js b/src/data-workspace/inputs/true-only-checkbox.js index 94dccb51d..bf844c877 100644 --- a/src/data-workspace/inputs/true-only-checkbox.js +++ b/src/data-workspace/inputs/true-only-checkbox.js @@ -1,5 +1,5 @@ import { Checkbox } from '@dhis2/ui' -import React, { useState } from 'react' +import React from 'react' import { useField } from 'react-final-form' import { useSetDataValueMutation } from '../../shared/index.js' import styles from './inputs.module.css' @@ -7,20 +7,22 @@ import { convertCallbackSignatures, InputPropTypes } from './utils.js' export const TrueOnlyCheckbox = ({ fieldname, + form, deId, cocId, - setSyncStatus, onKeyDown, onFocus, disabled, locked, }) => { - const { input, meta } = useField(fieldname, { + const { + input, + meta: { valid, data }, + } = useField(fieldname, { type: 'checkbox', - subscription: { value: true, dirty: true, valid: true }, + subscription: { value: true, dirty: true, valid: true, data: true }, }) - const [lastSyncedValue, setLastSyncedValue] = useState(input.value) const { mutate } = useSetDataValueMutation({ deId, cocId }) const syncData = (value) => { // todo: Here's where an error state could be set: ('onError') @@ -29,8 +31,11 @@ export const TrueOnlyCheckbox = ({ { value: value || '' }, { onSuccess: () => { - setLastSyncedValue(value) - setSyncStatus({ syncing: false, synced: true }) + form.mutators.setFieldData(fieldname, { + // value will be formatted to boolean, so keep same format + // '' becomes false + lastSyncedValue: !!value, + }) }, } ) @@ -40,8 +45,9 @@ export const TrueOnlyCheckbox = ({ const handleBlur = () => { // For 'True only', can only send 'true' (or '1') or '' const value = input.checked ? 'true' : '' - const { valid } = meta - if (valid && value !== lastSyncedValue) { + const lastVal = data.lastSyncedValue ? 'true' : '' + + if (valid && value !== lastVal) { syncData(value) } } diff --git a/src/data-workspace/inputs/utils.js b/src/data-workspace/inputs/utils.js index 90921dd70..2cf460e0a 100644 --- a/src/data-workspace/inputs/utils.js +++ b/src/data-workspace/inputs/utils.js @@ -1,5 +1,4 @@ import PropTypes from 'prop-types' - /** * Adapt UI components to Final Form's callbacks */ @@ -11,12 +10,16 @@ export const convertCallbackSignatures = (props) => ({ }) export const InputPropTypes = { + form: PropTypes.shape({ + mutators: PropTypes.shape({ + setFieldData: PropTypes.func, + }), + }), onKeyDown: PropTypes.func.isRequired, cocId: PropTypes.string, deId: PropTypes.string, disabled: PropTypes.bool, lastSyncedValue: PropTypes.any, locked: PropTypes.bool, - setSyncStatus: PropTypes.func, onFocus: PropTypes.func, } diff --git a/yarn.lock b/yarn.lock index 2e79c8093..e96ae3363 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8446,6 +8446,11 @@ filter-obj@^1.1.0: resolved "https://registry.yarnpkg.com/filter-obj/-/filter-obj-1.1.0.tgz#9b311112bc6c6127a16e016c6c5d7f19e0805c5b" integrity sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ== +final-form-set-field-data@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/final-form-set-field-data/-/final-form-set-field-data-1.0.2.tgz#ce19095af5d607148c1e6ce3403d75d40223d848" + integrity sha512-gAnENimyQ5GW3OEGca5pbwm4lYshW2orzfBlPUYqzcm7ZxkQrVO8FqCAgEcCM+Rq9U1OU0q+D+UkqETvvDY6jw== + final-form@^4.20.2, final-form@^4.20.6: version "4.20.7" resolved "https://registry.yarnpkg.com/final-form/-/final-form-4.20.7.tgz#e7e2eb5fd952951d4fe6153d46043da2d68b207e"