From 005ddb4dca7ede874d6bb7ef03ad1d9a14213213 Mon Sep 17 00:00:00 2001 From: Aries Date: Fri, 28 Mar 2025 11:45:17 +0800 Subject: [PATCH] optimize tag editor ui (#7647) * optimize tag editor ui * optimize selection by up/down key * optimize popover position * fix searched tree nodes folding --------- Co-authored-by: zhouwenxuan --- .../src/components/sf-table/utils/tree.js | 13 + .../cell-editors/tags-editor/index.css | 54 ++-- .../cell-editors/tags-editor/index.js | 276 ++++++++++++++---- .../tags-editor/tag-item/index.css | 42 +++ .../tags-editor/tag-item/index.js | 74 +++++ .../detail-editor/tags-editor/index.js | 21 +- 6 files changed, 401 insertions(+), 79 deletions(-) create mode 100644 frontend/src/metadata/components/cell-editors/tags-editor/tag-item/index.css create mode 100644 frontend/src/metadata/components/cell-editors/tags-editor/tag-item/index.js diff --git a/frontend/src/components/sf-table/utils/tree.js b/frontend/src/components/sf-table/utils/tree.js index 22b33124de..abafe44162 100644 --- a/frontend/src/components/sf-table/utils/tree.js +++ b/frontend/src/components/sf-table/utils/tree.js @@ -185,3 +185,16 @@ export const getTreeChildNodes = (parentNode, tree) => { } return childNodes; }; + +export const getNodesWithAncestors = (node, tree) => { + const nodeKey = getTreeNodeKey(node); + + let nodesWithAncestors = []; + tree.forEach((node, i) => { + if (!nodeKey.includes(getTreeNodeKey(node))) { + return; + } + nodesWithAncestors.push({ ...node, node_index: i }); + }); + return nodesWithAncestors; +}; diff --git a/frontend/src/metadata/components/cell-editors/tags-editor/index.css b/frontend/src/metadata/components/cell-editors/tags-editor/index.css index f9651cd6d8..08f927a2f3 100644 --- a/frontend/src/metadata/components/cell-editors/tags-editor/index.css +++ b/frontend/src/metadata/components/cell-editors/tags-editor/index.css @@ -16,11 +16,21 @@ padding: 10px 10px 0; } +.sf-metadata-tags-editor.tags-tree-container .sf-metadata-search-tags-container { + padding: 16px 16px 0; +} + .sf-metadata-tags-editor .sf-metadata-search-tags-container .sf-metadata-search-tags { font-size: 14px; max-height: 30px; } +.sf-metadata-tags-editor .sf-metadata-search-tags-container .search-control { + position: absolute; + top: 24px; + right: 26px; +} + .sf-metadata-tags-editor .sf-metadata-tags-editor-container { max-height: 200px; min-height: 100px; @@ -28,20 +38,27 @@ padding: 10px; } +.sf-metadata-tags-editor.tags-tree-container .sf-metadata-tags-editor-container { + max-height: 280px; + padding: 10px 0; +} + +.sf-metadata-tags-editor.tags-tree-container .sf-metadata-tags-editor-container .sf-metadata-tags-editor-tag-item { + padding: 0 16px; +} + .sf-metadata-tags-editor .sf-metadata-tags-editor-container .none-search-result { font-size: 14px; opacity: 0.5; display: inline-block; } -.sf-metadata-tags-editor .sf-metadata-tags-editor-tag-container { - align-items: center; - border-radius: 2px; - color: #212529; +.sf-metadata-tags-editor .sf-metadata-tags-editor-container .sf-metadata-tags-editor-title { + height: 32px; display: flex; - font-size: 13px; - height: 30px; - width: 100%; + align-items: center; + padding: 0 16px; + color: #666; } .sf-metadata-tags-editor .sf-metadata-tags-editor-tag-container-highlight { @@ -54,23 +71,10 @@ width: 20px; } -.sf-metadata-tag-color-and-name { - display: flex; - align-items: center; - flex: 1; -} - -.sf-metadata-tag-color-and-name .sf-metadata-tag-color { - height: 12px; - width: 12px; - border-radius: 50%; - flex-shrink: 0; -} - -.sf-metadata-tag-color-and-name .sf-metadata-tag-name { - flex: 1; - margin-left: 8px; +.sf-metadata-tags-editor .sf-metadata-tags-editor-divider { + border-top: 1px solid rgba(0, 40, 100, .12); + height: 0; + margin: 0.5rem 0; + opacity: 1; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } diff --git a/frontend/src/metadata/components/cell-editors/tags-editor/index.js b/frontend/src/metadata/components/cell-editors/tags-editor/index.js index 610db09de4..b389122a74 100644 --- a/frontend/src/metadata/components/cell-editors/tags-editor/index.js +++ b/frontend/src/metadata/components/cell-editors/tags-editor/index.js @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; import classnames from 'classnames'; import CommonAddTool from '../../../../components/common-add-tool'; import SearchInput from '../../../../components/search-input'; -import Icon from '../../../../components/icon'; import DeleteTags from './delete-tags'; import { Utils } from '../../../../utils/utils'; import { KeyCodes } from '../../../../constants'; @@ -14,9 +13,13 @@ import { getRecordIdFromRecord } from '../../../utils/cell'; import { getRowById } from '../../../../components/sf-table/utils/table'; import { SELECT_OPTION_COLORS } from '../../../constants'; import { PRIVATE_COLUMN_KEY as TAG_PRIVATE_COLUMN_KEY } from '../../../../tag/constants'; +import { checkIsTreeNodeShown, checkTreeNodeHasChildNodes, getNodesWithAncestors, getTreeNodeDepth, getTreeNodeId, getTreeNodeKey } from '../../../../components/sf-table/utils/tree'; +import TagItem from './tag-item'; import './index.css'; +const RECENTLY_USED_TAG_IDS = 'recently_used_tag_ids'; + const TagsEditor = forwardRef(({ height, column, @@ -25,6 +28,7 @@ const TagsEditor = forwardRef(({ editorPosition = { left: 0, top: 0 }, onPressTab, updateFileTags, + showTagsAsTree, }, ref) => { const { tagsData, addTag, context } = useTags(); @@ -33,12 +37,17 @@ const TagsEditor = forwardRef(({ const [value, setValue] = useState((oldValue || []).map(item => item.row_id).filter(item => getRowById(tagsData, item))); const [searchValue, setSearchValue] = useState(''); const [highlightIndex, setHighlightIndex] = useState(-1); + const [highlightNodeIndex, setHighlightNodeIndex] = useState(-1); const [maxItemNum, setMaxItemNum] = useState(0); + const [nodes, setNodes] = useState([]); + const [keyNodeFoldedMap, setKeyNodeFoldedMap] = useState({}); + const [recentlyUsed, setRecentlyUsed] = useState([]); const itemHeight = 30; const editorContainerRef = useRef(null); const editorRef = useRef(null); - const selectItemRef = useRef(null); + const canEditData = window.sfMetadataContext.canModifyColumnData(column); + const localStorage = window.sfMetadataContext.localStorage; const tags = useMemo(() => { if (!tagsData) return []; @@ -46,6 +55,7 @@ const TagsEditor = forwardRef(({ }, [tagsData]); const displayTags = useMemo(() => getTagsByNameOrColor(tags, searchValue), [searchValue, tags]); + const recentlyUsedTags = useMemo(() => recentlyUsed, [recentlyUsed]); const isShowCreateBtn = useMemo(() => { if (!canAddTag) return false; @@ -73,7 +83,16 @@ const TagsEditor = forwardRef(({ setValue(newValue); const recordId = getRecordIdFromRecord(record); updateFileTags([{ record_id: recordId, tags: newValue, old_tags: value }]); - }, [value, record, updateFileTags]); + + const ids = recentlyUsed.map(item => getTagId(item)); + if (ids.indexOf(tagId) > -1) return; + const tag = getRowById(tagsData, tagId); + const updated = [tag, ...recentlyUsed.filter(item => getTagId(item) !== tagId)].slice(0, 2); + setRecentlyUsed(updated); + + const newIds = updated.map(tag => getTagId(tag)); + localStorage.setItem(RECENTLY_USED_TAG_IDS, JSON.stringify(newIds)); + }, [value, record, tagsData, updateFileTags, recentlyUsed, localStorage]); const onDeleteTag = useCallback((tagId) => { const newValue = value.slice(0); @@ -86,14 +105,22 @@ const TagsEditor = forwardRef(({ updateFileTags([{ record_id: recordId, tags: newValue, old_tags: value }]); }, [value, record, updateFileTags]); - const onMenuMouseEnter = useCallback((highlightIndex) => { - setHighlightIndex(highlightIndex); + const onMenuMouseEnter = useCallback((i, id) => { + setHighlightIndex(i); }, []); - const onMenuMouseLeave = useCallback((index) => { + const onMenuMouseLeave = useCallback(() => { setHighlightIndex(-1); }, []); + const onTreeMenuMouseEnter = useCallback((i) => { + setHighlightNodeIndex(i); + }, []); + + const onTreeMenuMouseLeave = useCallback(() => { + setHighlightNodeIndex(-1); + }, []); + const createTag = useCallback((event) => { event && event.stopPropagation(); event && event.nativeEvent.stopImmediatePropagation(); @@ -115,18 +142,24 @@ const TagsEditor = forwardRef(({ const getMaxItemNum = useCallback(() => { let selectContainerStyle = getComputedStyle(editorContainerRef.current, null); - let selectItemStyle = getComputedStyle(selectItemRef.current, null); - let maxSelectItemNum = Math.floor(parseInt(selectContainerStyle.maxHeight) / parseInt(selectItemStyle.height)); + let maxSelectItemNum = Math.floor(parseInt(selectContainerStyle.maxHeight) / parseInt(itemHeight)); return maxSelectItemNum - 1; - }, [editorContainerRef, selectItemRef]); + }, [editorContainerRef]); const onEnter = useCallback((event) => { event.preventDefault(); let tag; - if (displayTags.length === 1) { - tag = displayTags[0]; - } else if (highlightIndex > -1) { - tag = displayTags[highlightIndex]; + if (showTagsAsTree) { + if (highlightNodeIndex > -1 && nodes[highlightNodeIndex]) { + const tagId = getTreeNodeId(nodes[highlightNodeIndex]); + tag = getRowById(tagsData, tagId); + } + } else { + if (displayTags.length === 1) { + tag = displayTags[0]; + } else if (highlightIndex > -1) { + tag = displayTags[highlightIndex]; + } } if (tag) { const newTagId = getTagId(tag); @@ -136,27 +169,54 @@ const TagsEditor = forwardRef(({ if (isShowCreateBtn) { createTag(); } - }, [displayTags, highlightIndex, isShowCreateBtn, onSelectTag, createTag]); + }, [displayTags, highlightIndex, isShowCreateBtn, onSelectTag, createTag, showTagsAsTree, tagsData, highlightNodeIndex, nodes]); + + const updateScroll = useCallback((index) => { + const visibleStart = Math.floor(editorContainerRef.current.scrollTop / itemHeight); + const visibleEnd = visibleStart + maxItemNum; + + if (index < visibleStart) { + editorContainerRef.current.scrollTop -= itemHeight; + } else if (index >= visibleEnd) { + editorContainerRef.current.scrollTop += itemHeight; + } + }, [maxItemNum]); const onUpArrow = useCallback((event) => { event.preventDefault(); event.stopPropagation(); - if (highlightIndex === 0) return; - setHighlightIndex(highlightIndex - 1); - if (highlightIndex > displayTags.length - maxItemNum) { - editorContainerRef.current.scrollTop -= itemHeight; + + if (showTagsAsTree) { + const newIndex = highlightNodeIndex - 1; + if (newIndex < 0) return; + const pos = recentlyUsedTags.length > 0 ? newIndex + recentlyUsedTags.length + 2 : newIndex; + updateScroll(pos); + setHighlightNodeIndex(newIndex); + } else { + const newIndex = highlightIndex - 1; + if (newIndex < 0) return; + updateScroll(highlightIndex, displayTags.length, setHighlightIndex); + setHighlightIndex(newIndex); } - }, [editorContainerRef, highlightIndex, maxItemNum, displayTags, itemHeight]); + }, [highlightIndex, displayTags, showTagsAsTree, recentlyUsedTags, highlightNodeIndex, updateScroll]); const onDownArrow = useCallback((event) => { event.preventDefault(); event.stopPropagation(); - if (highlightIndex === displayTags.length - 1) return; - setHighlightIndex(highlightIndex + 1); - if (highlightIndex >= maxItemNum) { - editorContainerRef.current.scrollTop += itemHeight; + + if (showTagsAsTree) { + const newIndex = highlightNodeIndex + 1; + if (newIndex >= nodes.length) return; + const pos = recentlyUsedTags.length > 0 ? newIndex + recentlyUsedTags.length + 2 : newIndex; + updateScroll(pos); + setHighlightNodeIndex(newIndex); + } else { + const newIndex = highlightIndex + 1; + if (newIndex >= displayTags.length) return; + updateScroll(newIndex); + setHighlightIndex(newIndex); } - }, [editorContainerRef, highlightIndex, maxItemNum, displayTags, itemHeight]); + }, [highlightIndex, displayTags, showTagsAsTree, nodes, recentlyUsedTags, highlightNodeIndex, updateScroll]); const onHotKey = useCallback((event) => { if (event.keyCode === KeyCodes.Enter) { @@ -183,6 +243,50 @@ const TagsEditor = forwardRef(({ } }, []); + const getShownNodes = useCallback((tree, keyNodeFoldedMap) => { + if (!Array.isArray(tree)) { + return []; + } + let shownNodes = []; + + tree.forEach((node, index) => { + const nodeId = getTreeNodeId(node); + const row = getRowById(tagsData, nodeId); + if (!row) return; + if (searchValue) { + const value = searchValue.toLowerCase(); + const tagName = getTagName(row).toLowerCase(); + const tagColor = getTagColor(row).toLowerCase(); + if (!tagName.includes(value) && !tagColor.includes(value)) return; + if (showTagsAsTree) { + const nodesWithAncestors = getNodesWithAncestors(node, tree).filter(node => checkIsTreeNodeShown(getTreeNodeKey(node), keyNodeFoldedMap)); + shownNodes = [...shownNodes, ...nodesWithAncestors]; + return; + } + } + const nodeKey = getTreeNodeKey(node); + if (row && checkIsTreeNodeShown(nodeKey, keyNodeFoldedMap)) { + shownNodes.push({ + ...node, + node_index: index, + }); + } + }); + return shownNodes; + }, [tagsData, searchValue, showTagsAsTree]); + + const toggleExpandTreeNode = useCallback((nodeKey) => { + const updatedKeyNodeFoldedMap = { ...keyNodeFoldedMap }; + if (updatedKeyNodeFoldedMap[nodeKey]) { + delete updatedKeyNodeFoldedMap[nodeKey]; + } else { + updatedKeyNodeFoldedMap[nodeKey] = true; + } + const updatedNodes = getShownNodes(tagsData.rows_tree, updatedKeyNodeFoldedMap); + setNodes(updatedNodes); + setKeyNodeFoldedMap(updatedKeyNodeFoldedMap); + }, [tagsData, keyNodeFoldedMap, getShownNodes]); + useEffect(() => { if (editorRef.current) { const { bottom } = editorRef.current.getBoundingClientRect(); @@ -191,7 +295,7 @@ const TagsEditor = forwardRef(({ editorRef.current.style.bottom = editorPosition.top + height - window.innerHeight + 'px'; } } - if (editorContainerRef.current && selectItemRef.current) { + if (editorContainerRef.current) { setMaxItemNum(getMaxItemNum()); } document.addEventListener('keydown', onHotKey, true); @@ -202,9 +306,18 @@ const TagsEditor = forwardRef(({ }, [onHotKey]); useEffect(() => { - const highlightIndex = displayTags.length === 0 ? -1 : 0; - setHighlightIndex(highlightIndex); - }, [displayTags]); + const saved = localStorage.getItem(RECENTLY_USED_TAG_IDS); + const ids = saved ? JSON.parse(saved) : []; + const tags = ids.map(id => getRowById(tagsData, id)).filter(Boolean); + setRecentlyUsed(tags); + }, [tagsData, localStorage]); + + useEffect(() => { + if (tagsData?.rows_tree) { + const shownNodes = getShownNodes(tagsData.rows_tree, keyNodeFoldedMap); + setNodes(shownNodes); + } + }, [tagsData, keyNodeFoldedMap, getShownNodes]); const renderOptions = useCallback(() => { if (displayTags.length === 0) { @@ -214,33 +327,84 @@ const TagsEditor = forwardRef(({ return displayTags.map((tag, i) => { const tagId = getTagId(tag); - const tagName = getTagName(tag); - const tagColor = getTagColor(tag); - const isSelected = Array.isArray(value) ? value.includes(tagId) : false; return ( -
-
onSelectTag(tagId)} - onMouseEnter={() => onMenuMouseEnter(i)} - onMouseLeave={() => onMenuMouseLeave(i)} - > -
-
-
{tagName}
-
-
- {isSelected && ()} -
-
-
+ onMenuMouseEnter(i, tagId)} + onMouseLeave={onMenuMouseLeave} + /> ); }); - }, [displayTags, searchValue, value, highlightIndex, onMenuMouseEnter, onMenuMouseLeave, onSelectTag]); + }, [displayTags, searchValue, value, highlightIndex, onSelectTag, onMenuMouseEnter, onMenuMouseLeave]); + + const renderRecentlyUsed = useCallback(() => { + return recentlyUsedTags.length > 0 && recentlyUsedTags.map((tag, i) => { + const tagId = getTagId(tag); + return ( + onMenuMouseEnter(i, tagId)} + onMouseLeave={onMenuMouseLeave} + /> + ); + }); + + }, [recentlyUsedTags, value, highlightIndex, onSelectTag, onMenuMouseEnter, onMenuMouseLeave]); + + const renderOptionsAsTree = useCallback(() => { + if (nodes.length === 0) { + const noOptionsTip = searchValue ? gettext('No tags available') : gettext('No tag'); + return ({noOptionsTip}); + } + const showRecentlyUsed = recentlyUsedTags.length > 0 && !searchValue; + return ( + <> + {showRecentlyUsed && ( + <> +
{gettext('Recently used tags')}
+ {renderRecentlyUsed()} +
+ + )} + {!searchValue &&
{gettext('All tags')}
} + {nodes.map((node, i) => { + const nodeKey = getTreeNodeKey(node); + const tagId = getTreeNodeId(node); + const tag = getRowById(tagsData, tagId); + if (!tag) return null; + + return ( + onTreeMenuMouseEnter(i)} + onMouseLeave={onTreeMenuMouseLeave} + depth={getTreeNodeDepth(node)} + hasChildren={checkTreeNodeHasChildNodes(node)} + isFolded={keyNodeFoldedMap[nodeKey]} + onToggleExpand={() => toggleExpandTreeNode(nodeKey)} + /> + ); + })} + + ); + }, [nodes, tagsData, value, highlightNodeIndex, searchValue, recentlyUsedTags, renderRecentlyUsed, toggleExpandTreeNode, keyNodeFoldedMap, onSelectTag, onTreeMenuMouseEnter, onTreeMenuMouseLeave]); return ( -
+
( + + ) + }} + clearValue={() => setSearchValue('')} />
- {renderOptions()} + {showTagsAsTree ? renderOptionsAsTree() : renderOptions()}
{isShowCreateBtn && ( { + const tagId = getTagId(tag); + const tagName = getTagName(tag); + const tagColor = getTagColor(tag); + const paddingLeft = node ? NODE_CONTENT_LEFT_INDENT + NODE_ICON_LEFT_INDENT * depth : 8; + + return ( +
+
onSelect(tagId) : () => {}} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + > + {hasChildren && ( + + + + )} +
onSelect(tagId) : () => {}}> +
+
{tagName}
+
+
+ {isSelected && } +
+
+
+ ); +}; + +TagItem.propTypes = { + node: PropTypes.object, + tag: PropTypes.object, + isSelected: PropTypes.bool, + highlight: PropTypes.bool, + onSelect: PropTypes.func, + onMouseEnter: PropTypes.func, + onMouseLeave: PropTypes.func, + depth: PropTypes.number, + hasChildren: PropTypes.bool, + isFolded: PropTypes.bool, + onToggleExpand: PropTypes.func, +}; + +export default TagItem; diff --git a/frontend/src/metadata/components/detail-editor/tags-editor/index.js b/frontend/src/metadata/components/detail-editor/tags-editor/index.js index 455c6b4a67..f6991d55e3 100644 --- a/frontend/src/metadata/components/detail-editor/tags-editor/index.js +++ b/frontend/src/metadata/components/detail-editor/tags-editor/index.js @@ -71,23 +71,36 @@ const TagsEditor = ({ record, value, field, updateFileTags }) => { const renderEditor = useCallback(() => { if (!showEditor) return null; - const { width } = ref.current.getBoundingClientRect(); + const { width, bottom } = ref.current.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const shouldPlaceBottom = (viewportHeight - bottom) > 300; + return ( );