mirror of
https://github.com/haiwen/seahub.git
synced 2025-09-27 23:56:18 +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:
@@ -13,6 +13,7 @@ import Paginator from '../../paginator';
|
|||||||
import Loading from '../../loading';
|
import Loading from '../../loading';
|
||||||
import BackIcon from '../../back-icon';
|
import BackIcon from '../../back-icon';
|
||||||
import EmptyTip from '../../empty-tip';
|
import EmptyTip from '../../empty-tip';
|
||||||
|
import SearchTrash from './trash-search/search-trash';
|
||||||
|
|
||||||
import '../../../css/toolbar.css';
|
import '../../../css/toolbar.css';
|
||||||
import '../../../css/search.css';
|
import '../../../css/search.css';
|
||||||
@@ -33,7 +34,10 @@ class TrashDialog extends React.Component {
|
|||||||
isOldTrashDialogOpen: false,
|
isOldTrashDialogOpen: false,
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
perPage: 100,
|
perPage: 100,
|
||||||
hasNextPage: false
|
hasNextPage: false,
|
||||||
|
searchKeyword: '',
|
||||||
|
filteredItems: [],
|
||||||
|
canSearch: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,9 +45,23 @@ class TrashDialog extends React.Component {
|
|||||||
this.getFolderTrash();
|
this.getFolderTrash();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleSearchResults = (result) => {
|
||||||
|
if (result?.reset) {
|
||||||
|
this.getFolderTrash(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = result?.items || [];
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
items,
|
||||||
|
hasNextPage: result?.hasMore || false
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
getFolderTrash = (page) => {
|
getFolderTrash = (page) => {
|
||||||
repoTrashAPI.getRepoFolderTrash(this.props.repoID, page, this.state.perPage).then((res) => {
|
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) {
|
if (!page) {
|
||||||
page = 1;
|
page = 1;
|
||||||
}
|
}
|
||||||
@@ -52,7 +70,8 @@ class TrashDialog extends React.Component {
|
|||||||
hasNextPage: total_count - page * this.state.perPage > 0,
|
hasNextPage: total_count - page * this.state.perPage > 0,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
items: items,
|
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 = () => {
|
getPreviousPage = () => {
|
||||||
this.getFolderTrash(this.state.currentPage - 1);
|
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) => {
|
seafileAPI.listCommitDir(this.props.repoID, commitID, `${baseDir.substr(0, baseDir.length - 1)}${folderPath}`).then((res) => {
|
||||||
this.setState({
|
this.setState({
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
folderItems: res.data.dirent_list
|
folderItems: res.data.dirent_list,
|
||||||
|
canSearch: false,
|
||||||
});
|
});
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
@@ -182,7 +222,7 @@ class TrashDialog extends React.Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { showTrashDialog, toggleTrashDialog, repoID } = this.props;
|
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 isRepoAdmin = this.props.currentRepoInfo.owner_email === username || this.props.currentRepoInfo.is_admin;
|
||||||
const repoFolderName = this.props.currentRepoInfo.repo_name;
|
const repoFolderName = this.props.currentRepoInfo.repo_name;
|
||||||
const oldTrashUrl = siteRoot + 'repo/' + this.props.repoID + '/trash/';
|
const oldTrashUrl = siteRoot + 'repo/' + this.props.repoID + '/trash/';
|
||||||
@@ -217,19 +257,38 @@ class TrashDialog extends React.Component {
|
|||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
{isLoading && <Loading />}
|
{isLoading && <Loading />}
|
||||||
|
{!isLoading && canSearch &&
|
||||||
|
<SearchTrash
|
||||||
|
repoID={this.props.repoID}
|
||||||
|
onSearchResults={this.handleSearchResults}
|
||||||
|
placeholder={gettext('Search in trash')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
{!isLoading && items.length === 0 &&
|
{!isLoading && items.length === 0 &&
|
||||||
<EmptyTip text={gettext('No file')} className="m-0" />
|
<EmptyTip text={gettext('No file')} className="m-0" />
|
||||||
}
|
}
|
||||||
{!isLoading && items.length > 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>
|
<span className="path-label mr-1">{gettext('Current path: ')}</span>
|
||||||
{showFolder ?
|
{showFolder ?
|
||||||
this.renderFolderPath() :
|
this.renderFolderPath() :
|
||||||
<span className="last-path-item" title={repoFolderName}>{repoFolderName}</span>
|
<span className="last-path-item" title={repoFolderName}>{repoFolderName}</span>
|
||||||
}
|
}
|
||||||
</div>
|
</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
|
<Paginator
|
||||||
gotoPreviousPage={this.getPreviousPage}
|
gotoPreviousPage={this.getPreviousPage}
|
||||||
gotoNextPage={this.getNextPage}
|
gotoNextPage={this.getNextPage}
|
||||||
|
@@ -11,7 +11,7 @@ const Table = ({ repoID, renderFolder, data, isDesktop }) => {
|
|||||||
{ isFixed: true, width: 40, className: 'pl-2 pr-2' },
|
{ isFixed: true, width: 40, className: 'pl-2 pr-2' },
|
||||||
{ isFixed: false, width: 0.25, children: gettext('Name') },
|
{ isFixed: false, width: 0.25, children: gettext('Name') },
|
||||||
{ isFixed: false, width: 0.4, children: gettext('Original path') },
|
{ 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.13, children: gettext('Size') },
|
||||||
{ isFixed: false, width: 0.1, children: '' },
|
{ isFixed: false, width: 0.1, children: '' },
|
||||||
], []);
|
], []);
|
||||||
|
@@ -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;
|
@@ -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;
|
@@ -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;
|
@@ -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;
|
||||||
|
}
|
@@ -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;
|
@@ -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;
|
@@ -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;
|
||||||
|
}
|
@@ -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;
|
@@ -141,7 +141,7 @@ class Content extends React.Component {
|
|||||||
this.theadData = [
|
this.theadData = [
|
||||||
{ width: '30%', text: gettext('Name') },
|
{ width: '30%', text: gettext('Name') },
|
||||||
{ width: '20%', text: gettext('Size') },
|
{ width: '20%', text: gettext('Size') },
|
||||||
{ width: '30%', text: gettext('Delete Time') },
|
{ width: '30%', text: gettext('Deleted time') },
|
||||||
{ width: '20%', text: '' }
|
{ width: '20%', text: '' }
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
@@ -149,7 +149,7 @@ class Content extends React.Component {
|
|||||||
{ width: '5%', text: gettext('Name') },
|
{ width: '5%', text: gettext('Name') },
|
||||||
{ width: '20%', text: '' },
|
{ width: '20%', text: '' },
|
||||||
{ width: '30%', text: gettext('Size') },
|
{ width: '30%', text: gettext('Size') },
|
||||||
{ width: '35%', text: gettext('Delete Time') },
|
{ width: '35%', text: gettext('Deleted time') },
|
||||||
{ width: '10%', text: '' }
|
{ width: '10%', text: '' }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@@ -239,7 +239,7 @@ class Content extends React.Component {
|
|||||||
{ width: '5%', text: '' },
|
{ width: '5%', text: '' },
|
||||||
{ width: '20%', text: gettext('Name') },
|
{ width: '20%', text: gettext('Name') },
|
||||||
{ width: '40%', text: gettext('Original path') },
|
{ width: '40%', text: gettext('Original path') },
|
||||||
{ width: '12%', text: gettext('Delete Time') },
|
{ width: '12%', text: gettext('Deleted time') },
|
||||||
{ width: '13%', text: gettext('Size') },
|
{ width: '13%', text: gettext('Size') },
|
||||||
{ width: '10%', text: '' }
|
{ width: '10%', text: '' }
|
||||||
];
|
];
|
||||||
|
@@ -42,6 +42,34 @@ class RepotrashAPI {
|
|||||||
};
|
};
|
||||||
return this.req.get(url, { params: params });
|
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();
|
let repoTrashAPI = new RepotrashAPI();
|
||||||
|
@@ -13,9 +13,10 @@ from rest_framework import status
|
|||||||
from seahub.api2.throttling import UserRateThrottle
|
from seahub.api2.throttling import UserRateThrottle
|
||||||
from seahub.api2.authentication import TokenAuthentication
|
from seahub.api2.authentication import TokenAuthentication
|
||||||
from seahub.api2.utils import api_error
|
from seahub.api2.utils import api_error
|
||||||
|
from seahub.base.models import FileTrash
|
||||||
|
|
||||||
from seahub.signals import clean_up_repo_trash
|
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.timeutils import timestamp_to_isoformat_timestr
|
||||||
from seahub.utils.repo import get_repo_owner, is_repo_admin
|
from seahub.utils.repo import get_repo_owner, is_repo_admin
|
||||||
from seahub.views import check_folder_permission
|
from seahub.views import check_folder_permission
|
||||||
@@ -362,8 +363,134 @@ class RepoTrash2(APIView):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
current_page = 1
|
current_page = 1
|
||||||
per_page = 100
|
per_page = 100
|
||||||
|
|
||||||
start = (current_page - 1) * per_page
|
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:
|
||||||
|
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
|
||||||
|
# -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:
|
||||||
|
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:
|
try:
|
||||||
dir_id = seafile_api.get_dir_id_by_path(repo_id, path)
|
dir_id = seafile_api.get_dir_id_by_path(repo_id, path)
|
||||||
except SearpcError as e:
|
except SearpcError as e:
|
||||||
@@ -380,12 +507,33 @@ class RepoTrash2(APIView):
|
|||||||
error_msg = 'Permission denied.'
|
error_msg = 'Permission denied.'
|
||||||
return api_error(status.HTTP_403_FORBIDDEN, error_msg)
|
return api_error(status.HTTP_403_FORBIDDEN, error_msg)
|
||||||
|
|
||||||
try:
|
keep_days = seafile_api.get_repo_history_limit(repo_id)
|
||||||
deleted_entries, total_count = get_trash_records(repo_id, SHOW_REPO_TRASH_DAYS, start, limit)
|
|
||||||
except Exception as e:
|
if keep_days == 0:
|
||||||
logger.error(e)
|
result = {
|
||||||
error_msg = 'Internal Server Error'
|
'items': [],
|
||||||
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
|
'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 = []
|
items = []
|
||||||
if len(deleted_entries) >= 1:
|
if len(deleted_entries) >= 1:
|
||||||
|
@@ -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 ###############
|
###### signal handler ###############
|
||||||
|
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
@@ -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.dir import DirView, DirDetailView
|
||||||
from seahub.api2.endpoints.file_tag import FileTagView
|
from seahub.api2.endpoints.file_tag import FileTagView
|
||||||
from seahub.api2.endpoints.file_tag import FileTagsView
|
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 import RepoCommitView
|
||||||
from seahub.api2.endpoints.repo_commit_dir import RepoCommitDirView
|
from seahub.api2.endpoints.repo_commit_dir import RepoCommitDirView
|
||||||
from seahub.api2.endpoints.repo_commit_revert import RepoCommitRevertView
|
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})/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})/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/$', 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})/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})/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"),
|
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"),
|
||||||
|
Reference in New Issue
Block a user