diff --git a/src/components/Dashboard/Production/Jobs/DeployJob.jsx b/src/components/Dashboard/Production/Jobs/DeployJob.jsx index 43b38fc8..3a9a8738 100644 --- a/src/components/Dashboard/Production/Jobs/DeployJob.jsx +++ b/src/components/Dashboard/Production/Jobs/DeployJob.jsx @@ -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 WizardView from '../../common/WizardView' import { ApiServerContext } from '../../context/ApiServerContext' @@ -6,7 +6,17 @@ import { Descriptions, Flex, Typography } from 'antd' import ObjectTable from '../../common/ObjectTable' import { getModelByName } from '../../../../database/ObjectModels.js' import ObjectProperty from '../../common/ObjectProperty' +import { calculateModelComputedEntries } from '../../utils/Utils' +import { + applyHydratedObjects, + applyRelatedObjectUpdates, + collectObjectPropertyRefs, + getReferenceId, + getRelatedObjectSubscriptionKey +} from '../../utils/objectPropertyRefs' + const { Text } = Typography + const DeployJob = ({ onOk, objectData = undefined, @@ -14,12 +24,45 @@ const DeployJob = ({ }) => { const [deployLoading, setDeployLoading] = useState(false) const [job, setJob] = useState(objectData) + const jobRef = useRef(job) const [deployedSubJobsCount, setDeployedSubJobsCount] = useState(0) const [subJobsCount, setSubJobsCount] = useState(999) 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 () => { + if (!job?._id) return + setDeployLoading(true) var hasMore = true var currentPage = 1 @@ -47,7 +90,7 @@ const DeployJob = ({ } ) }) - }, [job._id, sendObjectAction, fetchObjects]) + }, [job?._id, sendObjectAction, fetchObjects]) useEffect(() => { if (deployedSubJobsCount == subJobsCount && deployLoading == true) { @@ -56,8 +99,110 @@ const DeployJob = ({ }, [deployedSubJobsCount, subJobsCount, deployLoading, onOk]) useEffect(() => { - if (objectData?.printers) { - if (objectData.printers.every((printer) => printer.online)) { + commitJob(objectDataRef.current) + }, [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) } else { setAllPrintersOnline(false) @@ -65,7 +210,7 @@ const DeployJob = ({ } else { setAllPrintersOnline(false) } - }, [objectData]) + }, [job]) const steps = [ { @@ -98,13 +243,13 @@ const DeployJob = ({ object.job} readOnly={true} isEditing={true} objectType={'job'} onChange={(newJob) => { - setJob(newJob) + commitJob(newJob) }} /> @@ -131,7 +276,7 @@ const DeployJob = ({ object.printers} readOnly={true} isEditing={true} @@ -151,7 +296,9 @@ const DeployJob = ({ printer: false, job: false }} - masterFilter={{ job: getModelByName('job').prefix + ':' + job?._id }} + masterFilter={{ + job: getModelByName('job').prefix + ':' + job?._id + }} size={'small'} showActions={!slicerIntegration} /> @@ -164,7 +311,7 @@ const DeployJob = ({ { }, {}) } -// 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) +const syncFormValues = (form, data, refs = [], computedEntries = []) => { + refs.forEach((ref) => { + form.setFieldValue(ref.namePath, get(data, ref.namePath)) }) - 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({ _isEditing: true }) + const objectDataRef = useRef(objectData) const defaultValuesKey = serializeDefaultValues(defaultValues) const defaultValuesRef = useRef(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 [formValid, setFormValid] = useState(false) @@ -90,7 +106,15 @@ const NewObjectForm = ({ 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) @@ -170,10 +194,126 @@ const NewObjectForm = ({ form.setFieldsValue(initialFormData) // Keep objectData in sync with exactly the same initial data. - setObjectData(nextObjectData) + commitObjectData(nextObjectData) 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 useEffect(() => { @@ -186,7 +326,10 @@ const NewObjectForm = ({ const currentFormValues = form.getFieldsValue() - const currentFormData = mergeFormData(objectData || {}, currentFormValues) + const currentFormData = mergeFormData( + objectDataRef.current || {}, + currentFormValues + ) const computedEntries = calculateComputedValues(currentFormData, model) @@ -227,7 +370,10 @@ const NewObjectForm = ({ style={style} disabled={submitLoading} onValuesChange={(_changedValues, allFormValues) => { - const currentFormData = mergeFormData(objectData || {}, allFormValues) + const currentFormData = mergeFormData( + objectDataRef.current || {}, + allFormValues + ) const computedEntries = calculateComputedValues(currentFormData, model) @@ -257,7 +403,7 @@ const NewObjectForm = ({ allValues._isEditing = true - setObjectData((prev) => mergeFormData(prev, allValues)) + commitObjectData(mergeFormData(objectDataRef.current, allValues)) }} > {children({ @@ -268,7 +414,7 @@ const NewObjectForm = ({ form, formValid, objectData, - setObjectData + setObjectData: commitObjectData })} ) diff --git a/src/components/Dashboard/utils/objectPropertyRefs.js b/src/components/Dashboard/utils/objectPropertyRefs.js new file mode 100644 index 00000000..4766476f --- /dev/null +++ b/src/components/Dashboard/utils/objectPropertyRefs.js @@ -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 } +} diff --git a/src/database/models/Job.js b/src/database/models/Job.js index 960feb30..8f38b9d4 100644 --- a/src/database/models/Job.js +++ b/src/database/models/Job.js @@ -150,6 +150,7 @@ export const Job = { ], columns: [ '_reference', + 'name', 'quantity', 'state', 'gcodeFile', @@ -159,6 +160,7 @@ export const Job = { 'updatedAt' ], filters: [ + 'name', 'state', 'gcodeFile', 'quantity', @@ -170,6 +172,7 @@ export const Job = { 'finishedAt' ], sorters: [ + 'name', 'createdAt', 'state', 'quantity', @@ -212,6 +215,21 @@ export const Job = { readOnly: true, 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', label: 'State', @@ -224,21 +242,6 @@ export const Job = { columnWidth: 260, 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', label: 'Finished At', @@ -248,13 +251,12 @@ export const Job = { }, { - name: 'gcodeFile', - label: 'GCode File', - type: 'object', - columnWidth: 200, - objectType: 'gcodeFile', - required: true, - showHyperlink: true + name: 'quantity', + label: 'Quantity', + type: 'number', + columnFixed: 'left', + columnWidth: 125, + required: true }, { @@ -285,6 +287,15 @@ export const Job = { return parts.join(' ') } }, + { + name: 'gcodeFile', + label: 'GCode File', + type: 'object', + columnWidth: 200, + objectType: 'gcodeFile', + required: true, + showHyperlink: true + }, { name: 'printers', label: 'Printers', diff --git a/src/database/models/SubJob.js b/src/database/models/SubJob.js index 89b36adb..84a13efc 100644 --- a/src/database/models/SubJob.js +++ b/src/database/models/SubJob.js @@ -53,6 +53,7 @@ export const SubJob = { ], columns: [ '_reference', + 'name', 'printer', 'job', 'state', @@ -62,6 +63,7 @@ export const SubJob = { 'updatedAt' ], filters: [ + 'name', '_id', 'job', 'printer', @@ -73,6 +75,7 @@ export const SubJob = { 'finishedAt' ], sorters: [ + 'name', 'createdAt', 'state', 'updatedAt', @@ -116,6 +119,21 @@ export const SubJob = { readOnly: true, 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', label: 'State', @@ -128,9 +146,10 @@ export const SubJob = { columnWidth: 260, readOnly: true }, + { - name: 'startedAt', - label: 'Started At', + name: 'finishedAt', + label: 'Finished At', type: 'dateTime', readOnly: true, columnWidth: 175 @@ -144,32 +163,6 @@ export const SubJob = { 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', label: 'Total Time', @@ -197,6 +190,25 @@ export const SubJob = { 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 } ] }