import React, { Component, Fragment } from 'react';
import axios from 'axios';
import PropTypes from 'prop-types';
import isHotkey from 'is-hotkey';
import classnames from 'classnames';
import MediaQuery from 'react-responsive';
import { seafileAPI } from '../../utils/seafile-api';
import searchAPI from '../../utils/search-api';
import { gettext } from '../../utils/constants';
import SearchResultItem from './search-result-item';
import SearchResultLibrary from './search-result-library';
import { debounce, Utils } from '../../utils/utils';
import toaster from '../toast';
import Loading from '../loading';
import { SEARCH_MASK, SEARCH_CONTAINER } from '../../constants/zIndexes';
import { PRIVATE_FILE_TYPE, SEARCH_FILTER_BY_DATE_OPTION_KEY, SEARCH_FILTER_BY_DATE_TYPE_KEY, SEARCH_FILTERS_KEY, SEARCH_FILTERS_SHOW_KEY } from '../../constants';
import SearchFilters from './search-filters';
import SearchTags from './search-tags';
import IconBtn from '../icon-btn';
import SearchedItemDetails from './details';
import { CollaboratorsProvider } from '../../metadata';
const propTypes = {
repoID: PropTypes.string,
path: PropTypes.string,
placeholder: PropTypes.string,
onSearchedClick: PropTypes.func.isRequired,
isPublic: PropTypes.bool,
isViewFile: PropTypes.bool,
onSelectTag: PropTypes.func,
};
const PER_PAGE = 20;
const controlKey = Utils.isMac() ? '⌘' : 'Ctrl';
const isEnter = isHotkey('enter');
const isUp = isHotkey('up');
const isDown = isHotkey('down');
class Search extends Component {
constructor(props) {
super(props);
this.state = {
width: 'default',
value: '',
inputValue: '',
resultItems: [],
highlightIndex: 0,
page: 0,
isLoading: false,
isMaskShow: false,
showRecent: true,
isResultGotten: false,
isCloseShow: false,
isSearchInputShow: false, // for mobile
searchTypesMax: 0,
highlightSearchTypesIndex: 0,
isFiltersShow: false,
isFilterControllerActive: false,
filters: {
search_filename_only: false,
creator_list: [],
date: {
type: SEARCH_FILTER_BY_DATE_TYPE_KEY.CREATE_TIME,
value: '',
from: null,
to: null,
},
suffixes: '',
},
visitedItems: [],
};
this.highlightRef = null;
this.source = null; // used to cancel request;
this.inputRef = React.createRef();
this.searchContainer = React.createRef();
this.searchResultListRef = React.createRef();
this.isChineseInput = false;
this.searchResultListContainerRef = React.createRef();
this.calculateStoreKey(props);
this.timer = null;
}
componentDidMount() {
document.addEventListener('keydown', this.onDocumentKeydown);
document.addEventListener('compositionstart', this.onCompositionStart);
document.addEventListener('compositionend', this.onCompositionEnd);
const isFiltersShow = localStorage.getItem(SEARCH_FILTERS_SHOW_KEY) === 'true';
const visitedItems = JSON.parse(localStorage.getItem(this.storeKey)) || [];
this.setState({ isFiltersShow, visitedItems });
}
UNSAFE_componentWillReceiveProps(nextProps) {
this.calculateStoreKey(nextProps);
this.isChineseInput = false;
}
componentDidUpdate(prevProps, prevState) {
if (this.state.isMaskShow && !prevState.isMaskShow) {
const visitedItems = JSON.parse(localStorage.getItem(this.storeKey)) || [];
if (visitedItems !== prevState.visitedItems) {
this.setState({ visitedItems });
}
}
}
componentWillUnmount() {
document.removeEventListener('keydown', this.onDocumentKeydown);
document.removeEventListener('compositionstart', this.onCompositionStart);
document.removeEventListener('compositionend', this.onCompositionEnd);
this.isChineseInput = false;
}
calculateStoreKey = (props) => {
const { repoID } = props;
let storeKey = 'sfVisitedSearchItems';
if (repoID) {
storeKey += repoID;
}
this.storeKey = storeKey;
};
onCompositionStart = () => {
this.isChineseInput = true;
};
onCompositionEnd = () => {
this.isChineseInput = false;
};
onDocumentKeydown = (e) => {
if (isHotkey('mod+k')(e)) {
e.preventDefault();
this.onFocusHandler();
if (this.inputRef && this.inputRef.current) {
this.inputRef.current.focus();
}
}
if (this.state.isMaskShow) {
if (isHotkey('esc', e)) {
e.preventDefault();
this.inputRef && this.inputRef.current && this.inputRef.current.blur();
this.resetToDefault();
} else if (isEnter(e)) {
this.onEnter(e);
} else if (isUp(e)) {
this.onUp(e);
} else if (isDown(e)) {
this.onDown(e);
}
}
};
onFocusHandler = () => {
this.setState({ width: '100%', isMaskShow: true });
this.calculateHighlightType();
};
onCloseHandler = () => {
this.resetToDefault();
};
onUp = (e) => {
e.preventDefault();
e.stopPropagation();
const { highlightIndex, resultItems, isResultGotten } = this.state;
// 01 init search, display and highlight recent search results
if (this.state.showRecent) {
if (highlightIndex > 0) {
this.setState({ highlightIndex: highlightIndex - 1 }, () => {
if (this.highlightRef) {
const { top, height } = this.highlightRef.getBoundingClientRect();
if (top - height < 0) {
this.searchResultListContainerRef.current.scrollTop -= height;
}
}
});
}
return;
}
// 02 global search, display and highlight searched repos
if (!this.props.repoID && resultItems.length > 0 && !isResultGotten) {
let highlightSearchTypesIndex = this.state.highlightSearchTypesIndex - 1;
if (highlightSearchTypesIndex < 0) {
highlightSearchTypesIndex = resultItems.length;
}
this.setState({ highlightSearchTypesIndex }, () => {
if (this.highlightRef) {
const { top, height } = this.highlightRef.getBoundingClientRect();
if (top - height < 0) {
this.searchResultListContainerRef.current.scrollTop -= height;
}
}
});
return;
}
// 03 Internal repo search, highlight search types
if (!isResultGotten) {
let highlightSearchTypesIndex = this.state.highlightSearchTypesIndex - 1;
if (highlightSearchTypesIndex < 0) {
highlightSearchTypesIndex = this.state.searchTypesMax;
}
this.setState({ highlightSearchTypesIndex });
return;
}
// 04 When there are search results, highlight searched items
if (highlightIndex > 0) {
this.setState({ highlightIndex: highlightIndex - 1 }, () => {
if (this.highlightRef) {
const { top, height } = this.highlightRef.getBoundingClientRect();
if (top - height < 0) {
this.searchResultListContainerRef.current.scrollTop -= height;
}
}
});
}
};
onDown = (e) => {
e.preventDefault();
e.stopPropagation();
const { highlightIndex, resultItems, isResultGotten } = this.state;
// 01 init search, display and highlight recent search results
if (this.state.showRecent) {
const visitedItems = JSON.parse(localStorage.getItem(this.storeKey)) || [];
if (highlightIndex < visitedItems.length - 1) {
this.setState({ highlightIndex: highlightIndex + 1 }, () => {
if (this.highlightRef) {
const { top, height } = this.highlightRef.getBoundingClientRect();
const outerHeight = 300;
if (top > outerHeight) {
const newScrollTop = this.searchResultListContainerRef.current.scrollTop + height;
this.searchResultListContainerRef.current.scrollTop = newScrollTop;
}
}
});
}
return;
}
// 02 global search, display and highlight searched repos
if (!this.props.repoID && resultItems.length > 0 && !isResultGotten) {
let highlightSearchTypesIndex = this.state.highlightSearchTypesIndex + 1;
if (highlightSearchTypesIndex > resultItems.length) {
highlightSearchTypesIndex = 0;
}
this.setState({ highlightSearchTypesIndex }, () => {
if (this.highlightRef) {
const { top, height } = this.highlightRef.getBoundingClientRect();
const outerHeight = 300;
if (top > outerHeight) {
const newScrollTop = this.searchResultListContainerRef.current.scrollTop + height;
this.searchResultListContainerRef.current.scrollTop = newScrollTop;
}
}
});
return;
}
// 03 Internal repo search, highlight search types
if (!this.state.isResultGotten) {
let highlightSearchTypesIndex = this.state.highlightSearchTypesIndex + 1;
if (highlightSearchTypesIndex > this.state.searchTypesMax) {
highlightSearchTypesIndex = 0;
}
this.setState({ highlightSearchTypesIndex });
return;
}
// 04 When there are search results, highlight searched items
if (highlightIndex < resultItems.length - 1) {
this.setState({ highlightIndex: highlightIndex + 1 }, () => {
if (this.highlightRef) {
const { top, height } = this.highlightRef.getBoundingClientRect();
const outerHeight = 300;
if (top > outerHeight) {
const newScrollTop = this.searchResultListContainerRef.current.scrollTop + height;
this.searchResultListContainerRef.current.scrollTop = newScrollTop;
}
}
});
}
};
onEnter = (e) => {
e.preventDefault();
if (this.state.showRecent) {
const visitedItems = JSON.parse(localStorage.getItem(this.storeKey)) || [];
const item = visitedItems[this.state.highlightIndex];
if (item) {
if (document.activeElement) {
document.activeElement.blur();
}
this.onItemClickHandler(item);
}
return;
}
// global searching, searched repos needs to support enter
const { highlightSearchTypesIndex, resultItems, isResultGotten } = this.state;
if (!this.props.repoID && resultItems.length > 0 && !isResultGotten) {
if (highlightSearchTypesIndex === 0) {
this.searchAllRepos();
} else {
let item = this.state.resultItems[highlightSearchTypesIndex - 1];
if (item) {
if (document.activeElement) {
document.activeElement.blur();
}
this.onItemClickHandler(item);
}
return;
}
}
if (!this.state.isResultGotten) {
let highlightDom = document.querySelector('.search-types-highlight');
if (highlightDom) {
if (highlightDom.classList.contains('search-types-folder')) {
this.searchFolder();
}
else if (highlightDom.classList.contains('search-types-repo')) {
this.searchRepo();
}
else if (highlightDom.classList.contains('search-types-repos')) {
this.searchAllRepos();
}
return;
}
}
let item = this.state.resultItems[this.state.highlightIndex];
if (item) {
if (document.activeElement) {
document.activeElement.blur();
}
this.onItemClickHandler(item);
}
};
onItemClickHandler = (item) => {
if (item.is_dir === true) {
this.resetToDefault();
}
this.keepVisitedItem(item);
this.props.onSearchedClick(item);
};
calculateHighlightType = () => {
let searchTypesMax = 0;
const { repoID, path, isViewFile } = this.props;
if (repoID) {
searchTypesMax++;
}
if (path && path !== '/' && !isViewFile) {
searchTypesMax++;
}
this.setState({
searchTypesMax,
highlightSearchTypesIndex: 0,
});
};
keepVisitedItem = (targetItem) => {
let targetIndex;
const { repo_id: targetRepoID, path: targetPath } = targetItem;
const storeKey = 'sfVisitedSearchItems' + targetRepoID;
const items = JSON.parse(localStorage.getItem(storeKey)) || [];
for (let i = 0, len = items.length; i < len; i++) {
const { repo_id, path } = items[i];
if (repo_id == targetRepoID && path == targetPath) {
targetIndex = i;
break;
}
}
if (targetIndex != undefined) {
items.splice(targetIndex, 1);
}
items.unshift(targetItem);
if (items.length > 50) { // keep 50 items at most
items.pop();
}
localStorage.setItem(storeKey, JSON.stringify(items));
};
onChangeHandler = (event) => {
const newValue = event.target.value;
if (this.state.showRecent) {
this.setState({ showRecent: false });
}
this.setState({ value: newValue, isCloseShow: newValue.length > 0 });
setTimeout(() => {
const trimmedValue = newValue.trim();
const isInRepo = this.props.repoID;
if (this.isChineseInput === false && this.state.inputValue !== newValue) {
this.setState({
inputValue: newValue,
isLoading: false,
highlightIndex: 0,
isResultGotten: false,
}, () => {
if (!isInRepo && trimmedValue !== '') {
this.getRepoSearchResult(newValue);
}
});
}
}, 1);
};
handleError = (e) => {
if (!axios.isCancel(e)) {
let errMessage = Utils.getErrorMsg(e);
toaster.danger(errMessage);
}
this.setState({ isLoading: false });
};
getRepoSearchResult = (query_str) => {
if (this.source) {
this.source.cancel('prev request is cancelled');
}
this.source = seafileAPI.getSource();
const query_type = 'library';
let results = [];
searchAPI.searchItems(query_str, query_type, this.source.token).then(res => {
results = [...results, ...this.formatResultItems(res.data.results)];
this.setState({
resultItems: results,
isLoading: false,
});
}).catch(error => {
this.handleError(error);
});
};
getSearchResult = (queryData) => {
if (this.source) {
this.source.cancel('prev request is cancelled');
}
this.setState({
isLoading: true,
isResultGotten: false,
resultItems: [],
highlightIndex: 0,
});
this.source = seafileAPI.getSource();
this.sendRequest(queryData, this.source.token, 1);
};
sendRequest = (queryData, cancelToken, page) => {
let isPublic = this.props.isPublic;
this.queryData = queryData;
if (isPublic) {
seafileAPI.searchFilesInPublishedRepo(queryData.search_repo, queryData.q, page, PER_PAGE, queryData.search_filename_only).then(res => {
this.source = null;
if (res.data.total > 0) {
this.setState({
resultItems: [...this.state.resultItems, ...this.formatResultItems(res.data.results)],
isResultGotten: true,
page: page + 1,
isLoading: false,
});
} else {
this.setState({
highlightIndex: 0,
resultItems: [],
isLoading: false,
isResultGotten: true,
});
}
}).catch(error => {
this.handleError(error);
});
} else {
this.onNormalSearch(queryData, cancelToken, page);
}
};
onNormalSearch = (queryData, cancelToken, page) => {
queryData['per_page'] = PER_PAGE;
queryData['page'] = page;
seafileAPI.searchFiles(queryData, cancelToken).then(res => {
this.source = null;
if (res.data.total > 0) {
this.setState({
resultItems: [...this.state.resultItems, ...this.formatResultItems(res.data.results)],
isResultGotten: true,
isLoading: false,
page: page + 1,
});
return;
}
this.setState({
highlightIndex: 0,
resultItems: [],
isLoading: false,
isResultGotten: true,
});
}).catch(error => {
this.handleError(error);
});
};
formatResultItems(data) {
let items = [];
for (let i = 0; i < data.length; i++) {
items[i] = {};
let name = data[i].is_dir ? data[i].name : data[i].fullpath.split('/').pop();
items[i]['index'] = [i];
items[i]['name'] = name;
items[i]['path'] = data[i].fullpath;
items[i]['repo_id'] = data[i].repo_id;
items[i]['repo_name'] = data[i].repo_name;
items[i]['is_dir'] = data[i].is_dir;
items[i]['link_content'] = decodeURI(data[i].fullpath).substring(1);
items[i]['content'] = data[i].content_highlight;
items[i]['thumbnail_url'] = data[i].thumbnail_url;
items[i]['mtime'] = data[i].mtime || '';
items[i]['repo_owner_email'] = data[i].repo_owner_email || '';
}
return items;
}
resetToDefault() {
this.setState({
width: '',
value: '',
inputValue: '',
isMaskShow: false,
isCloseShow: false,
isResultGotten: false,
resultItems: [],
highlightIndex: 0,
isSearchInputShow: false,
showRecent: true,
isFilterControllerActive: false,
filters: {
search_filename_only: false,
creator_list: [],
date: {
type: SEARCH_FILTER_BY_DATE_TYPE_KEY.CREATE_TIME,
value: '',
start: null,
end: null,
},
suffixes: '',
}
});
}
onClearSearch = () => {
this.setState({
value: '',
inputValue: '',
isResultGotten: false,
resultItems: [],
highlightIndex: 0,
isSearchInputShow: false,
isCloseShow: false,
});
};
renderSearchResult() {
const { resultItems, width, showRecent, isResultGotten, isLoading, visitedItems } = this.state;
if (!width || width === 'default') return null;
if (showRecent) {
if (visitedItems.length > 0) {
return this.renderResults(visitedItems, true);
}
}
const filteredItems = this.filterByCreator(resultItems);
if (isLoading) {
return