Enhance styling and functionality of dashboard components
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Added new CSS variables for padding in ScrollBox to improve layout. - Updated ObjectForm to include console logs for debugging during editing cancellation. - Refactored ObjectInfo to use mergeWith for better array handling in state updates. - Enhanced ObjectProperty and ObjectTable components to support new props for better integration with tags. - Improved TagsDisplay and TagsInput components for better tag management and display, including loading states and dynamic options fetching. - Introduced getModelPropertyValues function in ApiServerContext for fetching model-specific property values, enhancing data handling capabilities.
This commit is contained in:
parent
a6fce60d04
commit
b16cdd6e96
@ -139,6 +139,8 @@
|
||||
|
||||
:root {
|
||||
--unit-100vh: 100vh;
|
||||
--scrollbox-vertical-right-padding: 16px;
|
||||
--scrollbox-horizontal-bottom-padding: 16px;
|
||||
}
|
||||
@supports (height: 100dvh) {
|
||||
:root {
|
||||
@ -352,15 +354,36 @@ body {
|
||||
margin-left: 1px !important;
|
||||
}
|
||||
|
||||
.tags-input.ant-select-multiple .ant-select-selection-item,
|
||||
.tags-input.ant-tree-select.ant-select-multiple .ant-select-selection-item {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
margin-inline-end: 0 !important;
|
||||
height: auto !important;
|
||||
line-height: normal !important;
|
||||
}
|
||||
|
||||
.tags-input.ant-select-multiple .ant-select-selection-item-remove,
|
||||
.tags-input.ant-tree-select.ant-select-multiple
|
||||
.ant-select-selection-item-remove {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-badge.ant-badge-status {
|
||||
line-height: 18.5px;
|
||||
}
|
||||
|
||||
.simplebar-track.simplebar-vertical {
|
||||
right: -16px;
|
||||
right: calc(-1 * var(--scrollbox-vertical-right-padding));
|
||||
width: 8px !important;
|
||||
}
|
||||
|
||||
.simplebar-track.simplebar-horizontal {
|
||||
bottom: calc(-1 * var(--scrollbox-horizontal-bottom-padding));
|
||||
height: 8px !important;
|
||||
}
|
||||
|
||||
.simplebar-scrollbar:before {
|
||||
background: #78787854 !important;
|
||||
}
|
||||
|
||||
@ -402,6 +402,8 @@ const ObjectForm = forwardRef(
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
console.log('cancelEditing')
|
||||
|
||||
if (serverObjectData.current) {
|
||||
// Recalculate computed values when canceling
|
||||
const computedEntries = calculateComputedValues(
|
||||
@ -417,6 +419,7 @@ const ObjectForm = forwardRef(
|
||||
setIsEditing(false)
|
||||
isEditingRef.current = false
|
||||
form.setFieldsValue(resetFormData)
|
||||
console.log('resetFormData', resetFormData)
|
||||
setObjectData({ ...resetFormData, _isEditing: isEditingRef.current })
|
||||
}
|
||||
|
||||
|
||||
@ -4,7 +4,13 @@ import { LoadingOutlined } from '@ant-design/icons'
|
||||
import PropTypes from 'prop-types'
|
||||
import ObjectProperty from './ObjectProperty'
|
||||
import { getModelProperties } from '../../../database/ObjectModels'
|
||||
import merge from 'lodash/merge'
|
||||
import mergeWith from 'lodash/mergeWith'
|
||||
|
||||
const arrayReplaceCustomizer = (objValue, srcValue) => {
|
||||
if (Array.isArray(srcValue)) {
|
||||
return srcValue
|
||||
}
|
||||
}
|
||||
|
||||
const ObjectInfo = ({
|
||||
loading = false,
|
||||
@ -36,7 +42,9 @@ const ObjectInfo = ({
|
||||
const [combinedObjectData, setCombinedObjectData] = useState(objectData)
|
||||
|
||||
useEffect(() => {
|
||||
setCombinedObjectData((prev) => merge({}, prev, objectData))
|
||||
setCombinedObjectData((prev) =>
|
||||
mergeWith({}, prev, objectData, arrayReplaceCustomizer)
|
||||
)
|
||||
}, [objectData])
|
||||
|
||||
// If properties array is empty, show all properties
|
||||
@ -108,9 +116,8 @@ const ObjectInfo = ({
|
||||
parentData={parentData}
|
||||
showSince={true}
|
||||
useFormItem={isControlled ? false : objectPropertyProps.useFormItem}
|
||||
value={
|
||||
isControlled ? combinedObjectData?.[item.name] : undefined
|
||||
}
|
||||
value={isControlled ? combinedObjectData?.[item.name] : undefined}
|
||||
modelType={type}
|
||||
onChange={
|
||||
isControlled
|
||||
? (newVal) => onPropertyChange(item.name, newVal)
|
||||
|
||||
@ -83,6 +83,7 @@ const timeEditFormItemProps = {
|
||||
}
|
||||
|
||||
const ObjectProperty = ({
|
||||
modelType = 'unknown',
|
||||
type = 'text',
|
||||
prefix,
|
||||
size,
|
||||
@ -96,6 +97,7 @@ const ObjectProperty = ({
|
||||
formItemProps = {},
|
||||
required = false,
|
||||
name,
|
||||
inTable = false,
|
||||
label,
|
||||
showLabel = false,
|
||||
masterFilter = {},
|
||||
@ -567,7 +569,14 @@ const ObjectProperty = ({
|
||||
}
|
||||
case 'tags': {
|
||||
if (value != null || value?.length != 0) {
|
||||
return <TagsDisplay tags={value} />
|
||||
return (
|
||||
<TagsDisplay
|
||||
tags={value}
|
||||
propertyName={name}
|
||||
scrollHorizontal={inTable}
|
||||
objectType={objectType}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<Text type='secondary' {...textParams}>
|
||||
@ -930,7 +939,15 @@ const ObjectProperty = ({
|
||||
case 'objectList':
|
||||
return <ObjectSelect type={objectType} multiple {...inputProps} />
|
||||
case 'tags':
|
||||
return <TagsInput {...inputProps} />
|
||||
return (
|
||||
<TagsInput
|
||||
propertyName={name}
|
||||
modelType={modelType}
|
||||
disabled={disabled}
|
||||
placeholder={label}
|
||||
{...inputProps}
|
||||
/>
|
||||
)
|
||||
case 'address':
|
||||
return (
|
||||
<AddressDisplay
|
||||
@ -1012,6 +1029,7 @@ const ObjectProperty = ({
|
||||
|
||||
ObjectProperty.propTypes = {
|
||||
type: PropTypes.string.isRequired,
|
||||
modelType: PropTypes.string,
|
||||
value: PropTypes.oneOfType([PropTypes.any, PropTypes.func]),
|
||||
isEditing: PropTypes.bool,
|
||||
formItemProps: PropTypes.object,
|
||||
@ -1038,6 +1056,7 @@ ObjectProperty.propTypes = {
|
||||
showHyperlink: PropTypes.bool,
|
||||
options: PropTypes.array,
|
||||
showSince: PropTypes.bool,
|
||||
inTable: PropTypes.bool,
|
||||
loading: PropTypes.bool,
|
||||
rollups: PropTypes.arrayOf(PropTypes.object),
|
||||
canAddRemove: PropTypes.bool,
|
||||
|
||||
@ -977,6 +977,7 @@ const ObjectTable = forwardRef(
|
||||
<ObjectProperty
|
||||
{...prop}
|
||||
longId={false}
|
||||
inTable={true}
|
||||
objectData={record}
|
||||
isEditing={isEditing}
|
||||
/>
|
||||
|
||||
@ -2,9 +2,22 @@ import PropTypes from 'prop-types'
|
||||
import SimpleBar from 'simplebar-react'
|
||||
import 'simplebar-react/dist/simplebar.min.css'
|
||||
|
||||
const ScrollBox = ({ children, style, ...rest }) => {
|
||||
const ScrollBox = ({
|
||||
children,
|
||||
style,
|
||||
horizontalBottomPadding = 16,
|
||||
verticalRightPadding = 16,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ height: '100%', minHeight: '0' }}>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
minHeight: '0',
|
||||
'--scrollbox-vertical-right-padding': `${verticalRightPadding}px`,
|
||||
'--scrollbox-horizontal-bottom-padding': `${horizontalBottomPadding}px`
|
||||
}}
|
||||
>
|
||||
<SimpleBar style={{ height: '100%', ...style }} {...rest}>
|
||||
{children}
|
||||
</SimpleBar>
|
||||
@ -14,7 +27,9 @@ const ScrollBox = ({ children, style, ...rest }) => {
|
||||
|
||||
ScrollBox.propTypes = {
|
||||
children: PropTypes.node,
|
||||
style: PropTypes.object
|
||||
style: PropTypes.object,
|
||||
horizontalBottomPadding: PropTypes.number,
|
||||
verticalRightPadding: PropTypes.number
|
||||
}
|
||||
|
||||
export default ScrollBox
|
||||
|
||||
@ -1,15 +1,19 @@
|
||||
import { Tag, Space, Typography } from 'antd'
|
||||
import { Tag, Flex, Typography } from 'antd'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
const { Text } = Typography
|
||||
import { useEffect, useState } from 'react'
|
||||
import ScrollBox from './ScrollBox'
|
||||
|
||||
const TagsDisplay = ({ tags, style }) => {
|
||||
let tagArray = []
|
||||
const TagsDisplay = ({ tags, style, scrollHorizontal = false }) => {
|
||||
const [tagArray, setTagArray] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof tags === 'string') {
|
||||
tagArray = [tags]
|
||||
setTagArray([tags])
|
||||
} else if (Array.isArray(tags)) {
|
||||
tagArray = tags
|
||||
setTagArray(tags)
|
||||
}
|
||||
}, [tags])
|
||||
|
||||
if (
|
||||
!tagArray ||
|
||||
@ -19,20 +23,27 @@ const TagsDisplay = ({ tags, style }) => {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<Space size={'small'} wrap style={style}>
|
||||
const tagContents = (
|
||||
<Flex gap={'4px'} wrap={!scrollHorizontal} style={style} justify={'start'}>
|
||||
{tagArray.map((tag, index) => (
|
||||
<Tag key={index} color='blue' style={{ margin: 0 }}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</Flex>
|
||||
)
|
||||
|
||||
if (scrollHorizontal) {
|
||||
return <ScrollBox horizontalBottomPadding={10}>{tagContents}</ScrollBox>
|
||||
} else {
|
||||
return tagContents
|
||||
}
|
||||
}
|
||||
|
||||
TagsDisplay.propTypes = {
|
||||
tags: PropTypes.arrayOf(PropTypes.string),
|
||||
style: PropTypes.object
|
||||
style: PropTypes.object,
|
||||
scrollHorizontal: PropTypes.bool
|
||||
}
|
||||
|
||||
export default TagsDisplay
|
||||
|
||||
@ -1,56 +1,179 @@
|
||||
import { useState } from 'react'
|
||||
import { Space, Tag, Input, Button } from 'antd'
|
||||
import { useState, useEffect, useContext, useMemo } from 'react'
|
||||
import { Space, Button, TreeSelect, Tag } from 'antd'
|
||||
import PlusIcon from '../../Icons/PlusIcon'
|
||||
import PropTypes from 'prop-types'
|
||||
import { ApiServerContext } from '../context/ApiServerContext'
|
||||
import TagsDisplay from './TagsDisplay'
|
||||
|
||||
const TagsInput = ({ value = [], onChange }) => {
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const { SHOW_CHILD } = TreeSelect
|
||||
|
||||
const handleTagClose = (removedTag) => {
|
||||
const newTags = value.filter((tag) => tag !== removedTag)
|
||||
onChange && onChange(newTags)
|
||||
}
|
||||
const TagsInput = ({
|
||||
value = [],
|
||||
onChange,
|
||||
propertyName,
|
||||
modelType,
|
||||
placeholder = 'Select or create tags',
|
||||
disabled = false
|
||||
}) => {
|
||||
const { getModelPropertyValues } = useContext(ApiServerContext)
|
||||
const [options, setOptions] = useState([])
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [delayedLoading, setDelayedLoading] = useState(true)
|
||||
|
||||
const handleTagAdd = () => {
|
||||
const newTag = inputValue.trim()
|
||||
if (newTag && !value.includes(newTag)) {
|
||||
const newTags = [...value, newTag]
|
||||
onChange && onChange(newTags)
|
||||
setInputValue('')
|
||||
const tags = Array.isArray(value) ? value : []
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const loadOptions = async () => {
|
||||
if (!modelType || !propertyName) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const values = await getModelPropertyValues(modelType, propertyName)
|
||||
if (cancelled) return
|
||||
const unique = [
|
||||
...new Set(
|
||||
(values || []).filter(
|
||||
(v) => v != null && v !== '' && typeof v === 'string'
|
||||
)
|
||||
)
|
||||
]
|
||||
setOptions(unique.map((tag) => ({ label: tag, value: tag })))
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadOptions()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [modelType, propertyName, getModelPropertyValues])
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
const timer = setTimeout(() => setDelayedLoading(false), 100)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
setDelayedLoading(true)
|
||||
}, [loading])
|
||||
|
||||
const mergedOptions = useMemo(() => {
|
||||
const map = new Map(options.map((option) => [option.value, option]))
|
||||
tags.forEach((tag) => {
|
||||
if (tag != null && tag !== '' && !map.has(tag)) {
|
||||
map.set(tag, { label: tag, value: tag })
|
||||
}
|
||||
})
|
||||
return Array.from(map.values())
|
||||
}, [options, tags])
|
||||
|
||||
const treeData = useMemo(
|
||||
() =>
|
||||
mergedOptions.map((option) => ({
|
||||
title: (
|
||||
<div style={{ paddingTop: 1 }}>
|
||||
<TagsDisplay tags={[option.label]} />
|
||||
</div>
|
||||
),
|
||||
value: option.value,
|
||||
key: option.value,
|
||||
isLeaf: true
|
||||
})),
|
||||
[mergedOptions]
|
||||
)
|
||||
|
||||
const handleChange = (nextTags) => {
|
||||
onChange?.(nextTags)
|
||||
setSearchValue('')
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
const newTag = searchValue.trim()
|
||||
if (!newTag || tags.includes(newTag)) return
|
||||
onChange?.([...tags, newTag])
|
||||
setSearchValue('')
|
||||
}
|
||||
|
||||
const onInputKeyDown = (e) => {
|
||||
if (e.key !== 'Enter' || e.defaultPrevented) return
|
||||
const newTag = searchValue.trim()
|
||||
if (!newTag || tags.includes(newTag)) return
|
||||
const existsInOptions = mergedOptions.some(
|
||||
(option) => option.value === newTag
|
||||
)
|
||||
if (existsInOptions) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onChange?.([...tags, newTag])
|
||||
setSearchValue('')
|
||||
}
|
||||
|
||||
const tagRender = ({ label, value: tagValue, closable, onClose }) => (
|
||||
<Tag
|
||||
color='blue'
|
||||
closable={closable}
|
||||
onClose={onClose}
|
||||
style={{ marginInlineEnd: 4 }}
|
||||
>
|
||||
{typeof label === 'string' ? label : tagValue}
|
||||
</Tag>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space size={'small'} wrap style={{ marginBottom: 4, maxWidth: '300px' }}>
|
||||
{value.map((tag) => (
|
||||
<Tag
|
||||
key={tag}
|
||||
color='blue'
|
||||
closable
|
||||
onClose={() => handleTagClose(tag)}
|
||||
style={{ marginBottom: 12, marginRight: 0 }}
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
<Space.Compact block>
|
||||
<Input
|
||||
placeholder='Add new tag'
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onPressEnter={handleTagAdd}
|
||||
<div style={{ position: 'relative', flex: 1, width: '100%' }}>
|
||||
<TreeSelect
|
||||
multiple
|
||||
treeDataSimpleMode={false}
|
||||
treeDefaultExpandAll
|
||||
showCheckedStrategy={SHOW_CHILD}
|
||||
className='object-select-multiple tags-input'
|
||||
style={{ width: '100%', opacity: delayedLoading ? 0 : 1 }}
|
||||
placeholder={placeholder}
|
||||
value={tags}
|
||||
onChange={handleChange}
|
||||
treeData={treeData}
|
||||
showSearch
|
||||
treeNodeFilterProp='value'
|
||||
searchValue={searchValue}
|
||||
onSearch={setSearchValue}
|
||||
onInputKeyDown={onInputKeyDown}
|
||||
tagRender={tagRender}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{delayedLoading && (
|
||||
<TreeSelect
|
||||
disabled
|
||||
loading
|
||||
placeholder='Loading...'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
icon={<PlusIcon />}
|
||||
disabled={disabled || !searchValue.trim()}
|
||||
/>
|
||||
<Button onClick={handleTagAdd} icon={<PlusIcon />} />
|
||||
</Space.Compact>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
TagsInput.propTypes = {
|
||||
value: PropTypes.arrayOf(PropTypes.string),
|
||||
onChange: PropTypes.func
|
||||
onChange: PropTypes.func,
|
||||
propertyName: PropTypes.string,
|
||||
modelType: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
placeholder: PropTypes.string
|
||||
}
|
||||
|
||||
export default TagsInput
|
||||
|
||||
@ -1403,6 +1403,30 @@ const ApiServerProvider = ({ children }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const getModelPropertyValues = async (objectType, property) => {
|
||||
logger.debug('Fetching property values for model type:', objectType, property)
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${config.backendUrl}/${getObjectEndpoint(objectType)}/values`,
|
||||
{
|
||||
params: { property },
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
)
|
||||
logger.debug('Fetched property values for model type:', objectType, property)
|
||||
return Array.isArray(response.data) ? response.data : []
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
showError(err, () => {
|
||||
getModelPropertyValues(objectType, property)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const getModelHistory = async (objectType, startDate, endDate) => {
|
||||
logger.debug('Fetching history for model type:', objectType)
|
||||
const encodedStartDate = encodeURIComponent(startDate.toISOString())
|
||||
@ -1967,6 +1991,7 @@ const ApiServerProvider = ({ children }) => {
|
||||
searchObjects,
|
||||
fetchSpotlightData,
|
||||
getModelStats,
|
||||
getModelPropertyValues,
|
||||
getModelHistory,
|
||||
fetchLoading,
|
||||
showError,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user