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 set from 'lodash/set'
import { getModelByName } from '../../../database/ObjectModels'
import {
mergeFormData,
stripNestedObjectProperties,
@ -14,26 +15,34 @@ import {
const buildObjectFromEntries = (entries = []) => {
return entries.reduce((acc, entry) => {
const { namePath, value } = entry || {}
if (!Array.isArray(namePath) || value === undefined) {
return acc
}
set(acc, namePath, value)
return acc
}, {})
}
// Patch computed fields onto the complete object.
// This mirrors the handling used by ObjectForm.
const applyComputedEntries = (base, entries = []) => {
const result = mergeFormData(base || {})
entries.forEach((entry) => {
const { namePath, value } = entry || {}
if (!Array.isArray(namePath) || value === undefined) return
set(result, namePath, value)
})
return result
}
/**
* NewObjectForm is a reusable form component for creating new objects.
*
* It handles form validation, submission, and error handling logic.
*
* Props:
@ -45,21 +54,33 @@ const applyComputedEntries = (base, entries = []) => {
* }) => ReactNode
*/
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({
...defaultValues,
_isEditing: true
})
const [submitLoading, setSubmitLoading] = useState(false)
const [formValid, setFormValid] = useState(false)
const [form] = Form.useForm()
const validationRunRef = useRef(0)
const formUpdateValues = Form.useWatch([], form)
const { showSuccess, showError: showMessageError } = useMessageContext()
const { createObject, showError } = useContext(ApiServerContext)
const model = getModelByName(type)
const validateForm = useCallback(() => {
const validationRun = ++validationRunRef.current
let cancelled = false
const timeoutId = setTimeout(() => {
form
.validateFields({ validateOnly: true })
@ -81,27 +102,59 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
}
}, [form])
const model = getModelByName(type)
const calculateComputedValues = useCallback(
(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(() => {
if (Object.keys(defaultValues).length > 0) {
const computedEntries = calculateComputedValues(defaultValues, model)
const initialFormData = applyComputedEntries(
defaultValues,
computedEntries
)
form.setFieldsValue(initialFormData)
setObjectData((prev) => mergeFormData(prev, initialFormData))
return validateForm()
const initialData = mergeFormData(defaultValues || {})
const computedEntries = calculateComputedValues(initialData, model)
const initialFormData = applyComputedEntries(initialData, computedEntries)
const nextObjectData = {
...initialFormData,
_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])
// Validate form on change
@ -112,23 +165,34 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
const handleSubmit = async () => {
try {
setSubmitLoading(true)
const currentFormValues = form.getFieldsValue()
const currentFormData = mergeFormData(objectData || {}, currentFormValues)
const computedEntries = calculateComputedValues(currentFormData, model)
const computedObjectData = applyComputedEntries(
currentFormData,
computedEntries
)
const payload = stripNestedObjectProperties(computedObjectData, model)
const newObject = await createObject(type, payload)
showSuccess('Object created successfully')
return newObject
} catch (err) {
console.error(err)
if (err.errorFields) {
return
}
showMessageError('Failed to create object')
showError(
`Failed to create object. Message: ${err.message}. Code: ${err.code}`,
() => handleSubmit()
@ -146,12 +210,17 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
disabled={submitLoading}
onValuesChange={(_changedValues, allFormValues) => {
const currentFormData = mergeFormData(objectData || {}, allFormValues)
const computedEntries = calculateComputedValues(currentFormData, model)
if (Array.isArray(computedEntries) && computedEntries.length > 0) {
computedEntries.forEach(({ namePath, value }) => {
if (!Array.isArray(namePath) || value === undefined) return
if (!Array.isArray(namePath) || value === undefined) {
return
}
const currentValue = form.getFieldValue(namePath)
if (currentValue !== value) {
if (typeof form.setFieldValue === 'function') {
form.setFieldValue(namePath, value)
@ -159,6 +228,7 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
const fallbackPayload = buildObjectFromEntries([
{ namePath, value }
])
form.setFieldsValue(fallbackPayload)
}
}
@ -166,6 +236,9 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
}
const allValues = applyComputedEntries(allFormValues, computedEntries)
allValues._isEditing = true
setObjectData((prev) => mergeFormData(prev, allValues))
}}
>
@ -185,8 +258,11 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
NewObjectForm.propTypes = {
type: PropTypes.string.isRequired,
children: PropTypes.func.isRequired,
style: PropTypes.object,
defaultValues: PropTypes.object
}

View File

@ -25,6 +25,14 @@ const areValuesEqual = (v1, v2) => {
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) => {
if (!Array.isArray(nodes)) return null
for (const node of nodes) {
@ -69,6 +77,23 @@ const isValueInTree = (nodes, id) => {
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 = (
externalValue,
multiple,
@ -528,9 +553,27 @@ const ObjectSelect = ({
setObjectList(objects)
setTreeData(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 }
},
[buildTreeData]
[buildTreeData, multiple]
)
const buildFilterFromNode = useCallback(
@ -633,6 +676,17 @@ const ObjectSelect = ({
const onTreeSelectChange = useCallback(
(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)
clearedMissingValueRef.current = false
// Mark this as an internal change
@ -827,7 +881,7 @@ const ObjectSelect = ({
setExpandedKeys([...new Set(pathKeys)])
setTreeSelectValue(
value
.map((item) => (item && typeof item === 'object' ? item._id : item))
.map((item) => toSelectValue(getValueId(item)))
.filter((id) => id != null)
)
setInitialized(true)
@ -880,7 +934,7 @@ const ObjectSelect = ({
setExpandedKeys(pathKeys)
// Fetch with the new filter
handleFetchObjectsProperties(valueFilter)
setTreeSelectValue(valueRef.current._id)
setTreeSelectValue(toSelectValue(valueRef.current._id))
setInitialized(true)
return
}
@ -905,7 +959,13 @@ const ObjectSelect = ({
setInitialized(true)
}
}
handleValue()
const timeoutId = setTimeout(() => {
handleValue()
}, 10)
return () => {
clearTimeout(timeoutId)
}
}, [
value,
filter,
@ -940,6 +1000,7 @@ const ObjectSelect = ({
setTreeVersion((v) => v + 1)
setExpandedKeys([])
setInitialized(false)
valueRef.current = null
onTreeSelectChange(null)
setTreeSelectValue(null)
setInitialLoading(true)
@ -963,7 +1024,12 @@ const ObjectSelect = ({
if (changeSource == 'external') {
setObjectPropertiesTree({})
setTreeData([])
treeDataRef.current = []
setInitialized(false)
setInitialLoading(true)
valueRef.current = null
clearedMissingValueRef.current = false
setValueNotFound(false)
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 { AuthContext } from './AuthContext'
import { useLocation, useNavigate } from 'react-router-dom'
import { LoadingOutlined } from '@ant-design/icons'
import axios from 'axios'
import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon'
import ReloadIcon from '../../Icons/ReloadIcon'
import LockIcon from '../../Icons/LockIcon'
import config from '../../../config'
import loglevel from 'loglevel'
import { getModelByName } from '../../../database/ObjectModels'
import ProgressDisplay from '../common/ProgressDisplay'
const logger = loglevel.getLogger('ApiServerContext')
@ -2920,42 +2921,59 @@ const ApiServerProvider = ({ children }) => {
{children}
<Modal
title={
<Space size={'middle'}>
<ExclamationOctagonIcon />
Connection Lost
</Space>
!isReconnecting ? (
<Space size={'middle'}>
<ExclamationOctagonIcon />
Connection Lost
</Space>
) : (
false
)
}
open={Boolean(token) && authenticated == true && connectionIssue}
style={{ maxWidth: 480 }}
style={{ maxWidth: !isReconnecting ? 480 : 260 }}
zIndex={3000}
closable={false}
height={isReconnecting ? 20 : undefined}
centered
className={isReconnecting ? 'loading-modal' : undefined}
maskClosable={false}
getContainer={() => document.body}
footer={[
<Button
key='reconnect'
loading={isReconnecting}
onClick={() => attemptReconnect()}
>
Reconnect
</Button>
]}
footer={
!isReconnecting
? [
<Button
key='reconnect'
loading={isReconnecting}
onClick={() => attemptReconnect()}
>
Reconnect
</Button>
]
: false
}
>
<Flex vertical gap='middle'>
<Text>
{isReconnecting
? 'Reconnecting to the API server...'
: `Lost connection to the API server. Reconnecting in ${reconnectSecondsRemaining} second${
reconnectSecondsRemaining === 1 ? '' : 's'
}...`}
</Text>
<ProgressDisplay
percent={isReconnecting ? 0 : 100 - reconnectProgress}
showInfo={false}
status={'exception'}
/>
</Flex>
{!isReconnecting ? (
<Flex vertical gap='middle'>
<Text>
{isReconnecting
? 'Reconnecting to the API server...'
: `Lost connection to the API server. Reconnecting in ${reconnectSecondsRemaining} second${
reconnectSecondsRemaining === 1 ? '' : 's'
}...`}
</Text>
<ProgressDisplay
percent={isReconnecting ? 0 : 100 - reconnectProgress}
showInfo={false}
status={'exception'}
/>
</Flex>
) : (
<Space size={'middle'}>
<LoadingOutlined />
<Text style={{ margin: 0 }}>Reconnecting, please wait...</Text>
</Space>
)}
</Modal>
<Modal
title={

View File

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

View File

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