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';
|
font-family: 'DM Sans';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ant-dropdown {
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
.ant-typography code,
|
.ant-typography code,
|
||||||
.ant-typography pre,
|
.ant-typography pre,
|
||||||
.ͼ1 .cm-scroller {
|
.ͼ1 .cm-scroller {
|
||||||
@ -750,6 +754,45 @@ body {
|
|||||||
position: relative;
|
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 {
|
.input-number-cal .ant-input-suffix {
|
||||||
margin-right: 28px;
|
margin-right: 28px;
|
||||||
}
|
}
|
||||||
@ -760,7 +803,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.input-number-cal .ant-input {
|
.input-number-cal .ant-input {
|
||||||
font-family: 'DM Mono';
|
font-family: 'DM Sans';
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ const NewFilamentStock = ({ onOk, reset, defaultValues }) => {
|
|||||||
<NewObjectForm
|
<NewObjectForm
|
||||||
type={'filamentStock'}
|
type={'filamentStock'}
|
||||||
reset={reset}
|
reset={reset}
|
||||||
defaultValues={{ state: { type: 'unconsumed' }, ...defaultValues }}
|
defaultValues={{ state: { type: 'draft' }, ...defaultValues }}
|
||||||
>
|
>
|
||||||
{({ handleSubmit, submitLoading, objectData, formValid }) => {
|
{({ handleSubmit, submitLoading, objectData, formValid }) => {
|
||||||
const steps = [
|
const steps = [
|
||||||
@ -38,7 +38,8 @@ const NewFilamentStock = ({ onOk, reset, defaultValues }) => {
|
|||||||
_id: false,
|
_id: false,
|
||||||
_reference: false,
|
_reference: false,
|
||||||
createdAt: false,
|
createdAt: false,
|
||||||
updatedAt: false
|
updatedAt: false,
|
||||||
|
postedAt: false
|
||||||
}}
|
}}
|
||||||
isEditing={false}
|
isEditing={false}
|
||||||
objectData={objectData}
|
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 useStyle, { useSharedStyle } from 'antd/es/input/style'
|
||||||
import { useThemeContext } from '../context/ThemeContext'
|
import { useThemeContext } from '../context/ThemeContext'
|
||||||
import SimplePropertyFilter from './SimplePropertyFilter'
|
import SimplePropertyFilter from './SimplePropertyFilter'
|
||||||
|
import QuickPropertyFilters from './QuickPropertyFilters'
|
||||||
import ScrollBox from './ScrollBox'
|
import ScrollBox from './ScrollBox'
|
||||||
import ChevronRightIcon from '../../Icons/ChevronRightIcon'
|
import ChevronRightIcon from '../../Icons/ChevronRightIcon'
|
||||||
import GreaterThanIcon from '../../Icons/GreaterThanIcon'
|
import GreaterThanIcon from '../../Icons/GreaterThanIcon'
|
||||||
@ -25,9 +26,12 @@ import LessThanIcon from '../../Icons/LessThanIcon'
|
|||||||
import LessThanOrEqualToIcon from '../../Icons/LessThanOrEqualToIcon'
|
import LessThanOrEqualToIcon from '../../Icons/LessThanOrEqualToIcon'
|
||||||
import NotEqualIcon from '../../Icons/NotEqualIcon'
|
import NotEqualIcon from '../../Icons/NotEqualIcon'
|
||||||
import EqualIcon from '../../Icons/EqualIcon'
|
import EqualIcon from '../../Icons/EqualIcon'
|
||||||
|
import { Divider } from 'antd'
|
||||||
|
|
||||||
const operandIconStyle = { fontSize: 8 }
|
const operandIconStyle = { fontSize: 8 }
|
||||||
|
|
||||||
|
const PROPERTY_FILTER_PANEL_MAX_HEIGHT = 220
|
||||||
|
|
||||||
// Longer symbols first so ".." / "<>" / ">=" match before "." / "<" / ">".
|
// Longer symbols first so ".." / "<>" / ">=" match before "." / "<" / ">".
|
||||||
// Wildcards (* ?) are highlighted in place; @ stays as plain text — none are operands.
|
// Wildcards (* ?) are highlighted in place; @ stays as plain text — none are operands.
|
||||||
const WILDCARDS = new Set(['*', '?'])
|
const WILDCARDS = new Set(['*', '?'])
|
||||||
@ -469,8 +473,12 @@ const FilterInput = ({
|
|||||||
const composingRef = useRef(false)
|
const composingRef = useRef(false)
|
||||||
const selectingRef = useRef(false)
|
const selectingRef = useRef(false)
|
||||||
const valueRef = useRef(value ?? '')
|
const valueRef = useRef(value ?? '')
|
||||||
|
const quickFiltersRef = useRef(null)
|
||||||
|
const quickFilterModalOpenRef = useRef(false)
|
||||||
const [focused, setFocused] = useState(false)
|
const [focused, setFocused] = useState(false)
|
||||||
const [internalValue, setInternalValue] = useState(value ?? '')
|
const [internalValue, setInternalValue] = useState(value ?? '')
|
||||||
|
const [quickFiltersHeight, setQuickFiltersHeight] = useState(0)
|
||||||
|
const [quickFilterModalOpen, setQuickFilterModalOpen] = useState(false)
|
||||||
|
|
||||||
const clearTagRoots = useCallback(() => {
|
const clearTagRoots = useCallback(() => {
|
||||||
tagRootsRef.current.forEach((root) => {
|
tagRootsRef.current.forEach((root) => {
|
||||||
@ -864,6 +872,48 @@ const FilterInput = ({
|
|||||||
[emitChange, paint]
|
[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 ? (
|
const propertyFilterContent = propertyFilterEnabled ? (
|
||||||
<div
|
<div
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
@ -872,17 +922,34 @@ const FilterInput = ({
|
|||||||
}}
|
}}
|
||||||
className='filter-input-property-filter'
|
className='filter-input-property-filter'
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-block',
|
display: 'inline-flex',
|
||||||
|
flexDirection: 'column',
|
||||||
maxWidth: 280,
|
maxWidth: 280,
|
||||||
maxHeight: 220,
|
maxHeight: quickFiltersHeight + PROPERTY_FILTER_PANEL_MAX_HEIGHT,
|
||||||
margin: -4,
|
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
|
<ScrollBox
|
||||||
inner
|
inner
|
||||||
smallPadding
|
smallPadding
|
||||||
style={{ height: 'auto', maxHeight: 220, maxWidth: 280 }}
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
maxHeight: PROPERTY_FILTER_PANEL_MAX_HEIGHT,
|
||||||
|
maxWidth: 280
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -1031,7 +1098,9 @@ const FilterInput = ({
|
|||||||
zIndex: focused ? 3 : style?.zIndex
|
zIndex: focused ? 3 : style?.zIndex
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!disabled) editorRef.current?.focus()
|
if (!disabled && !quickFilterModalOpenRef.current) {
|
||||||
|
editorRef.current?.focus()
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<style>{`
|
<style>{`
|
||||||
@ -1100,6 +1169,11 @@ const FilterInput = ({
|
|||||||
onFocus?.(event)
|
onFocus?.(event)
|
||||||
}}
|
}}
|
||||||
onBlur={(event) => {
|
onBlur={(event) => {
|
||||||
|
if (quickFilterModalOpenRef.current) {
|
||||||
|
focusedRef.current = false
|
||||||
|
setFocused(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
focusedRef.current = false
|
focusedRef.current = false
|
||||||
selectingRef.current = false
|
selectingRef.current = false
|
||||||
setFocused(false)
|
setFocused(false)
|
||||||
@ -1151,9 +1225,9 @@ const FilterInput = ({
|
|||||||
)}
|
)}
|
||||||
{propertyFilterEnabled && (
|
{propertyFilterEnabled && (
|
||||||
<Popover
|
<Popover
|
||||||
open={focused}
|
open={propertyFilterPopoverOpen}
|
||||||
destroyOnHidden={true}
|
destroyOnHidden={true}
|
||||||
content={focused ? propertyFilterContent : null}
|
content={propertyFilterPopoverOpen ? propertyFilterContent : null}
|
||||||
placement='bottomLeft'
|
placement='bottomLeft'
|
||||||
arrow={false}
|
arrow={false}
|
||||||
trigger={[]}
|
trigger={[]}
|
||||||
|
|||||||
@ -1,9 +1,55 @@
|
|||||||
import { useState, useRef } from 'react'
|
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
|
||||||
import { Input, InputNumber } from 'antd'
|
import { Input, InputNumber } from 'antd'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import FunctionIcon from '../../Icons/FunctionIcon'
|
import FunctionIcon from '../../Icons/FunctionIcon'
|
||||||
|
|
||||||
const OPERATOR_KEYS = ['+', '-', '*', '/']
|
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 +, -, *, /
|
* Safely evaluate a math expression. Only allows numbers and +, -, *, /
|
||||||
@ -32,21 +78,54 @@ const InputNumberCal = ({
|
|||||||
suffix,
|
suffix,
|
||||||
placeholder,
|
placeholder,
|
||||||
disabled,
|
disabled,
|
||||||
|
style,
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
const [isExprMode, setIsExprMode] = useState(false)
|
const [isExprMode, setIsExprMode] = useState(false)
|
||||||
const [exprValue, setExprValue] = useState('')
|
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) => {
|
const switchToExprMode = (initialValue) => {
|
||||||
setIsExprMode(true)
|
setIsExprMode(true)
|
||||||
setExprValue(initialValue)
|
setExprValue(initialValue)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
//inputRef.current?.focus()
|
const input = wrapperRef.current?.getElementsByTagName('input')[0]
|
||||||
const input = inputRef.current.getElementsByTagName('input')[0]
|
input?.focus()
|
||||||
if (input) {
|
|
||||||
input.focus()
|
|
||||||
}
|
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,14 +162,24 @@ const InputNumberCal = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInputKeyDown = (e) => {
|
const commitExpr = () => {
|
||||||
if (e.key === '=') {
|
|
||||||
e.preventDefault()
|
|
||||||
const result = safeEval(exprValue)
|
const result = safeEval(exprValue)
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
exitExprMode(result)
|
exitExprMode(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleInputKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === '=') {
|
||||||
|
e.preventDefault()
|
||||||
|
commitExpr()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInputScroll = (e) => {
|
||||||
|
if (highlightRef.current) {
|
||||||
|
highlightRef.current.scrollLeft = e.target.scrollLeft
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInputBlur = (e) => {
|
const handleInputBlur = (e) => {
|
||||||
@ -125,13 +214,26 @@ const InputNumberCal = ({
|
|||||||
|
|
||||||
if (isExprMode) {
|
if (isExprMode) {
|
||||||
return (
|
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
|
<Input
|
||||||
|
ref={(node) => {
|
||||||
|
inputElRef.current = node?.input ?? null
|
||||||
|
}}
|
||||||
|
className='input-number-cal-expr-input'
|
||||||
value={exprValue}
|
value={exprValue}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleInputKeyDown}
|
onKeyDown={handleInputKeyDown}
|
||||||
onBlur={handleInputBlur}
|
onBlur={handleInputBlur}
|
||||||
|
onScroll={handleInputScroll}
|
||||||
{...commonProps}
|
{...commonProps}
|
||||||
|
style={style}
|
||||||
/>
|
/>
|
||||||
<div className='input-number-cal-icon'>
|
<div className='input-number-cal-icon'>
|
||||||
<FunctionIcon style={{ fontSize: 24 }} />
|
<FunctionIcon style={{ fontSize: 24 }} />
|
||||||
@ -147,6 +249,7 @@ const InputNumberCal = ({
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onKeyDown={handleNumberKeyDown}
|
onKeyDown={handleNumberKeyDown}
|
||||||
|
style={style}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -161,6 +264,7 @@ InputNumberCal.propTypes = {
|
|||||||
prefix: PropTypes.node,
|
prefix: PropTypes.node,
|
||||||
suffix: PropTypes.node,
|
suffix: PropTypes.node,
|
||||||
placeholder: PropTypes.string,
|
placeholder: PropTypes.string,
|
||||||
|
style: PropTypes.object,
|
||||||
disabled: PropTypes.bool
|
disabled: PropTypes.bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,13 +12,15 @@ const NewObjectButtons = ({
|
|||||||
submitText = 'Done',
|
submitText = 'Done',
|
||||||
disabled = false
|
disabled = false
|
||||||
}) => {
|
}) => {
|
||||||
|
const controlsDisabled = disabled || submitLoading
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex justify='end'>
|
<Flex justify='end'>
|
||||||
{totalSteps > 1 ? (
|
{totalSteps > 1 ? (
|
||||||
<Button
|
<Button
|
||||||
style={{ margin: '0 8px' }}
|
style={{ margin: '0 8px' }}
|
||||||
onClick={onPrevious}
|
onClick={onPrevious}
|
||||||
disabled={currentStep === 0}
|
disabled={currentStep === 0 || controlsDisabled}
|
||||||
>
|
>
|
||||||
Previous
|
Previous
|
||||||
</Button>
|
</Button>
|
||||||
@ -27,7 +29,7 @@ const NewObjectButtons = ({
|
|||||||
{currentStep < totalSteps - 1 ? (
|
{currentStep < totalSteps - 1 ? (
|
||||||
<Button
|
<Button
|
||||||
type='primary'
|
type='primary'
|
||||||
disabled={!formValid || disabled}
|
disabled={!formValid || controlsDisabled}
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
@ -36,7 +38,7 @@ const NewObjectButtons = ({
|
|||||||
<Button
|
<Button
|
||||||
type='primary'
|
type='primary'
|
||||||
loading={submitLoading}
|
loading={submitLoading}
|
||||||
disabled={!formValid || disabled}
|
disabled={!formValid || controlsDisabled}
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
>
|
>
|
||||||
{submitText}
|
{submitText}
|
||||||
|
|||||||
@ -5,7 +5,11 @@ import { useMessageContext } from '../context/MessageContext'
|
|||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import set from 'lodash/set'
|
import set from 'lodash/set'
|
||||||
import { getModelByName } from '../../../database/ObjectModels'
|
import { getModelByName } from '../../../database/ObjectModels'
|
||||||
import { mergeFormData, stripNestedObjectProperties } from '../utils/Utils'
|
import {
|
||||||
|
mergeFormData,
|
||||||
|
stripNestedObjectProperties,
|
||||||
|
calculateModelComputedEntries
|
||||||
|
} from '../utils/Utils'
|
||||||
|
|
||||||
const buildObjectFromEntries = (entries = []) => {
|
const buildObjectFromEntries = (entries = []) => {
|
||||||
return entries.reduce((acc, entry) => {
|
return entries.reduce((acc, entry) => {
|
||||||
@ -37,7 +41,7 @@ const applyComputedEntries = (base, entries = []) => {
|
|||||||
* - formItems: array (for ObjectInfo/ObjectProperty items)
|
* - formItems: array (for ObjectInfo/ObjectProperty items)
|
||||||
* - defaultValues: object (optional) - initial values for the form
|
* - defaultValues: object (optional) - initial values for the form
|
||||||
* - children: function({
|
* - children: function({
|
||||||
* loading, isSubmitting, handleSubmit, form, formValid, objectData, setObjectData
|
* loading, submitLoading, disabled, handleSubmit, form, formValid, objectData, setObjectData
|
||||||
* }) => ReactNode
|
* }) => ReactNode
|
||||||
*/
|
*/
|
||||||
const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
|
const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
|
||||||
@ -77,93 +81,11 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
|
|||||||
}
|
}
|
||||||
}, [form])
|
}, [form])
|
||||||
|
|
||||||
// Get the model definition for this object type
|
|
||||||
const model = getModelByName(type)
|
const model = getModelByName(type)
|
||||||
|
|
||||||
// Function to calculate computed values from model properties
|
|
||||||
const calculateComputedValues = useCallback(
|
const calculateComputedValues = useCallback(
|
||||||
(currentData, modelDefinition) => {
|
(currentData, modelDefinition, options = {}) => {
|
||||||
if (!modelDefinition || !Array.isArray(modelDefinition.properties)) {
|
return calculateModelComputedEntries(currentData, modelDefinition, options)
|
||||||
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
|
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
@ -171,7 +93,6 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
|
|||||||
// Set initial form values when defaultValues change
|
// Set initial form values when defaultValues change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Object.keys(defaultValues).length > 0) {
|
if (Object.keys(defaultValues).length > 0) {
|
||||||
// Calculate computed values for initial data
|
|
||||||
const computedEntries = calculateComputedValues(defaultValues, model)
|
const computedEntries = calculateComputedValues(defaultValues, model)
|
||||||
const initialFormData = applyComputedEntries(
|
const initialFormData = applyComputedEntries(
|
||||||
defaultValues,
|
defaultValues,
|
||||||
@ -191,9 +112,11 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
|
|||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
setSubmitLoading(true)
|
setSubmitLoading(true)
|
||||||
const computedEntries = calculateComputedValues(objectData, model)
|
const currentFormValues = form.getFieldsValue()
|
||||||
|
const currentFormData = mergeFormData(objectData || {}, currentFormValues)
|
||||||
|
const computedEntries = calculateComputedValues(currentFormData, model)
|
||||||
const computedObjectData = applyComputedEntries(
|
const computedObjectData = applyComputedEntries(
|
||||||
objectData,
|
currentFormData,
|
||||||
computedEntries
|
computedEntries
|
||||||
)
|
)
|
||||||
const payload = stripNestedObjectProperties(computedObjectData, model)
|
const payload = stripNestedObjectProperties(computedObjectData, model)
|
||||||
@ -220,8 +143,8 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
|
|||||||
form={form}
|
form={form}
|
||||||
layout='vertical'
|
layout='vertical'
|
||||||
style={style}
|
style={style}
|
||||||
|
disabled={submitLoading}
|
||||||
onValuesChange={(_changedValues, allFormValues) => {
|
onValuesChange={(_changedValues, allFormValues) => {
|
||||||
// Calculate computed values based on current form data
|
|
||||||
const currentFormData = mergeFormData(objectData || {}, allFormValues)
|
const currentFormData = mergeFormData(objectData || {}, allFormValues)
|
||||||
const computedEntries = calculateComputedValues(currentFormData, model)
|
const computedEntries = calculateComputedValues(currentFormData, model)
|
||||||
|
|
||||||
@ -247,7 +170,9 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children({
|
{children({
|
||||||
|
loading: submitLoading,
|
||||||
submitLoading,
|
submitLoading,
|
||||||
|
disabled: submitLoading,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
form,
|
form,
|
||||||
formValid,
|
formValid,
|
||||||
|
|||||||
@ -814,7 +814,7 @@ const ObjectProperty = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inputProps = useFormItem
|
const inputProps = useFormItem
|
||||||
? {}
|
? { disabled }
|
||||||
: {
|
: {
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
|||||||
@ -38,7 +38,6 @@ import ObjectProperty from './ObjectProperty'
|
|||||||
import ObjectCard from './ObjectCard'
|
import ObjectCard from './ObjectCard'
|
||||||
import FilterSidebar from './FilterSidebar'
|
import FilterSidebar from './FilterSidebar'
|
||||||
import SortSidebar from './SortSidebar'
|
import SortSidebar from './SortSidebar'
|
||||||
import XMarkIcon from '../../Icons/XMarkIcon'
|
|
||||||
import CheckIcon from '../../Icons/CheckIcon'
|
import CheckIcon from '../../Icons/CheckIcon'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
|
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
|
||||||
@ -49,6 +48,7 @@ import ActionsIcon from '../../Icons/ActionsIcon'
|
|||||||
import FilterIcon from '../../Icons/FilterIcon'
|
import FilterIcon from '../../Icons/FilterIcon'
|
||||||
import ScrollBox from './ScrollBox'
|
import ScrollBox from './ScrollBox'
|
||||||
import SimplePropertyFilter from './SimplePropertyFilter'
|
import SimplePropertyFilter from './SimplePropertyFilter'
|
||||||
|
import QuickPropertyFilters from './QuickPropertyFilters'
|
||||||
import FilterInput from './FilterInput'
|
import FilterInput from './FilterInput'
|
||||||
import {
|
import {
|
||||||
getActiveFilterValues,
|
getActiveFilterValues,
|
||||||
@ -138,13 +138,6 @@ const ColumnFilterDropdown = ({
|
|||||||
confirm()
|
confirm()
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetFilter = () => {
|
|
||||||
setExpression('')
|
|
||||||
setDraft([])
|
|
||||||
clearFilters()
|
|
||||||
confirm()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 8 }}>
|
<div style={{ padding: 8 }}>
|
||||||
<Flex vertical gap='small'>
|
<Flex vertical gap='small'>
|
||||||
@ -154,11 +147,16 @@ const ColumnFilterDropdown = ({
|
|||||||
value={expression}
|
value={expression}
|
||||||
onChange={handleExpressionChange}
|
onChange={handleExpressionChange}
|
||||||
onPressEnter={applyFilter}
|
onPressEnter={applyFilter}
|
||||||
style={{ width: 200 }}
|
style={{ width: 260 }}
|
||||||
/>
|
/>
|
||||||
<Button onClick={resetFilter} icon={<XMarkIcon />} />
|
|
||||||
<Button type='primary' onClick={applyFilter} icon={<CheckIcon />} />
|
<Button type='primary' onClick={applyFilter} icon={<CheckIcon />} />
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
|
<QuickPropertyFilters
|
||||||
|
modelType={modelType}
|
||||||
|
propertyName={propertyName}
|
||||||
|
value={draft}
|
||||||
|
onChange={handleDraftChange}
|
||||||
|
/>
|
||||||
<Card size='small' styles={{ body: { padding: 0, height: 200 } }}>
|
<Card size='small' styles={{ body: { padding: 0, height: 200 } }}>
|
||||||
<ScrollBox inner={true} smallPadding={true}>
|
<ScrollBox inner={true} smallPadding={true}>
|
||||||
<div style={{ padding: '18px 20px', minWidth: 0 }}>
|
<div style={{ padding: '18px 20px', minWidth: 0 }}>
|
||||||
@ -285,7 +283,8 @@ const ObjectTable = forwardRef(
|
|||||||
persistFilter,
|
persistFilter,
|
||||||
persistSort,
|
persistSort,
|
||||||
persistTableState,
|
persistTableState,
|
||||||
registerPageFilter
|
registerPageFilter,
|
||||||
|
registerPageSorter
|
||||||
} = useTableStatePersistence({
|
} = useTableStatePersistence({
|
||||||
scope: type,
|
scope: type,
|
||||||
pagePath: location.pathname,
|
pagePath: location.pathname,
|
||||||
@ -1110,6 +1109,11 @@ const ObjectTable = forwardRef(
|
|||||||
return () => registerPageFilter({})
|
return () => registerPageFilter({})
|
||||||
}, [sidebarFilter, registerPageFilter])
|
}, [sidebarFilter, registerPageFilter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerPageSorter(tableSorter)
|
||||||
|
return () => registerPageSorter({})
|
||||||
|
}, [tableSorter, registerPageSorter])
|
||||||
|
|
||||||
const getFilterDropdown = ({
|
const getFilterDropdown = ({
|
||||||
setSelectedKeys,
|
setSelectedKeys,
|
||||||
selectedKeys,
|
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 dayjs from 'dayjs'
|
||||||
import { ApiServerContext } from '../context/ApiServerContext'
|
import { ApiServerContext } from '../context/ApiServerContext'
|
||||||
import { LoadingOutlined, CaretDownOutlined } from '@ant-design/icons'
|
import { LoadingOutlined, CaretDownOutlined } from '@ant-design/icons'
|
||||||
|
import MissingPlaceholder from './MissingPlaceholder'
|
||||||
|
import {
|
||||||
|
matchesFilterExpression,
|
||||||
|
valuesToExpression
|
||||||
|
} from './filterExpression'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
@ -271,6 +276,20 @@ const filterTreeBySearch = (nodes, query) => {
|
|||||||
return filterNodes(nodes)
|
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 parseExpressionToKeys = (expression, nodeByKey) => {
|
||||||
const text = String(expression).trim()
|
const text = String(expression).trim()
|
||||||
if (!text) return []
|
if (!text) return []
|
||||||
@ -345,6 +364,31 @@ const parseExpressionToKeys = (expression, nodeByKey) => {
|
|||||||
return []
|
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 expandKeysForChecked = (keys, nodeByKey) => {
|
||||||
const checked = new Set()
|
const checked = new Set()
|
||||||
|
|
||||||
@ -371,6 +415,13 @@ const expandKeysForChecked = (keys, nodeByKey) => {
|
|||||||
|
|
||||||
const EMPTY_OBJECT = {}
|
const EMPTY_OBJECT = {}
|
||||||
|
|
||||||
|
const omitUndefinedValues = (obj) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(obj || {}).filter(
|
||||||
|
([, value]) => value !== undefined && value !== ''
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
const stableStringify = (value) => {
|
const stableStringify = (value) => {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return `[${value.map(stableStringify).join(',')}]`
|
return `[${value.map(stableStringify).join(',')}]`
|
||||||
@ -402,16 +453,19 @@ const SimpleDateTimePropertyFilter = ({
|
|||||||
getModelPropertyValuesRef.current = getModelPropertyValues
|
getModelPropertyValuesRef.current = getModelPropertyValues
|
||||||
|
|
||||||
const [dates, setDates] = useState([])
|
const [dates, setDates] = useState([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(() => visible)
|
||||||
const [localChecked, setLocalChecked] = useState(null)
|
const [localChecked, setLocalChecked] = useState(null)
|
||||||
|
|
||||||
const filterForValues = useMemo(() => {
|
const filterForValues = useMemo(() => {
|
||||||
const next = { ...(filter || {}) }
|
const next = omitUndefinedValues(filter)
|
||||||
if (propertyName) delete next[propertyName]
|
if (propertyName) delete next[propertyName]
|
||||||
return next
|
return next
|
||||||
}, [filter, propertyName])
|
}, [filter, propertyName])
|
||||||
|
|
||||||
const masterFilterForValues = masterFilter || EMPTY_OBJECT
|
const masterFilterForValues = useMemo(
|
||||||
|
() => omitUndefinedValues(masterFilter),
|
||||||
|
[masterFilter]
|
||||||
|
)
|
||||||
|
|
||||||
const filterHash = useMemo(
|
const filterHash = useMemo(
|
||||||
() => getFilterHash(filterForValues, masterFilterForValues),
|
() => getFilterHash(filterForValues, masterFilterForValues),
|
||||||
@ -484,14 +538,10 @@ const SimpleDateTimePropertyFilter = ({
|
|||||||
if (treeData.length === 0) return
|
if (treeData.length === 0) return
|
||||||
|
|
||||||
if (value?.length > 0) {
|
if (value?.length > 0) {
|
||||||
const matched = []
|
const matched = matchTreeToFilterValues(value, nodeByKey, allLeafKeys)
|
||||||
for (const expr of value) {
|
|
||||||
matched.push(...parseExpressionToKeys(expr, nodeByKey))
|
|
||||||
}
|
|
||||||
if (matched.length > 0) {
|
if (matched.length > 0) {
|
||||||
setLocalChecked(expandKeysForChecked(matched, nodeByKey))
|
setLocalChecked(expandKeysForChecked(matched, nodeByKey))
|
||||||
} else {
|
} else {
|
||||||
// Unrecognized expression — treat as no tree selection highlight
|
|
||||||
setLocalChecked([])
|
setLocalChecked([])
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -499,7 +549,7 @@ const SimpleDateTimePropertyFilter = ({
|
|||||||
}
|
}
|
||||||
// valueKey captures value contents
|
// valueKey captures value contents
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [treeData, valueKey, allKeys, nodeByKey])
|
}, [treeData, valueKey, allKeys, allLeafKeys, nodeByKey])
|
||||||
|
|
||||||
const filteredTreeData = useMemo(() => {
|
const filteredTreeData = useMemo(() => {
|
||||||
const query = search.trim().toLowerCase()
|
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 (
|
return (
|
||||||
<Spin spinning={loading} indicator={<LoadingOutlined spin />}>
|
<Spin spinning={loading} indicator={<LoadingOutlined spin />}>
|
||||||
{treeData.length > 0 ? (
|
{treeData.length > 0 ? (
|
||||||
|
|||||||
@ -9,6 +9,10 @@ import {
|
|||||||
} from '../../../database/ObjectModels'
|
} from '../../../database/ObjectModels'
|
||||||
import ObjectProperty from './ObjectProperty'
|
import ObjectProperty from './ObjectProperty'
|
||||||
import SimpleDateTimePropertyFilter from './SimpleDateTimePropertyFilter'
|
import SimpleDateTimePropertyFilter from './SimpleDateTimePropertyFilter'
|
||||||
|
import {
|
||||||
|
getFieldOptions,
|
||||||
|
matchOptionsToFilterValues
|
||||||
|
} from './filterExpression'
|
||||||
import { LoadingOutlined } from '@ant-design/icons'
|
import { LoadingOutlined } from '@ant-design/icons'
|
||||||
import MissingPlaceholder from './MissingPlaceholder'
|
import MissingPlaceholder from './MissingPlaceholder'
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
@ -46,10 +50,19 @@ const getDisplayValue = (option, property) => {
|
|||||||
return option
|
return option
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchOptions = (options, selected) => {
|
const getCandidateValue = (option, property) => {
|
||||||
if (!selected?.length) return []
|
if (property?.type === 'number') {
|
||||||
const selectedKeys = new Set(selected.map(getOptionKey))
|
const num = Number(option)
|
||||||
return options.filter((option) => selectedKeys.has(getOptionKey(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) => {
|
const stableStringify = (value) => {
|
||||||
@ -217,17 +230,18 @@ const SimplePropertyFilter = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (options.length === 0) return
|
if (options.length === 0) return
|
||||||
if (value?.length > 0) {
|
if (value?.length > 0) {
|
||||||
const matched = matchOptions(options, value)
|
const matchedKeys = matchOptionsToFilterValues(options, value, {
|
||||||
const matchedKeys = matched.map(getOptionKey)
|
getOptionKey,
|
||||||
setLocalChecked(
|
getCandidate: (option) => getCandidateValue(option, property),
|
||||||
matchedKeys.length > 0 ? matchedKeys : value.map(getOptionKey)
|
fieldOptions: getFieldOptions(property)
|
||||||
)
|
})
|
||||||
|
setLocalChecked(matchedKeys)
|
||||||
} else {
|
} else {
|
||||||
setLocalChecked(options.map(getOptionKey))
|
setLocalChecked(options.map(getOptionKey))
|
||||||
}
|
}
|
||||||
// valueKey captures value contents; value is read for matching
|
// valueKey captures value contents; value is read for matching
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [options, valueKey])
|
}, [options, valueKey, property])
|
||||||
|
|
||||||
const filteredOptions = useMemo(() => {
|
const filteredOptions = useMemo(() => {
|
||||||
const query = search.trim().toLowerCase()
|
const query = search.trim().toLowerCase()
|
||||||
@ -256,17 +270,6 @@ const SimplePropertyFilter = ({
|
|||||||
emitChange([...hiddenSelected, ...visibleCheckedKeys])
|
emitChange([...hiddenSelected, ...visibleCheckedKeys])
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!loading && options.length === 0) {
|
|
||||||
return (
|
|
||||||
<MissingPlaceholder
|
|
||||||
message='No options found.'
|
|
||||||
hasBackground={false}
|
|
||||||
hasBorder={false}
|
|
||||||
padding='0'
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDateTimeProperty(property)) {
|
if (isDateTimeProperty(property)) {
|
||||||
return (
|
return (
|
||||||
<SimpleDateTimePropertyFilter
|
<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 (
|
return (
|
||||||
<Spin spinning={loading} indicator={<LoadingOutlined spin />}>
|
<Spin spinning={loading} indicator={<LoadingOutlined spin />}>
|
||||||
<Checkbox.Group
|
<Checkbox.Group
|
||||||
|
|||||||
@ -1,9 +1,23 @@
|
|||||||
import PropTypes from 'prop-types'
|
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 SortCircleFilledIcon from '../../Icons/SortCircleFilledIcon'
|
||||||
import SortCircleIcon from '../../Icons/SortCircleIcon'
|
import SortCircleIcon from '../../Icons/SortCircleIcon'
|
||||||
|
import { useTableState } from '../context/TableStateContext'
|
||||||
|
|
||||||
const SortSidebarButton = ({ active, onClick, ...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
|
<Button
|
||||||
icon={
|
icon={
|
||||||
active ? (
|
active ? (
|
||||||
@ -17,7 +31,9 @@ const SortSidebarButton = ({ active, onClick, ...buttonProps }) => (
|
|||||||
title={active ? 'Hide sort sidebar' : 'Show sort sidebar'}
|
title={active ? 'Hide sort sidebar' : 'Show sort sidebar'}
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
/>
|
/>
|
||||||
)
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
SortSidebarButton.propTypes = {
|
SortSidebarButton.propTypes = {
|
||||||
active: PropTypes.bool.isRequired,
|
active: PropTypes.bool.isRequired,
|
||||||
|
|||||||
@ -195,7 +195,7 @@ const WizardView = ({
|
|||||||
|
|
||||||
{showButtons && (
|
{showButtons && (
|
||||||
<NewObjectButtons
|
<NewObjectButtons
|
||||||
disabled={disabled}
|
disabled={disabled || loading}
|
||||||
currentStep={currentStep}
|
currentStep={currentStep}
|
||||||
totalSteps={steps.length}
|
totalSteps={steps.length}
|
||||||
onPrevious={() => setCurrentStep(currentStep - 1)}
|
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 }) => {
|
export const TableStateProvider = ({ children }) => {
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const [pageFilters, setPageFilters] = useState({})
|
const [pageFilters, setPageFilters] = useState({})
|
||||||
|
const [pageSorters, setPageSorters] = useState({})
|
||||||
|
|
||||||
const getPersistedFilter = useCallback(
|
const getPersistedFilter = useCallback(
|
||||||
(scope, { useFilterInUrl = false, useFilterInSession = false } = {}) => {
|
(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(
|
const hasPageFilter = useCallback(
|
||||||
(path) => Object.keys(pageFilters[path] || {}).length > 0,
|
(path) => Object.keys(pageFilters[path] || {}).length > 0,
|
||||||
[pageFilters]
|
[pageFilters]
|
||||||
@ -246,9 +260,11 @@ export const TableStateProvider = ({ children }) => {
|
|||||||
persistSort,
|
persistSort,
|
||||||
persistTableState,
|
persistTableState,
|
||||||
setPageFilter,
|
setPageFilter,
|
||||||
|
setPageSorter,
|
||||||
hasPageFilter,
|
hasPageFilter,
|
||||||
hasStoredFilter,
|
hasStoredFilter,
|
||||||
pageFilters
|
pageFilters,
|
||||||
|
pageSorters
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
getPersistedFilter,
|
getPersistedFilter,
|
||||||
@ -257,9 +273,11 @@ export const TableStateProvider = ({ children }) => {
|
|||||||
persistSort,
|
persistSort,
|
||||||
persistTableState,
|
persistTableState,
|
||||||
setPageFilter,
|
setPageFilter,
|
||||||
|
setPageSorter,
|
||||||
hasPageFilter,
|
hasPageFilter,
|
||||||
hasStoredFilter,
|
hasStoredFilter,
|
||||||
pageFilters
|
pageFilters,
|
||||||
|
pageSorters
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -298,7 +316,8 @@ export const useTableStatePersistence = ({
|
|||||||
persistFilter: persistFilterCtx,
|
persistFilter: persistFilterCtx,
|
||||||
persistSort: persistSortCtx,
|
persistSort: persistSortCtx,
|
||||||
persistTableState: persistTableStateCtx,
|
persistTableState: persistTableStateCtx,
|
||||||
setPageFilter
|
setPageFilter,
|
||||||
|
setPageSorter
|
||||||
} = useTableState()
|
} = useTableState()
|
||||||
|
|
||||||
const readOptions = useMemo(
|
const readOptions = useMemo(
|
||||||
@ -351,6 +370,15 @@ export const useTableStatePersistence = ({
|
|||||||
[pagePath, setPageFilter]
|
[pagePath, setPageFilter]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const registerPageSorter = useCallback(
|
||||||
|
(sorter) => {
|
||||||
|
if (pagePath) {
|
||||||
|
setPageSorter(pagePath, sorter)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[pagePath, setPageSorter]
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getPersistedFilter,
|
getPersistedFilter,
|
||||||
getPersistedSorter,
|
getPersistedSorter,
|
||||||
@ -358,6 +386,7 @@ export const useTableStatePersistence = ({
|
|||||||
persistSort,
|
persistSort,
|
||||||
persistTableState,
|
persistTableState,
|
||||||
registerPageFilter,
|
registerPageFilter,
|
||||||
|
registerPageSorter,
|
||||||
getActiveFilterValues
|
getActiveFilterValues
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -144,3 +144,137 @@ const stripProperties = (data, properties) => {
|
|||||||
export function stripNestedObjectProperties(data, modelDefinition) {
|
export function stripNestedObjectProperties(data, modelDefinition) {
|
||||||
return stripProperties(data, modelDefinition?.properties)
|
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',
|
'deliveryTime',
|
||||||
'cost',
|
'cost',
|
||||||
'costWithTax',
|
'costWithTax',
|
||||||
|
'additionalCost',
|
||||||
|
'additionalCostWithTax',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
'_reference'
|
'_reference'
|
||||||
@ -140,8 +142,11 @@ export const CourierService = {
|
|||||||
'courier',
|
'courier',
|
||||||
'active',
|
'active',
|
||||||
'tracked',
|
'tracked',
|
||||||
|
'deliveryTime',
|
||||||
'cost',
|
'cost',
|
||||||
'costWithTax',
|
'costWithTax',
|
||||||
|
'additionalCost',
|
||||||
|
'additionalCostWithTax',
|
||||||
'estimatedDeliveryTime',
|
'estimatedDeliveryTime',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'_id',
|
'_id',
|
||||||
|
|||||||
@ -103,6 +103,7 @@ export const DocumentPrinter = {
|
|||||||
'active',
|
'active',
|
||||||
'isGlobal',
|
'isGlobal',
|
||||||
'state',
|
'state',
|
||||||
|
'connection.port',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
'_reference'
|
'_reference'
|
||||||
@ -111,6 +112,7 @@ export const DocumentPrinter = {
|
|||||||
'name',
|
'name',
|
||||||
'documentSize',
|
'documentSize',
|
||||||
'connectedAt',
|
'connectedAt',
|
||||||
|
'connection.port',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
'state',
|
'state',
|
||||||
'createdAt'
|
'createdAt'
|
||||||
|
|||||||
@ -135,7 +135,10 @@ export const Filament = {
|
|||||||
'material',
|
'material',
|
||||||
'diameter',
|
'diameter',
|
||||||
'name',
|
'name',
|
||||||
|
'density',
|
||||||
|
'emptySpoolWeight',
|
||||||
'cost',
|
'cost',
|
||||||
|
'costWithTax',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
'_reference'
|
'_reference'
|
||||||
@ -145,6 +148,9 @@ export const Filament = {
|
|||||||
'createdAt',
|
'createdAt',
|
||||||
'vendor',
|
'vendor',
|
||||||
'material',
|
'material',
|
||||||
|
'diameter',
|
||||||
|
'density',
|
||||||
|
'emptySpoolWeight',
|
||||||
'cost',
|
'cost',
|
||||||
'costWithTax',
|
'costWithTax',
|
||||||
'updatedAt'
|
'updatedAt'
|
||||||
|
|||||||
@ -5,11 +5,23 @@ const FilamentStockInfo = lazy(
|
|||||||
import('../../components/Dashboard/Inventory/FilamentStocks/FilamentStockInfo')
|
import('../../components/Dashboard/Inventory/FilamentStocks/FilamentStockInfo')
|
||||||
)
|
)
|
||||||
const NewFilamentStock = lazy(
|
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 FilamentStockIcon from '../../components/Icons/FilamentStockIcon'
|
||||||
import PlusIcon from '../../components/Icons/PlusIcon'
|
import PlusIcon from '../../components/Icons/PlusIcon'
|
||||||
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
|
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'
|
import ListIcon from '../../components/Icons/ListIcon'
|
||||||
|
|
||||||
export const FilamentStock = {
|
export const FilamentStock = {
|
||||||
@ -18,7 +30,6 @@ export const FilamentStock = {
|
|||||||
labelPlural: 'Filament Stocks',
|
labelPlural: 'Filament Stocks',
|
||||||
url: '/dashboard/inventory/filamentstocks',
|
url: '/dashboard/inventory/filamentstocks',
|
||||||
prefix: 'FLS',
|
prefix: 'FLS',
|
||||||
readOnly: true,
|
|
||||||
icon: FilamentStockIcon,
|
icon: FilamentStockIcon,
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
@ -29,7 +40,11 @@ export const FilamentStock = {
|
|||||||
label: 'New Filament Stock',
|
label: 'New Filament Stock',
|
||||||
icon: PlusIcon,
|
icon: PlusIcon,
|
||||||
content: (objectData, { onOk } = {}) => {
|
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,
|
default: true,
|
||||||
row: true,
|
row: true,
|
||||||
icon: InfoCircleIcon
|
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: [
|
pages: [
|
||||||
@ -121,6 +206,13 @@ export const FilamentStock = {
|
|||||||
readOnly: true,
|
readOnly: true,
|
||||||
columnWidth: 260
|
columnWidth: 260
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'postedAt',
|
||||||
|
label: 'Posted At',
|
||||||
|
type: 'dateTime',
|
||||||
|
readOnly: true,
|
||||||
|
columnWidth: 175
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'filament',
|
name: 'filament',
|
||||||
label: 'Filament',
|
label: 'Filament',
|
||||||
@ -142,6 +234,9 @@ export const FilamentStock = {
|
|||||||
required: true,
|
required: true,
|
||||||
showHyperlink: true,
|
showHyperlink: true,
|
||||||
columnWidth: 200,
|
columnWidth: 200,
|
||||||
|
disabled: (objectData) => {
|
||||||
|
return !objectData?.filament
|
||||||
|
},
|
||||||
masterFilter: (objectData) => {
|
masterFilter: (objectData) => {
|
||||||
return {
|
return {
|
||||||
filament: objectData?.filament?._id
|
filament: objectData?.filament?._id
|
||||||
@ -166,10 +261,13 @@ export const FilamentStock = {
|
|||||||
required: true,
|
required: true,
|
||||||
columnWidth: 300,
|
columnWidth: 300,
|
||||||
value: (objectData) => {
|
value: (objectData) => {
|
||||||
if (objectData?.state?.type === 'unconsumed') {
|
if (
|
||||||
return objectData?.startingWeight
|
objectData?.state?.type === 'unconsumed' ||
|
||||||
|
objectData?.state?.type === 'draft'
|
||||||
|
) {
|
||||||
|
return objectData?.startingWeight || { net: 0, gross: 0 }
|
||||||
} else {
|
} else {
|
||||||
return objectData.currentWeight
|
return objectData?.currentWeight || { net: 0, gross: 0 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -178,7 +276,6 @@ export const FilamentStock = {
|
|||||||
label: 'Starting Weight',
|
label: 'Starting Weight',
|
||||||
type: 'netGross',
|
type: 'netGross',
|
||||||
suffix: 'g',
|
suffix: 'g',
|
||||||
readOnly: true,
|
|
||||||
initial: true,
|
initial: true,
|
||||||
required: true,
|
required: true,
|
||||||
columnWidth: 300,
|
columnWidth: 300,
|
||||||
@ -188,6 +285,12 @@ export const FilamentStock = {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
stats: [
|
stats: [
|
||||||
|
{
|
||||||
|
name: 'draft.count',
|
||||||
|
label: 'Draft',
|
||||||
|
type: 'number',
|
||||||
|
color: 'default'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'unconsumed.count',
|
name: 'unconsumed.count',
|
||||||
label: 'Unconsumed',
|
label: 'Unconsumed',
|
||||||
|
|||||||
@ -113,6 +113,11 @@ export const Part = {
|
|||||||
'product._id',
|
'product._id',
|
||||||
'_id',
|
'_id',
|
||||||
'name',
|
'name',
|
||||||
|
'cost',
|
||||||
|
'costWithTax',
|
||||||
|
'price',
|
||||||
|
'margin',
|
||||||
|
'priceWithTax',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
'_reference'
|
'_reference'
|
||||||
@ -123,6 +128,7 @@ export const Part = {
|
|||||||
'cost',
|
'cost',
|
||||||
'costWithTax',
|
'costWithTax',
|
||||||
'price',
|
'price',
|
||||||
|
'margin',
|
||||||
'priceWithTax',
|
'priceWithTax',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
|
|||||||
@ -124,7 +124,10 @@ export const PartSku = {
|
|||||||
'part',
|
'part',
|
||||||
'name',
|
'name',
|
||||||
'cost',
|
'cost',
|
||||||
|
'costWithTax',
|
||||||
'price',
|
'price',
|
||||||
|
'priceWithTax',
|
||||||
|
'margin',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
'_reference'
|
'_reference'
|
||||||
@ -137,6 +140,7 @@ export const PartSku = {
|
|||||||
'costWithTax',
|
'costWithTax',
|
||||||
'price',
|
'price',
|
||||||
'priceWithTax',
|
'priceWithTax',
|
||||||
|
'margin',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedAt'
|
'updatedAt'
|
||||||
],
|
],
|
||||||
|
|||||||
@ -232,6 +232,9 @@ export const PartStock = {
|
|||||||
required: true,
|
required: true,
|
||||||
showHyperlink: true,
|
showHyperlink: true,
|
||||||
columnWidth: 200,
|
columnWidth: 200,
|
||||||
|
disabled: (objectData) => {
|
||||||
|
return !objectData?.part
|
||||||
|
},
|
||||||
masterFilter: (objectData) => {
|
masterFilter: (objectData) => {
|
||||||
return { part: objectData?.part?._id }
|
return { part: objectData?.part?._id }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -118,6 +118,11 @@ export const Product = {
|
|||||||
'name',
|
'name',
|
||||||
'globalPrice',
|
'globalPrice',
|
||||||
'productCategory',
|
'productCategory',
|
||||||
|
'cost',
|
||||||
|
'costWithTax',
|
||||||
|
'price',
|
||||||
|
'margin',
|
||||||
|
'priceWithTax',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
'_reference'
|
'_reference'
|
||||||
@ -131,6 +136,7 @@ export const Product = {
|
|||||||
'cost',
|
'cost',
|
||||||
'costWithTax',
|
'costWithTax',
|
||||||
'price',
|
'price',
|
||||||
|
'margin',
|
||||||
'priceWithTax',
|
'priceWithTax',
|
||||||
'updatedAt'
|
'updatedAt'
|
||||||
],
|
],
|
||||||
|
|||||||
@ -124,7 +124,10 @@ export const ProductSku = {
|
|||||||
'product',
|
'product',
|
||||||
'name',
|
'name',
|
||||||
'cost',
|
'cost',
|
||||||
|
'costWithTax',
|
||||||
'price',
|
'price',
|
||||||
|
'priceWithTax',
|
||||||
|
'margin',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
'_reference'
|
'_reference'
|
||||||
@ -137,6 +140,7 @@ export const ProductSku = {
|
|||||||
'costWithTax',
|
'costWithTax',
|
||||||
'price',
|
'price',
|
||||||
'priceWithTax',
|
'priceWithTax',
|
||||||
|
'margin',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedAt'
|
'updatedAt'
|
||||||
],
|
],
|
||||||
|
|||||||
@ -238,6 +238,9 @@ export const ProductStock = {
|
|||||||
required: true,
|
required: true,
|
||||||
showHyperlink: true,
|
showHyperlink: true,
|
||||||
columnWidth: 200,
|
columnWidth: 200,
|
||||||
|
disabled: (objectData) => {
|
||||||
|
return !objectData?.product
|
||||||
|
},
|
||||||
masterFilter: (objectData) => {
|
masterFilter: (objectData) => {
|
||||||
return { product: objectData?.product?._id }
|
return { product: objectData?.product?._id }
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user