Compare commits
12 Commits
a41f168adc
...
aef6ec8749
| Author | SHA1 | Date | |
|---|---|---|---|
| aef6ec8749 | |||
| e49c7c23ed | |||
| 415aa0cd9d | |||
| 451787debd | |||
| d180f2217a | |||
| 3352c25819 | |||
| 1597370a16 | |||
| b590890bd3 | |||
| d96b84513c | |||
| 291d5e438c | |||
| 41825a23e5 | |||
| 2feba8b3cc |
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
@ -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={[]}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -814,7 +814,7 @@ const ObjectProperty = ({
|
||||
}
|
||||
|
||||
const inputProps = useFormItem
|
||||
? {}
|
||||
? { disabled }
|
||||
: {
|
||||
value,
|
||||
onChange,
|
||||
|
||||
@ -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,
|
||||
|
||||
503
src/components/Dashboard/common/QuickPropertyFilters.jsx
Normal file
503
src/components/Dashboard/common/QuickPropertyFilters.jsx
Normal 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 "{propertyLabel}"
|
||||
</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
|
||||
@ -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 ? (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -195,7 +195,7 @@ const WizardView = ({
|
||||
|
||||
{showButtons && (
|
||||
<NewObjectButtons
|
||||
disabled={disabled}
|
||||
disabled={disabled || loading}
|
||||
currentStep={currentStep}
|
||||
totalSteps={steps.length}
|
||||
onPrevious={() => setCurrentStep(currentStep - 1)}
|
||||
|
||||
406
src/components/Dashboard/common/filterExpression.js
Normal file
406
src/components/Dashboard/common/filterExpression.js
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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'
|
||||
],
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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'
|
||||
],
|
||||
|
||||
@ -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'
|
||||
],
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user