1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-05-11 17:34:56 +00:00

Feature/show tags in search dialog ()

* show related tags

* optimize

* fix eslint warning

* change searched tags background

---------

Co-authored-by: zhouwenxuan <aries@Mac.local>
Co-authored-by: Michael An <1822852997@qq.com>
This commit is contained in:
Aries 2025-04-15 13:52:37 +08:00 committed by GitHub
parent da86a8e1b0
commit c23a153818
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 251 additions and 38 deletions
frontend/src

View File

@ -14,4 +14,10 @@ export const EVENT_BUS_TYPE = {
// migrate tags
OPEN_TREE_PANEL: 'open_tree_panel',
OPEN_LIBRARY_SETTINGS_TAGS: 'open_library_settings_tags',
// tags
TAG_STATUS: 'tag_status',
TAGS_DATA: 'tags_data',
SELECT_TAG: 'select_tag',
UPDATE_SELECTED_TAG: 'update_selected_tag',
};

View File

@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Dropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
import PropTypes from 'prop-types';
import { gettext } from '../../../utils/constants';
import { Utils } from '../../../utils/utils';
import UserItem from './user-item';
@ -7,7 +8,7 @@ import { seafileAPI } from '../../../utils/seafile-api';
import ModalPortal from '../../modal-portal';
import toaster from '../../toast';
const FilterByCreator = ({ repoID, onSelect }) => {
const FilterByCreator = ({ onSelect }) => {
const [isOpen, setIsOpen] = useState(false);
const [options, setOptions] = useState([]);
const [value, setValue] = useState([]);
@ -30,7 +31,7 @@ const FilterByCreator = ({ repoID, onSelect }) => {
}, [isOpen]);
const displayOptions = useMemo(() => {
if (!searchValue) return options;
if (!searchValue) return null;
return options.filter((option) => {
return option.name.toLowerCase().includes(searchValue.toLowerCase());
});
@ -48,8 +49,10 @@ const FilterByCreator = ({ repoID, onSelect }) => {
}
setValue(updated);
onSelect('creator', updated);
setSearchValue('');
}, [value, onSelect]);
if (displayOptions.length === 1) {
setSearchValue('');
}
}, [value, displayOptions, onSelect]);
const handleCancel = useCallback((v) => {
const updated = value.filter((item) => item !== v);
@ -57,26 +60,39 @@ const FilterByCreator = ({ repoID, onSelect }) => {
onSelect('creator', updated);
}, [value, onSelect]);
const handleInputChange = useCallback((e) => {
const v = e.target.value;
setSearchValue(v);
if (!value) {
setOptions([]);
}
}, [value]);
useEffect(() => {
if (!searchValue) return;
const getUsers = async () => {
try {
const res = await seafileAPI.listRepoRelatedUsers(repoID);
const users = res.data.user_list;
const options = users.map((user) => {
return {
const res = await seafileAPI.searchUsers(searchValue);
const userList = res.data.users
.filter(user => user.name.toLowerCase().includes(searchValue.toLowerCase()))
.map(user => ({
key: user.email,
value: user.email,
name: user.name,
label: <UserItem user={user} />,
};
});
setOptions(options);
}))
.filter(user => !options.some(option => option.key === user.key));
setOptions(prevOptions => [...prevOptions, ...userList]);
} catch (err) {
toaster.danger(Utils.getErrorMsg(err));
}
};
getUsers();
}, [repoID]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchValue]);
return (
<div className="search-filter filter-by-creator-container">
@ -104,11 +120,11 @@ const FilterByCreator = ({ repoID, onSelect }) => {
type="text"
placeholder={value.length ? '' : gettext('Search user')}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onChange={handleInputChange}
/>
</div>
</div>
{displayOptions.map((option) => (
{displayOptions && displayOptions.map((option) => (
<DropdownItem
key={option.key}
tag="div"
@ -129,4 +145,8 @@ const FilterByCreator = ({ repoID, onSelect }) => {
);
};
FilterByCreator.propTypes = {
onSelect: PropTypes.func.isRequired,
};
export default FilterByCreator;

View File

@ -17,10 +17,10 @@ const FilterByText = ({ onSelect }) => {
return [
{
key: TEXT_FILTER_KEY.SEARCH_FILENAME_AND_CONTENT,
label: gettext('Search filename and content'),
label: gettext('File name and content'),
}, {
key: TEXT_FILTER_KEY.SEARCH_FILENAME_ONLY,
label: gettext('Search filename only'),
label: gettext('File name only'),
}
];
}, []);

View File

@ -3,11 +3,12 @@
position: relative;
display: flex;
justify-content: flex-start;
align-items: flex-start;
margin-top: 4px;
padding: 0 16px;
align-items: center;
margin-top: 12px;
padding: 0 16px 10px;
overflow: auto hidden;
scrollbar-color: #C1C1C1 rgba(0, 0, 0, 0);
border-bottom: 1px solid #eee;
}
.search-filters-container .search-filter {

View File

@ -7,13 +7,11 @@ import FilterBySuffix from './filter-by-suffix';
import './index.css';
const SCROLLABLE_CONTAINER_HEIGHT = 44;
const SearchFilters = ({ repoID, onChange }) => {
const SearchFilters = ({ onChange }) => {
return (
<div className="search-filters-container" style={{ height: SCROLLABLE_CONTAINER_HEIGHT }}>
<div className="search-filters-container">
<FilterByText onSelect={onChange} />
<FilterByCreator repoID={repoID} onSelect={onChange} />
<FilterByCreator onSelect={onChange} />
<FilterByDate onSelect={onChange} />
<FilterBySuffix onSelect={onChange} />
</div>
@ -21,7 +19,6 @@ const SearchFilters = ({ repoID, onChange }) => {
};
SearchFilters.propTypes = {
repoID: PropTypes.string,
onChange: PropTypes.func,
};

View File

@ -0,0 +1,58 @@
.search-tags-container {
width: 100%;
height: fit-content;
display: flex;
flex-direction: column;
justify-content: flex-start;
font-size: 0.8125rem;
}
.search-tags-container .tags-title {
width: 100%;
height: 20px;
display: flex;
flex-direction: row;
justify-content: flex-start;
padding: 0 16px;
margin: 10px 0 4px;
font-size: 0.875rem;
color: #999;
}
.search-tags-container .tags-content {
width: 100%;
height: fit-content;
max-height: 180px;
overflow: auto;
scrollbar-color: #C1C1C1 rgba(0, 0, 0, 0);
padding: 0 16px;
margin-bottom: 10px;
}
.search-tags-container .tags-content .tag-item {
width: 100%;
height: 40px;
display: flex;
justify-content: flex-start;
align-items: center;
padding: 0 16px;
border-radius: 3px;
}
.search-tags-container .tags-content .tag-item:hover {
background-color: #f0f0f0;
cursor: pointer;
}
.search-tags-container .tags-content .tag-item .tag-color {
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 8px;
}
.search-tags-container .search-tags-divider {
height: 0;
border-top: 1px solid #eee;
margin: 0 16px;
}

View File

@ -0,0 +1,70 @@
import React, { useCallback, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { gettext } from '../../../utils/constants';
import { getTagColor, getTagId, getTagName } from '../../../tag/utils/cell';
import { PRIVATE_FILE_TYPE } from '../../../constants';
import { EVENT_BUS_TYPE } from '../../common/event-bus-type';
import './index.css';
const SearchTags = ({ repoID, tagsData, keyword, onSelectTag }) => {
const [displayTags, setDisplayTags] = useState([]);
const handleClick = useCallback((e, tagId) => {
e.preventDefault();
e.stopPropagation();
const node = {
children: [],
path: '/' + PRIVATE_FILE_TYPE.TAGS_PROPERTIES + '/' + tagId,
isExpanded: false,
isLoaded: true,
isPreload: true,
object: {
file_tags: [],
id: tagId,
type: PRIVATE_FILE_TYPE.TAGS_PROPERTIES,
isDir: () => false,
},
parentNode: {},
key: repoID,
tag_id: tagId,
};
onSelectTag(node);
window.sfTagsDataContext?.eventBus?.dispatch(EVENT_BUS_TYPE.UPDATE_SELECTED_TAG, tagId);
}, [repoID, onSelectTag]);
useEffect(() => {
if (!tagsData || tagsData.length === 0 || !keyword) return;
const tags = tagsData?.filter((tag) => getTagName(tag).toLowerCase().includes(keyword.toLowerCase()));
setDisplayTags(tags);
}, [tagsData, keyword]);
if (!tagsData || tagsData.length === 0 || !keyword || displayTags.length === 0) return null;
return (
<div className="search-tags-container">
<div className="tags-title">{gettext('Tags')}</div>
<div className="tags-content">
{displayTags.map((tag) => {
const tagId = getTagId(tag);
const tagName = getTagName(tag);
const tagColor = getTagColor(tag);
return (
<div className="tag-item" key={tagId} onClick={(e) => handleClick(e, tagId)}>
<div className="tag-color" style={{ backgroundColor: tagColor }} />
<div className="tag-name">{tagName}</div>
</div>
);
})}
</div>
<div className="search-tags-divider" />
</div>
);
};
SearchTags.propTypes = {
tagsData: PropTypes.array.isRequired,
};
export default SearchTags;

View File

@ -14,6 +14,7 @@ import Loading from '../loading';
import { SEARCH_MASK, SEARCH_CONTAINER } from '../../constants/zIndexes';
import { PRIVATE_FILE_TYPE } from '../../constants';
import SearchFilters from './search-filters';
import SearchTags from './search-tags';
const propTypes = {
repoID: PropTypes.string,
@ -22,6 +23,7 @@ const propTypes = {
onSearchedClick: PropTypes.func.isRequired,
isPublic: PropTypes.bool,
isViewFile: PropTypes.bool,
onSelectTag: PropTypes.func,
};
const PER_PAGE = 20;
@ -729,6 +731,7 @@ class Search extends Component {
return (
<>
<MediaQuery query="(min-width: 768px)">
{!isVisited && <h4 className="search-results-title">{gettext('Files')}</h4>}
<div className="search-result-list-container" ref={this.searchResultListContainerRef}>{results}</div>
</MediaQuery>
<MediaQuery query="(max-width: 767.8px)">
@ -759,12 +762,19 @@ class Search extends Component {
this.setState({ filters: newFilters }, () => this.forceUpdate());
}
handleSelectTag = (tag) => {
this.props.onSelectTag(tag);
this.resetToDefault();
}
render() {
let width = this.state.width !== 'default' ? this.state.width : '';
let style = {'width': width};
const { isMaskShow } = this.state;
const { repoID, isTagEnabled, tagsData } = this.props;
const { isMaskShow, isResultGotten } = this.state;
const placeholder = `${this.props.placeholder}${isMaskShow ? '' : ` (${controlKey} + k)`}`;
const isFiltersShow = this.props.repoID && isMaskShow;
const isFiltersShow = isMaskShow && isResultGotten;
const isTagsShow = this.props.repoID && isTagEnabled && isMaskShow && isResultGotten;
return (
<Fragment>
<MediaQuery query="(min-width: 768px)">
@ -795,10 +805,10 @@ class Search extends Component {
}
</div>
{isFiltersShow &&
<SearchFilters
repoID={this.props.repoID}
onChange={this.handleFiltersChange}
/>
<SearchFilters onChange={this.handleFiltersChange} />
}
{isTagsShow &&
<SearchTags repoID={repoID} tagsData={tagsData} keyword={this.state.value} onSelectTag={this.handleSelectTag} />
}
<div
className="search-result-container dropdown-search-result-container"

View File

@ -7,6 +7,7 @@ import Notification from '../common/notification';
import Account from '../common/account';
import Logout from '../common/logout';
import { EVENT_BUS_TYPE } from '../common/event-bus-type';
import tagsAPI from '../../tag/api';
const propTypes = {
repoID: PropTypes.string,
@ -32,19 +33,39 @@ class CommonToolbar extends React.Component {
path: props.path,
isViewFile: props.isViewFile,
currentRepoInfo: props.currentRepoInfo,
isTagEnabled: false,
tagsData: [],
};
}
componentDidMount() {
if (this.props.eventBus) {
this.unsubscribeLibChange = this.props.eventBus.subscribe(EVENT_BUS_TYPE.CURRENT_LIBRARY_CHANGED, this.onRepoChange);
this.unsubscribeTagStatus = this.props.eventBus.subscribe(EVENT_BUS_TYPE.TAG_STATUS, (status) => this.onTagStatus(status));
this.unsubscribeTagsChanged = this.props.eventBus.subscribe(EVENT_BUS_TYPE.TAGS_CHANGED, (tags) => this.setState({ tagsData: tags }));
}
}
componentWillUnmount() {
this.unsubscribeLibChange && this.unsubscribeLibChange();
this.unsubscribeMetadataStatus && this.unsubscribeMetadataStatus();
this.unsubscribeTagsChanged && this.unsubscribeTagsChanged();
}
onTagStatus = (status) => {
this.setState({ isTagEnabled: status });
if (status) {
tagsAPI.getTags(this.state.repoID).then((res) => {
const tags = res?.data?.results || [];
this.setState({ tagsData: tags });
});
}
};
onSelectTag = (tag) => {
this.props.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_TAG, tag);
};
onRepoChange = ({ repoID, repoName, isLibView, path, isViewFile, currentRepoInfo }) => {
this.setState({ repoID, repoName, isLibView, path, isViewFile, currentRepoInfo });
};
@ -59,7 +80,7 @@ class CommonToolbar extends React.Component {
};
renderSearch = () => {
const { repoID, repoName, isLibView, path, isViewFile } = this.state;
const { repoID, repoName, isLibView, path, isViewFile, isTagEnabled, tagsData } = this.state;
const { searchPlaceholder } = this.props;
const placeholder = searchPlaceholder || gettext('Search files');
@ -72,6 +93,9 @@ class CommonToolbar extends React.Component {
isViewFile={isViewFile}
isPublic={false}
path={path}
isTagEnabled={isTagEnabled}
tagsData={tagsData}
onSelectTag={this.onSelectTag}
/>
);
} else {

View File

@ -91,7 +91,7 @@
border-radius: 0 0 3px 3px;
box-shadow: 0 3px 8px 0 rgba(116, 129, 141, 0.1);
top: 60px;
padding: 0 16px;
padding-left: 16px;
}
.dropdown-search-result-container {
@ -118,10 +118,12 @@
.search-result-container .search-result-list-container {
overflow: auto;
scrollbar-color: #C1C1C1 rgba(0, 0, 0, 0);
flex: 1;
}
.search-result-container .search-result-item {
height: 58px;
display: flex;
padding: 10px 0 10px 8px;
font-size: 0.8125rem;
@ -149,7 +151,7 @@
.search-result-item .item-content {
flex: 1;
margin-left: 0.25rem;
overflow-x: hidden;
overflow: hidden;
}
.item-content .item-name a {
@ -405,11 +407,12 @@
cursor: pointer;
}
.search-results-title,
.visited-search-results-title {
color: #999;
font-size: .875rem;
font-weight: normal;
margin: 7px 0 10px;
margin: 10px 0 4px;
}
.search-types {

View File

@ -160,6 +160,7 @@ class LibContentView extends React.Component {
this.unsubscribeEvent = this.props.eventBus.subscribe(EVENT_BUS_TYPE.SEARCH_LIBRARY_CONTENT, this.onSearchedClick);
this.unsubscribeOpenTreePanel = eventBus.subscribe(EVENT_BUS_TYPE.OPEN_TREE_PANEL, this.openTreePanel);
this.calculatePara(this.props);
this.unsubscribeSelectSearchedTag = this.props.eventBus.subscribe(EVENT_BUS_TYPE.SELECT_TAG, this.onTreeNodeClick);
}
onMessageCallback = (noticeData) => {
@ -324,6 +325,7 @@ class LibContentView extends React.Component {
this.unsubscribeEvent();
this.unsubscribeOpenTreePanel();
this.unsubscribeEventBus && this.unsubscribeEventBus();
this.unsubscribeSelectSearchedTag && this.unsubscribeSelectSearchedTag();
this.props.eventBus.dispatch(EVENT_BUS_TYPE.CURRENT_LIBRARY_CHANGED, {
repoID: '',
repoName: '',
@ -2263,6 +2265,7 @@ class LibContentView extends React.Component {
};
metadataStatusCallback = ({ enableMetadata, enableTags }) => {
this.props.eventBus.dispatch(EVENT_BUS_TYPE.TAG_STATUS, enableTags);
if (enableMetadata && enableTags) {
this.updateUsedRepoTags();
return;
@ -2270,6 +2273,10 @@ class LibContentView extends React.Component {
this.clearRepoTags();
};
tagsChangedCallback = (tags) => {
this.props.eventBus.dispatch(EVENT_BUS_TYPE.TAGS_CHANGED, tags);
};
render() {
const { repoID } = this.props;
let { currentRepoInfo, userPerm, isCopyMoveProgressDialogShow, isDeleteFolderDialogOpen, errorMsg,
@ -2346,7 +2353,7 @@ class LibContentView extends React.Component {
const detailDirent = currentDirent || currentNode?.object || null;
return (
<MetadataStatusProvider repoID={repoID} repoInfo={currentRepoInfo} hideMetadataView={this.hideMetadataView} statusCallback={this.metadataStatusCallback} >
<TagsProvider repoID={repoID} currentPath={path} repoInfo={currentRepoInfo} selectTagsView={this.onTreeNodeClick} >
<TagsProvider repoID={repoID} currentPath={path} repoInfo={currentRepoInfo} selectTagsView={this.onTreeNodeClick} tagsChangedCallback={this.tagsChangedCallback} >
<MetadataProvider repoID={repoID} currentPath={path} repoInfo={currentRepoInfo} selectMetadataView={this.onTreeNodeClick} >
<CollaboratorsProvider repoID={repoID}>
<div className="main-panel-center flex-row">

View File

@ -16,7 +16,7 @@ import { getColumnOriginName } from '../../metadata/utils/column';
// This hook provides content related to seahub interaction, such as whether to enable extended attributes, views data, etc.
const TagsContext = React.createContext(null);
export const TagsProvider = ({ repoID, currentPath, selectTagsView, children, ...params }) => {
export const TagsProvider = ({ repoID, currentPath, selectTagsView, tagsChangedCallback, children, ...params }) => {
const [isLoading, setLoading] = useState(true);
const [isReloading, setReloading] = useState(false);
@ -31,7 +31,8 @@ export const TagsProvider = ({ repoID, currentPath, selectTagsView, children, ..
const tagsChanged = useCallback(() => {
setTagsData(storeRef.current.data);
}, []);
tagsChangedCallback && tagsChangedCallback(storeRef.current.data.rows);
}, [tagsChangedCallback]);
const handleTableError = useCallback((error) => {
toaster.danger(error.error);

View File

@ -9,6 +9,7 @@ import { checkTreeNodeHasChildNodes, getTreeChildNodes, getTreeNodeDepth, getTre
import { getRowById } from '../../components/sf-table/utils/table';
import { SIDEBAR_INIT_LEFT_INDENT } from '../constants/sidebar-tree';
import { EVENT_BUS_TYPE } from '../../metadata/constants';
import { EVENT_BUS_TYPE as COMMON_EVENT_BUS_TYPE } from '../../components/common/event-bus-type';
import './index.css';
@ -104,6 +105,21 @@ const TagsTreeView = ({ currentPath }) => {
setKeyTreeNodeExpandedMap(getKeyTreeNodeExpandedMap());
}, [getKeyTreeNodeExpandedMap]);
useEffect(() => {
const unsubscribeUpdateSelectedTag = window.sfTagsDataContext?.eventBus?.subscribe(COMMON_EVENT_BUS_TYPE.UPDATE_SELECTED_TAG, (tagId) => {
if (tagId) {
const node = recordsTree.find((node) => getTreeNodeId(node) === tagId);
const nodeKey = getTreeNodeKey(node);
if (!nodeKey) return;
setCurrSelectedNodeKey(nodeKey);
}
});
return () => {
unsubscribeUpdateSelectedTag && unsubscribeUpdateSelectedTag();
};
}, [recordsTree]);
return (
<div className="tree-view tree metadata-tree-view metadata-tree-view-tag">
<div className="tree-node">