Преглед изворни кода

Merge branch 'feat/search-implement' into feat/77545-filtering-search-result-for-sp-ver

# Conflicts:
#	packages/app/resource/locales/en_US/translation.json
#	packages/app/resource/locales/ja_JP/translation.json
#	packages/app/resource/locales/zh_CN/translation.json
#	packages/app/src/components/SearchPage.jsx
SULLEY\ryo-h пре 4 година
родитељ
комит
647b953ab4

+ 3 - 0
packages/app/resource/locales/en_US/translation.json

@@ -578,6 +578,9 @@
     "include_certain_path" : "Include {{pathToInclude}} path ",
     "delete_all_selected_page" : "Delete All",
     "search_again" : "Search again"
+    "number_of_list_to_display" : "Display",
+    "page_number_unit" : "pages"
+
   },
   "security_setting": {
     "Guest Users Access": "Guest users access",

+ 2 - 0
packages/app/resource/locales/ja_JP/translation.json

@@ -578,6 +578,8 @@
     "include_certain_path": "{{pathToInclude}}下を含む ",
     "delete_all_selected_page" : "一括削除",
     "search_again" : "再検索"
+    "number_of_list_to_display" : "表示件数",
+    "page_number_unit" : "件"
   },
   "security_setting": {
     "Guest Users Access": "ゲストユーザーのアクセス",

+ 2 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -851,6 +851,8 @@
     "include_certain_path": "包含 {{pathToInclude}} 路径 ",
     "delete_all_selected_page": "删除所有",
     "search_again" : "再次搜索"
+    "number_of_list_to_display" : "显示器的数量",
+    "page_number_unit" : "例"
 	},
 	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",
 	"login": {

+ 0 - 49
packages/app/src/client/services/PageContainer.js

@@ -328,12 +328,6 @@ export default class PageContainer extends Container {
     });
   }
 
-  async toggleBookmark() {
-    const bool = !this.state.isBookmarked;
-    await this.appContainer.apiv3Put('/bookmarks', { pageId: this.state.pageId, bool });
-    return this.retrieveBookmarkInfo();
-  }
-
   async checkAndUpdateImageUrlCached(users) {
     const noImageCacheUsers = users.filter((user) => { return user.imageUrlCached == null });
     if (noImageCacheUsers.length === 0) {
@@ -529,49 +523,6 @@ export default class PageContainer extends Container {
     return res;
   }
 
-  deletePage(isRecursively, isCompletely) {
-    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
-
-    // control flag
-    const completely = isCompletely ? true : null;
-    const recursively = isRecursively ? true : null;
-
-    return this.appContainer.apiPost('/pages.remove', {
-      recursively,
-      completely,
-      page_id: this.state.pageId,
-      revision_id: this.state.revisionId,
-    });
-
-  }
-
-  revertRemove(isRecursively) {
-    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
-
-    // control flag
-    const recursively = isRecursively ? true : null;
-
-    return this.appContainer.apiPost('/pages.revertRemove', {
-      recursively,
-      page_id: this.state.pageId,
-    });
-  }
-
-  rename(newPagePath, isRecursively, isRenameRedirect, isRemainMetadata) {
-    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
-    const { pageId, revisionId, path } = this.state;
-
-    return this.appContainer.apiv3Put('/pages/rename', {
-      revisionId,
-      pageId,
-      isRecursively,
-      isRenameRedirect,
-      isRemainMetadata,
-      newPagePath,
-      path,
-    });
-  }
-
   showSuccessToastr() {
     toastr.success(undefined, 'Saved successfully', {
       closeButton: true,

+ 24 - 12
packages/app/src/components/BookmarkButton.jsx

@@ -6,7 +6,8 @@ import { withTranslation } from 'react-i18next';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 import { toastError } from '~/client/util/apiNotification';
-import PageContainer from '~/client/services/PageContainer';
+import { apiv3Put } from '~/client/util/apiv3-client';
+
 import AppContainer from '~/client/services/AppContainer';
 
 class BookmarkButton extends React.Component {
@@ -18,7 +19,9 @@ class BookmarkButton extends React.Component {
   }
 
   async handleClick() {
-    const { appContainer, pageContainer } = this.props;
+    const {
+      appContainer, pageId, isBookmarked, onChangeInvoked,
+    } = this.props;
     const { isGuestUser } = appContainer;
 
     if (isGuestUser) {
@@ -26,16 +29,21 @@ class BookmarkButton extends React.Component {
     }
 
     try {
-      pageContainer.toggleBookmark();
+      const bool = !isBookmarked;
+      await apiv3Put('/bookmarks', { pageId, bool });
+      if (onChangeInvoked != null) {
+        onChangeInvoked();
+      }
     }
     catch (err) {
       toastError(err);
     }
   }
 
-
   render() {
-    const { appContainer, pageContainer, t } = this.props;
+    const {
+      appContainer, t, isBookmarked, sumOfBookmarks,
+    } = this.props;
     const { isGuestUser } = appContainer;
 
     return (
@@ -45,12 +53,14 @@ class BookmarkButton extends React.Component {
           id="bookmark-button"
           onClick={this.handleClick}
           className={`btn btn-bookmark border-0
-          ${`btn-${this.props.size}`} ${pageContainer.state.isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
+          ${`btn-${this.props.size}`} ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
         >
           <i className="icon-star mr-3"></i>
-          <span className="total-bookmarks">
-            {pageContainer.state.sumOfBookmarks}
-          </span>
+          {sumOfBookmarks && (
+            <span className="total-bookmarks">
+              {sumOfBookmarks}
+            </span>
+          )}
         </button>
 
         {isGuestUser && (
@@ -67,13 +77,15 @@ class BookmarkButton extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const BookmarkButtonWrapper = withUnstatedContainers(BookmarkButton, [AppContainer, PageContainer]);
+const BookmarkButtonWrapper = withUnstatedContainers(BookmarkButton, [AppContainer]);
 
 BookmarkButton.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
-  pageId: PropTypes.string,
+  pageId: PropTypes.string.isRequired,
+  isBookmarked: PropTypes.bool.isRequired,
+  sumOfBookmarks: PropTypes.number,
+  onChangeInvoked: PropTypes.func,
   t: PropTypes.func.isRequired,
   size: PropTypes.string,
 };

+ 15 - 1
packages/app/src/components/Navbar/SubNavButtons.jsx

@@ -4,11 +4,14 @@ import AppContainer from '~/client/services/AppContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
 import { withUnstatedContainers } from '../UnstatedUtils';
+import loggerFactory from '~/utils/logger';
 
 import BookmarkButton from '../BookmarkButton';
 import LikeButtons from '../LikeButtons';
 import PageManagement from '../Page/PageManagement';
 
+const logger = loggerFactory('growi:SubnavButtons');
+
 const SubnavButtons = (props) => {
   const {
     appContainer, navigationContainer, pageContainer, isCompactMode,
@@ -18,6 +21,12 @@ const SubnavButtons = (props) => {
 
   /* eslint-disable react/prop-types */
   const PageReactionButtons = ({ pageContainer }) => {
+    const { pageId, isBookmarked, sumOfBookmarks } = pageContainer.state;
+
+    const onChangeInvoked = () => {
+      if (pageContainer.retrieveBookmarkInfo == null) { logger.error('retrieveBookmarkInfo is null') }
+      else { pageContainer.retrieveBookmarkInfo() }
+    };
 
     return (
       <>
@@ -27,7 +36,12 @@ const SubnavButtons = (props) => {
           </span>
         )}
         <span>
-          <BookmarkButton />
+          <BookmarkButton
+            pageId={pageId}
+            isBookmarked={isBookmarked}
+            sumOfBookmarks={sumOfBookmarks}
+            onChangeInvoked={onChangeInvoked}
+          />
         </span>
       </>
     );

+ 9 - 1
packages/app/src/components/Page/PageManagement.jsx

@@ -22,7 +22,9 @@ const PageManagement = (props) => {
   const {
     t, appContainer, pageContainer, isCompactMode,
   } = props;
-  const { path, isDeletable, isAbleToDeleteCompletely } = pageContainer.state;
+  const {
+    pageId, revisionId, path, isDeletable, isAbleToDeleteCompletely,
+  } = pageContainer.state;
 
   const { currentUser } = appContainer;
   const isTopPagePath = isTopPage(path);
@@ -165,11 +167,15 @@ const PageManagement = (props) => {
         <PageRenameModal
           isOpen={isPageRenameModalShown}
           onClose={closePageRenameModalHandler}
+          pageId={pageId}
+          revisionId={revisionId}
           path={path}
         />
         <PageDuplicateModal
           isOpen={isPageDuplicateModalShown}
           onClose={closePageDuplicateModalHandler}
+          pageId={pageId}
+          path={path}
         />
         <CreateTemplateModal
           isOpen={isPageTemplateModalShown}
@@ -178,6 +184,8 @@ const PageManagement = (props) => {
         <PageDeleteModal
           isOpen={isPageDeleteModalShown}
           onClose={closePageDeleteModalHandler}
+          pageId={pageId}
+          revisionId={revisionId}
           path={path}
           isAbleToDeleteCompletely={isAbleToDeleteCompletely}
         />

+ 4 - 1
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -15,7 +15,7 @@ import PageDeleteModal from '../PageDeleteModal';
 const TrashPageAlert = (props) => {
   const { t, pageContainer } = props;
   const {
-    path, isDeleted, lastUpdateUsername, updatedAt, deletedUserName, deletedAt, isAbleToDeleteCompletely,
+    pageId, revisionId, path, isDeleted, lastUpdateUsername, updatedAt, deletedUserName, deletedAt, isAbleToDeleteCompletely,
   } = pageContainer.state;
   const [isEmptyTrashModalShown, setIsEmptyTrashModalShown] = useState(false);
   const [isPutbackPageModalShown, setIsPutbackPageModalShown] = useState(false);
@@ -92,11 +92,14 @@ const TrashPageAlert = (props) => {
         <PutbackPageModal
           isOpen={isPutbackPageModalShown}
           onClose={closePutbackPageModalHandler}
+          pageId={pageId}
           path={path}
         />
         <PageDeleteModal
           isOpen={isPageDeleteModalShown}
           onClose={opclosePageDeleteModalHandler}
+          pageId={pageId}
+          revisionId={revisionId}
           path={path}
           isDeleteCompletelyModal
           isAbleToDeleteCompletely={isAbleToDeleteCompletely}

+ 17 - 11
packages/app/src/components/PageDeleteModal.jsx

@@ -7,8 +7,7 @@ import {
 
 import { withTranslation } from 'react-i18next';
 
-import { withUnstatedContainers } from './UnstatedUtils';
-import PageContainer from '~/client/services/PageContainer';
+import { apiPost } from '~/client/util/apiv1-client';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
@@ -27,7 +26,7 @@ const deleteIconAndKey = {
 
 const PageDeleteModal = (props) => {
   const {
-    t, pageContainer, isOpen, onClose, isDeleteCompletelyModal, path, isAbleToDeleteCompletely,
+    t, isOpen, onClose, isDeleteCompletelyModal, pageId, revisionId, path, isAbleToDeleteCompletely,
   } = props;
   const [isDeleteRecursively, setIsDeleteRecursively] = useState(true);
   const [isDeleteCompletely, setIsDeleteCompletely] = useState(isDeleteCompletelyModal && isAbleToDeleteCompletely);
@@ -50,7 +49,18 @@ const PageDeleteModal = (props) => {
     setErrs(null);
 
     try {
-      const response = await pageContainer.deletePage(isDeleteRecursively, isDeleteCompletely);
+      // control flag
+      // If is it not true, Request value must be `null`.
+      const recursively = isDeleteRecursively ? true : null;
+      const completely = isDeleteCompletely ? true : null;
+
+      const response = await apiPost('/pages.remove', {
+        page_id: pageId,
+        revision_id: revisionId,
+        recursively,
+        completely,
+      });
+
       const trashPagePath = response.page.path;
       window.location.href = encodeURI(trashPagePath);
     }
@@ -133,18 +143,14 @@ const PageDeleteModal = (props) => {
   );
 };
 
-/**
- * Wrapper component for using unstated
- */
-const PageDeleteModalWrapper = withUnstatedContainers(PageDeleteModal, [PageContainer]);
-
 PageDeleteModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func.isRequired,
 
+  pageId: PropTypes.string.isRequired,
+  revisionId: PropTypes.string.isRequired,
   path: PropTypes.string.isRequired,
   isDeleteCompletelyModal: PropTypes.bool,
   isAbleToDeleteCompletely: PropTypes.bool,
@@ -154,4 +160,4 @@ PageDeleteModal.defaultProps = {
   isDeleteCompletelyModal: false,
 };
 
-export default withTranslation()(PageDeleteModalWrapper);
+export default withTranslation()(PageDeleteModal);

+ 7 - 5
packages/app/src/components/PageDuplicateModal.jsx

@@ -11,7 +11,6 @@ import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
 
 import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
 import PagePathAutoComplete from './PagePathAutoComplete';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import ComparePathsTable from './ComparePathsTable';
@@ -20,11 +19,12 @@ import DuplicatePathsTable from './DuplicatedPathsTable';
 const LIMIT_FOR_LIST = 10;
 
 const PageDuplicateModal = (props) => {
-  const { t, appContainer, pageContainer } = props;
+  const {
+    t, appContainer, pageId, path,
+  } = props;
 
   const config = appContainer.getConfig();
   const isReachable = config.isSearchServiceReachable;
-  const { pageId, path } = pageContainer.state;
   const { crowi } = appContainer.config;
 
   const [pageNameInput, setPageNameInput] = useState(path);
@@ -213,16 +213,18 @@ const PageDuplicateModal = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const PageDuplicateModallWrapper = withUnstatedContainers(PageDuplicateModal, [AppContainer, PageContainer]);
+const PageDuplicateModallWrapper = withUnstatedContainers(PageDuplicateModal, [AppContainer]);
 
 
 PageDuplicateModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func.isRequired,
+
+  pageId: PropTypes.string.isRequired,
+  path: PropTypes.string.isRequired,
 };
 
 export default withTranslation()(PageDuplicateModallWrapper);

+ 25 - 22
packages/app/src/components/PageRenameModal.jsx

@@ -14,7 +14,9 @@ import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
 
 import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
+
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
+
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import ComparePathsTable from './ComparePathsTable';
 import DuplicatedPathsTable from './DuplicatedPathsTable';
@@ -22,11 +24,9 @@ import DuplicatedPathsTable from './DuplicatedPathsTable';
 
 const PageRenameModal = (props) => {
   const {
-    t, appContainer, pageContainer,
+    t, appContainer, path, pageId, revisionId,
   } = props;
 
-  const { path } = pageContainer.state;
-
   const { crowi } = appContainer.config;
 
   const [pageNameInput, setPageNameInput] = useState(path);
@@ -37,7 +37,7 @@ const PageRenameModal = (props) => {
   const [existingPaths, setExistingPaths] = useState([]);
   const [isRenameRecursively, SetIsRenameRecursively] = useState(true);
   const [isRenameRedirect, SetIsRenameRedirect] = useState(false);
-  const [isRenameMetadata, SetIsRenameMetadata] = useState(false);
+  const [isRemainMetadata, SetIsRemainMetadata] = useState(false);
   const [subordinatedError] = useState(null);
   const [isRenameRecursivelyWithoutExistPath, setIsRenameRecursivelyWithoutExistPath] = useState(true);
 
@@ -53,13 +53,13 @@ const PageRenameModal = (props) => {
     SetIsRenameRedirect(!isRenameRedirect);
   }
 
-  function changeIsRenameMetadataHandler() {
-    SetIsRenameMetadata(!isRenameMetadata);
+  function changeIsRemainMetadataHandler() {
+    SetIsRemainMetadata(!isRemainMetadata);
   }
 
   const updateSubordinatedList = useCallback(async() => {
     try {
-      const res = await appContainer.apiv3Get('/pages/subordinated-list', { path });
+      const res = await apiv3Get('/pages/subordinated-list', { path });
       const { subordinatedPaths } = res.data;
       setSubordinatedPages(subordinatedPaths);
     }
@@ -67,7 +67,7 @@ const PageRenameModal = (props) => {
       setErrs(err);
       toastError(t('modal_rename.label.Fail to get subordinated pages'));
     }
-  }, [appContainer, path, t]);
+  }, [path, t]);
 
   useEffect(() => {
     if (props.isOpen) {
@@ -78,7 +78,7 @@ const PageRenameModal = (props) => {
 
   const checkExistPaths = async(newParentPath) => {
     try {
-      const res = await appContainer.apiv3Get('/page/exist-paths', { fromPath: path, toPath: newParentPath });
+      const res = await apiv3Get('/page/exist-paths', { fromPath: path, toPath: newParentPath });
       const { existPaths } = res.data;
       setExistingPaths(existPaths);
     }
@@ -112,12 +112,15 @@ const PageRenameModal = (props) => {
     setErrs(null);
 
     try {
-      const response = await pageContainer.rename(
-        pageNameInput,
-        isRenameRecursively,
+      const response = await apiv3Put('/pages/rename', {
+        revisionId,
+        pageId,
+        isRecursively: isRenameRecursively,
         isRenameRedirect,
-        isRenameMetadata,
-      );
+        isRemainMetadata,
+        newPagePath: pageNameInput,
+        path,
+      });
 
       const { page } = response.data;
       const url = new URL(page.path, 'https://dummy');
@@ -215,12 +218,12 @@ const PageRenameModal = (props) => {
           <input
             className="custom-control-input"
             name="remain_metadata"
-            id="cbRenameMetadata"
+            id="cbRemainMetadata"
             type="checkbox"
-            checked={isRenameMetadata}
-            onChange={changeIsRenameMetadataHandler}
+            checked={isRemainMetadata}
+            onChange={changeIsRemainMetadataHandler}
           />
-          <label className="custom-control-label" htmlFor="cbRenameMetadata">
+          <label className="custom-control-label" htmlFor="cbRemainMetadata">
             { t('modal_rename.label.Do not update metadata') }
             <p className="form-text text-muted mt-0">{ t('modal_rename.help.metadata') }</p>
           </label>
@@ -244,17 +247,17 @@ const PageRenameModal = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const PageRenameModalWrapper = withUnstatedContainers(PageRenameModal, [AppContainer, PageContainer]);
-
+const PageRenameModalWrapper = withUnstatedContainers(PageRenameModal, [AppContainer]);
 
 PageRenameModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func.isRequired,
 
+  pageId: PropTypes.string.isRequired,
+  revisionId: PropTypes.string.isRequired,
   path: PropTypes.string.isRequired,
 };
 

+ 13 - 12
packages/app/src/components/PutbackPageModal.jsx

@@ -7,15 +7,13 @@ import {
 
 import { withTranslation } from 'react-i18next';
 
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import PageContainer from '~/client/services/PageContainer';
+import { apiPost } from '~/client/util/apiv1-client';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
 const PutBackPageModal = (props) => {
   const {
-    t, isOpen, onClose, pageContainer, path,
+    t, isOpen, onClose, pageId, path,
   } = props;
 
   const [errs, setErrs] = useState(null);
@@ -30,7 +28,15 @@ const PutBackPageModal = (props) => {
     setErrs(null);
 
     try {
-      const response = await pageContainer.revertRemove(isPutbackRecursively);
+      // control flag
+      // If is it not true, Request value must be `null`.
+      const recursively = isPutbackRecursively ? true : null;
+
+      const response = await apiPost('/pages.revertRemove', {
+        page_id: pageId,
+        recursively,
+      });
+
       const putbackPagePath = response.page.path;
       window.location.href = encodeURI(putbackPagePath);
     }
@@ -80,20 +86,15 @@ const PutBackPageModal = (props) => {
 
 };
 
-/**
- * Wrapper component for using unstated
- */
-const PutBackPageModalWrapper = withUnstatedContainers(PutBackPageModal, [PageContainer]);
-
 PutBackPageModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func.isRequired,
 
+  pageId: PropTypes.string.isRequired,
   path: PropTypes.string.isRequired,
 };
 
 
-export default withTranslation()(PutBackPageModalWrapper);
+export default withTranslation()(PutBackPageModal);

+ 14 - 10
packages/app/src/components/SearchPage.jsx

@@ -6,7 +6,6 @@ import { withTranslation } from 'react-i18next';
 
 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';
@@ -34,19 +33,20 @@ class SearchPage extends React.Component {
       selectedPages: new Set(),
       searchResultCount: 0,
       activePage: 1,
-      pagingLimit: 10, // change to an appropriate limit number
+      pagingLimit: this.props.appContainer.config.pageLimitationL,
       excludeUserPages: true,
       excludeTrashPages: true,
     };
 
     this.changeURL = this.changeURL.bind(this);
     this.search = this.search.bind(this);
-    this.searchHandler = this.searchHandler.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.onPagingNumberChanged = this.onPagingNumberChanged.bind(this);
+    this.onPagingLimitChanged = this.onPagingLimitChanged.bind(this);
   }
 
   componentDidMount() {
@@ -105,20 +105,23 @@ class SearchPage extends React.Component {
    * this method is called when user changes paging number
    */
   async onPagingNumberChanged(activePage) {
-    // this.setState does not change the state immediately and following calls of this.search outside of this.setState will have old activePage state.
-    // To prevent above, pass this.search as a callback function to make sure this.search will have the latest activePage state.
     this.setState({ activePage }, () => this.search({ keyword: this.state.searchedKeyword }));
   }
 
   /**
    * this method is called when user searches by pressing Enter or using searchbox
    */
-  async searchHandler(data) {
-    // this.setState does not change the state immediately and following calls of this.search outside of this.setState will have old activePage state.
-    // To prevent above, pass this.search as a callback function to make sure this.search will have the latest activePage state.
+  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) {
@@ -225,7 +228,7 @@ class SearchPage extends React.Component {
       <SearchControl
         searchingKeyword={this.state.searchingKeyword}
         appContainer={this.props.appContainer}
-        onSearchInvoked={this.searchHandler}
+        onSearchInvoked={this.onSearchInvoked}
         onExcludeUserPagesSwitched={this.switchExcludeUserPagesHandler}
         onExcludeTrashPagesSwitched={this.switchExcludeTrashPagesHandler}
         excludeUserPages={this.state.excludeUserPages}
@@ -244,6 +247,8 @@ class SearchPage extends React.Component {
           SearchResultContent={this.renderSearchResultContent}
           searchResultMeta={this.state.searchResultMeta}
           searchingKeyword={this.state.searchedKeyword}
+          onPagingLimitChanged={this.onPagingLimitChanged}
+          initialPagingLimit={this.props.appContainer.config.pageLimitationL || 50}
         >
         </SearchPageLayout>
       </div>
@@ -260,7 +265,6 @@ const SearchPageWrapper = withUnstatedContainers(SearchPage, [AppContainer]);
 SearchPage.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
   query: PropTypes.object,
 };
 SearchPage.defaultProps = {

+ 15 - 3
packages/app/src/components/SearchPage/SearchPageLayout.tsx

@@ -12,7 +12,9 @@ type Props = {
   SearchResultList: React.FunctionComponent,
   SearchResultContent: React.FunctionComponent,
   searchResultMeta: SearchResultMeta,
-  searchingKeyword: string
+  searchingKeyword: string,
+  initialPagingLimit: number,
+  onPagingLimitChanged: (limit: number) => void
 }
 
 const SearchPageLayout: FC<Props> = (props: Props) => {
@@ -27,13 +29,23 @@ const SearchPageLayout: FC<Props> = (props: Props) => {
         <div className="col-lg-6  page-list border boder-gray search-result-list px-0" id="search-result-list">
 
           <nav><SearchControl></SearchControl></nav>
-          <div className="d-flex align-items-start justify-content-between mt-1">
-            <div className="search-result-meta">
+          <div className="d-flex align-items-center justify-content-between mt-1 mb-3">
+            <div className="search-result-meta text-nowrap mr-3">
               <span className="font-weight-light">{t('search_result.result_meta')} </span>
               <span className="h5">{`"${searchingKeyword}"`}</span>
               {/* Todo: replace "1-10" to the appropriate value */}
               <span className="ml-3">1-10 / {searchResultMeta.total || 0}</span>
             </div>
+            <div className="input-group search-result-select-group">
+              <div className="input-group-prepend">
+                <label className="input-group-text text-secondary" htmlFor="inputGroupSelect01">{t('search_result.number_of_list_to_display')}</label>
+              </div>
+              <select className="custom-select" id="inputGroupSelect01" onChange={(e) => { props.onPagingLimitChanged(Number(e.target.value)) }}>
+                {[20, 50, 100, 200].map((limit) => {
+                  return <option selected={limit === props.initialPagingLimit} value={limit}>{limit}{t('search_result.page_number_unit')}</option>;
+                })}
+              </select>
+            </div>
           </div>
 
           <div className="page-list">

+ 1 - 0
packages/app/src/server/models/config.ts

@@ -235,6 +235,7 @@ schema.statics.getLocalconfig = function(crowi) {
     isSearchServiceReachable: crowi.searchService.isReachable,
     isMailerSetup: crowi.mailService.isMailerSetup,
     globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
+    pageLimitationL: crowi.configManager.getConfig('crowi', 'customize:showPageLimitationL'),
   };
 
   return localConfig;

+ 5 - 2
packages/app/src/styles/_search.scss

@@ -207,10 +207,13 @@
     }
 
     .search-result-meta {
-      margin-bottom: 10px;
       font-weight: bold;
     }
-
+    .search-result-select-group {
+      > select {
+        max-width: 8rem;
+      }
+    }
     .search-result-list-delete-checkbox {
       margin: 0 10px 0 0;
       vertical-align: middle;