|
|
@@ -0,0 +1,396 @@
|
|
|
+// This is the root component for #search-page
|
|
|
+
|
|
|
+import React from 'react';
|
|
|
+import PropTypes from 'prop-types';
|
|
|
+import { withTranslation } from 'react-i18next';
|
|
|
+
|
|
|
+import {
|
|
|
+ DetachCodeBlockInterceptor,
|
|
|
+ RestoreCodeBlockInterceptor,
|
|
|
+} from '../client/util/interceptor/detach-code-blocks';
|
|
|
+
|
|
|
+import { withUnstatedContainers } from './UnstatedUtils';
|
|
|
+import AppContainer from '~/client/services/AppContainer';
|
|
|
+import { toastError } from '~/client/util/apiNotification';
|
|
|
+import SearchPageLayout from './SearchPage/SearchPageLayout';
|
|
|
+import SearchResultContent from './SearchPage/SearchResultContent';
|
|
|
+import SearchResultList from './SearchPage/SearchResultList';
|
|
|
+import SearchControl from './SearchPage/SearchControl';
|
|
|
+import { CheckboxType, SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
|
|
|
+import PageDeleteModal from './PageDeleteModal';
|
|
|
+import { useIsGuestUser } from '~/stores/context';
|
|
|
+
|
|
|
+export const specificPathNames = {
|
|
|
+ user: '/user',
|
|
|
+ trash: '/trash',
|
|
|
+};
|
|
|
+class SearchPage extends React.Component {
|
|
|
+
|
|
|
+ constructor(props) {
|
|
|
+ super(props);
|
|
|
+ // NOTE : selectedPages is deletion related state, will be used later in story 77535, 77565.
|
|
|
+ // deletionModal, deletion related functions are all removed, add them back when necessary.
|
|
|
+ // i.e ) in story 77525 or any tasks implementing deletion functionalities
|
|
|
+ this.state = {
|
|
|
+ searchingKeyword: decodeURI(this.props.query.q) || '',
|
|
|
+ searchedKeyword: '',
|
|
|
+ searchResults: [],
|
|
|
+ searchResultMeta: {},
|
|
|
+ focusedSearchResultData: null,
|
|
|
+ selectedPagesIdList: new Set(),
|
|
|
+ searchResultCount: 0,
|
|
|
+ activePage: 1,
|
|
|
+ pagingLimit: this.props.appContainer.config.pageLimitationL || 50,
|
|
|
+ excludeUserPages: true,
|
|
|
+ excludeTrashPages: true,
|
|
|
+ sort: SORT_AXIS.RELATION_SCORE,
|
|
|
+ order: SORT_ORDER.DESC,
|
|
|
+ selectAllCheckboxType: CheckboxType.NONE_CHECKED,
|
|
|
+ isDeleteConfirmModalShown: false,
|
|
|
+ deleteTargetPageIds: new Set(),
|
|
|
+ };
|
|
|
+
|
|
|
+ // TODO: Move this code to the right place after completing the "omit unstated" initiative.
|
|
|
+ const { interceptorManager } = props.appContainer;
|
|
|
+ interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(props.appContainer), 10); // process as soon as possible
|
|
|
+ interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(props.appContainer), 900); // process as late as possible
|
|
|
+
|
|
|
+ this.changeURL = this.changeURL.bind(this);
|
|
|
+ this.search = this.search.bind(this);
|
|
|
+ this.onSearchInvoked = this.onSearchInvoked.bind(this);
|
|
|
+ this.selectPage = this.selectPage.bind(this);
|
|
|
+ this.toggleCheckBox = this.toggleCheckBox.bind(this);
|
|
|
+ this.switchExcludeUserPagesHandler = this.switchExcludeUserPagesHandler.bind(this);
|
|
|
+ this.switchExcludeTrashPagesHandler = this.switchExcludeTrashPagesHandler.bind(this);
|
|
|
+ this.onChangeSortInvoked = this.onChangeSortInvoked.bind(this);
|
|
|
+ this.onPagingNumberChanged = this.onPagingNumberChanged.bind(this);
|
|
|
+ this.onPagingLimitChanged = this.onPagingLimitChanged.bind(this);
|
|
|
+ this.deleteSinglePageButtonHandler = this.deleteSinglePageButtonHandler.bind(this);
|
|
|
+ this.deleteAllPagesButtonHandler = this.deleteAllPagesButtonHandler.bind(this);
|
|
|
+ this.closeDeleteConfirmModalHandler = this.closeDeleteConfirmModalHandler.bind(this);
|
|
|
+ }
|
|
|
+
|
|
|
+ componentDidMount() {
|
|
|
+ const keyword = this.state.searchingKeyword;
|
|
|
+ if (keyword !== '') {
|
|
|
+ this.search({ keyword });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ static getQueryByLocation(location) {
|
|
|
+ const search = location.search || '';
|
|
|
+ const query = {};
|
|
|
+
|
|
|
+ search.replace(/^\?/, '').split('&').forEach((element) => {
|
|
|
+ const queryParts = element.split('=');
|
|
|
+ query[queryParts[0]] = decodeURIComponent(queryParts[1]).replace(/\+/g, ' ');
|
|
|
+ });
|
|
|
+
|
|
|
+ return query;
|
|
|
+ }
|
|
|
+
|
|
|
+ switchExcludeUserPagesHandler() {
|
|
|
+ this.setState({ excludeUserPages: !this.state.excludeUserPages });
|
|
|
+ }
|
|
|
+
|
|
|
+ switchExcludeTrashPagesHandler() {
|
|
|
+ this.setState({ excludeTrashPages: !this.state.excludeTrashPages });
|
|
|
+ }
|
|
|
+
|
|
|
+ onChangeSortInvoked(nextSort, nextOrder) {
|
|
|
+ this.setState({
|
|
|
+ sort: nextSort,
|
|
|
+ order: nextOrder,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ changeURL(keyword, refreshHash) {
|
|
|
+ let hash = window.location.hash || '';
|
|
|
+ // TODO 整理する
|
|
|
+ if (refreshHash || this.state.searchedKeyword !== '') {
|
|
|
+ hash = '';
|
|
|
+ }
|
|
|
+ if (window.history && window.history.pushState) {
|
|
|
+ window.history.pushState('', `Search - ${keyword}`, `/_search?q=${keyword}${hash}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ createSearchQuery(keyword) {
|
|
|
+ let query = keyword;
|
|
|
+
|
|
|
+ // pages included in specific path are not retrived when prefix is added
|
|
|
+ if (this.state.excludeTrashPages) {
|
|
|
+ query = `${query} -prefix:${specificPathNames.trash}`;
|
|
|
+ }
|
|
|
+ if (this.state.excludeUserPages) {
|
|
|
+ query = `${query} -prefix:${specificPathNames.user}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ return query;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * this method is called when user changes paging number
|
|
|
+ */
|
|
|
+ async onPagingNumberChanged(activePage) {
|
|
|
+ this.setState({ activePage }, () => this.search({ keyword: this.state.searchedKeyword }));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * this method is called when user searches by pressing Enter or using searchbox
|
|
|
+ */
|
|
|
+ async onSearchInvoked(data) {
|
|
|
+ this.setState({ activePage: 1 }, () => this.search(data));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * change number of pages to display per page and execute search method after.
|
|
|
+ */
|
|
|
+ async onPagingLimitChanged(limit) {
|
|
|
+ this.setState({ pagingLimit: limit }, () => this.search({ keyword: this.state.searchedKeyword }));
|
|
|
+ }
|
|
|
+
|
|
|
+ // todo: refactoring
|
|
|
+ // refs: https://redmine.weseek.co.jp/issues/82139
|
|
|
+ async search(data) {
|
|
|
+ // reset following states when search runs
|
|
|
+ this.setState({
|
|
|
+ selectedPagesIdList: new Set(),
|
|
|
+ selectAllCheckboxType: CheckboxType.NONE_CHECKED,
|
|
|
+ });
|
|
|
+
|
|
|
+ const keyword = data.keyword;
|
|
|
+ if (keyword === '') {
|
|
|
+ this.setState({
|
|
|
+ searchingKeyword: '',
|
|
|
+ searchedKeyword: '',
|
|
|
+ searchResults: [],
|
|
|
+ searchResultMeta: {},
|
|
|
+ searchResultCount: 0,
|
|
|
+ activePage: 1,
|
|
|
+ });
|
|
|
+
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.setState({
|
|
|
+ searchingKeyword: keyword,
|
|
|
+ });
|
|
|
+ const pagingLimit = this.state.pagingLimit;
|
|
|
+ const offset = (this.state.activePage * pagingLimit) - pagingLimit;
|
|
|
+ const { sort, order } = this.state;
|
|
|
+ try {
|
|
|
+ const res = await this.props.appContainer.apiGet('/search', {
|
|
|
+ q: this.createSearchQuery(keyword),
|
|
|
+ limit: pagingLimit,
|
|
|
+ offset,
|
|
|
+ sort,
|
|
|
+ order,
|
|
|
+ });
|
|
|
+
|
|
|
+ this.changeURL(keyword);
|
|
|
+ if (res.data.length > 0) {
|
|
|
+ this.setState({
|
|
|
+ searchedKeyword: keyword,
|
|
|
+ searchResults: res.data,
|
|
|
+ searchResultMeta: res.meta,
|
|
|
+ searchResultCount: res.meta.total,
|
|
|
+ focusedSearchResultData: res.data[0],
|
|
|
+ // reset active page if keyword changes, otherwise set the current state
|
|
|
+ activePage: this.state.searchedKeyword === keyword ? this.state.activePage : 1,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ else {
|
|
|
+ this.setState({
|
|
|
+ searchedKeyword: keyword,
|
|
|
+ searchResults: [],
|
|
|
+ searchResultMeta: {},
|
|
|
+ searchResultCount: 0,
|
|
|
+ focusedSearchResultData: {},
|
|
|
+ activePage: 1,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ catch (err) {
|
|
|
+ toastError(err);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ selectPage= (pageId) => {
|
|
|
+ const index = this.state.searchResults.findIndex(({ pageData }) => {
|
|
|
+ return pageData._id === pageId;
|
|
|
+ });
|
|
|
+ this.setState({
|
|
|
+ focusedSearchResultData: this.state.searchResults[index],
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ toggleCheckBox = (pageId) => {
|
|
|
+ const { selectedPagesIdList } = this.state;
|
|
|
+
|
|
|
+ if (selectedPagesIdList.has(pageId)) {
|
|
|
+ selectedPagesIdList.delete(pageId);
|
|
|
+ }
|
|
|
+ else {
|
|
|
+ selectedPagesIdList.add(pageId);
|
|
|
+ }
|
|
|
+ switch (selectedPagesIdList.size) {
|
|
|
+ case 0:
|
|
|
+ return this.setState({ selectAllCheckboxType: CheckboxType.NONE_CHECKED });
|
|
|
+ case this.state.searchResults.length:
|
|
|
+ return this.setState({ selectAllCheckboxType: CheckboxType.ALL_CHECKED });
|
|
|
+ default:
|
|
|
+ return this.setState({ selectAllCheckboxType: CheckboxType.INDETERMINATE });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ toggleAllCheckBox = (nextSelectAllCheckboxType) => {
|
|
|
+ const { selectedPagesIdList, searchResults } = this.state;
|
|
|
+ if (nextSelectAllCheckboxType === CheckboxType.NONE_CHECKED) {
|
|
|
+ selectedPagesIdList.clear();
|
|
|
+ }
|
|
|
+ else {
|
|
|
+ searchResults.forEach((page) => {
|
|
|
+ selectedPagesIdList.add(page.pageData._id);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ this.setState({
|
|
|
+ selectedPagesIdList,
|
|
|
+ selectAllCheckboxType: nextSelectAllCheckboxType,
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ getSelectedPagesToDelete() {
|
|
|
+ const filteredPages = this.state.searchResults.filter((page) => {
|
|
|
+ return Array.from(this.state.deleteTargetPageIds).find(id => id === page.pageData._id);
|
|
|
+ });
|
|
|
+ return filteredPages.map(page => ({
|
|
|
+ pageId: page.pageData._id,
|
|
|
+ revisionId: page.pageData.revision,
|
|
|
+ path: page.pageData.path,
|
|
|
+ }));
|
|
|
+ }
|
|
|
+
|
|
|
+ deleteSinglePageButtonHandler(pageId) {
|
|
|
+ this.setState({ deleteTargetPageIds: new Set([pageId]) });
|
|
|
+ this.setState({ isDeleteConfirmModalShown: true });
|
|
|
+ }
|
|
|
+
|
|
|
+ deleteAllPagesButtonHandler() {
|
|
|
+ if (this.state.selectedPagesIdList.size === 0) { return }
|
|
|
+ this.setState({ deleteTargetPageIds: this.state.selectedPagesIdList });
|
|
|
+ this.setState({ isDeleteConfirmModalShown: true });
|
|
|
+ }
|
|
|
+
|
|
|
+ closeDeleteConfirmModalHandler() {
|
|
|
+ this.setState({ isDeleteConfirmModalShown: false });
|
|
|
+ }
|
|
|
+
|
|
|
+ renderSearchResultContent = () => {
|
|
|
+ return (
|
|
|
+ <SearchResultContent
|
|
|
+ appContainer={this.props.appContainer}
|
|
|
+ searchingKeyword={this.state.searchingKeyword}
|
|
|
+ focusedSearchResultData={this.state.focusedSearchResultData}
|
|
|
+ showPageControlDropdown={!this.props.isGuestUser}
|
|
|
+ >
|
|
|
+ </SearchResultContent>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ renderSearchResultList = () => {
|
|
|
+ return (
|
|
|
+ <SearchResultList
|
|
|
+ pages={this.state.searchResults || []}
|
|
|
+ isEnableActions={!this.props.isGuestUser}
|
|
|
+ focusedSearchResultData={this.state.focusedSearchResultData}
|
|
|
+ selectedPagesIdList={this.state.selectedPagesIdList || []}
|
|
|
+ searchResultCount={this.state.searchResultCount}
|
|
|
+ activePage={this.state.activePage}
|
|
|
+ pagingLimit={this.state.pagingLimit}
|
|
|
+ onClickItem={this.selectPage}
|
|
|
+ onClickCheckbox={this.toggleCheckBox}
|
|
|
+ onPagingNumberChanged={this.onPagingNumberChanged}
|
|
|
+ onClickDeleteButton={this.deleteSinglePageButtonHandler}
|
|
|
+ />
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ renderSearchControl = () => {
|
|
|
+ return (
|
|
|
+ <SearchControl
|
|
|
+ searchingKeyword={this.state.searchingKeyword}
|
|
|
+ sort={this.state.sort}
|
|
|
+ order={this.state.order}
|
|
|
+ searchResultCount={this.state.searchResultCount || 0}
|
|
|
+ appContainer={this.props.appContainer}
|
|
|
+ onSearchInvoked={this.onSearchInvoked}
|
|
|
+ onClickSelectAllCheckbox={this.toggleAllCheckBox}
|
|
|
+ selectAllCheckboxType={this.state.selectAllCheckboxType}
|
|
|
+ onClickDeleteAllButton={this.deleteAllPagesButtonHandler}
|
|
|
+ onExcludeUserPagesSwitched={this.switchExcludeUserPagesHandler}
|
|
|
+ onExcludeTrashPagesSwitched={this.switchExcludeTrashPagesHandler}
|
|
|
+ excludeUserPages={this.state.excludeUserPages}
|
|
|
+ excludeTrashPages={this.state.excludeTrashPages}
|
|
|
+ onChangeSortInvoked={this.onChangeSortInvoked}
|
|
|
+ >
|
|
|
+ </SearchControl>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ render() {
|
|
|
+ return (
|
|
|
+ <div>
|
|
|
+ <SearchPageLayout
|
|
|
+ SearchControl={this.renderSearchControl}
|
|
|
+ SearchResultList={this.renderSearchResultList}
|
|
|
+ SearchResultContent={this.renderSearchResultContent}
|
|
|
+ searchResultMeta={this.state.searchResultMeta}
|
|
|
+ searchingKeyword={this.state.searchedKeyword}
|
|
|
+ onPagingLimitChanged={this.onPagingLimitChanged}
|
|
|
+ pagingLimit={this.state.pagingLimit}
|
|
|
+ activePage={this.state.activePage}
|
|
|
+ >
|
|
|
+ </SearchPageLayout>
|
|
|
+ {/* TODO: show PageDeleteModal with usePageDeleteModal by 87569 */}
|
|
|
+ <PageDeleteModal
|
|
|
+ isOpen={this.state.isDeleteConfirmModalShown}
|
|
|
+ onClose={this.closeDeleteConfirmModalHandler}
|
|
|
+ pages={this.getSelectedPagesToDelete()}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Wrapper component for using unstated
|
|
|
+ */
|
|
|
+const SearchPageHOCWrapper = withTranslation()(withUnstatedContainers(SearchPage, [AppContainer]));
|
|
|
+
|
|
|
+SearchPage.propTypes = {
|
|
|
+ t: PropTypes.func.isRequired, // i18next
|
|
|
+ appContainer: PropTypes.instanceOf(AppContainer).isRequired,
|
|
|
+ query: PropTypes.object,
|
|
|
+ isGuestUser: PropTypes.bool.isRequired,
|
|
|
+};
|
|
|
+SearchPage.defaultProps = {
|
|
|
+ // pollInterval: 1000,
|
|
|
+ query: SearchPage.getQueryByLocation(window.location || {}),
|
|
|
+};
|
|
|
+
|
|
|
+const SearchPageFCWrapper = (props) => {
|
|
|
+ const { data: isGuestUser } = useIsGuestUser();
|
|
|
+
|
|
|
+ /*
|
|
|
+ * dependencies
|
|
|
+ */
|
|
|
+ if (isGuestUser == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return <SearchPageHOCWrapper {...props} isGuestUser={isGuestUser} />;
|
|
|
+};
|
|
|
+
|
|
|
+export default SearchPageFCWrapper;
|