Compare commits

..

13 Commits

Author SHA1 Message Date
cd83679232 Add support for additional property types in ObjectProperty component, including variance, markdown, and object list displays. Enhance prop types to accommodate functions for prefix, suffix, and object type. Update ObjectInfo to pass custom property props. 2025-07-14 23:09:56 +01:00
38f03f8fe9 Refactor NotesPanel component to integrate ApiServerContext for fetching notes, add support for parent type in new note creation, and streamline note handling logic. 2025-07-14 23:09:21 +01:00
9a1f58aafe Add ObjectList component to display a list of objects with icons and IDs, enhancing the dashboard's UI. 2025-07-14 23:08:51 +01:00
a0ab5be6f2 Add danger property to action items in ObjectActions component and refine action filtering logic 2025-07-14 23:08:33 +01:00
5aa7355b0f Enhance PropertyChanges component to handle cases where both old and new values are absent, and improve rendering logic for displaying property changes. 2025-07-14 23:08:18 +01:00
a505e1aaba Add OperationDisplay component to visually represent operation types with icons and tags 2025-07-14 23:07:24 +01:00
a5458c6b67 Refactor InfoCollapse component to use collapseKey prop for active state management and improve structure 2025-07-14 23:07:15 +01:00
3bd2628960 Minor UI fix 2025-07-14 23:05:52 +01:00
d46402983f Add DeleteObjectModal component for confirming deletion of objects 2025-07-14 23:05:37 +01:00
b6c2cb22f4 Add VarianceDisplay component and update StockEvent model for variance type 2025-07-14 23:05:10 +01:00
0a897e663c Removed unnecessary wrapper elements and improving readability. 2025-07-14 23:04:44 +01:00
3ad0002bbb Moved more common functions into ApiServerContext 2025-07-14 23:04:18 +01:00
b71537dc64 Updated models 2025-07-14 23:02:26 +01:00
18 changed files with 921 additions and 335 deletions

View File

@ -0,0 +1,68 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Modal, Button, Space, Typography } from 'antd'
import { getModelByName } from '../../../database/ObjectModels'
import BinIcon from '../../Icons/BinIcon'
const { Text } = Typography
const DeleteObjectModal = ({
open,
onOk,
onCancel,
loading,
objectType,
objectName
}) => {
const model = getModelByName(objectType)
return (
<Modal
open={open}
title={
<Space size={'middle'}>
<BinIcon />
{`Confirm Delete ${model.label}`}
</Space>
}
onOk={onOk}
onCancel={onCancel}
okText='Delete'
cancelText='Cancel'
okType='danger'
closable={false}
centered
maskClosable={false}
footer={[
<Button key='cancel' onClick={onCancel} disabled={loading}>
Cancel
</Button>,
<Button
key='delete'
type='primary'
danger
onClick={onOk}
loading={loading}
disabled={loading}
>
Delete
</Button>
]}
>
<Text>
Are you sure you want to delete this {model.label.toLowerCase()}
{objectName ? ` "${objectName}"` : ''}?
</Text>
</Modal>
)
}
DeleteObjectModal.propTypes = {
open: PropTypes.bool.isRequired,
onOk: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
loading: PropTypes.bool,
objectType: PropTypes.string.isRequired,
objectName: PropTypes.string
}
export default DeleteObjectModal

View File

@ -49,7 +49,7 @@ const IdDisplay = ({
}
return (
<Flex align={'center'} className='iddisplay'>
<Flex align={'end'} className='iddisplay'>
{(() => {
const content = (
<Flex gap={4}>

View File

@ -12,33 +12,33 @@ const InfoCollapse = ({
active,
onToggle,
className = '',
key = 'default'
collapseKey = 'default'
}) => {
return (
<Collapse
ghost
expandIconPosition='end'
activeKey={active ? [key] : []}
activeKey={active ? [collapseKey] : []}
onChange={(keys) => onToggle(keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className={`no-h-padding-collapse ${className}`}
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
{icon}
<Title level={5} style={{ margin: 0 }}>
{title}
</Title>
</Flex>
items={[
{
key: collapseKey,
children: children,
label: (
<Flex align='center' gap={'middle'}>
{icon}
<Title level={5} style={{ margin: 0 }}>
{title}
</Title>
</Flex>
)
}
key={key}
>
{children}
</Collapse.Panel>
</Collapse>
]}
/>
)
}
@ -49,7 +49,7 @@ InfoCollapse.propTypes = {
active: PropTypes.bool.isRequired,
onToggle: PropTypes.func.isRequired,
className: PropTypes.string,
key: PropTypes.string
collapseKey: PropTypes.string
}
export default InfoCollapse

View File

@ -26,6 +26,7 @@ import MarkdownDisplay from './MarkdownDisplay'
import axios from 'axios'
import config from '../../../config'
import { AuthContext } from '../context/AuthContext'
import { ApiServerContext } from '../context/ApiServerContext'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import NoteTypeSelect from './NoteTypeSelect'
import IdDisplay from './IdDisplay'
@ -259,7 +260,7 @@ NoteItem.propTypes = {
onChildNoteAdded: PropTypes.func
}
const NotesPanel = ({ _id, onNewNote }) => {
const NotesPanel = ({ _id, onNewNote, type }) => {
const [newNoteOpen, setNewNoteOpen] = useState(false)
const [showMarkdown, setShowMarkdown] = useState(false)
const [loading, setLoading] = useState(true)
@ -273,6 +274,7 @@ const NotesPanel = ({ _id, onNewNote }) => {
const [expandedNotes, setExpandedNotes] = useState({})
const [newNoteForm] = Form.useForm()
const [selectedParentId, setSelectedParentId] = useState(null)
const [selectedParentType, setSelectedParentType] = useState(null)
const [childNoteCallbacks, setChildNoteCallbacks] = useState({})
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [noteToDelete, setNoteToDelete] = useState(null)
@ -289,30 +291,33 @@ const NotesPanel = ({ _id, onNewNote }) => {
}, [newNoteForm, newNoteFormUpdateValues])
const { authenticated, userProfile } = useContext(AuthContext)
const { fetchNotes } = useContext(ApiServerContext)
const fetchData = useCallback(async (id) => {
try {
const response = await axios.get(`${config.backendUrl}/notes`, {
params: {
parent: id,
sort: 'createdAt',
order: 'ascend'
},
headers: {
Accept: 'application/json'
},
withCredentials: true
})
const fetchData = useCallback(
async (id) => {
try {
const newData = await fetchNotes(id)
setLoading(false)
return newData
} catch (error) {
setNotes([])
setError(error)
setLoading(false)
}
},
[fetchNotes]
)
const newData = response.data
setLoading(false)
return newData
} catch (error) {
setNotes([])
setError(error)
setLoading(false)
}
}, [])
const handleNewChildNote = useCallback(
(parentId) => {
setSelectedParentId(parentId)
setSelectedParentType('note')
setNewNoteOpen(true)
newNoteForm.resetFields()
setNewNoteFormValues({})
},
[newNoteForm]
)
const generateNotes = useCallback(
async (id) => {
@ -345,7 +350,7 @@ const NotesPanel = ({ _id, onNewNote }) => {
expandedNotes={expandedNotes}
setExpandedNotes={setExpandedNotes}
fetchData={fetchData}
onNewNote={handleNewNoteFromDropdown}
onNewNote={handleNewChildNote}
onDeleteNote={handleDeleteNote}
userProfile={userProfile}
onChildNoteAdded={(noteId, callback) => {
@ -357,7 +362,7 @@ const NotesPanel = ({ _id, onNewNote }) => {
/>
))
},
[loading, fetchData, expandedNotes, userProfile]
[loading, fetchData, expandedNotes, userProfile, handleNewChildNote]
)
const handleNewNote = async () => {
@ -365,7 +370,11 @@ const NotesPanel = ({ _id, onNewNote }) => {
try {
await axios.post(
`${config.backendUrl}/notes`,
{ ...newNoteFormValues, parent: selectedParentId || _id },
{
...newNoteFormValues,
parent: selectedParentId,
parentType: selectedParentType
},
{
withCredentials: true
}
@ -402,13 +411,6 @@ const NotesPanel = ({ _id, onNewNote }) => {
}
}
const handleNewNoteFromDropdown = (parentId) => {
setSelectedParentId(parentId)
setNewNoteOpen(true)
newNoteForm.resetFields()
setNewNoteFormValues({})
}
const handleDeleteNote = async (noteId) => {
setNoteToDelete(noteId)
setDeleteConfirmOpen(true)
@ -499,7 +501,8 @@ const NotesPanel = ({ _id, onNewNote }) => {
setLoading(true)
handleReloadData()
} else if (key === 'newNote') {
setSelectedParentId(null)
setSelectedParentId(_id)
setSelectedParentType(type)
setNewNoteOpen(true)
newNoteForm.resetFields()
setNewNoteFormValues({})
@ -522,7 +525,8 @@ const NotesPanel = ({ _id, onNewNote }) => {
icon={<PlusIcon />}
disabled={loading}
onClick={() => {
setSelectedParentId(null)
setSelectedParentId(_id)
setSelectedParentType(type)
setNewNoteOpen(true)
newNoteForm.resetFields()
setNewNoteFormValues({})
@ -688,6 +692,7 @@ const NotesPanel = ({ _id, onNewNote }) => {
NotesPanel.propTypes = {
_id: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
onNewNote: PropTypes.func
}

View File

@ -14,6 +14,7 @@ function mapActionsToMenuItems(actions, currentUrlWithActions, id) {
const item = {
key: action.key || action.name,
label: action.label,
danger: action?.danger || false,
icon: action.icon ? React.createElement(action.icon) : undefined,
disabled: actionUrl && actionUrl === currentUrlWithActions
}
@ -53,11 +54,10 @@ const ObjectActions = ({
location.search
)
console.log('curr url', currentUrlWithoutActions)
// Filter out actions whose url matches currentUrl
const filteredActions = actions.filter(
(action) => !(action.url(id) && action.url(id) === currentUrlWithoutActions)
(action) =>
typeof action.url !== 'function' ||
action.url(id) !== currentUrlWithoutActions
)
const currentUrlWithActions = location.pathname + location.search

View File

@ -13,6 +13,7 @@ const ObjectInfo = ({
properties = [],
required = undefined,
visibleProperties = {},
objectPropertyProps = {},
...rest
}) => {
const allItems = getModelProperties(type)
@ -55,6 +56,7 @@ const ObjectInfo = ({
children: (
<ObjectProperty
{...item}
{...objectPropertyProps}
isEditing={isEditing}
objectData={objectData}
/>
@ -91,7 +93,8 @@ ObjectInfo.propTypes = {
type: PropTypes.string.isRequired,
objectData: PropTypes.object,
required: PropTypes.bool,
visibleProperties: PropTypes.object
visibleProperties: PropTypes.object,
objectPropertyProps: PropTypes.object
}
export default ObjectInfo

View File

@ -0,0 +1,49 @@
import React from 'react'
import PropTypes from 'prop-types'
import { List, Typography, Flex } from 'antd'
import { getModelByName } from '../../../database/ObjectModels'
import IdDisplay from './IdDisplay'
const { Text } = Typography
const ObjectList = ({ value, objectType, bordered = true }) => {
if (!value || !Array.isArray(value) || value.length === 0) {
return <Text type='secondary'>n/a</Text>
}
return (
<List
size='small'
bordered={bordered}
dataSource={value}
renderItem={(item) => {
const model = getModelByName(objectType)
const Icon = model.icon
return (
<List.Item>
<Flex gap={'small'} align='center'>
<Icon />
{item?.name ? <Text ellipsis>{item.name}</Text> : null}
{item?._id ? (
<IdDisplay
id={item?._id}
longId={false}
type={objectType}
showHyperlink={true}
/>
) : null}
</Flex>
</List.Item>
)
}}
style={{ width: '100%' }}
/>
)
}
ObjectList.propTypes = {
value: PropTypes.array,
bordered: PropTypes.bool,
objectType: PropTypes.string
}
export default ObjectList

View File

@ -37,6 +37,10 @@ import { getPropertyValue } from '../../../database/ObjectModels'
import PropertyChanges from './PropertyChanges'
import NetGrossDisplay from './NetGrossDisplay'
import NetGrossInput from './NetGrossInput'
import ObjectList from './ObjectList'
import VarianceDisplay from './VarianceDisplay'
import OperationDisplay from './OperationDisplay'
import MarkdownDisplay from './MarkdownDisplay'
const { Text } = Typography
@ -71,7 +75,7 @@ const ObjectProperty = ({
initial = false,
...rest
}) => {
if (typeof value == 'function' && objectData) {
if (value && typeof value == 'function' && objectData) {
value = value(objectData)
}
@ -87,6 +91,14 @@ const ObjectProperty = ({
difference = difference(objectData)
}
if (prefix && typeof prefix == 'function' && objectData) {
prefix = prefix(objectData)
}
if (suffix && typeof suffix == 'function' && objectData) {
suffix = suffix(objectData)
}
if (!value) {
value = getPropertyValue(objectData, name)
}
@ -218,6 +230,19 @@ const ObjectProperty = ({
)
}
}
case 'variance': {
if (value != null) {
return (
<VarianceDisplay value={value} prefix={prefix} suffix={suffix} />
)
} else {
return (
<Text type='secondary' {...textParams}>
n/a
</Text>
)
}
}
case 'text':
if (value != null && value != '') {
return (
@ -234,6 +259,16 @@ const ObjectProperty = ({
</Text>
)
}
case 'markdown':
if (value != null && value != '') {
return <MarkdownDisplay content={value} />
} else {
return (
<Text type='secondary' {...textParams}>
n/a
</Text>
)
}
case 'email':
if (value != null && value != '') {
return <EmailDisplay email={value} />
@ -265,6 +300,9 @@ const ObjectProperty = ({
)
}
}
case 'objectList': {
return <ObjectList value={value} objectType={objectType} />
}
case 'state': {
if (value && value?.type) {
switch (objectType) {
@ -346,6 +384,17 @@ const ObjectProperty = ({
)
}
}
case 'operation': {
if (value != null) {
return <OperationDisplay operation={value} />
} else {
return (
<Text type='secondary' {...textParams}>
n/a
</Text>
)
}
}
case 'propertyChanges': {
return <PropertyChanges type={objectType} value={value} />
}
@ -555,7 +604,7 @@ const ObjectProperty = ({
<PrinterSelect placeholder={label} />
</Form.Item>
)
case 'gcodefile':
case 'gcodeFile':
return (
<Form.Item name={formItemName} {...mergedFormItemProps}>
<GCodeFileSelect placeholder={label} />
@ -603,20 +652,22 @@ const ObjectProperty = ({
ObjectProperty.propTypes = {
type: PropTypes.string.isRequired,
value: PropTypes.any,
value: PropTypes.oneOfType([PropTypes.any, PropTypes.func]),
isEditing: PropTypes.bool,
formItemProps: PropTypes.object,
required: PropTypes.bool,
name: PropTypes.string,
prefix: PropTypes.string,
suffix: PropTypes.string,
prefix: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
suffix: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
min: PropTypes.number,
max: PropTypes.number,
step: PropTypes.number,
showLabel: PropTypes.bool,
objectType: PropTypes.string,
objectType: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
readOnly: PropTypes.bool,
disabled: PropTypes.bool
disabled: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]),
difference: PropTypes.oneOfType([PropTypes.any, PropTypes.func]),
objectData: PropTypes.object
}
export default ObjectProperty

View File

@ -0,0 +1,57 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Space, Tag } from 'antd'
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
import PlusIcon from '../../Icons/PlusIcon'
import BinIcon from '../../Icons/BinIcon'
import EditIcon from '../../Icons/EditIcon'
const OperationDisplay = ({
operation,
showIcon = true,
showText = true,
showColor = true
}) => {
var tagText = 'False'
var tagIcon = <QuestionCircleIcon />
var tagColor = 'default'
switch (operation) {
case 'new':
tagText = 'New'
tagIcon = <PlusIcon />
tagColor = 'success'
break
case 'delete':
tagText = 'Delete'
tagIcon = <BinIcon />
tagColor = 'error'
break
case 'edit':
tagText = 'Edit'
tagIcon = <EditIcon />
tagColor = 'blue'
break
default:
break
}
return (
<Space>
<Tag
style={{ margin: 0 }}
color={showColor ? tagColor : 'default'}
icon={showIcon ? tagIcon : undefined}
>
{showText ? tagText : null}
</Tag>
</Space>
)
}
OperationDisplay.propTypes = {
operation: PropTypes.bool.isRequired,
showIcon: PropTypes.bool,
showText: PropTypes.bool,
showColor: PropTypes.bool
}
export default OperationDisplay

View File

@ -8,7 +8,7 @@ import ArrowRightIcon from '../../Icons/ArrowRightIcon'
const { Text } = Typography
const PropertyChanges = ({ type, value }) => {
if (!value || !value.new) {
if (!value || (!value.new && !value.old)) {
return <Text type='secondary'>n/a</Text>
}
@ -18,7 +18,15 @@ const PropertyChanges = ({ type, value }) => {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const val = obj[key]
const newKey = prefix ? `${prefix}.${key}` : key
if (val && typeof val === 'object' && !Array.isArray(val)) {
// Don't flatten keys that are "state" or "netGross"
if (
val &&
typeof val === 'object' &&
!Array.isArray(val) &&
key !== 'state' &&
key !== 'netGross'
) {
flattenObject(val, newKey, res)
} else {
res[newKey] = val
@ -34,7 +42,6 @@ const PropertyChanges = ({ type, value }) => {
...flatOld,
...flatNew
}
console.log('combined', combinedChanges)
return (
<Descriptions size='small' column={1}>
{Object.keys(combinedChanges).map((key) => {
@ -46,19 +53,25 @@ const PropertyChanges = ({ type, value }) => {
return (
<Descriptions.Item key={key} label={changeProperty.label}>
<Space>
<ObjectProperty
{...changeProperty}
longId={false}
objectData={value?.old}
/>
<Text type='secondary'>
<ArrowRightIcon />
</Text>
<ObjectProperty
{...changeProperty}
longId={false}
objectData={value?.new}
/>
{value?.old ? (
<ObjectProperty
{...changeProperty}
longId={false}
objectData={value?.old}
/>
) : null}
{value?.old && value?.new ? (
<Text type='secondary'>
<ArrowRightIcon />
</Text>
) : null}
{value?.new ? (
<ObjectProperty
{...changeProperty}
longId={false}
objectData={value?.new}
/>
) : null}
</Space>
</Descriptions.Item>
)

View File

@ -0,0 +1,32 @@
import React from 'react'
import { Typography } from 'antd'
import PropTypes from 'prop-types'
const { Text } = Typography
const VarianceDisplay = ({ value, prefix, suffix }) => {
if (value === null || value === undefined) {
return <Text type='secondary'>n/a</Text>
}
const isPositive = value > 0
const isNegative = value < 0
const displayValue = Math.abs(value).toFixed(2)
return (
<Text type={isPositive ? 'success' : isNegative ? 'danger' : undefined}>
{prefix || ''}
{isPositive ? '+' : isNegative ? '-' : ''}
{displayValue}
{suffix || ''}
</Text>
)
}
VarianceDisplay.propTypes = {
value: PropTypes.number,
prefix: PropTypes.string,
suffix: PropTypes.string
}
export default VarianceDisplay

View File

@ -4,7 +4,8 @@ import React, {
useEffect,
useState,
useContext,
useRef
useRef,
useCallback
} from 'react'
import io from 'socket.io-client'
import { message, notification, Modal, Space, Button } from 'antd'
@ -22,8 +23,9 @@ logger.setLevel(config.logLevel)
const ApiServerContext = createContext()
const ApiServerProvider = ({ children }) => {
const { token, userProfile } = useContext(AuthContext)
const { token, userProfile, authenticated } = useContext(AuthContext)
const socketRef = useRef(null)
const [connected, setConnected] = useState(false)
const [connecting, setConnecting] = useState(false)
const [error, setError] = useState(null)
const [messageApi, contextHolder] = message.useMessage()
@ -32,9 +34,43 @@ const ApiServerProvider = ({ children }) => {
const [showErrorModal, setShowErrorModal] = useState(false)
const [errorModalContent, setErrorModalContent] = useState('')
const [retryCallback, setRetryCallback] = useState(null)
const subscribedCallbacksRef = useRef(new Map())
const subscribedLockCallbacksRef = useRef(new Map())
useEffect(() => {
if (token) {
const notifyLockUpdate = useCallback(
async (lockData) => {
logger.debug('Notifying lock update:', lockData)
const objectId = lockData._id || lockData.id
if (
objectId &&
subscribedLockCallbacksRef.current.has(objectId) &&
lockData.user != userProfile?._id
) {
const callbacks = subscribedLockCallbacksRef.current.get(objectId)
logger.debug(
`Calling ${callbacks.length} lock callbacks for object:`,
objectId
)
callbacks.forEach((callback) => {
try {
callback(lockData)
} catch (error) {
logger.error('Error in lock update callback:', error)
}
})
} else {
logger.debug(
`No lock callbacks found for object: ${objectId}, subscribed lock callbacks:`,
Array.from(subscribedLockCallbacksRef.current.keys())
)
}
},
[userProfile?._id]
)
const connectToServer = useCallback(() => {
if (token && authenticated == true) {
logger.debug('Token is available, connecting to api server...')
const newSocket = io(config.apiServerUrl, {
@ -48,18 +84,25 @@ const ApiServerProvider = ({ children }) => {
newSocket.on('connect', () => {
logger.debug('Api Server connected')
setConnecting(false)
setConnected(true)
setError(null)
})
newSocket.on('notify_object_update', notifyObjectUpdate)
newSocket.on('notify_object_new', notifyObjectNew)
newSocket.on('notify_lock_update', notifyLockUpdate)
newSocket.on('disconnect', () => {
logger.debug('Api Server disconnected')
setError('Api Server disconnected')
setConnected(false)
})
newSocket.on('connect_error', (err) => {
logger.error('Api Server connection error:', err)
messageApi.error('Api Server connection error: ' + err.message)
setError('Api Server connection error')
setConnected(false)
})
newSocket.on('bridge.notification', (data) => {
@ -75,21 +118,27 @@ const ApiServerProvider = ({ children }) => {
})
socketRef.current = newSocket
}
}, [token, authenticated, messageApi, notificationApi, notifyLockUpdate])
// Clean up function
return () => {
if (socketRef.current) {
logger.debug('Cleaning up api server connection...')
socketRef.current.disconnect()
socketRef.current = null
}
}
useEffect(() => {
if (token && authenticated == true) {
connectToServer()
} else if (!token && socketRef.current) {
logger.debug('Token not available, disconnecting api server...')
socketRef.current.disconnect()
socketRef.current = null
}
}, [token, messageApi])
// Clean up function
return () => {
if (socketRef.current) {
logger.debug('Cleaning up api server connection...')
socketRef.current.disconnect()
socketRef.current = null
}
}
}, [token, authenticated, connectToServer])
const lockObject = (id, type) => {
logger.debug('Locking ' + id)
@ -118,8 +167,12 @@ const ApiServerProvider = ({ children }) => {
type: type
},
(lockEvent) => {
logger.debug('Received lock event for object:', id, lockEvent)
resolve(lockEvent)
logger.debug('Received lock status for object:', id, lockEvent)
if (lockEvent.user != userProfile?._id) {
resolve(lockEvent)
} else {
resolve(null)
}
}
)
logger.debug('Sent fetch lock command for object:', id)
@ -127,65 +180,186 @@ const ApiServerProvider = ({ children }) => {
}
}
const onLockEvent = (id, callback) => {
const notifyObjectUpdate = async (object) => {
logger.debug('Notifying object update:', object)
const objectId = object._id || object.id
if (objectId && subscribedCallbacksRef.current.has(objectId)) {
const callbacks = subscribedCallbacksRef.current.get(objectId)
logger.debug(
`Calling ${callbacks.length} callbacks for object:`,
objectId
)
callbacks.forEach((callback) => {
try {
callback(object)
} catch (error) {
logger.error('Error in object update callback:', error)
}
})
} else {
logger.debug(
`No callbacks found for object: ${objectId}, subscribed callbacks:`,
Array.from(subscribedCallbacksRef.current.keys())
)
}
}
const notifyObjectNew = async (object) => {
logger.debug('Notifying object new:', object)
const objectType = object.type || 'unknown'
if (objectType && subscribedCallbacksRef.current.has(objectType)) {
const callbacks = subscribedCallbacksRef.current.get(objectType)
logger.debug(
`Calling ${callbacks.length} callbacks for type:`,
objectType
)
callbacks.forEach((callback) => {
try {
callback(object)
} catch (error) {
logger.error('Error in object new callback:', error)
}
})
} else {
logger.debug(
`No callbacks found for object: ${objectType}, subscribed callbacks:`,
Array.from(subscribedCallbacksRef.current.keys())
)
}
}
const offUpdateEvent = useCallback((id, type, callback) => {
if (socketRef.current && socketRef.current.connected == true) {
const eventHandler = (data) => {
if (data._id === id && data?.user !== userProfile._id) {
logger.debug(
'Lock update received for object:',
id,
'locked:',
data.locked
)
callback(data)
// Remove callback from the subscribed callbacks map
if (subscribedCallbacksRef.current.has(id)) {
const callbacks = subscribedCallbacksRef.current
.get(id)
.filter((cb) => cb !== callback)
if (callbacks.length === 0) {
subscribedCallbacksRef.current.delete(id)
socketRef.current.emit('unsubscribe', { id: id, type: type })
} else {
subscribedCallbacksRef.current.set(id, callbacks)
}
}
socketRef.current.on('notify_lock_update', eventHandler)
logger.debug('Registered lock event listener for object:', id)
// Return cleanup function
return () => offLockEvent(id, eventHandler)
}
}
const offLockEvent = (id, eventHandler) => {
if (socketRef.current && socketRef.current.connected == true) {
socketRef.current.off('notify_lock_update', eventHandler)
logger.debug('Removed lock event listener for object:', id)
}
}
const onUpdateEvent = (id, callback) => {
if (socketRef.current && socketRef.current.connected == true) {
const eventHandler = (data) => {
if (data._id === id && data?.user !== userProfile._id) {
logger.debug(
'Update event received for object:',
id,
'updatedAt:',
data.updatedAt
)
callback(data)
}
}
socketRef.current.on('notify_object_update', eventHandler)
logger.debug('Registered update event listener for object:', id)
// Return cleanup function
return () => offUpdateEvent(id, eventHandler)
}
}
const offUpdateEvent = (id, eventHandler) => {
if (socketRef.current && socketRef.current.connected == true) {
socketRef.current.off('notify_update', eventHandler)
logger.debug('Removed update event listener for object:', id)
}
}
}, [])
const showError = (content, callback = null) => {
const offTypeEvent = useCallback((type, callback) => {
if (socketRef.current && socketRef.current.connected == true) {
// Remove callback from the subscribed callbacks map
if (subscribedCallbacksRef.current.has(type)) {
const callbacks = subscribedCallbacksRef.current
.get(type)
.filter((cb) => cb !== callback)
if (callbacks.length === 0) {
subscribedCallbacksRef.current.delete(type)
socketRef.current.emit('unsubscribe', { type: type })
} else {
subscribedCallbacksRef.current.set(type, callbacks)
}
}
logger.debug('Removed new event listener for type:', type)
}
}, [])
const subscribeToObject = useCallback(
(id, type, callback) => {
logger.debug('Subscribing to object:', id, 'type:', type)
if (socketRef.current && socketRef.current.connected == true) {
// Add callback to the subscribed callbacks map immediately
if (!subscribedCallbacksRef.current.has(id)) {
subscribedCallbacksRef.current.set(id, [])
}
subscribedCallbacksRef.current.get(id).push(callback)
logger.debug(
`Added callback for object ${id}, total callbacks: ${subscribedCallbacksRef.current.get(id).length}`
)
socketRef.current.emit('subscribe', { id: id, type: type })
logger.debug('Registered update event listener for object:', id)
// Return cleanup function
return () => offUpdateEvent(id, type, callback)
}
},
[offUpdateEvent]
)
const subscribeToType = useCallback(
(type, callback) => {
logger.debug('Subscribing to type:', type)
if (socketRef.current && socketRef.current.connected == true) {
// Add callback to the subscribed callbacks map immediately
if (!subscribedCallbacksRef.current.has(type)) {
subscribedCallbacksRef.current.set(type, [])
}
subscribedCallbacksRef.current.get(type).push(callback)
logger.debug(
`Added callback for type ${type}, total callbacks: ${subscribedCallbacksRef.current.get(type).length}`
)
socketRef.current.emit('subscribe', { type: type })
logger.debug('Registered update event listener for object:', type)
// Return cleanup function
return () => offTypeEvent(type, callback)
}
},
[offTypeEvent]
)
const offLockEvent = useCallback((id, callback) => {
if (socketRef.current && socketRef.current.connected == true) {
// Remove callback from the subscribed lock callbacks map
if (subscribedLockCallbacksRef.current.has(id)) {
const callbacks = subscribedLockCallbacksRef.current
.get(id)
.filter((cb) => cb !== callback)
if (callbacks.length === 0) {
subscribedLockCallbacksRef.current.delete(id)
} else {
subscribedLockCallbacksRef.current.set(id, callbacks)
}
}
logger.debug('Removed lock event listener for object:', id)
}
}, [])
const subscribeToLock = useCallback(
(id, type, callback) => {
logger.debug('Subscribing to lock for object:', id, 'type:', type)
if (socketRef.current && socketRef.current.connected == true) {
// Add callback to the subscribed lock callbacks map immediately
if (!subscribedLockCallbacksRef.current.has(id)) {
subscribedLockCallbacksRef.current.set(id, [])
}
subscribedLockCallbacksRef.current.get(id).push(callback)
logger.debug(
`Added lock callback for object ${id}, total lock callbacks: ${subscribedLockCallbacksRef.current.get(id).length}`
)
socketRef.current.emit('subscribe_lock', { id: id, type: type })
logger.debug('Registered lock event listener for object:', id)
// Return cleanup function
return () => offLockEvent(id, callback)
}
},
[offLockEvent]
)
const showError = (error, callback = null) => {
var content = `Error ${error.code} (${error.status}): ${error.message}`
if (error.response?.data?.error) {
content = `${error.response?.data?.error} (${error.status})`
}
setErrorModalContent(content)
setRetryCallback(() => callback)
setShowErrorModal(true)
@ -200,8 +374,8 @@ const ApiServerProvider = ({ children }) => {
setRetryCallback(null)
}
// Generalized fetchObjectInfo function
const fetchObjectInfo = async (id, type) => {
// Generalized fetchObject function
const fetchObject = async (id, type) => {
const fetchUrl = `${config.backendUrl}/${type}s/${id}`
setFetchLoading(true)
logger.debug('Fetching from ' + fetchUrl)
@ -214,62 +388,17 @@ const ApiServerProvider = ({ children }) => {
})
return response.data
} catch (err) {
logger.error('Failed to fetch object information:', err)
// Don't automatically show error - let the component handle it
throw err
showError(err, () => {
fetchObject(id, type)
})
return {}
} finally {
setFetchLoading(false)
}
}
// Update filament information
const updateObjectInfo = async (id, type, value) => {
const updateUrl = `${config.backendUrl}/${type.toLowerCase()}s/${id}`
logger.debug('Updating info for ' + id)
try {
const response = await axios.put(updateUrl, value, {
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
})
logger.debug('Filament updated successfully')
if (socketRef.current && socketRef.current.connected == true) {
await socketRef.current.emit('update', {
_id: id,
type: type,
updatedAt: response.data.updatedAt
})
}
return response.data
} catch (err) {
logger.error('Failed to update filament information:', err)
// Don't automatically show error - let the component handle it
throw err
}
}
// Update filament information
const createObject = async (type, value) => {
const createUrl = `${config.backendUrl}/${type.toLowerCase()}s`
logger.debug('Creating object...')
try {
const response = await axios.post(createUrl, value, {
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
})
return response.data
} catch (err) {
logger.error('Failed to update filament information:', err)
// Don't automatically show error - let the component handle it
throw err
}
}
// Fetch table data with pagination, filtering, and sorting
const fetchTableData = async (type, params = {}) => {
const fetchObjects = async (type, params = {}) => {
const {
page = 1,
limit = 25,
@ -319,9 +448,122 @@ const ApiServerProvider = ({ children }) => {
hasMore,
page
}
} catch (error) {
logger.error('Failed to fetch table data:', error)
throw error
} catch (err) {
showError(err, () => {
fetchObjects(type, params)
})
return []
}
}
// Fetch table data with pagination, filtering, and sorting
const fetchObjectsByProperty = async (type, params = {}) => {
const { filter = {}, properties = [] } = params
logger.debug('Fetching property object data from:', type, {
properties,
filter
})
try {
const response = await axios.get(
`${config.backendUrl}/${type.toLowerCase()}s/properties`,
{
params: {
...filter,
properties: properties.join(',') // Convert array to comma-separated string
},
headers: {
Accept: 'application/json'
},
withCredentials: true
}
)
const newData = response.data
return newData
} catch (err) {
showError(err, () => {
fetchObjectsByProperty(type, params)
})
return []
}
}
// Update filament information
const updateObject = async (id, type, value) => {
const updateUrl = `${config.backendUrl}/${type.toLowerCase()}s/${id}`
logger.debug('Updating info for ' + id)
try {
const response = await axios.put(updateUrl, value, {
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
})
logger.debug('Object updated successfully')
if (socketRef.current && socketRef.current.connected == true) {
await socketRef.current.emit('update', {
_id: id,
type: type,
updatedAt: response.data.updatedAt
})
}
return response.data
} catch (err) {
setError(err, () => {
updateObject(id, type, value)
})
return {}
}
}
// Update filament information
const deleteObject = async (id, type) => {
const deleteUrl = `${config.backendUrl}/${type.toLowerCase()}s/${id}`
logger.debug('Deleting object ID: ' + id)
try {
const response = await axios.delete(deleteUrl, {
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
})
logger.debug('Object deleted successfully')
if (socketRef.current && socketRef.current.connected == true) {
await socketRef.current.emit('update', {
_id: id,
type: type,
updatedAt: response.data.updatedAt
})
}
return response.data
} catch (err) {
showError(err, () => {
deleteObject(id, type)
})
return {}
}
}
// Update filament information
const createObject = async (type, value) => {
const createUrl = `${config.backendUrl}/${type.toLowerCase()}s`
logger.debug('Creating object...')
try {
const response = await axios.post(createUrl, value, {
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
})
return response.data
} catch (err) {
showError(err, () => {
createObject(type, value)
})
return {}
}
}
@ -348,27 +590,35 @@ const ApiServerProvider = ({ children }) => {
document.body.appendChild(fileLink)
fileLink.click()
fileLink.parentNode.removeChild(fileLink)
} catch (error) {
logger.error('Failed to download GCode file content:', error)
} catch (err) {
showError(err, () => {
fetchObjectContent(id, type, fileName)
})
}
}
if (error.response) {
if (error.response.status === 404) {
showError(
`The ${type} file "${fileName}" was not found on the server. It may have been deleted or moved.`,
() => fetchObjectContent(id, type, fileName)
)
} else {
showError(
`Error downloading ${type} file: ${error.response.status} - ${error.response.statusText}`,
() => fetchObjectContent(id, type, fileName)
)
}
} else {
showError(
'An unexpected error occurred while downloading. Please check your connection and try again.',
() => fetchObjectContent(id, type, fileName)
)
}
// Fetch notes for a specific parent
const fetchNotes = async (parentId) => {
logger.debug('Fetching notes for parent:', parentId)
try {
const response = await axios.get(`${config.backendUrl}/notes`, {
params: {
parent: parentId,
sort: 'createdAt',
order: 'ascend'
},
headers: {
Accept: 'application/json'
},
withCredentials: true
})
const notesData = response.data
logger.debug('Fetched notes:', notesData.length)
return notesData
} catch (error) {
logger.error('Failed to fetch notes:', error)
throw error
}
}
@ -378,19 +628,24 @@ const ApiServerProvider = ({ children }) => {
apiServer: socketRef.current,
error,
connecting,
connected,
lockObject,
unlockObject,
fetchObjectLock,
updateObjectInfo,
updateObject,
createObject,
onLockEvent,
onUpdateEvent,
deleteObject,
subscribeToObject,
subscribeToType,
subscribeToLock,
offUpdateEvent,
fetchObjectInfo,
fetchTableData,
fetchObject,
fetchObjects,
fetchObjectsByProperty,
fetchLoading,
showError,
fetchObjectContent
fetchObjectContent,
fetchNotes
}}
>
{contextHolder}

View File

@ -53,8 +53,7 @@ const AuthProvider = ({ children }) => {
})
if (response.status === 200 && response.data) {
logger.debug('User is authenticated!')
setAuthenticated(true)
logger.debug('Got auth token!')
setToken(response.data.access_token)
setExpiresAt(response.data.expires_at)
setUserProfile(response.data)
@ -89,75 +88,6 @@ const AuthProvider = ({ children }) => {
}
}, [])
const showTokenExpirationMessage = useCallback(
(expiresAt) => {
const now = new Date()
const expirationDate = new Date(expiresAt)
const timeRemaining = expirationDate - now
if (timeRemaining <= 0) {
if (authenticated) {
setShowSessionExpiredModal(true)
setAuthenticated(false)
notificationApi.destroy('token-expiration')
}
} else {
const minutes = Math.floor(timeRemaining / 60000)
const seconds = Math.floor((timeRemaining % 60000) / 1000)
// Only show notification in the final minute
if (minutes === 0) {
const totalSeconds = 60
const remainingSeconds = totalSeconds - seconds
const progress = (remainingSeconds / totalSeconds) * 100
notificationApi.info({
message: 'Session Expiring Soon',
description: (
<div>
<div style={{ marginBottom: 8 }}>
Your session will expire in {seconds} seconds
</div>
<Progress
percent={progress}
size='small'
status='active'
showInfo={false}
/>
</div>
),
duration: 0,
key: 'token-expiration',
icon: null,
placement: 'bottomRight',
style: {
width: 360
},
className: 'token-expiration-notification',
closeIcon: null,
onClose: () => {},
btn: (
<Button
type='primary'
size='small'
onClick={() => {
notificationApi.destroy('token-expiration')
refreshToken()
}}
>
Reload Session
</Button>
)
})
} else if (minutes === 1) {
// Clear any existing notification when we enter the final minute
notificationApi.destroy('token-expiration')
}
}
},
[authenticated, notificationApi]
)
const handleSessionExpiredModalOk = () => {
setShowSessionExpiredModal(false)
loginWithSSO()
@ -167,22 +97,89 @@ const AuthProvider = ({ children }) => {
useEffect(() => {
let intervalId
const tokenRefreshInterval = () => {
const tokenRefresh = () => {
if (expiresAt) {
showTokenExpirationMessage(expiresAt)
const now = new Date()
const expirationDate = new Date(expiresAt)
const timeRemaining = expirationDate - now
if (timeRemaining <= 0) {
if (authenticated == true) {
setAuthenticated(false)
}
setShowSessionExpiredModal(true)
notificationApi.destroy('token-expiration')
} else {
if (authenticated == false) {
setAuthenticated(true)
}
const minutes = Math.floor(timeRemaining / 60000)
const seconds = Math.floor((timeRemaining % 60000) / 1000)
// Only show notification in the final minute
if (minutes === 0) {
const totalSeconds = 60
const remainingSeconds = totalSeconds - seconds
const progress = (remainingSeconds / totalSeconds) * 100
notificationApi.info({
message: 'Session Expiring Soon',
description: (
<div>
<div style={{ marginBottom: 8 }}>
Your session will expire in {seconds} seconds
</div>
<Progress
percent={progress}
size='small'
status='active'
showInfo={false}
/>
</div>
),
duration: 0,
key: 'token-expiration',
icon: null,
placement: 'bottomRight',
style: {
width: 360
},
className: 'token-expiration-notification',
closeIcon: null,
onClose: () => {},
btn: (
<Button
type='primary'
size='small'
onClick={() => {
notificationApi.destroy('token-expiration')
refreshToken()
}}
>
Reload Session
</Button>
)
})
} else if (minutes === 1) {
// Clear any existing notification when we enter the final minute
notificationApi.destroy('token-expiration')
}
}
}
}
if (authenticated) {
intervalId = setInterval(tokenRefreshInterval, 1000)
}
intervalId = setInterval(tokenRefresh, 1000)
console.log('fresh', authenticated)
tokenRefresh()
return () => {
if (intervalId) {
clearInterval(intervalId)
}
}
}, [expiresAt, authenticated, showTokenExpirationMessage])
}, [expiresAt, authenticated, notificationApi, refreshToken])
useEffect(() => {
checkAuthStatus()

View File

@ -461,16 +461,9 @@ const SpotlightProvider = ({ children }) => {
align='center'
justify='space-between'
>
<Flex
gap={'small'}
style={{ marginBottom: '2px' }}
align='center'
>
<Text>
{Icon ? (
<Icon style={{ fontSize: '20px' }} />
) : null}
</Text>
<Flex gap={'small'} align='center'>
{Icon ? <Icon style={{ fontSize: '20px' }} /> : null}
{item.name ? (
<Text ellipsis style={{ maxWidth: 170 }}>
{item.name}

View File

@ -6,8 +6,16 @@ export const AuditLog = {
prefix: 'ADL',
icon: AuditLogIcon,
actions: [],
columns: ['_id', 'owner', 'owner._id', 'parent._id', 'changes', 'createdAt'],
filters: ['_id', 'owner._id', 'parent._id'],
columns: [
'_id',
'owner',
'owner._id',
'parent._id',
'operation',
'changes',
'createdAt'
],
filters: ['_id', 'owner._id', 'parent._id', 'operation'],
sorters: ['createdAt'],
properties: [
{
@ -75,6 +83,12 @@ export const AuditLog = {
showHyperlink: true,
showCopy: true
},
{
name: 'operation',
label: 'Operation',
columnWidth: 120,
type: 'operation'
},
{
name: 'changes',
label: 'Changes',

View File

@ -1,7 +1,6 @@
import JobIcon from '../../components/Icons/JobIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon'
import EditIcon from '../../components/Icons/EditIcon'
export const Job = {
name: 'job',
@ -22,13 +21,6 @@ export const Job = {
label: 'Reload',
icon: ReloadIcon,
url: (_id) => `/dashboard/production/jobs/info?jobId=${_id}&action=reload`
},
{
name: 'edit',
label: 'Edit',
row: true,
icon: EditIcon,
url: (_id) => `/dashboard/production/jobs/info?jobId=${_id}&action=edit`
}
],
columns: [
@ -68,7 +60,7 @@ export const Job = {
type: 'object',
columnFixed: 'left',
objectType: 'gcodeFile',
readOnly: true
required: true
},
{
name: 'gcodeFile._id',
@ -98,9 +90,10 @@ export const Job = {
},
{
name: 'printers',
label: 'Assigned Printers',
type: 'number',
readOnly: true
label: 'Printers',
type: 'objectList',
objectType: 'printer',
required: true
}
]
}

View File

@ -16,5 +16,58 @@ export const Note = {
url: (_id) => `/dashboard/management/notes/info?noteId=${_id}`
}
],
url: () => `#`
properties: [
{
name: '_id',
label: 'ID',
type: 'id',
objectType: 'note',
showCopy: true
},
{
name: 'createdAt',
label: 'Created At',
type: 'dateTime',
readOnly: true
},
{
name: 'noteType',
label: 'Note Type',
type: 'object',
objectType: 'noteType',
showHyperlink: true
},
{
name: 'parent._id',
label: 'Parent ID',
type: 'id',
objectType: (objectData) => {
return objectData.parentType
},
showHyperlink: true
},
{
name: 'noteType._id',
label: 'Note Type ID',
type: 'id',
objectType: 'noteType'
},
{
name: 'user._id',
label: 'User ID',
type: 'id',
objectType: 'user'
},
{
name: 'content',
label: 'Content',
type: 'markdown'
},
{
name: 'updatedAt',
label: 'Updated At',
type: 'dateTime',
readOnly: true
}
]
}

View File

@ -60,7 +60,7 @@ export const StockEvent = {
label: 'Parent',
type: 'object',
objectType: (objectData) => {
return objectData.parentType
return objectData?.parentType
},
value: null,
showCopy: true
@ -78,9 +78,12 @@ export const StockEvent = {
{
name: 'value',
label: 'Value',
columnWidth: 100,
type: 'number',
showCopy: true
columnWidth: 120,
type: 'variance',
showCopy: true,
suffix: (objectData) => {
return objectData.unit
}
}
]
}