Procházet zdrojové kódy

Merge branch 'feat/80324-adjust-design-for-left-pane' into feat/80324-82319-hide-nav-bar

Yohei Shiina před 4 roky
rodič
revize
9da3da1b3d
61 změnil soubory, kde provedl 748 přidání a 525 odebrání
  1. 13 1
      CHANGELOG.md
  2. 1 1
      lerna.json
  3. 1 1
      package.json
  4. 1 0
      packages/app/config/logger/config.dev.js
  5. 2 2
      packages/app/docker/README.md
  6. 7 7
      packages/app/package.json
  7. 6 2
      packages/app/resource/locales/en_US/translation.json
  8. 6 1
      packages/app/resource/locales/ja_JP/translation.json
  9. 6 1
      packages/app/resource/locales/zh_CN/translation.json
  10. 22 4
      packages/app/src/client/services/ContextExtractor.tsx
  11. 5 5
      packages/app/src/components/BookmarkButton.jsx
  12. 74 0
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  13. 0 1
      packages/app/src/components/Page/TagLabels.jsx
  14. 1 1
      packages/app/src/components/PageDeleteModal.tsx
  15. 5 2
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  16. 17 2
      packages/app/src/components/SearchPage.jsx
  17. 17 4
      packages/app/src/components/SearchPage/SearchControl.tsx
  18. 20 18
      packages/app/src/components/SearchPage/SearchPageLayout.tsx
  19. 9 70
      packages/app/src/components/SearchPage/SearchResultListItem.tsx
  20. 69 0
      packages/app/src/components/SearchPage/SortControl.tsx
  21. 12 71
      packages/app/src/components/Sidebar.tsx
  22. 35 4
      packages/app/src/components/Sidebar/PageTree.tsx
  23. 59 20
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  24. 63 39
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  25. 8 3
      packages/app/src/components/StickyStretchableScroller.jsx
  26. 6 1
      packages/app/src/interfaces/page-listing-results.ts
  27. 2 2
      packages/app/src/interfaces/page.ts
  28. 14 1
      packages/app/src/interfaces/search.ts
  29. 31 0
      packages/app/src/server/middlewares/inject-user-ui-settings-to-localvars.ts
  30. 9 2
      packages/app/src/server/models/obsolete-page.js
  31. 19 7
      packages/app/src/server/models/page.ts
  32. 2 2
      packages/app/src/server/models/user-ui-settings.ts
  33. 14 0
      packages/app/src/server/routes/apiv3/page-listing.ts
  34. 0 18
      packages/app/src/server/routes/apiv3/user-ui-settings.ts
  35. 16 15
      packages/app/src/server/routes/index.js
  36. 4 4
      packages/app/src/server/routes/page.js
  37. 6 2
      packages/app/src/server/routes/search.js
  38. 42 27
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  39. 35 58
      packages/app/src/server/service/search.ts
  40. 1 10
      packages/app/src/server/service/slack-command-handler/search.js
  41. 5 7
      packages/app/src/server/service/slack-event-handler/link-shared.ts
  42. 6 0
      packages/app/src/server/views/layout/layout.html
  43. 4 4
      packages/app/src/stores/context.tsx
  44. 15 1
      packages/app/src/stores/page-listing.tsx
  45. 6 7
      packages/app/src/stores/page.tsx
  46. 15 69
      packages/app/src/stores/ui.tsx
  47. 5 0
      packages/app/src/styles/_layout.scss
  48. 4 2
      packages/app/src/styles/_search.scss
  49. 1 17
      packages/app/src/styles/_sidebar.scss
  50. 5 0
      packages/app/src/styles/_subnav.scss
  51. 1 1
      packages/codemirror-textlint/package.json
  52. 1 1
      packages/core/package.json
  53. 1 1
      packages/plugin-attachment-refs/package.json
  54. 1 1
      packages/plugin-lsx/package.json
  55. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  56. 1 1
      packages/slack/package.json
  57. 1 0
      packages/slack/src/index.ts
  58. 10 0
      packages/slack/src/utils/generate-last-update-markdown.ts
  59. 2 0
      packages/slack/src/utils/required-scopes.ts
  60. 2 2
      packages/slackbot-proxy/package.json
  61. 1 1
      packages/ui/package.json

+ 13 - 1
CHANGELOG.md

@@ -1,9 +1,21 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.5.0...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v4.5.2...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v4.5.2](https://github.com/weseek/growi/compare/v4.5.1...v4.5.2) - 2021-12-06
+
+### 🐛 Bug Fixes
+
+- fix: Added scope for unfurl (#4811) @hakumizuki
+
+## [v4.5.1](https://github.com/weseek/growi/compare/v4.5.0...v4.5.1) - 2021-12-06
+
+### 🐛 Bug Fixes
+
+- fix: /admin/slack-integration page dump undefined error (#4806) @yuki-takei
+
 ## [v4.5.0](https://github.com/weseek/growi/compare/v4.4.13...v4.5.0) - 2021-12-06
 
 ### BREAKING CHANGES

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
   "npmClient": "yarn",
   "useWorkspaces": true,
-  "version": "4.5.1-RC.0",
+  "version": "4.5.3-RC.0",
   "packages": [
     "packages/*"
   ]

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.5.1-RC.0",
+  "version": "4.5.3-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 1 - 0
packages/app/config/logger/config.dev.js

@@ -26,6 +26,7 @@ module.exports = {
   // 'growi:routes:page': 'debug',
   'growi-plugin:*': 'debug',
   // 'growi:InterceptorManager': 'debug',
+  'growi:service:search-delegator:elasticsearch': 'debug',
 
   /*
    * configure level for client

+ 2 - 2
packages/app/docker/README.md

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`4.5.0`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.0/docker/Dockerfile)
-* [`4.5.0-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.0/docker/Dockerfile)
+* [`4.5.2`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.2/docker/Dockerfile)
+* [`4.5.2-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.2/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 

+ 7 - 7
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "4.5.1-RC.0",
+  "version": "4.5.3-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -58,11 +58,11 @@
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^4.5.1-RC.0",
-    "@growi/plugin-attachment-refs": "^4.5.1-RC.0",
-    "@growi/plugin-lsx": "^4.5.1-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^4.5.1-RC.0",
-    "@growi/slack": "^4.5.1-RC.0",
+    "@growi/codemirror-textlint": "^4.5.3-RC.0",
+    "@growi/plugin-attachment-refs": "^4.5.3-RC.0",
+    "@growi/plugin-lsx": "^4.5.3-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^4.5.3-RC.0",
+    "@growi/slack": "^4.5.3-RC.0",
     "@promster/express": "^5.1.0",
     "@promster/server": "^6.0.3",
     "@slack/events-api": "^3.0.0",
@@ -158,7 +158,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.0.16",
-    "@growi/ui": "^4.5.1-RC.0",
+    "@growi/ui": "^4.5.3-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",

+ 6 - 2
packages/app/resource/locales/en_US/translation.json

@@ -582,8 +582,12 @@
     "currently_not_implemented":"This is not currently implemented",
     "search_again" : "Search again",
     "number_of_list_to_display" : "Display",
-    "page_number_unit" : "pages"
-
+    "page_number_unit" : "pages",
+    "sort_axis": {
+      "relationScore": "Sort by relevance",
+      "createdAt": "Creation date",
+      "updatedAt": "Last update date"
+    }
   },
   "security_setting": {
     "Guest Users Access": "Guest users access",

+ 6 - 1
packages/app/resource/locales/ja_JP/translation.json

@@ -582,7 +582,12 @@
     "currently_not_implemented":"現在未実装の機能です",
     "search_again" : "再検索",
     "number_of_list_to_display" : "表示件数",
-    "page_number_unit" : "件"
+    "page_number_unit" : "件",
+    "sort_axis": {
+      "relationScore": "関連度順",
+      "createdAt": "作成日時",
+      "updatedAt": "更新日時"
+    }
   },
   "security_setting": {
     "Guest Users Access": "ゲストユーザーのアクセス",

+ 6 - 1
packages/app/resource/locales/zh_CN/translation.json

@@ -855,7 +855,12 @@
     "currently_not_implemented": "这是当前未实现的功能",
     "search_again" : "再次搜索",
     "number_of_list_to_display" : "显示器的数量",
-    "page_number_unit" : "例"
+    "page_number_unit" : "例",
+    "sort_axis": {
+      "relationScore": "按相关性排序",
+      "createdAt": "按创建日期排序",
+      "updatedAt": "按更新日期排序"
+    }
 	},
 	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",
 	"login": {

+ 22 - 4
packages/app/src/client/services/ContextExtractor.tsx

@@ -4,13 +4,14 @@ import { pagePathUtils } from '@growi/core';
 import {
   useCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd, useIsAbleToDeleteCompletely,
   useIsDeletable, useIsDeleted, useIsNotCreatable, useIsPageExist, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
-  usePageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
+  useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
 } from '../../stores/context';
-
 import {
-  useIsDeviceSmallerThanMd, usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser,
+  useIsDeviceSmallerThanMd,
+  usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
 } from '~/stores/ui';
+import { IUserUISettings } from '~/interfaces/user-ui-settings';
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
 
@@ -25,6 +26,11 @@ const ContextExtractorOnce: FC = () => {
    */
   const currentUser = JSON.parse(document.getElementById('growi-current-user')?.textContent || jsonNull);
 
+  /*
+   * UserUISettings from DOM
+   */
+  const userUISettings: Partial<IUserUISettings> = JSON.parse(document.getElementById('growi-user-ui-settings')?.textContent || jsonNull);
+
   /*
    * Page Context from DOM
    */
@@ -62,6 +68,13 @@ const ContextExtractorOnce: FC = () => {
   // App
   useCurrentUser(currentUser);
 
+  // UserUISettings
+  usePreferDrawerModeByUser(userUISettings?.preferDrawerModeByUser);
+  usePreferDrawerModeOnEditByUser(userUISettings?.preferDrawerModeOnEditByUser);
+  useSidebarCollapsed(userUISettings?.isSidebarCollapsed);
+  useCurrentSidebarContents(userUISettings?.currentSidebarContents);
+  useCurrentProductNavWidth(userUISettings?.currentProductNavWidth);
+
   // Page
   useCreatedAt(createdAt);
   useDeleteUsername(deleteUsername);
@@ -76,7 +89,7 @@ const ContextExtractorOnce: FC = () => {
   useIsTrashPage(isTrashPage);
   useIsUserPage(isUserPage);
   useLastUpdateUsername(lastUpdateUsername);
-  usePageId(pageId);
+  useCurrentPageId(pageId);
   usePageIdOnHackmd(pageIdOnHackmd);
   usePageUser(pageUser);
   useCurrentPagePath(path);
@@ -96,6 +109,11 @@ const ContextExtractorOnce: FC = () => {
   usePreferDrawerModeOnEditByUser();
   useIsDeviceSmallerThanMd();
 
+  // Navigation
+  usePreferDrawerModeByUser();
+  usePreferDrawerModeOnEditByUser();
+  useIsDeviceSmallerThanMd();
+
   return null;
 };
 

+ 5 - 5
packages/app/src/components/BookmarkButton.jsx

@@ -42,11 +42,11 @@ class LegacyBookmarkButton extends React.Component {
           ${`btn-${this.props.size}`} ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
         >
           <i className="icon-star mr-3"></i>
-          {sumOfBookmarks && (
-            <span className="total-bookmarks">
-              {sumOfBookmarks}
-            </span>
-          )}
+          <span className="total-bookmarks">
+            {sumOfBookmarks && (
+              sumOfBookmarks
+            )}
+          </span>
         </button>
 
         {isGuestUser && (

+ 74 - 0
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -0,0 +1,74 @@
+import React, { FC } from 'react';
+
+import toastr from 'toastr';
+import { useTranslation } from 'react-i18next';
+
+import { IPageHasId } from '~/interfaces/page';
+
+type PageItemControlProps = {
+  page: Partial<IPageHasId>,
+  onClickDeleteButton?: (pageId: string) => void,
+}
+
+const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps) => {
+
+  const { page, onClickDeleteButton } = props;
+  const { t } = useTranslation('');
+
+  const deleteButtonHandler = () => {
+    if (onClickDeleteButton != null && page._id != null) {
+      onClickDeleteButton(page._id);
+    }
+  };
+  return (
+    <>
+      <button
+        type="button"
+        className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded grw-btn-page-management py-0"
+        data-toggle="dropdown"
+      >
+        <i className="fa fa-ellipsis-v text-muted"></i>
+      </button>
+      <div className="dropdown-menu dropdown-menu-right">
+
+        {/* TODO: if there is the following button in XD add it here
+        <button
+          type="button"
+          className="btn btn-link p-0"
+          value={page.path}
+          onClick={(e) => {
+            window.location.href = e.currentTarget.value;
+          }}
+        >
+          <i className="icon-login" />
+        </button>
+        */}
+
+        {/*
+          TODO: add function to the following buttons like using modal or others
+          ref: https://estoc.weseek.co.jp/redmine/issues/79026
+        */}
+        <button className="dropdown-item" type="button" onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
+          <i className="icon-fw icon-star"></i>
+          {t('Add to bookmark')}
+        </button>
+        <button className="dropdown-item" type="button" onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
+          <i className="icon-fw icon-docs"></i>
+          {t('Duplicate')}
+        </button>
+        <button className="dropdown-item" type="button" onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
+          <i className="icon-fw  icon-action-redo"></i>
+          {t('Move/Rename')}
+        </button>
+        <div className="dropdown-divider"></div>
+        <button className="dropdown-item text-danger pt-2" type="button" onClick={deleteButtonHandler}>
+          <i className="icon-fw icon-trash"></i>
+          {t('Delete')}
+        </button>
+      </div>
+    </>
+  );
+
+};
+
+export default PageItemControl;

+ 0 - 1
packages/app/src/components/Page/TagLabels.jsx

@@ -8,7 +8,6 @@ import AppContainer from '~/client/services/AppContainer';
 
 import RenderTagLabels from './RenderTagLabels';
 import TagEditModal from './TagEditModal';
-import { EditorMode } from '~/stores/ui';
 
 class TagLabels extends React.Component {
 

+ 1 - 1
packages/app/src/components/PageDeleteModal.tsx

@@ -149,7 +149,7 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
         { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
       </ModalHeader>
       <ModalBody>
-        <div className="form-group">
+        <div className="form-group grw-scrollable-modal-body pb-1">
           <label>{ t('modal_delete.deleting_page') }:</label><br />
           {/* Todo: change the way to show path on modal when too many pages are selected */}
           {/* https://redmine.weseek.co.jp/issues/82787 */}

+ 5 - 2
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -264,7 +264,10 @@ export default class CodeMirrorEditor extends AbstractEditor {
     const linePosition = Math.max(0, line);
 
     editor.setCursor({ line: linePosition }); // leave 'ch' field as null/undefined to indicate the end of line
-    this.setScrollTopByLine(linePosition);
+
+    setTimeout(() => {
+      this.setScrollTopByLine(linePosition);
+    }, 100);
   }
 
   /**
@@ -277,7 +280,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
     const editor = this.getCodeMirror();
     // get top position of the line
-    const top = editor.charCoords({ line, ch: 0 }, 'local').top;
+    const top = editor.charCoords({ line: line - 1, ch: 0 }, 'local').top;
     editor.scrollTo(null, top);
   }
 

+ 17 - 2
packages/app/src/components/SearchPage.jsx

@@ -11,10 +11,9 @@ 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 { CheckboxType } from '../interfaces/search';
-
 export const specificPathNames = {
   user: '/user',
   trash: '/trash',
@@ -38,6 +37,8 @@ class SearchPage extends React.Component {
       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(),
@@ -50,6 +51,7 @@ class SearchPage extends React.Component {
     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);
@@ -85,6 +87,13 @@ class SearchPage extends React.Component {
     this.setState({ excludeTrashPages: !this.state.excludeTrashPages });
   }
 
+  onChangeSortInvoked(nextSort, nextOrder) {
+    this.setState({
+      sort: nextSort,
+      order: nextOrder,
+    });
+  }
+
   changeURL(keyword, refreshHash) {
     let hash = window.location.hash || '';
     // TODO 整理する
@@ -153,11 +162,14 @@ class SearchPage extends React.Component {
     });
     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) {
@@ -289,6 +301,8 @@ class SearchPage extends React.Component {
     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}
@@ -299,6 +313,7 @@ class SearchPage extends React.Component {
         onExcludeTrashPagesSwitched={this.switchExcludeTrashPagesHandler}
         excludeUserPages={this.state.excludeUserPages}
         excludeTrashPages={this.state.excludeTrashPages}
+        onChangeSortInvoked={this.onChangeSortInvoked}
       >
       </SearchControl>
     );

+ 17 - 4
packages/app/src/components/SearchPage/SearchControl.tsx

@@ -4,10 +4,13 @@ import SearchPageForm from './SearchPageForm';
 import AppContainer from '../../client/services/AppContainer';
 import DeleteSelectedPageGroup from './DeleteSelectedPageGroup';
 import SearchOptionModal from './SearchOptionModal';
-import { CheckboxType } from '../../interfaces/search';
+import SortControl from './SortControl';
+import { CheckboxType, SORT_AXIS, SORT_ORDER } from '../../interfaces/search';
 
 type Props = {
   searchingKeyword: string,
+  sort: SORT_AXIS,
+  order: SORT_ORDER,
   appContainer: AppContainer,
   searchResultCount: number,
   selectAllCheckboxType: CheckboxType,
@@ -18,6 +21,7 @@ type Props = {
   onSearchInvoked: (data: {keyword: string}) => boolean,
   onExcludeUserPagesSwitched?: () => void,
   onExcludeTrashPagesSwitched?: () => void,
+  onChangeSortInvoked?: (nextSort: SORT_AXIS, nextOrder: SORT_ORDER) => void,
 }
 
 const SearchControl: FC <Props> = (props: Props) => {
@@ -41,6 +45,12 @@ const SearchControl: FC <Props> = (props: Props) => {
     }
   };
 
+  const onChangeSortInvoked = (nextSort: SORT_AXIS, nextOrder:SORT_ORDER) => {
+    if (props.onChangeSortInvoked != null) {
+      props.onChangeSortInvoked(nextSort, nextOrder);
+    }
+  };
+
   const openSearchOptionModalHandler = () => {
     setIsFileterOptionModalShown(true);
   };
@@ -79,9 +89,12 @@ const SearchControl: FC <Props> = (props: Props) => {
             onSearchFormChanged={props.onSearchInvoked}
           />
         </div>
-        <div className="mr-4">
-          {/* TODO: replace the following button */}
-          <button type="button">related pages</button>
+        <div className="mr-4 d-flex">
+          <SortControl
+            sort={props.sort}
+            order={props.order}
+            onChangeSortInvoked={onChangeSortInvoked}
+          />
         </div>
       </div>
       {/* TODO: replace the following elements deleteAll button , relevance button and include specificPath button component */}

+ 20 - 18
packages/app/src/components/SearchPage/SearchPageLayout.tsx

@@ -37,27 +37,29 @@ const SearchPageLayout: FC<Props> = (props: Props) => {
         <div className="flex-grow-1 flex-basis-0 page-list border boder-gray search-result-list" id="search-result-list">
 
           <SearchControl></SearchControl>
-          <div className="d-flex align-items-center justify-content-between my-3 ml-4">
-            <div className="search-result-meta text-nowrap">
-              <span className="font-weight-light">{t('search_result.result_meta')} </span>
-              <span className="h5">{`"${searchingKeyword}"`}</span>
-              {/* Todo: replace "1-10" to the appropriate value */}
-              {renderShowingPageCountInfo()}
-            </div>
-            <div className="input-group search-result-select-group ml-4">
-              <div className="input-group-prepend">
-                <label className="input-group-text text-secondary" htmlFor="inputGroupSelect01">{t('search_result.number_of_list_to_display')}</label>
+          <div className="search-result-list-scroll">
+            <div className="d-flex align-items-center justify-content-between my-3 ml-4">
+              <div className="search-result-meta text-nowrap">
+                <span className="font-weight-light">{t('search_result.result_meta')} </span>
+                <span className="h5">{`"${searchingKeyword}"`}</span>
+                {/* Todo: replace "1-10" to the appropriate value */}
+                {renderShowingPageCountInfo()}
+              </div>
+              <div className="input-group search-result-select-group ml-4">
+                <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.pagingLimit} value={limit}>{limit}{t('search_result.page_number_unit')}</option>;
+                  })}
+                </select>
               </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.pagingLimit} value={limit}>{limit}{t('search_result.page_number_unit')}</option>;
-                })}
-              </select>
             </div>
-          </div>
 
-          <div className="page-list">
-            <ul className="page-list-ul page-list-ul-flat pl-4 nav nav-pills"><SearchResultList></SearchResultList></ul>
+            <div className="page-list">
+              <ul className="page-list-ul page-list-ul-flat pl-4 nav nav-pills"><SearchResultList></SearchResultList></ul>
+            </div>
           </div>
         </div>
         <div className="flex-grow-1 flex-basis-0 d-none d-lg-block search-result-content">

+ 9 - 70
packages/app/src/components/SearchPage/SearchResultListItem.tsx

@@ -1,75 +1,13 @@
 import React, { FC } from 'react';
 
 import Clamp from 'react-multiline-clamp';
-import toastr from 'toastr';
 
-import { useTranslation } from 'react-i18next';
 import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
-import { IPageSearchResultData } from '../../interfaces/search';
-
-import { IPageHasId } from '~/interfaces/page';
-
-type PageItemControlProps = {
-  page: IPageHasId,
-  onClickDeleteButton?: (pageId: string)=>void,
-}
 
-const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps) => {
-
-  const { page, onClickDeleteButton } = props;
-  const { t } = useTranslation('');
-
-  const deleteButtonHandler = () => {
-    if (onClickDeleteButton != null) {
-      onClickDeleteButton(page._id);
-    }
-  };
-  return (
-    <>
-      <button
-        type="button"
-        className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded grw-btn-page-management py-0"
-        data-toggle="dropdown"
-      >
-        <i className="fa fa-ellipsis-v text-muted"></i>
-      </button>
-      <div className="dropdown-menu dropdown-menu-right">
-
-        {/* TODO: if there is the following button in XD add it here
-        <button
-          type="button"
-          className="btn btn-link p-0"
-          value={page.path}
-          onClick={(e) => {
-            window.location.href = e.currentTarget.value;
-          }}
-        >
-          <i className="icon-login" />
-        </button>
-        */}
-
-        {/*
-          TODO: add function to the following buttons like using modal or others
-          ref: https://estoc.weseek.co.jp/redmine/issues/79026
-        */}
-        <button className="dropdown-item text-danger" type="button" onClick={deleteButtonHandler}>
-          <i className="icon-fw icon-fire"></i>{t('Delete')}
-        </button>
-        <button className="dropdown-item" type="button" onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
-          <i className="icon-fw icon-star"></i>{t('Add to bookmark')}
-        </button>
-        <button className="dropdown-item" type="button" onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
-          <i className="icon-fw icon-docs"></i>{t('Duplicate')}
-        </button>
-        <button className="dropdown-item" type="button" onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
-          <i className="icon-fw  icon-action-redo"></i>{t('Move/Rename')}
-        </button>
-      </div>
-    </>
-  );
+import { IPageSearchResultData } from '../../interfaces/search';
+import PageItemControl from '../Common/Dropdown/PageItemControl';
 
-};
 
 type Props = {
   page: IPageSearchResultData,
@@ -143,12 +81,13 @@ const SearchResultListItem: FC<Props> = (props:Props) => {
               </div>
             </div>
             <div className="my-2">
-              <Clamp
-                lines={2}
-              >
-                {pageMeta.elasticSearchResult != null
-                && <div className="mt-1" dangerouslySetInnerHTML={{ __html: pageMeta.elasticSearchResult.snippet }}></div>}
-              </Clamp>
+              {
+                pageMeta.elasticSearchResult != null && (
+                  <Clamp lines={2}>
+                    <div className="mt-1" dangerouslySetInnerHTML={{ __html: pageMeta.elasticSearchResult.snippet }}></div>
+                  </Clamp>
+                )
+              }
             </div>
           </div>
         </div>

+ 69 - 0
packages/app/src/components/SearchPage/SortControl.tsx

@@ -0,0 +1,69 @@
+import React, { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+import { SORT_AXIS, SORT_ORDER } from '../../interfaces/search';
+
+const { DESC, ASC } = SORT_ORDER;
+
+type Props = {
+  sort: SORT_AXIS,
+  order: SORT_ORDER,
+  onChangeSortInvoked?: (nextSort: SORT_AXIS, nextOrder: SORT_ORDER) => void,
+}
+
+const SortControl: FC <Props> = (props: Props) => {
+
+  const { t } = useTranslation('');
+
+  const onClickChangeSort = (nextSortAxis: SORT_AXIS, nextSortOrder: SORT_ORDER) => {
+    if (props.onChangeSortInvoked != null) {
+      props.onChangeSortInvoked(nextSortAxis, nextSortOrder);
+    }
+  };
+
+  const renderOrderIcon = (order: SORT_ORDER) => {
+    const iconClassName = ASC === order ? 'fa fa-sort-amount-asc' : 'fa fa-sort-amount-desc';
+    return <i className={iconClassName} aria-hidden="true" />;
+  };
+
+  const renderSortItem = (sort, order) => {
+    return <><span className="mr-3">{t(`search_result.sort_axis.${sort}`)}</span>{renderOrderIcon(order)}</>;
+  };
+
+  return (
+    <>
+      <div className="input-group">
+        <div className="input-group-prepend">
+          <div className="input-group-text border" id="btnGroupAddon">
+            {renderOrderIcon(props.order)}
+          </div>
+        </div>
+        <div className="btn-group" role="group">
+          <button
+            type="button"
+            className="btn border dropdown-toggle"
+            data-toggle="dropdown"
+          >
+            <span className="mr-4">{t(`search_result.sort_axis.${props.sort}`)}</span>
+          </button>
+          <div className="dropdown-menu dropdown-menu-right">
+            {Object.values(SORT_AXIS).map((sortAxis) => {
+              const nextOrder = (props.sort !== sortAxis || props.order === ASC) ? DESC : ASC;
+              return (
+                <button
+                  className="dropdown-item d-flex justify-content-between"
+                  type="button"
+                  onClick={() => { onClickChangeSort(sortAxis, nextOrder) }}
+                >
+                  {renderSortItem(sortAxis, nextOrder)}
+                </button>
+              );
+            })}
+          </div>
+        </div>
+      </div>
+    </>
+  );
+};
+
+
+export default SortControl;

+ 12 - 71
packages/app/src/components/Sidebar.tsx

@@ -43,20 +43,9 @@ const GlobalNavigation = () => {
   return <SidebarNav onItemSelected={itemSelectedHandler} />;
 };
 
-// dummy skelton contents
-const GlobalNavigationSkelton = () => {
-  return (
-    <div className="grw-sidebar-nav">
-      <div className="grw-sidebar-nav-primary-container">
-      </div>
-      <div className="grw-sidebar-nav-secondary-container">
-      </div>
-    </div>
-  );
-};
-
-
 const SidebarContentsWrapper = () => {
+  const [resetKey, setResetKey] = useState(0);
+
   const scrollTargetSelector = '#grw-sidebar-contents-scroll-target';
 
   const calcViewHeight = useCallback(() => {
@@ -73,10 +62,11 @@ const SidebarContentsWrapper = () => {
         contentsElemSelector="#grw-sidebar-content-container"
         stickyElemSelector=".grw-sidebar"
         calcViewHeightFunc={calcViewHeight}
+        resetKey={resetKey}
       />
 
       <div id="grw-sidebar-contents-scroll-target">
-        <div id="grw-sidebar-content-container">
+        <div id="grw-sidebar-content-container" onLoad={() => setResetKey(Math.random())}>
           <SidebarContents />
         </div>
       </div>
@@ -86,13 +76,6 @@ const SidebarContentsWrapper = () => {
   );
 };
 
-// dummy skelton contents
-const SidebarSkeltonContents = () => {
-  return (
-    <div>Skelton Contents!!!</div>
-  );
-};
-
 
 type Props = {
 }
@@ -104,26 +87,12 @@ const Sidebar: FC<Props> = (props: Props) => {
   const { data: isCollapsed, mutate: mutateSidebarCollapsed } = useSidebarCollapsed();
   const { data: isResizeDisabled, mutate: mutateSidebarResizeDisabled } = useSidebarResizeDisabled();
 
+  const [isTransitionEnabled, setTransitionEnabled] = useState(false);
+
   const [isHover, setHover] = useState(false);
   const [isDragging, setDrag] = useState(false);
-  const [isMounted, setMounted] = useState(false);
 
   const isResizableByDrag = !isResizeDisabled && !isDrawerMode && (!isCollapsed || isHover);
-  /**
-   * hack and override UIController.storeState
-   *
-   * Since UIController is an unstated container, setState() in storeState method should be awaited before writing to cache.
-   */
-  // hackUIController() {
-  //   const { navigationUIController } = this.props;
-
-  //   // see: @atlaskit/navigation-next/dist/esm/ui-controller/UIController.js
-  //   const orgStoreState = navigationUIController.storeState;
-  //   navigationUIController.storeState = async(state) => {
-  //     await navigationUIController.setState(state);
-  //     orgStoreState(state);
-  //   };
-  // }
 
   const toggleDrawerMode = useCallback((bool) => {
     const isStateModified = isResizeDisabled !== bool;
@@ -133,52 +102,24 @@ const Sidebar: FC<Props> = (props: Props) => {
 
     // Drawer <-- Dock
     if (bool) {
-      // // cache state
-      // this.sidebarCollapsedCached = navigationUIController.state.isCollapsed;
-      // this.sidebarWidthCached = navigationUIController.state.productNavWidth;
-
-      // // clear transition temporary
-      // if (this.sidebarCollapsedCached) {
-      //   this.addCssClassTemporary('grw-sidebar-supress-transitions-to-drawer');
-      // }
-
       // disable resize
       mutateSidebarResizeDisabled(true, false);
     }
     // Drawer --> Dock
     else {
-      // // clear transition temporary
-      // if (this.sidebarCollapsedCached) {
-      //   this.addCssClassTemporary('grw-sidebar-supress-transitions-to-dock');
-      // }
-
       // enable resize
       mutateSidebarResizeDisabled(false, false);
-
-      // // restore width
-      // if (this.sidebarWidthCached != null) {
-      //   navigationUIController.setState({ productNavWidth: this.sidebarWidthCached });
-      // }
     }
   }, [isResizeDisabled, mutateSidebarResizeDisabled]);
 
-  // addCssClassTemporary(className) {
-  //   // clear
-  //   this.sidebarElem.classList.add(className);
-
-  //   // restore after 300ms
-  //   setTimeout(() => {
-  //     this.sidebarElem.classList.remove(className);
-  //   }, 300);
-  // }
-
   const backdropClickedHandler = useCallback(() => {
     mutateDrawerOpened(false, false);
   }, [mutateDrawerOpened]);
 
   useEffect(() => {
-    // this.hackUIController();
-    setMounted(true);
+    setTimeout(() => {
+      setTransitionEnabled(true);
+    }, 1000);
   }, []);
 
   useEffect(() => {
@@ -285,10 +226,10 @@ const Sidebar: FC<Props> = (props: Props) => {
     <>
       <div className={`grw-sidebar d-print-none ${isDrawerMode ? 'grw-sidebar-drawer' : ''} ${isDrawerOpened ? 'open' : ''}`}>
         <div className="data-layout-container">
-          <div className="navigation" onMouseLeave={hoverOutHandler}>
+          <div className={`navigation ${isTransitionEnabled ? 'transition-enabled' : ''}`} onMouseLeave={hoverOutHandler}>
             <div className="grw-navigation-wrap">
               <div className="grw-global-navigation">
-                { isMounted ? <GlobalNavigation></GlobalNavigation> : <GlobalNavigationSkelton></GlobalNavigationSkelton> }
+                <GlobalNavigation></GlobalNavigation>
               </div>
               <div
                 ref={resizableContainer}
@@ -298,7 +239,7 @@ const Sidebar: FC<Props> = (props: Props) => {
               >
                 <div className="grw-contextual-navigation-child">
                   <div role="group" className={`grw-contextual-navigation-sub ${!isHover && isCollapsed ? 'collapsed' : ''}`}>
-                    { isMounted ? <SidebarContentsWrapper></SidebarContentsWrapper> : <SidebarSkeltonContents></SidebarSkeltonContents> }
+                    <SidebarContentsWrapper></SidebarContentsWrapper>
                   </div>
                 </div>
               </div>

+ 35 - 4
packages/app/src/components/Sidebar/PageTree.tsx

@@ -1,16 +1,37 @@
-import React, { FC, memo } from 'react';
+import React, { FC, memo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
+import { useCurrentPagePath, useCurrentPageId, useTargetAndAncestors } from '~/stores/context';
 
 import ItemsTree from './PageTree/ItemsTree';
 import PrivateLegacyPages from './PageTree/PrivateLegacyPages';
+import { IPageForPageDeleteModal } from '../PageDeleteModal';
 
 
 const PageTree: FC = memo(() => {
   const { t } = useTranslation();
 
-  const { data } = useSWRxV5MigrationStatus();
+  const { data: currentPath } = useCurrentPagePath();
+  const { data: targetId } = useCurrentPageId();
+  const { data: targetAndAncestorsData } = useTargetAndAncestors();
+
+  const { data: migrationStatus } = useSWRxV5MigrationStatus();
+
+  // for delete modal
+  const [isDeleteModalOpen, setDeleteModalOpen] = useState(false);
+  const [pagesToDelete, setPagesToDelete] = useState<IPageForPageDeleteModal[]>([]);
+
+  const onClickDeleteByPage = (page: IPageForPageDeleteModal) => {
+    setDeleteModalOpen(true);
+    setPagesToDelete([page]);
+  };
+
+  const onCloseDelete = () => {
+    setDeleteModalOpen(false);
+  };
+
+  const path = currentPath || '/';
 
   return (
     <>
@@ -19,12 +40,22 @@ const PageTree: FC = memo(() => {
       </div>
 
       <div className="grw-sidebar-content-body">
-        <ItemsTree />
+        <ItemsTree
+          targetPath={path}
+          targetId={targetId}
+          targetAndAncestorsData={targetAndAncestorsData}
+          isDeleteModalOpen={isDeleteModalOpen}
+          pagesToDelete={pagesToDelete}
+          isAbleToDeleteCompletely={false} // TODO: pass isAbleToDeleteCompletely
+          isDeleteCompletelyModal={false} // TODO: pass isDeleteCompletelyModal
+          onCloseDelete={onCloseDelete}
+          onClickDeleteByPage={onClickDeleteByPage}
+        />
       </div>
 
       <div className="grw-sidebar-content-footer">
         {
-          data?.migratablePagesCount != null && data.migratablePagesCount !== 0 && (
+          migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
             <PrivateLegacyPages />
           )
         }

+ 59 - 20
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -5,55 +5,68 @@ import nodePath from 'path';
 import { useTranslation } from 'react-i18next';
 
 import { ItemNode } from './ItemNode';
+import { IPageHasId } from '~/interfaces/page';
 import { useSWRxPageChildren } from '../../../stores/page-listing';
-import { usePageId } from '../../../stores/context';
 import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
+import PageItemControl from '../../Common/Dropdown/PageItemControl';
+import { IPageForPageDeleteModal } from '~/components/PageDeleteModal';
 
 
 interface ItemProps {
   itemNode: ItemNode
+  targetId?: string
   isOpen?: boolean
+  onClickDeleteByPage?(page: IPageForPageDeleteModal): void
 }
 
 // Utility to mark target
-const markTarget = (children: ItemNode[], targetId: string): void => {
+const markTarget = (children: ItemNode[], targetId?: string): void => {
+  if (targetId == null) {
+    return;
+  }
+
   children.forEach((node) => {
     if (node.page._id === targetId) {
       node.page.isTarget = true;
     }
     return node;
   });
-
-  return;
 };
 
 type ItemControlProps = {
+  page: Partial<IPageHasId>
+  onClickDeleteButtonHandler?(): void
   onClickPlusButtonHandler?(): void
 }
 
 const ItemControl: FC<ItemControlProps> = memo((props: ItemControlProps) => {
-  const onClickHandler = () => {
-    const { onClickPlusButtonHandler: handler } = props;
-    if (handler == null) {
+  const onClickPlusButton = () => {
+    if (props.onClickPlusButtonHandler == null) {
       return;
     }
 
-    handler();
+    props.onClickPlusButtonHandler();
   };
 
+  const onClickDeleteButton = () => {
+    if (props.onClickDeleteButtonHandler == null) {
+      return;
+    }
+
+    props.onClickDeleteButtonHandler();
+  };
+
+  if (props.page == null) {
+    return <></>;
+  }
+
   return (
     <>
-      <button
-        type="button"
-        className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded grw-btn-page-management py-0"
-        data-toggle="dropdown"
-      >
-        <i className="icon-options-vertical text-muted"></i>
-      </button>
+      <PageItemControl page={props.page} onClickDeleteButton={onClickDeleteButton} />
       <button
         type="button"
         className="btn-link nav-link border-0 rounded grw-btn-page-management py-0"
-        onClick={onClickHandler}
+        onClick={onClickPlusButton}
       >
         <i className="icon-plus text-muted"></i>
       </button>
@@ -65,7 +78,7 @@ const ItemCount: FC = () => {
   return (
     <>
       <span className="grw-pagetree-count badge badge-pill badge-light">
-        10
+        {/* TODO: consider to show the number of children pages */}
       </span>
     </>
   );
@@ -73,7 +86,9 @@ const ItemCount: FC = () => {
 
 const Item: FC<ItemProps> = (props: ItemProps) => {
   const { t } = useTranslation();
-  const { itemNode, isOpen: _isOpen = false } = props;
+  const {
+    itemNode, targetId, isOpen: _isOpen = false, onClickDeleteByPage,
+  } = props;
 
   const { page, children } = itemNode;
 
@@ -82,7 +97,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
   const [isNewPageInputShown, setNewPageInputShown] = useState(false);
 
-  const { data: targetId } = usePageId();
   const { data, error } = useSWRxPageChildren(isOpen ? page._id : null);
 
   const hasChildren = useCallback((): boolean => {
@@ -93,6 +107,26 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     setIsOpen(!isOpen);
   }, [isOpen]);
 
+  const onClickDeleteButtonHandler = useCallback(() => {
+    if (onClickDeleteByPage == null) {
+      return;
+    }
+
+    const { _id: pageId, revision: revisionId, path } = page;
+
+    if (pageId == null || revisionId == null || path == null) {
+      throw Error('Any of _id, revision, and path must not be null.');
+    }
+
+    const pageToDelete: IPageForPageDeleteModal = {
+      pageId,
+      revisionId: revisionId as string,
+      path,
+    };
+
+    onClickDeleteByPage(pageToDelete);
+  }, [page, onClickDeleteByPage]);
+
   const inputValidator = (title: string | null): AlertInfo | null => {
     if (title == null || title === '') {
       return {
@@ -158,7 +192,11 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           <ItemCount />
         </div>
         <div className="grw-pagetree-control d-none">
-          <ItemControl onClickPlusButtonHandler={() => { setNewPageInputShown(true) }} />
+          <ItemControl
+            page={page}
+            onClickDeleteButtonHandler={onClickDeleteButtonHandler}
+            onClickPlusButtonHandler={() => { setNewPageInputShown(true) }}
+          />
         </div>
       </div>
 
@@ -175,6 +213,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             key={node.page._id}
             itemNode={node}
             isOpen={false}
+            onClickDeleteByPage={onClickDeleteByPage}
           />
         ))
       }

+ 63 - 39
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -1,17 +1,17 @@
 import React, { FC } from 'react';
 
-import { IPage } from '../../../interfaces/page';
+import { IPageHasId } from '../../../interfaces/page';
 import { ItemNode } from './ItemNode';
 import Item from './Item';
-import { useSWRxPageAncestorsChildren } from '../../../stores/page-listing';
-import { useTargetAndAncestors, useCurrentPagePath } from '../../../stores/context';
-import { HasObjectId } from '../../../interfaces/has-object-id';
-
+import { useSWRxPageAncestorsChildren, useSWRxRootPage } from '../../../stores/page-listing';
+import { TargetAndAncestors } from '~/interfaces/page-listing-results';
+import { toastError } from '~/client/util/apiNotification';
+import PageDeleteModal, { IPageForPageDeleteModal } from '~/components/PageDeleteModal';
 
 /*
  * Utility to generate initial node
  */
-const generateInitialNodeBeforeResponse = (targetAndAncestors: Partial<IPage>[]): ItemNode => {
+const generateInitialNodeBeforeResponse = (targetAndAncestors: Partial<IPageHasId>[]): ItemNode => {
   const nodes = targetAndAncestors.map((page): ItemNode => {
     return new ItemNode(page, []);
   });
@@ -25,11 +25,11 @@ const generateInitialNodeBeforeResponse = (targetAndAncestors: Partial<IPage>[])
   return rootNode;
 };
 
-const generateInitialNodeAfterResponse = (ancestorsChildren: Record<string, Partial<IPage & HasObjectId>[]>, rootNode: ItemNode): ItemNode => {
+const generateInitialNodeAfterResponse = (ancestorsChildren: Record<string, Partial<IPageHasId>[]>, rootNode: ItemNode): ItemNode => {
   const paths = Object.keys(ancestorsChildren);
 
   let currentNode = rootNode;
-  paths.reverse().forEach((path) => {
+  paths.forEach((path) => {
     const childPages = ancestorsChildren[path];
     currentNode.children = ItemNode.generateNodesFromPages(childPages);
 
@@ -42,53 +42,77 @@ const generateInitialNodeAfterResponse = (ancestorsChildren: Record<string, Part
   return rootNode;
 };
 
+type ItemsTreeProps = {
+  targetPath: string
+  targetId?: string
+  targetAndAncestorsData?: TargetAndAncestors
+
+  // for deleteModal
+  isDeleteModalOpen: boolean
+  pagesToDelete: IPageForPageDeleteModal[]
+  isAbleToDeleteCompletely: boolean
+  isDeleteCompletelyModal: boolean
+  onCloseDelete(): void
+  onClickDeleteByPage(page: IPageForPageDeleteModal): void
+}
+
+const renderByInitialNode = (
+    initialNode: ItemNode, DeleteModal: JSX.Element, targetId?: string, onClickDeleteByPage?: (page: IPageForPageDeleteModal) => void,
+): JSX.Element => {
+  return (
+    <div className="grw-pagetree p-3">
+      <Item key={initialNode.page.path} targetId={targetId} itemNode={initialNode} isOpen onClickDeleteByPage={onClickDeleteByPage} />
+      {DeleteModal}
+    </div>
+  );
+};
+
 
 /*
  * ItemsTree
  */
-const ItemsTree: FC = () => {
-  const { data: currentPath } = useCurrentPagePath();
-
-  const { data, error } = useTargetAndAncestors();
-
-  const { data: ancestorsChildrenData, error: error2 } = useSWRxPageAncestorsChildren(currentPath || null);
-
-  if (error != null || error2 != null) {
-    return null;
-  }
+const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
+  const {
+    targetPath, targetId, targetAndAncestorsData, isDeleteModalOpen, pagesToDelete, isAbleToDeleteCompletely, isDeleteCompletelyModal, onCloseDelete,
+    onClickDeleteByPage,
+  } = props;
+
+  const { data: ancestorsChildrenData, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
+  const { data: rootPageData, error: error2 } = useSWRxRootPage();
+
+  const DeleteModal = (
+    <PageDeleteModal
+      isOpen={isDeleteModalOpen}
+      pages={pagesToDelete}
+      isAbleToDeleteCompletely={isAbleToDeleteCompletely}
+      isDeleteCompletelyModal={isDeleteCompletelyModal}
+      onClose={onCloseDelete}
+    />
+  );
 
-  if (data == null) {
+  if (error1 != null || error2 != null) {
+    // TODO: improve message
+    toastError('Error occurred while fetching pages to render PageTree');
     return null;
   }
 
-  const { targetAndAncestors, rootPage } = data;
-
-  let initialNode: ItemNode;
-
   /*
-   * Before swr response comes back
+   * Render completely
    */
-  if (ancestorsChildrenData == null) {
-    initialNode = generateInitialNodeBeforeResponse(targetAndAncestors);
+  if (ancestorsChildrenData != null && rootPageData != null) {
+    const initialNode = generateInitialNodeAfterResponse(ancestorsChildrenData.ancestorsChildren, new ItemNode(rootPageData.rootPage));
+    return renderByInitialNode(initialNode, DeleteModal, targetId, onClickDeleteByPage);
   }
 
   /*
-   * When swr request finishes
+   * Before swr response comes back
    */
-  else {
-    const { ancestorsChildren } = ancestorsChildrenData;
-
-    const rootNode = new ItemNode(rootPage);
-
-    initialNode = generateInitialNodeAfterResponse(ancestorsChildren, rootNode);
+  if (targetAndAncestorsData != null) {
+    const initialNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
+    return renderByInitialNode(initialNode, DeleteModal, targetId, onClickDeleteByPage);
   }
 
-  const isOpen = true;
-  return (
-    <div className="grw-pagetree p-3">
-      <Item key={(initialNode as ItemNode).page.path} itemNode={(initialNode as ItemNode)} isOpen={isOpen} />
-    </div>
-  );
+  return null;
 };
 
 

+ 8 - 3
packages/app/src/components/StickyStretchableScroller.jsx

@@ -48,6 +48,7 @@ const StickyStretchableScroller = (props) => {
   const {
     children, contentsElemSelector, stickyElemSelector,
     calcViewHeightFunc, calcContentsHeightFunc,
+    resetKey,
   } = props;
 
   if (scrollTargetSelector == null && children == null) {
@@ -137,10 +138,12 @@ const StickyStretchableScroller = (props) => {
     };
   }, [resetScrollbarDebounced]);
 
-  // setup effect by update props
+  // setup effect on init
   useEffect(() => {
-    resetScrollbarDebounced();
-  }, [resetScrollbarDebounced]);
+    if (resetKey != null) {
+      resetScrollbarDebounced();
+    }
+  }, [resetKey, resetScrollbarDebounced]);
 
   return (
     <>
@@ -156,6 +159,8 @@ StickyStretchableScroller.propTypes = {
   scrollTargetSelector: PropTypes.string,
   stickyElemSelector: PropTypes.string,
 
+  resetKey: PropTypes.any,
+
   calcViewHeightFunc: PropTypes.func,
   calcContentsHeightFunc: PropTypes.func,
 };

+ 6 - 1
packages/app/src/interfaces/page-listing-results.ts

@@ -1,7 +1,12 @@
-import { IPageForItem } from './page';
+import { IPageForItem, IPageHasId } from './page';
 
 
 type ParentPath = string;
+
+export interface RootPageResult {
+  rootPage: IPageHasId
+}
+
 export interface AncestorsChildrenResult {
   ancestorsChildren: Record<ParentPath, Partial<IPageForItem>[]>
 }

+ 2 - 2
packages/app/src/interfaces/page.ts

@@ -31,6 +31,6 @@ export type IPage = {
   deletedAt: Date,
 }
 
-export type IPageForItem = Partial<IPage & {isTarget?: boolean} & HasObjectId>;
-
 export type IPageHasId = IPage & HasObjectId;
+
+export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean}>;

+ 14 - 1
packages/app/src/interfaces/search.ts

@@ -9,10 +9,23 @@ export enum CheckboxType {
 export type IPageSearchResultData = {
   pageData: IPageHasId,
   pageMeta: {
-    bookmarkCount: number,
+    bookmarkCount?: number,
     elasticSearchResult?: {
       snippet: string,
       highlightedPath: string,
     },
   },
 }
+
+export const SORT_AXIS = {
+  RELATION_SCORE: 'relationScore',
+  CREATED_AT: 'createdAt',
+  UPDATED_AT: 'updatedAt',
+} as const;
+export type SORT_AXIS = typeof SORT_AXIS[keyof typeof SORT_AXIS];
+
+export const SORT_ORDER = {
+  DESC: 'desc',
+  ASC: 'asc',
+} as const;
+export type SORT_ORDER = typeof SORT_ORDER[keyof typeof SORT_ORDER];

+ 31 - 0
packages/app/src/server/middlewares/inject-user-ui-settings-to-localvars.ts

@@ -0,0 +1,31 @@
+import { IUserUISettings } from '~/interfaces/user-ui-settings';
+import loggerFactory from '~/utils/logger';
+
+import UserUISettings from '../models/user-ui-settings';
+
+const logger = loggerFactory('growi:middleware:inject-user-ui-settings-to-localvars');
+
+async function getSettings(userId: string): Promise<Partial<IUserUISettings> | null> {
+  const doc = await UserUISettings.findOne({ user: userId }).exec();
+
+  let partialDoc: Partial<IUserUISettings> | null = null;
+  if (doc != null) {
+    partialDoc = doc.toObject();
+    delete partialDoc.user;
+  }
+
+  return partialDoc;
+}
+
+module.exports = () => {
+  return async(req, res, next) => {
+    try {
+      res.locals.userUISettings = await getSettings(req.user._id);
+    }
+    catch (err: unknown) {
+      logger.error(err);
+    }
+
+    next();
+  };
+};

+ 9 - 2
packages/app/src/server/models/obsolete-page.js

@@ -234,7 +234,14 @@ export class PageQueryBuilder {
 
   addConditionAsMigrated() {
     this.query = this.query
-      .and({ parent: { $ne: null } });
+      .and(
+        {
+          $or: [
+            { parent: { $ne: null } },
+            { path: '/' },
+          ],
+        },
+      );
 
     return this;
   }
@@ -249,7 +256,7 @@ export class PageQueryBuilder {
   }
 
   addConditionToMinimizeDataForRendering() {
-    this.query = this.query.select('_id path isEmpty grant');
+    this.query = this.query.select('_id path isEmpty grant revision');
 
     return this;
   }

+ 19 - 7
packages/app/src/server/models/page.ts

@@ -41,7 +41,7 @@ export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete methods
   createEmptyPagesByPaths(paths: string[]): Promise<void>
   getParentIdAndFillAncestors(path: string): Promise<string | null>
-  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?): Promise<PageDocument[]>
+  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean): Promise<PageDocument[]>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
   findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>
@@ -227,7 +227,7 @@ const addViewerCondition = async(queryBuilder: PageQueryBuilder, user, userGroup
     relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
   }
 
-  queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, true);
+  queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, false);
 };
 
 /*
@@ -252,7 +252,7 @@ schema.statics.findByPathAndViewer = async function(
  * Find all ancestor pages by path. When duplicate pages found, it uses the oldest page as a result
  * The result will include the target as well
  */
-schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: string): Promise<TargetAndAncestorsResult> {
+schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: string, user, userGroups): Promise<TargetAndAncestorsResult> {
   let path;
   if (!hasSlash(pathOrId)) {
     const _id = pathOrId;
@@ -270,7 +270,10 @@ schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: strin
 
   // Do not populate
   const queryBuilder = new PageQueryBuilder(this.find());
+  await addViewerCondition(queryBuilder, user, userGroups);
+
   const _targetAndAncestors: PageDocument[] = await queryBuilder
+    .addConditionAsMigrated()
     .addConditionToListByPathsArray(ancestorPaths)
     .addConditionToMinimizeDataForRendering()
     .addConditionToSortAncestorPages()
@@ -307,13 +310,14 @@ schema.statics.findChildrenByParentPathOrIdAndViewer = async function(parentPath
 };
 
 schema.statics.findAncestorsChildrenByPathAndViewer = async function(path: string, user, userGroups = null): Promise<Record<string, PageDocument[]>> {
-  const ancestorPaths = isTopPage(path) ? ['/'] : collectAncestorPaths(path);
+  const ancestorPaths = isTopPage(path) ? ['/'] : collectAncestorPaths(path); // root path is necessary for rendering
   const regexps = ancestorPaths.map(path => new RegExp(generateChildrenRegExp(path))); // cannot use re2
 
   // get pages at once
   const queryBuilder = new PageQueryBuilder(this.find({ path: { $in: regexps } }));
   await addViewerCondition(queryBuilder, user, userGroups);
   const _pages = await queryBuilder
+    .addConditionAsMigrated()
     .addConditionToMinimizeDataForRendering()
     .query
     .lean()
@@ -326,10 +330,18 @@ schema.statics.findAncestorsChildrenByPathAndViewer = async function(path: strin
     return page;
   });
 
-  // make map
+  /*
+   * If any non-migrated page is found during creating the pathToChildren map, it will stop incrementing at that moment
+   */
   const pathToChildren: Record<string, PageDocument[]> = {};
-  ancestorPaths.forEach((path) => {
-    pathToChildren[path] = pages.filter(page => nodePath.dirname(page.path) === path);
+  const sortedPaths = ancestorPaths.sort((a, b) => a.length - b.length); // sort paths by path.length
+  sortedPaths.every((path) => {
+    const children = pages.filter(page => nodePath.dirname(page.path) === path);
+    if (children.length === 0) {
+      return false; // break when children do not exist
+    }
+    pathToChildren[path] = children;
+    return true;
   });
 
   return pathToChildren;

+ 2 - 2
packages/app/src/server/models/user-ui-settings.ts

@@ -12,7 +12,7 @@ export interface UserUISettingsDocument extends IUserUISettings, Document {}
 export type UserUISettingsModel = Model<UserUISettingsDocument>
 
 const schema = new Schema<UserUISettingsDocument, UserUISettingsModel>({
-  user: { type: Schema.Types.ObjectId, ref: 'User', index: true },
+  user: { type: Schema.Types.ObjectId, ref: 'User', unique: true },
   isSidebarCollapsed: { type: Boolean, default: false },
   currentSidebarContents: {
     type: String,
@@ -21,7 +21,7 @@ const schema = new Schema<UserUISettingsDocument, UserUISettingsModel>({
   },
   currentProductNavWidth: { type: Number },
   preferDrawerModeByUser: { type: Boolean, default: false },
-  preferDrawerModeOnEditByUser: { type: Boolean, default: false },
+  preferDrawerModeOnEditByUser: { type: Boolean, default: true },
 });
 
 

+ 14 - 0
packages/app/src/server/routes/apiv3/page-listing.ts

@@ -41,6 +41,20 @@ export default (crowi: Crowi): Router => {
   const router = express.Router();
 
 
+  router.get('/root', accessTokenParser, loginRequiredStrictly, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const Page: PageModel = crowi.model('Page');
+
+    let rootPage;
+    try {
+      rootPage = await Page.findByPathAndViewer('/', req.user, null, true);
+    }
+    catch (err) {
+      return res.apiv3Err(new ErrorV3('rootPage not found'));
+    }
+
+    return res.apiv3({ rootPage });
+  });
+
   // eslint-disable-next-line max-len
   router.get('/ancestors-children', accessTokenParser, loginRequiredStrictly, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
     const { path } = req.query;

+ 0 - 18
packages/app/src/server/routes/apiv3/user-ui-settings.ts

@@ -25,24 +25,6 @@ module.exports = (crowi) => {
     body('settings.preferDrawerModeOnEditByUser').optional().isBoolean(),
   ];
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  router.get('/', loginRequiredStrictly, async(req: any, res: any) => {
-    const { user } = req;
-
-    try {
-      const updatedSettings = await UserUISettings.findOneAndUpdate(
-        { user: user._id },
-        { user: user._id },
-        { upsert: true, new: true },
-      );
-      return res.apiv3(updatedSettings);
-    }
-    catch (err) {
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(err));
-    }
-  });
-
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   router.put('/', loginRequiredStrictly, csrf, validatorForPut, apiV3FormValidator, async(req: any, res: any) => {
     const { user } = req;

+ 16 - 15
packages/app/src/server/routes/index.js

@@ -28,6 +28,7 @@ module.exports = function(crowi, app) {
   const adminRequired = require('../middlewares/admin-required')(crowi);
   const certifySharedFile = require('../middlewares/certify-shared-file')(crowi);
   const csrf = require('../middlewares/csrf')(crowi);
+  const injectUserUISettings = require('../middlewares/inject-user-ui-settings-to-localvars')();
 
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
   const form = require('../form');
@@ -52,7 +53,7 @@ module.exports = function(crowi, app) {
   app.use('/api-docs', require('./apiv3/docs')(crowi));
   app.use('/_api/v3', require('./apiv3')(crowi));
 
-  app.get('/'                         , applicationInstalled, loginRequired , autoReconnectToSearch, page.showTopPage);
+  app.get('/'                         , applicationInstalled, loginRequired, autoReconnectToSearch, injectUserUISettings, page.showTopPage);
 
   app.get('/login/error/:reason'      , applicationInstalled, login.error);
   app.get('/login'                    , applicationInstalled, login.preLogin, login.login);
@@ -132,21 +133,21 @@ module.exports = function(crowi, app) {
   app.get('/admin/export'                       , loginRequiredStrictly , adminRequired ,admin.export.index);
   app.get('/admin/export/:fileName'             , loginRequiredStrictly , adminRequired ,admin.export.api.validators.export.download(), admin.export.download);
 
-  app.get('/admin/*'                       , loginRequiredStrictly ,adminRequired, admin.notFound.index);
+  app.get('/admin/*'                            , loginRequiredStrictly ,adminRequired, admin.notFound.index);
 
-  app.get('/me'                       , loginRequiredStrictly , me.index);
+  app.get('/me'                                 , loginRequiredStrictly, injectUserUISettings, me.index);
   // external-accounts
-  app.get('/me/external-accounts'                         , loginRequiredStrictly , me.externalAccounts.list);
+  app.get('/me/external-accounts'               , loginRequiredStrictly, injectUserUISettings, me.externalAccounts.list);
   // my drafts
-  app.get('/me/drafts'                , loginRequiredStrictly, me.drafts.list);
+  app.get('/me/drafts'                          , loginRequiredStrictly, injectUserUISettings, me.drafts.list);
 
   app.get('/attachment/:id([0-9a-z]{24})' , certifySharedFile , loginRequired, attachment.api.get);
   app.get('/attachment/profile/:id([0-9a-z]{24})' , loginRequired, attachment.api.get);
-  app.get('/attachment/:pageId/:fileName', loginRequired, attachment.api.obsoletedGetForMongoDB); // DEPRECATED: remains for backward compatibility for v3.3.x or below
-  app.get('/download/:id([0-9a-z]{24})'    , loginRequired, attachment.api.download);
+  app.get('/attachment/:pageId/:fileName'       , loginRequired, attachment.api.obsoletedGetForMongoDB); // DEPRECATED: remains for backward compatibility for v3.3.x or below
+  app.get('/download/:id([0-9a-z]{24})'         , loginRequired, attachment.api.download);
 
-  app.get('/_search'                 , loginRequired , search.searchPage);
-  app.get('/_api/search'             , accessTokenParser , loginRequired, search.api.search);
+  app.get('/_search'                            , loginRequired, injectUserUISettings, search.searchPage);
+  app.get('/_api/search'                        , accessTokenParser , loginRequired , search.api.search);
 
   app.get('/_api/check_username'           , user.api.checkUsername);
   app.get('/_api/me/user-group-relations'  , accessTokenParser , loginRequiredStrictly , me.api.userGroupRelations);
@@ -177,9 +178,9 @@ module.exports = function(crowi, app) {
   app.post('/_api/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , csrf, attachment.api.removeProfileImage);
   app.get('/_api/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachment.api.limit);
 
-  app.get('/trash$'                   , loginRequired , page.trashPageShowWrapper);
-  app.get('/trash/$'                  , loginRequired , page.trashPageListShowWrapper);
-  app.get('/trash/*/$'                , loginRequired , page.deletedPageListShowWrapper);
+  app.get('/trash$'                   , loginRequired, injectUserUISettings, page.trashPageShowWrapper);
+  app.get('/trash/$'                  , loginRequired, injectUserUISettings, page.trashPageListShowWrapper);
+  app.get('/trash/*/$'                , loginRequired, injectUserUISettings, page.deletedPageListShowWrapper);
 
   app.get('/_hackmd/load-agent'          , hackmd.loadAgent);
   app.get('/_hackmd/load-styles'         , hackmd.loadStyles);
@@ -197,9 +198,9 @@ module.exports = function(crowi, app) {
 
   app.get('/share/:linkId', page.showSharedPage);
 
-  app.get('/:id([0-9a-z]{24})'       , loginRequired , page.showPage);
+  app.get('/:id([0-9a-z]{24})'       , loginRequired , injectUserUISettings, page.showPage);
 
-  app.get('/*/$'                   , loginRequired , page.redirectorWithEndOfSlash);
-  app.get('/*'                     , loginRequired , autoReconnectToSearch, page.redirector);
+  app.get('/*/$'                   , loginRequired , injectUserUISettings, page.redirectorWithEndOfSlash);
+  app.get('/*'                     , loginRequired , autoReconnectToSearch, injectUserUISettings, page.redirector);
 
 };

+ 4 - 4
packages/app/src/server/routes/page.js

@@ -264,8 +264,8 @@ module.exports = function(crowi, app) {
     renderVars.pages = result.pages;
   }
 
-  async function addRenderVarsForPageTree(renderVars, path) {
-    const { targetAndAncestors, rootPage } = await Page.findTargetAndAncestorsByPathOrId(path);
+  async function addRenderVarsForPageTree(renderVars, path, user) {
+    const { targetAndAncestors, rootPage } = await Page.findTargetAndAncestorsByPathOrId(path, user);
 
     if (targetAndAncestors.length === 0 && !isTopPage(path)) {
       throw new Error('Ancestors must have at least one page.');
@@ -385,7 +385,7 @@ module.exports = function(crowi, app) {
 
     await addRenderVarsForDescendants(renderVars, portalPath, req.user, offset, limit);
 
-    await addRenderVarsForPageTree(renderVars, portalPath);
+    await addRenderVarsForPageTree(renderVars, portalPath, req.user);
 
     await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     return res.render(view, renderVars);
@@ -447,7 +447,7 @@ module.exports = function(crowi, app) {
       await addRenderVarsForUserPage(renderVars, page);
     }
 
-    await addRenderVarsForPageTree(renderVars, path);
+    await addRenderVarsForPageTree(renderVars, path, req.user);
 
     await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     return res.render(view, renderVars);

+ 6 - 2
packages/app/src/server/routes/search.js

@@ -112,7 +112,9 @@ module.exports = function(crowi, app) {
    */
   api.search = async function(req, res) {
     const user = req.user;
-    const { q: keyword = null, type = null } = req.query;
+    const {
+      q: keyword = null, type = null, sort = null, order = null,
+    } = req.query;
     let paginateOpts;
 
     try {
@@ -137,7 +139,9 @@ module.exports = function(crowi, app) {
       userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     }
 
-    const searchOpts = { ...paginateOpts, type };
+    const searchOpts = {
+      ...paginateOpts, type, sort, order,
+    };
 
     let searchResult;
     let delegatorName;

+ 42 - 27
packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -13,6 +13,7 @@ import {
   MetaData, SearchDelegator, Result, SearchableData, QueryTerms,
 } from '../../interfaces/search';
 import { SearchDelegatorName } from '~/interfaces/named-query';
+import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 
 const logger = loggerFactory('growi:service:search-delegator:elasticsearch');
 
@@ -20,6 +21,19 @@ const DEFAULT_OFFSET = 0;
 const DEFAULT_LIMIT = 50;
 const BULK_REINDEX_SIZE = 100;
 
+const { RELATION_SCORE, CREATED_AT, UPDATED_AT } = SORT_AXIS;
+const { DESC, ASC } = SORT_ORDER;
+
+const ES_SORT_AXIS = {
+  [RELATION_SCORE]: '_score',
+  [CREATED_AT]: 'created_at',
+  [UPDATED_AT]: 'updated_at',
+};
+const ES_SORT_ORDER = {
+  [DESC]: 'desc',
+  [ASC]: 'asc',
+};
+
 type Data = any;
 
 class ElasticsearchDelegator implements SearchDelegator<Data> {
@@ -608,29 +622,13 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     };
   }
 
-  createSearchQuerySortedByUpdatedAt(option) {
-    // getting path by default is almost for debug
-    let fields = ['path', 'bookmark_count', 'comment_count', 'seenUsers_count', 'updated_at', 'tag_names'];
-    if (option) {
-      fields = option.fields || fields;
-    }
-
-    // default is only id field, sorted by updated_at
-    const query = {
-      index: this.aliasName,
-      type: 'pages',
-      body: {
-        sort: [{ updated_at: { order: 'desc' } }],
-        query: {}, // query
-        _source: fields,
-      },
-    };
-    this.appendResultSize(query);
-
-    return query;
-  }
-
-  createSearchQuerySortedByScore(option?) {
+  /**
+   * create search query for Elasticsearch
+   *
+   * @param {object | undefined} option optional paramas
+   * @returns {object} query object
+   */
+  createSearchQuery(option?) {
     let fields = ['path', 'bookmark_count', 'comment_count', 'seenUsers_count', 'updated_at', 'tag_names', 'comments'];
     if (option) {
       fields = option.fields || fields;
@@ -641,12 +639,10 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
       index: this.aliasName,
       type: 'pages',
       body: {
-        sort: [{ _score: { order: 'desc' } }],
         query: {}, // query
         _source: fields,
       },
     };
-    this.appendResultSize(query);
 
     return query;
   }
@@ -656,8 +652,22 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     query.size = size || DEFAULT_LIMIT;
   }
 
+  appendSortOrder(query, sortAxis: SORT_AXIS, sortOrder: SORT_ORDER) {
+    // default sort order is score descending
+    const sort = ES_SORT_AXIS[sortAxis] || ES_SORT_AXIS[RELATION_SCORE];
+    const order = ES_SORT_ORDER[sortOrder] || ES_SORT_ORDER[DESC];
+    query.body.sort = { [sort]: { order } };
+  }
+
+  convertSortQuery(sortAxis) {
+    switch (sortAxis) {
+      case RELATION_SCORE:
+        return '_score';
+    }
+  }
+
   initializeBoolQuery(query) {
-    // query is created by createSearchQuerySortedByScore() or createSearchQuerySortedByUpdatedAt()
+    // query is created by createSearchQuery()
     if (!query.body.query.bool) {
       query.body.query.bool = {};
     }
@@ -889,14 +899,19 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
 
     const from = option.offset || null;
     const size = option.limit || null;
-    const query = this.createSearchQuerySortedByScore();
+    const sort = option.sort || null;
+    const order = option.order || null;
+    const query = this.createSearchQuery();
     this.appendCriteriaForQueryString(query, terms);
 
     await this.filterPagesByViewer(query, user, userGroups);
 
     this.appendResultSize(query, from, size);
 
+    this.appendSortOrder(query, sort, order);
+
     await this.appendFunctionScore(query, queryString);
+    this.appendHighlight(query);
 
     return this.searchKeyword(query);
   }

+ 35 - 58
packages/app/src/server/service/search.ts

@@ -13,7 +13,7 @@ import PrivateLegacyPagesDelegator from './search-delegator/private-legacy-pages
 import loggerFactory from '~/utils/logger';
 import { PageModel } from '../models/page';
 import { serializeUserSecurely } from '../models/serializers/user-serializer';
-import { IPageHasId } from '~/interfaces/page';
+import { IPageSearchResultData } from '~/interfaces/search';
 
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:service:search');
@@ -35,16 +35,7 @@ const normalizeQueryString = (_queryString: string): string => {
 };
 
 export type FormattedSearchResult = {
-  data: {
-    pageData: IPageHasId
-    pageMeta: {
-      bookmarkCount?: number
-      elasticsearchResult?: {
-        snippet: string
-        highlightedPath: string
-      }
-    }
-  }[]
+  data: IPageSearchResultData[]
 
   totalCount: number
 
@@ -383,68 +374,54 @@ class SearchService implements SearchQueryParser, SearchResolver {
     /*
      * Format ElasticSearch result
      */
-
     const Page = this.crowi.model('Page') as PageModel;
     const User = this.crowi.model('User');
     const result = {} as FormattedSearchResult;
 
-    // create score map for sorting
-    // key: id , value: score
-    const scoreMap = {};
-    for (const esPage of searchResult.data) {
-      scoreMap[esPage._id] = esPage._score;
-    }
+    // get page data
+    const pageIds = searchResult.data.map((page) => { return page._id });
+    const findPageResult = await Page.findListByPageIds(pageIds);
 
-    const ids = searchResult.data.map((page) => { return page._id });
-    const findResult = await Page.findListByPageIds(ids);
+    // set meta data
+    result.meta = searchResult.meta;
+    result.totalCount = findPageResult.totalCount;
 
-    // add tags data to page
-    findResult.pages.map((pageData) => {
-      const data = searchResult.data.find((data) => {
+    // set search result page data
+    result.data = searchResult.data.map((data) => {
+      const pageData = findPageResult.pages.find((pageData) => {
         return pageData.id === data._id;
       });
+
+      // add tags and seenUserCount to pageData
       pageData._doc.tags = data._source.tag_names;
-      return pageData;
-    });
+      pageData._doc.seenUserCount = (pageData.seenUsers && pageData.seenUsers.length) || 0;
 
-    result.meta = searchResult.meta;
-    result.totalCount = findResult.totalCount;
-    result.data = findResult.pages
-      .map((pageData) => {
-        if (pageData.lastUpdateUser != null && pageData.lastUpdateUser instanceof User) {
-          pageData.lastUpdateUser = serializeUserSecurely(pageData.lastUpdateUser);
-        }
+      // serialize lastUpdateUser
+      if (pageData.lastUpdateUser != null && pageData.lastUpdateUser instanceof User) {
+        pageData.lastUpdateUser = serializeUserSecurely(pageData.lastUpdateUser);
+      }
 
-        const data = searchResult.data.find((data) => {
-          return pageData.id === data._id;
-        });
-
-        // increment elasticSearchResult
-        let elasticSearchResult;
-        const highlightData = data._highlight;
-        if (highlightData != null) {
-          const snippet = highlightData['body.en'] || highlightData['body.ja'] || '';
-          const pathMatch = highlightData['path.en'] || highlightData['path.ja'] || '';
-
-          elasticSearchResult = {
-            snippet: filterXss.process(snippet),
-            highlightedPath: filterXss.process(pathMatch),
-          };
-        }
+      // increment elasticSearchResult
+      let elasticSearchResult;
+      const highlightData = data._highlight;
+      if (highlightData != null) {
+        const snippet = highlightData['body.en'] || highlightData['body.ja'] || '';
+        const pathMatch = highlightData['path.en'] || highlightData['path.ja'] || '';
 
-        const pageMeta = {
-          bookmarkCount: data._source.bookmark_count || 0,
-          elasticSearchResult,
+        elasticSearchResult = {
+          snippet: filterXss.process(snippet),
+          highlightedPath: filterXss.process(pathMatch),
         };
+      }
 
-        pageData._doc.seenUserCount = (pageData.seenUsers && pageData.seenUsers.length) || 0;
+      // generate pageMeta data
+      const pageMeta = {
+        bookmarkCount: data._source.bookmark_count || 0,
+        elasticSearchResult,
+      };
 
-        return { pageData, pageMeta };
-      })
-      .sort((page1, page2) => {
-        // note: this do not consider NaN
-        return scoreMap[page2.pageData._id] - scoreMap[page1.pageData._id];
-      });
+      return { pageData, pageMeta };
+    });
 
     return result;
   }

+ 1 - 10
packages/app/src/server/service/slack-command-handler/search.js

@@ -3,7 +3,7 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:service:SlackCommandHandler:search');
 
 const {
-  markdownSectionBlock, divider,
+  markdownSectionBlock, divider, generateLastUpdateMrkdwn,
 } = require('@growi/slack');
 const { formatDistanceStrict } = require('date-fns');
 
@@ -36,15 +36,6 @@ module.exports = (crowi) => {
     return `<${decodeURI(href)} | ${decodeURI(pathname)}>`;
   }
 
-  function generateLastUpdateMrkdwn(updatedAt, baseDate) {
-    if (updatedAt != null) {
-      // cast to date
-      const date = new Date(updatedAt);
-      return formatDistanceStrict(date, baseDate);
-    }
-    return '';
-  }
-
   async function retrieveSearchResults(growiCommandArgs, offset = 0) {
     const keywords = getKeywords(growiCommandArgs);
 

+ 5 - 7
packages/app/src/server/service/slack-event-handler/link-shared.ts

@@ -1,9 +1,8 @@
 import urljoin from 'url-join';
-import { format } from 'date-fns';
 import {
   MessageAttachment, LinkUnfurls, WebClient,
 } from '@slack/web-api';
-import { GrowiBotEvent } from '@growi/slack';
+import { GrowiBotEvent, generateLastUpdateMrkdwn } from '@growi/slack';
 import { SlackEventHandler } from './base-event-handler';
 import {
   DataForUnfurl, PublicData, UnfurlEventLink, UnfurlRequestEvent,
@@ -84,18 +83,17 @@ export class LinkSharedEventHandler implements SlackEventHandler<UnfurlRequestEv
 
   // builder method for unfurl parameter
   generateLinkUnfurls(body: PublicData, growiTargetUrl: string, toUrl: string): LinkUnfurls {
-    const { pageBody: text, updatedAt, commentCount } = body;
+    const { pageBody: text, updatedAt } = body;
 
+    const appTitle = this.crowi.appService.getAppTitle();
     const siteUrl = this.crowi.appService.getSiteUrl();
 
-    const updatedAtFormatted = format(updatedAt, 'yyyy-MM-dd HH:mm');
-    const footer = `URL: ${siteUrl}  Updated at: ${updatedAtFormatted}`;
-
     const attachment: MessageAttachment = {
       title: body.path,
       title_link: toUrl, // permalink
       text,
-      footer,
+      footer: `<${decodeURI(siteUrl)}|*${appTitle}*>`
+      + `  |  Last updated: \`${generateLastUpdateMrkdwn(updatedAt, new Date())}\``,
     };
 
     const unfurls: LinkUnfurls = {

+ 6 - 0
packages/app/src/server/views/layout/layout.html

@@ -120,6 +120,12 @@
   {{ user|json|safe|preventXss }}
   </script>
 {% endif %}
+{% if userUISettings != null %}
+  <script type="application/json" id="growi-user-ui-settings">
+  {{ userUISettings|json|safe }}
+  </script>
+{% endif %}
+
 
 {% block custom_script %}
 <script>

+ 4 - 4
packages/app/src/stores/context.tsx

@@ -19,13 +19,13 @@ export const useRevisionId = (initialData?: Nullable<any>): SWRResponse<Nullable
   return useStaticSWR<Nullable<any>, Error>('revisionId', initialData ?? null);
 };
 
-export const useCurrentPagePath = (initialData?: Nullable<string>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('currentPagePath', initialData ?? null);
+export const useCurrentPagePath = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
+  return useStaticSWR<Nullable<string>, Error>('currentPagePath', initialData ?? null);
 };
 
 
-export const usePageId = (initialData?: Nullable<string>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('pageId', initialData ?? null);
+export const useCurrentPageId = (initialData?: Nullable<string>): SWRResponse<Nullable<any>, Error> => {
+  return useStaticSWR<Nullable<any>, Error>('currentPageId', initialData ?? null);
 };
 
 export const useRevisionCreatedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {

+ 15 - 1
packages/app/src/stores/page-listing.tsx

@@ -1,9 +1,23 @@
 import useSWR, { SWRResponse } from 'swr';
 
 import { apiv3Get } from '../client/util/apiv3-client';
-import { AncestorsChildrenResult, ChildrenResult, V5MigrationStatus } from '../interfaces/page-listing-results';
+import {
+  AncestorsChildrenResult, ChildrenResult, V5MigrationStatus, RootPageResult,
+} from '../interfaces/page-listing-results';
 
 
+export const useSWRxRootPage = (): SWRResponse<RootPageResult, Error> => {
+  return useSWR(
+    '/page-listing/root',
+    endpoint => apiv3Get(endpoint).then((response) => {
+      return {
+        rootPage: response.data.rootPage,
+      };
+    }),
+    { revalidateOnFocus: false },
+  );
+};
+
 export const useSWRxPageAncestorsChildren = (
     path: string | null,
 ): SWRResponse<AncestorsChildrenResult, Error> => {

+ 6 - 7
packages/app/src/stores/page.tsx

@@ -1,16 +1,15 @@
 import useSWR, { SWRResponse } from 'swr';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
-import { HasObjectId } from '~/interfaces/has-object-id';
 
-import { IPage } from '~/interfaces/page';
+import { IPageHasId } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { apiGet } from '../client/util/apiv1-client';
 
 import { IPageTagsInfo } from '../interfaces/pageTagsInfo';
 import { IPageInfo } from '../interfaces/page-info';
 
-export const useSWRxPageByPath = (path: string, initialData?: IPage): SWRResponse<IPage & HasObjectId, Error> => {
+export const useSWRxPageByPath = (path: string, initialData?: IPageHasId): SWRResponse<IPageHasId, Error> => {
   return useSWR(
     ['/page', path],
     (endpoint, path) => apiv3Get(endpoint, { path }).then(result => result.data.page),
@@ -22,10 +21,10 @@ export const useSWRxPageByPath = (path: string, initialData?: IPage): SWRRespons
 
 
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
-export const useSWRxRecentlyUpdated = (): SWRResponse<(IPage & HasObjectId)[], Error> => {
+export const useSWRxRecentlyUpdated = (): SWRResponse<(IPageHasId)[], Error> => {
   return useSWR(
     '/pages/recent',
-    endpoint => apiv3Get<{ pages:(IPage & HasObjectId)[] }>(endpoint).then(response => response.data?.pages),
+    endpoint => apiv3Get<{ pages:(IPageHasId)[] }>(endpoint).then(response => response.data?.pages),
   );
 };
 
@@ -33,11 +32,11 @@ export const useSWRxRecentlyUpdated = (): SWRResponse<(IPage & HasObjectId)[], E
 export const useSWRxPageList = (
     path: string,
     pageNumber?: number,
-): SWRResponse<IPagingResult<IPage>, Error> => {
+): SWRResponse<IPagingResult<IPageHasId>, Error> => {
   const page = pageNumber || 1;
   return useSWR(
     `/pages/list?path=${path}&page=${page}`,
-    endpoint => apiv3Get<{pages: IPage[], totalCount: number, limit: number}>(endpoint).then((response) => {
+    endpoint => apiv3Get<{pages: IPageHasId[], totalCount: number, limit: number}>(endpoint).then((response) => {
       return {
         items: response.data.pages,
         totalCount: response.data.totalCount,

+ 15 - 69
packages/app/src/stores/ui.tsx

@@ -1,17 +1,14 @@
 import useSWR, {
-  useSWRConfig, SWRResponse, Key, Fetcher, Middleware,
+  useSWRConfig, SWRResponse, Key, Fetcher,
 } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { Breakpoint, addBreakpointListener } from '@growi/ui';
 
-import { apiv3Get } from '~/client/util/apiv3-client';
 import { SidebarContentsType } from '~/interfaces/ui';
 import loggerFactory from '~/utils/logger';
 
-import { sessionStorageMiddleware } from './middlewares/sync-to-storage';
 import { useStaticSWR } from './use-static-swr';
-import { IUserUISettings } from '~/interfaces/user-ui-settings';
 import { useCurrentPagePath, useIsEditable } from './context';
 
 const logger = loggerFactory('growi:stores:ui');
@@ -36,16 +33,6 @@ export type EditorMode = typeof EditorMode[keyof typeof EditorMode];
  *                      for switching UI
  *********************************************************** */
 
-export const useSWRxUserUISettings = (): SWRResponse<IUserUISettings, Error> => {
-  const key = isServer ? null : 'userUISettings';
-
-  return useSWRImmutable(
-    key,
-    () => apiv3Get<IUserUISettings>('/user-ui-settings').then(response => response.data),
-  );
-};
-
-
 export const useIsMobile = (): SWRResponse<boolean|null, Error> => {
   const key = isServer ? null : 'isMobile';
 
@@ -167,20 +154,24 @@ export const useIsDeviceSmallerThanMd = (): SWRResponse<boolean|null, Error> =>
   return useStaticSWR(key);
 };
 
-export const usePreferDrawerModeByUser = (isPrefered?: boolean): SWRResponse<boolean, Error> => {
-  const { data } = useSWRxUserUISettings();
-  const key: Key = data === undefined ? null : 'preferDrawerModeByUser';
-  const initialData = data?.preferDrawerModeByUser;
+export const usePreferDrawerModeByUser = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR('preferDrawerModeByUser', initialData ?? null, { fallbackData: false });
+};
+
+export const usePreferDrawerModeOnEditByUser = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR('preferDrawerModeOnEditByUser', initialData ?? null, { fallbackData: true });
+};
 
-  return useStaticSWR(key, isPrefered || null, { fallbackData: initialData, use: [sessionStorageMiddleware] });
+export const useSidebarCollapsed = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR('isSidebarCollapsed', initialData ?? null, { fallbackData: false });
 };
 
-export const usePreferDrawerModeOnEditByUser = (isPrefered?: boolean): SWRResponse<boolean, Error> => {
-  const { data } = useSWRxUserUISettings();
-  const key: Key = data === undefined ? null : 'preferDrawerModeOnEditByUser';
-  const initialData = data?.preferDrawerModeOnEditByUser;
+export const useCurrentSidebarContents = (initialData?: SidebarContentsType): SWRResponse<SidebarContentsType, Error> => {
+  return useStaticSWR('sidebarContents', initialData ?? null, { fallbackData: SidebarContentsType.RECENT });
+};
 
-  return useStaticSWR(key, isPrefered || null, { fallbackData: initialData, use: [sessionStorageMiddleware] });
+export const useCurrentProductNavWidth = (initialData?: number): SWRResponse<number, Error> => {
+  return useStaticSWR('productNavWidth', initialData ?? null, { fallbackData: 320 });
 };
 
 export const useDrawerMode = (): SWRResponse<boolean, Error> => {
@@ -215,51 +206,6 @@ export const useDrawerOpened = (isOpened?: boolean): SWRResponse<boolean, Error>
   return useStaticSWR('isDrawerOpened', isOpened || null, { fallbackData: initialData });
 };
 
-export const useSidebarCollapsed = (): SWRResponse<boolean, Error> => {
-  const { data } = useSWRxUserUISettings();
-  const key = data === undefined ? null : 'isSidebarCollapsed';
-  const initialData = data?.isSidebarCollapsed || false;
-
-  return useStaticSWR(
-    key,
-    null,
-    {
-      fallbackData: initialData,
-      use: [sessionStorageMiddleware],
-    },
-  );
-};
-
-export const useCurrentSidebarContents = (): SWRResponse<SidebarContentsType, Error> => {
-  const { data } = useSWRxUserUISettings();
-  const key = data === undefined ? null : 'sidebarContents';
-  const initialData = data?.currentSidebarContents || SidebarContentsType.RECENT;
-
-  return useStaticSWR(
-    key,
-    null,
-    {
-      fallbackData: initialData,
-      use: [sessionStorageMiddleware],
-    },
-  );
-};
-
-export const useCurrentProductNavWidth = (): SWRResponse<number, Error> => {
-  const { data } = useSWRxUserUISettings();
-  const key = data === undefined ? null : 'productNavWidth';
-  const initialData = data?.currentProductNavWidth || 320;
-
-  return useStaticSWR(
-    key,
-    null,
-    {
-      fallbackData: initialData,
-      use: [sessionStorageMiddleware],
-    },
-  );
-};
-
 export const useSidebarResizeDisabled = (isDisabled?: boolean): SWRResponse<boolean, Error> => {
   const initialData = false;
   return useStaticSWR('isSidebarResizeDisabled', isDisabled || null, { fallbackData: initialData });

+ 5 - 0
packages/app/src/styles/_layout.scss

@@ -31,6 +31,11 @@ body.growi-layout-fluid .grw-container-convertible {
   border-bottom: 1px solid transparent;
 }
 
+.grw-scrollable-modal-body {
+  max-height: calc(100vh - 330px);
+  overflow-y: scroll;
+}
+
 // padding settings for GrowiNavbarBottom
 .page-wrapper {
   padding-bottom: $grw-navbar-bottom-height;

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

@@ -174,9 +174,11 @@
   .search-result-list {
     position: sticky;
     top: 0px;
-    height: 100vh;
-    overflow-y: scroll;
 
+    .search-result-list-scroll {
+      height: calc(100vh - 125px); // subtract the height of SearchControl component
+      overflow-y: scroll;
+    }
     .nav.nav-pills {
       > .page-list-li {
         &.active {

+ 1 - 17
packages/app/src/styles/_sidebar.scss

@@ -267,7 +267,7 @@
     top: 0;
     width: 0;
   }
-  div.navigation {
+  div.navigation.transition-enabled {
     max-width: 80vw;
 
     // apply transition
@@ -329,22 +329,6 @@
   }
 }
 
-// supress transition
-.grw-sidebar {
-  &.grw-sidebar-supress-transitions-to-drawer {
-    div.navigation {
-      transition: none !important;
-    }
-  }
-
-  &.grw-sidebar-supress-transitions-to-dock {
-    div.content,
-    div.contextual-navigation {
-      transition: none !important;
-    }
-  }
-}
-
 .grw-sidebar-backdrop.modal-backdrop {
   z-index: $zindex-fixed + 1;
 }

+ 5 - 0
packages/app/src/styles/_subnav.scss

@@ -45,6 +45,11 @@
     border-radius: $border-radius-xl;
   }
 
+  .btn-bookmark {
+    display: flex;
+    align-items: center;
+  }
+
   .total-likes,
   .total-bookmarks {
     font-size: 17px;

+ 1 - 1
packages/codemirror-textlint/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/codemirror-textlint",
-  "version": "4.5.1-RC.0",
+  "version": "4.5.3-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "scripts": {

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "4.5.1-RC.0",
+  "version": "4.5.3-RC.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-attachment-refs",
-  "version": "4.5.1-RC.0",
+  "version": "4.5.3-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-lsx/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-lsx",
-  "version": "4.5.1-RC.0",
+  "version": "4.5.3-RC.0",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-pukiwiki-like-linker/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-pukiwiki-like-linker",
-  "version": "4.5.1-RC.0",
+  "version": "4.5.3-RC.0",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "4.5.1-RC.0",
+  "version": "4.5.3-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",

+ 1 - 0
packages/slack/src/index.ts

@@ -44,6 +44,7 @@ export * from './middlewares/verify-growi-to-slack-request';
 export * from './middlewares/verify-slack-request';
 export * from './utils/block-kit-builder';
 export * from './utils/check-communicable';
+export * from './utils/generate-last-update-markdown';
 export * from './utils/get-supported-growi-actions-regexps';
 export * from './utils/post-ephemeral-errors';
 export * from './utils/publish-initial-home-view';

+ 10 - 0
packages/slack/src/utils/generate-last-update-markdown.ts

@@ -0,0 +1,10 @@
+import { formatDistanceStrict } from 'date-fns';
+
+export function generateLastUpdateMrkdwn(updatedAt: string | Date | number, baseDate: Date): string {
+  if (updatedAt != null) {
+    // cast to date
+    const date = new Date(updatedAt);
+    return formatDistanceStrict(date, baseDate);
+  }
+  return '';
+}

+ 2 - 0
packages/slack/src/utils/required-scopes.ts

@@ -8,4 +8,6 @@ export const requiredScopes: string[] = [
   'groups:history',
   'im:history',
   'mpim:history',
+  'links:read',
+  'links:write',
 ];

+ 2 - 2
packages/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "4.5.1-slackbot-proxy.0",
+  "version": "4.5.3-slackbot-proxy.1",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -25,7 +25,7 @@
   },
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^4.5.1-RC.0",
+    "@growi/slack": "^4.5.3-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/ui",
-  "version": "4.5.1-RC.0",
+  "version": "4.5.3-RC.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": [