Enhance DeployJob and NewObjectForm Components with Object Hydration and State Management
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Updated DeployJob to utilize useRef for job state management and improved object hydration logic. - Introduced utility functions for managing object property references and hydration in object forms. - Enhanced NewObjectForm to synchronize form values with fetched object data, ensuring real-time updates. - Added new properties to Job and SubJob models for better data representation and filtering capabilities.
This commit is contained in:
parent
b65fe208c0
commit
82d76db106
@ -1,4 +1,4 @@
|
|||||||
import { useState, useContext, useEffect, useCallback } from 'react'
|
import { useState, useContext, useEffect, useCallback, useRef } from 'react'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import WizardView from '../../common/WizardView'
|
import WizardView from '../../common/WizardView'
|
||||||
import { ApiServerContext } from '../../context/ApiServerContext'
|
import { ApiServerContext } from '../../context/ApiServerContext'
|
||||||
@ -6,7 +6,17 @@ import { Descriptions, Flex, Typography } from 'antd'
|
|||||||
import ObjectTable from '../../common/ObjectTable'
|
import ObjectTable from '../../common/ObjectTable'
|
||||||
import { getModelByName } from '../../../../database/ObjectModels.js'
|
import { getModelByName } from '../../../../database/ObjectModels.js'
|
||||||
import ObjectProperty from '../../common/ObjectProperty'
|
import ObjectProperty from '../../common/ObjectProperty'
|
||||||
|
import { calculateModelComputedEntries } from '../../utils/Utils'
|
||||||
|
import {
|
||||||
|
applyHydratedObjects,
|
||||||
|
applyRelatedObjectUpdates,
|
||||||
|
collectObjectPropertyRefs,
|
||||||
|
getReferenceId,
|
||||||
|
getRelatedObjectSubscriptionKey
|
||||||
|
} from '../../utils/objectPropertyRefs'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
const DeployJob = ({
|
const DeployJob = ({
|
||||||
onOk,
|
onOk,
|
||||||
objectData = undefined,
|
objectData = undefined,
|
||||||
@ -14,12 +24,45 @@ const DeployJob = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [deployLoading, setDeployLoading] = useState(false)
|
const [deployLoading, setDeployLoading] = useState(false)
|
||||||
const [job, setJob] = useState(objectData)
|
const [job, setJob] = useState(objectData)
|
||||||
|
const jobRef = useRef(job)
|
||||||
const [deployedSubJobsCount, setDeployedSubJobsCount] = useState(0)
|
const [deployedSubJobsCount, setDeployedSubJobsCount] = useState(0)
|
||||||
const [subJobsCount, setSubJobsCount] = useState(999)
|
const [subJobsCount, setSubJobsCount] = useState(999)
|
||||||
const [allPrintersOnline, setAllPrintersOnline] = useState(false)
|
const [allPrintersOnline, setAllPrintersOnline] = useState(false)
|
||||||
const { sendObjectAction, fetchObjects } = useContext(ApiServerContext)
|
const {
|
||||||
|
sendObjectAction,
|
||||||
|
fetchObjects,
|
||||||
|
fetchObject,
|
||||||
|
subscribeToObjectUpdates,
|
||||||
|
connected
|
||||||
|
} = useContext(ApiServerContext)
|
||||||
|
const fetchObjectRef = useRef(fetchObject)
|
||||||
|
fetchObjectRef.current = fetchObject
|
||||||
|
|
||||||
|
const model = getModelByName('job')
|
||||||
|
const objectDataId = getReferenceId(objectData)
|
||||||
|
const objectDataRef = useRef(objectData)
|
||||||
|
objectDataRef.current = objectData
|
||||||
|
|
||||||
|
const commitJob = useCallback((value) => {
|
||||||
|
const next = typeof value === 'function' ? value(jobRef.current) : value
|
||||||
|
jobRef.current = next
|
||||||
|
setJob(next)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const calculateComputedValues = useCallback(
|
||||||
|
(currentData, modelDefinition, options) => {
|
||||||
|
return calculateModelComputedEntries(
|
||||||
|
currentData,
|
||||||
|
modelDefinition,
|
||||||
|
options
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
const handleDeploy = useCallback(async () => {
|
const handleDeploy = useCallback(async () => {
|
||||||
|
if (!job?._id) return
|
||||||
|
|
||||||
setDeployLoading(true)
|
setDeployLoading(true)
|
||||||
var hasMore = true
|
var hasMore = true
|
||||||
var currentPage = 1
|
var currentPage = 1
|
||||||
@ -47,7 +90,7 @@ const DeployJob = ({
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [job._id, sendObjectAction, fetchObjects])
|
}, [job?._id, sendObjectAction, fetchObjects])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (deployedSubJobsCount == subJobsCount && deployLoading == true) {
|
if (deployedSubJobsCount == subJobsCount && deployLoading == true) {
|
||||||
@ -56,8 +99,110 @@ const DeployJob = ({
|
|||||||
}, [deployedSubJobsCount, subJobsCount, deployLoading, onOk])
|
}, [deployedSubJobsCount, subJobsCount, deployLoading, onOk])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (objectData?.printers) {
|
commitJob(objectDataRef.current)
|
||||||
if (objectData.printers.every((printer) => printer.online)) {
|
}, [objectDataId, commitJob])
|
||||||
|
|
||||||
|
const hydrationKey = collectObjectPropertyRefs(job, model)
|
||||||
|
.filter((ref) => !ref.hydrated)
|
||||||
|
.map((ref) => `${ref.objectType}:${ref.id.toLowerCase()}`)
|
||||||
|
.sort()
|
||||||
|
.join('|')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hydrationKey) return undefined
|
||||||
|
|
||||||
|
const refs = collectObjectPropertyRefs(jobRef.current, model).filter(
|
||||||
|
(ref) => !ref.hydrated
|
||||||
|
)
|
||||||
|
if (refs.length === 0) return undefined
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
const seen = new Set()
|
||||||
|
const uniqueRefs = []
|
||||||
|
refs.forEach((ref) => {
|
||||||
|
const key = `${ref.objectType}:${ref.id.toLowerCase()}`
|
||||||
|
if (seen.has(key)) return
|
||||||
|
seen.add(key)
|
||||||
|
uniqueRefs.push(ref)
|
||||||
|
})
|
||||||
|
|
||||||
|
const hydrate = async () => {
|
||||||
|
const fetchedByKey = new Map()
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
uniqueRefs.map(async (ref) => {
|
||||||
|
const fullObject = await fetchObjectRef.current(
|
||||||
|
ref.id,
|
||||||
|
ref.objectType
|
||||||
|
)
|
||||||
|
if (!getReferenceId(fullObject)) return
|
||||||
|
fetchedByKey.set(
|
||||||
|
`${ref.objectType}:${ref.id.toLowerCase()}`,
|
||||||
|
fullObject
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
const applied = applyHydratedObjects(
|
||||||
|
jobRef.current,
|
||||||
|
refs,
|
||||||
|
fetchedByKey,
|
||||||
|
model,
|
||||||
|
calculateComputedValues
|
||||||
|
)
|
||||||
|
if (!applied) return
|
||||||
|
|
||||||
|
commitJob(applied.next)
|
||||||
|
}
|
||||||
|
|
||||||
|
hydrate()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [hydrationKey, model, calculateComputedValues, commitJob])
|
||||||
|
|
||||||
|
const relatedObjectKey = getRelatedObjectSubscriptionKey(job, model)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (connected !== true || !relatedObjectKey) return undefined
|
||||||
|
|
||||||
|
const unsubscribes = relatedObjectKey.split('|').map((entry) => {
|
||||||
|
const separator = entry.indexOf(':')
|
||||||
|
const objectType = entry.slice(0, separator)
|
||||||
|
const id = entry.slice(separator + 1)
|
||||||
|
|
||||||
|
return subscribeToObjectUpdates(id, objectType, (value) => {
|
||||||
|
const applied = applyRelatedObjectUpdates(
|
||||||
|
jobRef.current,
|
||||||
|
objectType,
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
model,
|
||||||
|
calculateComputedValues
|
||||||
|
)
|
||||||
|
if (!applied) return
|
||||||
|
commitJob(applied.next)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribes.forEach((unsubscribe) => unsubscribe?.())
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
connected,
|
||||||
|
relatedObjectKey,
|
||||||
|
subscribeToObjectUpdates,
|
||||||
|
model,
|
||||||
|
calculateComputedValues,
|
||||||
|
commitJob
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (job?.printers) {
|
||||||
|
if (job.printers.every((printer) => printer.online)) {
|
||||||
setAllPrintersOnline(true)
|
setAllPrintersOnline(true)
|
||||||
} else {
|
} else {
|
||||||
setAllPrintersOnline(false)
|
setAllPrintersOnline(false)
|
||||||
@ -65,7 +210,7 @@ const DeployJob = ({
|
|||||||
} else {
|
} else {
|
||||||
setAllPrintersOnline(false)
|
setAllPrintersOnline(false)
|
||||||
}
|
}
|
||||||
}, [objectData])
|
}, [job])
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{
|
{
|
||||||
@ -98,13 +243,13 @@ const DeployJob = ({
|
|||||||
<ObjectProperty
|
<ObjectProperty
|
||||||
type={'object'}
|
type={'object'}
|
||||||
name={'job'}
|
name={'job'}
|
||||||
objectData={{ job: objectData }}
|
objectData={{ job }}
|
||||||
value={(object) => object.job}
|
value={(object) => object.job}
|
||||||
readOnly={true}
|
readOnly={true}
|
||||||
isEditing={true}
|
isEditing={true}
|
||||||
objectType={'job'}
|
objectType={'job'}
|
||||||
onChange={(newJob) => {
|
onChange={(newJob) => {
|
||||||
setJob(newJob)
|
commitJob(newJob)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -131,7 +276,7 @@ const DeployJob = ({
|
|||||||
<ObjectProperty
|
<ObjectProperty
|
||||||
type={'objectList'}
|
type={'objectList'}
|
||||||
name={'printers'}
|
name={'printers'}
|
||||||
objectData={{ printers: objectData.printers }}
|
objectData={{ printers: job?.printers }}
|
||||||
value={(object) => object.printers}
|
value={(object) => object.printers}
|
||||||
readOnly={true}
|
readOnly={true}
|
||||||
isEditing={true}
|
isEditing={true}
|
||||||
@ -151,7 +296,9 @@ const DeployJob = ({
|
|||||||
printer: false,
|
printer: false,
|
||||||
job: false
|
job: false
|
||||||
}}
|
}}
|
||||||
masterFilter={{ job: getModelByName('job').prefix + ':' + job?._id }}
|
masterFilter={{
|
||||||
|
job: getModelByName('job').prefix + ':' + job?._id
|
||||||
|
}}
|
||||||
size={'small'}
|
size={'small'}
|
||||||
showActions={!slicerIntegration}
|
showActions={!slicerIntegration}
|
||||||
/>
|
/>
|
||||||
@ -164,7 +311,7 @@ const DeployJob = ({
|
|||||||
<WizardView
|
<WizardView
|
||||||
steps={steps}
|
steps={steps}
|
||||||
loading={deployLoading}
|
loading={deployLoading}
|
||||||
formValid={objectData != undefined && allPrintersOnline}
|
formValid={job != undefined && allPrintersOnline}
|
||||||
title='Deploy Job'
|
title='Deploy Job'
|
||||||
showSteps={false}
|
showSteps={false}
|
||||||
showSkipButton={slicerIntegration}
|
showSkipButton={slicerIntegration}
|
||||||
|
|||||||
@ -50,6 +50,7 @@ const NewJob = ({
|
|||||||
visibleProperties={{
|
visibleProperties={{
|
||||||
_id: false,
|
_id: false,
|
||||||
_reference: false,
|
_reference: false,
|
||||||
|
name: false,
|
||||||
createdAt: false,
|
createdAt: false,
|
||||||
updatedAt: false,
|
updatedAt: false,
|
||||||
startedAt: false,
|
startedAt: false,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { Form } from 'antd'
|
|||||||
import { ApiServerContext } from '../context/ApiServerContext'
|
import { ApiServerContext } from '../context/ApiServerContext'
|
||||||
import { useMessageContext } from '../context/MessageContext'
|
import { useMessageContext } from '../context/MessageContext'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
|
import get from 'lodash/get'
|
||||||
import set from 'lodash/set'
|
import set from 'lodash/set'
|
||||||
import { getModelByName } from '../../../database/ObjectModels'
|
import { getModelByName } from '../../../database/ObjectModels'
|
||||||
|
|
||||||
@ -11,6 +12,14 @@ import {
|
|||||||
stripNestedObjectProperties,
|
stripNestedObjectProperties,
|
||||||
calculateModelComputedEntries
|
calculateModelComputedEntries
|
||||||
} from '../utils/Utils'
|
} from '../utils/Utils'
|
||||||
|
import {
|
||||||
|
applyComputedEntries,
|
||||||
|
applyHydratedObjects,
|
||||||
|
applyRelatedObjectUpdates,
|
||||||
|
collectObjectPropertyRefs,
|
||||||
|
getReferenceId,
|
||||||
|
getRelatedObjectSubscriptionKey
|
||||||
|
} from '../utils/objectPropertyRefs'
|
||||||
|
|
||||||
const EMPTY_DEFAULT_VALUES = {}
|
const EMPTY_DEFAULT_VALUES = {}
|
||||||
|
|
||||||
@ -36,18 +45,17 @@ const buildObjectFromEntries = (entries = []) => {
|
|||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Patch computed fields onto the complete object.
|
const syncFormValues = (form, data, refs = [], computedEntries = []) => {
|
||||||
// This mirrors the handling used by ObjectForm.
|
refs.forEach((ref) => {
|
||||||
const applyComputedEntries = (base, entries = []) => {
|
form.setFieldValue(ref.namePath, get(data, ref.namePath))
|
||||||
const result = mergeFormData(base || {})
|
|
||||||
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
const { namePath, value } = entry || {}
|
|
||||||
if (!Array.isArray(namePath) || value === undefined) return
|
|
||||||
set(result, namePath, value)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return result
|
computedEntries.forEach(({ namePath, value }) => {
|
||||||
|
if (!Array.isArray(namePath) || value === undefined) return
|
||||||
|
if (form.getFieldValue(namePath) !== value) {
|
||||||
|
form.setFieldValue(namePath, value)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -75,10 +83,18 @@ const NewObjectForm = ({
|
|||||||
const [objectData, setObjectData] = useState({
|
const [objectData, setObjectData] = useState({
|
||||||
_isEditing: true
|
_isEditing: true
|
||||||
})
|
})
|
||||||
|
const objectDataRef = useRef(objectData)
|
||||||
const defaultValuesKey = serializeDefaultValues(defaultValues)
|
const defaultValuesKey = serializeDefaultValues(defaultValues)
|
||||||
const defaultValuesRef = useRef(defaultValues)
|
const defaultValuesRef = useRef(defaultValues)
|
||||||
defaultValuesRef.current = defaultValues
|
defaultValuesRef.current = defaultValues
|
||||||
|
|
||||||
|
const commitObjectData = useCallback((value) => {
|
||||||
|
const next =
|
||||||
|
typeof value === 'function' ? value(objectDataRef.current) : value
|
||||||
|
objectDataRef.current = next
|
||||||
|
setObjectData(next)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const [submitLoading, setSubmitLoading] = useState(false)
|
const [submitLoading, setSubmitLoading] = useState(false)
|
||||||
const [formValid, setFormValid] = useState(false)
|
const [formValid, setFormValid] = useState(false)
|
||||||
|
|
||||||
@ -90,7 +106,15 @@ const NewObjectForm = ({
|
|||||||
|
|
||||||
const { showSuccess, showError: showMessageError } = useMessageContext()
|
const { showSuccess, showError: showMessageError } = useMessageContext()
|
||||||
|
|
||||||
const { createObject, showError } = useContext(ApiServerContext)
|
const {
|
||||||
|
createObject,
|
||||||
|
showError,
|
||||||
|
fetchObject,
|
||||||
|
subscribeToObjectUpdates,
|
||||||
|
connected
|
||||||
|
} = useContext(ApiServerContext)
|
||||||
|
const fetchObjectRef = useRef(fetchObject)
|
||||||
|
fetchObjectRef.current = fetchObject
|
||||||
|
|
||||||
const model = getModelByName(type)
|
const model = getModelByName(type)
|
||||||
|
|
||||||
@ -170,10 +194,126 @@ const NewObjectForm = ({
|
|||||||
form.setFieldsValue(initialFormData)
|
form.setFieldsValue(initialFormData)
|
||||||
|
|
||||||
// Keep objectData in sync with exactly the same initial data.
|
// Keep objectData in sync with exactly the same initial data.
|
||||||
setObjectData(nextObjectData)
|
commitObjectData(nextObjectData)
|
||||||
|
|
||||||
return validateForm()
|
return validateForm()
|
||||||
}, [form, defaultValuesKey, calculateComputedValues, model, validateForm])
|
}, [
|
||||||
|
form,
|
||||||
|
defaultValuesKey,
|
||||||
|
calculateComputedValues,
|
||||||
|
model,
|
||||||
|
validateForm,
|
||||||
|
commitObjectData
|
||||||
|
])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Default object and objectList values are often id stubs ({ _id }).
|
||||||
|
* Fetch the full records, then keep them current from object updates
|
||||||
|
* the same way ObjectForm does for the record it is editing.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const refs = collectObjectPropertyRefs(
|
||||||
|
defaultValuesRef.current,
|
||||||
|
model
|
||||||
|
).filter((ref) => !ref.hydrated)
|
||||||
|
|
||||||
|
if (refs.length === 0) return undefined
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
const seen = new Set()
|
||||||
|
const uniqueRefs = []
|
||||||
|
refs.forEach((ref) => {
|
||||||
|
const key = `${ref.objectType}:${ref.id.toLowerCase()}`
|
||||||
|
if (seen.has(key)) return
|
||||||
|
seen.add(key)
|
||||||
|
uniqueRefs.push(ref)
|
||||||
|
})
|
||||||
|
|
||||||
|
const hydrate = async () => {
|
||||||
|
const fetchedByKey = new Map()
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
uniqueRefs.map(async (ref) => {
|
||||||
|
const fullObject = await fetchObjectRef.current(ref.id, ref.objectType)
|
||||||
|
if (!getReferenceId(fullObject)) return
|
||||||
|
fetchedByKey.set(
|
||||||
|
`${ref.objectType}:${ref.id.toLowerCase()}`,
|
||||||
|
fullObject
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
const applied = applyHydratedObjects(
|
||||||
|
objectDataRef.current,
|
||||||
|
refs,
|
||||||
|
fetchedByKey,
|
||||||
|
model,
|
||||||
|
calculateComputedValues
|
||||||
|
)
|
||||||
|
if (!applied) return
|
||||||
|
|
||||||
|
syncFormValues(
|
||||||
|
form,
|
||||||
|
applied.next,
|
||||||
|
applied.refs,
|
||||||
|
applied.computedEntries
|
||||||
|
)
|
||||||
|
commitObjectData(applied.next)
|
||||||
|
}
|
||||||
|
|
||||||
|
hydrate()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [defaultValuesKey, model, calculateComputedValues, form, commitObjectData])
|
||||||
|
|
||||||
|
const relatedObjectKey = getRelatedObjectSubscriptionKey(objectData, model)
|
||||||
|
|
||||||
|
// Keep referenced objects current while the form is open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (connected !== true || !relatedObjectKey) return undefined
|
||||||
|
|
||||||
|
const unsubscribes = relatedObjectKey.split('|').map((entry) => {
|
||||||
|
const separator = entry.indexOf(':')
|
||||||
|
const objectType = entry.slice(0, separator)
|
||||||
|
const id = entry.slice(separator + 1)
|
||||||
|
|
||||||
|
return subscribeToObjectUpdates(id, objectType, (value) => {
|
||||||
|
const applied = applyRelatedObjectUpdates(
|
||||||
|
objectDataRef.current,
|
||||||
|
objectType,
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
model,
|
||||||
|
calculateComputedValues
|
||||||
|
)
|
||||||
|
if (!applied) return
|
||||||
|
|
||||||
|
syncFormValues(
|
||||||
|
form,
|
||||||
|
applied.next,
|
||||||
|
applied.refs,
|
||||||
|
applied.computedEntries
|
||||||
|
)
|
||||||
|
commitObjectData(applied.next)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribes.forEach((unsubscribe) => unsubscribe?.())
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
connected,
|
||||||
|
relatedObjectKey,
|
||||||
|
subscribeToObjectUpdates,
|
||||||
|
model,
|
||||||
|
calculateComputedValues,
|
||||||
|
form,
|
||||||
|
commitObjectData
|
||||||
|
])
|
||||||
|
|
||||||
// Validate form on change
|
// Validate form on change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -186,7 +326,10 @@ const NewObjectForm = ({
|
|||||||
|
|
||||||
const currentFormValues = form.getFieldsValue()
|
const currentFormValues = form.getFieldsValue()
|
||||||
|
|
||||||
const currentFormData = mergeFormData(objectData || {}, currentFormValues)
|
const currentFormData = mergeFormData(
|
||||||
|
objectDataRef.current || {},
|
||||||
|
currentFormValues
|
||||||
|
)
|
||||||
|
|
||||||
const computedEntries = calculateComputedValues(currentFormData, model)
|
const computedEntries = calculateComputedValues(currentFormData, model)
|
||||||
|
|
||||||
@ -227,7 +370,10 @@ const NewObjectForm = ({
|
|||||||
style={style}
|
style={style}
|
||||||
disabled={submitLoading}
|
disabled={submitLoading}
|
||||||
onValuesChange={(_changedValues, allFormValues) => {
|
onValuesChange={(_changedValues, allFormValues) => {
|
||||||
const currentFormData = mergeFormData(objectData || {}, allFormValues)
|
const currentFormData = mergeFormData(
|
||||||
|
objectDataRef.current || {},
|
||||||
|
allFormValues
|
||||||
|
)
|
||||||
|
|
||||||
const computedEntries = calculateComputedValues(currentFormData, model)
|
const computedEntries = calculateComputedValues(currentFormData, model)
|
||||||
|
|
||||||
@ -257,7 +403,7 @@ const NewObjectForm = ({
|
|||||||
|
|
||||||
allValues._isEditing = true
|
allValues._isEditing = true
|
||||||
|
|
||||||
setObjectData((prev) => mergeFormData(prev, allValues))
|
commitObjectData(mergeFormData(objectDataRef.current, allValues))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children({
|
{children({
|
||||||
@ -268,7 +414,7 @@ const NewObjectForm = ({
|
|||||||
form,
|
form,
|
||||||
formValid,
|
formValid,
|
||||||
objectData,
|
objectData,
|
||||||
setObjectData
|
setObjectData: commitObjectData
|
||||||
})}
|
})}
|
||||||
</Form>
|
</Form>
|
||||||
)
|
)
|
||||||
|
|||||||
240
src/components/Dashboard/utils/objectPropertyRefs.js
Normal file
240
src/components/Dashboard/utils/objectPropertyRefs.js
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
import get from 'lodash/get'
|
||||||
|
import set from 'lodash/set'
|
||||||
|
import cloneDeep from 'lodash/cloneDeep'
|
||||||
|
import { mergeFormData } from './Utils'
|
||||||
|
|
||||||
|
// Patch computed fields onto the complete object.
|
||||||
|
// This mirrors the handling used by ObjectForm.
|
||||||
|
export 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
|
||||||
|
}
|
||||||
|
|
||||||
|
const idsEqual = (left, right) =>
|
||||||
|
left != null &&
|
||||||
|
right != null &&
|
||||||
|
String(left).toLowerCase() === String(right).toLowerCase()
|
||||||
|
|
||||||
|
export const getReferenceId = (value) => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
return trimmed || null
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||||
|
if (typeof value._id === 'string' && value._id.trim()) return value._id
|
||||||
|
if (typeof value._id?._id === 'string' && value._id._id.trim()) {
|
||||||
|
return value._id._id
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reference is hydrated when it has fields besides _id.
|
||||||
|
// API object ids often arrive as { _id } until they are fetched.
|
||||||
|
const isHydratedObject = (value) => {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
||||||
|
return Object.keys(value).some((key) => key !== '_id')
|
||||||
|
}
|
||||||
|
|
||||||
|
const toNamePath = (name, parentPath = []) => {
|
||||||
|
if (Array.isArray(name)) return [...parentPath, ...name]
|
||||||
|
if (typeof name === 'number') return [...parentPath, name]
|
||||||
|
if (typeof name === 'string' && name.length > 0) {
|
||||||
|
return [...parentPath, ...name.split('.')]
|
||||||
|
}
|
||||||
|
return parentPath
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvePropertyObjectType = (property, scopeData, parentData) => {
|
||||||
|
if (typeof property?.objectType !== 'function') {
|
||||||
|
return property?.objectType || null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return property.objectType(scopeData || {}, parentData) || null
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`Error resolving object type for property ${property?.name}:`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const collectObjectPropertyRefs = (data, modelDefinition) => {
|
||||||
|
const refs = []
|
||||||
|
if (!data || typeof data !== 'object') return refs
|
||||||
|
|
||||||
|
const visit = (properties, parentPath, scopeData, parentData) => {
|
||||||
|
if (!Array.isArray(properties)) return
|
||||||
|
|
||||||
|
properties.forEach((property) => {
|
||||||
|
if (!property?.name) return
|
||||||
|
|
||||||
|
const propertyPath = toNamePath(property.name, parentPath)
|
||||||
|
const value = get(data, propertyPath)
|
||||||
|
|
||||||
|
if (property.type === 'object' || property.type === 'objectList') {
|
||||||
|
const objectType = resolvePropertyObjectType(
|
||||||
|
property,
|
||||||
|
scopeData,
|
||||||
|
parentData
|
||||||
|
)
|
||||||
|
if (!objectType) return
|
||||||
|
|
||||||
|
const items =
|
||||||
|
property.type === 'objectList'
|
||||||
|
? Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: []
|
||||||
|
: value == null
|
||||||
|
? []
|
||||||
|
: [value]
|
||||||
|
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
const id = getReferenceId(item)
|
||||||
|
if (!id) return
|
||||||
|
refs.push({
|
||||||
|
namePath:
|
||||||
|
property.type === 'objectList'
|
||||||
|
? [...propertyPath, index]
|
||||||
|
: propertyPath,
|
||||||
|
id: String(id),
|
||||||
|
objectType,
|
||||||
|
hydrated: isHydratedObject(item)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!Array.isArray(property.properties) ||
|
||||||
|
property.properties.length === 0
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (property.type === 'objectChildren' && Array.isArray(value)) {
|
||||||
|
value.forEach((child, index) => {
|
||||||
|
visit(
|
||||||
|
property.properties,
|
||||||
|
[...propertyPath, index],
|
||||||
|
child,
|
||||||
|
scopeData
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
visit(property.properties, propertyPath, value, scopeData)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
visit(modelDefinition?.properties, [], data, null)
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getRelatedObjectSubscriptionKey = (data, modelDefinition) => {
|
||||||
|
const seen = new Set()
|
||||||
|
collectObjectPropertyRefs(data, modelDefinition).forEach((ref) => {
|
||||||
|
seen.add(`${ref.objectType}:${ref.id.toLowerCase()}`)
|
||||||
|
})
|
||||||
|
return Array.from(seen).sort().join('|')
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergeRelatedObjectValue = (existing, id, value) => {
|
||||||
|
const base =
|
||||||
|
existing && typeof existing === 'object' && !Array.isArray(existing)
|
||||||
|
? existing
|
||||||
|
: { _id: id }
|
||||||
|
return mergeFormData(base, value, { _id: id })
|
||||||
|
}
|
||||||
|
|
||||||
|
const withEditingFlag = (prev, next) => {
|
||||||
|
if (prev?._isEditing != null) {
|
||||||
|
next._isEditing = prev._isEditing
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export const applyHydratedObjects = (
|
||||||
|
prev,
|
||||||
|
refs,
|
||||||
|
fetchedByKey,
|
||||||
|
modelDefinition,
|
||||||
|
calculateComputedValues
|
||||||
|
) => {
|
||||||
|
if (!prev || refs.length === 0 || fetchedByKey.size === 0) return null
|
||||||
|
|
||||||
|
const draft = cloneDeep(prev)
|
||||||
|
const applied = []
|
||||||
|
|
||||||
|
refs.forEach((ref) => {
|
||||||
|
const fullObject = fetchedByKey.get(
|
||||||
|
`${ref.objectType}:${ref.id.toLowerCase()}`
|
||||||
|
)
|
||||||
|
if (!fullObject) return
|
||||||
|
|
||||||
|
const current = get(draft, ref.namePath)
|
||||||
|
const currentId = getReferenceId(current)
|
||||||
|
if (!idsEqual(currentId, ref.id) || isHydratedObject(current)) return
|
||||||
|
|
||||||
|
set(
|
||||||
|
draft,
|
||||||
|
ref.namePath,
|
||||||
|
mergeRelatedObjectValue(current, ref.id, fullObject)
|
||||||
|
)
|
||||||
|
applied.push(ref)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (applied.length === 0) return null
|
||||||
|
|
||||||
|
const computedEntries = calculateComputedValues(draft, modelDefinition)
|
||||||
|
const next = withEditingFlag(
|
||||||
|
prev,
|
||||||
|
applyComputedEntries(draft, computedEntries)
|
||||||
|
)
|
||||||
|
return { next, refs: applied, computedEntries }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const applyRelatedObjectUpdates = (
|
||||||
|
prev,
|
||||||
|
objectType,
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
modelDefinition,
|
||||||
|
calculateComputedValues
|
||||||
|
) => {
|
||||||
|
if (!prev || !objectType || id == null || value == null) return null
|
||||||
|
|
||||||
|
const normalizedId = String(id).toLowerCase()
|
||||||
|
const refs = collectObjectPropertyRefs(prev, modelDefinition).filter(
|
||||||
|
(ref) =>
|
||||||
|
ref.objectType === objectType && ref.id.toLowerCase() === normalizedId
|
||||||
|
)
|
||||||
|
if (refs.length === 0) return null
|
||||||
|
|
||||||
|
const draft = cloneDeep(prev)
|
||||||
|
refs.forEach((ref) => {
|
||||||
|
set(
|
||||||
|
draft,
|
||||||
|
ref.namePath,
|
||||||
|
mergeRelatedObjectValue(get(draft, ref.namePath), ref.id, value)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const computedEntries = calculateComputedValues(draft, modelDefinition)
|
||||||
|
const next = withEditingFlag(
|
||||||
|
prev,
|
||||||
|
applyComputedEntries(draft, computedEntries)
|
||||||
|
)
|
||||||
|
return { next, refs, computedEntries }
|
||||||
|
}
|
||||||
@ -150,6 +150,7 @@ export const Job = {
|
|||||||
],
|
],
|
||||||
columns: [
|
columns: [
|
||||||
'_reference',
|
'_reference',
|
||||||
|
'name',
|
||||||
'quantity',
|
'quantity',
|
||||||
'state',
|
'state',
|
||||||
'gcodeFile',
|
'gcodeFile',
|
||||||
@ -159,6 +160,7 @@ export const Job = {
|
|||||||
'updatedAt'
|
'updatedAt'
|
||||||
],
|
],
|
||||||
filters: [
|
filters: [
|
||||||
|
'name',
|
||||||
'state',
|
'state',
|
||||||
'gcodeFile',
|
'gcodeFile',
|
||||||
'quantity',
|
'quantity',
|
||||||
@ -170,6 +172,7 @@ export const Job = {
|
|||||||
'finishedAt'
|
'finishedAt'
|
||||||
],
|
],
|
||||||
sorters: [
|
sorters: [
|
||||||
|
'name',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'state',
|
'state',
|
||||||
'quantity',
|
'quantity',
|
||||||
@ -212,6 +215,21 @@ export const Job = {
|
|||||||
readOnly: true,
|
readOnly: true,
|
||||||
columnWidth: 200
|
columnWidth: 200
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
label: 'Name',
|
||||||
|
type: 'text',
|
||||||
|
readOnly: true,
|
||||||
|
columnFixed: 'left',
|
||||||
|
columnWidth: 240
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'startedAt',
|
||||||
|
label: 'Started At',
|
||||||
|
type: 'dateTime',
|
||||||
|
readOnly: true,
|
||||||
|
columnWidth: 175
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'state',
|
name: 'state',
|
||||||
label: 'State',
|
label: 'State',
|
||||||
@ -224,21 +242,6 @@ export const Job = {
|
|||||||
columnWidth: 260,
|
columnWidth: 260,
|
||||||
readOnly: true
|
readOnly: true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'startedAt',
|
|
||||||
label: 'Started At',
|
|
||||||
type: 'dateTime',
|
|
||||||
readOnly: true,
|
|
||||||
columnWidth: 175
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'quantity',
|
|
||||||
label: 'Quantity',
|
|
||||||
type: 'number',
|
|
||||||
columnFixed: 'left',
|
|
||||||
columnWidth: 125,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'finishedAt',
|
name: 'finishedAt',
|
||||||
label: 'Finished At',
|
label: 'Finished At',
|
||||||
@ -248,13 +251,12 @@ export const Job = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'gcodeFile',
|
name: 'quantity',
|
||||||
label: 'GCode File',
|
label: 'Quantity',
|
||||||
type: 'object',
|
type: 'number',
|
||||||
columnWidth: 200,
|
columnFixed: 'left',
|
||||||
objectType: 'gcodeFile',
|
columnWidth: 125,
|
||||||
required: true,
|
required: true
|
||||||
showHyperlink: true
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@ -285,6 +287,15 @@ export const Job = {
|
|||||||
return parts.join(' ')
|
return parts.join(' ')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'gcodeFile',
|
||||||
|
label: 'GCode File',
|
||||||
|
type: 'object',
|
||||||
|
columnWidth: 200,
|
||||||
|
objectType: 'gcodeFile',
|
||||||
|
required: true,
|
||||||
|
showHyperlink: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'printers',
|
name: 'printers',
|
||||||
label: 'Printers',
|
label: 'Printers',
|
||||||
|
|||||||
@ -53,6 +53,7 @@ export const SubJob = {
|
|||||||
],
|
],
|
||||||
columns: [
|
columns: [
|
||||||
'_reference',
|
'_reference',
|
||||||
|
'name',
|
||||||
'printer',
|
'printer',
|
||||||
'job',
|
'job',
|
||||||
'state',
|
'state',
|
||||||
@ -62,6 +63,7 @@ export const SubJob = {
|
|||||||
'updatedAt'
|
'updatedAt'
|
||||||
],
|
],
|
||||||
filters: [
|
filters: [
|
||||||
|
'name',
|
||||||
'_id',
|
'_id',
|
||||||
'job',
|
'job',
|
||||||
'printer',
|
'printer',
|
||||||
@ -73,6 +75,7 @@ export const SubJob = {
|
|||||||
'finishedAt'
|
'finishedAt'
|
||||||
],
|
],
|
||||||
sorters: [
|
sorters: [
|
||||||
|
'name',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'state',
|
'state',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
@ -116,6 +119,21 @@ export const SubJob = {
|
|||||||
readOnly: true,
|
readOnly: true,
|
||||||
columnWidth: 200
|
columnWidth: 200
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
label: 'Name',
|
||||||
|
type: 'text',
|
||||||
|
readOnly: true,
|
||||||
|
columnFixed: 'left',
|
||||||
|
columnWidth: 220
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'startedAt',
|
||||||
|
label: 'Started At',
|
||||||
|
type: 'dateTime',
|
||||||
|
readOnly: true,
|
||||||
|
columnWidth: 175
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'state',
|
name: 'state',
|
||||||
label: 'State',
|
label: 'State',
|
||||||
@ -128,9 +146,10 @@ export const SubJob = {
|
|||||||
columnWidth: 260,
|
columnWidth: 260,
|
||||||
readOnly: true
|
readOnly: true
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'startedAt',
|
name: 'finishedAt',
|
||||||
label: 'Started At',
|
label: 'Finished At',
|
||||||
type: 'dateTime',
|
type: 'dateTime',
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
columnWidth: 175
|
columnWidth: 175
|
||||||
@ -144,32 +163,6 @@ export const SubJob = {
|
|||||||
showCopy: true
|
showCopy: true
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
|
||||||
name: 'finishedAt',
|
|
||||||
label: 'Finished At',
|
|
||||||
type: 'dateTime',
|
|
||||||
readOnly: true,
|
|
||||||
columnWidth: 175
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
name: 'job',
|
|
||||||
label: 'Job',
|
|
||||||
type: 'object',
|
|
||||||
objectType: 'job',
|
|
||||||
showHyperlink: true,
|
|
||||||
columnWidth: 200
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
name: 'printer',
|
|
||||||
label: 'Printer',
|
|
||||||
type: 'object',
|
|
||||||
columnFixed: 'left',
|
|
||||||
objectType: 'printer',
|
|
||||||
showHyperlink: true,
|
|
||||||
columnWidth: 200
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'totalTime',
|
name: 'totalTime',
|
||||||
label: 'Total Time',
|
label: 'Total Time',
|
||||||
@ -197,6 +190,25 @@ export const SubJob = {
|
|||||||
|
|
||||||
return parts.join(' ')
|
return parts.join(' ')
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'job',
|
||||||
|
label: 'Job',
|
||||||
|
type: 'object',
|
||||||
|
objectType: 'job',
|
||||||
|
showHyperlink: true,
|
||||||
|
columnWidth: 200
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'printer',
|
||||||
|
label: 'Printer',
|
||||||
|
type: 'object',
|
||||||
|
columnFixed: 'left',
|
||||||
|
objectType: 'printer',
|
||||||
|
showHyperlink: true,
|
||||||
|
columnWidth: 200
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user