Enhance styling and functionality of dashboard components
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:
Tom Butcher 2026-07-24 23:20:00 +01:00
parent a6fce60d04
commit b16cdd6e96
9 changed files with 289 additions and 62 deletions

View File

@ -139,6 +139,8 @@
:root { :root {
--unit-100vh: 100vh; --unit-100vh: 100vh;
--scrollbox-vertical-right-padding: 16px;
--scrollbox-horizontal-bottom-padding: 16px;
} }
@supports (height: 100dvh) { @supports (height: 100dvh) {
:root { :root {
@ -352,15 +354,36 @@ body {
margin-left: 1px !important; 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 { .ant-badge.ant-badge-status {
line-height: 18.5px; line-height: 18.5px;
} }
.simplebar-track.simplebar-vertical { .simplebar-track.simplebar-vertical {
right: -16px; right: calc(-1 * var(--scrollbox-vertical-right-padding));
width: 8px !important; width: 8px !important;
} }
.simplebar-track.simplebar-horizontal {
bottom: calc(-1 * var(--scrollbox-horizontal-bottom-padding));
height: 8px !important;
}
.simplebar-scrollbar:before { .simplebar-scrollbar:before {
background: #78787854 !important; background: #78787854 !important;
} }

View File

@ -402,6 +402,8 @@ const ObjectForm = forwardRef(
} }
const cancelEditing = () => { const cancelEditing = () => {
console.log('cancelEditing')
if (serverObjectData.current) { if (serverObjectData.current) {
// Recalculate computed values when canceling // Recalculate computed values when canceling
const computedEntries = calculateComputedValues( const computedEntries = calculateComputedValues(
@ -417,6 +419,7 @@ const ObjectForm = forwardRef(
setIsEditing(false) setIsEditing(false)
isEditingRef.current = false isEditingRef.current = false
form.setFieldsValue(resetFormData) form.setFieldsValue(resetFormData)
console.log('resetFormData', resetFormData)
setObjectData({ ...resetFormData, _isEditing: isEditingRef.current }) setObjectData({ ...resetFormData, _isEditing: isEditingRef.current })
} }

View File

@ -4,7 +4,13 @@ import { LoadingOutlined } from '@ant-design/icons'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import ObjectProperty from './ObjectProperty' import ObjectProperty from './ObjectProperty'
import { getModelProperties } from '../../../database/ObjectModels' 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 = ({ const ObjectInfo = ({
loading = false, loading = false,
@ -36,7 +42,9 @@ const ObjectInfo = ({
const [combinedObjectData, setCombinedObjectData] = useState(objectData) const [combinedObjectData, setCombinedObjectData] = useState(objectData)
useEffect(() => { useEffect(() => {
setCombinedObjectData((prev) => merge({}, prev, objectData)) setCombinedObjectData((prev) =>
mergeWith({}, prev, objectData, arrayReplaceCustomizer)
)
}, [objectData]) }, [objectData])
// If properties array is empty, show all properties // If properties array is empty, show all properties
@ -108,9 +116,8 @@ const ObjectInfo = ({
parentData={parentData} parentData={parentData}
showSince={true} showSince={true}
useFormItem={isControlled ? false : objectPropertyProps.useFormItem} useFormItem={isControlled ? false : objectPropertyProps.useFormItem}
value={ value={isControlled ? combinedObjectData?.[item.name] : undefined}
isControlled ? combinedObjectData?.[item.name] : undefined modelType={type}
}
onChange={ onChange={
isControlled isControlled
? (newVal) => onPropertyChange(item.name, newVal) ? (newVal) => onPropertyChange(item.name, newVal)

View File

@ -83,6 +83,7 @@ const timeEditFormItemProps = {
} }
const ObjectProperty = ({ const ObjectProperty = ({
modelType = 'unknown',
type = 'text', type = 'text',
prefix, prefix,
size, size,
@ -96,6 +97,7 @@ const ObjectProperty = ({
formItemProps = {}, formItemProps = {},
required = false, required = false,
name, name,
inTable = false,
label, label,
showLabel = false, showLabel = false,
masterFilter = {}, masterFilter = {},
@ -567,7 +569,14 @@ const ObjectProperty = ({
} }
case 'tags': { case 'tags': {
if (value != null || value?.length != 0) { if (value != null || value?.length != 0) {
return <TagsDisplay tags={value} /> return (
<TagsDisplay
tags={value}
propertyName={name}
scrollHorizontal={inTable}
objectType={objectType}
/>
)
} else { } else {
return ( return (
<Text type='secondary' {...textParams}> <Text type='secondary' {...textParams}>
@ -930,7 +939,15 @@ const ObjectProperty = ({
case 'objectList': case 'objectList':
return <ObjectSelect type={objectType} multiple {...inputProps} /> return <ObjectSelect type={objectType} multiple {...inputProps} />
case 'tags': case 'tags':
return <TagsInput {...inputProps} /> return (
<TagsInput
propertyName={name}
modelType={modelType}
disabled={disabled}
placeholder={label}
{...inputProps}
/>
)
case 'address': case 'address':
return ( return (
<AddressDisplay <AddressDisplay
@ -1012,6 +1029,7 @@ const ObjectProperty = ({
ObjectProperty.propTypes = { ObjectProperty.propTypes = {
type: PropTypes.string.isRequired, type: PropTypes.string.isRequired,
modelType: PropTypes.string,
value: PropTypes.oneOfType([PropTypes.any, PropTypes.func]), value: PropTypes.oneOfType([PropTypes.any, PropTypes.func]),
isEditing: PropTypes.bool, isEditing: PropTypes.bool,
formItemProps: PropTypes.object, formItemProps: PropTypes.object,
@ -1038,6 +1056,7 @@ ObjectProperty.propTypes = {
showHyperlink: PropTypes.bool, showHyperlink: PropTypes.bool,
options: PropTypes.array, options: PropTypes.array,
showSince: PropTypes.bool, showSince: PropTypes.bool,
inTable: PropTypes.bool,
loading: PropTypes.bool, loading: PropTypes.bool,
rollups: PropTypes.arrayOf(PropTypes.object), rollups: PropTypes.arrayOf(PropTypes.object),
canAddRemove: PropTypes.bool, canAddRemove: PropTypes.bool,

View File

@ -977,6 +977,7 @@ const ObjectTable = forwardRef(
<ObjectProperty <ObjectProperty
{...prop} {...prop}
longId={false} longId={false}
inTable={true}
objectData={record} objectData={record}
isEditing={isEditing} isEditing={isEditing}
/> />

View File

@ -2,9 +2,22 @@ import PropTypes from 'prop-types'
import SimpleBar from 'simplebar-react' import SimpleBar from 'simplebar-react'
import 'simplebar-react/dist/simplebar.min.css' import 'simplebar-react/dist/simplebar.min.css'
const ScrollBox = ({ children, style, ...rest }) => { const ScrollBox = ({
children,
style,
horizontalBottomPadding = 16,
verticalRightPadding = 16,
...rest
}) => {
return ( 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}> <SimpleBar style={{ height: '100%', ...style }} {...rest}>
{children} {children}
</SimpleBar> </SimpleBar>
@ -14,7 +27,9 @@ const ScrollBox = ({ children, style, ...rest }) => {
ScrollBox.propTypes = { ScrollBox.propTypes = {
children: PropTypes.node, children: PropTypes.node,
style: PropTypes.object style: PropTypes.object,
horizontalBottomPadding: PropTypes.number,
verticalRightPadding: PropTypes.number
} }
export default ScrollBox export default ScrollBox

View File

@ -1,15 +1,19 @@
import { Tag, Space, Typography } from 'antd' import { Tag, Flex, Typography } from 'antd'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
const { Text } = Typography const { Text } = Typography
import { useEffect, useState } from 'react'
import ScrollBox from './ScrollBox'
const TagsDisplay = ({ tags, style }) => { const TagsDisplay = ({ tags, style, scrollHorizontal = false }) => {
let tagArray = [] const [tagArray, setTagArray] = useState([])
if (typeof tags === 'string') {
tagArray = [tags] useEffect(() => {
} else if (Array.isArray(tags)) { if (typeof tags === 'string') {
tagArray = tags setTagArray([tags])
} } else if (Array.isArray(tags)) {
setTagArray(tags)
}
}, [tags])
if ( if (
!tagArray || !tagArray ||
@ -19,20 +23,27 @@ const TagsDisplay = ({ tags, style }) => {
return <Text type='secondary'>n/a</Text> return <Text type='secondary'>n/a</Text>
} }
return ( const tagContents = (
<Space size={'small'} wrap style={style}> <Flex gap={'4px'} wrap={!scrollHorizontal} style={style} justify={'start'}>
{tagArray.map((tag, index) => ( {tagArray.map((tag, index) => (
<Tag key={index} color='blue' style={{ margin: 0 }}> <Tag key={index} color='blue' style={{ margin: 0 }}>
{tag} {tag}
</Tag> </Tag>
))} ))}
</Space> </Flex>
) )
if (scrollHorizontal) {
return <ScrollBox horizontalBottomPadding={10}>{tagContents}</ScrollBox>
} else {
return tagContents
}
} }
TagsDisplay.propTypes = { TagsDisplay.propTypes = {
tags: PropTypes.arrayOf(PropTypes.string), tags: PropTypes.arrayOf(PropTypes.string),
style: PropTypes.object style: PropTypes.object,
scrollHorizontal: PropTypes.bool
} }
export default TagsDisplay export default TagsDisplay

View File

@ -1,56 +1,179 @@
import { useState } from 'react' import { useState, useEffect, useContext, useMemo } from 'react'
import { Space, Tag, Input, Button } from 'antd' import { Space, Button, TreeSelect, Tag } from 'antd'
import PlusIcon from '../../Icons/PlusIcon' import PlusIcon from '../../Icons/PlusIcon'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { ApiServerContext } from '../context/ApiServerContext'
import TagsDisplay from './TagsDisplay'
const TagsInput = ({ value = [], onChange }) => { const { SHOW_CHILD } = TreeSelect
const [inputValue, setInputValue] = useState('')
const handleTagClose = (removedTag) => { const TagsInput = ({
const newTags = value.filter((tag) => tag !== removedTag) value = [],
onChange && onChange(newTags) 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 tags = Array.isArray(value) ? value : []
const newTag = inputValue.trim()
if (newTag && !value.includes(newTag)) { useEffect(() => {
const newTags = [...value, newTag] let cancelled = false
onChange && onChange(newTags)
setInputValue('') 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 ( return (
<> <Space.Compact block>
<Space size={'small'} wrap style={{ marginBottom: 4, maxWidth: '300px' }}> <div style={{ position: 'relative', flex: 1, width: '100%' }}>
{value.map((tag) => ( <TreeSelect
<Tag multiple
key={tag} treeDataSimpleMode={false}
color='blue' treeDefaultExpandAll
closable showCheckedStrategy={SHOW_CHILD}
onClose={() => handleTagClose(tag)} className='object-select-multiple tags-input'
style={{ marginBottom: 12, marginRight: 0 }} style={{ width: '100%', opacity: delayedLoading ? 0 : 1 }}
> placeholder={placeholder}
{tag} value={tags}
</Tag> onChange={handleChange}
))} treeData={treeData}
</Space> showSearch
<Space.Compact block> treeNodeFilterProp='value'
<Input searchValue={searchValue}
placeholder='Add new tag' onSearch={setSearchValue}
value={inputValue} onInputKeyDown={onInputKeyDown}
onChange={(e) => setInputValue(e.target.value)} tagRender={tagRender}
onPressEnter={handleTagAdd} disabled={disabled}
/> />
<Button onClick={handleTagAdd} icon={<PlusIcon />} /> {delayedLoading && (
</Space.Compact> <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()}
/>
</Space.Compact>
) )
} }
TagsInput.propTypes = { TagsInput.propTypes = {
value: PropTypes.arrayOf(PropTypes.string), 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 export default TagsInput

View File

@ -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) => { const getModelHistory = async (objectType, startDate, endDate) => {
logger.debug('Fetching history for model type:', objectType) logger.debug('Fetching history for model type:', objectType)
const encodedStartDate = encodeURIComponent(startDate.toISOString()) const encodedStartDate = encodeURIComponent(startDate.toISOString())
@ -1967,6 +1991,7 @@ const ApiServerProvider = ({ children }) => {
searchObjects, searchObjects,
fetchSpotlightData, fetchSpotlightData,
getModelStats, getModelStats,
getModelPropertyValues,
getModelHistory, getModelHistory,
fetchLoading, fetchLoading,
showError, showError,