Compare commits

...

12 Commits

Author SHA1 Message Date
aef6ec8749 Enhance SortSidebarButton with Badge and Sorting State Management
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
- Integrated Badge component to display the count of active sorters in the SortSidebarButton.
- Utilized useLocation and useTableState hooks to manage sorting state based on the current route.
- Updated TableStateContext to include pageSorters state and setPageSorter function for improved sorting functionality across the dashboard.
2026-09-01 20:14:03 +01:00
e49c7c23ed Enhance Filter Functionality in Dashboard Components
- Integrated property filter value into the QuickPropertyFilters component for improved filtering capabilities.
- Updated ObjectTable to register page sorter and manage filter states more effectively.
- Refactored QuickPropertyFilters to include category labels and clear filter functionality, enhancing user experience.
- Adjusted styles and layout for better usability and visual consistency across components.
2026-09-01 20:13:59 +01:00
415aa0cd9d Update App.css for Input Number Component Styling
- Added padding to input fields within the input-number-cal class for improved usability.
- Adjusted z-index and color properties for better visual hierarchy and clarity in the input-number-cal-highlight class.
- Ensured consistent background styling for input fields in the input-number-cal-expr-input class to enhance user experience.
2026-09-01 19:58:44 +01:00
451787debd Refactor SimplePropertyFilter to Move MissingPlaceholder Logic
- Moved the MissingPlaceholder component logic to a new position within the SimplePropertyFilter component for improved readability and structure.
- Ensured that the component correctly displays a message when no options are found while maintaining the loading state functionality.
2026-09-01 19:55:23 +01:00
d180f2217a Enhance SimpleDateTimePropertyFilter with MissingPlaceholder and Filter Optimization
- Added MissingPlaceholder component to display a message when no options are found.
- Introduced omitUndefinedValues utility to filter out undefined values from filter objects, improving data handling.
- Updated loading state initialization to reflect visibility status.
- Optimized filter handling for better performance and clarity in the SimpleDateTimePropertyFilter component.
2026-09-01 19:55:10 +01:00
3352c25819 Add QuickPropertyFilters Component and Enhance FilterInput and ObjectTable
- Introduced QuickPropertyFilters component for streamlined property filtering functionality.
- Updated FilterInput to integrate QuickPropertyFilters, enhancing user experience with quick access to filter options.
- Modified ObjectTable to include QuickPropertyFilters, improving filtering capabilities within the dashboard.
- Adjusted styles in App.css for dropdown z-index and input font consistency.
2026-09-01 18:44:40 +01:00
1597370a16 Enhance InputNumberCal Component with Expression Highlighting and Improved Layout
- Added expression highlighting functionality to the InputNumberCal component, allowing operators and brackets to be visually distinct.
- Implemented layout synchronization for highlighted expressions to match input field dimensions.
- Updated styles in App.css for dropdown and input number components to improve visual hierarchy and user experience.
- Refactored input handling to support expression mode with keyboard interactions for better usability.
2026-09-01 18:44:31 +01:00
b590890bd3 Enhance Database Models with Additional Cost and Pricing Fields
- Added 'additionalCost' and 'additionalCostWithTax' fields to CourierService model.
- Introduced 'connection.port' field in DocumentPrinter model.
- Updated Filament model with 'density' and 'emptySpoolWeight' fields.
- Enhanced Part, PartSku, Product, and ProductSku models by adding 'cost', 'costWithTax', 'price', 'margin', and 'priceWithTax' fields for improved pricing structure.
2026-09-01 17:35:20 +01:00
d96b84513c Add Filter Expression Utility and Integrate with DateTime Filters 2026-09-01 17:33:18 +01:00
291d5e438c Add PostFilamentStock Component and Update FilamentStock Model
- Introduced the PostFilamentStock component to handle posting filament stock with confirmation dialog and loading state management.
- Updated NewFilamentStock component to change the default state from 'unconsumed' to 'draft'.
- Enhanced FilamentStock model by adding 'postedAt' field and integrating the PostFilamentStock component into the actions for posting filament stocks.
- Improved visibility and disabled state logic for editing and posting actions based on filament stock state.
2026-09-01 17:26:15 +01:00
41825a23e5 Enhance FilamentStock, PartStock, and ProductStock Models with Disabled State Logic
- Added disabled state logic to FilamentStock, PartStock, and ProductStock models to prevent actions when the respective object data is not present.
- Improved return values in FilamentStock model to handle undefined cases for starting and current weights, ensuring robust data handling.
2026-09-01 17:13:38 +01:00
2feba8b3cc Enhance NewObjectButtons, NewObjectForm, and related components with improved loading and disabled state handling
- Updated NewObjectButtons to disable controls based on loading state and current step.
- Refactored NewObjectForm to integrate computed entries calculation and manage loading state more effectively.
- Enhanced ObjectProperty and WizardView components to respect the disabled state during form interactions.
- Introduced new utility functions for calculating model computed entries, improving data handling and clarity.
2026-09-01 17:11:07 +01:00
27 changed files with 1689 additions and 185 deletions

View File

@ -47,6 +47,10 @@
font-family: 'DM Sans';
}
.ant-dropdown {
z-index: 999;
}
.ant-typography code,
.ant-typography pre,
.ͼ1 .cm-scroller {
@ -750,6 +754,45 @@ body {
position: relative;
}
.input-number-cal input {
padding: 4px 12px;
}
.input-number-cal-highlight {
position: absolute;
display: flex;
align-items: center;
overflow: hidden;
white-space: pre;
pointer-events: none;
z-index: 2;
color: color-mix(in srgb, var(--color-text) 85%, transparent);
}
.input-number-cal-operator {
color: var(--color-purple);
}
.input-number-cal-bracket {
color: var(--color-primary);
}
.input-number-cal-expr-input,
.input-number-cal-expr-input.ant-input-affix-wrapper {
position: relative;
z-index: 1;
}
.input-number-cal-expr-input.ant-input-affix-wrapper .ant-input,
.input-number-cal-expr-input.ant-input-outlined:not(.ant-input-affix-wrapper) {
color: transparent !important;
caret-color: var(--color-text);
}
.input-number-cal-expr-input.ant-input-affix-wrapper .ant-input {
background: transparent !important;
}
.input-number-cal .ant-input-suffix {
margin-right: 28px;
}
@ -760,7 +803,7 @@ body {
}
.input-number-cal .ant-input {
font-family: 'DM Mono';
font-family: 'DM Sans';
font-weight: 400;
}

View File

@ -8,7 +8,7 @@ const NewFilamentStock = ({ onOk, reset, defaultValues }) => {
<NewObjectForm
type={'filamentStock'}
reset={reset}
defaultValues={{ state: { type: 'unconsumed' }, ...defaultValues }}
defaultValues={{ state: { type: 'draft' }, ...defaultValues }}
>
{({ handleSubmit, submitLoading, objectData, formValid }) => {
const steps = [
@ -38,7 +38,8 @@ const NewFilamentStock = ({ onOk, reset, defaultValues }) => {
_id: false,
_reference: false,
createdAt: false,
updatedAt: false
updatedAt: false,
postedAt: false
}}
isEditing={false}
objectData={objectData}

View File

@ -0,0 +1,46 @@
import { useState, useContext } from 'react'
import PropTypes from 'prop-types'
import { ApiServerContext } from '../../context/ApiServerContext'
import { message } from 'antd'
import MessageDialogView from '../../common/MessageDialogView.jsx'
const PostFilamentStock = ({ onOk, objectData }) => {
const [postLoading, setPostLoading] = useState(false)
const { sendObjectFunction } = useContext(ApiServerContext)
const handlePost = async () => {
setPostLoading(true)
try {
const result = await sendObjectFunction(
objectData._id,
'FilamentStock',
'post'
)
if (result) {
message.success('Filament stock posted successfully')
onOk(result)
}
} catch (error) {
console.error('Error posting filament stock:', error)
} finally {
setPostLoading(false)
}
}
return (
<MessageDialogView
title={'Are you sure you want to post this filament stock?'}
description={`Posting filament stock ${objectData?.name || objectData?._reference || objectData?._id} will finalize it and make it read-only.`}
onOk={handlePost}
okText='Post'
okLoading={postLoading}
/>
)
}
PostFilamentStock.propTypes = {
onOk: PropTypes.func.isRequired,
objectData: PropTypes.object
}
export default PostFilamentStock

View File

@ -17,6 +17,7 @@ import { useCompactItemContext } from 'antd/es/space/Compact'
import useStyle, { useSharedStyle } from 'antd/es/input/style'
import { useThemeContext } from '../context/ThemeContext'
import SimplePropertyFilter from './SimplePropertyFilter'
import QuickPropertyFilters from './QuickPropertyFilters'
import ScrollBox from './ScrollBox'
import ChevronRightIcon from '../../Icons/ChevronRightIcon'
import GreaterThanIcon from '../../Icons/GreaterThanIcon'
@ -25,9 +26,12 @@ import LessThanIcon from '../../Icons/LessThanIcon'
import LessThanOrEqualToIcon from '../../Icons/LessThanOrEqualToIcon'
import NotEqualIcon from '../../Icons/NotEqualIcon'
import EqualIcon from '../../Icons/EqualIcon'
import { Divider } from 'antd'
const operandIconStyle = { fontSize: 8 }
const PROPERTY_FILTER_PANEL_MAX_HEIGHT = 220
// Longer symbols first so ".." / "<>" / ">=" match before "." / "<" / ">".
// Wildcards (* ?) are highlighted in place; @ stays as plain text — none are operands.
const WILDCARDS = new Set(['*', '?'])
@ -469,8 +473,12 @@ const FilterInput = ({
const composingRef = useRef(false)
const selectingRef = useRef(false)
const valueRef = useRef(value ?? '')
const quickFiltersRef = useRef(null)
const quickFilterModalOpenRef = useRef(false)
const [focused, setFocused] = useState(false)
const [internalValue, setInternalValue] = useState(value ?? '')
const [quickFiltersHeight, setQuickFiltersHeight] = useState(0)
const [quickFilterModalOpen, setQuickFilterModalOpen] = useState(false)
const clearTagRoots = useCallback(() => {
tagRootsRef.current.forEach((root) => {
@ -864,6 +872,48 @@ const FilterInput = ({
[emitChange, paint]
)
const handleQuickFilterModalOpenChange = useCallback((open) => {
quickFilterModalOpenRef.current = open
setQuickFilterModalOpen(open)
if (open) {
editorRef.current?.blur()
focusedRef.current = false
setFocused(false)
} else {
requestAnimationFrame(() => {
editorRef.current?.focus()
focusedRef.current = true
setFocused(true)
})
}
}, [])
const propertyFilterPopoverOpen = focused || quickFilterModalOpen
useEffect(() => {
if (!propertyFilterEnabled) {
setQuickFiltersHeight(0)
return
}
const node = quickFiltersRef.current
if (!node) return
const update = () => {
setQuickFiltersHeight(node.getBoundingClientRect().height)
}
update()
const observer = new ResizeObserver(update)
observer.observe(node)
return () => observer.disconnect()
}, [
propertyFilterEnabled,
propertyFilter?.modelType,
propertyFilter?.propertyName,
focused
])
const propertyFilterContent = propertyFilterEnabled ? (
<div
onMouseDown={(event) => {
@ -872,17 +922,34 @@ const FilterInput = ({
}}
className='filter-input-property-filter'
style={{
display: 'inline-block',
display: 'inline-flex',
flexDirection: 'column',
maxWidth: 280,
maxHeight: 220,
maxHeight: quickFiltersHeight + PROPERTY_FILTER_PANEL_MAX_HEIGHT,
margin: -4,
verticalAlign: 'top'
verticalAlign: 'top',
overflow: 'hidden'
}}
>
<div ref={quickFiltersRef} style={{ flexShrink: 0 }}>
<QuickPropertyFilters
modelType={propertyFilter.modelType}
propertyName={propertyFilter.propertyName}
value={propertyFilterValue}
onChange={handlePropertyFilterChange}
onModalOpenChange={handleQuickFilterModalOpenChange}
useCard={false}
/>
</div>
<Divider style={{ margin: '4px 0' }} />
<ScrollBox
inner
smallPadding
style={{ height: 'auto', maxHeight: 220, maxWidth: 280 }}
style={{
flexShrink: 0,
maxHeight: PROPERTY_FILTER_PANEL_MAX_HEIGHT,
maxWidth: 280
}}
>
<div
style={{
@ -1031,7 +1098,9 @@ const FilterInput = ({
zIndex: focused ? 3 : style?.zIndex
}}
onClick={() => {
if (!disabled) editorRef.current?.focus()
if (!disabled && !quickFilterModalOpenRef.current) {
editorRef.current?.focus()
}
}}
>
<style>{`
@ -1100,6 +1169,11 @@ const FilterInput = ({
onFocus?.(event)
}}
onBlur={(event) => {
if (quickFilterModalOpenRef.current) {
focusedRef.current = false
setFocused(false)
return
}
focusedRef.current = false
selectingRef.current = false
setFocused(false)
@ -1151,9 +1225,9 @@ const FilterInput = ({
)}
{propertyFilterEnabled && (
<Popover
open={focused}
open={propertyFilterPopoverOpen}
destroyOnHidden={true}
content={focused ? propertyFilterContent : null}
content={propertyFilterPopoverOpen ? propertyFilterContent : null}
placement='bottomLeft'
arrow={false}
trigger={[]}

View File

@ -1,9 +1,55 @@
import { useState, useRef } from 'react'
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
import { Input, InputNumber } from 'antd'
import PropTypes from 'prop-types'
import FunctionIcon from '../../Icons/FunctionIcon'
const OPERATOR_KEYS = ['+', '-', '*', '/']
const OPERATORS = new Set(OPERATOR_KEYS)
const BRACKETS = new Set(['(', ')'])
const tokenizeExpr = (raw = '') => {
const value = String(raw)
const tokens = []
let text = ''
for (let i = 0; i < value.length; i += 1) {
const char = value[i]
if (OPERATORS.has(char) || BRACKETS.has(char)) {
if (text) {
tokens.push({ type: 'text', value: text })
text = ''
}
tokens.push({
type: OPERATORS.has(char) ? 'operator' : 'bracket',
value: char
})
} else {
text += char
}
}
if (text) tokens.push({ type: 'text', value: text })
return tokens
}
const renderHighlightedExpr = (raw = '') =>
tokenizeExpr(raw).map((token, index) => {
if (token.type === 'operator') {
return (
<span key={index} className='input-number-cal-operator'>
{token.value}
</span>
)
}
if (token.type === 'bracket') {
return (
<span key={index} className='input-number-cal-bracket'>
{token.value}
</span>
)
}
return <span key={index}>{token.value}</span>
})
/**
* Safely evaluate a math expression. Only allows numbers and +, -, *, /
@ -32,21 +78,54 @@ const InputNumberCal = ({
suffix,
placeholder,
disabled,
style,
...rest
}) => {
const [isExprMode, setIsExprMode] = useState(false)
const [exprValue, setExprValue] = useState('')
const inputRef = useRef(null)
const wrapperRef = useRef(null)
const highlightRef = useRef(null)
const inputElRef = useRef(null)
const syncHighlightLayout = useCallback(() => {
const wrapper = wrapperRef.current
const highlight = highlightRef.current
const inputEl = inputElRef.current
if (!wrapper || !highlight || !inputEl) return
const wrapperRect = wrapper.getBoundingClientRect()
const inputRect = inputEl.getBoundingClientRect()
const inputStyle = window.getComputedStyle(inputEl)
highlight.style.top = `${inputRect.top - wrapperRect.top}px`
highlight.style.left = `${inputRect.left - wrapperRect.left}px`
highlight.style.width = `${inputRect.width}px`
highlight.style.height = `${inputRect.height}px`
highlight.style.paddingLeft = inputStyle.paddingLeft
highlight.style.paddingRight = inputStyle.paddingRight
highlight.style.font = inputStyle.font
highlight.style.letterSpacing = inputStyle.letterSpacing
highlight.scrollLeft = inputEl.scrollLeft
}, [])
useLayoutEffect(() => {
if (!isExprMode) return undefined
syncHighlightLayout()
const wrapper = wrapperRef.current
if (!wrapper) return undefined
const observer = new ResizeObserver(syncHighlightLayout)
observer.observe(wrapper)
return () => observer.disconnect()
}, [isExprMode, exprValue, prefix, suffix, syncHighlightLayout])
const switchToExprMode = (initialValue) => {
setIsExprMode(true)
setExprValue(initialValue)
setTimeout(() => {
//inputRef.current?.focus()
const input = inputRef.current.getElementsByTagName('input')[0]
if (input) {
input.focus()
}
const input = wrapperRef.current?.getElementsByTagName('input')[0]
input?.focus()
}, 0)
}
@ -83,13 +162,23 @@ const InputNumberCal = ({
}
}
const commitExpr = () => {
const result = safeEval(exprValue)
if (result != null) {
exitExprMode(result)
}
}
const handleInputKeyDown = (e) => {
if (e.key === '=') {
if (e.key === 'Enter' || e.key === '=') {
e.preventDefault()
const result = safeEval(exprValue)
if (result != null) {
exitExprMode(result)
}
commitExpr()
}
}
const handleInputScroll = (e) => {
if (highlightRef.current) {
highlightRef.current.scrollLeft = e.target.scrollLeft
}
}
@ -125,13 +214,26 @@ const InputNumberCal = ({
if (isExprMode) {
return (
<div className='input-number-cal' ref={inputRef}>
<div className='input-number-cal' ref={wrapperRef} style={style}>
<div
ref={highlightRef}
className='input-number-cal-highlight'
aria-hidden
>
{renderHighlightedExpr(exprValue)}
</div>
<Input
ref={(node) => {
inputElRef.current = node?.input ?? null
}}
className='input-number-cal-expr-input'
value={exprValue}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onBlur={handleInputBlur}
onScroll={handleInputScroll}
{...commonProps}
style={style}
/>
<div className='input-number-cal-icon'>
<FunctionIcon style={{ fontSize: 24 }} />
@ -147,6 +249,7 @@ const InputNumberCal = ({
onChange={onChange}
onBlur={onBlur}
onKeyDown={handleNumberKeyDown}
style={style}
/>
)
}
@ -161,6 +264,7 @@ InputNumberCal.propTypes = {
prefix: PropTypes.node,
suffix: PropTypes.node,
placeholder: PropTypes.string,
style: PropTypes.object,
disabled: PropTypes.bool
}

View File

@ -12,13 +12,15 @@ const NewObjectButtons = ({
submitText = 'Done',
disabled = false
}) => {
const controlsDisabled = disabled || submitLoading
return (
<Flex justify='end'>
{totalSteps > 1 ? (
<Button
style={{ margin: '0 8px' }}
onClick={onPrevious}
disabled={currentStep === 0}
disabled={currentStep === 0 || controlsDisabled}
>
Previous
</Button>
@ -27,7 +29,7 @@ const NewObjectButtons = ({
{currentStep < totalSteps - 1 ? (
<Button
type='primary'
disabled={!formValid || disabled}
disabled={!formValid || controlsDisabled}
onClick={onNext}
>
Next
@ -36,7 +38,7 @@ const NewObjectButtons = ({
<Button
type='primary'
loading={submitLoading}
disabled={!formValid || disabled}
disabled={!formValid || controlsDisabled}
onClick={onSubmit}
>
{submitText}

View File

@ -5,7 +5,11 @@ import { useMessageContext } from '../context/MessageContext'
import PropTypes from 'prop-types'
import set from 'lodash/set'
import { getModelByName } from '../../../database/ObjectModels'
import { mergeFormData, stripNestedObjectProperties } from '../utils/Utils'
import {
mergeFormData,
stripNestedObjectProperties,
calculateModelComputedEntries
} from '../utils/Utils'
const buildObjectFromEntries = (entries = []) => {
return entries.reduce((acc, entry) => {
@ -37,7 +41,7 @@ const applyComputedEntries = (base, entries = []) => {
* - formItems: array (for ObjectInfo/ObjectProperty items)
* - defaultValues: object (optional) - initial values for the form
* - children: function({
* loading, isSubmitting, handleSubmit, form, formValid, objectData, setObjectData
* loading, submitLoading, disabled, handleSubmit, form, formValid, objectData, setObjectData
* }) => ReactNode
*/
const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
@ -77,93 +81,11 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
}
}, [form])
// Get the model definition for this object type
const model = getModelByName(type)
// Function to calculate computed values from model properties
const calculateComputedValues = useCallback(
(currentData, modelDefinition) => {
if (!modelDefinition || !Array.isArray(modelDefinition.properties)) {
return []
}
const normalizedPath = (name, parentPath = []) => {
if (Array.isArray(name)) {
return [...parentPath, ...name]
}
if (typeof name === 'number') {
return [...parentPath, name]
}
if (typeof name === 'string' && name.length > 0) {
return [...parentPath, ...name.split('.')]
}
return parentPath
}
const getValueAtPath = (dataSource, path) => {
if (!Array.isArray(path) || path.length === 0) {
return dataSource
}
return path.reduce((acc, key) => {
if (acc == null) return acc
return acc[key]
}, dataSource)
}
const computedEntries = []
const processProperty = (property, scopeData, parentPath = []) => {
if (!property?.name) return
const propertyPath = normalizedPath(property.name, parentPath)
if (property.value && typeof property.value === 'function') {
try {
const computedValue = property.value(scopeData || {})
if (computedValue !== undefined) {
computedEntries.push({
namePath: propertyPath,
value: computedValue
})
}
} catch (error) {
console.warn(
`Error calculating value for property ${property.name}:`,
error
)
}
}
if (
Array.isArray(property.properties) &&
property.properties.length > 0
) {
if (property.type === 'objectChildren') {
const childValues = getValueAtPath(currentData, propertyPath)
if (Array.isArray(childValues)) {
childValues.forEach((childData = {}, index) => {
property.properties.forEach((childProperty) => {
processProperty(childProperty, childData || {}, [
...propertyPath,
index
])
})
})
}
} else {
const nestedScope = getValueAtPath(currentData, propertyPath) || {}
property.properties.forEach((childProperty) => {
processProperty(childProperty, nestedScope || {}, propertyPath)
})
}
}
}
modelDefinition.properties.forEach((property) => {
processProperty(property, currentData)
})
return computedEntries
(currentData, modelDefinition, options = {}) => {
return calculateModelComputedEntries(currentData, modelDefinition, options)
},
[]
)
@ -171,7 +93,6 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
// Set initial form values when defaultValues change
useEffect(() => {
if (Object.keys(defaultValues).length > 0) {
// Calculate computed values for initial data
const computedEntries = calculateComputedValues(defaultValues, model)
const initialFormData = applyComputedEntries(
defaultValues,
@ -191,9 +112,11 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
const handleSubmit = async () => {
try {
setSubmitLoading(true)
const computedEntries = calculateComputedValues(objectData, model)
const currentFormValues = form.getFieldsValue()
const currentFormData = mergeFormData(objectData || {}, currentFormValues)
const computedEntries = calculateComputedValues(currentFormData, model)
const computedObjectData = applyComputedEntries(
objectData,
currentFormData,
computedEntries
)
const payload = stripNestedObjectProperties(computedObjectData, model)
@ -220,8 +143,8 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
form={form}
layout='vertical'
style={style}
disabled={submitLoading}
onValuesChange={(_changedValues, allFormValues) => {
// Calculate computed values based on current form data
const currentFormData = mergeFormData(objectData || {}, allFormValues)
const computedEntries = calculateComputedValues(currentFormData, model)
@ -247,7 +170,9 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
}}
>
{children({
loading: submitLoading,
submitLoading,
disabled: submitLoading,
handleSubmit,
form,
formValid,

View File

@ -814,7 +814,7 @@ const ObjectProperty = ({
}
const inputProps = useFormItem
? {}
? { disabled }
: {
value,
onChange,

View File

@ -38,7 +38,6 @@ import ObjectProperty from './ObjectProperty'
import ObjectCard from './ObjectCard'
import FilterSidebar from './FilterSidebar'
import SortSidebar from './SortSidebar'
import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon'
import { useLocation } from 'react-router-dom'
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
@ -49,6 +48,7 @@ import ActionsIcon from '../../Icons/ActionsIcon'
import FilterIcon from '../../Icons/FilterIcon'
import ScrollBox from './ScrollBox'
import SimplePropertyFilter from './SimplePropertyFilter'
import QuickPropertyFilters from './QuickPropertyFilters'
import FilterInput from './FilterInput'
import {
getActiveFilterValues,
@ -138,13 +138,6 @@ const ColumnFilterDropdown = ({
confirm()
}
const resetFilter = () => {
setExpression('')
setDraft([])
clearFilters()
confirm()
}
return (
<div style={{ padding: 8 }}>
<Flex vertical gap='small'>
@ -154,11 +147,16 @@ const ColumnFilterDropdown = ({
value={expression}
onChange={handleExpressionChange}
onPressEnter={applyFilter}
style={{ width: 200 }}
style={{ width: 260 }}
/>
<Button onClick={resetFilter} icon={<XMarkIcon />} />
<Button type='primary' onClick={applyFilter} icon={<CheckIcon />} />
</Space.Compact>
<QuickPropertyFilters
modelType={modelType}
propertyName={propertyName}
value={draft}
onChange={handleDraftChange}
/>
<Card size='small' styles={{ body: { padding: 0, height: 200 } }}>
<ScrollBox inner={true} smallPadding={true}>
<div style={{ padding: '18px 20px', minWidth: 0 }}>
@ -285,7 +283,8 @@ const ObjectTable = forwardRef(
persistFilter,
persistSort,
persistTableState,
registerPageFilter
registerPageFilter,
registerPageSorter
} = useTableStatePersistence({
scope: type,
pagePath: location.pathname,
@ -1110,6 +1109,11 @@ const ObjectTable = forwardRef(
return () => registerPageFilter({})
}, [sidebarFilter, registerPageFilter])
useEffect(() => {
registerPageSorter(tableSorter)
return () => registerPageSorter({})
}, [tableSorter, registerPageSorter])
const getFilterDropdown = ({
setSelectedKeys,
selectedKeys,

View File

@ -0,0 +1,503 @@
import { useMemo, useRef, useState } from 'react'
import {
Button,
Card,
DatePicker,
Descriptions,
Dropdown,
Flex,
Input,
Modal,
Typography
} from 'antd'
import PropTypes from 'prop-types'
import dayjs from 'dayjs'
import InfoCircleIcon from '../../Icons/InfoCircleIcon.jsx'
import InputNumberCal from './InputNumberCal'
import {
getModelByName,
getModelProperties
} from '../../../database/ObjectModels'
import { isDateTimePropertyType } from './filterExpression'
import { CaretRightFilled } from '@ant-design/icons'
const { Text } = Typography
const TEXT_TYPES = new Set([
'text',
'email',
'url',
'codeBlock',
'markdown',
'tags',
'stringList',
'country',
'phone',
'address',
'secret',
'miscId'
])
const NUMBER_TYPES = new Set(['number', 'density', 'variance', 'numberList'])
const BOOL_TYPES = new Set(['bool', 'boolean'])
const OBJECT_TYPES = new Set([
'object',
'objectList',
'reference',
'state',
'id',
'objectType'
])
const formatFilterValue = (value, property) => {
if (value == null || value === '') return ''
if (typeof value !== 'object') return String(value)
if (property?.type === 'state') {
return String(value.type ?? value)
}
if (value.objectType) {
const { prefix } = getModelByName(value.objectType)
if (value._reference != null) return `${prefix}:${value._reference}`
if (value._id != null) return `${prefix}:${value._id}`
}
if (property?.objectType && value._id != null) {
const { prefix } = getModelByName(property.objectType)
return `${prefix}:${value._id}`
}
return String(value._id ?? value.type ?? JSON.stringify(value))
}
const formatDateFilterValue = (value, property) => {
if (value == null || value === '') return ''
const date = dayjs(value)
if (!date.isValid()) return String(value)
if (property?.type === 'date') {
return `${date.date()} ${date.month() + 1} ${date.year()}`
}
return date.toISOString()
}
const buildExpression = (operator, rawValue, property) => {
const value = isDateTimePropertyType(property)
? formatDateFilterValue(rawValue, property)
: formatFilterValue(rawValue, property)
switch (operator) {
case 'startsWith':
return `${value}*`
case 'notStartsWith':
return `<>${value}*`
case 'contains':
return `*${value}*`
case 'notContains':
return `<>*${value}*`
case 'endsWith':
return `*${value}`
case 'notEndsWith':
return `<>*${value}`
case 'equals':
return value
case 'notEquals':
return `<>${value}`
case 'greaterThan':
return `>${value}`
case 'greaterOrEqual':
return `>=${value}`
case 'lessThan':
return `<${value}`
case 'lessOrEqual':
return `<=${value}`
case 'between':
return value
case 'isTrue':
return 'true'
case 'isFalse':
return 'false'
case 'isEmpty':
return ''
default:
return value
}
}
const getPropertyCategory = (property) => {
const type = property?.type
if (!type) return 'text'
if (BOOL_TYPES.has(type)) return 'boolean'
if (isDateTimePropertyType(property)) return 'date'
if (NUMBER_TYPES.has(type)) return 'number'
if (OBJECT_TYPES.has(type)) return 'object'
if (TEXT_TYPES.has(type)) return 'text'
return 'text'
}
const getCategoryLabel = (category) => {
switch (category) {
case 'boolean':
return 'Boolean'
case 'number':
return 'Number'
case 'date':
return 'Date'
case 'object':
return 'Object'
case 'text':
default:
return 'Text'
}
}
const getFilterOptions = (category) => {
switch (category) {
case 'boolean':
return [
{ key: 'isTrue', label: 'Is true' },
{ key: 'isFalse', label: 'Is false' }
]
case 'number':
return [
{ key: 'equals', label: 'Equals', needsValue: true },
{ key: 'notEquals', label: 'Does not equal', needsValue: true },
{ type: 'divider' },
{ key: 'greaterThan', label: 'Greater than', needsValue: true },
{
key: 'greaterOrEqual',
label: 'Greater than or equal',
needsValue: true
},
{ key: 'lessThan', label: 'Less than', needsValue: true },
{
key: 'lessOrEqual',
label: 'Less than or equal',
needsValue: true
},
{ type: 'divider' },
{ key: 'between', label: 'Between', needsRange: true }
]
case 'date':
return [
{ key: 'equals', label: 'On', needsValue: true },
{ type: 'divider' },
{ key: 'lessThan', label: 'Before', needsValue: true },
{ key: 'greaterThan', label: 'After', needsValue: true },
{ type: 'divider' },
{ key: 'between', label: 'Between', needsRange: true }
]
case 'object':
return [
{ key: 'equals', label: 'Equals', needsValue: true },
{ key: 'notEquals', label: 'Does not equal', needsValue: true }
]
case 'text':
default:
return [
{ key: 'startsWith', label: 'Starts with', needsValue: true },
{
key: 'notStartsWith',
label: 'Does not start with',
needsValue: true
},
{ type: 'divider' },
{ key: 'contains', label: 'Contains', needsValue: true },
{ key: 'notContains', label: 'Does not contain', needsValue: true },
{ type: 'divider' },
{ key: 'endsWith', label: 'Ends with', needsValue: true },
{ key: 'notEndsWith', label: 'Does not end with', needsValue: true },
{ type: 'divider' },
{ key: 'equals', label: 'Equals', needsValue: true },
{ key: 'notEquals', label: 'Does not equal', needsValue: true },
{ type: 'divider' },
{ key: 'isEmpty', label: 'Is empty' }
]
}
}
const buildFilterMenuItems = (options, onOptionClick) =>
options.map((item) =>
item.type === 'divider'
? item
: {
key: item.key,
label: item.label,
onClick: () => onOptionClick(item)
}
)
const QuickPropertyFilters = ({
modelType,
propertyName,
value = [],
onChange,
onModalOpenChange,
useCard = true
}) => {
const [activeOption, setActiveOption] = useState(null)
const [inputValue, setInputValue] = useState(null)
const [rangeFrom, setRangeFrom] = useState(null)
const [rangeTo, setRangeTo] = useState(null)
const modalContentRef = useRef(null)
const property = useMemo(
() =>
getModelProperties(modelType).find((prop) => prop.name === propertyName),
[modelType, propertyName]
)
const category = useMemo(() => getPropertyCategory(property), [property])
const options = useMemo(() => getFilterOptions(category), [category])
const propertyLabel = property?.label || propertyName
const categoryLabel = getCategoryLabel(category)
const handleClear = () => {
onChange?.([])
}
const closeModal = () => {
onModalOpenChange?.(false)
setActiveOption(null)
setInputValue(null)
setRangeFrom(null)
setRangeTo(null)
}
const applyFilter = (operator, rawValue) => {
const expression = buildExpression(operator, rawValue, property)
onChange?.([expression])
closeModal()
}
const handleOptionClick = (option) => {
if (option.key === 'isTrue' || option.key === 'isFalse') {
applyFilter(option.key)
return
}
if (option.key === 'isEmpty') {
applyFilter('isEmpty')
return
}
setInputValue(null)
setRangeFrom(null)
setRangeTo(null)
onModalOpenChange?.(true)
setActiveOption(option)
}
const handleModalOk = () => {
if (!activeOption) return
if (activeOption.needsRange) {
const from = isDateTimePropertyType(property)
? formatDateFilterValue(rangeFrom, property)
: formatFilterValue(rangeFrom, property)
const to = isDateTimePropertyType(property)
? formatDateFilterValue(rangeTo, property)
: formatFilterValue(rangeTo, property)
if (!from && !to) return
applyFilter('between', `${from}..${to}`)
return
}
if (
inputValue == null ||
(typeof inputValue === 'string' && inputValue.trim() === '')
) {
return
}
applyFilter(activeOption.key, inputValue)
}
const focusFirstModalInput = () => {
requestAnimationFrame(() => {
modalContentRef.current
?.querySelector('input, textarea, .ant-picker-input input')
?.focus()
})
}
const renderValueInput = (value, onValueChange) => {
if (isDateTimePropertyType(property)) {
const pickerValue =
value == null ? null : dayjs.isDayjs(value) ? value : dayjs(value)
const validValue =
pickerValue && pickerValue.isValid() ? pickerValue : null
return (
<DatePicker
style={{ width: '100%' }}
showTime={property?.type === 'dateTime'}
value={validValue}
onChange={onValueChange}
/>
)
}
if (NUMBER_TYPES.has(property?.type)) {
return (
<InputNumberCal
style={{ width: '100%' }}
value={value}
onChange={onValueChange}
/>
)
}
return (
<Input
type='text'
value={value ?? ''}
onChange={(event) => onValueChange(event.target.value)}
/>
)
}
const filterMenuItems = buildFilterMenuItems(options, handleOptionClick)
if (!property) return null
const content = (
<Flex gap={0} vertical>
<Button
disabled={!value?.length}
onClick={handleClear}
style={{
textAlign: 'left',
padding: '5px 12px',
justifyContent: 'flex-start'
}}
type='text'
>
Clear Filter from &quot;{propertyLabel}&quot;
</Button>
<Dropdown
menu={{ items: filterMenuItems }}
trigger={['hover']}
placement='rightTop'
>
<Button
style={{
textAlign: 'left',
padding: '5px 12px',
justifyContent: 'flex-start'
}}
type='text'
>
{categoryLabel} Filters
{<CaretRightFilled style={{ marginLeft: 'auto' }} />}
</Button>
</Dropdown>
</Flex>
)
return (
<>
{useCard ? (
<Card size='small' styles={{ body: { padding: '4px 4px' } }}>
{content}
</Card>
) : (
content
)}
<Modal
open={activeOption != null}
onCancel={closeModal}
afterOpenChange={(open) => {
if (open) focusFirstModalInput()
}}
destroyOnHidden
focusTriggerAfterClose={false}
footer={null}
centered
closeIcon={null}
getContainer={() => document.body}
width={520}
>
{activeOption && (
<Flex
ref={modalContentRef}
vertical
gap='middle'
onMouseDown={(event) => event.stopPropagation()}
>
<Flex gap='middle'>
<InfoCircleIcon />
<Text strong>Filter by {propertyLabel}</Text>
</Flex>
<Text>
{activeOption.needsRange
? `Enter a range to continue:`
: `Enter a ${categoryLabel.toLowerCase()} value to continue:`}
</Text>
{activeOption.needsRange ? (
<Flex vertical gap='middle'>
<Descriptions
column={1}
size='small'
styles={{ label: { width: '60px' } }}
items={[
{
key: 'from',
label: (
<Flex
vertical
style={{ height: '100%' }}
justify='center'
>
From
</Flex>
),
children: renderValueInput(rangeFrom, setRangeFrom)
},
{
key: 'to',
label: (
<Flex
vertical
style={{ height: '100%' }}
justify='center'
>
To
</Flex>
),
children: renderValueInput(rangeTo, setRangeTo)
}
]}
/>
</Flex>
) : activeOption.needsValue ? (
renderValueInput(inputValue, setInputValue)
) : null}
<Flex justify='end' gap='small'>
<Button type='default' onClick={closeModal}>
Cancel
</Button>
<Button type='primary' onClick={handleModalOk}>
Apply
</Button>
</Flex>
</Flex>
)}
</Modal>
</>
)
}
QuickPropertyFilters.propTypes = {
modelType: PropTypes.string,
propertyName: PropTypes.string,
value: PropTypes.array,
onChange: PropTypes.func,
onModalOpenChange: PropTypes.func,
useCard: PropTypes.bool
}
export default QuickPropertyFilters

View File

@ -4,6 +4,11 @@ import PropTypes from 'prop-types'
import dayjs from 'dayjs'
import { ApiServerContext } from '../context/ApiServerContext'
import { LoadingOutlined, CaretDownOutlined } from '@ant-design/icons'
import MissingPlaceholder from './MissingPlaceholder'
import {
matchesFilterExpression,
valuesToExpression
} from './filterExpression'
const { Text } = Typography
@ -271,6 +276,20 @@ const filterTreeBySearch = (nodes, query) => {
return filterNodes(nodes)
}
const getNodeDate = (node) => {
const { year, month, day, hour, minute, second } = node
if (second != null) {
return new Date(year, month - 1, day, hour, minute, second)
}
if (minute != null) {
return new Date(year, month - 1, day, hour, minute, 0)
}
if (hour != null) {
return new Date(year, month - 1, day, hour, 0, 0)
}
return new Date(year, month - 1, day, 0, 0, 0)
}
const parseExpressionToKeys = (expression, nodeByKey) => {
const text = String(expression).trim()
if (!text) return []
@ -345,6 +364,31 @@ const parseExpressionToKeys = (expression, nodeByKey) => {
return []
}
const matchTreeToFilterValues = (value, nodeByKey, allLeafKeys) => {
if (!value?.length) return []
const matched = new Set()
const fieldOptions = { isDateField: true }
const expression = valuesToExpression(value)
for (const expr of value) {
for (const key of parseExpressionToKeys(expr, nodeByKey)) {
matched.add(key)
}
}
for (const leafKey of allLeafKeys) {
const node = nodeByKey.get(leafKey)
if (!node) continue
const date = getNodeDate(node)
if (matchesFilterExpression(expression, date, fieldOptions)) {
matched.add(leafKey)
}
}
return [...matched]
}
const expandKeysForChecked = (keys, nodeByKey) => {
const checked = new Set()
@ -371,6 +415,13 @@ const expandKeysForChecked = (keys, nodeByKey) => {
const EMPTY_OBJECT = {}
const omitUndefinedValues = (obj) =>
Object.fromEntries(
Object.entries(obj || {}).filter(
([, value]) => value !== undefined && value !== ''
)
)
const stableStringify = (value) => {
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(',')}]`
@ -402,16 +453,19 @@ const SimpleDateTimePropertyFilter = ({
getModelPropertyValuesRef.current = getModelPropertyValues
const [dates, setDates] = useState([])
const [loading, setLoading] = useState(false)
const [loading, setLoading] = useState(() => visible)
const [localChecked, setLocalChecked] = useState(null)
const filterForValues = useMemo(() => {
const next = { ...(filter || {}) }
const next = omitUndefinedValues(filter)
if (propertyName) delete next[propertyName]
return next
}, [filter, propertyName])
const masterFilterForValues = masterFilter || EMPTY_OBJECT
const masterFilterForValues = useMemo(
() => omitUndefinedValues(masterFilter),
[masterFilter]
)
const filterHash = useMemo(
() => getFilterHash(filterForValues, masterFilterForValues),
@ -484,14 +538,10 @@ const SimpleDateTimePropertyFilter = ({
if (treeData.length === 0) return
if (value?.length > 0) {
const matched = []
for (const expr of value) {
matched.push(...parseExpressionToKeys(expr, nodeByKey))
}
const matched = matchTreeToFilterValues(value, nodeByKey, allLeafKeys)
if (matched.length > 0) {
setLocalChecked(expandKeysForChecked(matched, nodeByKey))
} else {
// Unrecognized expression — treat as no tree selection highlight
setLocalChecked([])
}
} else {
@ -499,7 +549,7 @@ const SimpleDateTimePropertyFilter = ({
}
// valueKey captures value contents
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [treeData, valueKey, allKeys, nodeByKey])
}, [treeData, valueKey, allKeys, allLeafKeys, nodeByKey])
const filteredTreeData = useMemo(() => {
const query = search.trim().toLowerCase()
@ -609,6 +659,17 @@ const SimpleDateTimePropertyFilter = ({
)
}
if (!loading && treeData.length === 0) {
return (
<MissingPlaceholder
message='No options found.'
hasBackground={false}
hasBorder={false}
padding='0'
/>
)
}
return (
<Spin spinning={loading} indicator={<LoadingOutlined spin />}>
{treeData.length > 0 ? (

View File

@ -9,6 +9,10 @@ import {
} from '../../../database/ObjectModels'
import ObjectProperty from './ObjectProperty'
import SimpleDateTimePropertyFilter from './SimpleDateTimePropertyFilter'
import {
getFieldOptions,
matchOptionsToFilterValues
} from './filterExpression'
import { LoadingOutlined } from '@ant-design/icons'
import MissingPlaceholder from './MissingPlaceholder'
const { Text } = Typography
@ -46,10 +50,19 @@ const getDisplayValue = (option, property) => {
return option
}
const matchOptions = (options, selected) => {
if (!selected?.length) return []
const selectedKeys = new Set(selected.map(getOptionKey))
return options.filter((option) => selectedKeys.has(getOptionKey(option)))
const getCandidateValue = (option, property) => {
if (property?.type === 'number') {
const num = Number(option)
return Number.isNaN(num) ? getOptionKey(option) : num
}
if (property?.type === 'boolean') {
if (typeof option === 'boolean') return option
const lower = String(option).toLowerCase()
if (['true', 'yes', '1', 'on', 'y'].includes(lower)) return true
if (['false', 'no', '0', 'off', 'n'].includes(lower)) return false
return option
}
return getOptionKey(option)
}
const stableStringify = (value) => {
@ -217,17 +230,18 @@ const SimplePropertyFilter = ({
useEffect(() => {
if (options.length === 0) return
if (value?.length > 0) {
const matched = matchOptions(options, value)
const matchedKeys = matched.map(getOptionKey)
setLocalChecked(
matchedKeys.length > 0 ? matchedKeys : value.map(getOptionKey)
)
const matchedKeys = matchOptionsToFilterValues(options, value, {
getOptionKey,
getCandidate: (option) => getCandidateValue(option, property),
fieldOptions: getFieldOptions(property)
})
setLocalChecked(matchedKeys)
} else {
setLocalChecked(options.map(getOptionKey))
}
// valueKey captures value contents; value is read for matching
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options, valueKey])
}, [options, valueKey, property])
const filteredOptions = useMemo(() => {
const query = search.trim().toLowerCase()
@ -256,17 +270,6 @@ const SimplePropertyFilter = ({
emitChange([...hiddenSelected, ...visibleCheckedKeys])
}
if (!loading && options.length === 0) {
return (
<MissingPlaceholder
message='No options found.'
hasBackground={false}
hasBorder={false}
padding='0'
/>
)
}
if (isDateTimeProperty(property)) {
return (
<SimpleDateTimePropertyFilter
@ -282,6 +285,17 @@ const SimplePropertyFilter = ({
)
}
if (!loading && options.length === 0) {
return (
<MissingPlaceholder
message='No options found.'
hasBackground={false}
hasBorder={false}
padding='0'
/>
)
}
return (
<Spin spinning={loading} indicator={<LoadingOutlined spin />}>
<Checkbox.Group

View File

@ -1,23 +1,39 @@
import PropTypes from 'prop-types'
import { Button } from 'antd'
import { Badge, Button } from 'antd'
import { useLocation } from 'react-router-dom'
import SortCircleFilledIcon from '../../Icons/SortCircleFilledIcon'
import SortCircleIcon from '../../Icons/SortCircleIcon'
import { useTableState } from '../context/TableStateContext'
const SortSidebarButton = ({ active, onClick, ...buttonProps }) => (
<Button
icon={
active ? (
<SortCircleFilledIcon style={{ color: 'var(--color-primary)' }} />
) : (
<SortCircleIcon />
)
}
onClick={onClick}
type={'default'}
title={active ? 'Hide sort sidebar' : 'Show sort sidebar'}
{...buttonProps}
/>
)
const SortSidebarButton = ({ active, onClick, ...buttonProps }) => {
const location = useLocation()
const { pageSorters } = useTableState()
const sorter = pageSorters[location.pathname]
const count = sorter?.field && sorter?.order ? 1 : 0
return (
<Badge
count={count}
size='small'
offset={[-4, 4]}
style={{ padding: 0, fontWeight: 600 }}
>
<Button
icon={
active ? (
<SortCircleFilledIcon style={{ color: 'var(--color-primary)' }} />
) : (
<SortCircleIcon />
)
}
onClick={onClick}
type={'default'}
title={active ? 'Hide sort sidebar' : 'Show sort sidebar'}
{...buttonProps}
/>
</Badge>
)
}
SortSidebarButton.propTypes = {
active: PropTypes.bool.isRequired,

View File

@ -195,7 +195,7 @@ const WizardView = ({
{showButtons && (
<NewObjectButtons
disabled={disabled}
disabled={disabled || loading}
currentStep={currentStep}
totalSteps={steps.length}
onPrevious={() => setCurrentStep(currentStep - 1)}

View File

@ -0,0 +1,406 @@
/**
* Client-side filter expression parsing and matching.
* Mirrors the syntax supported by farmcontrol-api/src/utils.js.
*/
const OPERATORS = [
['<>', 'ne'],
['>=', 'gte'],
['<=', 'lte'],
['>', 'gt'],
['<', 'lt'],
['=', 'eq']
]
export const valuesToExpression = (values) => {
if (!values?.length) return ''
if (values.length === 1) return String(values[0])
return values.map((value) => {
if (value && typeof value === 'object') {
return String(value._id ?? value.type ?? JSON.stringify(value))
}
return String(value)
}).join('|')
}
const buildWildcardRegexPattern = (input) => {
const escaped = String(input).replace(/[.+^${}()|[\]\\]/g, '\\$&')
const withWildcards = escaped.replace(/\*/g, '.*').replace(/\?/g, '.')
return `^${withWildcards}$`
}
const stripIgnoreCase = (str) => (str.startsWith('@') ? str.slice(1) : str)
const isNumeric = (value) => String(value).trim() !== '' && !Number.isNaN(Number(value))
const parseBooleanOperand = (value) => {
const lower = String(value).trim().toLowerCase()
if (['yes', 'true', '1', 'on', 'y'].includes(lower)) return true
if (['no', 'false', '0', 'off', 'n'].includes(lower)) return false
if (typeof value === 'boolean') return value
return undefined
}
const startOfDay = (date) => {
const d = new Date(date)
d.setHours(0, 0, 0, 0)
return d
}
const endOfDay = (date) => {
const d = new Date(date)
d.setHours(23, 59, 59, 999)
return d
}
const normalizeYear = (year) => {
if (year >= 100) return year
return year < 70 ? 2000 + year : 1900 + year
}
const parseDateOperand = (value, boundary = 'start') => {
const text = String(value).trim()
if (!text) return null
const end = boundary === 'end'
if (/[-/T]/.test(text) || /\d:\d/.test(text)) {
const parsed = new Date(text)
if (Number.isNaN(parsed.getTime())) return null
if (!/[T:]/.test(text)) {
return end ? endOfDay(parsed) : startOfDay(parsed)
}
return parsed
}
const parts = text.split(/\s+/)
if (!parts.every((part) => /^\d+$/.test(part))) return null
const nums = parts.map(Number)
const now = new Date()
const day = nums[0]
const month = nums.length >= 2 ? nums[1] : now.getMonth() + 1
const year = nums.length >= 3 ? normalizeYear(nums[2]) : now.getFullYear()
const hour = nums.length >= 4 ? nums[3] : end ? 23 : 0
const minute = nums.length >= 5 ? nums[4] : end ? 59 : 0
const second = nums.length >= 6 ? nums[5] : end ? 59 : 0
const ms = end ? 999 : 0
const date = new Date(year, month - 1, day, hour, minute, second, ms)
return Number.isNaN(date.getTime()) ? null : date
}
const coerceScalar = (value) => {
const lower = String(value).toLowerCase()
if (lower === 'true') return true
if (lower === 'false') return false
if (isNumeric(value)) return Number(value)
return value
}
const coerceBoundary = (value, isDateField, boundary) => {
if (isDateField) {
const date = parseDateOperand(value, boundary)
if (date) return date
}
return coerceScalar(value)
}
const splitTopLevel = (str, separator) => {
const parts = []
let depth = 0
let current = ''
for (const ch of str) {
if (ch === '(') depth++
else if (ch === ')') depth = Math.max(0, depth - 1)
if (ch === separator && depth === 0) {
parts.push(current)
current = ''
} else {
current += ch
}
}
parts.push(current)
return parts
}
const isWrappedInParens = (str) => {
if (!str.startsWith('(') || !str.endsWith(')')) return false
let depth = 0
for (let i = 0; i < str.length; i++) {
if (str[i] === '(') depth++
else if (str[i] === ')') {
depth--
if (depth === 0 && i < str.length - 1) return false
}
}
return depth === 0
}
const parseExpression = (str) => {
const orParts = splitTopLevel(str, '|')
if (orParts.length > 1) {
return { type: 'or', items: orParts.map(parseExpression) }
}
const andParts = splitTopLevel(str, '&')
if (andParts.length > 1) {
return { type: 'and', items: andParts.map(parseExpression) }
}
const trimmed = str.trim()
if (isWrappedInParens(trimmed)) {
return parseExpression(trimmed.slice(1, -1))
}
return { type: 'leaf', token: trimmed }
}
const wildcardMatch = (candidate, pattern) => {
const regex = new RegExp(buildWildcardRegexPattern(pattern), 'i')
return regex.test(String(candidate))
}
const compareValues = (left, right) => {
if (left instanceof Date && right instanceof Date) {
return left.getTime() - right.getTime()
}
if (typeof left === 'number' && typeof right === 'number') {
return left - right
}
return String(left).localeCompare(String(right), undefined, {
sensitivity: 'base',
numeric: true
})
}
const matchEquality = (candidate, value, { isDateField, isBooleanField, isNumberField }) => {
if (isBooleanField) {
const bool = parseBooleanOperand(value)
return bool !== undefined && candidate === bool
}
if (isDateField) {
if (!(candidate instanceof Date) || Number.isNaN(candidate.getTime())) return false
const start = parseDateOperand(value, 'start')
const end = parseDateOperand(value, 'end')
if (!start || !end) return false
const time = candidate.getTime()
return time >= start.getTime() && time <= end.getTime()
}
if (isNumberField) {
if (typeof candidate !== 'number' || Number.isNaN(candidate)) {
if (!isNumeric(candidate)) return false
candidate = Number(candidate)
}
if (/[*?]/.test(value)) {
return wildcardMatch(candidate, value)
}
return isNumeric(value) && candidate === Number(value)
}
if (/[*?]/.test(value)) {
return wildcardMatch(candidate, value)
}
if (typeof candidate === 'number' && isNumeric(value)) {
return candidate === Number(value)
}
return String(candidate).toLowerCase() === String(value).toLowerCase()
}
const matchComparison = (
name,
candidate,
value,
{ isDateField, isBooleanField, isNumberField }
) => {
if (isBooleanField) {
if (name === 'eq') return matchEquality(candidate, value, { isBooleanField })
if (name === 'ne') {
const bool = parseBooleanOperand(value)
return bool !== undefined && candidate !== bool
}
return false
}
if (name === 'eq') {
return matchEquality(candidate, value, {
isDateField,
isBooleanField,
isNumberField
})
}
if (name === 'ne') {
if (isDateField) {
if (!(candidate instanceof Date)) return false
const start = parseDateOperand(value, 'start')
const end = parseDateOperand(value, 'end')
if (!start || !end) return false
const time = candidate.getTime()
return time < start.getTime() || time > end.getTime()
}
if (/[*?]/.test(value)) {
return !wildcardMatch(candidate, value)
}
if (isNumberField) {
if (typeof candidate !== 'number') candidate = Number(candidate)
if (!isNumeric(value)) return false
return candidate !== Number(value)
}
return String(candidate).toLowerCase() !== String(value).toLowerCase()
}
const boundary = name === 'gt' || name === 'lte' ? 'end' : 'start'
const bound = coerceBoundary(value, isDateField, boundary)
if (isDateField) {
if (!(candidate instanceof Date) || !(bound instanceof Date)) return false
const cmp = compareValues(candidate, bound)
if (name === 'gt') return cmp > 0
if (name === 'gte') return cmp >= 0
if (name === 'lt') return cmp < 0
if (name === 'lte') return cmp <= 0
return false
}
if (isNumberField) {
if (!isNumeric(value)) return false
const numCandidate =
typeof candidate === 'number' ? candidate : Number(candidate)
const numBound = Number(value)
if (Number.isNaN(numCandidate)) return false
if (name === 'gt') return numCandidate > numBound
if (name === 'gte') return numCandidate >= numBound
if (name === 'lt') return numCandidate < numBound
if (name === 'lte') return numCandidate <= numBound
return false
}
const cmp = compareValues(candidate, bound)
if (name === 'gt') return cmp > 0
if (name === 'gte') return cmp >= 0
if (name === 'lt') return cmp < 0
if (name === 'lte') return cmp <= 0
return false
}
const matchRange = (candidate, lo, hi, { isDateField, isNumberField }) => {
if (isDateField) {
if (!(candidate instanceof Date)) return false
const time = candidate.getTime()
if (lo !== '') {
const start = parseDateOperand(lo, 'start')
if (!start || time < start.getTime()) return false
}
if (hi !== '') {
const end = parseDateOperand(hi, 'end')
if (!end || time > end.getTime()) return false
}
return true
}
if (isNumberField) {
const numCandidate =
typeof candidate === 'number' ? candidate : Number(candidate)
if (Number.isNaN(numCandidate)) return false
if (lo !== '') {
if (!isNumeric(lo)) return false
if (numCandidate < Number(lo)) return false
}
if (hi !== '') {
if (!isNumeric(hi)) return false
if (numCandidate > Number(hi)) return false
}
return true
}
const str = String(candidate)
if (lo !== '' && compareValues(str, lo) < 0) return false
if (hi !== '' && compareValues(str, hi) > 0) return false
return true
}
const matchLeaf = (candidate, rawToken, fieldOptions) => {
const token = rawToken.trim()
if (token === '') {
return fieldOptions.isBooleanField || fieldOptions.isNumberField
? false
: String(candidate) === ''
}
const rangeIdx = token.indexOf('..')
if (rangeIdx !== -1) {
if (fieldOptions.isBooleanField) return false
const lo = stripIgnoreCase(token.slice(0, rangeIdx).trim())
const hi = stripIgnoreCase(token.slice(rangeIdx + 2).trim())
if (lo === '' && hi === '') return true
return matchRange(candidate, lo, hi, fieldOptions)
}
for (const [symbol, name] of OPERATORS) {
if (token.startsWith(symbol)) {
return matchComparison(
name,
candidate,
stripIgnoreCase(token.slice(symbol.length).trim()),
fieldOptions
)
}
}
return matchEquality(candidate, stripIgnoreCase(token), fieldOptions)
}
const evalNode = (candidate, node, fieldOptions) => {
if (node.type === 'or') {
return node.items.some((item) => evalNode(candidate, item, fieldOptions))
}
if (node.type === 'and') {
return node.items.every((item) => evalNode(candidate, item, fieldOptions))
}
return matchLeaf(candidate, node.token, fieldOptions)
}
export const isDateTimePropertyType = (property) =>
property?.type === 'dateTime' || property?.type === 'date'
export const getFieldOptions = (property) => ({
isDateField: isDateTimePropertyType(property),
isBooleanField: property?.type === 'boolean',
isNumberField: property?.type === 'number'
})
/**
* Returns true when `candidate` satisfies the filter `expression`.
*/
export const matchesFilterExpression = (expression, candidate, fieldOptions = {}) => {
const text = String(expression ?? '').trim()
if (!text) return true
try {
const ast = parseExpression(text)
return evalNode(candidate, ast, fieldOptions)
} catch {
return false
}
}
/**
* Returns option keys from `options` that match any of the filter `values`.
*/
export const matchOptionsToFilterValues = (
options,
values,
{ getOptionKey, getCandidate, fieldOptions } = {}
) => {
if (!values?.length || !options?.length) return []
const expression = valuesToExpression(values)
if (!expression.trim()) return options.map(getOptionKey)
const matched = []
for (const option of options) {
const candidate = getCandidate(option)
if (matchesFilterExpression(expression, candidate, fieldOptions)) {
matched.push(getOptionKey(option))
}
}
return matched
}

View File

@ -96,6 +96,7 @@ const normalizeSorter = (sorter) => {
export const TableStateProvider = ({ children }) => {
const [searchParams, setSearchParams] = useSearchParams()
const [pageFilters, setPageFilters] = useState({})
const [pageSorters, setPageSorters] = useState({})
const getPersistedFilter = useCallback(
(scope, { useFilterInUrl = false, useFilterInSession = false } = {}) => {
@ -231,6 +232,19 @@ export const TableStateProvider = ({ children }) => {
})
}, [])
const setPageSorter = useCallback((path, sorter) => {
const hasSort = sorter?.field && sorter?.order
setPageSorters((prev) => {
if (!hasSort) {
if (!(path in prev)) return prev
const next = { ...prev }
delete next[path]
return next
}
return { ...prev, [path]: { field: sorter.field, order: sorter.order } }
})
}, [])
const hasPageFilter = useCallback(
(path) => Object.keys(pageFilters[path] || {}).length > 0,
[pageFilters]
@ -246,9 +260,11 @@ export const TableStateProvider = ({ children }) => {
persistSort,
persistTableState,
setPageFilter,
setPageSorter,
hasPageFilter,
hasStoredFilter,
pageFilters
pageFilters,
pageSorters
}),
[
getPersistedFilter,
@ -257,9 +273,11 @@ export const TableStateProvider = ({ children }) => {
persistSort,
persistTableState,
setPageFilter,
setPageSorter,
hasPageFilter,
hasStoredFilter,
pageFilters
pageFilters,
pageSorters
]
)
@ -298,7 +316,8 @@ export const useTableStatePersistence = ({
persistFilter: persistFilterCtx,
persistSort: persistSortCtx,
persistTableState: persistTableStateCtx,
setPageFilter
setPageFilter,
setPageSorter
} = useTableState()
const readOptions = useMemo(
@ -351,6 +370,15 @@ export const useTableStatePersistence = ({
[pagePath, setPageFilter]
)
const registerPageSorter = useCallback(
(sorter) => {
if (pagePath) {
setPageSorter(pagePath, sorter)
}
},
[pagePath, setPageSorter]
)
return {
getPersistedFilter,
getPersistedSorter,
@ -358,6 +386,7 @@ export const useTableStatePersistence = ({
persistSort,
persistTableState,
registerPageFilter,
registerPageSorter,
getActiveFilterValues
}
}

View File

@ -144,3 +144,137 @@ const stripProperties = (data, properties) => {
export function stripNestedObjectProperties(data, modelDefinition) {
return stripProperties(data, modelDefinition?.properties)
}
const collectModelPropertyPaths = (modelDefinition) => {
const paths = []
const visit = (properties) => {
;(properties || []).forEach((property) => {
if (property?.name) {
paths.push(property.name)
}
if (Array.isArray(property?.properties) && property.properties.length > 0) {
visit(property.properties)
}
})
}
visit(modelDefinition?.properties)
return paths
}
const pathToString = (path) =>
Array.isArray(path) ? path.join('.') : String(path)
// Computed display fields (e.g. deviceInfo.cpu) must not replace parent objects
// when sibling nested properties exist (e.g. deviceInfo.cpu.model).
const hasNestedPropertyPaths = (propertyPath, allPropertyPaths) => {
const pathStr = pathToString(propertyPath)
const prefix = `${pathStr}.`
return allPropertyPaths.some(
(name) => name !== pathStr && name.startsWith(prefix)
)
}
export function calculateModelComputedEntries(
currentData,
modelDefinition,
options = {}
) {
if (!modelDefinition || !Array.isArray(modelDefinition.properties)) {
return []
}
const skipObjectChildrenValue = options.skipObjectChildrenValue === true
const workingData = mergeFormData(currentData)
const normalizedPath = (name, parentPath = []) => {
if (Array.isArray(name)) {
return [...parentPath, ...name]
}
if (typeof name === 'number') {
return [...parentPath, name]
}
if (typeof name === 'string' && name.length > 0) {
return [...parentPath, ...name.split('.')]
}
return parentPath
}
const getValueAtPath = (dataSource, path) => {
if (!Array.isArray(path) || path.length === 0) {
return dataSource
}
return path.reduce((acc, key) => {
if (acc == null) return acc
return acc[key]
}, dataSource)
}
const computedEntries = []
const allPropertyPaths = collectModelPropertyPaths(modelDefinition)
const processProperty = (property, parentPath = []) => {
if (!property?.name) return
const propertyPath = normalizedPath(property.name, parentPath)
const scopeData =
parentPath.length === 0
? workingData
: getValueAtPath(workingData, parentPath)
if (property.value && typeof property.value === 'function') {
const skipValue =
skipObjectChildrenValue && property.type === 'objectChildren'
if (!skipValue) {
try {
const computedValue = property.value(scopeData || {})
if (computedValue !== undefined) {
const preserveNestedObject = hasNestedPropertyPaths(
propertyPath,
allPropertyPaths
)
if (!preserveNestedObject) {
computedEntries.push({
namePath: propertyPath,
value: computedValue
})
set(workingData, propertyPath, computedValue)
}
}
} catch (error) {
console.warn(
`Error calculating value for property ${property.name}:`,
error
)
}
}
}
if (
Array.isArray(property.properties) &&
property.properties.length > 0
) {
if (property.type === 'objectChildren') {
const childValues = getValueAtPath(workingData, propertyPath)
if (Array.isArray(childValues)) {
childValues.forEach((_, index) => {
property.properties.forEach((childProperty) => {
processProperty(childProperty, [...propertyPath, index])
})
})
}
} else {
property.properties.forEach((childProperty) => {
processProperty(childProperty, propertyPath)
})
}
}
}
modelDefinition.properties.forEach((property) => {
processProperty(property, [])
})
return computedEntries
}

View File

@ -131,6 +131,8 @@ export const CourierService = {
'deliveryTime',
'cost',
'costWithTax',
'additionalCost',
'additionalCostWithTax',
'createdAt',
'updatedAt',
'_reference'
@ -140,8 +142,11 @@ export const CourierService = {
'courier',
'active',
'tracked',
'deliveryTime',
'cost',
'costWithTax',
'additionalCost',
'additionalCostWithTax',
'estimatedDeliveryTime',
'createdAt',
'_id',

View File

@ -103,6 +103,7 @@ export const DocumentPrinter = {
'active',
'isGlobal',
'state',
'connection.port',
'createdAt',
'updatedAt',
'_reference'
@ -111,6 +112,7 @@ export const DocumentPrinter = {
'name',
'documentSize',
'connectedAt',
'connection.port',
'updatedAt',
'state',
'createdAt'

View File

@ -135,7 +135,10 @@ export const Filament = {
'material',
'diameter',
'name',
'density',
'emptySpoolWeight',
'cost',
'costWithTax',
'createdAt',
'updatedAt',
'_reference'
@ -145,6 +148,9 @@ export const Filament = {
'createdAt',
'vendor',
'material',
'diameter',
'density',
'emptySpoolWeight',
'cost',
'costWithTax',
'updatedAt'

View File

@ -5,11 +5,23 @@ const FilamentStockInfo = lazy(
import('../../components/Dashboard/Inventory/FilamentStocks/FilamentStockInfo')
)
const NewFilamentStock = lazy(
() => import('../../components/Dashboard/Inventory/FilamentStocks/NewFilamentStock')
() =>
import('../../components/Dashboard/Inventory/FilamentStocks/NewFilamentStock')
)
const PostFilamentStock = lazy(
() =>
import('../../components/Dashboard/Inventory/FilamentStocks/PostFilamentStock.jsx')
)
const DeleteObject = lazy(
() => import('../../components/Dashboard/common/DeleteObject')
)
import FilamentStockIcon from '../../components/Icons/FilamentStockIcon'
import PlusIcon from '../../components/Icons/PlusIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import EditIcon from '../../components/Icons/EditIcon'
import CheckIcon from '../../components/Icons/CheckIcon'
import XMarkIcon from '../../components/Icons/XMarkIcon'
import BinIcon from '../../components/Icons/BinIcon'
import ListIcon from '../../components/Icons/ListIcon'
export const FilamentStock = {
@ -18,7 +30,6 @@ export const FilamentStock = {
labelPlural: 'Filament Stocks',
url: '/dashboard/inventory/filamentstocks',
prefix: 'FLS',
readOnly: true,
icon: FilamentStockIcon,
actions: [
{
@ -29,7 +40,11 @@ export const FilamentStock = {
label: 'New Filament Stock',
icon: PlusIcon,
content: (objectData, { onOk } = {}) => {
return createElement(NewFilamentStock, { defaultValues: objectData, onOk, reset: true })
return createElement(NewFilamentStock, {
defaultValues: objectData,
onOk,
reset: true
})
}
},
{
@ -47,6 +62,76 @@ export const FilamentStock = {
default: true,
row: true,
icon: InfoCircleIcon
},
{
name: 'edit',
type: 'page',
pageName: 'info',
label: 'Edit',
row: true,
icon: EditIcon,
visible: (objectData) => {
return !(objectData?._isEditing && objectData?._isEditing == true)
},
disabled: (objectData) => {
return objectData?.state?.type != 'draft'
}
},
{
name: 'cancelEdit',
label: 'Cancel Edits',
type: 'page',
pageName: 'info',
icon: XMarkIcon,
visible: (objectData) => {
return objectData?._isEditing && objectData?._isEditing == true
}
},
{
name: 'finishEdit',
label: 'Save Edits',
type: 'page',
pageName: 'info',
icon: CheckIcon,
visible: (objectData) => {
return objectData?._isEditing && objectData?._isEditing == true
}
},
{
name: 'delete',
type: 'modal',
modalWidth: 520,
modalCentered: true,
label: 'Delete',
icon: BinIcon,
danger: true,
visible: (objectData) => {
return !(objectData?._isEditing && objectData?._isEditing == true)
},
disabled: (objectData) => {
return objectData?.state?.type != 'draft'
},
content: (objectData, { onOk } = {}) => {
return createElement(DeleteObject, { objectData, onOk })
}
},
{ type: 'divider' },
{
name: 'post',
type: 'modal',
modalWidth: 520,
modalCentered: true,
label: 'Post',
icon: CheckIcon,
disabled: (objectData) => {
return objectData?._isEditing == true
},
visible: (objectData) => {
return objectData?.state?.type == 'draft'
},
content: (objectData, { onOk } = {}) => {
return createElement(PostFilamentStock, { objectData, onOk })
}
}
],
pages: [
@ -121,6 +206,13 @@ export const FilamentStock = {
readOnly: true,
columnWidth: 260
},
{
name: 'postedAt',
label: 'Posted At',
type: 'dateTime',
readOnly: true,
columnWidth: 175
},
{
name: 'filament',
label: 'Filament',
@ -142,6 +234,9 @@ export const FilamentStock = {
required: true,
showHyperlink: true,
columnWidth: 200,
disabled: (objectData) => {
return !objectData?.filament
},
masterFilter: (objectData) => {
return {
filament: objectData?.filament?._id
@ -166,10 +261,13 @@ export const FilamentStock = {
required: true,
columnWidth: 300,
value: (objectData) => {
if (objectData?.state?.type === 'unconsumed') {
return objectData?.startingWeight
if (
objectData?.state?.type === 'unconsumed' ||
objectData?.state?.type === 'draft'
) {
return objectData?.startingWeight || { net: 0, gross: 0 }
} else {
return objectData.currentWeight
return objectData?.currentWeight || { net: 0, gross: 0 }
}
}
},
@ -178,7 +276,6 @@ export const FilamentStock = {
label: 'Starting Weight',
type: 'netGross',
suffix: 'g',
readOnly: true,
initial: true,
required: true,
columnWidth: 300,
@ -188,6 +285,12 @@ export const FilamentStock = {
}
],
stats: [
{
name: 'draft.count',
label: 'Draft',
type: 'number',
color: 'default'
},
{
name: 'unconsumed.count',
label: 'Unconsumed',

View File

@ -113,6 +113,11 @@ export const Part = {
'product._id',
'_id',
'name',
'cost',
'costWithTax',
'price',
'margin',
'priceWithTax',
'createdAt',
'updatedAt',
'_reference'
@ -123,6 +128,7 @@ export const Part = {
'cost',
'costWithTax',
'price',
'margin',
'priceWithTax',
'createdAt',
'updatedAt',

View File

@ -124,7 +124,10 @@ export const PartSku = {
'part',
'name',
'cost',
'costWithTax',
'price',
'priceWithTax',
'margin',
'createdAt',
'updatedAt',
'_reference'
@ -137,6 +140,7 @@ export const PartSku = {
'costWithTax',
'price',
'priceWithTax',
'margin',
'createdAt',
'updatedAt'
],

View File

@ -232,6 +232,9 @@ export const PartStock = {
required: true,
showHyperlink: true,
columnWidth: 200,
disabled: (objectData) => {
return !objectData?.part
},
masterFilter: (objectData) => {
return { part: objectData?.part?._id }
}

View File

@ -118,6 +118,11 @@ export const Product = {
'name',
'globalPrice',
'productCategory',
'cost',
'costWithTax',
'price',
'margin',
'priceWithTax',
'createdAt',
'updatedAt',
'_reference'
@ -131,6 +136,7 @@ export const Product = {
'cost',
'costWithTax',
'price',
'margin',
'priceWithTax',
'updatedAt'
],

View File

@ -124,7 +124,10 @@ export const ProductSku = {
'product',
'name',
'cost',
'costWithTax',
'price',
'priceWithTax',
'margin',
'createdAt',
'updatedAt',
'_reference'
@ -137,6 +140,7 @@ export const ProductSku = {
'costWithTax',
'price',
'priceWithTax',
'margin',
'createdAt',
'updatedAt'
],

View File

@ -238,6 +238,9 @@ export const ProductStock = {
required: true,
showHyperlink: true,
columnWidth: 200,
disabled: (objectData) => {
return !objectData?.product
},
masterFilter: (objectData) => {
return { product: objectData?.product?._id }
}