From b16cdd6e96a6e14d7e2f36140f5c7244f6bf2362 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Fri, 24 Jul 2026 23:20:00 +0100 Subject: [PATCH] Enhance styling and functionality of dashboard components - 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. --- assets/stylesheets/App.css | 25 ++- .../Dashboard/common/ObjectForm.jsx | 3 + .../Dashboard/common/ObjectInfo.jsx | 17 +- .../Dashboard/common/ObjectProperty.jsx | 23 +- .../Dashboard/common/ObjectTable.jsx | 1 + src/components/Dashboard/common/ScrollBox.jsx | 21 +- .../Dashboard/common/TagsDisplay.jsx | 37 ++-- src/components/Dashboard/common/TagsInput.jsx | 199 ++++++++++++++---- .../Dashboard/context/ApiServerContext.jsx | 25 +++ 9 files changed, 289 insertions(+), 62 deletions(-) diff --git a/assets/stylesheets/App.css b/assets/stylesheets/App.css index 96c77ee..85191b3 100644 --- a/assets/stylesheets/App.css +++ b/assets/stylesheets/App.css @@ -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; } diff --git a/src/components/Dashboard/common/ObjectForm.jsx b/src/components/Dashboard/common/ObjectForm.jsx index 4dab3f7..e3750a6 100644 --- a/src/components/Dashboard/common/ObjectForm.jsx +++ b/src/components/Dashboard/common/ObjectForm.jsx @@ -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 }) } diff --git a/src/components/Dashboard/common/ObjectInfo.jsx b/src/components/Dashboard/common/ObjectInfo.jsx index 2699bb8..f80c521 100644 --- a/src/components/Dashboard/common/ObjectInfo.jsx +++ b/src/components/Dashboard/common/ObjectInfo.jsx @@ -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) diff --git a/src/components/Dashboard/common/ObjectProperty.jsx b/src/components/Dashboard/common/ObjectProperty.jsx index 657e903..da9378b 100644 --- a/src/components/Dashboard/common/ObjectProperty.jsx +++ b/src/components/Dashboard/common/ObjectProperty.jsx @@ -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 + return ( + + ) } else { return ( @@ -930,7 +939,15 @@ const ObjectProperty = ({ case 'objectList': return case 'tags': - return + return ( + + ) case 'address': return ( diff --git a/src/components/Dashboard/common/ScrollBox.jsx b/src/components/Dashboard/common/ScrollBox.jsx index b738f11..3c66096 100644 --- a/src/components/Dashboard/common/ScrollBox.jsx +++ b/src/components/Dashboard/common/ScrollBox.jsx @@ -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 ( -
+
{children} @@ -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 diff --git a/src/components/Dashboard/common/TagsDisplay.jsx b/src/components/Dashboard/common/TagsDisplay.jsx index 7e27ee6..87ae800 100644 --- a/src/components/Dashboard/common/TagsDisplay.jsx +++ b/src/components/Dashboard/common/TagsDisplay.jsx @@ -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 = [] - if (typeof tags === 'string') { - tagArray = [tags] - } else if (Array.isArray(tags)) { - tagArray = tags - } +const TagsDisplay = ({ tags, style, scrollHorizontal = false }) => { + const [tagArray, setTagArray] = useState([]) + + useEffect(() => { + if (typeof tags === 'string') { + setTagArray([tags]) + } else if (Array.isArray(tags)) { + setTagArray(tags) + } + }, [tags]) if ( !tagArray || @@ -19,20 +23,27 @@ const TagsDisplay = ({ tags, style }) => { return n/a } - return ( - + const tagContents = ( + {tagArray.map((tag, index) => ( {tag} ))} - + ) + + if (scrollHorizontal) { + return {tagContents} + } else { + return tagContents + } } TagsDisplay.propTypes = { tags: PropTypes.arrayOf(PropTypes.string), - style: PropTypes.object + style: PropTypes.object, + scrollHorizontal: PropTypes.bool } export default TagsDisplay diff --git a/src/components/Dashboard/common/TagsInput.jsx b/src/components/Dashboard/common/TagsInput.jsx index 4f50808..7a0be0d 100644 --- a/src/components/Dashboard/common/TagsInput.jsx +++ b/src/components/Dashboard/common/TagsInput.jsx @@ -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: ( +
+ +
+ ), + 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 }) => ( + + {typeof label === 'string' ? label : tagValue} + + ) + return ( - <> - - {value.map((tag) => ( - handleTagClose(tag)} - style={{ marginBottom: 12, marginRight: 0 }} - > - {tag} - - ))} - - - setInputValue(e.target.value)} - onPressEnter={handleTagAdd} + +
+ -
+