1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-08 02:10:24 +00:00

feat(tag): display tags sidebar with tree (#7428)

This commit is contained in:
Jerry Ren
2025-01-26 17:56:49 +08:00
committed by GitHub
parent 01791be348
commit 94914009c1
10 changed files with 246 additions and 77 deletions

View File

@@ -162,3 +162,26 @@ export const getAllSubTreeNodes = (nodeIndex, tree) => {
}
return subNodes;
};
export const getTreeChildNodes = (parentNode, tree) => {
const parentNodeKey = getTreeNodeKey(parentNode);
const parentNodeIndex = tree.findIndex((node) => getTreeNodeKey(node) === parentNodeKey);
if (parentNodeIndex < 0) {
return [];
}
const parentNodeDepth = getTreeNodeDepth(parentNode);
const childNodeDepth = parentNodeDepth + 1;
let childNodes = [];
for (let i = parentNodeIndex + 1, len = tree.length; i < len; i++) {
const currentNode = tree[i];
if (!getTreeNodeKey(currentNode).includes(parentNodeKey)) {
break;
}
if (getTreeNodeDepth(currentNode) === childNodeDepth) {
childNodes.push({ ...currentNode });
}
}
return childNodes;
};

View File

@@ -0,0 +1 @@
export const SIDEBAR_INIT_LEFT_INDENT = 40;

View File

@@ -2,7 +2,7 @@ import React, { useCallback, useContext, useEffect, useState } from 'react';
import { Utils } from '../../utils/utils';
import tagsAPI from '../api';
import { useTags } from './tags';
import { getTreeNodeByKey } from '../../components/sf-table/utils/tree';
import { getTreeNodeById, getTreeNodeByKey } from '../../components/sf-table/utils/tree';
import { getAllChildTagsIdsFromNode } from '../utils/tree';
// This hook provides content related to seahub interaction, such as whether to enable extended attributes, views data, etc.
@@ -15,15 +15,20 @@ export const TagViewProvider = ({ repoID, tagID, nodeKey, children, ...params })
const { tagsData } = useTags();
const getChildTagsIds = useCallback((nodeKey) => {
if (!nodeKey) return [];
const displayNode = getTreeNodeByKey(nodeKey, tagsData.key_tree_node_map);
const getChildTagsIds = useCallback((tagID, nodeKey) => {
let displayNode = null;
if (nodeKey) {
displayNode = getTreeNodeByKey(nodeKey, tagsData.key_tree_node_map);
}
if (!displayNode) {
displayNode = getTreeNodeById(tagID, tagsData.rows_tree);
}
return getAllChildTagsIdsFromNode(displayNode);
}, [tagsData]);
useEffect(() => {
setLoading(true);
const childTagsIds = getChildTagsIds(nodeKey);
const childTagsIds = getChildTagsIds(tagID, nodeKey);
let tagsIds = [tagID];
if (Array.isArray(childTagsIds) && childTagsIds.length > 0) {
tagsIds.push(...childTagsIds);

View File

@@ -21,6 +21,7 @@ export const TagsProvider = ({ repoID, currentPath, selectTagsView, children, ..
const [isLoading, setLoading] = useState(true);
const [isReloading, setReloading] = useState(false);
const [tagsData, setTagsData] = useState(null);
const [displayNodeKey, setDisplayNodeKey] = useState('');
const storeRef = useRef(null);
const contextRef = useRef(null);
@@ -92,7 +93,7 @@ export const TagsProvider = ({ repoID, currentPath, selectTagsView, children, ..
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enableMetadata, enableTags]);
const handelSelectTag = useCallback((tag, isSelected) => {
const handleSelectTag = useCallback((tag, nodeKey, isSelected) => {
if (isSelected) return;
const id = getTagId(tag);
const node = {
@@ -111,21 +112,22 @@ export const TagsProvider = ({ repoID, currentPath, selectTagsView, children, ..
key: repoID,
tag_id: id,
};
setDisplayNodeKey(nodeKey || '');
selectTagsView(node);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [repoID, selectTagsView]);
const addTag = useCallback((row, callback) => {
return storeRef.current.addTags([row], callback);
}, []);
}, [storeRef]);
const addTags = useCallback((rows, callback) => {
return storeRef.current.addTags(rows, callback);
}, []);
}, [storeRef]);
const addChildTag = useCallback((tagData, parentTagId, callback = {}) => {
return storeRef.current.addChildTag(tagData, parentTagId, callback);
}, []);
}, [storeRef]);
const modifyTags = useCallback((tagIds, idTagUpdates, idOriginalRowUpdates, idOldRowData, idOriginalOldRowData, { success_callback, fail_callback }) => {
storeRef.current.modifyTags(tagIds, idTagUpdates, idOriginalRowUpdates, idOldRowData, idOriginalOldRowData, { success_callback, fail_callback });
@@ -149,10 +151,10 @@ export const TagsProvider = ({ repoID, currentPath, selectTagsView, children, ..
addTag(newTag, {
success_callback: (operation) => {
const copiedTag = operation.tags[0];
handelSelectTag(copiedTag);
handleSelectTag(copiedTag);
}
});
}, [tagsData, addTag, handelSelectTag]);
}, [tagsData, addTag, handleSelectTag]);
const updateTag = useCallback((tagId, update, { success_callback, fail_callback } = { }) => {
const tag = getRowById(tagsData, tagId);
@@ -192,22 +194,22 @@ export const TagsProvider = ({ repoID, currentPath, selectTagsView, children, ..
const addTagLinks = useCallback((columnKey, tagId, otherTagsIds, { success_callback, fail_callback } = {}) => {
storeRef.current.addTagLinks(columnKey, tagId, otherTagsIds, success_callback, fail_callback);
}, []);
}, [storeRef]);
const deleteTagLinks = useCallback((columnKey, tagId, otherTagsIds, { success_callback, fail_callback } = {}) => {
storeRef.current.deleteTagLinks(columnKey, tagId, otherTagsIds, success_callback, fail_callback);
}, []);
}, [storeRef]);
const mergeTags = useCallback((target_tag_id, merged_tags_ids, { success_callback, fail_callback } = {}) => {
storeRef.current.mergeTags(target_tag_id, merged_tags_ids, success_callback, fail_callback);
}, []);
}, [storeRef]);
const modifyColumnWidth = useCallback((columnKey, newWidth) => {
storeRef.current.modifyColumnWidth(columnKey, newWidth);
}, [storeRef]);
useEffect(() => {
if (!handelSelectTag) return;
if (!handleSelectTag) return;
if (isLoading) return;
const { search } = window.location;
const urlParams = new URLSearchParams(search);
@@ -215,17 +217,17 @@ export const TagsProvider = ({ repoID, currentPath, selectTagsView, children, ..
const tagId = urlParams.get('tag');
if (tagId) {
if (tagId === ALL_TAGS_ID) {
handelSelectTag({ [PRIVATE_COLUMN_KEY.ID]: ALL_TAGS_ID });
handleSelectTag({ [PRIVATE_COLUMN_KEY.ID]: ALL_TAGS_ID });
return;
}
const lastOpenedTag = getRowById(tagsData, tagId);
if (lastOpenedTag) {
handelSelectTag(lastOpenedTag);
handleSelectTag(lastOpenedTag);
return;
}
handelSelectTag({ [PRIVATE_COLUMN_KEY.ID]: ALL_TAGS_ID });
handleSelectTag({ [PRIVATE_COLUMN_KEY.ID]: ALL_TAGS_ID });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoading]);
@@ -262,6 +264,7 @@ export const TagsProvider = ({ repoID, currentPath, selectTagsView, children, ..
isLoading,
isReloading,
tagsData,
displayNodeKey,
currentPath,
store: storeRef.current,
context: contextRef.current,
@@ -279,7 +282,7 @@ export const TagsProvider = ({ repoID, currentPath, selectTagsView, children, ..
deleteTagLinks,
mergeTags,
updateLocalTag,
selectTag: handelSelectTag,
selectTag: handleSelectTag,
modifyColumnWidth,
}}>
{children}

View File

@@ -2,23 +2,19 @@ import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { PRIVATE_FILE_TYPE } from '../../../constants';
import { PRIVATE_COLUMN_KEY, ALL_TAGS_ID } from '../../constants';
import { useTags } from '../../hooks';
import { ALL_TAGS_ID } from '../../constants';
import { gettext } from '../../../utils/constants';
import './index.css';
const AllTags = ({ currentPath }) => {
const { selectTag } = useTags();
const AllTags = ({ currentPath, selectAllTags }) => {
const path = useMemo(() => '/' + PRIVATE_FILE_TYPE.TAGS_PROPERTIES + '/' + ALL_TAGS_ID, []);
const isSelected = useMemo(() => currentPath === path, [currentPath, path]);
const handelClick = useCallback(() => {
selectTag({
[PRIVATE_COLUMN_KEY.ID]: ALL_TAGS_ID,
}, isSelected);
}, [isSelected, selectTag]);
selectAllTags(isSelected);
}, [isSelected, selectAllTags]);
return (
<div

View File

@@ -1,17 +1,6 @@
.metadata-tree-view-tag .tree-node-inner .left-icon {
top: 5px;
padding-left: 8px;
}
.metadata-tree-view-tag .tree-node-inner .tree-node-text {
padding-left: 28px;
}
.metadata-tree-view-tag .tree-node-icon {
height: 100%;
line-height: 1.5;
height: 26px;
display: flex;
justify-content: center;
align-items: center;
transform: translateY(1px);
}

View File

@@ -1,39 +1,124 @@
import React, { useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import AllTags from './all-tags';
import Tag from './tag';
import { useTags } from '../hooks';
import { getTagId } from '../utils/cell';
import { PRIVATE_FILE_TYPE } from '../../constants';
import { PRIVATE_COLUMN_KEY, ALL_TAGS_ID } from '../constants';
import { checkTreeNodeHasChildNodes, getTreeChildNodes, getTreeNodeDepth, getTreeNodeId, getTreeNodeKey } from '../../components/sf-table/utils/tree';
import { getRowById } from '../../metadata/utils/table';
import { SIDEBAR_INIT_LEFT_INDENT } from '../constants/sidebar-tree';
import './index.css';
const LOCAL_KEY_TREE_NODE_EXPANDED = 'sidebar_key_tree_node_expanded_map';
const TagsTreeView = ({ currentPath }) => {
const { tagsData, selectTag } = useTags();
const [currSelectedNodeKey, setCurrSelectedNodeKey] = useState('');
const [keyTreeNodeExpandedMap, setKeyTreeNodeExpandedMap] = useState({});
const tags = useMemo(() => {
if (!tagsData) return [];
return tagsData.rows;
const recordsTree = useMemo(() => {
return tagsData.rows_tree || [];
}, [tagsData]);
const buildTree = useCallback((roots, tree) => {
roots.forEach((node) => {
const childNodes = checkTreeNodeHasChildNodes(node) ? getTreeChildNodes(node, tree) : [];
if (childNodes.length > 0) {
node.children = childNodes;
buildTree(node.children, tree);
}
});
}, []);
const visibleRoots = useMemo(() => {
let roots = recordsTree.filter((node) => getTreeNodeDepth(node) === 0);
roots = roots.slice(0, 20);
buildTree(roots, recordsTree);
return roots;
}, [recordsTree, buildTree]);
const getKeyTreeNodeExpandedMap = useCallback(() => {
const strKeyTreeNodeExpandedMap = window.sfTagsDataContext.localStorage.getItem(LOCAL_KEY_TREE_NODE_EXPANDED);
if (strKeyTreeNodeExpandedMap) {
try {
return JSON.parse(strKeyTreeNodeExpandedMap);
} catch {
return {};
}
}
return {};
}, []);
const storeKeyTreeNodeExpandedMap = useCallback((keyTreeNodeExpandedMap) => {
window.sfTagsDataContext.localStorage.setItem(LOCAL_KEY_TREE_NODE_EXPANDED, JSON.stringify(keyTreeNodeExpandedMap));
}, []);
const checkNodeExpanded = useCallback((nodeKey) => {
return !!keyTreeNodeExpandedMap[nodeKey];
}, [keyTreeNodeExpandedMap]);
const toggleExpanded = useCallback((nodeKey, expanded) => {
let updatedKeyTreeNodeExpandedMap = { ...keyTreeNodeExpandedMap };
if (expanded) {
delete updatedKeyTreeNodeExpandedMap[nodeKey];
} else {
updatedKeyTreeNodeExpandedMap[nodeKey] = true;
}
storeKeyTreeNodeExpandedMap(updatedKeyTreeNodeExpandedMap);
setKeyTreeNodeExpandedMap(updatedKeyTreeNodeExpandedMap);
}, [keyTreeNodeExpandedMap, storeKeyTreeNodeExpandedMap]);
const selectNode = useCallback((node) => {
const tagId = getTreeNodeId(node);
const tag = getRowById(tagsData, tagId);
const nodeKey = getTreeNodeKey(node);
selectTag(tag, nodeKey);
setCurrSelectedNodeKey(nodeKey);
}, [tagsData, selectTag]);
const selectAllTags = useCallback((isSelected) => {
selectTag({ [PRIVATE_COLUMN_KEY.ID]: ALL_TAGS_ID }, isSelected);
setCurrSelectedNodeKey('');
}, [selectTag]);
useEffect(() => {
if (!currSelectedNodeKey) {
const selectedNode = recordsTree.find((node) => {
const nodePath = '/' + PRIVATE_FILE_TYPE.TAGS_PROPERTIES + '/' + getTreeNodeId(node);
return nodePath === currentPath;
});
const nextSelectedNodeKey = getTreeNodeKey(selectedNode);
setCurrSelectedNodeKey(nextSelectedNodeKey);
}
}, [currentPath, currSelectedNodeKey, recordsTree]);
useEffect(() => {
setKeyTreeNodeExpandedMap(getKeyTreeNodeExpandedMap());
}, [getKeyTreeNodeExpandedMap]);
return (
<div className="tree-view tree metadata-tree-view metadata-tree-view-tag">
<div className="tree-node">
<div className="children">
{tags.slice(0, 20).map(tag => {
const id = getTagId(tag);
const tagPath = '/' + PRIVATE_FILE_TYPE.TAGS_PROPERTIES + '/' + id;
const isSelected = currentPath === tagPath;
{visibleRoots.map((node) => {
const nodeKey = getTreeNodeKey(node);
return (
<Tag
key={id}
tag={tag}
isSelected={isSelected}
onClick={(tag) => selectTag(tag, isSelected)}
key={`sidebar-tree-node-${nodeKey}`}
node={node}
expanded={checkNodeExpanded(nodeKey)}
currentPath={currentPath}
leftIndent={SIDEBAR_INIT_LEFT_INDENT}
selectedNodeKey={currSelectedNodeKey}
checkNodeExpanded={checkNodeExpanded}
toggleExpanded={toggleExpanded}
selectNode={selectNode}
/>
);
})}
<AllTags currentPath={currentPath} />
<AllTags currentPath={currentPath} selectAllTags={selectAllTags} />
</div>
</div>
</div>

View File

@@ -2,7 +2,6 @@
height: 12px;
width: 12px;
border-radius: 50%;
transform: translateY(2px);
}
.tag-tree-node .tag-tree-node-text {

View File

@@ -1,15 +1,51 @@
import React, { useCallback, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { getTagColor, getTagName, getTagFilesCount } from '../../utils/cell';
import { getTagColor, getTagName, getTagFilesLinks } from '../../utils/cell';
import { checkTreeNodeHasChildNodes, getTreeNodeId, getTreeNodeKey } from '../../../components/sf-table/utils/tree';
import { getRowById } from '../../../metadata/utils/table';
import { useTags } from '../../hooks';
import { SIDEBAR_INIT_LEFT_INDENT } from '../../constants/sidebar-tree';
import { getAllChildTagsIdsFromNode } from '../../utils/tree';
import './index.css';
const Tag = ({ isSelected, tag, onClick }) => {
const LEFT_INDENT_UNIT = 20;
const NODE_TEXT_LEFT_INDENT_UNIT = 5;
const Tag = ({ node, currentPath, leftIndent, selectedNodeKey, expanded, checkNodeExpanded, toggleExpanded, selectNode }) => {
const { tagsData } = useTags();
const [highlight, setHighlight] = useState(false);
const tagId = useMemo(() => {
return getTreeNodeId(node);
}, [node]);
const tag = useMemo(() => {
return getRowById(tagsData, tagId);
}, [tagsData, tagId]);
const hasChildren = useMemo(() => checkTreeNodeHasChildNodes(node), [node]);
const nodeKey = useMemo(() => getTreeNodeKey(node), [node]);
const tagName = useMemo(() => getTagName(tag), [tag]);
const tagColor = useMemo(() => getTagColor(tag), [tag]);
const tagCount = useMemo(() => getTagFilesCount(tag), [tag]);
const [highlight, setHighlight] = useState(false);
const tagCount = useMemo(() => {
const filesLinks = getTagFilesLinks(tag);
let allFilesLinks = [...filesLinks];
const childTagsIds = getAllChildTagsIdsFromNode(node);
childTagsIds.forEach((childTagId) => {
const childTag = getRowById(tagsData, childTagId);
const childFilesLinks = getTagFilesLinks(childTag);
if (childFilesLinks && childFilesLinks.length > 0) {
allFilesLinks.push(...childFilesLinks);
}
});
return allFilesLinks.length;
}, [node, tag, tagsData]);
const isSelected = useMemo(() => {
return nodeKey === selectedNodeKey;
}, [nodeKey, selectedNodeKey]);
const onMouseEnter = useCallback(() => {
setHighlight(true);
@@ -23,30 +59,62 @@ const Tag = ({ isSelected, tag, onClick }) => {
setHighlight(false);
}, []);
const onToggleExpanded = useCallback((event) => {
event.stopPropagation();
toggleExpanded(nodeKey, expanded);
}, [nodeKey, expanded, toggleExpanded]);
const renderChildren = useCallback(() => {
const { children } = node;
if (!expanded || !hasChildren || !Array.isArray(children) || children.length === 0) {
return null;
}
return children.map((childNode) => {
const childNodeKey = getTreeNodeKey(childNode);
return (
<Tag
key={`sidebar-tree-node-${childNodeKey}`}
node={childNode}
expanded={checkNodeExpanded(childNodeKey)}
selectedNodeKey={selectedNodeKey}
leftIndent={leftIndent + LEFT_INDENT_UNIT}
currentPath={currentPath}
checkNodeExpanded={checkNodeExpanded}
toggleExpanded={toggleExpanded}
selectNode={selectNode}
/>
);
});
}, [currentPath, node, selectedNodeKey, hasChildren, leftIndent, expanded, checkNodeExpanded, toggleExpanded, selectNode]);
return (
<div className="tree-node">
<div
className={classnames('tree-node-inner text-nowrap tag-tree-node', { 'tree-node-inner-hover': highlight, 'tree-node-hight-light': isSelected })}
title={`${tagName} (${tagCount})`}
onMouseEnter={onMouseEnter}
onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave}
onClick={() => onClick(tag)}
onClick={() => selectNode(node)}
>
<div className="tree-node-text tag-tree-node-text">
<div className="tree-node-text tag-tree-node-text" style={{ paddingLeft: leftIndent + NODE_TEXT_LEFT_INDENT_UNIT }}>
<div className="tag-tree-node-name">{tagName}</div>
<div className="tag-tree-node-count">{tagCount}</div>
</div>
<div className="left-icon">
<div className="left-icon" style={{ left: leftIndent - SIDEBAR_INIT_LEFT_INDENT }}>
{hasChildren && <i className={classnames('folder-toggle-icon sf3-font sf3-font-down', { 'rotate-270': !expanded })} onClick={onToggleExpanded}></i>}
<div className="tree-node-icon">
<div className="tag-tree-node-color" style={{ backgroundColor: tagColor }}></div>
</div>
</div>
</div>
{hasChildren && renderChildren()}
</div>
);
};
Tag.propTypes = {
isSelected: PropTypes.bool,
tag: PropTypes.object,
onClick: PropTypes.func,
};

View File

@@ -6,7 +6,7 @@ import AllTags from './all-tags';
import { ALL_TAGS_ID } from '../constants';
const Views = ({ ...params }) => {
const { isLoading } = useTags();
const { isLoading, displayNodeKey } = useTags();
if (isLoading) return (<CenteredLoading />);
if (params.tagID === ALL_TAGS_ID) {
@@ -14,7 +14,7 @@ const Views = ({ ...params }) => {
}
return (
<TagViewProvider { ...params }>
<TagViewProvider { ...params } nodeKey={displayNodeKey}>
<View />
</TagViewProvider>
);