Просмотр исходного кода

Merge branch 'dev/5.0.x' into feat/80324-adjust-design-for-left-pane

* dev/5.0.x: (70 commits)
  refs #83399: fix display snnipet
  resolve merge conflict
  Improved sorting ancestorsChildren
  Added a comment
  Renamed
  Removed unnecessary code
  Throw instead
  Added delete modal
  Added revision attr as minimized data for rendering pagetree
  83073 divider in dropdown buttons
  improved rendering clamp for highlight
  Added appendHighlight
  Removed unnecessary param
  Renamed usePageId -> useCurrentPageId, Removed unnecessary condition
  refs #82628: fix search keyword result sorting - Fix lint error
  refs #82338: Display and fix design sort dropdown - fix dropdown view
  Imprv/inject user UI settings (#4830)
  refs #82628: fix search keyword result sorting - Add translation
  refs #82628: fix search keyword result sorting - Sepalate sort control component from search control
  refs #82628: fix search keyword result sorting - Additional changes for resolve conflict
  ...
Mao 4 лет назад
Родитель
Сommit
d7895e0817
61 измененных файлов с 748 добавлено и 525 удалено
  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);
@@ -84,6 +86,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 整理する
@@ -152,11 +161,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) {
@@ -288,6 +300,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}
@@ -298,6 +312,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": [