Compare commits
4 Commits
56479ea358
...
749ddf0c31
| Author | SHA1 | Date | |
|---|---|---|---|
| 749ddf0c31 | |||
| 8bb8d417f7 | |||
| a1697fe0fd | |||
| 6bc81cf90f |
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
|
||||
@ -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,19 +2921,27 @@ const ApiServerProvider = ({ children }) => {
|
||||
{children}
|
||||
<Modal
|
||||
title={
|
||||
!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={[
|
||||
footer={
|
||||
!isReconnecting
|
||||
? [
|
||||
<Button
|
||||
key='reconnect'
|
||||
loading={isReconnecting}
|
||||
@ -2940,8 +2949,11 @@ const ApiServerProvider = ({ children }) => {
|
||||
>
|
||||
Reconnect
|
||||
</Button>
|
||||
]}
|
||||
]
|
||||
: false
|
||||
}
|
||||
>
|
||||
{!isReconnecting ? (
|
||||
<Flex vertical gap='middle'>
|
||||
<Text>
|
||||
{isReconnecting
|
||||
@ -2956,6 +2968,12 @@ const ApiServerProvider = ({ children }) => {
|
||||
status={'exception'}
|
||||
/>
|
||||
</Flex>
|
||||
) : (
|
||||
<Space size={'middle'}>
|
||||
<LoadingOutlined />
|
||||
<Text style={{ margin: 0 }}>Reconnecting, please wait...</Text>
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
<Modal
|
||||
title={
|
||||
|
||||
@ -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) => ({
|
||||
objectData: (objectData) => {
|
||||
return {
|
||||
orderType: 'salesOrder',
|
||||
order: { _id: objectData._id }
|
||||
})
|
||||
order: objectData
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'newInvoice',
|
||||
|
||||
@ -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: [
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user