Refactor LockIndicator imports to use common path across components, remove unused LockIndicator file, and update related components for consistency. Enhance DashboardBreadcrumb for improved path mapping and add Hosts section to ManagementSidebar. Adjust ObjectSelect for better object handling and update ObjectTable for improved loading behavior.

This commit is contained in:
Tom Butcher 2025-07-20 18:29:48 +01:00
parent 587ef7f480
commit 66e137fac2
41 changed files with 1163 additions and 665 deletions

View File

@ -0,0 +1,75 @@
import React, { useState, useEffect, useContext, useCallback } from 'react'
import { Flex, Card, Alert } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import AuthParticles from './AppParticles'
import FarmControlLogo from '../Logos/FarmControlLogo'
import { AuthContext } from '../Dashboard/context/AuthContext'
import { useLocation, useNavigate } from 'react-router-dom'
const AuthCallback = () => {
const [isVisible, setIsVisible] = useState(false)
const [loading, setLoading] = useState(false)
const [initialized, setInitialized] = useState(false)
const { getLoginToken } = useContext(AuthContext)
const location = useLocation()
const navigate = useNavigate()
const code = new URLSearchParams(location.search).get('code')
const state = new URLSearchParams(location.search).get('state')
const handleGetloginToken = useCallback(async () => {
await getLoginToken(code)
setLoading(false)
navigate(state)
}, [code, state, navigate, getLoginToken])
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(true)
}, 1000)
if (!initialized && !loading) {
handleGetloginToken()
setInitialized(true)
setLoading(true)
}
return () => clearTimeout(timer)
}, [handleGetloginToken, initialized, loading])
return (
<div
style={{
backgroundColor: 'black'
}}
>
<div
style={{
backgroundColor: 'black',
minHeight: '100vh',
transition: 'opacity 0.5s ease-in-out',
opacity: isVisible ? 1 : 0
}}
>
<AuthParticles />
<Flex
align='center'
justify='center'
vertical
style={{ height: '100vh' }}
gap={'large'}
>
<Card style={{ borderRadius: 20 }}>
<Flex vertical align='center'>
<FarmControlLogo style={{ fontSize: '500px', height: '40px' }} />
</Flex>
</Card>
<Alert
message='Loading Farm Control please wait...'
icon={<LoadingOutlined />}
showIcon
/>
</Flex>
</div>
</div>
)
}
export default AuthCallback

View File

@ -11,7 +11,7 @@ import EditButtons from '../../common/EditButtons'
import ActionHandler from '../../common/ActionHandler' import ActionHandler from '../../common/ActionHandler'
import InfoCollapse from '../../common/InfoCollapse' import InfoCollapse from '../../common/InfoCollapse'
import NotesPanel from '../../common/NotesPanel' import NotesPanel from '../../common/NotesPanel'
import LockIndicator from '../../Management/Filaments/LockIndicator' import LockIndicator from '../../common/LockIndicator'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon' import InfoCircleIcon from '../../../Icons/InfoCircleIcon'
import FilamentStockIcon from '../../../Icons/FilamentStockIcon' import FilamentStockIcon from '../../../Icons/FilamentStockIcon'
import NoteIcon from '../../../Icons/NoteIcon' import NoteIcon from '../../../Icons/NoteIcon'

View File

@ -90,7 +90,6 @@ const LoadFilamentStock = ({
) )
) )
} }
logger.debug(statusUpdate)
} }
printServer.emit('printer.objects.subscribe', params) printServer.emit('printer.objects.subscribe', params)

View File

@ -14,7 +14,7 @@ import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx' import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import EditObjectForm from '../../common/EditObjectForm' import EditObjectForm from '../../common/EditObjectForm'
import EditButtons from '../../common/EditButtons' import EditButtons from '../../common/EditButtons'
import LockIndicator from './LockIndicator' import LockIndicator from '../../common/LockIndicator.jsx'
import ActionHandler from '../../common/ActionHandler' import ActionHandler from '../../common/ActionHandler'
import ObjectActions from '../../common/ObjectActions.jsx' import ObjectActions from '../../common/ObjectActions.jsx'
import ObjectTable from '../../common/ObjectTable.jsx' import ObjectTable from '../../common/ObjectTable.jsx'

View File

@ -0,0 +1,105 @@
// src/hosts.js
import React, { useRef, useState } from 'react'
import { Button, Flex, Space, Modal, message, Dropdown } from 'antd'
import NewHost from './Hosts/NewHost'
import useColumnVisibility from '../hooks/useColumnVisibility'
import ColumnViewButton from '../common/ColumnViewButton'
import ObjectTable from '../common/ObjectTable'
import PlusIcon from '../../Icons/PlusIcon'
import ReloadIcon from '../../Icons/ReloadIcon'
import ListIcon from '../../Icons/ListIcon'
import GridIcon from '../../Icons/GridIcon'
import useViewMode from '../hooks/useViewMode'
const Hosts = () => {
const [messageApi, contextHolder] = message.useMessage()
const [newHostOpen, setNewHostOpen] = useState(false)
const tableRef = useRef()
// View mode state (cards/list), persisted in sessionStorage via custom hook
const [viewMode, setViewMode] = useViewMode('host')
const [columnVisibility, setColumnVisibility] = useColumnVisibility('host')
const actionItems = {
items: [
{
label: 'New Host',
key: 'newHost',
icon: <PlusIcon />
},
{ type: 'divider' },
{
label: 'Reload List',
key: 'reloadList',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reloadList') {
tableRef.current?.reload()
} else if (key === 'newHost') {
setNewHostOpen(true)
}
}
}
return (
<>
<Flex vertical={'true'} gap='large'>
{contextHolder}
<Flex justify={'space-between'}>
<Space>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<ColumnViewButton
type='host'
loading={false}
visibleState={columnVisibility}
updateVisibleState={setColumnVisibility}
/>
</Space>
<Space>
<Button
icon={viewMode === 'cards' ? <ListIcon /> : <GridIcon />}
onClick={() =>
setViewMode(viewMode === 'cards' ? 'list' : 'cards')
}
/>
</Space>
</Flex>
<ObjectTable
ref={tableRef}
type='host'
cards={viewMode === 'cards'}
visibleColumns={columnVisibility}
/>
<Modal
open={newHostOpen}
footer={null}
width={700}
onCancel={() => {
setNewHostOpen(false)
}}
>
<NewHost
onOk={() => {
setNewHostOpen(false)
messageApi.success('New host added successfully.')
tableRef.current?.reload()
}}
reset={newHostOpen}
/>
</Modal>
</Flex>
</>
)
}
export default Hosts

View File

@ -0,0 +1,186 @@
import React from 'react'
import { useLocation } from 'react-router-dom'
import { Space, Flex, Card } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import loglevel from 'loglevel'
import config from '../../../../config.js'
import useCollapseState from '../../hooks/useCollapseState.js'
import NotesPanel from '../../common/NotesPanel.jsx'
import InfoCollapse from '../../common/InfoCollapse.jsx'
import ObjectInfo from '../../common/ObjectInfo.jsx'
import ViewButton from '../../common/ViewButton.jsx'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import EditObjectForm from '../../common/EditObjectForm.jsx'
import EditButtons from '../../common/EditButtons.jsx'
import LockIndicator from '../../common/LockIndicator.jsx'
import ActionHandler from '../../common/ActionHandler.jsx'
import ObjectActions from '../../common/ObjectActions.jsx'
import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
const log = loglevel.getLogger('HostInfo')
log.setLevel(config.logLevel)
const HostInfo = () => {
const location = useLocation()
const hostId = new URLSearchParams(location.search).get('hostId')
const [collapseState, updateCollapseState] = useCollapseState('HostInfo', {
info: true,
stocks: true,
notes: true,
auditLogs: true
})
return (
<EditObjectForm id={hostId} type='host' style={{ height: '100%' }}>
{({
loading,
isEditing,
startEditing,
cancelEditing,
handleUpdate,
formValid,
objectData,
editLoading,
lock,
fetchObject
}) => {
// Define actions for ActionHandler
const actions = {
reload: () => {
fetchObject()
return true
},
edit: () => {
startEditing()
return false
},
cancelEdit: () => {
cancelEditing()
return true
},
finishEdit: () => {
handleUpdate()
return true
}
}
return (
<ActionHandler actions={actions} loading={loading}>
{({ callAction }) => (
<Flex
gap='large'
vertical='true'
style={{
height: 'calc(var(--unit-100vh) - 155px)',
minHeight: 0
}}
>
<Flex justify={'space-between'}>
<Space size='middle'>
<Space size='small'>
<ObjectActions
type='host'
id={hostId}
disabled={loading}
/>
<ViewButton
disabled={loading}
items={[
{ key: 'info', label: 'Host Information' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
visibleState={collapseState}
updateVisibleState={updateCollapseState}
/>
</Space>
<LockIndicator lock={lock} />
</Space>
<Space>
<EditButtons
isEditing={isEditing}
handleUpdate={() => {
callAction('finishEdit')
}}
cancelEditing={() => {
callAction('cancelEdit')
}}
startEditing={() => {
callAction('edit')
}}
editLoading={editLoading}
formValid={formValid}
disabled={lock?.locked || loading}
loading={editLoading}
/>
</Space>
</Flex>
<div style={{ height: '100%', overflowY: 'scroll' }}>
<Flex vertical gap={'large'}>
<InfoCollapse
title='Host Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) =>
updateCollapseState('info', expanded)
}
collapseKey='info'
>
<ObjectInfo
loading={loading}
indicator={<LoadingOutlined />}
isEditing={isEditing}
type='host'
objectData={objectData}
/>
</InfoCollapse>
<InfoCollapse
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) =>
updateCollapseState('notes', expanded)
}
collapseKey='notes'
>
<Card>
<NotesPanel _id={hostId} type='host' />
</Card>
</InfoCollapse>
<InfoCollapse
title='Audit Logs'
icon={<AuditLogIcon />}
active={collapseState.auditLogs}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
collapseKey='auditLogs'
>
{loading ? (
<InfoCollapsePlaceholder />
) : (
<ObjectTable
type='auditLog'
masterFilter={{ 'parent._id': hostId }}
visibleColumns={{ _id: false, 'parent._id': false }}
/>
)}
</InfoCollapse>
</Flex>
</div>
</Flex>
)}
</ActionHandler>
)
}}
</EditObjectForm>
)
}
export default HostInfo

View File

@ -0,0 +1,117 @@
import PropTypes from 'prop-types'
import React, { useState } from 'react'
import { useMediaQuery } from 'react-responsive'
import { Typography, Flex, Steps, Divider } from 'antd'
import ObjectInfo from '../../common/ObjectInfo'
import NewObjectForm from '../../common/NewObjectForm'
import NewObjectButtons from '../../common/NewObjectButtons'
const { Title } = Typography
const NewHost = ({ onOk }) => {
const [currentStep, setCurrentStep] = useState(0)
const isMobile = useMediaQuery({ maxWidth: 768 })
return (
<NewObjectForm type={'host'}>
{({ handleSubmit, submitLoading, objectData, formValid }) => {
const steps = [
{
title: 'Required',
key: 'required',
content: (
<ObjectInfo
type='host'
column={1}
bordered={false}
isEditing={true}
required={true}
objectData={objectData}
/>
)
},
{
title: 'Optional',
key: 'optional',
content: (
<ObjectInfo
type='host'
column={1}
bordered={false}
isEditing={true}
required={false}
objectData={objectData}
/>
)
},
{
title: 'Summary',
key: 'summary',
content: (
<ObjectInfo
type='host'
column={1}
bordered={false}
visibleProperties={{
_id: false,
createdAt: false,
updatedAt: false
}}
isEditing={false}
objectData={objectData}
/>
)
}
]
return (
<Flex gap='middle'>
{!isMobile && (
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
/>
</div>
)}
{!isMobile && (
<Divider type='vertical' style={{ height: 'unset' }} />
)}
<Flex vertical gap='middle' style={{ flexGrow: 1 }}>
<Title level={2} style={{ margin: 0 }}>
New Host
</Title>
<div style={{ minHeight: '260px', marginBottom: 8 }}>
{steps[currentStep].content}
</div>
<NewObjectButtons
currentStep={currentStep}
totalSteps={steps.length}
onPrevious={() => setCurrentStep((prev) => prev - 1)}
onNext={() => setCurrentStep((prev) => prev + 1)}
onSubmit={() => {
handleSubmit()
onOk()
}}
formValid={formValid}
submitLoading={submitLoading}
/>
</Flex>
</Flex>
)
}}
</NewObjectForm>
)
}
NewHost.propTypes = {
onOk: PropTypes.func.isRequired,
reset: PropTypes.bool
}
export default NewHost

View File

@ -11,6 +11,7 @@ import SettingsIcon from '../../Icons/SettingsIcon'
import AuditLogIcon from '../../Icons/AuditLogIcon' import AuditLogIcon from '../../Icons/AuditLogIcon'
import DeveloperIcon from '../../Icons/DeveloperIcon' import DeveloperIcon from '../../Icons/DeveloperIcon'
import PersonIcon from '../../Icons/PersonIcon' import PersonIcon from '../../Icons/PersonIcon'
import HostIcon from '../../Icons/HostIcon'
const items = [ const items = [
{ {
@ -50,6 +51,12 @@ const items = [
label: 'Note Types', label: 'Note Types',
path: '/dashboard/management/notetypes' path: '/dashboard/management/notetypes'
}, },
{
key: 'hosts',
icon: <HostIcon />,
label: 'Hosts',
path: '/dashboard/management/hosts'
},
{ {
key: 'users', key: 'users',
icon: <PersonIcon />, icon: <PersonIcon />,
@ -91,7 +98,8 @@ const routeKeyMap = {
'/dashboard/management/materials': 'materials', '/dashboard/management/materials': 'materials',
'/dashboard/management/notetypes': 'notetypes', '/dashboard/management/notetypes': 'notetypes',
'/dashboard/management/settings': 'settings', '/dashboard/management/settings': 'settings',
'/dashboard/management/auditlogs': 'auditlogs' '/dashboard/management/auditlogs': 'auditlogs',
'/dashboard/management/hosts': 'hosts'
} }
const ManagementSidebar = (props) => { const ManagementSidebar = (props) => {

View File

@ -10,7 +10,7 @@ import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx' import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import EditObjectForm from '../../common/EditObjectForm' import EditObjectForm from '../../common/EditObjectForm'
import EditButtons from '../../common/EditButtons' import EditButtons from '../../common/EditButtons'
import LockIndicator from '../Filaments/LockIndicator' import LockIndicator from '../../common/LockIndicator.jsx'
import ActionHandler from '../../common/ActionHandler.jsx' import ActionHandler from '../../common/ActionHandler.jsx'
import ObjectActions from '../../common/ObjectActions.jsx' import ObjectActions from '../../common/ObjectActions.jsx'
import ObjectTable from '../../common/ObjectTable.jsx' import ObjectTable from '../../common/ObjectTable.jsx'

View File

@ -8,7 +8,7 @@ import ObjectInfo from '../../common/ObjectInfo'
import ViewButton from '../../common/ViewButton' import ViewButton from '../../common/ViewButton'
import EditObjectForm from '../../common/EditObjectForm' import EditObjectForm from '../../common/EditObjectForm'
import EditButtons from '../../common/EditButtons' import EditButtons from '../../common/EditButtons'
import LockIndicator from '../Filaments/LockIndicator' import LockIndicator from '../../common/LockIndicator.jsx'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx' import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx' import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx' import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'

View File

@ -8,7 +8,7 @@ import ObjectInfo from '../../common/ObjectInfo'
import ViewButton from '../../common/ViewButton' import ViewButton from '../../common/ViewButton'
import EditObjectForm from '../../common/EditObjectForm' import EditObjectForm from '../../common/EditObjectForm'
import EditButtons from '../../common/EditButtons' import EditButtons from '../../common/EditButtons'
import LockIndicator from '../Filaments/LockIndicator' import LockIndicator from '../../common/LockIndicator.jsx'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx' import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx' import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx' import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'

View File

@ -12,7 +12,7 @@ import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx' import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import EditObjectForm from '../../common/EditObjectForm' import EditObjectForm from '../../common/EditObjectForm'
import EditButtons from '../../common/EditButtons' import EditButtons from '../../common/EditButtons'
import LockIndicator from '../Filaments/LockIndicator' import LockIndicator from '../../common/LockIndicator.jsx'
import ActionHandler from '../../common/ActionHandler' import ActionHandler from '../../common/ActionHandler'
import ObjectActions from '../../common/ObjectActions.jsx' import ObjectActions from '../../common/ObjectActions.jsx'
import ObjectTable from '../../common/ObjectTable.jsx' import ObjectTable from '../../common/ObjectTable.jsx'

View File

@ -13,7 +13,7 @@ import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx' import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import EditObjectForm from '../../common/EditObjectForm' import EditObjectForm from '../../common/EditObjectForm'
import EditButtons from '../../common/EditButtons' import EditButtons from '../../common/EditButtons'
import LockIndicator from '../Filaments/LockIndicator' import LockIndicator from '../../common/LockIndicator.jsx'
import ActionHandler from '../../common/ActionHandler.jsx' import ActionHandler from '../../common/ActionHandler.jsx'
import ObjectActions from '../../common/ObjectActions.jsx' import ObjectActions from '../../common/ObjectActions.jsx'
import ObjectTable from '../../common/ObjectTable.jsx' import ObjectTable from '../../common/ObjectTable.jsx'

View File

@ -9,7 +9,7 @@ import ObjectInfo from '../../common/ObjectInfo'
import ViewButton from '../../common/ViewButton' import ViewButton from '../../common/ViewButton'
import EditObjectForm from '../../common/EditObjectForm' import EditObjectForm from '../../common/EditObjectForm'
import EditButtons from '../../common/EditButtons' import EditButtons from '../../common/EditButtons'
import LockIndicator from '../../Management/Filaments/LockIndicator' import LockIndicator from '../../common/LockIndicator.jsx'
import ActionHandler from '../../common/ActionHandler' import ActionHandler from '../../common/ActionHandler'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx' import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx' import NoteIcon from '../../../Icons/NoteIcon.jsx'

View File

@ -9,8 +9,7 @@ import ObjectInfo from '../../common/ObjectInfo'
import ViewButton from '../../common/ViewButton' import ViewButton from '../../common/ViewButton'
import EditObjectForm from '../../common/EditObjectForm' import EditObjectForm from '../../common/EditObjectForm'
import EditButtons from '../../common/EditButtons' import EditButtons from '../../common/EditButtons'
import LockIndicator from '../../Management/Filaments/LockIndicator' import LockIndicator from '../../common/LockIndicator.jsx'
import SubJobsTree from '../../common/SubJobsTree'
import ActionHandler from '../../common/ActionHandler' import ActionHandler from '../../common/ActionHandler'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon' import InfoCircleIcon from '../../../Icons/InfoCircleIcon'
import JobIcon from '../../../Icons/JobIcon' import JobIcon from '../../../Icons/JobIcon'
@ -128,7 +127,11 @@ const JobInfo = () => {
} }
collapseKey='subJobs' collapseKey='subJobs'
> >
<SubJobsTree jobData={objectData} loading={loading} /> <ObjectTable
type='subJob'
masterFilter={{ 'job._id': jobId }}
visibleColumns={{ 'job._id': false }}
/>
</InfoCollapse> </InfoCollapse>
<InfoCollapse <InfoCollapse

View File

@ -17,7 +17,7 @@ const NewJob = ({ onOk }) => {
<NewObjectForm <NewObjectForm
type={'job'} type={'job'}
defaultValues={{ defaultValues={{
active: true state: { type: 'draft' }
}} }}
> >
{({ handleSubmit, submitLoading, objectData, formValid }) => { {({ handleSubmit, submitLoading, objectData, formValid }) => {
@ -36,20 +36,6 @@ const NewJob = ({ onOk }) => {
/> />
) )
}, },
{
title: 'Optional',
key: 'optional',
content: (
<ObjectInfo
type='job'
column={1}
bordered={false}
isEditing={true}
required={false}
objectData={objectData}
/>
)
},
{ {
title: 'Summary', title: 'Summary',
key: 'summary', key: 'summary',
@ -61,7 +47,8 @@ const NewJob = ({ onOk }) => {
visibleProperties={{ visibleProperties={{
_id: false, _id: false,
createdAt: false, createdAt: false,
updatedAt: false updatedAt: false,
startedAt: false
}} }}
isEditing={false} isEditing={false}
objectData={objectData} objectData={objectData}

View File

@ -9,7 +9,7 @@ import ObjectInfo from '../../common/ObjectInfo'
import ViewButton from '../../common/ViewButton' import ViewButton from '../../common/ViewButton'
import EditObjectForm from '../../common/EditObjectForm' import EditObjectForm from '../../common/EditObjectForm'
import EditButtons from '../../common/EditButtons' import EditButtons from '../../common/EditButtons'
import LockIndicator from '../../Management/Filaments/LockIndicator' import LockIndicator from '../../common/LockIndicator.jsx'
import PrinterJobsTree from '../../common/PrinterJobsTree' import PrinterJobsTree from '../../common/PrinterJobsTree'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx' import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx' import NoteIcon from '../../../Icons/NoteIcon.jsx'

View File

@ -23,7 +23,9 @@ const CountryDisplay = ({ countryCode }) => {
hasBorderRadius={true} hasBorderRadius={true}
gradient='real-circular' gradient='real-circular'
/> />
<div>
<Text ellipsis>{country.name}</Text> <Text ellipsis>{country.name}</Text>
</div>
</Flex> </Flex>
) )
} }

View File

@ -6,70 +6,55 @@ import ArrowLeftIcon from '../../Icons/ArrowLeftIcon'
import ArrowRightIcon from '../../Icons/ArrowRightIcon' import ArrowRightIcon from '../../Icons/ArrowRightIcon'
const breadcrumbNameMap = { const breadcrumbNameMap = {
'/dashboard/production': 'Production', production: 'Production',
'/dashboard/inventory': 'Inventory', inventory: 'Inventory',
'/dashboard/management': 'Management', management: 'Management',
'/dashboard/developer': 'Developer', developer: 'Developer',
'/dashboard/production/overview': 'Overview', overview: 'Overview',
'/dashboard/production/printers': 'Printers', printers: 'Printers',
'/dashboard/production/printers/control': 'Control', hosts: 'Hosts',
'/dashboard/production/printers/info': 'Info', control: 'Control',
'/dashboard/production/jobs': 'Jobs', info: 'Info',
'/dashboard/production/subjobs': 'Sub Jobs', jobs: 'Jobs',
'/dashboard/production/jobs/info': 'Info', subjobs: 'Sub Jobs',
'/dashboard/production/gcodefiles': 'G Code Files', gcodefiles: 'G Code Files',
'/dashboard/production/gcodefiles/info': 'Info', filaments: 'Filaments',
'/dashboard/management/filaments': 'Filaments', parts: 'Parts',
'/dashboard/management/filaments/info': 'Info', products: 'Products',
'/dashboard/management/parts': 'Parts', vendors: 'Vendors',
'/dashboard/management/parts/info': 'Info', materials: 'Materials',
'/dashboard/management/products': 'Products', notetypes: 'Note Types',
'/dashboard/management/products/info': 'Info', users: 'Users',
'/dashboard/management/vendors': 'Vendors', settings: 'Settings',
'/dashboard/management/vendors/info': 'Info', auditlogs: 'Audit Logs',
'/dashboard/management/materials': 'Materials', filamentstocks: 'Filament Stocks',
'/dashboard/management/materials/info': 'Info', partstocks: 'Part Stocks',
'/dashboard/management/notetypes': 'Note Types', productstocks: 'Products',
'/dashboard/management/notetypes/info': 'Info', stockevents: 'Stock Events',
'/dashboard/management/users': 'Users', stockaudits: 'Stock Audits',
'/dashboard/management/users/info': 'Info', sessionstorage: 'Session Storage',
'/dashboard/management/settings': 'Settings', authcontextdebug: 'Auth Context Debug',
'/dashboard/management/auditlogs': 'Audit Logs', printservercontextdebug: 'Print Server Context Debug'
'/dashboard/inventory/filamentstocks': 'Filament Stocks',
'/dashboard/inventory/filamentstocks/info': 'Info',
'/dashboard/inventory/partstocks': 'Part Stocks',
'/dashboard/inventory/partstocks/info': 'Info',
'/dashboard/inventory/productstocks': 'Products',
'/dashboard/inventory/productstocks/info': 'Info',
'/dashboard/inventory/stockevents': 'Stock Events',
'/dashboard/inventory/stockevents/info': 'Info',
'/dashboard/inventory/stockaudits': 'Stock Audits',
'/dashboard/inventory/stockaudits/info': 'Info',
'/dashboard/developer/sessionstorage': 'Session Storage',
'/dashboard/developer/authcontextdebug': 'Auth Context Debug',
'/dashboard/developer/printservercontextdebug': 'Print Server Context Debug'
} }
const mainSections = ['production', 'inventory', 'management', 'developer']
const DashboardBreadcrumb = () => { const DashboardBreadcrumb = () => {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const pathSnippets = location.pathname.split('/').filter((i) => i) const pathSnippets = location.pathname.split('/').filter((i) => i)
const breadcrumbItems = pathSnippets.map((_, index) => { const breadcrumbItems = pathSnippets.map((segment, index) => {
const url = `/${pathSnippets.slice(0, index + 1).join('/')}` const url = `/${pathSnippets.slice(0, index + 1).join('/')}`
if (url != '/dashboard') { if (segment !== 'dashboard') {
// Check if this is a main section (Production, Inventory, or Management) const isMainSection = mainSections.includes(segment)
const isMainSection = const name = breadcrumbNameMap[segment] || segment
url === '/dashboard/production' ||
url === '/dashboard/inventory' ||
url === '/dashboard/management'
return { return {
title: isMainSection ? ( title: isMainSection ? (
<span style={{ padding: '0 12px' }}>{breadcrumbNameMap[url]}</span> <span style={{ padding: '0 12px' }}>{name}</span>
) : ( ) : (
<Link to={url} style={{ padding: '0 12px' }}> <Link to={url} style={{ padding: '0 12px' }}>
{breadcrumbNameMap[url]} {name}
</Link> </Link>
), ),
key: url key: url

View File

@ -37,6 +37,8 @@ import SearchIcon from '../../Icons/SearchIcon'
import SettingsIcon from '../../Icons/SettingsIcon' import SettingsIcon from '../../Icons/SettingsIcon'
import DeveloperIcon from '../../Icons/DeveloperIcon' import DeveloperIcon from '../../Icons/DeveloperIcon'
import PrinterIcon from '../../Icons/PrinterIcon' import PrinterIcon from '../../Icons/PrinterIcon'
import { ElectronContext } from '../context/ElectronContext'
import DashboardWindowButtons from './DashboardWindowButtons'
const DashboardNavigation = () => { const DashboardNavigation = () => {
const { logout, userProfile } = useContext(AuthContext) const { logout, userProfile } = useContext(AuthContext)
@ -50,6 +52,7 @@ const DashboardNavigation = () => {
const location = useLocation() const location = useLocation()
const [selectedKey, setSelectedKey] = useState('production') const [selectedKey, setSelectedKey] = useState('production')
const isMobile = useMediaQuery({ maxWidth: 768 }) const isMobile = useMediaQuery({ maxWidth: 768 })
const { platform, isElectron } = useContext(ElectronContext)
useEffect(() => { useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean) const pathParts = location.pathname.split('/').filter(Boolean)
@ -135,34 +138,17 @@ const DashboardNavigation = () => {
} }
} }
return ( const navigationContents = (
<Header <>
style={{ {isElectron && platform == 'darwin' ? <DashboardWindowButtons /> : null}
width: '100vw', {!isElectron && !isMobile ? (
padding: 0,
marginBottom: '0.1px',
background: 'unset'
}}
theme='light'
className='ant-menu-horizontal'
>
<Flex
gap={'large'}
align='center'
className='ant-menu-light'
style={{
padding: '0 26px',
height: '100%',
borderBottom: '1px solid rgba(5, 5, 5, 0.00)'
}}
>
{!isMobile ? (
<FarmControlLogo style={{ fontSize: '200px' }} /> <FarmControlLogo style={{ fontSize: '200px' }} />
) : ( ) : !isElectron && isMobile ? (
<FarmControlLogoSmall style={{ fontSize: '48px' }} /> <FarmControlLogoSmall style={{ fontSize: '48px' }} />
)} ) : null}
<Menu <Menu
mode='horizontal' mode='horizontal'
className={isElectron ? 'electron-navigation' : null}
items={mainMenuItems} items={mainMenuItems}
style={{ style={{
flexWrap: 'wrap', flexWrap: 'wrap',
@ -281,9 +267,47 @@ const DashboardNavigation = () => {
</Space> </Space>
) : null} ) : null}
</Flex> </Flex>
</>
)
return (
<>
{isElectron ? (
<Flex
className='ant-menu-horizontal ant-menu-light electron-navigation-wrapper'
style={{ lineHeight: '40px', padding: '0 8px 0 4px' }}
>
{navigationContents}
</Flex>
) : (
<Flex vertical>
<Header
style={{
width: '100vw',
padding: 0,
marginBottom: '0.1px',
background: 'unset'
}}
theme='light'
className='ant-menu-horizontal'
>
<Flex
gap={'large'}
align='center'
className='ant-menu-light'
style={{
padding: '0 26px',
height: '100%',
borderBottom: '1px solid rgba(5, 5, 5, 0.00)'
}}
>
{navigationContents}
</Flex> </Flex>
<Divider style={{ margin: 0 }} /> <Divider style={{ margin: 0 }} />
</Header> </Header>
</Flex>
)}
</>
) )
} }

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect, useContext } from 'react'
import { Layout, Menu, Flex, Button } from 'antd' import { Layout, Menu, Flex, Button } from 'antd'
import { CaretDownFilled } from '@ant-design/icons' import { CaretDownFilled } from '@ant-design/icons'
import CollapseSidebarIcon from '../../Icons/CollapseSidebarIcon' import CollapseSidebarIcon from '../../Icons/CollapseSidebarIcon'
@ -6,7 +6,7 @@ import ExpandSidebarIcon from '../../Icons/ExpandSidebarIcon'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { ElectronContext } from '../context/ElectronContext'
const { Sider } = Layout const { Sider } = Layout
const DashboardSidebar = ({ const DashboardSidebar = ({
@ -24,6 +24,8 @@ const DashboardSidebar = ({
const isMobile = useMediaQuery({ maxWidth: 768 }) const isMobile = useMediaQuery({ maxWidth: 768 })
const navigate = useNavigate() const navigate = useNavigate()
const { isElectron } = useContext(ElectronContext)
useEffect(() => { useEffect(() => {
if (typeof collapsedProp === 'boolean') { if (typeof collapsedProp === 'boolean') {
setCollapsed(collapsedProp) setCollapsed(collapsedProp)
@ -56,6 +58,7 @@ const DashboardSidebar = ({
selectedKeys={[selectedKey]} selectedKeys={[selectedKey]}
items={_items} items={_items}
_internalDisableMenuItemTitleTooltip _internalDisableMenuItemTitleTooltip
style={{ lineHeight: '40px' }}
overflowedIndicator={<Button type='text' icon={<CaretDownFilled />} />} overflowedIndicator={<Button type='text' icon={<CaretDownFilled />} />}
/> />
) )
@ -72,6 +75,7 @@ const DashboardSidebar = ({
mode='inline' mode='inline'
selectedKeys={[selectedKey]} selectedKeys={[selectedKey]}
items={_items} items={_items}
className={isElectron ? 'electron-sidebar' : null}
style={{ flexGrow: 1, border: 'none' }} style={{ flexGrow: 1, border: 'none' }}
_internalDisableMenuItemTitleTooltip _internalDisableMenuItemTitleTooltip
/> />

View File

@ -0,0 +1,50 @@
import React, { useContext } from 'react'
import { Flex, Button } from 'antd'
import { ElectronContext } from '../context/ElectronContext'
import XMarkIcon from '../../Icons/XMarkIcon'
import MinusIcon from '../../Icons/MinusIcon'
import ContractIcon from '../../Icons/ContractIcon'
import ExpandIcon from '../../Icons/ExpandIcon'
const DashboardWindowButtons = () => {
const { isMaximized, handleWindowControl, platform } =
useContext(ElectronContext)
const closeButton = (
<Button
icon={<XMarkIcon />}
type={'text'}
onClick={() => handleWindowControl('close')}
/>
)
const maximizeButton = (
<Button
icon={<MinusIcon />}
type={'text'}
onClick={() => handleWindowControl('minimize')}
/>
)
const minimizeButton = (
<Button
icon={isMaximized ? <ContractIcon /> : <ExpandIcon />}
type={'text'}
onClick={() => handleWindowControl('maximize')}
/>
)
return (
<Flex align='center'>
{platform == 'darwin' ? (
<>
{closeButton}
{minimizeButton}
{maximizeButton}
</>
) : (
<>{closeButton}</>
)}
</Flex>
)
}
export default DashboardWindowButtons

View File

@ -6,14 +6,7 @@ import BinIcon from '../../Icons/BinIcon'
const { Text } = Typography const { Text } = Typography
const DeleteObjectModal = ({ const DeleteObjectModal = ({ open, onOk, onCancel, loading, objectType }) => {
open,
onOk,
onCancel,
loading,
objectType,
objectName
}) => {
const model = getModelByName(objectType) const model = getModelByName(objectType)
return ( return (
<Modal <Modal
@ -49,8 +42,7 @@ const DeleteObjectModal = ({
]} ]}
> >
<Text> <Text>
Are you sure you want to delete this {model.label.toLowerCase()} Are you sure you want to delete this {model.label.toLowerCase()}?
{objectName ? ` "${objectName}"` : ''}?
</Text> </Text>
</Modal> </Modal>
) )
@ -61,8 +53,7 @@ DeleteObjectModal.propTypes = {
onOk: PropTypes.func.isRequired, onOk: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired, onCancel: PropTypes.func.isRequired,
loading: PropTypes.bool, loading: PropTypes.bool,
objectType: PropTypes.string.isRequired, objectType: PropTypes.string.isRequired
objectName: PropTypes.string
} }
export default DeleteObjectModal export default DeleteObjectModal

View File

@ -4,6 +4,7 @@ import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import DeleteObjectModal from './DeleteObjectModal' import DeleteObjectModal from './DeleteObjectModal'
import merge from 'lodash/merge'
/** /**
* EditObjectForm is a reusable form component for editing any object type. * EditObjectForm is a reusable form component for editing any object type.
@ -82,7 +83,7 @@ const EditObjectForm = ({ id, type, style, children }) => {
// Update event handler // Update event handler
const updateObjectEventHandler = useCallback((value) => { const updateObjectEventHandler = useCallback((value) => {
setObjectData((prev) => ({ ...prev, ...value })) setObjectData((prev) => merge({}, prev, value))
}, []) }, [])
// Update event handler // Update event handler

View File

@ -13,7 +13,7 @@ const propertyOrder = [
const GCodeFileSelect = ({ onChange, filter, useFilter = false, style }) => { const GCodeFileSelect = ({ onChange, filter, useFilter = false, style }) => {
return ( return (
<ObjectSelect <ObjectSelect
endpoint={`${config.backendUrl}/gcodefiles`} endpoint={`${config.backendUrl}/gcodefiles/properties`}
propertyOrder={propertyOrder} propertyOrder={propertyOrder}
filter={filter} filter={filter}
useFilter={useFilter} useFilter={useFilter}
@ -21,7 +21,7 @@ const GCodeFileSelect = ({ onChange, filter, useFilter = false, style }) => {
showSearch={true} showSearch={true}
style={style} style={style}
placeholder='Select GCode File' placeholder='Select GCode File'
type='gcodefile' type='gcodeFile'
/> />
) )
} }

View File

@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import { Flex, Tag } from 'antd' import { Flex, Tag } from 'antd'
import IdDisplay from '../../common/IdDisplay' import IdDisplay from './IdDisplay'
import LockIcon from '../../../Icons/LockIcon' import LockIcon from '../../Icons/LockIcon'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
const LockIndicator = ({ lock }) => { const LockIndicator = ({ lock }) => {

View File

@ -95,7 +95,7 @@ const NoteItem = ({
} }
// Reload child notes when a new child note is added // Reload child notes when a new child note is added
const reloadChildNotes = async () => { const reloadChildNotes = useCallback(async () => {
// Always fetch child notes when this function is called // Always fetch child notes when this function is called
// This ensures child notes are loaded even if the parent wasn't expanded before // This ensures child notes are loaded even if the parent wasn't expanded before
setLoadingChildNotes(note._id) setLoadingChildNotes(note._id)
@ -110,14 +110,14 @@ const NoteItem = ({
} finally { } finally {
setLoadingChildNotes(null) setLoadingChildNotes(null)
} }
} }, [fetchData, note._id])
// Listen for child note additions // Listen for child note additions
useEffect(() => { useEffect(() => {
if (onChildNoteAdded) { if (onChildNoteAdded) {
onChildNoteAdded(note._id, reloadChildNotes) onChildNoteAdded(note._id, reloadChildNotes)
} }
}, [note._id, onChildNoteAdded]) }, [note._id, onChildNoteAdded, reloadChildNotes])
// Check if the current user can delete this note // Check if the current user can delete this note
const canDeleteNote = userProfile && userProfile._id === note.user._id const canDeleteNote = userProfile && userProfile._id === note.user._id

View File

@ -0,0 +1,27 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Tag, Typography } from 'antd'
import { getModelByName } from '../../../database/ObjectModels'
const { Text } = Typography
const ObjectDisplay = ({ object, objectType }) => {
if (!object) {
return <Text type='secondary'>n/a</Text>
}
const model = getModelByName(objectType)
const Icon = model.icon
return (
<Tag color='default' style={{ margin: 0 }} icon={<Icon />}>
{object?.name ? object.name : null}
</Tag>
)
}
ObjectDisplay.propTypes = {
object: PropTypes.object,
objectType: PropTypes.string,
style: PropTypes.object
}
export default ObjectDisplay

View File

@ -1,49 +1,28 @@
import React from 'react' import React from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { List, Typography, Flex } from 'antd' import ObjectDisplay from './ObjectDisplay'
import { getModelByName } from '../../../database/ObjectModels' import { Space, Typography } from 'antd'
import IdDisplay from './IdDisplay'
const { Text } = Typography const { Text } = Typography
const ObjectList = ({ value, objectType, bordered = true }) => { const ObjectList = ({ value, objectType, style }) => {
if (!value || !Array.isArray(value) || value.length === 0) { if (!value || !Array.isArray(value) || value.length === 0) {
return <Text type='secondary'>n/a</Text> return <Text type='secondary'>n/a</Text>
} }
return ( return (
<List <Space size={'small'} wrap style={style}>
size='small' {value.map((item) => (
bordered={bordered} <ObjectDisplay object={item} objectType={objectType} key={item._id} />
dataSource={value} ))}
renderItem={(item) => { </Space>
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 = { ObjectList.propTypes = {
value: PropTypes.array, value: PropTypes.array,
bordered: PropTypes.bool, objectType: PropTypes.string,
objectType: PropTypes.string style: PropTypes.object
} }
export default ObjectList export default ObjectList

View File

@ -10,14 +10,9 @@ import {
DatePicker, DatePicker,
Switch Switch
} from 'antd' } from 'antd'
import VendorSelect from './VendorSelect'
import FilamentSelect from './FilamentSelect'
import IdDisplay from './IdDisplay' import IdDisplay from './IdDisplay'
import TimeDisplay from './TimeDisplay' import TimeDisplay from './TimeDisplay'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import PrinterSelect from './PrinterSelect'
import GCodeFileSelect from './GCodeFileSelect'
import PartSelect from './PartSelect'
import EmailDisplay from './EmailDisplay' import EmailDisplay from './EmailDisplay'
import UrlDisplay from './UrlDisplay' import UrlDisplay from './UrlDisplay'
import CountryDisplay from './CountryDisplay' import CountryDisplay from './CountryDisplay'
@ -41,6 +36,8 @@ import ObjectList from './ObjectList'
import VarianceDisplay from './VarianceDisplay' import VarianceDisplay from './VarianceDisplay'
import OperationDisplay from './OperationDisplay' import OperationDisplay from './OperationDisplay'
import MarkdownDisplay from './MarkdownDisplay' import MarkdownDisplay from './MarkdownDisplay'
import ObjectSelect from './ObjectSelect'
import ObjectDisplay from './ObjectDisplay'
const { Text } = Typography const { Text } = Typography
@ -291,7 +288,7 @@ const ObjectProperty = ({
} }
case 'object': { case 'object': {
if (value && value.name) { if (value && value.name) {
return <Text ellipsis>{value.name}</Text> return <ObjectDisplay object={value} objectType={objectType} />
} else { } else {
return ( return (
<Text type='secondary' {...textParams}> <Text type='secondary' {...textParams}>
@ -424,7 +421,7 @@ const ObjectProperty = ({
} }
const hasRequiredRule = rules.some((rule) => rule && rule.required) const hasRequiredRule = rules.some((rule) => rule && rule.required)
if (!hasRequiredRule) { if (!hasRequiredRule) {
rules.push({ required: true, message: 'This field is required' }) rules.push({ required: true, message: '' })
} }
mergedFormItemProps.rules = rules mergedFormItemProps.rules = rules
} }
@ -591,45 +588,17 @@ const ObjectProperty = ({
) )
} }
case 'object': case 'object':
switch (objectType) {
case 'vendor':
return ( return (
<Form.Item name={formItemName} {...mergedFormItemProps}> <Form.Item name={formItemName} {...mergedFormItemProps}>
<VendorSelect placeholder={label} /> <ObjectSelect type={objectType} />
</Form.Item> </Form.Item>
) )
case 'printer': case 'objectList':
return ( return (
<Form.Item name={formItemName} {...mergedFormItemProps}> <Form.Item name={formItemName} {...mergedFormItemProps}>
<PrinterSelect placeholder={label} /> <ObjectSelect type={objectType} multiple />
</Form.Item> </Form.Item>
) )
case 'gcodeFile':
return (
<Form.Item name={formItemName} {...mergedFormItemProps}>
<GCodeFileSelect placeholder={label} />
</Form.Item>
)
case 'filament':
return (
<Form.Item name={formItemName} {...mergedFormItemProps}>
<FilamentSelect placeholder={label} />
</Form.Item>
)
case 'part':
return (
<Form.Item name={formItemName} {...mergedFormItemProps}>
<PartSelect placeholder={label} />
</Form.Item>
)
default:
return (
<Text type='secondary' {...textParams}>
n/a
</Text>
)
}
case 'tags': case 'tags':
return ( return (
<Form.Item name={formItemName} {...mergedFormItemProps}> <Form.Item name={formItemName} {...mergedFormItemProps}>

View File

@ -1,340 +1,215 @@
import React, { useEffect, useState, useCallback } from 'react' import React, {
useEffect,
useState,
useContext,
useCallback,
useMemo
} from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { TreeSelect, Typography, Flex, Badge, Space, Button, Input } from 'antd' import { TreeSelect, Space, Button, Input } from 'antd'
import axios from 'axios'
import { getModelByName } from '../../../database/ObjectModels'
import IdDisplay from './IdDisplay'
import ReloadIcon from '../../Icons/ReloadIcon' import ReloadIcon from '../../Icons/ReloadIcon'
import { ApiServerContext } from '../context/ApiServerContext'
import ObjectProperty from './ObjectProperty' import ObjectProperty from './ObjectProperty'
const { Text } = Typography import { getModelByName } from '../../../database/ObjectModels'
import merge from 'lodash/merge'
const { SHOW_CHILD } = TreeSelect const { SHOW_CHILD } = TreeSelect
// --- Utility: Resolve nested property path (e.g., 'filament.diameter') ---
function resolvePropertyPath(obj, path) {
if (!obj || !path) return { value: undefined, finalProperty: undefined }
const props = path.split('.')
let value = obj
for (const prop of props) {
if (value && typeof value === 'object') {
value = value[prop]
} else {
return { value: undefined, finalProperty: prop }
}
}
return { value, finalProperty: props[props.length - 1] }
}
// --- Utility: Build filter object for a node based on propertyOrder ---
function buildFilterForNode(node, treeData, propertyOrder) {
let filterObj = {}
let currentId = node.id
while (currentId !== 0) {
const currentNode = treeData.find((d) => d.id === currentId)
if (!currentNode) break
filterObj[propertyOrder[currentNode.propertyId]] = currentNode.value
currentId = currentNode.pId
}
return filterObj
}
/**
* ObjectSelect - a generic, reusable async TreeSelect for hierarchical object selection.
*
* Props:
* - endpoint: API endpoint to fetch data from (required)
* - propertyOrder: array of property names for category levels (required)
* - filter: object for filtering (optional)
* - useFilter: bool (optional)
* - value: selected value (optional) - can be an object with _id, array of objects, or simple value/array
* - onChange: function (optional)
* - showSearch: bool (optional, default false)
* - treeCheckable: bool (optional, default false) - enables multi-select mode with checkboxes
* - treeSelectProps: any other TreeSelect props (optional)
*/
const ObjectSelect = ({ const ObjectSelect = ({
endpoint,
propertyOrder,
filter = {},
useFilter = false,
value,
onChange,
showSearch = false,
treeCheckable = false,
treeSelectProps = {},
type = 'unknown', type = 'unknown',
showSearch = false,
multiple = false,
treeSelectProps = {},
filter = {},
value,
...rest ...rest
}) => { }) => {
const { fetchObjectsByProperty } = useContext(ApiServerContext)
// --- State --- // --- State ---
const [treeData, setTreeData] = useState([]) const [treeData, setTreeData] = useState([])
const [loading, setLoading] = useState(false) const [objectPropertiesTree, setObjectPropertiesTree] = useState({})
const [defaultValue, setDefaultValue] = useState(treeCheckable ? [] : value) const [initialized, setInitialized] = useState(false)
const [searchValue, setSearchValue] = useState('')
const [error, setError] = useState(false) const [error, setError] = useState(false)
const properties = useMemo(() => getModelByName(type).group || [], [type])
const [objectList, setObjectList] = useState([])
const [treeSelectValue, setTreeSelectValue] = useState(null)
const [initialLoading, setInitialLoading] = useState(true)
// --- API: Fetch data for a property level or leaf --- // Fetch the object properties tree from the API
const fetchData = useCallback( const handleFetchObjectsProperties = useCallback(
async (property, filter, search) => { async (customFilter = filter) => {
setLoading(true)
setError(false)
try { try {
const params = { ...filter, property } const data = await fetchObjectsByProperty(type, {
if (search) params.search = search properties: properties,
const response = await axios.get(endpoint, { filter: customFilter
params,
withCredentials: true
}) })
setLoading(false) setObjectPropertiesTree((prev) => merge({}, prev, data))
return response.data setInitialLoading(false)
} catch (err) {
setLoading(false)
setError(true)
return []
}
},
[endpoint]
)
// --- API: Fetch a single object by ID ---
const fetchObjectById = useCallback(
async (objectId) => {
setLoading(true)
setError(false) setError(false)
try { return data
const response = await axios.get(`${endpoint}/${objectId}`, {
withCredentials: true
})
setLoading(false)
return response.data
} catch (err) { } catch (err) {
setLoading(false)
setError(true) setError(true)
return null return null
} }
}, },
[endpoint] [type, fetchObjectsByProperty, properties, filter]
) )
// --- Render node title --- // Convert the API response to AntD TreeSelect treeData
const renderTitle = useCallback( const buildTreeData = useCallback(
(item) => { (data, pIdx = 0, parentKeys = [], filterPath = []) => {
if (item.propertyType) { if (!data) return []
return ( if (Array.isArray(data)) {
return data.map((object) => {
setObjectList((prev) => {
const filtered = prev.filter(
(prevObject) => prevObject._id != object._id
)
return [...filtered, object]
})
return {
title: (
<ObjectProperty <ObjectProperty
type={item.propertyType} key={object._id}
value={item.value} type='object'
value={object}
objectType={type} objectType={type}
objectData={object}
isEditing={false}
/> />
) ),
} else { value: object._id,
const model = getModelByName(type) key: object._id,
const Icon = model.icon
return (
<Flex gap={'small'} align='center' style={{ width: '100%' }}>
{Icon && <Icon />}
{item?.color && <Badge color={item.color}></Badge>}
<Text ellipsis>{item.name || type.title}</Text>
<IdDisplay id={item._id} longId={false} type={type} />
</Flex>
)
}
},
[type]
)
// --- Build tree nodes for a property level ---
const buildCategoryNodes = useCallback(
(data, propertyName, propertyId, parentId) => {
return data.map((item) => {
let resolved = resolvePropertyPath(item, propertyName)
let value = resolved.value
let propertyType = resolved.finalProperty
return {
id: value,
pId: parentId,
value: value,
key: value,
propertyId: propertyId,
title: renderTitle({ ...item, value, propertyType }),
isLeaf: false,
selectable: false,
raw: item
}
})
},
[renderTitle]
)
// --- Build tree nodes for leaf level ---
const buildLeafNodes = useCallback(
(data, parentId) => {
return data.map((item) => {
const value = item._id || item.id || item.value
return {
id: value,
pId: parentId,
value: value,
key: value,
title: renderTitle(item),
isLeaf: true, isLeaf: true,
raw: item property: properties[pIdx - 1], // previous property
} parentKeys,
}) filterPath
},
[renderTitle]
)
// --- Tree loader: load children for a node or root ---
const handleTreeLoad = useCallback(
async (node) => {
if (!propertyOrder.length) return
if (node) {
// Not at leaf level yet
if (node.propertyId !== propertyOrder.length - 1) {
const nextPropertyId = node.propertyId + 1
const propertyName = propertyOrder[nextPropertyId]
const filterObj = buildFilterForNode(node, treeData, propertyOrder)
const data = await fetchData(propertyName, filterObj, searchValue)
setTreeData((prev) => [
...prev,
...buildCategoryNodes(data, propertyName, nextPropertyId, node.id)
])
} else {
// At leaf level
const filterObj = buildFilterForNode(node, treeData, propertyOrder)
const data = await fetchData(null, filterObj, searchValue)
setTreeData((prev) => [...prev, ...buildLeafNodes(data, node.id)])
}
} else {
// Root load
const propertyName = propertyOrder[0]
const data = await fetchData(propertyName, {}, searchValue)
setTreeData(buildCategoryNodes(data, propertyName, 0, 0))
}
},
[
propertyOrder,
treeData,
fetchData,
buildCategoryNodes,
buildLeafNodes,
searchValue
]
)
// --- OnChange handler ---
const handleOnChange = (val, selectedOptions) => {
if (onChange) {
if (treeCheckable) {
// Multi-select
let selectedObjects = []
if (Array.isArray(val)) {
selectedObjects = val.map((selectedValue) => {
const node = treeData.find((n) => n.value === selectedValue)
return node ? node.raw : selectedValue
})
}
onChange(selectedObjects, selectedOptions)
} else {
// Single select
const node = treeData.find((n) => n.value === val)
onChange(node ? node.raw : val, selectedOptions)
}
}
setDefaultValue(val)
}
// --- Search handler ---
const handleSearch = (val) => {
setSearchValue(val)
setTreeData([])
}
// --- Sync defaultValue and load tree path for object values ---
useEffect(() => {
if (treeCheckable) {
if (Array.isArray(value)) {
const valueIds = value.map((v) => v._id || v.id || v)
setDefaultValue(valueIds)
value.forEach((item) => {
if (item && typeof item === 'object' && item._id) {
const existingNode = treeData.find(
(node) => node.value === item._id
)
if (!existingNode) {
fetchObjectById(item._id).then((object) => {
if (object) {
// For multi-select, just add the leaf node
setTreeData((prev) => [
...prev,
...buildLeafNodes(
[object],
object[propertyOrder[propertyOrder.length - 2]] || 0
)
])
} }
}) })
} }
} if (typeof data == 'object') {
}) const property = properties[pIdx] || null
} else { return Object.entries(data)
setDefaultValue([]) .map(([key, value]) => {
} if (property != null && typeof value === 'object') {
} else { const newFilterPath = filterPath.concat({ property, value: key })
if (value?._id) { return {
setDefaultValue(value._id) title: <ObjectProperty type={property} value={key} />,
const existingNode = treeData.find((node) => node.value === value._id) value: key,
if (!existingNode) { key: parentKeys.concat(key).join(':'),
fetchObjectById(value._id).then((object) => { property,
if (object) { parentKeys: parentKeys.concat(key),
setTreeData((prev) => [ filterPath: newFilterPath,
...prev, selectable: false,
...buildLeafNodes( children: buildTreeData(
[object],
object[propertyOrder[propertyOrder.length - 2]] || 0
)
])
}
})
}
}
}
}, [
value, value,
treeData, pIdx + 1,
fetchObjectById, parentKeys.concat(key),
buildLeafNodes, newFilterPath
propertyOrder, ),
treeCheckable isLeaf: false
]) }
}
})
.filter(Boolean)
}
},
[properties, type]
)
// --- Initial load --- // --- loadData for async loading on expand ---
const loadData = async (node) => {
// node.property is the property name, node.value is the value
if (!node.property) return
// Build filter for this node by merging all parent property-value pairs
const customFilter = { ...filter }
if (Array.isArray(node.filterPath)) {
node.filterPath.forEach(({ property, value }) => {
customFilter[property] = value
})
}
customFilter[node.property] = node.value
// Fetch children for this node
const data = await handleFetchObjectsProperties(customFilter)
if (!data) return
// Build new children
const children = buildTreeData(
data,
properties.indexOf(node.property) + 1,
node.parentKeys || [],
(node.filterPath || []).concat({
property: node.property,
value: node.value
})
)
// Update treeData with new children for this node
setTreeData((prevTreeData) => {
// Helper to recursively update the correct node
const updateNode = (nodes) =>
nodes.map((n) => {
if (n.key === node.key) {
return { ...n, children, isLeaf: children.length === 0 }
} else if (n.children) {
return { ...n, children: updateNode(n.children) }
}
return n
})
return updateNode(prevTreeData)
})
}
const onTreeSelectChange = (value) => {
// value can be a string (single) or array (multiple)
if (multiple) {
// Multiple selection
let selectedObjects = []
if (Array.isArray(value)) {
selectedObjects = value
.map((id) => objectList.find((obj) => obj._id === id))
.filter(Boolean)
}
setTreeSelectValue(value)
if (rest.onChange) rest.onChange(selectedObjects)
} else {
// Single selection
const selectedObject = objectList.find((obj) => obj._id === value)
setTreeSelectValue(value)
if (rest.onChange) rest.onChange(selectedObject)
}
}
// Update treeData when objectPropertiesTree changes
useEffect(() => { useEffect(() => {
if (treeData.length === 0 && !error && !loading) { if (objectPropertiesTree && Object.keys(objectPropertiesTree).length > 0) {
if (!treeCheckable && value && typeof value === 'object' && value._id) { const newTreeData = buildTreeData(objectPropertiesTree)
setTreeData((prev) => {
if (JSON.stringify(prev) !== JSON.stringify(newTreeData)) {
return newTreeData
}
return prev
})
}
}, [objectPropertiesTree, properties, type, buildTreeData])
useEffect(() => {
if (value && typeof value === 'object' && value !== null && !initialized) {
// Build a new filter from value's properties that are in the properties list
const valueFilter = { ...filter }
properties.forEach((prop) => {
if (Object.prototype.hasOwnProperty.call(value, prop)) {
valueFilter[prop] = value[prop]
}
})
// Fetch with the new filter
handleFetchObjectsProperties(valueFilter)
setTreeSelectValue(value._id)
setInitialized(true)
return return
} }
if (useFilter || searchValue) { if (!initialized) {
// Flat filter mode handleFetchObjectsProperties()
fetchData(null, filter, searchValue).then((data) => { setInitialized(true)
setTreeData(buildLeafNodes(data, 0))
})
} else {
handleTreeLoad(null)
} }
} }, [value, filter, properties, handleFetchObjectsProperties, initialized])
}, [
treeData,
useFilter,
filter,
searchValue,
buildLeafNodes,
fetchData,
handleTreeLoad,
error,
loading,
value,
treeCheckable
])
// --- Error UI --- // --- Error UI ---
if (error) { if (error) {
@ -346,6 +221,7 @@ const ObjectSelect = ({
onClick={() => { onClick={() => {
setError(false) setError(false)
setTreeData([]) setTreeData([])
setInitialized(false)
}} }}
danger danger
/> />
@ -353,35 +229,37 @@ const ObjectSelect = ({
) )
} }
if (initialLoading) {
return <TreeSelect disabled loading placeholder='Loading...' />
}
// --- Main TreeSelect UI --- // --- Main TreeSelect UI ---
return ( return (
<TreeSelect <TreeSelect
treeDataSimpleMode treeDataSimpleMode={false}
treeDefaultExpandAll={true} treeDefaultExpandAll={true}
loadData={handleTreeLoad}
treeData={treeData} treeData={treeData}
onChange={handleOnChange}
loading={loading}
value={loading ? 'Loading...' : defaultValue}
showSearch={showSearch} showSearch={showSearch}
onSearch={showSearch ? handleSearch : undefined} multiple={multiple}
treeCheckable={treeCheckable} loadData={loadData}
showCheckedStrategy={treeCheckable ? SHOW_CHILD : undefined} showCheckedStrategy={SHOW_CHILD}
placeholder={`Select a ${getModelByName(type).label.toLowerCase()}...`}
{...treeSelectProps} {...treeSelectProps}
{...rest} {...rest}
value={treeSelectValue}
onChange={onTreeSelectChange}
/> />
) )
} }
ObjectSelect.propTypes = { ObjectSelect.propTypes = {
endpoint: PropTypes.string.isRequired, properties: PropTypes.arrayOf(PropTypes.string).isRequired,
propertyOrder: PropTypes.arrayOf(PropTypes.string).isRequired,
filter: PropTypes.object, filter: PropTypes.object,
useFilter: PropTypes.bool, useFilter: PropTypes.bool,
value: PropTypes.any, value: PropTypes.any,
onChange: PropTypes.func, onChange: PropTypes.func,
showSearch: PropTypes.bool, showSearch: PropTypes.bool,
treeCheckable: PropTypes.bool, multiple: PropTypes.bool,
treeSelectProps: PropTypes.object, treeSelectProps: PropTypes.object,
type: PropTypes.string.isRequired type: PropTypes.string.isRequired
} }

View File

@ -38,6 +38,7 @@ import CheckIcon from '../../Icons/CheckIcon'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon' import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import merge from 'lodash/merge'
const logger = loglevel.getLogger('DasboardTable') const logger = loglevel.getLogger('DasboardTable')
logger.setLevel(config.logLevel) logger.setLevel(config.logLevel)
@ -65,7 +66,7 @@ const ObjectTable = forwardRef(
adjustedScrollHeight = 'calc(var(--unit-100vh) - 316px)' adjustedScrollHeight = 'calc(var(--unit-100vh) - 316px)'
} }
if (cards) { if (cards) {
adjustedScrollHeight = 'calc(var(--unit-100vh) - 210px)' adjustedScrollHeight = 'calc(var(--unit-100vh) - 280px)'
} }
const [, contextHolder] = message.useMessage() const [, contextHolder] = message.useMessage()
const tableRef = useRef(null) const tableRef = useRef(null)
@ -108,7 +109,6 @@ const ObjectTable = forwardRef(
type={'text'} type={'text'}
size={'small'} size={'small'}
onClick={() => { onClick={() => {
console.log(objectData)
if (action.url) { if (action.url) {
navigate(action.url(objectData._id)) navigate(action.url(objectData._id))
} }
@ -135,13 +135,6 @@ const ObjectTable = forwardRef(
order: sorter.order order: sorter.order
}) })
} }
console.log('Fetching Objects', {
page: pageNum,
limit: pageSize,
filter: { ...filter, ...masterFilter },
sorter,
onDataChange
})
try { try {
const result = await fetchObjects(type, { const result = await fetchObjects(type, {
page: pageNum, page: pageNum,
@ -250,12 +243,6 @@ const ObjectTable = forwardRef(
const lowestPage = Math.min(...pages.map((p) => p.pageNum)) const lowestPage = Math.min(...pages.map((p) => p.pageNum))
const prevPage = lowestPage - 1 const prevPage = lowestPage - 1
logger.debug(
'Down',
scrollHeight - scrollTop - clientHeight < 100,
lazyLoading
)
// Load more data when scrolling down // Load more data when scrolling down
if (scrollHeight - scrollTop - clientHeight < 100 && hasMore) { if (scrollHeight - scrollTop - clientHeight < 100 && hasMore) {
setTimeout(() => { setTimeout(() => {
@ -276,13 +263,14 @@ const ObjectTable = forwardRef(
loadPreviousPage() loadPreviousPage()
} }
}, },
[lazyLoading, loadNextPage, loadPreviousPage] [lazyLoading, loadNextPage, loadPreviousPage, hasMore, pages]
) )
const reload = useCallback(() => { const reload = useCallback(async () => {
setLazyLoading(true)
for (let i = 0; i < pages.length; i++) { for (let i = 0; i < pages.length; i++) {
const page = pages[i] const page = pages[i]
fetchData(page.pageNum) await fetchData(page.pageNum)
} }
}, [fetchData, pages]) }, [fetchData, pages])
@ -292,7 +280,7 @@ const ObjectTable = forwardRef(
prevPages.map((page) => ({ prevPages.map((page) => ({
...page, ...page,
items: page.items.map((item) => items: page.items.map((item) =>
item._id === updatedData._id ? { ...item, ...updatedData } : item item._id === updatedData._id ? merge({}, item, updatedData) : item
) )
})) }))
) )
@ -453,7 +441,7 @@ const ObjectTable = forwardRef(
// Table columns from model properties // Table columns from model properties
const columnsWithSkeleton = [ const columnsWithSkeleton = [
{ {
title: model.icon, title: lazyLoading ? <LoadingOutlined /> : cards ? model.icon : null,
key: 'icon', key: 'icon',
width: 45, width: 45,
fixed: 'left', fixed: 'left',
@ -688,16 +676,12 @@ const ObjectTable = forwardRef(
return ( return (
<> <>
{contextHolder} {contextHolder}
{cards ? ( <Flex gap={'middle'} vertical>
<Spin indicator={<LoadingOutlined />} spinning={loading}>
{renderCards()}
</Spin>
) : (
<Table <Table
ref={tableRef} ref={tableRef}
dataSource={tableData} dataSource={tableData}
columns={columnsWithSkeleton} columns={columnsWithSkeleton}
className={'dashboard-table'} className={cards ? 'dashboard-cards-header' : 'dashboard-table'}
pagination={false} pagination={false}
scroll={{ y: adjustedScrollHeight }} scroll={{ y: adjustedScrollHeight }}
rowKey='_id' rowKey='_id'
@ -707,7 +691,12 @@ const ObjectTable = forwardRef(
showSorterTooltip={false} showSorterTooltip={false}
style={{ height: '100%' }} style={{ height: '100%' }}
/> />
)} {cards ? (
<Spin indicator={<LoadingOutlined />} spinning={loading}>
{renderCards()}
</Spin>
) : null}
</Flex>
</> </>
) )
} }

View File

@ -41,7 +41,6 @@ const PrinterMovementPanel = ({ printerId }) => {
const handleHomeAxisClick = (axis) => { const handleHomeAxisClick = (axis) => {
if (printServer) { if (printServer) {
logger.debug('Homeing Axis:', axis)
printServer.emit('printer.gcode.script', { printServer.emit('printer.gcode.script', {
printerId, printerId,
script: `G28 ${axis}` script: `G28 ${axis}`
@ -52,7 +51,6 @@ const PrinterMovementPanel = ({ printerId }) => {
const handleMoveAxisClick = (axis, minus) => { const handleMoveAxisClick = (axis, minus) => {
const distanceValue = !minus ? posValue * -1 : posValue const distanceValue = !minus ? posValue * -1 : posValue
if (printServer) { if (printServer) {
logger.debug('Moving Axis:', axis, distanceValue)
printServer.emit('printer.gcode.script', { printServer.emit('printer.gcode.script', {
printerId, printerId,
script: `_CLIENT_LINEAR_MOVE ${axis}=${distanceValue} F=${rateValue}` script: `_CLIENT_LINEAR_MOVE ${axis}=${distanceValue} F=${rateValue}`

View File

@ -0,0 +1,7 @@
import React from 'react'
import Icon from '@ant-design/icons'
import { ReactComponent as CustomIconSvg } from '../../assets/icons/contracticon.min.svg'
const ContractIcon = (props) => <Icon component={CustomIconSvg} {...props} />
export default ContractIcon

View File

@ -0,0 +1,7 @@
import React from 'react'
import Icon from '@ant-design/icons'
import { ReactComponent as CustomIconSvg } from '../../assets/icons/expandicon.min.svg'
const ExpandIcon = (props) => <Icon component={CustomIconSvg} {...props} />
export default ExpandIcon

View File

@ -0,0 +1,7 @@
import React from 'react'
import Icon from '@ant-design/icons'
import { ReactComponent as CustomIconSvg } from '../../assets/icons/hosticon.min.svg'
const HostIcon = (props) => <Icon component={CustomIconSvg} {...props} />
export default HostIcon

View File

@ -0,0 +1,7 @@
import React from 'react'
import Icon from '@ant-design/icons'
import { ReactComponent as CustomIconSvg } from '../../assets/icons/minusicon.min.svg'
const MinusIcon = (props) => <Icon component={CustomIconSvg} {...props} />
export default MinusIcon

View File

@ -1,4 +1,5 @@
import { Printer } from './models/Printer.js' import { Printer } from './models/Printer.js'
import { Host } from './models/Host.js'
import { Filament } from './models/Filament.js' import { Filament } from './models/Filament.js'
import { Spool } from './models/Spool' import { Spool } from './models/Spool'
import { GCodeFile } from './models/GCodeFile' import { GCodeFile } from './models/GCodeFile'
@ -21,6 +22,7 @@ import QuestionCircleIcon from '../components/Icons/QuestionCircleIcon'
export const objectModels = [ export const objectModels = [
Printer, Printer,
Host,
Filament, Filament,
Spool, Spool,
GCodeFile, GCodeFile,
@ -44,6 +46,7 @@ export const objectModels = [
// Re-export individual models for direct access // Re-export individual models for direct access
export { export {
Printer, Printer,
Host,
Filament, Filament,
Spool, Spool,
GCodeFile, GCodeFile,

View File

@ -0,0 +1,90 @@
import HostIcon from '../../components/Icons/HostIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon'
import EditIcon from '../../components/Icons/EditIcon'
export const Host = {
name: 'host',
label: 'Host',
prefix: 'HST',
icon: HostIcon,
actions: [
{
name: 'info',
label: 'Info',
default: true,
row: true,
icon: InfoCircleIcon,
url: (_id) => `/dashboard/production/hosts/info?hostId=${_id}`
},
{
name: 'reload',
label: 'Reload',
icon: ReloadIcon,
url: (_id) =>
`/dashboard/production/hosts/info?hostId=${_id}&action=reload`
},
{
name: 'edit',
label: 'Edit',
row: true,
icon: EditIcon,
url: (_id) => `/dashboard/production/hosts/info?hostId=${_id}&action=edit`
}
],
columns: ['name', '_id', 'state', 'tags', 'connectedAt'],
filters: ['name', '_id', 'state', 'tags'],
sorters: ['name', 'state', 'connectedAt'],
group: ['tags'],
properties: [
{
name: '_id',
label: 'ID',
type: 'id',
objectType: 'host',
showCopy: true
},
{
name: 'connectedAt',
label: 'Connected At',
type: 'dateTime',
readOnly: true
},
{
name: 'name',
label: 'Name',
required: true,
type: 'text',
columnWidth: 200,
columnFixed: 'left'
},
{
name: 'state',
label: 'Status',
type: 'state',
objectType: 'host',
showName: false,
readOnly: true
},
{
name: 'host',
label: 'Host',
type: 'text',
required: true
},
{
name: 'tags',
label: 'Tags',
type: 'tags',
required: false
},
{
name: 'operatingSystem',
label: 'Operating System',
type: 'text',
required: false,
readOnly: true
}
]
}

View File

@ -74,7 +74,7 @@ export const Job = {
label: 'Quantity', label: 'Quantity',
type: 'number', type: 'number',
columnWidth: 125, columnWidth: 125,
readOnly: true required: true
}, },
{ {
name: 'createdAt', name: 'createdAt',

View File

@ -7,7 +7,7 @@ export const SubJob = {
icon: SubJobIcon, icon: SubJobIcon,
actions: [], actions: [],
columns: ['_id', 'printer', 'printer._id', 'job._id', 'state', 'createdAt'], columns: ['_id', 'printer', 'printer._id', 'job._id', 'state', 'createdAt'],
filters: ['state', '_id'], filters: ['state', '_id', 'job._id', 'printer._id'],
sorters: ['createdAt', 'state'], sorters: ['createdAt', 'state'],
properties: [ properties: [
{ {