Refactor NewObjectForm for Improved Initialization and State Management

- Updated the form initialization logic to ensure it accurately reflects the latest default values and computed entries.
- Enhanced the handling of form state synchronization with object data, ensuring consistency during edits.
- Improved validation and reset logic to maintain form integrity when default values change.
- Streamlined the application of computed values to enhance clarity and maintainability of the component.
This commit is contained in:
Tom Butcher 2026-09-06 18:18:53 +01:00
parent 6bc81cf90f
commit a1697fe0fd

View File

@ -5,6 +5,7 @@ import { useMessageContext } from '../context/MessageContext'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import set from 'lodash/set' import set from 'lodash/set'
import { getModelByName } from '../../../database/ObjectModels' import { getModelByName } from '../../../database/ObjectModels'
import { import {
mergeFormData, mergeFormData,
stripNestedObjectProperties, stripNestedObjectProperties,
@ -14,26 +15,34 @@ import {
const buildObjectFromEntries = (entries = []) => { const buildObjectFromEntries = (entries = []) => {
return entries.reduce((acc, entry) => { return entries.reduce((acc, entry) => {
const { namePath, value } = entry || {} const { namePath, value } = entry || {}
if (!Array.isArray(namePath) || value === undefined) { if (!Array.isArray(namePath) || value === undefined) {
return acc return acc
} }
set(acc, namePath, value) set(acc, namePath, value)
return acc return acc
}, {}) }, {})
} }
// Patch computed fields onto the complete object.
// This mirrors the handling used by ObjectForm.
const applyComputedEntries = (base, entries = []) => { const applyComputedEntries = (base, entries = []) => {
const result = mergeFormData(base || {}) const result = mergeFormData(base || {})
entries.forEach((entry) => { entries.forEach((entry) => {
const { namePath, value } = entry || {} const { namePath, value } = entry || {}
if (!Array.isArray(namePath) || value === undefined) return if (!Array.isArray(namePath) || value === undefined) return
set(result, namePath, value) set(result, namePath, value)
}) })
return result return result
} }
/** /**
* NewObjectForm is a reusable form component for creating new objects. * NewObjectForm is a reusable form component for creating new objects.
*
* It handles form validation, submission, and error handling logic. * It handles form validation, submission, and error handling logic.
* *
* Props: * Props:
@ -45,21 +54,33 @@ const applyComputedEntries = (base, entries = []) => {
* }) => ReactNode * }) => ReactNode
*/ */
const NewObjectForm = ({ type, style, defaultValues = {}, children }) => { const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
// Do not initialise this from defaultValues.
// The form initialisation effect below is the single source of truth,
// matching ObjectForm's fetched-object handling.
const [objectData, setObjectData] = useState({ const [objectData, setObjectData] = useState({
...defaultValues,
_isEditing: true _isEditing: true
}) })
const [submitLoading, setSubmitLoading] = useState(false) const [submitLoading, setSubmitLoading] = useState(false)
const [formValid, setFormValid] = useState(false) const [formValid, setFormValid] = useState(false)
const [form] = Form.useForm() const [form] = Form.useForm()
const validationRunRef = useRef(0) const validationRunRef = useRef(0)
const formUpdateValues = Form.useWatch([], form) const formUpdateValues = Form.useWatch([], form)
const { showSuccess, showError: showMessageError } = useMessageContext() const { showSuccess, showError: showMessageError } = useMessageContext()
const { createObject, showError } = useContext(ApiServerContext) const { createObject, showError } = useContext(ApiServerContext)
const model = getModelByName(type)
const validateForm = useCallback(() => { const validateForm = useCallback(() => {
const validationRun = ++validationRunRef.current const validationRun = ++validationRunRef.current
let cancelled = false let cancelled = false
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
form form
.validateFields({ validateOnly: true }) .validateFields({ validateOnly: true })
@ -81,27 +102,59 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
} }
}, [form]) }, [form])
const model = getModelByName(type)
const calculateComputedValues = useCallback( const calculateComputedValues = useCallback(
(currentData, modelDefinition, options = {}) => { (currentData, modelDefinition, options = {}) => {
return calculateModelComputedEntries(currentData, modelDefinition, options) return calculateModelComputedEntries(
currentData,
modelDefinition,
options
)
}, },
[] []
) )
// Set initial form values when defaultValues change /*
* Initialise the form from defaultValues.
*
* This deliberately follows ObjectForm's initial fetch handling:
*
* defaultValues
* ↓
* calculate computed values
* ↓
* apply computed values to the complete object
* ↓
* setObjectData(...)
* ↓
* form.setFieldsValue(...)
*
* Keeping the exact same object structure in objectData and the Form
* is important for object-select fields.
*/
useEffect(() => { useEffect(() => {
if (Object.keys(defaultValues).length > 0) { const initialData = mergeFormData(defaultValues || {})
const computedEntries = calculateComputedValues(defaultValues, model)
const initialFormData = applyComputedEntries( const computedEntries = calculateComputedValues(initialData, model)
defaultValues,
computedEntries const initialFormData = applyComputedEntries(initialData, computedEntries)
)
form.setFieldsValue(initialFormData) const nextObjectData = {
setObjectData((prev) => mergeFormData(prev, initialFormData)) ...initialFormData,
return validateForm() _isEditing: true
} }
// Clear any previous values before applying the new defaults.
// This is particularly important when defaultValues changes while
// the component remains mounted.
form.resetFields()
// Use the complete object as the form value, just like ObjectForm.
form.setFieldsValue(initialFormData)
// Keep objectData in sync with exactly the same initial data.
setObjectData(nextObjectData)
return validateForm()
}, [form, defaultValues, calculateComputedValues, model, validateForm]) }, [form, defaultValues, calculateComputedValues, model, validateForm])
// Validate form on change // Validate form on change
@ -112,23 +165,34 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
setSubmitLoading(true) setSubmitLoading(true)
const currentFormValues = form.getFieldsValue() const currentFormValues = form.getFieldsValue()
const currentFormData = mergeFormData(objectData || {}, currentFormValues) const currentFormData = mergeFormData(objectData || {}, currentFormValues)
const computedEntries = calculateComputedValues(currentFormData, model) const computedEntries = calculateComputedValues(currentFormData, model)
const computedObjectData = applyComputedEntries( const computedObjectData = applyComputedEntries(
currentFormData, currentFormData,
computedEntries computedEntries
) )
const payload = stripNestedObjectProperties(computedObjectData, model) const payload = stripNestedObjectProperties(computedObjectData, model)
const newObject = await createObject(type, payload) const newObject = await createObject(type, payload)
showSuccess('Object created successfully') showSuccess('Object created successfully')
return newObject return newObject
} catch (err) { } catch (err) {
console.error(err) console.error(err)
if (err.errorFields) { if (err.errorFields) {
return return
} }
showMessageError('Failed to create object') showMessageError('Failed to create object')
showError( showError(
`Failed to create object. Message: ${err.message}. Code: ${err.code}`, `Failed to create object. Message: ${err.message}. Code: ${err.code}`,
() => handleSubmit() () => handleSubmit()
@ -146,12 +210,17 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
disabled={submitLoading} disabled={submitLoading}
onValuesChange={(_changedValues, allFormValues) => { onValuesChange={(_changedValues, allFormValues) => {
const currentFormData = mergeFormData(objectData || {}, allFormValues) const currentFormData = mergeFormData(objectData || {}, allFormValues)
const computedEntries = calculateComputedValues(currentFormData, model) const computedEntries = calculateComputedValues(currentFormData, model)
if (Array.isArray(computedEntries) && computedEntries.length > 0) { if (Array.isArray(computedEntries) && computedEntries.length > 0) {
computedEntries.forEach(({ namePath, value }) => { computedEntries.forEach(({ namePath, value }) => {
if (!Array.isArray(namePath) || value === undefined) return if (!Array.isArray(namePath) || value === undefined) {
return
}
const currentValue = form.getFieldValue(namePath) const currentValue = form.getFieldValue(namePath)
if (currentValue !== value) { if (currentValue !== value) {
if (typeof form.setFieldValue === 'function') { if (typeof form.setFieldValue === 'function') {
form.setFieldValue(namePath, value) form.setFieldValue(namePath, value)
@ -159,6 +228,7 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
const fallbackPayload = buildObjectFromEntries([ const fallbackPayload = buildObjectFromEntries([
{ namePath, value } { namePath, value }
]) ])
form.setFieldsValue(fallbackPayload) form.setFieldsValue(fallbackPayload)
} }
} }
@ -166,6 +236,9 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
} }
const allValues = applyComputedEntries(allFormValues, computedEntries) const allValues = applyComputedEntries(allFormValues, computedEntries)
allValues._isEditing = true
setObjectData((prev) => mergeFormData(prev, allValues)) setObjectData((prev) => mergeFormData(prev, allValues))
}} }}
> >
@ -185,8 +258,11 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
NewObjectForm.propTypes = { NewObjectForm.propTypes = {
type: PropTypes.string.isRequired, type: PropTypes.string.isRequired,
children: PropTypes.func.isRequired, children: PropTypes.func.isRequired,
style: PropTypes.object, style: PropTypes.object,
defaultValues: PropTypes.object defaultValues: PropTypes.object
} }