1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-26 07:22:34 +00:00

Optimize/tags filter UI (#7904)

* optimize tags editor

* update tags filter

* optimize

---------

Co-authored-by: zhouwenxuan <aries@Mac.local>
This commit is contained in:
Aries
2025-06-11 11:25:01 +08:00
committed by GitHub
parent 07de90eab0
commit 7aa257ce09
9 changed files with 119 additions and 115 deletions

View File

@@ -13,6 +13,7 @@
} }
.sf-metadata-tags-editor .sf-metadata-search-tags-container { .sf-metadata-tags-editor .sf-metadata-search-tags-container {
position: relative;
padding: 10px 10px 0; padding: 10px 10px 0;
} }

View File

@@ -13,6 +13,7 @@ import { SELECT_OPTION_COLORS } from '../../../constants';
import { PRIVATE_COLUMN_KEY as TAG_PRIVATE_COLUMN_KEY, RECENTLY_USED_TAG_IDS } from '../../../../tag/constants'; import { PRIVATE_COLUMN_KEY as TAG_PRIVATE_COLUMN_KEY, RECENTLY_USED_TAG_IDS } from '../../../../tag/constants';
import { checkIsTreeNodeShown, checkTreeNodeHasChildNodes, getNodesWithAncestors, getTreeNodeDepth, getTreeNodeId, getTreeNodeKey } from '../../../../components/sf-table/utils/tree'; import { checkIsTreeNodeShown, checkTreeNodeHasChildNodes, getNodesWithAncestors, getTreeNodeDepth, getTreeNodeId, getTreeNodeKey } from '../../../../components/sf-table/utils/tree';
import TagItem from './tag-item'; import TagItem from './tag-item';
import DeleteTag from './delete-tags';
import './index.css'; import './index.css';
@@ -27,6 +28,8 @@ const TagsEditor = forwardRef(({
showTagsAsTree, showTagsAsTree,
canEditData = false, canEditData = false,
canAddTag = false, canAddTag = false,
showDeletableTags = false,
showRecentlyUsed = true,
}, ref) => { }, ref) => {
const { tagsData, context, addTag } = useTags(); const { tagsData, context, addTag } = useTags();
@@ -419,10 +422,10 @@ const TagsEditor = forwardRef(({
const noOptionsTip = searchValue ? gettext('No tags available') : gettext('No tag'); const noOptionsTip = searchValue ? gettext('No tags available') : gettext('No tag');
return (<span className="none-search-result px-4">{noOptionsTip}</span>); return (<span className="none-search-result px-4">{noOptionsTip}</span>);
} }
const showRecentlyUsed = recentlyUsedTags.length > 0 && !searchValue; const isRecentlyUsedVisible = showRecentlyUsed && recentlyUsedTags.length > 0 && !searchValue;
return ( return (
<> <>
{showRecentlyUsed && ( {isRecentlyUsedVisible && (
<> <>
<div className="sf-metadata-tags-editor-title">{gettext('Recently used tags')}</div> <div className="sf-metadata-tags-editor-title">{gettext('Recently used tags')}</div>
{renderRecentlyUsed()} {renderRecentlyUsed()}
@@ -455,10 +458,11 @@ const TagsEditor = forwardRef(({
})} })}
</> </>
); );
}, [nodes, tagsData, value, highlightNodeIndex, searchValue, recentlyUsedTags, renderRecentlyUsed, toggleExpandTreeNode, keyNodeFoldedMap, searchedKeyNodeFoldedMap, handleSelectTags, onTreeMenuMouseEnter, onTreeMenuMouseLeave]); }, [nodes, tagsData, value, highlightNodeIndex, searchValue, recentlyUsedTags, keyNodeFoldedMap, searchedKeyNodeFoldedMap, showRecentlyUsed, renderRecentlyUsed, toggleExpandTreeNode, handleSelectTags, onTreeMenuMouseEnter, onTreeMenuMouseLeave]);
return ( return (
<div className={classnames('sf-metadata-tags-editor', { 'tags-tree-container': showTagsAsTree })} style={style} ref={editorRef}> <div className={classnames('sf-metadata-tags-editor', { 'tags-tree-container': showTagsAsTree })} style={style} ref={editorRef}>
{showDeletableTags && <DeleteTag value={value} tags={tagsData} onDelete={handleSelectTags} />}
<div className="sf-metadata-search-tags-container"> <div className="sf-metadata-search-tags-container">
<SearchInput <SearchInput
placeholder={gettext('Search tag')} placeholder={gettext('Search tag')}

View File

@@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { getTagColor, getTagId, getTagName } from '../../../../../tag/utils/cell'; import { getTagColor, getTagId, getTagName } from '../../../../../tag/utils/cell';
import { NODE_CONTENT_LEFT_INDENT, NODE_ICON_LEFT_INDENT } from '../../../../../components/sf-table/constants/tree'; import { NODE_CONTENT_LEFT_INDENT, NODE_ICON_LEFT_INDENT } from '../../../../../components/sf-table/constants/tree';
import Icon from '../../../../../components/icon';
import './index.css'; import './index.css';
@@ -50,7 +49,7 @@ const TagItem = ({
<div className="sf-metadata-tag-name">{tagName}</div> <div className="sf-metadata-tag-name">{tagName}</div>
</div> </div>
<div className="sf-metadata-tags-editor-tag-check-icon mr-1"> <div className="sf-metadata-tags-editor-tag-check-icon mr-1">
{isSelected && <Icon className="sf-metadata-icon" symbol="check-mark" />} {isSelected && <i className="sf2-icon-tick"></i>}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,6 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import CustomizeSelect from '../../../../../components/customize-select'; import CustomizeSelect from '../../../../../components/customize-select';
import Icon from '../../../../../components/icon';
import { gettext } from '../../../../../utils/constants'; import { gettext } from '../../../../../utils/constants';
const OPTIONS = [ const OPTIONS = [
@@ -21,7 +20,7 @@ const FileOrFolderFilter = ({ readOnly, value = 'all', onChange: onChangeAPI })
<div className="select-basic-filter-option"> <div className="select-basic-filter-option">
<div className="select-basic-filter-option-name" title={name} aria-label={name}>{name}</div> <div className="select-basic-filter-option-name" title={name} aria-label={name}>{name}</div>
<div className="select-basic-filter-option-check-icon"> <div className="select-basic-filter-option-check-icon">
{value === o.value && (<Icon symbol="check-mark" />)} {value === o.value && (<i className="sf2-icon-tick"></i>)}
</div> </div>
</div> </div>
) )
@@ -31,13 +30,7 @@ const FileOrFolderFilter = ({ readOnly, value = 'all', onChange: onChangeAPI })
const displayValue = useMemo(() => { const displayValue = useMemo(() => {
const selectedOption = OPTIONS.find(o => o.value === value) || OPTIONS[2]; const selectedOption = OPTIONS.find(o => o.value === value) || OPTIONS[2];
return { return { label: <>{selectedOption.name}</> };
label: (
<div>
{selectedOption.name}
</div>
)
};
}, [value]); }, [value]);
const onChange = useCallback((newValue) => { const onChange = useCallback((newValue) => {

View File

@@ -1,7 +1,6 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import CustomizeSelect from '../../../../../components/customize-select'; import CustomizeSelect from '../../../../../components/customize-select';
import Icon from '../../../../../components/icon';
import { gettext } from '../../../../../utils/constants'; import { gettext } from '../../../../../utils/constants';
const OPTIONS = [ const OPTIONS = [
@@ -21,7 +20,7 @@ const GalleryFileTypeFilter = ({ readOnly, value = 'picture', onChange: onChange
<div className="select-basic-filter-option"> <div className="select-basic-filter-option">
<div className="select-basic-filter-option-name" title={name} aria-label={name}>{name}</div> <div className="select-basic-filter-option-name" title={name} aria-label={name}>{name}</div>
<div className="select-basic-filter-option-check-icon"> <div className="select-basic-filter-option-check-icon">
{value === o.value && (<Icon symbol="check-mark" />)} {value === o.value && (<i className="sf2-icon-tick"></i>)}
</div> </div>
</div> </div>
) )
@@ -31,13 +30,7 @@ const GalleryFileTypeFilter = ({ readOnly, value = 'picture', onChange: onChange
const displayValue = useMemo(() => { const displayValue = useMemo(() => {
const selectedOption = OPTIONS.find(o => o.value === value) || OPTIONS[2]; const selectedOption = OPTIONS.find(o => o.value === value) || OPTIONS[2];
return { return { label: <>{selectedOption.name}</> };
label: (
<div>
{selectedOption.name}
</div>
)
};
}, [value]); }, [value]);
const onChange = useCallback((newValue) => { const onChange = useCallback((newValue) => {

View File

@@ -9,20 +9,42 @@
} }
.sf-metadata-basic-filters-select.seafile-customize-select:hover { .sf-metadata-basic-filters-select.seafile-customize-select:hover {
background: #f5f5f5; background: #efefef;
}
.sf-metadata-basic-filters-select.seafile-customize-select.highlighted:hover {
background-color: rgba(255, 152, 0, 0.2);
} }
.sf-metadata-basic-filters-select.seafile-customize-select .selected-option { .sf-metadata-basic-filters-select.seafile-customize-select .selected-option {
background-color: inherit; background-color: transparent;
}
.sf-metadata-basic-filters-select.seafile-customize-select .selected-option .sf3-font-down,
.sf-metadata-basic-filters-select.sf-metadata-basic-filter-tags-select .sf3-font-down {
margin-left: 0;
} }
.sf-metadata-basic-filters-select.seafile-customize-select.focus { .sf-metadata-basic-filters-select.seafile-customize-select.focus {
border-color: unset; border-color: unset;
box-shadow: none; box-shadow: none;
background-color: rgba(255, 152, 0, 0.2);
} }
.sf-metadata-basic-filters-select .selected-option-show { .sf-metadata-basic-filters-select.focus .selected-option .selected-option-show,
margin-right: 8px; .sf-metadata-basic-filters-select.focus .selected-option .sf3-font-down,
.sf-metadata-basic-filters-select.highlighted .selected-option .selected-option-show,
.sf-metadata-basic-filters-select.highlighted .selected-option .sf3-font-down {
color: #ff9800;
}
.sf-metadata-basic-filters-select.seafile-customize-select .selected-option .selected-option-show {
width: 100%;
}
.sf-metadata-basic-filters-select .selected-option-show,
.sf-metadata-basic-filters-select .select-placeholder {
margin-right: 4px;
} }
.sf-metadata-basic-filters-select .selected-option .custom-select-dropdown-icon { .sf-metadata-basic-filters-select .selected-option .custom-select-dropdown-icon {
@@ -75,3 +97,21 @@
.sf-metadata-table-view-basic-filter-file-type-select .select-basic-filter-option .select-basic-filter-option-checkbox input { .sf-metadata-table-view-basic-filter-file-type-select .select-basic-filter-option .select-basic-filter-option-checkbox input {
cursor: inherit; cursor: inherit;
} }
.sf-metadata-basic-filters-select .select-basic-filter-display-name:hover {
cursor: pointer;
}
.sf-metadata-basic-filter-tags-select .tag-options-container {
width: auto;
height: auto;
position: absolute;
top: 28px;
left: -340px;
z-index: 10001;
}
.sf-metadata-basic-filter-tags-select .tag-options-container .sf-metadata-delete-select-tags {
padding: 8px 16px;
min-height: 28px;
}

View File

@@ -53,7 +53,7 @@ const BasicFilters = ({ readOnly, filters = [], onChange, viewType }) => {
return (<FileTypeFilter key={column_key} readOnly={readOnly} value={filter_term} onChange={onChangeFileTypeFilter} />); return (<FileTypeFilter key={column_key} readOnly={readOnly} value={filter_term} onChange={onChangeFileTypeFilter} />);
} }
if (column_key === PRIVATE_COLUMN_KEY.TAGS) { if (column_key === PRIVATE_COLUMN_KEY.TAGS) {
return (<TagsFilter key={column_key} readOnly={readOnly} value={filter_term} onChange={onChangeTagsFilter} />); return (<TagsFilter key={column_key} value={filter_term} onChange={onChangeTagsFilter} />);
} }
return null; return null;
})} })}

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames';
import CustomizeSelect from '../../../../../components/customize-select'; import CustomizeSelect from '../../../../../components/customize-select';
import { gettext } from '../../../../../utils/constants'; import { gettext } from '../../../../../utils/constants';
import { getFileTypeColumnOptions } from '../../../../utils/column'; import { getFileTypeColumnOptions } from '../../../../utils/column';
@@ -33,15 +34,8 @@ const TableFileTypeFilter = ({ readOnly, value, onChange: onChangeAPI }) => {
}, [OPTIONS, value]); }, [OPTIONS, value]);
const displayValue = useMemo(() => { const displayValue = useMemo(() => {
const selectedOptions = OPTIONS.filter(o => value.includes(o.value)); return { label: <>{gettext('File type')}</> };
return { }, []);
label: (
<div className="select-basic-filter-display-name">
{selectedOptions.length > 0 ? selectedOptions.map(o => o.name).join(', ') : gettext('File type')}
</div>
)
};
}, [OPTIONS, value]);
const onChange = useCallback((newValue) => { const onChange = useCallback((newValue) => {
if (value.includes(newValue)) { if (value.includes(newValue)) {
@@ -54,7 +48,9 @@ const TableFileTypeFilter = ({ readOnly, value, onChange: onChangeAPI }) => {
return ( return (
<CustomizeSelect <CustomizeSelect
readOnly={readOnly} readOnly={readOnly}
className="sf-metadata-basic-filters-select sf-metadata-table-view-basic-filter-file-type-select mr-4" className={classNames('sf-metadata-basic-filters-select sf-metadata-table-view-basic-filter-file-type-select mr-4', {
'highlighted': value.length > 0,
})}
value={displayValue} value={displayValue}
options={options} options={options}
onSelectOption={onChange} onSelectOption={onChange}

View File

@@ -1,21 +1,23 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo, useRef, useState } from 'react';
import { UncontrolledTooltip } from 'reactstrap'; import { UncontrolledTooltip } from 'reactstrap';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import CustomizeSelect from '../../../../../components/customize-select';
import Icon from '../../../../../components/icon'; import Icon from '../../../../../components/icon';
import FileTagsFormatter from '../../../cell-formatter/file-tags';
import { gettext } from '../../../../../utils/constants'; import { gettext } from '../../../../../utils/constants';
import { useMetadataStatus } from '../../../../../hooks'; import { useMetadataStatus } from '../../../../../hooks';
import { useTags } from '../../../../../tag/hooks'; import { useTags } from '../../../../../tag/hooks';
import { getTagId, getTagName, getTagColor } from '../../../../../tag/utils/cell';
import { getRowById } from '../../../../../components/sf-table/utils/table'; import { getRowById } from '../../../../../components/sf-table/utils/table';
import IconBtn from '../../../../../components/icon-btn'; import IconBtn from '../../../../../components/icon-btn';
import ClickOutside from '../../../../../components/click-outside';
import TagsEditor from '../../../cell-editors/tags-editor';
import classNames from 'classnames';
const TagsFilter = ({ readOnly, value: oldValue, onChange: onChangeAPI }) => { const TagsFilter = ({ value: oldValue, onChange: onChangeAPI }) => {
const [isOptionsVisible, setIsOptionsVisible] = useState(false);
const { tagsData } = useTags(); const { tagsData } = useTags();
const { enableTags } = useMetadataStatus(); const { enableTags } = useMetadataStatus();
const invalidFilterTip = React.createRef(); const invalidFilterTip = useRef(null);
const ref = useRef(null);
const value = useMemo(() => { const value = useMemo(() => {
if (!enableTags) return []; if (!enableTags) return [];
@@ -24,62 +26,22 @@ const TagsFilter = ({ readOnly, value: oldValue, onChange: onChangeAPI }) => {
return oldValue.filter(tagId => getRowById(tagsData, tagId)); return oldValue.filter(tagId => getRowById(tagsData, tagId));
}, [oldValue, tagsData, enableTags]); }, [oldValue, tagsData, enableTags]);
const options = useMemo(() => { const onSelectToggle = useCallback(() => {
if (!tagsData) return []; setIsOptionsVisible(!isOptionsVisible);
const tags = tagsData?.rows || []; }, [isOptionsVisible]);
if (tags.length === 0) return [];
return tags.map(tag => {
const tagId = getTagId(tag);
const tagName = getTagName(tag);
const tagColor = getTagColor(tag);
const isSelected = Array.isArray(value) ? value.includes(tagId) : false;
return {
name: tagName,
value: tagId,
label: (
<div className="select-basic-filter-option">
<div className="sf-metadata-tag-color-and-name">
<div className="sf-metadata-tag-color" style={{ backgroundColor: tagColor }}></div>
<div className="sf-metadata-tag-name">{tagName}</div>
</div>
<div className="select-basic-filter-option-check-icon">
{isSelected && (<Icon symbol="check-mark" />)}
</div>
</div>
)
};
});
}, [value, tagsData]);
const displayValue = useMemo(() => { const onClickOutside = useCallback((e) => {
const emptyTip = { if (!ref.current.contains(e.target)) {
label: ( setIsOptionsVisible(false);
<div className="select-basic-filter-display-name">
{gettext('Tags')}
</div>
),
};
if (!tagsData) return emptyTip;
const tags = tagsData?.rows || [];
if (tags.length === 0) emptyTip;
if (!Array.isArray(value) || value.length === 0) return emptyTip;
const selectedTags = value.map(tagId => getRowById(tagsData, tagId)).filter(item => item).map(tag => ({ row_id: getTagId(tag) }));
if (selectedTags.length === 0) return emptyTip;
return {
label: (
<div className="select-basic-filter-display-name">
<FileTagsFormatter className="sf-metadata-basic-tags-filter-formatter pr-2" tagsData={tagsData} value={selectedTags} />
</div>
)
};
}, [value, tagsData]);
const onChange = useCallback((newValue) => {
if (value.includes(newValue)) {
onChangeAPI(value.filter(v => v !== newValue));
} else {
onChangeAPI([...value, newValue]);
} }
}, []);
const handleSelect = useCallback((tagId) => {
onChangeAPI([...value, tagId]);
}, [value, onChangeAPI]);
const handleDeselect = useCallback((tagId) => {
onChangeAPI(value.filter(v => v !== tagId));
}, [value, onChangeAPI]); }, [value, onChangeAPI]);
const onDeleteFilter = useCallback((event) => { const onDeleteFilter = useCallback((event) => {
@@ -89,7 +51,6 @@ const TagsFilter = ({ readOnly, value: oldValue, onChange: onChangeAPI }) => {
oldValue = []; oldValue = [];
}, [value, onChangeAPI, oldValue]); }, [value, onChangeAPI, oldValue]);
const renderErrorMessage = () => { const renderErrorMessage = () => {
return ( return (
<div className="ml-2"> <div className="ml-2">
@@ -108,19 +69,38 @@ const TagsFilter = ({ readOnly, value: oldValue, onChange: onChangeAPI }) => {
); );
}; };
const renderCustomizeSelect = () => { const renderTagsTree = () => {
return ( return (
<CustomizeSelect <div
readOnly={readOnly} ref={ref}
searchable={true} className={classNames('sf-metadata-basic-filters-select sf-metadata-basic-filter-tags-select seafile-customize-select custom-select mr-4', {
supportMultipleSelect={true} 'focus': isOptionsVisible,
className="sf-metadata-basic-filters-select sf-metadata-table-view-basic-filter-file-type-select mr-4" 'highlighted': value.length > 0
value={displayValue} })}
options={options} >
onSelectOption={onChange} <div
searchPlaceholder={gettext('Search tag')} className="selected-option"
noOptionsPlaceholder={gettext('No tags')} onClick={onSelectToggle}
/> >
<span className="selected-option-show">{gettext('Tags')}</span>
<i className="sf3-font sf3-font-down" aria-hidden="true"></i>
</div>
{isOptionsVisible && (
<ClickOutside onClickOutside={onClickOutside}>
<div className="tag-options-container">
<TagsEditor
value={value.map(tagId => ({ row_id: tagId }))}
column={{ width: 400 }}
onSelect={handleSelect}
onDeselect={handleDeselect}
showTagsAsTree={true}
showDeletableTags={true}
showRecentlyUsed={false}
/>
</div>
</ClickOutside>
)}
</div>
); );
}; };
@@ -128,7 +108,7 @@ const TagsFilter = ({ readOnly, value: oldValue, onChange: onChangeAPI }) => {
if (oldValue.length !== 0) { if (oldValue.length !== 0) {
return ( return (
<div> <div>
{renderCustomizeSelect()} {renderTagsTree()}
{renderErrorMessage()} {renderErrorMessage()}
<div className="delete-filter" onClick={onDeleteFilter}> <div className="delete-filter" onClick={onDeleteFilter}>
<Icon className="sf-metadata-icon" symbol="fork-number"/> <Icon className="sf-metadata-icon" symbol="fork-number"/>
@@ -141,9 +121,7 @@ const TagsFilter = ({ readOnly, value: oldValue, onChange: onChangeAPI }) => {
} }
return ( return (
<div> <>{renderTagsTree()}</>
{renderCustomizeSelect()}
</div>
); );
}; };