Compare commits

...

4 Commits

Author SHA1 Message Date
749ddf0c31 Refactor SalesOrder and User Models for Improved Object Data Handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Updated the objectData method in SalesOrder and User models to directly return the complete objectData instead of a subset, enhancing data accessibility.
- Improved clarity and maintainability of the objectData function structure in both models, ensuring consistent handling of order and user data.
2026-09-06 19:24:25 +01:00
8bb8d417f7 Enhance ObjectSelect Component with Improved Value Handling and Synchronization
- Introduced utility functions to standardize value conversion and retrieval, improving consistency in value handling.
- Enhanced synchronization of external values with internal state, ensuring accurate representation of selected items.
- Improved selection change handling to prevent unnecessary updates when no valid selection is made.
- Refactored tree selection logic for better clarity and maintainability, ensuring robust handling of both single and multiple selections.
2026-09-06 19:24:17 +01:00
a1697fe0fd 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.
2026-09-06 18:18:53 +01:00
6bc81cf90f Enhance ApiServerContext with Improved Reconnection Handling
- Added loading state indication during reconnection attempts in the modal.
- Adjusted modal dimensions and styles based on the reconnection status for better user experience.
- Streamlined the display logic for connection lost messages and reconnection progress, improving clarity and responsiveness.
2026-09-06 17:10:48 +01:00
5 changed files with 216 additions and 54 deletions

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

View File

@ -25,6 +25,14 @@ const areValuesEqual = (v1, v2) => {
return String(id1) === String(id2) return String(id1) === String(id2)
} }
const toSelectValue = (id) => {
if (id == null || id === '') return null
return String(id).toLowerCase()
}
const getValueId = (item) =>
item && typeof item === 'object' ? item._id : item
const getFirstSelectableLeaf = (nodes) => { const getFirstSelectableLeaf = (nodes) => {
if (!Array.isArray(nodes)) return null if (!Array.isArray(nodes)) return null
for (const node of nodes) { for (const node of nodes) {
@ -69,6 +77,23 @@ const isValueInTree = (nodes, id) => {
return findTreeNodeByValue(nodes, id) != null return findTreeNodeByValue(nodes, id) != null
} }
const getSelectValueFromExternal = (externalValue, multiple, nodes) => {
if (externalValue == null || !Array.isArray(nodes) || nodes.length === 0) {
return undefined
}
if (multiple) {
const values = Array.isArray(externalValue) ? externalValue : []
const ids = values
.map((item) => toSelectValue(getValueId(item)))
.filter((id) => id != null && isValueInTree(nodes, id))
return ids
}
const node = findTreeNodeByValue(nodes, getValueId(externalValue))
return node?.value
}
const isExternalValueMissing = ( const isExternalValueMissing = (
externalValue, externalValue,
multiple, multiple,
@ -528,9 +553,27 @@ const ObjectSelect = ({
setObjectList(objects) setObjectList(objects)
setTreeData(treeNodes) setTreeData(treeNodes)
treeDataRef.current = treeNodes treeDataRef.current = treeNodes
const syncedValue = getSelectValueFromExternal(
valueRef.current,
multiple,
treeNodes
)
if (multiple) {
if (Array.isArray(syncedValue) && syncedValue.length > 0) {
setTreeSelectValue(syncedValue)
setValueNotFound(false)
clearedMissingValueRef.current = false
}
} else if (syncedValue != null) {
setTreeSelectValue(syncedValue)
setValueNotFound(false)
clearedMissingValueRef.current = false
}
return { treeNodes, objects } return { treeNodes, objects }
}, },
[buildTreeData] [buildTreeData, multiple]
) )
const buildFilterFromNode = useCallback( const buildFilterFromNode = useCallback(
@ -633,6 +676,17 @@ const ObjectSelect = ({
const onTreeSelectChange = useCallback( const onTreeSelectChange = useCallback(
(value) => { (value) => {
const isEmptySelection = multiple
? !Array.isArray(value) || value.length === 0
: value == null || value === ''
if (
isEmptySelection &&
treeDataRef.current.length === 0 &&
valueRef.current != null
) {
return
}
setValueNotFound(false) setValueNotFound(false)
clearedMissingValueRef.current = false clearedMissingValueRef.current = false
// Mark this as an internal change // Mark this as an internal change
@ -827,7 +881,7 @@ const ObjectSelect = ({
setExpandedKeys([...new Set(pathKeys)]) setExpandedKeys([...new Set(pathKeys)])
setTreeSelectValue( setTreeSelectValue(
value value
.map((item) => (item && typeof item === 'object' ? item._id : item)) .map((item) => toSelectValue(getValueId(item)))
.filter((id) => id != null) .filter((id) => id != null)
) )
setInitialized(true) setInitialized(true)
@ -880,7 +934,7 @@ const ObjectSelect = ({
setExpandedKeys(pathKeys) setExpandedKeys(pathKeys)
// Fetch with the new filter // Fetch with the new filter
handleFetchObjectsProperties(valueFilter) handleFetchObjectsProperties(valueFilter)
setTreeSelectValue(valueRef.current._id) setTreeSelectValue(toSelectValue(valueRef.current._id))
setInitialized(true) setInitialized(true)
return return
} }
@ -905,7 +959,13 @@ const ObjectSelect = ({
setInitialized(true) setInitialized(true)
} }
} }
handleValue() const timeoutId = setTimeout(() => {
handleValue()
}, 10)
return () => {
clearTimeout(timeoutId)
}
}, [ }, [
value, value,
filter, filter,
@ -940,6 +1000,7 @@ const ObjectSelect = ({
setTreeVersion((v) => v + 1) setTreeVersion((v) => v + 1)
setExpandedKeys([]) setExpandedKeys([])
setInitialized(false) setInitialized(false)
valueRef.current = null
onTreeSelectChange(null) onTreeSelectChange(null)
setTreeSelectValue(null) setTreeSelectValue(null)
setInitialLoading(true) setInitialLoading(true)
@ -963,7 +1024,12 @@ const ObjectSelect = ({
if (changeSource == 'external') { if (changeSource == 'external') {
setObjectPropertiesTree({}) setObjectPropertiesTree({})
setTreeData([]) setTreeData([])
treeDataRef.current = []
setInitialized(false) setInitialized(false)
setInitialLoading(true)
valueRef.current = null
clearedMissingValueRef.current = false
setValueNotFound(false)
prevValuesRef.current = { type, masterFilter } prevValuesRef.current = { type, masterFilter }
} }

View File

@ -12,13 +12,14 @@ import { message, Modal, Space, Button, Typography, Flex } from 'antd'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { AuthContext } from './AuthContext' import { AuthContext } from './AuthContext'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { LoadingOutlined } from '@ant-design/icons'
import axios from 'axios' import axios from 'axios'
import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon' import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon'
import ReloadIcon from '../../Icons/ReloadIcon' import ReloadIcon from '../../Icons/ReloadIcon'
import LockIcon from '../../Icons/LockIcon' import LockIcon from '../../Icons/LockIcon'
import config from '../../../config' import config from '../../../config'
import loglevel from 'loglevel' import loglevel from 'loglevel'
import { getModelByName } from '../../../database/ObjectModels' import { getModelByName } from '../../../database/ObjectModels'
import ProgressDisplay from '../common/ProgressDisplay' import ProgressDisplay from '../common/ProgressDisplay'
const logger = loglevel.getLogger('ApiServerContext') const logger = loglevel.getLogger('ApiServerContext')
@ -2920,42 +2921,59 @@ const ApiServerProvider = ({ children }) => {
{children} {children}
<Modal <Modal
title={ title={
<Space size={'middle'}> !isReconnecting ? (
<ExclamationOctagonIcon /> <Space size={'middle'}>
Connection Lost <ExclamationOctagonIcon />
</Space> Connection Lost
</Space>
) : (
false
)
} }
open={Boolean(token) && authenticated == true && connectionIssue} open={Boolean(token) && authenticated == true && connectionIssue}
style={{ maxWidth: 480 }} style={{ maxWidth: !isReconnecting ? 480 : 260 }}
zIndex={3000} zIndex={3000}
closable={false} closable={false}
height={isReconnecting ? 20 : undefined}
centered centered
className={isReconnecting ? 'loading-modal' : undefined}
maskClosable={false} maskClosable={false}
getContainer={() => document.body} getContainer={() => document.body}
footer={[ footer={
<Button !isReconnecting
key='reconnect' ? [
loading={isReconnecting} <Button
onClick={() => attemptReconnect()} key='reconnect'
> loading={isReconnecting}
Reconnect onClick={() => attemptReconnect()}
</Button> >
]} Reconnect
</Button>
]
: false
}
> >
<Flex vertical gap='middle'> {!isReconnecting ? (
<Text> <Flex vertical gap='middle'>
{isReconnecting <Text>
? 'Reconnecting to the API server...' {isReconnecting
: `Lost connection to the API server. Reconnecting in ${reconnectSecondsRemaining} second${ ? 'Reconnecting to the API server...'
reconnectSecondsRemaining === 1 ? '' : 's' : `Lost connection to the API server. Reconnecting in ${reconnectSecondsRemaining} second${
}...`} reconnectSecondsRemaining === 1 ? '' : 's'
</Text> }...`}
<ProgressDisplay </Text>
percent={isReconnecting ? 0 : 100 - reconnectProgress} <ProgressDisplay
showInfo={false} percent={isReconnecting ? 0 : 100 - reconnectProgress}
status={'exception'} showInfo={false}
/> status={'exception'}
</Flex> />
</Flex>
) : (
<Space size={'middle'}>
<LoadingOutlined />
<Text style={{ margin: 0 }}>Reconnecting, please wait...</Text>
</Space>
)}
</Modal> </Modal>
<Modal <Modal
title={ title={

View File

@ -128,7 +128,7 @@ export const SalesOrder = {
return objectData?.state?.type != 'draft' return objectData?.state?.type != 'draft'
}, },
objectData: (objectData) => ({ objectData: (objectData) => ({
order: { _id: objectData._id }, order: objectData,
orderType: 'salesOrder', orderType: 'salesOrder',
syncAmount: 'itemPrice' syncAmount: 'itemPrice'
}) })
@ -142,10 +142,12 @@ export const SalesOrder = {
disabled: (objectData) => { disabled: (objectData) => {
return objectData?.state?.type != 'draft' return objectData?.state?.type != 'draft'
}, },
objectData: (objectData) => ({ objectData: (objectData) => {
orderType: 'salesOrder', return {
order: { _id: objectData._id } orderType: 'salesOrder',
}) order: objectData
}
}
}, },
{ {
name: 'newInvoice', name: 'newInvoice',

View File

@ -74,7 +74,7 @@ export const User = {
disabled: (objectData) => { disabled: (objectData) => {
return objectData?._user?._id != objectData?._id return objectData?._user?._id != objectData?._id
}, },
objectData: (objectData) => ({ user: objectData }) objectData: (objectData) => ({ user: objectData?._user || objectData })
} }
], ],
pages: [ pages: [