1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-28 16:17:02 +00:00

add-search-trash (#8045)

* add-search-trash

* update

* Update settings.py

* Update __init__.py

* change search trash style

* update

* Update models.py

* change search trash style and text

* change error text

* Update models.py

---------

Co-authored-by: r350178982 <32759763+r350178982@users.noreply.github.com>
Co-authored-by: Michael An <1822852997@qq.com>
This commit is contained in:
kaichen999
2025-07-22 13:16:20 +08:00
committed by GitHub
parent 104802c7f4
commit 1f2c8ed3cc
16 changed files with 1236 additions and 20 deletions

View File

@@ -13,6 +13,7 @@ import Paginator from '../../paginator';
import Loading from '../../loading';
import BackIcon from '../../back-icon';
import EmptyTip from '../../empty-tip';
import SearchTrash from './trash-search/search-trash';
import '../../../css/toolbar.css';
import '../../../css/search.css';
@@ -33,7 +34,10 @@ class TrashDialog extends React.Component {
isOldTrashDialogOpen: false,
currentPage: 1,
perPage: 100,
hasNextPage: false
hasNextPage: false,
searchKeyword: '',
filteredItems: [],
canSearch: true,
};
}
@@ -41,9 +45,23 @@ class TrashDialog extends React.Component {
this.getFolderTrash();
}
handleSearchResults = (result) => {
if (result?.reset) {
this.getFolderTrash(1);
return;
}
const items = result?.items || [];
this.setState({
items,
hasNextPage: result?.hasMore || false
});
};
getFolderTrash = (page) => {
repoTrashAPI.getRepoFolderTrash(this.props.repoID, page, this.state.perPage).then((res) => {
const { items, total_count } = res.data;
const { items, total_count, can_search } = res.data;
if (!page) {
page = 1;
}
@@ -52,7 +70,8 @@ class TrashDialog extends React.Component {
hasNextPage: total_count - page * this.state.perPage > 0,
isLoading: false,
items: items,
more: false
more: false,
canSearch: can_search
});
});
};
@@ -68,6 +87,26 @@ class TrashDialog extends React.Component {
}
};
searchTrash = (query) => {
this.setState({ isSearching: true });
repoTrashAPI.searchRepoFolderTrash(this.props.repoID, query)
.then((res) => {
this.setState({
isSearching: false,
items: res.data,
currentPage: 1,
hasNextPage: false
});
})
.catch((error) => {
this.setState({
isSearching: false,
errorMsg: gettext('Search failed')
});
});
};
getPreviousPage = () => {
this.getFolderTrash(this.state.currentPage - 1);
};
@@ -119,7 +158,8 @@ class TrashDialog extends React.Component {
seafileAPI.listCommitDir(this.props.repoID, commitID, `${baseDir.substr(0, baseDir.length - 1)}${folderPath}`).then((res) => {
this.setState({
isLoading: false,
folderItems: res.data.dirent_list
folderItems: res.data.dirent_list,
canSearch: false,
});
}).catch((error) => {
if (error.response) {
@@ -182,7 +222,7 @@ class TrashDialog extends React.Component {
render() {
const { showTrashDialog, toggleTrashDialog, repoID } = this.props;
const { isCleanTrashDialogOpen, showFolder, isLoading, items, perPage, currentPage, hasNextPage } = this.state;
const { isCleanTrashDialogOpen, showFolder, isLoading, items, perPage, currentPage, hasNextPage, canSearch } = this.state;
const isRepoAdmin = this.props.currentRepoInfo.owner_email === username || this.props.currentRepoInfo.is_admin;
const repoFolderName = this.props.currentRepoInfo.repo_name;
const oldTrashUrl = siteRoot + 'repo/' + this.props.repoID + '/trash/';
@@ -217,19 +257,38 @@ class TrashDialog extends React.Component {
</ModalHeader>
<ModalBody>
{isLoading && <Loading />}
{!isLoading && canSearch &&
<SearchTrash
repoID={this.props.repoID}
onSearchResults={this.handleSearchResults}
placeholder={gettext('Search in trash')}
/>
}
{!isLoading && items.length === 0 &&
<EmptyTip text={gettext('No file')} className="m-0" />
}
{!isLoading && items.length > 0 &&
<>
<div className="path-container dir-view-path mw-100 pb-2">
<div className="path-container dir-view-path mw-100 pb-2 mt-1">
<span className="path-label mr-1">{gettext('Current path: ')}</span>
{showFolder ?
this.renderFolderPath() :
<span className="last-path-item" title={repoFolderName}>{repoFolderName}</span>
}
</div>
<Table repoID={repoID} data={this.state} renderFolder={this.renderFolder} isDesktop={isDesktop} />
<Table
repoID={repoID}
data={{
items: items,
showFolder: showFolder,
commitID: this.state.commitID,
baseDir: this.state.baseDir,
folderPath: this.state.folderPath,
folderItems: this.state.folderItems
}}
renderFolder={this.renderFolder}
isDesktop={isDesktop}
/>
<Paginator
gotoPreviousPage={this.getPreviousPage}
gotoNextPage={this.getNextPage}

View File

@@ -11,7 +11,7 @@ const Table = ({ repoID, renderFolder, data, isDesktop }) => {
{ isFixed: true, width: 40, className: 'pl-2 pr-2' },
{ isFixed: false, width: 0.25, children: gettext('Name') },
{ isFixed: false, width: 0.4, children: gettext('Original path') },
{ isFixed: false, width: 0.12, children: gettext('Delete Time') },
{ isFixed: false, width: 0.12, children: gettext('Deleted time') },
{ isFixed: false, width: 0.13, children: gettext('Size') },
{ isFixed: false, width: 0.1, children: '' },
], []);

View File

@@ -0,0 +1,150 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Dropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import isHotkey from 'is-hotkey';
import { gettext } from '../../../../../utils/constants';
import { Utils } from '../../../../../utils/utils';
import UserItem from './user-item';
import { seafileAPI } from '../../../../../utils/seafile-api';
import ModalPortal from '../../../../modal-portal';
import toaster from '../../../../toast';
import { SEARCH_FILTERS_KEY } from '../../../../../constants';
const FilterByCreator = ({ creatorList, onChange }) => {
const [isOpen, setIsOpen] = useState(false);
const [options, setOptions] = useState([]);
const [selectedOptions, setSelectedOptions] = useState(creatorList || []);
const [searchValue, setSearchValue] = useState('');
const [inputFocus, setInputFocus] = useState(false);
const toggle = useCallback((e) => {
setIsOpen(!isOpen);
}, [isOpen]);
const displayOptions = useMemo(() => {
if (!searchValue) return null;
return options.filter((option) => {
return option.name.toLowerCase().includes(searchValue.toLowerCase());
});
}, [options, searchValue]);
const onChangeOption = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
const name = Utils.getEventData(e, 'toggle') ?? e.currentTarget.getAttribute('data-toggle');
let updated = [...selectedOptions];
if (!updated.some((item) => item.name === name)) {
const newOption = options.find((option) => option.name === name);
updated = [...updated, newOption];
} else {
updated = updated.filter((option) => option.name !== name);
}
setSelectedOptions(updated);
onChange(SEARCH_FILTERS_KEY.CREATOR_LIST, updated);
if (displayOptions.length === 1) {
setSearchValue('');
}
}, [selectedOptions, displayOptions, options, onChange]);
const handleCancel = useCallback((e, name) => {
const updated = selectedOptions.filter((option) => option.name !== name);
setSelectedOptions(updated);
onChange(SEARCH_FILTERS_KEY.CREATOR_LIST, updated);
}, [selectedOptions, onChange]);
const handleInputChange = useCallback((e) => {
const v = e.target.value;
setSearchValue(v);
if (!selectedOptions) {
setOptions([]);
}
}, [selectedOptions]);
const handleInputKeyDown = useCallback((e) => {
if (isHotkey('enter')(e)) {
e.preventDefault();
e.stopPropagation();
setSearchValue('');
toggle();
}
}, [toggle]);
useEffect(() => {
if (!searchValue) return;
const getUsers = async () => {
try {
const res = await seafileAPI.searchUsers(searchValue);
const userList = res.data.users.filter(user => user.name.toLowerCase().includes(searchValue.toLowerCase()));
setOptions(userList);
} catch (err) {
toaster.danger(Utils.getErrorMsg(err));
}
};
getUsers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchValue]);
return (
<div className="search-filter filter-by-creator-container">
<Dropdown isOpen={isOpen} toggle={toggle}>
<DropdownToggle tag="div" className={classNames('search-filter-toggle', {
'active': isOpen && selectedOptions.length > 0,
'highlighted': selectedOptions.length > 0,
})}>
<div className="filter-label" title={gettext('Deleted by')}>{gettext('Deleted by')}</div>
<i className="sf3-font sf3-font-down sf3-font pl-1" />
</DropdownToggle>
<ModalPortal>
<DropdownMenu className="search-filter-menu filter-by-creator-menu">
<div className={classNames('input-container', { 'focus': inputFocus })}>
{selectedOptions.map((option) => (
<UserItem
key={option.name}
user={option}
isCancellable={true}
onCancel={handleCancel}
/>
))}
<div className="search-input-wrapper">
<input
type="text"
placeholder={selectedOptions.length ? '' : gettext('Search user')}
value={searchValue}
autoFocus
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onFocus={() => setInputFocus(true)}
onBlur={() => setInputFocus(false)}
/>
</div>
</div>
{displayOptions && displayOptions.map((option) => (
<DropdownItem
key={option.name}
tag="div"
tabIndex="-1"
data-toggle={option.name}
onMouseDown={(e) => e.preventDefault()}
onClick={onChangeOption}
toggle={false}
>
{isOpen && <UserItem user={option} />}
{selectedOptions.includes(option.name) && <i className="dropdown-item-tick sf2-icon-tick"></i>}
</DropdownItem>
))}
</DropdownMenu>
</ModalPortal>
</Dropdown>
</div>
);
};
FilterByCreator.propTypes = {
creatorList: PropTypes.array.isRequired,
onChange: PropTypes.func.isRequired,
};
export default FilterByCreator;

View File

@@ -0,0 +1,196 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { Dropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
import dayjs from 'dayjs';
import { gettext } from '../../../../../utils/constants';
import { Utils } from '../../../../../utils/utils';
import Picker from '../../../../date-and-time-picker';
import ModalPortal from '../../../../modal-portal';
import { SEARCH_FILTERS_KEY, SEARCH_FILTER_BY_DATE_OPTION_KEY } from '../../../../../constants';
import classNames from 'classnames';
const DATE_INPUT_WIDTH = 118;
const FilterByDate = ({ date, onChange }) => {
const [value, setValue] = useState(date.value);
const [isOpen, setIsOpen] = useState(false);
const [isCustomDate, setIsCustomDate] = useState(date.value === SEARCH_FILTER_BY_DATE_OPTION_KEY.CUSTOM);
const [time, setTime] = useState({
from: date.from,
to: date.to,
});
const label = useMemo(() => {
if (!value || value.length === 0) return gettext('Deleted time');
return gettext('Deleted time');
}, [value]);
const options = useMemo(() => {
return [
{
key: SEARCH_FILTER_BY_DATE_OPTION_KEY.TODAY,
label: gettext('Today'),
}, {
key: SEARCH_FILTER_BY_DATE_OPTION_KEY.LAST_7_DAYS,
label: gettext('Last 7 days'),
}, {
key: SEARCH_FILTER_BY_DATE_OPTION_KEY.LAST_30_DAYS,
label: gettext('Last 30 days'),
},
'Divider',
{
key: SEARCH_FILTER_BY_DATE_OPTION_KEY.CUSTOM,
label: gettext('Custom time'),
},
];
}, []);
const toggle = useCallback(() => setIsOpen(!isOpen), [isOpen]);
const onClearDate = useCallback(() => {
setValue('');
setIsCustomDate(false);
setTime({ from: null, to: null });
setIsOpen(false);
}, []);
const onOptionClick = useCallback((e) => {
const option = Utils.getEventData(e, 'toggle') ?? e.currentTarget.getAttribute('data-toggle');
if (option === value) return;
const today = dayjs().endOf('day');
const isCustomOption = option === SEARCH_FILTER_BY_DATE_OPTION_KEY.CUSTOM;
setIsCustomDate(isCustomOption);
setValue(option);
setIsOpen(isCustomOption);
switch (option) {
case SEARCH_FILTER_BY_DATE_OPTION_KEY.TODAY:
setTime({ from: dayjs().startOf('day').unix(), to: today.unix() });
break;
case SEARCH_FILTER_BY_DATE_OPTION_KEY.LAST_7_DAYS:
setTime({ from: dayjs().subtract(6, 'day').startOf('day').unix(), to: today.unix() });
break;
case SEARCH_FILTER_BY_DATE_OPTION_KEY.LAST_30_DAYS:
setTime({ from: dayjs().subtract(30, 'day').startOf('day').unix(), to: today.unix() });
break;
case SEARCH_FILTER_BY_DATE_OPTION_KEY.CUSTOM:
setTime({ from: null, to: null });
break;
}
}, [value]);
const disabledStartDate = useCallback((startDate) => {
if (!startDate) return false;
const today = dayjs();
const endValue = time.to;
if (!endValue) return startDate.isAfter(today);
return endValue.isBefore(startDate) || startDate.isAfter(today);
}, [time]);
const disabledEndDate = useCallback((endDate) => {
if (!endDate) return false;
const today = dayjs();
const startValue = time.from;
if (!startValue) return endDate.isAfter(today);
return endDate.isBefore(startValue) || endDate.isAfter(today);
}, [time]);
useEffect(() => {
if (!isOpen) {
if (value !== date.value || time.from !== date.from || time.to !== date.to) {
onChange(SEARCH_FILTERS_KEY.DATE, {
value,
from: time.from,
to: time.to,
});
}
}
}, [isOpen, date, time, value, onChange]);
return (
<div className="search-filter filter-by-date-container">
<Dropdown isOpen={isOpen} toggle={toggle}>
<DropdownToggle
tag="div"
className={classNames('search-filter-toggle', {
'active': isOpen && value,
'highlighted': value,
})}
onClick={toggle}
>
<div className="filter-label" style={{ maxWidth: 300 }} title={label}>{label}</div>
<i
className="sf3-font sf3-font-down pl-1"
onClick={(e) => {
e.stopPropagation();
toggle();
}}
/>
</DropdownToggle>
<ModalPortal>
<DropdownMenu className="search-filter-menu filter-by-date-menu">
<div className="filter-by-date-menu-toolbar">
<div className="delete-btn" onClick={onClearDate}>
<i className="op-icon sf3-font-delete1 sf3-font"></i>
</div>
</div>
{options.map((option, i) => {
const isSelected = option.key === value;
if (option === 'Divider') return <div key={i} className="seafile-divider dropdown-divider"></div>;
return (
<DropdownItem
key={option.key}
tag="div"
tabIndex="-1"
data-toggle={option.key}
onMouseDown={(e) => e.preventDefault()}
onClick={onOptionClick}
toggle={false}
>
{option.label}
{isSelected && <i className="dropdown-item-tick sf2-icon-tick"></i>}
</DropdownItem>
);
})}
{isCustomDate && (
<div className="filter-by-date-custom-date-container">
<div className="custom-date-container">
<div className="custom-date-label">{gettext('Start date')}</div>
<Picker
showHourAndMinute={false}
disabledDate={disabledStartDate}
value={time.from ? dayjs.unix(time.from) : null}
onChange={(value) => setTime({ ...time, from: dayjs(value).unix() })}
inputWidth={DATE_INPUT_WIDTH}
/>
</div>
<div className="custom-date-container">
<div className="custom-date-label">{gettext('End date')}</div>
<Picker
showHourAndMinute={false}
disabledDate={disabledEndDate}
value={time.to ? dayjs.unix(time.to) : null}
onChange={(value) => setTime({ ...time, to: dayjs(value).unix() })}
inputWidth={DATE_INPUT_WIDTH}
/>
</div>
</div>
)}
</DropdownMenu>
</ModalPortal>
</Dropdown>
</div>
);
};
FilterByDate.propTypes = {
date: PropTypes.shape({
value: PropTypes.string,
from: PropTypes.oneOfType([PropTypes.number, PropTypes.object]),
to: PropTypes.oneOfType([PropTypes.number, PropTypes.object]),
}),
onChange: PropTypes.func.isRequired,
};
export default FilterByDate;

View File

@@ -0,0 +1,84 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Dropdown, DropdownMenu, DropdownToggle } from 'reactstrap';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { gettext } from '../../../../../utils/constants';
import ModalPortal from '../../../../modal-portal';
import { SEARCH_FILTERS_KEY } from '../../../../../constants';
const FilterBySuffix = ({ suffixes, onChange }) => {
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState(suffixes);
const inputRef = useRef(null);
const toggle = useCallback(() => setIsOpen(!isOpen), [isOpen]);
const handleInput = useCallback((e) => {
setInputValue(e.target.value);
}, []);
const handleKeyDown = useCallback((e) => {
e.stopPropagation();
if (e.key === 'Enter') {
setIsOpen(false);
onChange(SEARCH_FILTERS_KEY.SUFFIXES, inputValue.replace(/\./g, ''));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleClearInput = useCallback(() => {
setInputValue('');
setIsOpen(false);
}, []);
useEffect(() => {
if (!isOpen && inputValue !== suffixes) {
onChange(SEARCH_FILTERS_KEY.SUFFIXES, inputValue.replace(/\./g, ''));
}
}, [isOpen, inputValue, suffixes, onChange]);
return (
<div className="search-filter filter-by-suffix-container">
<Dropdown isOpen={isOpen} toggle={toggle}>
<DropdownToggle tag="div" className={classNames('search-filter-toggle', {
'active': isOpen && inputValue.length > 0,
'highlighted': inputValue.length > 0,
})} onClick={toggle}>
<div className="filter-label" title={gettext('File suffix')}>{gettext('File suffix')}</div>
<i className="sf3-font sf3-font-down pl-1" />
</DropdownToggle>
<ModalPortal>
<DropdownMenu className="search-filter-menu filter-by-suffix-menu p-4">
<input
ref={inputRef}
type="text"
className="form-control"
placeholder={gettext('Separate multiple suffixes by ","(like sdoc, pdf)')}
value={inputValue}
autoFocus
onChange={handleInput}
onKeyDown={handleKeyDown}
/>
{inputValue.length > 0 && (
<button
type="button"
className="clear-icon-right sf3-font sf3-font-x-01"
onClick={handleClearInput}
aria-label={gettext('Clear')}
>
</button>
)}
</DropdownMenu>
</ModalPortal>
</Dropdown>
</div>
);
};
FilterBySuffix.propTypes = {
suffixes: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
};
export default FilterBySuffix;

View File

@@ -0,0 +1,233 @@
.search-filters-container {
min-height: 32px;
position: relative;
display: flex;
justify-content: flex-start;
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 {
width: fit-content;
display: flex;
align-items: center;
margin-right: 8px;
}
.search-filters-container .search-filter .search-filter-toggle,
.search-filter-menu .search-filter-toggle {
display: flex;
align-items: center;
cursor: pointer;
padding: 2px 4px;
border-radius: 3px;
}
.search-filters-container .search-filter .search-filter-toggle:hover,
.search-filter-menu .search-filter-toggle:hover {
background-color: #efefef;
}
.search-filter-toggle .sf3-font-down {
color: #666;
}
.search-filters-container .search-filter .filter-label {
width: fit-content;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-filters-container .search-filter.filter-by-suffix-container .filter-label,
.search-filters-container .search-filter.filter-by-creator-container .filter-label {
max-width: 120px;
}
.search-filter-menu.filter-by-text-menu,
.search-filter-menu.filter-by-date-menu,
.search-filter-menu.filter-by-creator-menu {
width: 280px;
}
.search-filter-menu.filter-by-suffix-menu {
width: 400px;
position: relative;
}
.search-filter-menu.filter-by-suffix-menu .clear-icon-right {
width: 20px;
height: 20px;
position: absolute;
top: 16px;
right: 16px;
display: flex;
justify-content: center;
align-items: center;
color: #666;
margin: 9px;
border: 0;
border-radius: 3px;
background-color: transparent;
padding: 0;
}
.search-filter-menu.filter-by-suffix-menu .clear-icon-right:hover {
background-color: #efefef;
}
.search-filters-container .search-filters-dropdown-item {
width: 100%;
}
.search-filter-menu {
z-index: 1050;
}
.search-filter-menu .dropdown-item {
position: relative;
}
.search-filter-menu .dropdown-item .dropdown-item-tick {
width: 1rem;
left: auto;
right: 16px;
}
.search-filter-menu .input-container {
position: relative;
border: 1px solid #eaeaea;
border-radius: 4px;
padding: 4px 6px;
margin: 8px 16px;
min-height: 28px;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.search-filter-menu .input-container.focus {
background-color: #fff;
border-color: #1991eb;
box-shadow: 0 0 0 2px rgba(70, 127, 207, .25);
color: #495057;
outline: 0;
}
.search-filter-menu .input-container .search-input-wrapper {
display: flex;
align-items: center;
border: none;
padding: 0px;
width: auto;
background: transparent;
font-size: inherit;
line-height: 20px;
flex: 1 1 60px;
min-width: 60px;
border-radius: 4px;
}
.search-filter-menu .input-container input {
border: none;
outline: none;
width: 100%;
display: block;
resize: none;
padding: 0px;
height: 20px;
}
.search-filter-menu .user-item {
height: 20px;
position: relative;
display: inline-flex;
align-items: center;
justify-content: flex-start;
}
.search-filter-menu .user-item .user-avatar {
width: 16px;
height: 16px;
margin-right: 4px;
border-radius: 50%;
}
.search-filter-menu .input-container .user-item {
background-color: #eaeaea;
border-radius: 10px;
padding: 0 4px 0 2px;
margin-right: 2px;
font-size: 13px;
}
.filter-by-date-menu .filter-by-date-menu-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 12px;
}
.filter-by-date-menu .filter-by-date-menu-toolbar .filter-by-date-type-toggle {
display: flex;
align-items: center;
}
.filter-by-date-menu .filter-by-date-menu-toolbar .filter-by-date-type-toggle .filter-label {
color: #7d7d7d;
}
.filter-by-date-menu .filter-by-date-menu-toolbar .delete-btn {
display: flex;
align-items: center;
justify-content: center;
margin-right: 4px;
cursor: pointer;
}
.filter-by-date-menu .filter-by-date-custom-date-container {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 16px;
gap: 8px;
}
.filter-by-date-menu .filter-by-date-custom-date-container .custom-date-container {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
font-size: 14px;
}
.filter-by-date-menu .filter-by-date-custom-date-container .custom-date-container .custom-date-label {
margin-bottom: 6px;
}
.filter-by-date-menu .filter-by-date-custom-date-container .custom-date-container .form-control {
height: 28px;
font-size: 14px;
}
.search-filters-container .search-filter-toggle.active:hover,
.search-filters-container .search-filter-toggle.highlighted:hover {
background-color: #efefef;
}
.search-filters-container .search-filter-toggle.highlighted,
.search-filter-toggle.highlighted .sf3-font-down {
color: #ed7109;
}
.search-filter-menu {
z-index: 9999 !important;
position: relative;
}

View File

@@ -0,0 +1,24 @@
import React from 'react';
import PropTypes from 'prop-types';
import FilterByCreator from './filter-by-creator';
import FilterByDate from './filter-by-date';
import FilterBySuffix from './filter-by-suffix';
import './index.css';
const TrashFilters = ({ filters, onChange }) => {
return (
<div className="search-filters-container px-0">
<FilterBySuffix suffixes={filters.suffixes} onChange={onChange} />
<FilterByCreator creatorList={filters.creator_list} onChange={onChange} />
<FilterByDate date={filters.date} onChange={onChange} />
</div>
);
};
TrashFilters.propTypes = {
filters: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
};
export default TrashFilters;

View File

@@ -0,0 +1,22 @@
import React from 'react';
import PropTypes from 'prop-types';
import { mediaUrl } from '../../../../../utils/constants';
import IconBtn from '../../../../icon-btn';
const UserItem = ({ user, isCancellable, onCancel }) => {
return (
<div className="user-item">
<img src={user.avatar_url || `${mediaUrl}avatars/default.png`} alt={user.name} className="user-avatar" />
<span className="user-name">{user.name}</span>
{isCancellable && <IconBtn className="user-remove" onClick={(e) => onCancel(e, user.name)} symbol="x-01" />}
</div>
);
};
UserItem.propTypes = {
user: PropTypes.object.isRequired,
isCancellable: PropTypes.bool,
onCancel: PropTypes.func,
};
export default UserItem;

View File

@@ -0,0 +1,57 @@
.search-trash .search-trash-input {
width: 100%;
height: 38px;
min-width: 0;
flex: 1;
}
.search-trash.search-container .input-icon {
margin: 0px;
}
.search-trash .trash-input-icon {
position: absolute;
right: 16px;
display: flex;
justify-content: center;
align-items: center;
}
.search-trash .input-icon .search-icon-right.search-filter-controller.active {
color: #ed7109;
}
.search-trash .search-icon-left {
position: absolute;
top: 8px;
left: 12px;
color: #666;
}
.search-trash .search-icon-right {
position: absolute;
top: 1px;
right: 32px;
display: flex;
justify-content: center;
align-items: center;
color: #666;
}
.search-trash #search-filter-controller {
position: absolute;
top: 10px;
right: 12px;
color: #666;
cursor: pointer;
height: 20px;
width: 20px;
display: flex;
justify-content: center;
align-items: center;
}
.search-trash #search-filter-controller:hover {
background-color: var(--bs-hover-bg);
border-radius: 3px;
}

View File

@@ -0,0 +1,161 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import axios from 'axios';
import classnames from 'classnames';
import { gettext } from '../../../../utils/constants';
import { debounce, Utils } from '../../../../utils/utils';
import toaster from '../../../toast';
import Loading from '../../../loading';
import IconBtn from '../../../icon-button';
import { SEARCH_FILTERS_SHOW_KEY } from '../../../../constants';
import { repoTrashAPI } from '../../../../utils/repo-trash-api';
import TrashFilters from './search-filters';
import './search-trash.css';
const propTypes = {
repoID: PropTypes.string.isRequired,
onSearchResults: PropTypes.func.isRequired,
placeholder: PropTypes.string,
};
const PER_PAGE = 20;
class SearchTrash extends Component {
constructor(props) {
super(props);
this.state = {
value: '',
isLoading: false,
isFilterControllerActive: false,
filters: {
date: {
value: '',
from: null,
to: null,
},
suffixes: '',
creator_list: [],
},
};
this.source = null;
this.inputRef = React.createRef();
this.debouncedSearch = debounce(this.searchTrash, 300);
}
handleError = (e) => {
if (!axios.isCancel(e)) {
const errMessage = Utils.getErrorMsg(e);
toaster.danger(errMessage);
}
this.setState({ isLoading: false });
};
onChangeHandler = (event) => {
const newValue = event.target.value;
this.setState({ value: newValue });
this.debouncedSearch(newValue);
};
handleFiltersShow = () => {
const { isFiltersShow } = this.state;
localStorage.setItem(SEARCH_FILTERS_SHOW_KEY, !isFiltersShow);
this.setState({ isFiltersShow: !isFiltersShow });
};
searchTrash = (query) => {
if (this.source) {
this.source.cancel('prev request is cancelled');
}
this.source = axios.CancelToken.source();
this.setState({ isLoading: true });
const { repoID } = this.props;
const page = 1;
const per_page = PER_PAGE;
const { suffixes, date, creator_list } = this.state.filters;
const creators = creator_list.map(user => user.email).join(',');
repoTrashAPI.searchRepoFolderTrash(repoID, page, per_page, query.trim(), { suffixes, date, creators }).then(res => {
const items = Array.isArray(res.data.items) ? res.data.items : [];
const hasMore = Boolean(res.data.has_more);
this.props.onSearchResults({ items, hasMore });
this.setState({
isLoading: false
});
}).catch(error => {
this.setState({ isLoading: false });
toaster.danger(gettext('Search failed. Please try again.'));
});
};
handleFiltersChange = (key, value) => {
const newFilters = { ...this.state.filters, [key]: value };
const hasActiveFilter = newFilters.suffixes || newFilters.date.value;
this.setState({
filters: newFilters,
isFilterControllerActive: hasActiveFilter
}, () => {
this.searchTrash(this.state.value);
});
};
onClearSearch = () => {
this.setState({ value: '' });
this.props.onSearchResults({ reset: true });
};
render() {
const { placeholder } = this.props;
const { value, isLoading, isFilterControllerActive, filters, isFiltersShow } = this.state;
return (
<div className="search-container search-trash">
<div className="search-controls">
<div className="input-icon">
<i className="search-icon-left trash-input-icon-addon sf3-font sf3-font-search"></i>
<input
type="text"
className="form-control search-trash-input"
name="query"
placeholder={placeholder || gettext('Search in trash...')}
value={this.state.value}
onChange={this.onChangeHandler}
autoComplete="off"
ref={this.inputRef}
/>
{value && (
<button
type="button"
className="search-icon-right sf3-font sf3-font-x-01"
onClick={this.onClearSearch}
aria-label={gettext('Clear search')}
title={gettext('Clear search')}
/>
)}
<IconBtn
icon="filter-circled"
text={isFiltersShow ? gettext('Hide filters') : gettext('Show filters')}
aria-label={isFiltersShow ? gettext('Hide advanced search') : gettext('Show advanced search')}
size={20}
className={classnames('search-icon-right input-icon-addon search-filter-controller', { 'active': isFilterControllerActive })}
onClick={this.handleFiltersShow}
id="search-filter-controller"
/>
</div>
{isFiltersShow && <TrashFilters filters={filters} onChange={this.handleFiltersChange} />}
</div>
{isLoading && (
<div className="search-loading-indicator">
<Loading />
</div>
)}
</div>
);
}
}
SearchTrash.propTypes = propTypes;
export default SearchTrash;

View File

@@ -141,7 +141,7 @@ class Content extends React.Component {
this.theadData = [
{ width: '30%', text: gettext('Name') },
{ width: '20%', text: gettext('Size') },
{ width: '30%', text: gettext('Delete Time') },
{ width: '30%', text: gettext('Deleted time') },
{ width: '20%', text: '' }
];
} else {
@@ -149,7 +149,7 @@ class Content extends React.Component {
{ width: '5%', text: gettext('Name') },
{ width: '20%', text: '' },
{ width: '30%', text: gettext('Size') },
{ width: '35%', text: gettext('Delete Time') },
{ width: '35%', text: gettext('Deleted time') },
{ width: '10%', text: '' }
];
}

View File

@@ -239,7 +239,7 @@ class Content extends React.Component {
{ width: '5%', text: '' },
{ width: '20%', text: gettext('Name') },
{ width: '40%', text: gettext('Original path') },
{ width: '12%', text: gettext('Delete Time') },
{ width: '12%', text: gettext('Deleted time') },
{ width: '13%', text: gettext('Size') },
{ width: '10%', text: '' }
];

View File

@@ -42,6 +42,34 @@ class RepotrashAPI {
};
return this.req.get(url, { params: params });
}
searchRepoFolderTrash(repoID, page, per_page, searchQuery = '', filters = {}) {
const url = this.server + '/api/v2.1/repos/' + repoID + '/trash2/search/';
let params = {
page: page || 1,
per_page: per_page
};
if (searchQuery && searchQuery.trim() !== '') {
params.q = searchQuery.trim();
}
if (filters.suffixes) {
params.suffixes = filters.suffixes;
}
if (filters.date.from != null && filters.date.to != null) {
params.time_from = filters.date.from;
params.time_to = filters.date.to;
}
if (filters.creators != null) {
params.op_users = filters.creators;
}
return this.req.get(url, { params: params });
}
}
let repoTrashAPI = new RepotrashAPI();

View File

@@ -13,9 +13,10 @@ from rest_framework import status
from seahub.api2.throttling import UserRateThrottle
from seahub.api2.authentication import TokenAuthentication
from seahub.api2.utils import api_error
from seahub.base.models import FileTrash
from seahub.signals import clean_up_repo_trash
from seahub.utils import get_trash_records, is_org_context
from seahub.utils import is_org_context
from seahub.utils.timeutils import timestamp_to_isoformat_timestr
from seahub.utils.repo import get_repo_owner, is_repo_admin
from seahub.views import check_folder_permission
@@ -362,8 +363,9 @@ class RepoTrash2(APIView):
except ValueError:
current_page = 1
per_page = 100
start = (current_page - 1) * per_page
limit = per_page
end = start + per_page
try:
dir_id = seafile_api.get_dir_id_by_path(repo_id, path)
except SearpcError as e:
@@ -380,13 +382,159 @@ class RepoTrash2(APIView):
error_msg = 'Permission denied.'
return api_error(status.HTTP_403_FORBIDDEN, error_msg)
# keep_days
# -1 all history
# 0 no history
# n n-days history
keep_days = seafile_api.get_repo_history_limit(repo_id)
if keep_days == 0:
result = {
'items': [],
'total_count': 0,
'can_search': False
}
return Response(result)
# pre-filter by repo history
elif keep_days == -1:
qset = FileTrash.objects.by_repo_id(repo_id)
else:
qset = FileTrash.objects.by_repo_id(repo_id).by_history_limit(keep_days)
deleted_entries = qset[start:end]
total_count = qset.count()
items = []
if len(deleted_entries) >= 1:
for item in deleted_entries:
item_info = self.get_item_info(item)
items.append(item_info)
result = {
'items': items,
'total_count': total_count,
'can_search': True
}
return Response(result)
class SearchRepoTrash2(APIView):
authentication_classes = (TokenAuthentication, SessionAuthentication)
permission_classes = (IsAuthenticated,)
throttle_classes = (UserRateThrottle,)
def get_item_info(self, trash_item):
item_info = {
'parent_dir': '/' if trash_item.path == '/' else trash_item.path,
'obj_name': trash_item.obj_name,
'deleted_time': timestamp_to_isoformat_timestr(int(trash_item.delete_time.timestamp())),
'commit_id': trash_item.commit_id,
}
if trash_item.obj_type == 'dir':
is_dir = True
else:
is_dir = False
item_info['is_dir'] = is_dir
item_info['size'] = trash_item.size if not is_dir else ''
item_info['obj_id'] = trash_item.obj_id if not is_dir else ''
return item_info
def get(self, request, repo_id):
""" Return deleted files/dirs of a repo/folder
Permission checking:
1. all authenticated user can perform this action.
"""
path = '/'
# resource check
repo = seafile_api.get_repo(repo_id)
if not repo:
error_msg = 'Library %s not found.' % repo_id
return api_error(status.HTTP_404_NOT_FOUND, error_msg)
q = request.GET.get('q', None)
op_users = request.GET.get('op_users', '').split(',') if request.GET.get('op_users') else None
op_users = [u for u in op_users if u] if op_users else None
time_from = request.GET.get('time_from')
time_to = request.GET.get('time_to')
try:
deleted_entries, total_count = get_trash_records(repo_id, SHOW_REPO_TRASH_DAYS, start, limit)
except Exception as e:
time_from = int(time_from) if time_from else None
time_to = int(time_to) if time_to else None
except (TypeError, ValueError):
time_from = None
time_to = None
suffixes = request.GET.get('suffixes', None)
if suffixes:
suffixes = suffixes.split(',')
else:
suffixes = None
suffixes = [s for s in suffixes if s] if suffixes else None
try:
current_page = int(request.GET.get('page', '1'))
per_page = int(request.GET.get('per_page', '20'))
except ValueError:
current_page = 1
per_page = 20
start = (current_page - 1) * per_page
end = start + per_page
try:
dir_id = seafile_api.get_dir_id_by_path(repo_id, path)
except SearpcError as e:
logger.error(e)
error_msg = 'Internal Server Error'
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
if not dir_id:
error_msg = 'Folder %s not found.' % path
return api_error(status.HTTP_404_NOT_FOUND, error_msg)
# permission check
if check_folder_permission(request, repo_id, path) is None:
error_msg = 'Permission denied.'
return api_error(status.HTTP_403_FORBIDDEN, error_msg)
keep_days = seafile_api.get_repo_history_limit(repo_id)
if keep_days == 0:
result = {
'items': [],
'total_count': 0
}
return Response(result)
# pre-filter by repo history
elif keep_days == -1:
qset = FileTrash.objects.by_repo_id(repo_id)
else:
qset = FileTrash.objects.by_repo_id(repo_id).by_history_limit(keep_days)
if q:
qset = qset.by_keywords(q)
if op_users:
qset = qset.by_users(op_users)
if time_from and time_to:
qset = qset.by_time_range(time_from, time_to)
if suffixes:
qset = qset.by_suffixes(suffixes)
deleted_entries = qset[start:end]
total_count = qset.count()
items = []
if len(deleted_entries) >= 1:
for item in deleted_entries:

View File

@@ -585,6 +585,59 @@ class OrgQuotaUsage(models.Model):
class FileTrashQuerySet(models.QuerySet):
def by_repo_id(self, repo_id):
return self.filter(repo_id=repo_id)
def by_history_limit(self, keep_days):
_timestamp = datetime.datetime.now() - datetime.timedelta(days=keep_days)
return self.filter(delete_time__gte=_timestamp)
def by_time_range(self, start_timestamp=None, end_timestamp=None):
queryset = self
if start_timestamp is not None:
start_datetime = datetime.datetime.fromtimestamp(start_timestamp)
queryset = queryset.filter(delete_time__gte=start_datetime)
if end_timestamp is not None:
end_datetime = datetime.datetime.fromtimestamp(end_timestamp)
queryset = queryset.filter(delete_time__lte=end_datetime)
return queryset
def by_suffixes(self, suffixes):
queries = Q()
for ext in suffixes:
queries |= Q(obj_name__iendswith=ext)
return self.filter(queries)
def by_users(self, users):
return self.filter(user__in=users)
def by_keywords(self, keywords):
return self.filter(obj_name__icontains=keywords)
class FileTrash(models.Model):
user = models.CharField(max_length=255)
obj_type = models.CharField(max_length=128)
obj_id = models.CharField(max_length=40)
obj_name = models.CharField(max_length=255)
delete_time = models.DateTimeField()
repo_id = models.CharField(max_length=36, db_index=True)
commit_id = models.CharField(max_length=40)
path = models.TextField()
size = models.BigIntegerField()
objects = FileTrashQuerySet.as_manager()
class Meta:
db_table = 'FileTrash'
ordering = ["-delete_time"]
###### signal handler ###############
from django.dispatch import receiver

View File

@@ -74,7 +74,7 @@ from seahub.api2.endpoints.file_history import FileHistoryView, NewFileHistoryVi
from seahub.api2.endpoints.dir import DirView, DirDetailView
from seahub.api2.endpoints.file_tag import FileTagView
from seahub.api2.endpoints.file_tag import FileTagsView
from seahub.api2.endpoints.repo_trash import RepoTrash, RepoTrashRevertDirents, RepoTrash2
from seahub.api2.endpoints.repo_trash import RepoTrash, RepoTrashRevertDirents, RepoTrash2, SearchRepoTrash2
from seahub.api2.endpoints.repo_commit import RepoCommitView
from seahub.api2.endpoints.repo_commit_dir import RepoCommitDirView
from seahub.api2.endpoints.repo_commit_revert import RepoCommitRevertView
@@ -461,6 +461,7 @@ urlpatterns = [
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/dir/detail/$', DirDetailView.as_view(), name='api-v2.1-dir-detail-view'),
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/trash/$', RepoTrash.as_view(), name='api-v2.1-repo-trash'),
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/trash2/$', RepoTrash2.as_view(), name='api-v2.1-repo-trash2'),
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/trash2/search/$', SearchRepoTrash2.as_view(), name='api-v2.1-repo-trash2-search'),
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/trash/revert-dirents/$', RepoTrashRevertDirents.as_view(), name='api-v2.1-repo-trash-revert-dirents'),
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/history/$', RepoHistory.as_view(), name='api-v2.1-repo-history'),
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/set-password/$', RepoSetPassword.as_view(), name="api-v2.1-repo-set-password"),