Przeglądaj źródła

Merge remote-tracking branch 'origin/master' into feat/10814-remark-drawio-plugin

Yuki Takei 3 lat temu
rodzic
commit
56cb4e4369
100 zmienionych plików z 1550 dodań i 1576 usunięć
  1. 1 1
      lerna.json
  2. 1 1
      package.json
  3. 8 8
      packages/app/package.json
  4. 8 5
      packages/app/public/static/locales/en_US/admin.json
  5. 37 2
      packages/app/public/static/locales/en_US/commons.json
  6. 0 19
      packages/app/public/static/locales/en_US/translation.json
  7. 7 4
      packages/app/public/static/locales/ja_JP/admin.json
  8. 37 2
      packages/app/public/static/locales/ja_JP/commons.json
  9. 1 18
      packages/app/public/static/locales/ja_JP/translation.json
  10. 7 4
      packages/app/public/static/locales/zh_CN/admin.json
  11. 37 2
      packages/app/public/static/locales/zh_CN/commons.json
  12. 0 17
      packages/app/public/static/locales/zh_CN/translation.json
  13. 2 2
      packages/app/src/components/Admin/App/AppSetting.jsx
  14. 1 1
      packages/app/src/components/Admin/App/AppSettingsPageContents.tsx
  15. 6 6
      packages/app/src/components/Admin/App/SiteUrlSetting.tsx
  16. 2 20
      packages/app/src/components/Admin/MarkdownSetting/XssForm.jsx
  17. 3 3
      packages/app/src/components/Admin/Notification/NotificationSetting.jsx
  18. 1 1
      packages/app/src/components/Admin/Notification/UserTriggerNotification.jsx
  19. 1 1
      packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  20. 13 11
      packages/app/src/components/Admin/Security/ShareLinkSetting.tsx
  21. 1 1
      packages/app/src/components/Admin/UserManagement.tsx
  22. 2 1
      packages/app/src/components/Admin/Users/GiveAdminButton.tsx
  23. 2 1
      packages/app/src/components/Admin/Users/RemoveAdminMenuItem.tsx
  24. 2 1
      packages/app/src/components/Admin/Users/StatusSuspendMenuItem.tsx
  25. 0 132
      packages/app/src/components/Admin/Users/UserMenu.jsx
  26. 5 0
      packages/app/src/components/Admin/Users/UserMenu.module.scss
  27. 114 0
      packages/app/src/components/Admin/Users/UserMenu.tsx
  28. 6 5
      packages/app/src/components/Comments.tsx
  29. 1 1
      packages/app/src/components/Common/ImageCropModal.tsx
  30. 1 1
      packages/app/src/components/InAppNotification/InAppNotificationDropdown.tsx
  31. 1 1
      packages/app/src/components/InAppNotification/InAppNotificationElm.tsx
  32. 5 3
      packages/app/src/components/InAppNotification/InAppNotificationPage.tsx
  33. 1 1
      packages/app/src/components/Navbar/AppearanceModeDropdown.tsx
  34. 1 1
      packages/app/src/components/Navbar/AuthorInfo.module.scss
  35. 15 5
      packages/app/src/components/Navbar/GlobalSearch.tsx
  36. 9 13
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  37. 1 1
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  38. 2 2
      packages/app/src/components/Navbar/GrowiSubNavigation.module.scss
  39. 3 3
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  40. 1 1
      packages/app/src/components/Navbar/PageEditorModeManager.jsx
  41. 1 1
      packages/app/src/components/Navbar/PageEditorModeManager.module.scss
  42. 3 3
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  43. 0 68
      packages/app/src/components/Page.tsx
  44. 1 1
      packages/app/src/components/Page/TagLabels.module.scss
  45. 4 4
      packages/app/src/components/Page/TagLabels.tsx
  46. 2 2
      packages/app/src/components/PageAccessoriesModal.tsx
  47. 6 3
      packages/app/src/components/PageAlert/OldRevisionAlert.tsx
  48. 1 1
      packages/app/src/components/PageComment.module.scss
  49. 10 11
      packages/app/src/components/PageComment.tsx
  50. 1 1
      packages/app/src/components/PageComment/Comment.module.scss
  51. 21 13
      packages/app/src/components/PageComment/Comment.tsx
  52. 3 3
      packages/app/src/components/PageComment/CommentEditor.tsx
  53. 5 1
      packages/app/src/components/PageComment/ReplyComments.tsx
  54. 0 58
      packages/app/src/components/PageCommentSkelton.tsx
  55. 1 1
      packages/app/src/components/PageContentFooter.module.scss
  56. 1 5
      packages/app/src/components/PageContentFooter.tsx
  57. 17 13
      packages/app/src/components/PageCreateModal.jsx
  58. 34 13
      packages/app/src/components/PageEditor.tsx
  59. 21 16
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  60. 1 1
      packages/app/src/components/PageEditor/Editor.tsx
  61. 0 537
      packages/app/src/components/PageEditor/HandsontableModal.jsx
  62. 27 5
      packages/app/src/components/PageEditor/HandsontableModal.module.scss
  63. 506 0
      packages/app/src/components/PageEditor/HandsontableModal.tsx
  64. 0 103
      packages/app/src/components/PageEditor/MarkdownTableDataImportForm.jsx
  65. 98 0
      packages/app/src/components/PageEditor/MarkdownTableDataImportForm.tsx
  66. 34 21
      packages/app/src/components/PageEditorByHackmd.tsx
  67. 14 4
      packages/app/src/components/PageHistory.tsx
  68. 11 4
      packages/app/src/components/PageHistory/PageRevisionTable.tsx
  69. 17 6
      packages/app/src/components/PageHistory/Revision.tsx
  70. 21 9
      packages/app/src/components/PageHistory/RevisionDiff.tsx
  71. 4 2
      packages/app/src/components/PageList/PageListItemL.tsx
  72. 3 0
      packages/app/src/components/PutbackPageModal.jsx
  73. 6 4
      packages/app/src/components/RevisionComparer/RevisionComparer.tsx
  74. 6 11
      packages/app/src/components/SavePageControls.tsx
  75. 1 0
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  76. 7 6
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  77. 4 2
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  78. 3 3
      packages/app/src/components/Skeleton.tsx
  79. 2 2
      packages/app/src/components/UnsavedAlertDialog.tsx
  80. 2 0
      packages/app/src/interfaces/page-operation.ts
  81. 21 4
      packages/app/src/pages/[[...path]].page.tsx
  82. 0 1
      packages/app/src/pages/admin/[...path].page.tsx
  83. 0 1
      packages/app/src/pages/admin/security.page.tsx
  84. 1 1
      packages/app/src/pages/login.page.tsx
  85. 2 2
      packages/app/src/pages/me/[[...path]].page.tsx
  86. 20 0
      packages/app/src/pages/tags.page.tsx
  87. 0 1
      packages/app/src/pages/utils/commons.ts
  88. 18 1
      packages/app/src/server/models/interfaces/page-operation.ts
  89. 14 117
      packages/app/src/server/models/obsolete-page.js
  90. 7 0
      packages/app/src/server/models/page-operation.ts
  91. 5 165
      packages/app/src/server/models/page.ts
  92. 9 0
      packages/app/src/server/models/user-group-relation.js
  93. 1 0
      packages/app/src/server/models/user-group.ts
  94. 5 3
      packages/app/src/server/routes/apiv3/page.js
  95. 8 8
      packages/app/src/server/routes/apiv3/pages.js
  96. 3 13
      packages/app/src/server/routes/page.js
  97. 1 1
      packages/app/src/server/routes/tag.js
  98. 1 1
      packages/app/src/server/service/installer.ts
  99. 187 16
      packages/app/src/server/service/page-grant.ts
  100. 2 1
      packages/app/src/server/service/page-operation.ts

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
 {
   "npmClient": "yarn",
   "npmClient": "yarn",
   "useWorkspaces": true,
   "useWorkspaces": true,
-  "version": "6.0.0-RC.8",
+  "version": "6.0.0-RC.9",
   "packages": [
   "packages": [
     "packages/*"
     "packages/*"
   ]
   ]

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "6.0.0-RC.8",
+  "version": "6.0.0-RC.9",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",

+ 8 - 8
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "6.0.0-RC.8",
+  "version": "6.0.0-RC.9",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -65,12 +65,12 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^6.0.0-RC.8",
-    "@growi/core": "^6.0.0-RC.8",
-    "@growi/hackmd": "^6.0.0-RC.8",
-    "@growi/plugin-attachment-refs": "^6.0.0-RC.8",
-    "@growi/plugin-lsx": "^6.0.0-RC.8",
-    "@growi/slack": "^6.0.0-RC.8",
+    "@growi/codemirror-textlint": "^6.0.0-RC.9",
+    "@growi/core": "^6.0.0-RC.9",
+    "@growi/hackmd": "^6.0.0-RC.9",
+    "@growi/plugin-attachment-refs": "^6.0.0-RC.9",
+    "@growi/plugin-lsx": "^6.0.0-RC.9",
+    "@growi/slack": "^6.0.0-RC.9",
     "@promster/express": "^7.0.2",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
     "@slack/events-api": "^3.0.0",
@@ -205,7 +205,7 @@
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "@growi/ui": "^6.0.0-RC.8",
+    "@growi/ui": "^6.0.0-RC.9",
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^12.2.3",
     "@next/bundle-analyzer": "^12.2.3",

+ 8 - 5
packages/app/public/static/locales/en_US/admin.json

@@ -329,9 +329,12 @@
     "site_name": "Site name",
     "site_name": "Site name",
     "sitename_change": "You can change site name which is used for header and HTML title.",
     "sitename_change": "You can change site name which is used for header and HTML title.",
     "header_content": "The contents entered here will be shown in the header etc.",
     "header_content": "The contents entered here will be shown in the header etc.",
-    "site_url_desc": "This is for the site URL setting.",
-    "site_url_warn": "Some features don't work because the site URL is not set.",
-    "siteurl_help": "Site full URL beginning from <code>http://</code> or <code>https://</code>.",
+    "site_url": {
+      "title": "Site URL settings",
+      "desc": "This is for the site URL setting.",
+      "warn": "Some features don't work because the site URL is not set.",
+      "help": "Site full URL beginning from <code>http://</code> or <code>https://</code>."
+    },
     "confidential_name": "Confidential name",
     "confidential_name": "Confidential name",
     "confidential_example": "ex): internal use only",
     "confidential_example": "ex): internal use only",
     "default_language": "Default language for new users",
     "default_language": "Default language for new users",
@@ -377,7 +380,7 @@
     "custom_endpoint_change": "Input the URL of the endpoint of an object storage service like MinIO that has a S3-compatible API.  Amazon S3 is used if empty.",
     "custom_endpoint_change": "Input the URL of the endpoint of an object storage service like MinIO that has a S3-compatible API.  Amazon S3 is used if empty.",
     "plugin_settings": "Plugin settings",
     "plugin_settings": "Plugin settings",
     "enable_plugin_loading": "Enable plugin loading",
     "enable_plugin_loading": "Enable plugin loading",
-    "load_plugins": "Load_plugins",
+    "load_plugins": "Load plugins",
     "enable": "Enable",
     "enable": "Enable",
     "disable": "Disable",
     "disable": "Disable",
     "use_env_var_if_empty": "If the value in the database is empty, the value of the environment variable <code>{{variable}}</code> is used.",
     "use_env_var_if_empty": "If the value in the database is empty, the value of the environment variable <code>{{variable}}</code> is used.",
@@ -577,7 +580,7 @@
     "export": "Export",
     "export": "Export",
     "cancel": "Cancel",
     "cancel": "Cancel",
     "file": "File",
     "file": "File",
-    "growi_version": "Growi Version",
+    "growi_version": "GROWI Version",
     "collections": "Collections",
     "collections": "Collections",
     "exported_at": "Exported At",
     "exported_at": "Exported At",
     "export_menu": "Export Menu",
     "export_menu": "Export Menu",

+ 37 - 2
packages/app/public/static/locales/en_US/commons.json

@@ -1,4 +1,11 @@
 {
 {
+  "Show": "Show",
+  "Hide": "Hide",
+  "Add": "Add",
+  "Reset": "Reset",
+  "Sign out": "Logout",
+  "New": "New",
+
   "meta": {
   "meta": {
     "display_name": "English"
     "display_name": "English"
   },
   },
@@ -37,9 +44,22 @@
     "description": "Description"
     "description": "Description"
   },
   },
 
 
+  "in_app_notification": {
+    "notification_list": "In-App Notification List",
+    "see_all": "See All",
+    "no_notification": "You don't have any notificatios.",
+    "all": "All",
+    "unopend": "Unread",
+    "mark_all_as_read": "Mark all as read"
+  },
+
   "personal_dropdown": {
   "personal_dropdown": {
     "home": "Home",
     "home": "Home",
-    "settings": "Settings"
+    "settings": "Settings",
+    "color_mode": "Color mode",
+    "sidebar_mode": "Sidebar mode",
+    "sidebar_mode_editor": "Sidebar mode on editor",
+    "use_os_settings": "Use OS settings"
   },
   },
 
 
   "copy_to_clipboard": {
   "copy_to_clipboard": {
@@ -55,10 +75,25 @@
     "image_crop": "Image Crop",
     "image_crop": "Image Crop",
     "crop": "Crop",
     "crop": "Crop",
     "save": "Save",
     "save": "Save",
-    "reset": "Reset",
     "cancel": "Cancel"
     "cancel": "Cancel"
   },
   },
 
 
+  "handsontable_modal": {
+    "title": "Edit Table",
+    "data_import": "Data Import",
+    "save": "Save",
+    "cancel": "Cancel",
+    "done": "Done",
+    "data_import_form": {
+      "select_data_format": "Select Data Format",
+      "import_data": "Import Data",
+      "paste_table_data": "Paste table data",
+      "parse_error": "Parse Error",
+      "cancel": "Cancel",
+      "import": "Import"
+    }
+  },
+
   "not_found_page": {
   "not_found_page": {
     "page_not_exist": "This page does not exist."
     "page_not_exist": "This page does not exist."
   }
   }

+ 0 - 19
packages/app/public/static/locales/en_US/translation.json

@@ -28,7 +28,6 @@
   "administrator": "Admin",
   "administrator": "Admin",
   "Tag": "Tag",
   "Tag": "Tag",
   "Tags": "Tags",
   "Tags": "Tags",
-  "New": "New",
   "Close": "Close",
   "Close": "Close",
   "Shortcuts": "Shortcuts",
   "Shortcuts": "Shortcuts",
   "CustomSidebar": "Custom Sidebar",
   "CustomSidebar": "Custom Sidebar",
@@ -100,8 +99,6 @@
   "Updated": "Updated",
   "Updated": "Updated",
   "Upload new image": "Upload new image",
   "Upload new image": "Upload new image",
   "Connected": "Connected",
   "Connected": "Connected",
-  "Show": "Show",
-  "Hide": "Hide",
   "Loading": "Loading...",
   "Loading": "Loading...",
   "Disclose E-mail": "Disclose E-mail",
   "Disclose E-mail": "Disclose E-mail",
   "page exists": "this page already exists",
   "page exists": "this page already exists",
@@ -115,7 +112,6 @@
   "V5 Page Migration": "Convert To V5 Compatibility",
   "V5 Page Migration": "Convert To V5 Compatibility",
   "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
   "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
   "See_more_detail_on_new_schema": "See more detail on <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'>{{title}}</a> <i class='icon-share-alt'></i> ",
   "See_more_detail_on_new_schema": "See more detail on <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'>{{title}}</a> <i class='icon-share-alt'></i> ",
-  "Site URL settings": "Site URL settings",
   "external_account_management": "External Account Management",
   "external_account_management": "External Account Management",
   "UserGroup": "UserGroup",
   "UserGroup": "UserGroup",
   "Basic Settings": "Basic Settings",
   "Basic Settings": "Basic Settings",
@@ -141,7 +137,6 @@
   "edited this page": "edited this page.",
   "edited this page": "edited this page.",
   "List Drafts": "Drafts",
   "List Drafts": "Drafts",
   "Deleted Pages": "Deleted Pages",
   "Deleted Pages": "Deleted Pages",
-  "Sign out": "Logout",
   "Disassociate": "Disassociate",
   "Disassociate": "Disassociate",
   "No bookmarks yet": "No bookmarks yet",
   "No bookmarks yet": "No bookmarks yet",
   "add_bookmark": "Add to Bookmarks",
   "add_bookmark": "Add to Bookmarks",
@@ -158,12 +153,6 @@
   "not_allowed_to_see_this_page": "You cannot see this page",
   "not_allowed_to_see_this_page": "You cannot see this page",
   "Confirm": "Confirm",
   "Confirm": "Confirm",
   "Successfully requested": "Successfully requested.",
   "Successfully requested": "Successfully requested.",
-  "personal_dropdown": {
-    "color_mode": "Color mode",
-    "sidebar_mode": "Sidebar mode",
-    "sidebar_mode_editor": "Sidebar mode on editor",
-    "use_os_settings": "Use OS settings"
-  },
   "form_validation": {
   "form_validation": {
     "error_message": "Some values ​​are incorrect",
     "error_message": "Some values ​​are incorrect",
     "required": "%s is required",
     "required": "%s is required",
@@ -246,14 +235,6 @@
   "API Token Settings": "API token settings",
   "API Token Settings": "API token settings",
   "Current API Token": "Current API token",
   "Current API Token": "Current API token",
   "Update API Token": "Update API token",
   "Update API Token": "Update API token",
-  "in_app_notification": {
-    "notification_list": "In-App Notification List",
-    "see_all": "See All",
-    "no_notification": "You don't have any notificatios.",
-    "all": "All",
-    "unopend": "Unread",
-    "mark_all_as_read": "Mark all as read"
-  },
   "in_app_notification_settings": {
   "in_app_notification_settings": {
     "in_app_notification_settings": "In-App Notification Settings",
     "in_app_notification_settings": "In-App Notification Settings",
     "subscribe_settings": "Settings to automatically subscribe (Receive notifications) to pages",
     "subscribe_settings": "Settings to automatically subscribe (Receive notifications) to pages",

+ 7 - 4
packages/app/public/static/locales/ja_JP/admin.json

@@ -355,9 +355,12 @@
     "site_name": "サイト名",
     "site_name": "サイト名",
     "sitename_change": "ヘッダーや HTML タイトルに使用されるサイト名を変更できます。",
     "sitename_change": "ヘッダーや HTML タイトルに使用されるサイト名を変更できます。",
     "header_content": "ここに入力した内容は、ヘッダー等に表示されます。",
     "header_content": "ここに入力した内容は、ヘッダー等に表示されます。",
-    "site_url_desc": "サイトURLを設定します。",
-    "site_url_warn": "サイトURLが設定されていないため、一部機能が動作しない状態になっています。",
-    "siteurl_help": "<code>http://</code> または <code>https://</code> から始まるサイトのURL",
+    "site_url": {
+      "title": "サイトURL設定",
+      "desc": "サイトURLを設定します。",
+      "warn": "サイトURLが設定されていないため、一部機能が動作しない状態になっています。",
+      "help": "<code>http://</code> または <code>https://</code> から始まるサイトのURL"
+    },
     "confidential_name": "コンフィデンシャル表示",
     "confidential_name": "コンフィデンシャル表示",
     "confidential_example": "例: 社外秘",
     "confidential_example": "例: 社外秘",
     "default_language": "新規ユーザーのデフォルト設定言語",
     "default_language": "新規ユーザーのデフォルト設定言語",
@@ -531,7 +534,7 @@
     "export": "エクスポート",
     "export": "エクスポート",
     "cancel": "キャンセル",
     "cancel": "キャンセル",
     "file": "ファイル名",
     "file": "ファイル名",
-    "growi_version": "Growi バージョン",
+    "growi_version": "GROWI バージョン",
     "collections": "コレクション",
     "collections": "コレクション",
     "exported_at": "エクスポートされた時間",
     "exported_at": "エクスポートされた時間",
     "export_menu": "エクスポートメニュー",
     "export_menu": "エクスポートメニュー",

+ 37 - 2
packages/app/public/static/locales/ja_JP/commons.json

@@ -1,4 +1,11 @@
 {
 {
+  "Show": "公開",
+  "Hide": "非公開",
+  "Add": "追加",
+  "Reset": "リセット",
+  "Sign out": "ログアウト",
+  "New": "作成",
+
   "meta": {
   "meta": {
     "display_name": "日本語"
     "display_name": "日本語"
   },
   },
@@ -37,9 +44,22 @@
     "description": "概要"
     "description": "概要"
   },
   },
 
 
+  "in_app_notification": {
+    "notification_list": "アプリ内通知一覧",
+    "see_all": "通知一覧を見る",
+    "no_notification": "通知はありません",
+    "all": "全て",
+    "unopend": "未読",
+    "mark_all_as_read": "全て既読にする"
+  },
+
   "personal_dropdown": {
   "personal_dropdown": {
     "home": "ホーム",
     "home": "ホーム",
-    "settings": "設定"
+    "settings": "設定",
+    "color_mode": "カラーモード",
+    "sidebar_mode": "サイドバーモード",
+    "sidebar_mode_editor": "サイドバーモード(編集時)",
+    "use_os_settings": "OS設定を利用する"
   },
   },
 
 
   "copy_to_clipboard": {
   "copy_to_clipboard": {
@@ -55,10 +75,25 @@
     "image_crop": "画像の切り抜き",
     "image_crop": "画像の切り抜き",
     "crop": "トリミング",
     "crop": "トリミング",
     "save": "保存",
     "save": "保存",
-    "reset": "リセット",
     "cancel": "キャンセル"
     "cancel": "キャンセル"
   },
   },
 
 
+  "handsontable_modal": {
+    "title": "テーブル編集",
+    "data_import": "データインポート",
+    "save": "保存",
+    "cancel": "キャンセル",
+    "done": "完了",
+    "data_import_form": {
+      "select_data_format": "データフォーマット",
+      "import_data": "インポートデータ",
+      "paste_table_data": "テーブルデータを貼り付け",
+      "parse_error": "パーザーエラー",
+      "cancel": "キャンセル",
+      "import": "インポート"
+    }
+  },
+
   "not_found_page": {
   "not_found_page": {
     "page_not_exist": "このページは存在しません。"
     "page_not_exist": "このページは存在しません。"
   }
   }

+ 1 - 18
packages/app/public/static/locales/ja_JP/translation.json

@@ -28,7 +28,6 @@
   "administrator": "管理者",
   "administrator": "管理者",
   "Tag": "タグ",
   "Tag": "タグ",
   "Tags": "タグ",
   "Tags": "タグ",
-  "New": "作成",
   "Close": "閉じる",
   "Close": "閉じる",
   "Shortcuts": "ショートカット",
   "Shortcuts": "ショートカット",
   "CustomSidebar": "カスタムサイドバー",
   "CustomSidebar": "カスタムサイドバー",
@@ -110,7 +109,6 @@
   "V5 Page Migration": "V5 互換形式 への変換",
   "V5 Page Migration": "V5 互換形式 への変換",
   "GROWI.5.0_new_schema": "GROWI.5.0における新スキーマについて",
   "GROWI.5.0_new_schema": "GROWI.5.0における新スキーマについて",
   "See_more_detail_on_new_schema": "詳しくは<a href='https://docs.growi.org/ja/admin-guide/upgrading/50x.html#新しい-v5-互換形式について' target='_blank'>{{title}}</a><i class='icon-share-alt'></i>を参照ください。",
   "See_more_detail_on_new_schema": "詳しくは<a href='https://docs.growi.org/ja/admin-guide/upgrading/50x.html#新しい-v5-互換形式について' target='_blank'>{{title}}</a><i class='icon-share-alt'></i>を参照ください。",
-  "Site URL settings": "サイトURL設定",
   "external_account_management": "外部アカウント管理",
   "external_account_management": "外部アカウント管理",
   "UserGroup": "グループ",
   "UserGroup": "グループ",
   "Basic Settings": "基本設定",
   "Basic Settings": "基本設定",
@@ -134,7 +132,6 @@
   "edited this page": "さんがこのページを編集しました。",
   "edited this page": "さんがこのページを編集しました。",
   "List Drafts": "下書き一覧",
   "List Drafts": "下書き一覧",
   "Deleted Pages": "削除済みページ",
   "Deleted Pages": "削除済みページ",
-  "Sign out": "ログアウト",
   "Disassociate": "連携解除",
   "Disassociate": "連携解除",
   "Color mode": "カラーモード",
   "Color mode": "カラーモード",
   "Sidebar mode": "サイドバーモード",
   "Sidebar mode": "サイドバーモード",
@@ -154,12 +151,6 @@
   "not_allowed_to_see_this_page": "このページは閲覧できません",
   "not_allowed_to_see_this_page": "このページは閲覧できません",
   "Confirm": "確認",
   "Confirm": "確認",
   "Successfully requested": "正常に処理を受け付けました",
   "Successfully requested": "正常に処理を受け付けました",
-  "personal_dropdown": {
-    "color_mode": "カラーモード",
-    "sidebar_mode": "サイドバーモード",
-    "sidebar_mode_editor": "サイドバーモード(編集時)",
-    "use_os_settings": "OS設定を利用する"
-  },
   "form_validation": {
   "form_validation": {
     "error_message": "いくつかの値が設定されていません",
     "error_message": "いくつかの値が設定されていません",
     "required": "%sに値を入力してください",
     "required": "%sに値を入力してください",
@@ -242,14 +233,6 @@
   "API Token Settings": "API Token設定",
   "API Token Settings": "API Token設定",
   "Current API Token": "現在のAPI Token",
   "Current API Token": "現在のAPI Token",
   "Update API Token": "API Tokenを更新",
   "Update API Token": "API Tokenを更新",
-  "in_app_notification": {
-    "notification_list": "アプリ内通知一覧",
-    "see_all": "通知一覧を見る",
-    "no_notification": "通知はありません",
-    "all": "全て",
-    "unopend": "未読",
-    "mark_all_as_read": "全て既読にする"
-  },
   "in_app_notification_settings": {
   "in_app_notification_settings": {
     "in_app_notification_settings": "アプリ内通知設定",
     "in_app_notification_settings": "アプリ内通知設定",
     "subscribe_settings": "自動でページをサブスクライブする(通知を受け取る)設定",
     "subscribe_settings": "自動でページをサブスクライブする(通知を受け取る)設定",
@@ -324,7 +307,7 @@
   },
   },
   "page_page": {
   "page_page": {
     "notice": {
     "notice": {
-      "version": "これは現在の版ではありません。",
+      "version": "これは最新のバージョンではありません。",
       "redirected": "リダイレクト元 >>",
       "redirected": "リダイレクト元 >>",
       "redirected_period":"",
       "redirected_period":"",
       "unlinked": "このページへのリダイレクトは削除されました。",
       "unlinked": "このページへのリダイレクトは削除されました。",

+ 7 - 4
packages/app/public/static/locales/zh_CN/admin.json

@@ -360,9 +360,12 @@
     "site_name": "网站名称 ",
     "site_name": "网站名称 ",
     "sitename_change": "您可以更改用于标题和HTML标题的网站名称。",
     "sitename_change": "您可以更改用于标题和HTML标题的网站名称。",
     "header_content": "此处输入的内容将显示在标题等中。",
     "header_content": "此处输入的内容将显示在标题等中。",
-    "site_url_desc": "用于网站URL设置。",
-    "site_url_warn": "某些功能不起作用,因为未设置网站URL。",
-    "siteurl_help": "网站完整URL起始于 <code>http://</code> or <code>https://</code>.",
+    "site_url": {
+      "title": "主页URL设置",
+      "desc": "用于网站URL设置。",
+      "warn": "某些功能不起作用,因为未设置网站URL。",
+      "help": "网站完整URL起始于 <code>http://</code> or <code>https://</code>."
+    },
     "confidential_name": "内部名称",
     "confidential_name": "内部名称",
     "confidential_example": "ex):仅供内部使用",
     "confidential_example": "ex):仅供内部使用",
     "default_language": "新用户的默认语言",
     "default_language": "新用户的默认语言",
@@ -623,7 +626,7 @@
     "export": "导出",
     "export": "导出",
     "cancel": "取消",
     "cancel": "取消",
     "file": "文件",
     "file": "文件",
-    "growi_version": "Growi Version",
+    "growi_version": "GROWI Version",
     "collections": "Collections",
     "collections": "Collections",
     "exported_at": "Exported At",
     "exported_at": "Exported At",
     "export_menu": "导出菜单",
     "export_menu": "导出菜单",

+ 37 - 2
packages/app/public/static/locales/zh_CN/commons.json

@@ -1,4 +1,11 @@
 {
 {
+	"Show": "显示",
+	"Hide": "隐藏",
+  "Add": "添加",
+  "Reset": "重启",
+	"Sign out": "退出",
+  "New": "新建",
+
   "meta": {
   "meta": {
     "display_name": "简体中文"
     "display_name": "简体中文"
   },
   },
@@ -37,9 +44,22 @@
     "description": "Description"
     "description": "Description"
   },
   },
 
 
+  "in_app_notification": {
+    "notification_list": "应用内通知列表",
+    "see_all": "查看通知列表",
+    "no_notification": "您没有任何通知",
+    "all": "全部",
+    "unopend": "未读",
+    "mark_all_as_read" : "标记为已读"
+  },
+
   "personal_dropdown": {
   "personal_dropdown": {
     "home": "家",
     "home": "家",
-    "settings": "设置"
+    "settings": "设置",
+		"color_mode": "颜色模式",
+		"sidebar_mode": "边栏模式",
+		"sidebar_mode_editor": "编辑器上的边栏模式",
+		"use_os_settings": "使用操作系统设置"
   },
   },
 
 
 	"copy_to_clipboard": {
 	"copy_to_clipboard": {
@@ -55,10 +75,25 @@
     "image_crop": "图像裁剪",
     "image_crop": "图像裁剪",
     "crop": "修剪",
     "crop": "修剪",
     "save": "节省",
     "save": "节省",
-    "reset": "重启",
     "cancel": "取消"
     "cancel": "取消"
   },
   },
 
 
+  "handsontable_modal": {
+    "title": "编辑表格",
+    "data_import": "数据导入",
+    "save": "节省",
+    "cancel": "取消",
+    "done": "完毕",
+    "data_import_form": {
+      "select_data_format": "数据格式",
+      "import_data": "导入数据",
+      "paste_table_data": "粘贴表格数据",
+      "parse_error": "解析错误",
+      "cancel": "取消",
+      "import": "导入"
+    }
+  },
+
   "not_found_page": {
   "not_found_page": {
     "page_not_exist": "该页面不存在"
     "page_not_exist": "该页面不存在"
   }
   }

+ 0 - 17
packages/app/public/static/locales/zh_CN/translation.json

@@ -28,7 +28,6 @@
 	"Admin": "管理",
 	"Admin": "管理",
 	"administrator": "管理员",
 	"administrator": "管理员",
 	"Tags": "Tags",
 	"Tags": "Tags",
-  "New": "新建",
   "Close": "Close",
   "Close": "Close",
 	"Shortcuts": "快捷方式",
 	"Shortcuts": "快捷方式",
   "CustomSidebar": "Custom Sidebar",
   "CustomSidebar": "Custom Sidebar",
@@ -117,7 +116,6 @@
   "V5 Page Migration": "转换为V5的兼容性",
   "V5 Page Migration": "转换为V5的兼容性",
   "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
   "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
   "See_more_detail_on_new_schema": "更多详情请见<a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'> {{title}}</a> <i class='icon-share-alt'></i> ",
   "See_more_detail_on_new_schema": "更多详情请见<a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'> {{title}}</a> <i class='icon-share-alt'></i> ",
-	"Site URL settings": "主页URL设置",
 	"Markdown Settings": "Markdown设置",
 	"Markdown Settings": "Markdown设置",
 	"Notification Settings": "通知设置",
 	"Notification Settings": "通知设置",
 	"external_account_management": "外部账户管理",
 	"external_account_management": "外部账户管理",
@@ -146,7 +144,6 @@
 	"edited this page": "edited this page.",
 	"edited this page": "edited this page.",
 	"List Drafts": "草稿",
 	"List Drafts": "草稿",
 	"Deleted Pages": "已删除页",
 	"Deleted Pages": "已删除页",
-	"Sign out": "退出",
   "Disassociate": "解除关联",
   "Disassociate": "解除关联",
   "No bookmarks yet": "暂无书签",
   "No bookmarks yet": "暂无书签",
   "add_bookmark": "添加到书签",
   "add_bookmark": "添加到书签",
@@ -229,14 +226,6 @@
 	"API Token Settings": "API token 设置",
 	"API Token Settings": "API token 设置",
 	"Current API Token": "当前 API token",
 	"Current API Token": "当前 API token",
 	"Update API Token": "更新 API token",
 	"Update API Token": "更新 API token",
-  "in_app_notification": {
-    "notification_list": "应用内通知列表",
-    "see_all": "查看通知列表",
-    "no_notification": "您没有任何通知",
-    "all": "全部",
-    "unopend": "未读",
-    "mark_all_as_read" : "标记为已读"
-  },
   "in_app_notification_settings": {
   "in_app_notification_settings": {
     "in_app_notification_settings": "在应用程序通知设置",
     "in_app_notification_settings": "在应用程序通知设置",
     "subscribe_settings": "自动订阅(接收通知)页面的设置",
     "subscribe_settings": "自动订阅(接收通知)页面的设置",
@@ -567,12 +556,6 @@
     "Invalid_Number_of_Date" : "You entered invalid value",
     "Invalid_Number_of_Date" : "You entered invalid value",
     "link_sharing_is_disabled": "链接共享已被禁用"
     "link_sharing_is_disabled": "链接共享已被禁用"
   },
   },
-	"personal_dropdown": {
-		"color_mode": "颜色模式",
-		"sidebar_mode": "边栏模式",
-		"sidebar_mode_editor": "编辑器上的边栏模式",
-		"use_os_settings": "使用操作系统设置"
-	},
 	"search_result": {
 	"search_result": {
 		"result_meta": "搜索结果:",
 		"result_meta": "搜索结果:",
 		"deletion_mode_btn_lavel": "选择并删除页面",
 		"deletion_mode_btn_lavel": "选择并删除页面",

+ 2 - 2
packages/app/src/components/Admin/App/AppSetting.jsx

@@ -120,7 +120,7 @@ const AppSetting = (props) => {
               checked={adminAppContainer.state.isEmailPublishedForNewUser === true}
               checked={adminAppContainer.state.isEmailPublishedForNewUser === true}
               onChange={() => { adminAppContainer.changeIsEmailPublishedForNewUserShow(true) }}
               onChange={() => { adminAppContainer.changeIsEmailPublishedForNewUserShow(true) }}
             />
             />
-            <label className="custom-control-label" htmlFor="radio-email-show">{t('Show')}</label>
+            <label className="custom-control-label" htmlFor="radio-email-show">{t('commons:Show')}</label>
           </div>
           </div>
 
 
           <div className="custom-control custom-radio custom-control-inline">
           <div className="custom-control custom-radio custom-control-inline">
@@ -132,7 +132,7 @@ const AppSetting = (props) => {
               checked={adminAppContainer.state.isEmailPublishedForNewUser === false}
               checked={adminAppContainer.state.isEmailPublishedForNewUser === false}
               onChange={() => { adminAppContainer.changeIsEmailPublishedForNewUserShow(false) }}
               onChange={() => { adminAppContainer.changeIsEmailPublishedForNewUserShow(false) }}
             />
             />
-            <label className="custom-control-label" htmlFor="radio-email-hide">{t('Hide')}</label>
+            <label className="custom-control-label" htmlFor="radio-email-hide">{t('commons:Hide')}</label>
           </div>
           </div>
 
 
         </div>
         </div>

+ 1 - 1
packages/app/src/components/Admin/App/AppSettingsPageContents.tsx

@@ -89,7 +89,7 @@ const AppSettingsPageContents = (props: Props) => {
 
 
       <div className="row mt-5">
       <div className="row mt-5">
         <div className="col-lg-12">
         <div className="col-lg-12">
-          <h2 className="admin-setting-header">{t('Site URL settings')}</h2>
+          <h2 className="admin-setting-header">{t('app_setting.site_url.title')}</h2>
           <SiteUrlSetting />
           <SiteUrlSetting />
         </div>
         </div>
       </div>
       </div>

+ 6 - 6
packages/app/src/components/Admin/App/SiteUrlSetting.tsx

@@ -17,14 +17,14 @@ type Props = {
 }
 }
 
 
 const SiteUrlSetting = (props: Props) => {
 const SiteUrlSetting = (props: Props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin', { keyPrefix: 'app_setting' });
   const { adminAppContainer } = props;
   const { adminAppContainer } = props;
 
 
 
 
   const submitHandler = useCallback(async() => {
   const submitHandler = useCallback(async() => {
     try {
     try {
       await adminAppContainer.updateSiteUrlSettingHandler();
       await adminAppContainer.updateSiteUrlSettingHandler();
-      toastSuccess(t('toaster.update_successed', { target: t('Site URL settings'), ns: 'commons' }));
+      toastSuccess(t('toaster.update_successed', { target: t('site_url.title') }));
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
@@ -34,9 +34,9 @@ const SiteUrlSetting = (props: Props) => {
 
 
   return (
   return (
     <React.Fragment>
     <React.Fragment>
-      <p className="card well">{t('admin:app_setting.site_url_desc')}</p>
+      <p className="card well">{t('site_url.desc')}</p>
       {!adminAppContainer.state.isSetSiteUrl
       {!adminAppContainer.state.isSetSiteUrl
-          && (<p className="alert alert-danger"><i className="icon-exclamation"></i> {t('admin:app_setting.site_url_warn')}</p>)}
+          && (<p className="alert alert-danger"><i className="icon-exclamation"></i> {t('site_url.warn')}</p>)}
 
 
       <div className="row form-group">
       <div className="row form-group">
         <div className="col-md-9 offset-md-3">
         <div className="col-md-9 offset-md-3">
@@ -64,14 +64,14 @@ const SiteUrlSetting = (props: Props) => {
                   />
                   />
                   <p className="form-text text-muted">
                   <p className="form-text text-muted">
                     {/* eslint-disable-next-line react/no-danger */}
                     {/* eslint-disable-next-line react/no-danger */}
-                    <span dangerouslySetInnerHTML={{ __html: t('admin:app_setting.siteurl_help') }} />
+                    <span dangerouslySetInnerHTML={{ __html: t('site_url.help') }} />
                   </p>
                   </p>
                 </td>
                 </td>
                 <td>
                 <td>
                   <input className="form-control" type="text" value={adminAppContainer.state.envSiteUrl || ''} readOnly />
                   <input className="form-control" type="text" value={adminAppContainer.state.envSiteUrl || ''} readOnly />
                   <p className="form-text text-muted">
                   <p className="form-text text-muted">
                     {/* eslint-disable-next-line react/no-danger */}
                     {/* eslint-disable-next-line react/no-danger */}
-                    <span dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'APP_SITE_URL' }) }} />
+                    <span dangerouslySetInnerHTML={{ __html: t('use_env_var_if_empty', { variable: 'APP_SITE_URL' }) }} />
                   </p>
                   </p>
                 </td>
                 </td>
               </tr>
               </tr>

+ 2 - 20
packages/app/src/components/Admin/MarkdownSetting/XssForm.jsx

@@ -43,26 +43,8 @@ class XssForm extends React.Component {
     return (
     return (
       <div className="form-group col-12 my-3">
       <div className="form-group col-12 my-3">
         <div className="row">
         <div className="row">
-          <div className="col-md-4 col-sm-12 align-self-start mb-4">
-            <div className="custom-control custom-radio ">
-              <input
-                type="radio"
-                className="custom-control-input"
-                id="xssOption1"
-                name="XssOption"
-                checked={xssOption === 1}
-                onChange={() => { adminMarkDownContainer.setState({ xssOption: 1 }) }}
-              />
-              <label className="custom-control-label w-100" htmlFor="xssOption1">
-                <p className="font-weight-bold">{t('markdown_settings.xss_options.remove_all_tags')}</p>
-                <div className="mt-4">
-                  {t('markdown_settings.xss_options.remove_all_tags_desc')}
-                </div>
-              </label>
-            </div>
-          </div>
 
 
-          <div className="col-md-4 col-sm-12 align-self-start mb-4">
+          <div className="col-md-6 col-sm-12 align-self-start mb-4">
             <div className="custom-control custom-radio">
             <div className="custom-control custom-radio">
               <input
               <input
                 type="radio"
                 type="radio"
@@ -104,7 +86,7 @@ class XssForm extends React.Component {
             </div>
             </div>
           </div>
           </div>
 
 
-          <div className="col-md-4 col-sm-12 align-self-start mb-4">
+          <div className="col-md-6 col-sm-12 align-self-start mb-4">
             <div className="custom-control custom-radio">
             <div className="custom-control custom-radio">
               <input
               <input
                 type="radio"
                 type="radio"

+ 3 - 3
packages/app/src/components/Admin/Notification/NotificationSetting.jsx

@@ -35,7 +35,7 @@ const Badge = ({ isEnabled }) => {
     : <span className="badge badge-secondary">{t('external_notification.disabled')}</span>;
     : <span className="badge badge-secondary">{t('external_notification.disabled')}</span>;
 };
 };
 
 
-const SkeltonListItem = () => (
+const SkeletonListItem = () => (
   <li className="list-group-item">
   <li className="list-group-item">
     <h4 className="mb-2">
     <h4 className="mb-2">
       <span className="badge badge-secondary">――</span>
       <span className="badge badge-secondary">――</span>
@@ -74,7 +74,7 @@ const LegacySlackIntegrationListItem = ({ isEnabled }) => {
     <li className="list-group-item">
     <li className="list-group-item">
       <h4>
       <h4>
         <Badge isEnabled={isEnabled} />
         <Badge isEnabled={isEnabled} />
-        <a href="/admin/slack-integration-legacy" className="ml-2">{t('legacy_slack_integration')}</a>
+        <a href="/admin/slack-integration-legacy" className="ml-2">{t('slack_integration_legacy.slack_integration_legacy')}</a>
       </h4>
       </h4>
       { isEnabled && (
       { isEnabled && (
         <ul className="mt-2 pl-4">
         <ul className="mt-2 pl-4">
@@ -144,7 +144,7 @@ function NotificationSetting(props) {
     <div data-testid="admin-notification">
     <div data-testid="admin-notification">
       <h2 className="admin-setting-header">{t('external_notification.header_status')}</h2>
       <h2 className="admin-setting-header">{t('external_notification.header_status')}</h2>
       <ul className="list-group">
       <ul className="list-group">
-        { !isMounted && <SkeltonListItem />}
+        { !isMounted && <SkeletonListItem />}
         { isMounted && (
         { isMounted && (
           <>
           <>
             <SlackIntegrationListItem isEnabled={isSlackEnabled} currentBotType={currentBotType} />
             <SlackIntegrationListItem isEnabled={isSlackEnabled} currentBotType={currentBotType} />

+ 1 - 1
packages/app/src/components/Admin/Notification/UserTriggerNotification.jsx

@@ -129,7 +129,7 @@ class UserTriggerNotification extends React.Component {
                 </p>
                 </p>
               </td>
               </td>
               <td>
               <td>
-                <button type="button" className="btn btn-primary" disabled={!this.validateForm()} onClick={this.onClickSubmit}>{t('add')}</button>
+                <button type="button" className="btn btn-primary" disabled={!this.validateForm()} onClick={this.onClickSubmit}>{t('commons:Add')}</button>
               </td>
               </td>
             </tr>
             </tr>
             {userNotifications.length > 0 && userNotifications.map((notification) => {
             {userNotifications.length > 0 && userNotifications.map((notification) => {

+ 1 - 1
packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx

@@ -100,7 +100,7 @@ class LocalSecuritySettingContents extends React.Component {
 
 
             <div className="row">
             <div className="row">
               <div className="col-12 col-md-3 text-left text-md-right py-2">
               <div className="col-12 col-md-3 text-left text-md-right py-2">
-                <strong>{t('Register limitation')}</strong>
+                <strong>{t('security_settings.Register limitation')}</strong>
               </div>
               </div>
               <div className="col-12 col-md-6">
               <div className="col-12 col-md-6">
                 <div className="dropdown">
                 <div className="dropdown">

+ 13 - 11
packages/app/src/components/Admin/Security/ShareLinkSetting.tsx

@@ -134,19 +134,21 @@ const ShareLinkSetting = (props: ShareLinkSettingProps) => {
         </div>
         </div>
       </div>
       </div>
       <h4>{t('security_settings.all_share_links')}</h4>
       <h4>{t('security_settings.all_share_links')}</h4>
-      <Pager
-        activePage={shareLinksActivePage}
-        pagingHandler={getShareLinkList}
-        totalLinks={totalshareLinks}
-        limit={shareLinksPagingLimit}
-      />
 
 
       {(shareLinks.length !== 0) ? (
       {(shareLinks.length !== 0) ? (
-        <ShareLinkList
-          shareLinks={shareLinks}
-          onClickDeleteButton={deleteLinkById}
-          isAdmin
-        />
+        <>
+          <Pager
+            activePage={shareLinksActivePage}
+            pagingHandler={getShareLinkList}
+            totalLinks={totalshareLinks}
+            limit={shareLinksPagingLimit}
+          />
+          <ShareLinkList
+            shareLinks={shareLinks}
+            onClickDeleteButton={deleteLinkById}
+            isAdmin
+          />
+        </>
       )
       )
         : (<p className="text-center">{t('security_settings.No_share_links')}</p>
         : (<p className="text-center">{t('security_settings.No_share_links')}</p>
         )
         )

+ 1 - 1
packages/app/src/components/Admin/UserManagement.tsx

@@ -182,7 +182,7 @@ const UserManagement = (props: UserManagementProps) => {
               onClick={resetButtonClickHandler}
               onClick={resetButtonClickHandler}
             >
             >
               <span className="icon-refresh mr-1"></span>
               <span className="icon-refresh mr-1"></span>
-              Reset
+              {t('commons:Reset')}
             </button>
             </button>
           </div>
           </div>
         </div>
         </div>

+ 2 - 1
packages/app/src/components/Admin/Users/GiveAdminButton.tsx

@@ -39,6 +39,7 @@ const GiveAdminButton = (props: GiveAdminButtonProps): JSX.Element => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const GiveAdminButtonWrapper = withUnstatedContainers(GiveAdminButton, [AdminUsersContainer]);
+// eslint-disable-next-line max-len
+const GiveAdminButtonWrapper: React.ForwardRefExoticComponent<Pick<any, string | number | symbol> & React.RefAttributes<any>> = withUnstatedContainers(GiveAdminButton, [AdminUsersContainer]);
 
 
 export default GiveAdminButtonWrapper;
 export default GiveAdminButtonWrapper;

+ 2 - 1
packages/app/src/components/Admin/Users/RemoveAdminMenuItem.tsx

@@ -58,6 +58,7 @@ const RemoveAdminMenuItem = (props: Props): JSX.Element => {
 /**
 /**
 * Wrapper component for using unstated
 * Wrapper component for using unstated
 */
 */
-const RemoveAdminMenuItemWrapper = withUnstatedContainers(RemoveAdminMenuItem, [AdminUsersContainer]);
+// eslint-disable-next-line max-len
+const RemoveAdminMenuItemWrapper: React.ForwardRefExoticComponent<Pick<any, string | number | symbol> & React.RefAttributes<any>> = withUnstatedContainers(RemoveAdminMenuItem, [AdminUsersContainer]);
 
 
 export default RemoveAdminMenuItemWrapper;
 export default RemoveAdminMenuItemWrapper;

+ 2 - 1
packages/app/src/components/Admin/Users/StatusSuspendMenuItem.tsx

@@ -56,6 +56,7 @@ const StatusSuspendMenuItem = (props: Props): JSX.Element => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const StatusSuspendMenuItemWrapper = withUnstatedContainers(StatusSuspendMenuItem, [AdminUsersContainer]);
+// eslint-disable-next-line max-len
+const StatusSuspendMenuItemWrapper: React.ForwardRefExoticComponent<Pick<any, string | number | symbol> & React.RefAttributes<any>> = withUnstatedContainers(StatusSuspendMenuItem, [AdminUsersContainer]);
 
 
 export default StatusSuspendMenuItemWrapper;
 export default StatusSuspendMenuItemWrapper;

+ 0 - 132
packages/app/src/components/Admin/Users/UserMenu.jsx

@@ -1,132 +0,0 @@
-import React, { Fragment } from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import {
-  UncontrolledDropdown, DropdownToggle, DropdownMenu,
-} from 'reactstrap';
-
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-import GiveAdminButton from './GiveAdminButton';
-import RemoveAdminMenuItem from './RemoveAdminMenuItem';
-import SendInvitationEmailButton from './SendInvitationEmailButton';
-import StatusActivateButton from './StatusActivateButton';
-import StatusSuspendedMenuItem from './StatusSuspendMenuItem';
-import UserRemoveButton from './UserRemoveButton';
-
-
-class UserMenu extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isInvitationEmailSended: this.props.user.isInvitationEmailSended,
-    };
-
-    this.onPasswordResetClicked = this.onPasswordResetClicked.bind(this);
-    this.onSuccessfullySentInvitationEmail = this.onSuccessfullySentInvitationEmail.bind(this);
-  }
-
-  onPasswordResetClicked() {
-    this.props.adminUsersContainer.showPasswordResetModal(this.props.user);
-  }
-
-  onSuccessfullySentInvitationEmail() {
-    this.setState({ isInvitationEmailSended: true });
-  }
-
-  renderEditMenu() {
-    const { t } = this.props;
-
-    return (
-      <Fragment>
-        <li className="dropdown-divider"></li>
-        <li className="dropdown-header">{t('admin:user_management.user_table.edit_menu')}</li>
-        <li>
-          <button className="dropdown-item" type="button" onClick={this.onPasswordResetClicked}>
-            <i className="icon-fw icon-key"></i>{ t('admin:user_management.reset_password') }
-          </button>
-        </li>
-      </Fragment>
-    );
-  }
-
-  renderStatusMenu() {
-    const { t, user } = this.props;
-    const { isInvitationEmailSended } = this.state;
-
-    return (
-      <Fragment>
-        <li className="dropdown-divider"></li>
-        <li className="dropdown-header">{t('user_management.status')}</li>
-        <li>
-          {(user.status === 1 || user.status === 3) && <StatusActivateButton user={user} />}
-          {user.status === 2 && <StatusSuspendedMenuItem user={user} />}
-          {user.status === 5 && (
-            <SendInvitationEmailButton
-              user={user}
-              isInvitationEmailSended={isInvitationEmailSended}
-              onSuccessfullySentInvitationEmail={this.onSuccessfullySentInvitationEmail}
-            />
-          )}
-          {(user.status === 1 || user.status === 3 || user.status === 5) && <UserRemoveButton user={user} />}
-        </li>
-      </Fragment>
-    );
-  }
-
-  renderAdminMenu() {
-    const { t, user } = this.props;
-
-    return (
-      <Fragment>
-        <li className="dropdown-divider pl-0"></li>
-        <li className="dropdown-header">{t('admin:user_management.user_table.administrator_menu')}</li>
-        <li>
-          {user.admin === true && <RemoveAdminMenuItem user={user} />}
-          {user.admin === false && <GiveAdminButton user={user} />}
-        </li>
-      </Fragment>
-    );
-  }
-
-  render() {
-    const { user } = this.props;
-    const { isInvitationEmailSended } = this.state;
-
-    return (
-      <UncontrolledDropdown id="userMenu" size="sm">
-        <DropdownToggle caret color="secondary" outline>
-          <i className="icon-settings" />
-          {(user.status === 5 && !isInvitationEmailSended) && <i className="fa fa-circle text-danger grw-usermenu-notification-icon" />}
-        </DropdownToggle>
-        <DropdownMenu positionFixed>
-          {this.renderEditMenu()}
-          {user.status !== 4 && this.renderStatusMenu()}
-          {user.status === 2 && this.renderAdminMenu()}
-        </DropdownMenu>
-      </UncontrolledDropdown>
-    );
-  }
-
-}
-
-const UserMenuWrapperFC = (props) => {
-  const { t } = useTranslation('admin');
-  return <UserMenu t={t} {...props} />;
-};
-
-const UserMenuWrapper = withUnstatedContainers(UserMenuWrapperFC, [AdminUsersContainer]);
-
-UserMenu.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
-
-  user: PropTypes.object.isRequired,
-};
-
-export default UserMenuWrapper;

+ 5 - 0
packages/app/src/components/Admin/Users/UserMenu.module.scss

@@ -0,0 +1,5 @@
+.grw-usermenu-notification-icon :global {
+  position: absolute;
+  top: -4px;
+  left: 30px;
+}

+ 114 - 0
packages/app/src/components/Admin/Users/UserMenu.tsx

@@ -0,0 +1,114 @@
+import React, { useState, useCallback } from 'react';
+
+import { IUserHasId, USER_STATUS } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+import {
+  UncontrolledDropdown, DropdownToggle, DropdownMenu,
+} from 'reactstrap';
+
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+import GiveAdminButton from './GiveAdminButton';
+import RemoveAdminMenuItem from './RemoveAdminMenuItem';
+import SendInvitationEmailButton from './SendInvitationEmailButton';
+import StatusActivateButton from './StatusActivateButton';
+import StatusSuspendedMenuItem from './StatusSuspendMenuItem';
+import UserRemoveButton from './UserRemoveButton';
+
+import styles from './UserMenu.module.scss';
+
+type UserMenuProps = {
+  adminUsersContainer: AdminUsersContainer,
+  user: IUserHasId,
+}
+
+const UserMenu = (props: UserMenuProps) => {
+  const { t } = useTranslation('admin');
+
+  const { adminUsersContainer, user } = props;
+
+  const [isInvitationEmailSended, setIsInvitationEmailSended] = useState<boolean>(user.isInvitationEmailSended);
+
+  const onClickPasswordResetHandler = useCallback(async() => {
+    await adminUsersContainer.showPasswordResetModal(user);
+  }, [adminUsersContainer, user]);
+
+  const onSuccessfullySentInvitationEmailHandler = useCallback(() => {
+    setIsInvitationEmailSended(true);
+  }, []);
+
+  const renderEditMenu = useCallback(() => {
+    return (
+      <>
+        <li className="dropdown-divider"></li>
+        <li className="dropdown-header">{t('user_management.user_table.edit_menu')}</li>
+        <li>
+          <button className="dropdown-item" type="button" onClick={onClickPasswordResetHandler}>
+            <i className="icon-fw icon-key"></i>{ t('user_management.reset_password') }
+          </button>
+        </li>
+      </>
+    );
+  }, [onClickPasswordResetHandler, t]);
+
+  const renderStatusMenu = useCallback(() => {
+    return (
+      <>
+        <li className="dropdown-divider"></li>
+        <li className="dropdown-header">{t('user_management.status')}</li>
+        <li>
+          {(user.status === USER_STATUS.REGISTERED || user.status === USER_STATUS.SUSPENDED) && <StatusActivateButton user={user} />}
+          {user.status === USER_STATUS.ACTIVE && <StatusSuspendedMenuItem user={user} />}
+          {user.status === USER_STATUS.INVITED && (
+            <SendInvitationEmailButton
+              user={user}
+              isInvitationEmailSended={isInvitationEmailSended}
+              onSuccessfullySentInvitationEmail={onSuccessfullySentInvitationEmailHandler}
+            />
+          )}
+          {(user.status === USER_STATUS.REGISTERED || user.status === USER_STATUS.SUSPENDED || user.status === USER_STATUS.INVITED)
+          && <UserRemoveButton user={user} />}
+        </li>
+      </>
+    );
+  }, [isInvitationEmailSended, onSuccessfullySentInvitationEmailHandler, t, user]);
+
+  const renderAdminMenu = useCallback(() => {
+    return (
+      <>
+        <li className="dropdown-divider pl-0"></li>
+        <li className="dropdown-header">{t('user_management.user_table.administrator_menu')}</li>
+        <li>
+          {user.admin === true && <RemoveAdminMenuItem user={user} />}
+          {user.admin === false && <GiveAdminButton user={user} />}
+        </li>
+      </>
+    );
+  }, [t, user]);
+
+  return (
+    <UncontrolledDropdown id="userMenu" size="sm">
+      <DropdownToggle caret color="secondary" outline>
+        <i className="icon-settings" />
+        {(user.status === USER_STATUS.INVITED && !isInvitationEmailSended)
+        && <i className={`fa fa-circle text-danger grw-usermenu-notification-icon ${styles['grw-usermenu-notification-icon']}`} />}
+      </DropdownToggle>
+      <DropdownMenu positionFixed>
+        {renderEditMenu()}
+        {user.status !== USER_STATUS.DELETED && renderStatusMenu()}
+        {user.status === USER_STATUS.ACTIVE && renderAdminMenu()}
+      </DropdownMenu>
+    </UncontrolledDropdown>
+  );
+
+};
+
+/**
+* Wrapper component for using unstated
+*/
+// eslint-disable-next-line max-len
+const UserMenuWrapper: React.ForwardRefExoticComponent<Pick<any, string | number | symbol> & React.RefAttributes<any>> = withUnstatedContainers(UserMenu, [AdminUsersContainer]);
+
+export default UserMenuWrapper;

+ 6 - 5
packages/app/src/components/Comments.tsx

@@ -3,7 +3,7 @@ import React from 'react';
 import { IRevisionHasId } from '@growi/core';
 import { IRevisionHasId } from '@growi/core';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 
 
-import { PageComment } from '~/components/PageComment';
+import { PageCommentProps } from '~/components/PageComment';
 import { useSWRxPageComment } from '~/stores/comment';
 import { useSWRxPageComment } from '~/stores/comment';
 import { useIsTrashPage } from '~/stores/page';
 import { useIsTrashPage } from '~/stores/page';
 
 
@@ -11,18 +11,18 @@ import { useCurrentUser } from '../stores/context';
 
 
 import { CommentEditorProps } from './PageComment/CommentEditor';
 import { CommentEditorProps } from './PageComment/CommentEditor';
 
 
-
+const PageComment = dynamic<PageCommentProps>(() => import('~/components/PageComment').then(mod => mod.PageComment), { ssr: false });
 const CommentEditor = dynamic<CommentEditorProps>(() => import('./PageComment/CommentEditor').then(mod => mod.CommentEditor), { ssr: false });
 const CommentEditor = dynamic<CommentEditorProps>(() => import('./PageComment/CommentEditor').then(mod => mod.CommentEditor), { ssr: false });
 
 
-
 type CommentsProps = {
 type CommentsProps = {
   pageId: string,
   pageId: string,
+  pagePath: string,
   revision: IRevisionHasId,
   revision: IRevisionHasId,
 }
 }
 
 
 export const Comments = (props: CommentsProps): JSX.Element => {
 export const Comments = (props: CommentsProps): JSX.Element => {
 
 
-  const { pageId, revision } = props;
+  const { pageId, pagePath, revision } = props;
 
 
   const { mutate } = useSWRxPageComment(pageId);
   const { mutate } = useSWRxPageComment(pageId);
   const { data: isDeleted } = useIsTrashPage();
   const { data: isDeleted } = useIsTrashPage();
@@ -33,13 +33,13 @@ export const Comments = (props: CommentsProps): JSX.Element => {
   }
   }
 
 
   return (
   return (
-    // TODO: Check and refactor CSS import
     <div className="page-comments-row mt-5 py-4 d-edit-none d-print-none">
     <div className="page-comments-row mt-5 py-4 d-edit-none d-print-none">
       <div className="container-lg">
       <div className="container-lg">
         <div className="page-comments">
         <div className="page-comments">
           <div id="page-comments-list" className="page-comments-list">
           <div id="page-comments-list" className="page-comments-list">
             <PageComment
             <PageComment
               pageId={pageId}
               pageId={pageId}
+              pagePath={pagePath}
               revision={revision}
               revision={revision}
               currentUser={currentUser}
               currentUser={currentUser}
               isReadOnly={false}
               isReadOnly={false}
@@ -53,6 +53,7 @@ export const Comments = (props: CommentsProps): JSX.Element => {
                 pageId={pageId}
                 pageId={pageId}
                 isForNewComment
                 isForNewComment
                 onCommentButtonClicked={mutate}
                 onCommentButtonClicked={mutate}
+                revisionId={revision._id}
               />
               />
             </div>
             </div>
           )}
           )}

+ 1 - 1
packages/app/src/components/Common/ImageCropModal.tsx

@@ -158,7 +158,7 @@ const ImageCropModal: FC<Props> = (props: Props) => {
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
         <button type="button" className="btn btn-outline-danger rounded-pill mr-auto" disabled={!isCropImage} onClick={reset}>
         <button type="button" className="btn btn-outline-danger rounded-pill mr-auto" disabled={!isCropImage} onClick={reset}>
-          {t('crop_image_modal.reset')}
+          {t('commons:Reset')}
         </button>
         </button>
         { !showCropOption && (
         { !showCropOption && (
           <div className="mr-auto">
           <div className="mr-auto">

+ 1 - 1
packages/app/src/components/InAppNotification/InAppNotificationDropdown.tsx

@@ -20,7 +20,7 @@ const logger = loggerFactory('growi:InAppNotificationDropdown');
 
 
 
 
 export const InAppNotificationDropdown = (): JSX.Element => {
 export const InAppNotificationDropdown = (): JSX.Element => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('commons');
 
 
   const [isOpen, setIsOpen] = useState(false);
   const [isOpen, setIsOpen] = useState(false);
   const limit = 6;
   const limit = 6;

+ 1 - 1
packages/app/src/components/InAppNotification/InAppNotificationElm.tsx

@@ -97,7 +97,7 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
       break;
       break;
     case 'PAGE_UPDATE':
     case 'PAGE_UPDATE':
       actionMsg = 'updated on';
       actionMsg = 'updated on';
-      actionIcon = 'ti-agenda';
+      actionIcon = 'ti ti-agenda';
       break;
       break;
     case 'PAGE_RENAME':
     case 'PAGE_RENAME':
       actionMsg = 'renamed';
       actionMsg = 'renamed';

+ 5 - 3
packages/app/src/components/InAppNotification/InAppNotificationPage.tsx

@@ -20,7 +20,7 @@ const logger = loggerFactory('growi:InAppNotificationPage');
 
 
 
 
 export const InAppNotificationPage: FC = () => {
 export const InAppNotificationPage: FC = () => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('commons');
   const { mutate } = useSWRxInAppNotificationStatus();
   const { mutate } = useSWRxInAppNotificationStatus();
 
 
   const { data: showPageLimitationXL } = useShowPageLimitationXL();
   const { data: showPageLimitationXL } = useShowPageLimitationXL();
@@ -64,7 +64,7 @@ export const InAppNotificationPage: FC = () => {
 
 
     if (notificationData == null) {
     if (notificationData == null) {
       return (
       return (
-        <div className="wiki">
+        <div className="wiki" data-testid="grw-in-app-notification-page-spinner">
           <div className="text-muted text-center">
           <div className="text-muted text-center">
             <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
             <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
           </div>
           </div>
@@ -138,7 +138,9 @@ export const InAppNotificationPage: FC = () => {
   };
   };
 
 
   return (
   return (
-    <CustomNavAndContents navTabMapping={navTabMapping} tabContentClasses={['mt-4']} />
+    <div data-testid="grw-in-app-notification-page">
+      <CustomNavAndContents navTabMapping={navTabMapping} tabContentClasses={['mt-4']} />
+    </div>
   );
   );
 };
 };
 
 

+ 1 - 1
packages/app/src/components/Navbar/AppearanceModeDropdown.tsx

@@ -20,7 +20,7 @@ type AppearanceModeDropdownProps = {
 }
 }
 export const AppearanceModeDropdown:FC<AppearanceModeDropdownProps> = (props: AppearanceModeDropdownProps) => {
 export const AppearanceModeDropdown:FC<AppearanceModeDropdownProps> = (props: AppearanceModeDropdownProps) => {
 
 
-  const { t } = useTranslation();
+  const { t } = useTranslation('commons');
 
 
   const { isAuthenticated } = props;
   const { isAuthenticated } = props;
 
 

+ 1 - 1
packages/app/src/components/Navbar/AuthorInfo.module.scss

@@ -25,7 +25,7 @@ $date-font-size: 11px;
   }
   }
 }
 }
 
 
-.grw-author-info-skelton :global {
+.grw-author-info-skeleton :global {
   width: 139px;
   width: 139px;
   height: calc((#{$author-font-size} + #{$date-font-size}) * #{bs.$line-height-base});
   height: calc((#{$author-font-size} + #{$date-font-size}) * #{bs.$line-height-base});
 }
 }

+ 15 - 5
packages/app/src/components/Navbar/GlobalSearch.tsx

@@ -1,7 +1,10 @@
-import React, { useState, useCallback, useRef } from 'react';
+import React, {
+  useState, useCallback, useRef, useEffect,
+} from 'react';
 
 
 import assert from 'assert';
 import assert from 'assert';
 
 
+import { pathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 
 
@@ -27,6 +30,8 @@ export const GlobalSearch = (props: GlobalSearchProps): JSX.Element => {
 
 
   const { dropup } = props;
   const { dropup } = props;
 
 
+  const { returnPathForURL } = pathUtils;
+
   const router = useRouter();
   const router = useRouter();
 
 
   const globalSearchFormRef = useRef<IFocusable>(null);
   const globalSearchFormRef = useRef<IFocusable>(null);
@@ -38,9 +43,13 @@ export const GlobalSearch = (props: GlobalSearchProps): JSX.Element => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
 
 
   const [text, setText] = useState('');
   const [text, setText] = useState('');
-  const [isScopeChildren, setScopeChildren] = useState<boolean|undefined>(isSearchScopeChildrenAsDefault);
+  const [isScopeChildren, setScopeChildren] = useState<boolean|undefined>(isSearchScopeChildrenAsDefault ?? false);
   const [isFocused, setFocused] = useState<boolean>(false);
   const [isFocused, setFocused] = useState<boolean>(false);
 
 
+  useEffect(() => {
+    setScopeChildren(isSearchScopeChildrenAsDefault);
+  }, [isSearchScopeChildrenAsDefault]);
+
 
 
   const gotoPage = useCallback((data: IPageWithSearchMeta[]) => {
   const gotoPage = useCallback((data: IPageWithSearchMeta[]) => {
     assert(data.length > 0);
     assert(data.length > 0);
@@ -49,9 +58,9 @@ export const GlobalSearch = (props: GlobalSearchProps): JSX.Element => {
 
 
     // navigate to page
     // navigate to page
     if (page != null) {
     if (page != null) {
-      router.push(`/${page._id}`);
+      router.push(returnPathForURL(page.path, page._id));
     }
     }
-  }, [router]);
+  }, [returnPathForURL, router]);
 
 
   const search = useCallback(() => {
   const search = useCallback(() => {
     const url = new URL(window.location.href);
     const url = new URL(window.location.href);
@@ -73,6 +82,7 @@ export const GlobalSearch = (props: GlobalSearchProps): JSX.Element => {
 
 
   const isIndicatorShown = !isFocused && (text.length === 0);
   const isIndicatorShown = !isFocused && (text.length === 0);
 
 
+
   if (isScopeChildren == null || isSearchServiceReachable == null) {
   if (isScopeChildren == null || isSearchServiceReachable == null) {
     return <></>;
     return <></>;
   }
   }
@@ -116,7 +126,7 @@ export const GlobalSearch = (props: GlobalSearchProps): JSX.Element => {
         </div>
         </div>
         <SearchForm
         <SearchForm
           ref={globalSearchFormRef}
           ref={globalSearchFormRef}
-          isSearchServiceReachable={isSearchServiceReachable}
+          isSearchServiceReachable={isSearchServiceReachable || false}
           dropup={dropup}
           dropup={dropup}
           onChange={gotoPage}
           onChange={gotoPage}
           onBlur={() => setFocused(false)}
           onBlur={() => setFocused(false)}

+ 9 - 13
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -15,8 +15,7 @@ import {
 import { IResTagsUpdateApiv1 } from '~/interfaces/tag';
 import { IResTagsUpdateApiv1 } from '~/interfaces/tag';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import {
 import {
-  useCurrentPageId, useCurrentPathname,
-  useIsNotFound,
+  useCurrentPageId, useCurrentPathname, useIsNotFound,
   useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useTemplateTagData, useIsContainerFluid,
   useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useTemplateTagData, useIsContainerFluid,
 } from '~/stores/context';
 } from '~/stores/context';
 import { usePageTagsForEditors } from '~/stores/editor';
 import { usePageTagsForEditors } from '~/stores/editor';
@@ -35,7 +34,7 @@ import AttachmentIcon from '../Icons/AttachmentIcon';
 import HistoryIcon from '../Icons/HistoryIcon';
 import HistoryIcon from '../Icons/HistoryIcon';
 import PresentationIcon from '../Icons/PresentationIcon';
 import PresentationIcon from '../Icons/PresentationIcon';
 import ShareLinkIcon from '../Icons/ShareLinkIcon';
 import ShareLinkIcon from '../Icons/ShareLinkIcon';
-import { Skelton } from '../Skelton';
+import { Skeleton } from '../Skeleton';
 
 
 import type { AuthorInfoProps } from './AuthorInfo';
 import type { AuthorInfoProps } from './AuthorInfo';
 import { GrowiSubNavigation } from './GrowiSubNavigation';
 import { GrowiSubNavigation } from './GrowiSubNavigation';
@@ -45,22 +44,22 @@ import AuthorInfoStyles from './AuthorInfo.module.scss';
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
 
 
 
 
-const AuthorInfoSkelton = () => <Skelton additionalClass={`${AuthorInfoStyles['grw-author-info-skelton']} py-1`} />;
+const AuthorInfoSkeleton = () => <Skeleton additionalClass={`${AuthorInfoStyles['grw-author-info-skeleton']} py-1`} />;
 
 
 
 
 const PageEditorModeManager = dynamic(
 const PageEditorModeManager = dynamic(
   () => import('./PageEditorModeManager'),
   () => import('./PageEditorModeManager'),
-  { ssr: false, loading: () => <Skelton additionalClass={`${PageEditorModeManagerStyles['grw-page-editor-mode-manager-skelton']}`} /> },
+  { ssr: false, loading: () => <Skeleton additionalClass={`${PageEditorModeManagerStyles['grw-page-editor-mode-manager-skeleton']}`} /> },
 );
 );
 // TODO: If enable skeleton, we get hydration error when create a page from PageCreateModal
 // TODO: If enable skeleton, we get hydration error when create a page from PageCreateModal
-// { ssr: false, loading: () => <Skelton additionalClass='btn-skelton py-2' /> },
+// { ssr: false, loading: () => <Skeleton additionalClass='btn-skeleton py-2' /> },
 const SubNavButtons = dynamic<SubNavButtonsProps>(
 const SubNavButtons = dynamic<SubNavButtonsProps>(
   () => import('./SubNavButtons').then(mod => mod.SubNavButtons),
   () => import('./SubNavButtons').then(mod => mod.SubNavButtons),
   { ssr: false, loading: () => <></> },
   { ssr: false, loading: () => <></> },
 );
 );
 const AuthorInfo = dynamic<AuthorInfoProps>(() => import('./AuthorInfo').then(mod => mod.AuthorInfo), {
 const AuthorInfo = dynamic<AuthorInfoProps>(() => import('./AuthorInfo').then(mod => mod.AuthorInfo), {
   ssr: false,
   ssr: false,
-  loading: AuthorInfoSkelton,
+  loading: AuthorInfoSkeleton,
 });
 });
 
 
 type PageOperationMenuItemsProps = {
 type PageOperationMenuItemsProps = {
@@ -229,10 +228,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
 
   useEffect(() => {
   useEffect(() => {
     if (pageId === null && templateTagData != null) {
     if (pageId === null && templateTagData != null) {
-      const tags = templateTagData.split(',').filter((str: string) => {
-        return str !== ''; // filter empty values
-      });
-      mutatePageTagsForEditors(tags);
+      mutatePageTagsForEditors(templateTagData);
     }
     }
   }, [pageId, mutatePageTagsForEditors, templateTagData, mutateSWRTagsInfo]);
   }, [pageId, mutatePageTagsForEditors, templateTagData, mutateSWRTagsInfo]);
 
 
@@ -387,13 +383,13 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
               <li className="pb-1">
               <li className="pb-1">
                 { currentPage != null
                 { currentPage != null
                   ? <AuthorInfo user={currentPage.creator as IUser} date={currentPage.createdAt} mode="create" locate="subnav" />
                   ? <AuthorInfo user={currentPage.creator as IUser} date={currentPage.createdAt} mode="create" locate="subnav" />
-                  : <AuthorInfoSkelton />
+                  : <AuthorInfoSkeleton />
                 }
                 }
               </li>
               </li>
               <li className="mt-1 pt-1 border-top">
               <li className="mt-1 pt-1 border-top">
                 { currentPage != null
                 { currentPage != null
                   ? <AuthorInfo user={currentPage.lastUpdateUser as IUser} date={currentPage.updatedAt} mode="update" locate="subnav" />
                   ? <AuthorInfo user={currentPage.lastUpdateUser as IUser} date={currentPage.updatedAt} mode="update" locate="subnav" />
-                  : <AuthorInfoSkelton />
+                  : <AuthorInfoSkeleton />
                 }
                 }
               </li>
               </li>
             </ul>
             </ul>

+ 1 - 1
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -59,7 +59,7 @@ const NavbarRight = memo((): JSX.Element => {
             onClick={() => openCreateModal(currentPagePath || '')}
             onClick={() => openCreateModal(currentPagePath || '')}
           >
           >
             <i className="icon-pencil mr-2"></i>
             <i className="icon-pencil mr-2"></i>
-            <span className="d-none d-lg-block">{ t('New') }</span>
+            <span className="d-none d-lg-block">{ t('commons:New') }</span>
           </button>
           </button>
         </li>
         </li>
 
 

+ 2 - 2
packages/app/src/components/Navbar/GrowiSubNavigation.module.scss

@@ -59,7 +59,7 @@
       }
       }
     }
     }
 
 
-    .btn-skelton {
+    .btn-skeleton {
       @extend %subnav-buttons-height;
       @extend %subnav-buttons-height;
       width: 100%;
       width: 100%;
     }
     }
@@ -140,7 +140,7 @@
         min-height: 90px;
         min-height: 90px;
       }
       }
 
 
-      .btn-skelton {
+      .btn-skeleton {
         @extend %compact-subnav-buttons-height;
         @extend %compact-subnav-buttons-height;
         width: 100%;
         width: 100%;
       }
       }

+ 3 - 3
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -6,7 +6,7 @@ import {
   EditorMode, useEditorMode,
   EditorMode, useEditorMode,
 } from '~/stores/ui';
 } from '~/stores/ui';
 
 
-import { TagLabelsSkelton } from '../Page/TagLabels';
+import { TagLabelsSkeleton } from '../Page/TagLabels';
 import PagePathNav from '../PagePathNav';
 import PagePathNav from '../PagePathNav';
 
 
 import DrawerToggler from './DrawerToggler';
 import DrawerToggler from './DrawerToggler';
@@ -17,7 +17,7 @@ import styles from './GrowiSubNavigation.module.scss';
 
 
 const TagLabels = dynamic(() => import('../Page/TagLabels').then(mod => mod.TagLabels), {
 const TagLabels = dynamic(() => import('../Page/TagLabels').then(mod => mod.TagLabels), {
   ssr: false,
   ssr: false,
-  loading: TagLabelsSkelton,
+  loading: TagLabelsSkeleton,
 });
 });
 
 
 
 
@@ -71,7 +71,7 @@ export const GrowiSubNavigation = (props: GrowiSubNavigationProps): JSX.Element
             <div className="grw-taglabels-container">
             <div className="grw-taglabels-container">
               { tags != null
               { tags != null
                 ? <TagLabels tags={tags} isGuestUser={isGuestUser ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
                 ? <TagLabels tags={tags} isGuestUser={isGuestUser ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
-                : <TagLabelsSkelton />
+                : <TagLabelsSkeleton />
               }
               }
             </div>
             </div>
           ) }
           ) }

+ 1 - 1
packages/app/src/components/Navbar/PageEditorModeManager.jsx

@@ -27,7 +27,7 @@ const PageEditorModeButtonWrapper = React.memo(({
       className={classNames.join(' ')}
       className={classNames.join(' ')}
       onClick={() => { onClick(targetMode) }}
       onClick={() => { onClick(targetMode) }}
       id={id}
       id={id}
-      data-testId={`${targetMode}-button`}
+      data-testid={`${targetMode}-button`}
     >
     >
       <span className="d-flex flex-column flex-md-row justify-content-center">
       <span className="d-flex flex-column flex-md-row justify-content-center">
         <span className="grw-page-editor-mode-manager-icon mr-md-1">{icon}</span>
         <span className="grw-page-editor-mode-manager-icon mr-md-1">{icon}</span>

+ 1 - 1
packages/app/src/components/Navbar/PageEditorModeManager.module.scss

@@ -35,7 +35,7 @@ $btn-line-height: 1.2rem;
   }
   }
 }
 }
 
 
-.grw-page-editor-mode-manager-skelton :global {
+.grw-page-editor-mode-manager-skeleton :global {
 
 
   width: 213px;
   width: 213px;
   height: calc($btn-line-height + bs.$btn-padding-y*2 + bs.$btn-border-width*2);
   height: calc($btn-line-height + bs.$btn-padding-y*2 + bs.$btn-border-width*2);

+ 3 - 3
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -39,7 +39,7 @@ const PersonalDropdown = () => {
       </button>
       </button>
 
 
       {/* Menu */}
       {/* Menu */}
-      <div className="dropdown-menu dropdown-menu-right">
+      <div className="dropdown-menu dropdown-menu-right" data-testid="personal-dropdown-menu">
 
 
         <div className="px-4 pt-3 pb-2 text-center">
         <div className="px-4 pt-3 pb-2 text-center">
           <UserPicture user={user} size="lg" noLink noTooltip />
           <UserPicture user={user} size="lg" noLink noTooltip />
@@ -55,12 +55,12 @@ const PersonalDropdown = () => {
 
 
           <div className="btn-group btn-block mt-2" role="group">
           <div className="btn-group btn-block mt-2" role="group">
             <Link href={`/user/${user.username}`}>
             <Link href={`/user/${user.username}`}>
-              <a className="btn btn-sm btn-outline-secondary col">
+              <a className="btn btn-sm btn-outline-secondary col" data-testid="grw-personal-dropdown-menu-user-home">
                 <i className="icon-fw icon-home"></i>{t('personal_dropdown.home')}
                 <i className="icon-fw icon-home"></i>{t('personal_dropdown.home')}
               </a>
               </a>
             </Link>
             </Link>
             <Link href="/me">
             <Link href="/me">
-              <a className="btn btn-sm btn-outline-secondary col">
+              <a className="btn btn-sm btn-outline-secondary col" data-testid="grw-personal-dropdown-menu-user-settings">
                 <i className="icon-fw icon-wrench"></i>{t('personal_dropdown.settings')}
                 <i className="icon-fw icon-wrench"></i>{t('personal_dropdown.settings')}
               </a>
               </a>
             </Link>
             </Link>

+ 0 - 68
packages/app/src/components/Page.tsx

@@ -28,16 +28,13 @@ import loggerFactory from '~/utils/logger';
 
 
 import RevisionRenderer from './Page/RevisionRenderer';
 import RevisionRenderer from './Page/RevisionRenderer';
 import { DrawioModal } from './PageEditor/DrawioModal';
 import { DrawioModal } from './PageEditor/DrawioModal';
-// import MarkdownTable from '~/client/models/MarkdownTable';
 import mdu from './PageEditor/MarkdownDrawioUtil';
 import mdu from './PageEditor/MarkdownDrawioUtil';
-import mtu from './PageEditor/MarkdownTableUtil';
 
 
 
 
 declare const globalEmitter: EventEmitter;
 declare const globalEmitter: EventEmitter;
 
 
 // const DrawioModal = dynamic(() => import('./PageEditor/DrawioModal'), { ssr: false });
 // const DrawioModal = dynamic(() => import('./PageEditor/DrawioModal'), { ssr: false });
 const GridEditModal = dynamic(() => import('./PageEditor/GridEditModal'), { ssr: false });
 const GridEditModal = dynamic(() => import('./PageEditor/GridEditModal'), { ssr: false });
-const HandsontableModal = dynamic(() => import('./PageEditor/HandsontableModal'), { ssr: false });
 const LinkEditModal = dynamic(() => import('./PageEditor/LinkEditModal'), { ssr: false });
 const LinkEditModal = dynamic(() => import('./PageEditor/LinkEditModal'), { ssr: false });
 
 
 const logger = loggerFactory('growi:Page');
 const logger = loggerFactory('growi:Page');
@@ -59,8 +56,6 @@ class PageSubstance extends React.Component<PageSubstanceProps> {
 
 
   linkEditModal: any;
   linkEditModal: any;
 
 
-  handsontableModal: any;
-
   drawioModal: any;
   drawioModal: any;
 
 
   constructor(props: PageSubstanceProps) {
   constructor(props: PageSubstanceProps) {
@@ -73,25 +68,11 @@ class PageSubstance extends React.Component<PageSubstanceProps> {
 
 
     this.gridEditModal = React.createRef();
     this.gridEditModal = React.createRef();
     this.linkEditModal = React.createRef();
     this.linkEditModal = React.createRef();
-    this.handsontableModal = React.createRef();
     this.drawioModal = React.createRef();
     this.drawioModal = React.createRef();
 
 
-    this.saveHandlerForHandsontableModal = this.saveHandlerForHandsontableModal.bind(this);
     this.saveHandlerForDrawioModal = this.saveHandlerForDrawioModal.bind(this);
     this.saveHandlerForDrawioModal = this.saveHandlerForDrawioModal.bind(this);
   }
   }
 
 
-  /**
-   * launch HandsontableModal with data specified by arguments
-   * @param beginLineNumber
-   * @param endLineNumber
-   */
-  launchHandsontableModal(beginLineNumber, endLineNumber) {
-    // const markdown = this.props.pageContainer.state.markdown;
-    // const tableLines = markdown.split(/\r\n|\r|\n/).slice(beginLineNumber - 1, endLineNumber).join('\n');
-    // this.setState({ currentTargetTableArea: { beginLineNumber, endLineNumber } });
-    // this.handsontableModal.current.show(MarkdownTable.fromMarkdownString(tableLines));
-  }
-
   /**
   /**
    * launch DrawioModal with data specified by arguments
    * launch DrawioModal with data specified by arguments
    * @param beginLineNumber
    * @param beginLineNumber
@@ -105,39 +86,6 @@ class PageSubstance extends React.Component<PageSubstanceProps> {
     // this.drawioModal.current.show(drawioData);
     // this.drawioModal.current.show(drawioData);
   }
   }
 
 
-  async saveHandlerForHandsontableModal(markdownTable) {
-    // const {
-    //   isSlackEnabled, slackChannels, pageContainer, mutateIsEnabledUnsavedWarning, grant, grantGroupId, grantGroupName, pageTags,
-    // } = this.props;
-    // const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
-
-    // const newMarkdown = mtu.replaceMarkdownTableInMarkdown(
-    //   markdownTable,
-    //   this.props.pageContainer.state.markdown,
-    //   this.state.currentTargetTableArea.beginLineNumber,
-    //   this.state.currentTargetTableArea.endLineNumber,
-    // );
-
-    // try {
-    //   // disable unsaved warning
-    //   mutateIsEnabledUnsavedWarning(false);
-
-    //   // eslint-disable-next-line no-unused-vars
-    //   const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
-    //   logger.debug('success to save');
-
-    // // Todo: add translation
-    // toastSuccess(t(''));
-    // }
-    // catch (error) {
-    //   logger.error('failed to save', error);
-    // toastError(error);
-    // }
-    // finally {
-    //   this.setState({ currentTargetTableArea: null });
-    // }
-  }
-
   async saveHandlerForDrawioModal(drawioData) {
   async saveHandlerForDrawioModal(drawioData) {
   //   const {
   //   const {
   //     isSlackEnabled, slackChannels, pageContainer, pageTags, grant, grantGroupId, grantGroupName, mutateIsEnabledUnsavedWarning,
   //     isSlackEnabled, slackChannels, pageContainer, pageTags, grant, grantGroupId, grantGroupName, mutateIsEnabledUnsavedWarning,
@@ -189,7 +137,6 @@ class PageSubstance extends React.Component<PageSubstanceProps> {
           <>
           <>
             <GridEditModal ref={this.gridEditModal} />
             <GridEditModal ref={this.gridEditModal} />
             <LinkEditModal ref={this.linkEditModal} />
             <LinkEditModal ref={this.linkEditModal} />
-            <HandsontableModal ref={this.handsontableModal} onSave={this.saveHandlerForHandsontableModal} />
             {/* TODO: use global DrawioModal https://redmine.weseek.co.jp/issues/105981 */}
             {/* TODO: use global DrawioModal https://redmine.weseek.co.jp/issues/105981 */}
             {/* <DrawioModal
             {/* <DrawioModal
               ref={this.drawioModal}
               ref={this.drawioModal}
@@ -245,20 +192,6 @@ export const Page = (props) => {
   //   };
   //   };
   // }, []);
   // }, []);
 
 
-  // // set handler to open HandsontableModal
-  // useEffect(() => {
-  //   const handler = (beginLineNumber, endLineNumber) => {
-  //     if (pageRef?.current != null) {
-  //       pageRef.current.launchHandsontableModal(beginLineNumber, endLineNumber);
-  //     }
-  //   };
-  //   window.globalEmitter.on('launchHandsontableModal', handler);
-
-  //   return function cleanup() {
-  //     window.globalEmitter.removeListener('launchHandsontableModal', handler);
-  //   };
-  // }, []);
-
   if (currentPage == null || editorMode == null || isGuestUser == null || rendererOptions == null) {
   if (currentPage == null || editorMode == null || isGuestUser == null || rendererOptions == null) {
     const entries = Object.entries({
     const entries = Object.entries({
       currentPage, editorMode, isGuestUser, rendererOptions,
       currentPage, editorMode, isGuestUser, rendererOptions,
@@ -270,7 +203,6 @@ export const Page = (props) => {
     return null;
     return null;
   }
   }
 
 
-
   return (
   return (
     <PageSubstance
     <PageSubstance
       {...props}
       {...props}

+ 1 - 1
packages/app/src/components/Page/TagLabels.module.scss

@@ -11,7 +11,7 @@ $grw-tag-label-font-size: 12px;
 }
 }
 
 
 
 
-.grw-tag-labels-skelton :global {
+.grw-tag-labels-skeleton :global {
   width: 137px;
   width: 137px;
   height: calc(#{$grw-tag-label-font-size} + #{bs.$badge-padding-y} * 2);
   height: calc(#{$grw-tag-label-font-size} + #{bs.$badge-padding-y} * 2);
   font-size: $grw-tag-label-font-size; // set font-size to use the same em value in bs.$badge-padding-y(https://getbootstrap.jp/docs/5.0/components/badge/#variables)
   font-size: $grw-tag-label-font-size; // set font-size to use the same em value in bs.$badge-padding-y(https://getbootstrap.jp/docs/5.0/components/badge/#variables)

+ 4 - 4
packages/app/src/components/Page/TagLabels.tsx

@@ -1,6 +1,6 @@
 import React, { FC, useState } from 'react';
 import React, { FC, useState } from 'react';
 
 
-import { Skelton } from '../Skelton';
+import { Skeleton } from '../Skeleton';
 
 
 import RenderTagLabels from './RenderTagLabels';
 import RenderTagLabels from './RenderTagLabels';
 import TagEditModal from './TagEditModal';
 import TagEditModal from './TagEditModal';
@@ -13,8 +13,8 @@ type Props = {
   tagsUpdateInvoked?: (tags: string[]) => Promise<void> | void,
   tagsUpdateInvoked?: (tags: string[]) => Promise<void> | void,
 }
 }
 
 
-export const TagLabelsSkelton = (): JSX.Element => {
-  return <Skelton additionalClass={`${styles['grw-tag-labels-skelton']} py-1`} />;
+export const TagLabelsSkeleton = (): JSX.Element => {
+  return <Skeleton additionalClass={`${styles['grw-tag-labels-skeleton']} py-1`} />;
 };
 };
 
 
 export const TagLabels:FC<Props> = (props: Props) => {
 export const TagLabels:FC<Props> = (props: Props) => {
@@ -31,7 +31,7 @@ export const TagLabels:FC<Props> = (props: Props) => {
   };
   };
 
 
   if (tags == null) {
   if (tags == null) {
-    return <TagLabelsSkelton />;
+    return <TagLabelsSkeleton />;
   }
   }
 
 
   return (
   return (

+ 2 - 2
packages/app/src/components/PageAccessoriesModal.tsx

@@ -57,7 +57,7 @@ const PageAccessoriesModal = (): JSX.Element => {
           if (!isOpened) {
           if (!isOpened) {
             return <></>;
             return <></>;
           }
           }
-          return <PageHistory />;
+          return <PageHistory onClose={close}/>;
         },
         },
         i18n: t('History'),
         i18n: t('History'),
         index: 0,
         index: 0,
@@ -87,7 +87,7 @@ const PageAccessoriesModal = (): JSX.Element => {
         isLinkEnabled: () => !isGuestUser && !isSharedUser && !isLinkSharingDisabled,
         isLinkEnabled: () => !isGuestUser && !isSharedUser && !isLinkSharingDisabled,
       },
       },
     };
     };
-  }, [status, t, isGuestUser, isSharedUser, isLinkSharingDisabled]);
+  }, [status, t, close, isGuestUser, isSharedUser, isLinkSharingDisabled]);
 
 
   const buttons = useMemo(() => (
   const buttons = useMemo(() => (
     <div className="d-flex flex-nowrap">
     <div className="d-flex flex-nowrap">

+ 6 - 3
packages/app/src/components/PageAlert/OldRevisionAlert.tsx

@@ -1,5 +1,6 @@
 import React from 'react';
 import React from 'react';
 
 
+import { pathUtils } from '@growi/core';
 import Link from 'next/link';
 import Link from 'next/link';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
@@ -12,15 +13,17 @@ export const OldRevisionAlert = (): JSX.Element => {
   const { data: isLatestRevision } = useIsLatestRevision();
   const { data: isLatestRevision } = useIsLatestRevision();
   const { data: page } = useSWRxCurrentPage();
   const { data: page } = useSWRxCurrentPage();
 
 
+  const { returnPathForURL } = pathUtils;
+
   if (page == null || isLatestRevision == null || isLatestRevision) {
   if (page == null || isLatestRevision == null || isLatestRevision) {
     return <></>;
     return <></>;
   }
   }
 
 
   return (
   return (
     <div className="alert alert-warning">
     <div className="alert alert-warning">
-      <strong>{ t('Warning') }: </strong> { t('page_page.notice.version') }
-      <Link href={`/${page._id}`}>
-        <a><i className="icon-fw icon-arrow-right-circle"></i>{ t('Show latest') }</a>
+      <strong>{t('Warning')}: </strong> {t('page_page.notice.version')}
+      <Link href={returnPathForURL(page.path, page._id)}>
+        <a><i className="icon-fw icon-arrow-right-circle"></i>{t('Show latest')}</a>
       </Link>
       </Link>
     </div>
     </div>
   );
   );

+ 1 - 1
packages/app/src/components/PageComment.module.scss

@@ -14,7 +14,7 @@
   }
   }
 
 
   // TODO: Refacotr Soft-coding
   // TODO: Refacotr Soft-coding
-  .page-comment-button-skelton {
+  .page-comment-button-skeleton {
     width: 70.0167px;
     width: 70.0167px;
     height: 26.3833px;
     height: 26.3833px;
     margin-top: 0.5em;
     margin-top: 0.5em;

+ 10 - 11
packages/app/src/components/PageComment.tsx

@@ -3,7 +3,6 @@ import React, {
 } from 'react';
 } from 'react';
 
 
 import { IRevisionHasId, isPopulated, getIdForRef } from '@growi/core';
 import { IRevisionHasId, isPopulated, getIdForRef } from '@growi/core';
-import dynamic from 'next/dynamic';
 import { Button } from 'reactstrap';
 import { Button } from 'reactstrap';
 
 
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
@@ -15,18 +14,12 @@ import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
 import { useSWRxPageComment } from '../stores/comment';
 import { useSWRxPageComment } from '../stores/comment';
 
 
 import { Comment } from './PageComment/Comment';
 import { Comment } from './PageComment/Comment';
-import { CommentEditorProps } from './PageComment/CommentEditor';
-import { DeleteCommentModalProps } from './PageComment/DeleteCommentModal';
+import { CommentEditor } from './PageComment/CommentEditor';
+import { DeleteCommentModal } from './PageComment/DeleteCommentModal';
 import { ReplyComments } from './PageComment/ReplyComments';
 import { ReplyComments } from './PageComment/ReplyComments';
-import { PageCommentSkelton } from './PageCommentSkelton';
 
 
 import styles from './PageComment.module.scss';
 import styles from './PageComment.module.scss';
 
 
-const CommentEditor = dynamic<CommentEditorProps>(() => import('./PageComment/CommentEditor').then(mod => mod.CommentEditor), { ssr: false });
-const DeleteCommentModal = dynamic<DeleteCommentModalProps>(
-  () => import('./PageComment/DeleteCommentModal').then(mod => mod.DeleteCommentModal), { ssr: false },
-);
-
 export const ROOT_ELEM_ID = 'page-comments' as const;
 export const ROOT_ELEM_ID = 'page-comments' as const;
 
 
 // Always render '#page-comments' for MutationObserver of SearchResultContent
 // Always render '#page-comments' for MutationObserver of SearchResultContent
@@ -38,6 +31,7 @@ const PageCommentRoot = (props: React.HTMLAttributes<HTMLDivElement>): JSX.Eleme
 export type PageCommentProps = {
 export type PageCommentProps = {
   rendererOptions?: RendererOptions,
   rendererOptions?: RendererOptions,
   pageId: string,
   pageId: string,
+  pagePath: string,
   revision: string | IRevisionHasId,
   revision: string | IRevisionHasId,
   currentUser: any,
   currentUser: any,
   isReadOnly: boolean,
   isReadOnly: boolean,
@@ -49,7 +43,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
 
 
   const {
   const {
     rendererOptions: rendererOptionsByProps,
     rendererOptions: rendererOptionsByProps,
-    pageId, revision, currentUser, isReadOnly, titleAlign, hideIfEmpty,
+    pageId, pagePath, revision, currentUser, isReadOnly, titleAlign, hideIfEmpty,
   } = props;
   } = props;
 
 
   const { data: comments, mutate } = useSWRxPageComment(pageId);
   const { data: comments, mutate } = useSWRxPageComment(pageId);
@@ -123,7 +117,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
       return <PageCommentRoot />;
       return <PageCommentRoot />;
     }
     }
     return (
     return (
-      <PageCommentSkelton commentTitleClasses={commentTitleClasses}/>
+      <></>
     );
     );
   }
   }
 
 
@@ -138,6 +132,8 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
       revisionCreatedAt={revisionCreatedAt as Date}
       revisionCreatedAt={revisionCreatedAt as Date}
       currentUser={currentUser}
       currentUser={currentUser}
       isReadOnly={isReadOnly}
       isReadOnly={isReadOnly}
+      pageId={pageId}
+      pagePath={pagePath}
       deleteBtnClicked={onClickDeleteButton}
       deleteBtnClicked={onClickDeleteButton}
       onComment={mutate}
       onComment={mutate}
     />
     />
@@ -151,6 +147,8 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
       revisionCreatedAt={revisionCreatedAt as Date}
       revisionCreatedAt={revisionCreatedAt as Date}
       currentUser={currentUser}
       currentUser={currentUser}
       replyList={replyComments}
       replyList={replyComments}
+      pageId={pageId}
+      pagePath={pagePath}
       deleteBtnClicked={onClickDeleteButton}
       deleteBtnClicked={onClickDeleteButton}
       onComment={mutate}
       onComment={mutate}
     />
     />
@@ -200,6 +198,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
                         removeShowEditorId(comment._id);
                         removeShowEditorId(comment._id);
                         mutate();
                         mutate();
                       }}
                       }}
+                      revisionId={revisionId}
                     />
                     />
                   )}
                   )}
                 </div>
                 </div>

+ 1 - 1
packages/app/src/components/PageComment/Comment.module.scss

@@ -86,7 +86,7 @@
   }
   }
 
 
   // TODO: Refacotr Soft-coding
   // TODO: Refacotr Soft-coding
-  .page-comment-comment-body-skelton {
+  .page-comment-comment-body-skeleton {
     position: relative;
     position: relative;
     height: 66px;
     height: 66px;
     padding: 1em;
     padding: 1em;

+ 21 - 13
packages/app/src/components/PageComment/Comment.tsx

@@ -1,11 +1,12 @@
 import React, { useEffect, useMemo, useState } from 'react';
 import React, { useEffect, useMemo, useState } from 'react';
 
 
-import { IUser } from '@growi/core';
+import { IUser, pathUtils } from '@growi/core';
 import { UserPicture } from '@growi/ui';
 import { UserPicture } from '@growi/ui';
 import { format } from 'date-fns';
 import { format } from 'date-fns';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import dynamic from 'next/dynamic';
+import Link from 'next/link';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
+import urljoin from 'url-join';
 
 
 import { RendererOptions } from '~/services/renderer/renderer';
 import { RendererOptions } from '~/services/renderer/renderer';
 
 
@@ -16,12 +17,10 @@ import RevisionRenderer from '../Page/RevisionRenderer';
 import { Username } from '../User/Username';
 import { Username } from '../User/Username';
 
 
 import { CommentControl } from './CommentControl';
 import { CommentControl } from './CommentControl';
-import { CommentEditorProps } from './CommentEditor';
+import { CommentEditor } from './CommentEditor';
 
 
 import styles from './Comment.module.scss';
 import styles from './Comment.module.scss';
 
 
-const CommentEditor = dynamic<CommentEditorProps>(() => import('./CommentEditor').then(mod => mod.CommentEditor), { ssr: false });
-
 type CommentProps = {
 type CommentProps = {
   comment: ICommentHasId,
   comment: ICommentHasId,
   rendererOptions: RendererOptions,
   rendererOptions: RendererOptions,
@@ -29,6 +28,8 @@ type CommentProps = {
   revisionCreatedAt: Date,
   revisionCreatedAt: Date,
   currentUser: IUser,
   currentUser: IUser,
   isReadOnly: boolean,
   isReadOnly: boolean,
+  pageId: string,
+  pagePath: string,
   deleteBtnClicked: (comment: ICommentHasId) => void,
   deleteBtnClicked: (comment: ICommentHasId) => void,
   onComment: () => void,
   onComment: () => void,
 }
 }
@@ -37,9 +38,11 @@ export const Comment = (props: CommentProps): JSX.Element => {
 
 
   const {
   const {
     comment, rendererOptions, revisionId, revisionCreatedAt, currentUser, isReadOnly,
     comment, rendererOptions, revisionId, revisionCreatedAt, currentUser, isReadOnly,
-    deleteBtnClicked, onComment,
+    pageId, pagePath, deleteBtnClicked, onComment,
   } = props;
   } = props;
 
 
+  const { returnPathForURL } = pathUtils;
+
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const [markdown, setMarkdown] = useState('');
   const [markdown, setMarkdown] = useState('');
@@ -121,7 +124,7 @@ export const Comment = (props: CommentProps): JSX.Element => {
   }, [comment, isMarkdown, markdown, rendererOptions]);
   }, [comment, isMarkdown, markdown, rendererOptions]);
 
 
   const rootClassName = getRootClassName(comment);
   const rootClassName = getRootClassName(comment);
-  const revHref = `?revision=${comment.revision}`;
+  const revHref = `?revisionId=${comment.revision}`;
   const editedDateId = `editedDate-${comment._id}`;
   const editedDateId = `editedDate-${comment._id}`;
   const editedDateFormatted = isEdited ? format(updatedAt, 'yyyy/MM/dd HH:mm') : null;
   const editedDateFormatted = isEdited ? format(updatedAt, 'yyyy/MM/dd HH:mm') : null;
 
 
@@ -138,6 +141,7 @@ export const Comment = (props: CommentProps): JSX.Element => {
             setIsReEdit(false);
             setIsReEdit(false);
             if (onComment != null) onComment();
             if (onComment != null) onComment();
           }}
           }}
+          revisionId={revisionId}
         />
         />
       ) : (
       ) : (
         <div id={commentId} className={rootClassName}>
         <div id={commentId} className={rootClassName}>
@@ -150,9 +154,11 @@ export const Comment = (props: CommentProps): JSX.Element => {
             </div>
             </div>
             <div className="page-comment-body">{commentBody}</div>
             <div className="page-comment-body">{commentBody}</div>
             <div className="page-comment-meta">
             <div className="page-comment-meta">
-              <a href={`#${commentId}`}>
-                <FormattedDistanceDate id={commentId} date={comment.createdAt} />
-              </a>
+              <Link href={`#${commentId}`} prefetch={false}>
+                <a>
+                  <FormattedDistanceDate id={commentId} date={comment.createdAt} />
+                </a>
+              </Link>
               { isEdited && (
               { isEdited && (
                 <>
                 <>
                   <span id={editedDateId}>&nbsp;(edited)</span>
                   <span id={editedDateId}>&nbsp;(edited)</span>
@@ -160,9 +166,11 @@ export const Comment = (props: CommentProps): JSX.Element => {
                 </>
                 </>
               ) }
               ) }
               <span className="ml-2">
               <span className="ml-2">
-                <a id={`page-comment-revision-${commentId}`} className="page-comment-revision" href={revHref}>
-                  <HistoryIcon />
-                </a>
+                <Link href={urljoin(returnPathForURL(pagePath, pageId), revHref)} prefetch={false}>
+                  <a id={`page-comment-revision-${commentId}`} className="page-comment-revision">
+                    <HistoryIcon />
+                  </a>
+                </Link>
                 <UncontrolledTooltip placement="bottom" fade={false} target={`page-comment-revision-${commentId}`}>
                 <UncontrolledTooltip placement="bottom" fade={false} target={`page-comment-revision-${commentId}`}>
                   {t('page_comment.display_the_page_when_posting_this_comment')}
                   {t('page_comment.display_the_page_when_posting_this_comment')}
                 </UncontrolledTooltip>
                 </UncontrolledTooltip>

+ 3 - 3
packages/app/src/components/PageComment/CommentEditor.tsx

@@ -13,7 +13,7 @@ import { apiPostForm } from '~/client/util/apiv1-client';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import { useSWRxPageComment } from '~/stores/comment';
 import { useSWRxPageComment } from '~/stores/comment';
 import {
 import {
-  useCurrentUser, useRevisionId, useIsSlackConfigured,
+  useCurrentUser, useIsSlackConfigured,
   useIsUploadableFile, useIsUploadableImage,
   useIsUploadableFile, useIsUploadableImage,
 } from '~/stores/context';
 } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
@@ -49,6 +49,7 @@ export type CommentEditorProps = {
   pageId: string,
   pageId: string,
   isForNewComment?: boolean,
   isForNewComment?: boolean,
   replyTo?: string,
   replyTo?: string,
+  revisionId: string,
   currentCommentId?: string,
   currentCommentId?: string,
   commentBody?: string,
   commentBody?: string,
   onCancelButtonClicked?: () => void,
   onCancelButtonClicked?: () => void,
@@ -59,14 +60,13 @@ export type CommentEditorProps = {
 export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
 export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
 
 
   const {
   const {
-    pageId, isForNewComment, replyTo,
+    pageId, isForNewComment, replyTo, revisionId,
     currentCommentId, commentBody, onCancelButtonClicked, onCommentButtonClicked,
     currentCommentId, commentBody, onCancelButtonClicked, onCommentButtonClicked,
   } = props;
   } = props;
 
 
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { update: updateComment, post: postComment } = useSWRxPageComment(pageId);
   const { update: updateComment, post: postComment } = useSWRxPageComment(pageId);
-  const { data: revisionId } = useRevisionId();
   const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
   const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackConfigured } = useIsSlackConfigured();
   const { data: isSlackConfigured } = useIsSlackConfigured();

+ 5 - 1
packages/app/src/components/PageComment/ReplyComments.tsx

@@ -21,6 +21,8 @@ type ReplycommentsProps = {
   revisionCreatedAt: Date,
   revisionCreatedAt: Date,
   currentUser: IUser,
   currentUser: IUser,
   replyList: ICommentHasIdList,
   replyList: ICommentHasIdList,
+  pageId: string,
+  pagePath: string,
   deleteBtnClicked: (comment: ICommentHasId) => void,
   deleteBtnClicked: (comment: ICommentHasId) => void,
   onComment: () => void,
   onComment: () => void,
 }
 }
@@ -29,7 +31,7 @@ export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
 
 
   const {
   const {
     rendererOptions, isReadOnly, revisionId, revisionCreatedAt, currentUser, replyList,
     rendererOptions, isReadOnly, revisionId, revisionCreatedAt, currentUser, replyList,
-    deleteBtnClicked, onComment,
+    pageId, pagePath, deleteBtnClicked, onComment,
   } = props;
   } = props;
 
 
   const { data: isAllReplyShown } = useIsAllReplyShown();
   const { data: isAllReplyShown } = useIsAllReplyShown();
@@ -46,6 +48,8 @@ export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
           revisionCreatedAt={revisionCreatedAt}
           revisionCreatedAt={revisionCreatedAt}
           currentUser={currentUser}
           currentUser={currentUser}
           isReadOnly={isReadOnly}
           isReadOnly={isReadOnly}
+          pageId={pageId}
+          pagePath={pagePath}
           deleteBtnClicked={deleteBtnClicked}
           deleteBtnClicked={deleteBtnClicked}
           onComment={onComment}
           onComment={onComment}
         />
         />

+ 0 - 58
packages/app/src/components/PageCommentSkelton.tsx

@@ -1,58 +0,0 @@
-import React from 'react';
-
-import { Skelton } from './Skelton';
-
-import styles from './PageComment.module.scss';
-import CommentStyles from './PageComment/Comment.module.scss';
-import CommentEditorStyles from './PageComment/CommentEditor.module.scss';
-
-type PageCommentSkeltonProps = {
-  commentTitleClasses?: string,
-  roundedPill?: boolean,
-}
-
-export const PageCommentSkelton = (props: PageCommentSkeltonProps): JSX.Element => {
-  const {
-    commentTitleClasses,
-  } = props;
-
-  return (
-    <>
-      {/* TODO: Check the comment.html CSS */}
-      <div className={`${styles['page-comment-styles']} page-comments-row comment-list`}>
-        <div className="container-lg">
-          <div className="page-comments">
-            <h2 className={commentTitleClasses}><i className="icon-fw icon-bubbles"></i>Comments</h2>
-            <div className="page-comments-list" id="page-comments-list">
-              <div className={`${CommentStyles['comment-styles']} page-comment-thread pb-5  page-comment-thread-no-replies`}>
-                <div className='page-comment flex-column'>
-                  <div className='page-commnet-writer'>
-                    <Skelton additionalClass='rounded-circle picture' roundedPill />
-                  </div>
-                  <Skelton additionalClass="page-comment-comment-body-skelton grw-skelton" />
-                </div>
-                <div className='page-comment flex-column ml-4 ml-sm-5 mr-3'>
-                  <div className='page-commnet-writer mt-3'>
-                    <Skelton additionalClass='rounded-circle picture' roundedPill />
-                  </div>
-                  <Skelton additionalClass="page-comment-comment-body-skelton grw-skelton mt-3" />
-                </div>
-                <div className="text-right">
-                  <Skelton additionalClass="page-comment-button-skelton btn btn-outline-secondary btn-sm grw-skelton" />
-                </div>
-              </div>
-            </div>
-            <div className={`${CommentEditorStyles['comment-editor-styles']} form page-comment-form`}>
-              <div className='comment-form'>
-                <div className='comment-form-user'>
-                  <Skelton additionalClass='rounded-circle picture' roundedPill />
-                </div>
-                <Skelton additionalClass="page-comment-commenteditorlazyrenderer-body-skelton grw-skelton" />
-              </div>
-            </div>
-          </div>
-        </div>
-      </div>
-    </>
-  );
-};

+ 1 - 1
packages/app/src/components/PageContentFooter.module.scss

@@ -7,7 +7,7 @@
   }
   }
 }
 }
 // TODO: Should Soft Coding see: https://github.com/weseek/growi/pull/6404
 // TODO: Should Soft Coding see: https://github.com/weseek/growi/pull/6404
-.page-content-footer-skelton :global {
+.page-content-footer-skeleton :global {
   width: 300px;
   width: 300px;
   height: 20px;
   height: 20px;
 }
 }

+ 1 - 5
packages/app/src/components/PageContentFooter.tsx

@@ -6,14 +6,10 @@ import dynamic from 'next/dynamic';
 import { useSWRxCurrentPage } from '~/stores/page';
 import { useSWRxCurrentPage } from '~/stores/page';
 
 
 import type { AuthorInfoProps } from './Navbar/AuthorInfo';
 import type { AuthorInfoProps } from './Navbar/AuthorInfo';
-import { Skelton } from './Skelton';
 
 
 import styles from './PageContentFooter.module.scss';
 import styles from './PageContentFooter.module.scss';
 
 
-const AuthorInfo = dynamic<AuthorInfoProps>(() => import('./Navbar/AuthorInfo').then(mod => mod.AuthorInfo), {
-  ssr: false,
-  loading: () => <Skelton additionalClass={`${styles['page-content-footer-skelton']} mb-3`} />,
-});
+const AuthorInfo = dynamic<AuthorInfoProps>(() => import('./Navbar/AuthorInfo').then(mod => mod.AuthorInfo), { ssr: false });
 
 
 export type PageContentFooterProps = {
 export type PageContentFooterProps = {
   page: IPage,
   page: IPage,

+ 17 - 13
packages/app/src/components/PageCreateModal.jsx

@@ -6,7 +6,9 @@ import { pagePathUtils, pathUtils } from '@growi/core';
 import { format } from 'date-fns';
 import { format } from 'date-fns';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
-import { Modal, ModalHeader, ModalBody } from 'reactstrap';
+import {
+  Modal, ModalHeader, ModalBody, UncontrolledButtonDropdown, DropdownToggle, DropdownMenu, DropdownItem,
+} from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
 
 
@@ -265,34 +267,36 @@ const PageCreateModal = () => {
 
 
           <h3 className="grw-modal-head pb-2">
           <h3 className="grw-modal-head pb-2">
             {t('template.modal_label.Create template under')}<br />
             {t('template.modal_label.Create template under')}<br />
-            <code className="h6">{pathname}</code>
+            <code className="h6" data-testid="grw-page-create-modal-path-name">{pathname}</code>
           </h3>
           </h3>
 
 
           <div className="d-sm-flex align-items-center justify-items-between">
           <div className="d-sm-flex align-items-center justify-items-between">
 
 
-            <div id="dd-template-type" className="dropdown flex-fill">
-              <button id="template-type" type="button" className="btn btn-secondary btn dropdown-toggle w-100" data-toggle="dropdown">
+            <UncontrolledButtonDropdown id="dd-template-type" className='flex-fill text-center'>
+              <DropdownToggle id="template-type" caret>
                 {template == null && t('template.option_label.select')}
                 {template == null && t('template.option_label.select')}
                 {template === 'children' && t('template.children.label')}
                 {template === 'children' && t('template.children.label')}
                 {template === 'decendants' && t('template.decendants.label')}
                 {template === 'decendants' && t('template.decendants.label')}
-              </button>
-              <div className="dropdown-menu" aria-labelledby="userMenu">
-                <button className="dropdown-item" type="button" onClick={() => onChangeTemplateHandler('children')}>
+              </DropdownToggle>
+              <DropdownMenu>
+                <DropdownItem onClick={() => onChangeTemplateHandler('children')}>
                   {t('template.children.label')} (_template)<br className="d-block d-md-none" />
                   {t('template.children.label')} (_template)<br className="d-block d-md-none" />
                   <small className="text-muted text-wrap">- {t('template.children.desc')}</small>
                   <small className="text-muted text-wrap">- {t('template.children.desc')}</small>
-                </button>
-                <button className="dropdown-item" type="button" onClick={() => onChangeTemplateHandler('decendants')}>
+                </DropdownItem>
+                <DropdownItem onClick={() => onChangeTemplateHandler('decendants')}>
                   {t('template.decendants.label')} (__template) <br className="d-block d-md-none" />
                   {t('template.decendants.label')} (__template) <br className="d-block d-md-none" />
                   <small className="text-muted">- {t('template.decendants.desc')}</small>
                   <small className="text-muted">- {t('template.decendants.desc')}</small>
-                </button>
-              </div>
-            </div>
+                </DropdownItem>
+              </DropdownMenu>
+            </UncontrolledButtonDropdown>
 
 
             <div className="d-flex justify-content-end mt-1 mt-sm-0">
             <div className="d-flex justify-content-end mt-1 mt-sm-0">
               <button
               <button
+                data-testid="grw-btn-edit-page"
                 type="button"
                 type="button"
-                className={`grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ml-3 ${template == null && 'disabled'}`}
+                className='grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ml-3'
                 onClick={createTemplatePage}
                 onClick={createTemplatePage}
+                disabled={template == null}
               >
               >
                 <i className="icon-fw icon-doc"></i>{t('Edit')}
                 <i className="icon-fw icon-doc"></i>{t('Edit')}
               </button>
               </button>

+ 34 - 13
packages/app/src/components/PageEditor.tsx

@@ -4,9 +4,10 @@ import React, {
 
 
 import EventEmitter from 'events';
 import EventEmitter from 'events';
 
 
-import { envUtils, PageGrant } from '@growi/core';
+import { envUtils, IPageHasId, PageGrant } from '@growi/core';
 import detectIndent from 'detect-indent';
 import detectIndent from 'detect-indent';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
 import { throttle, debounce } from 'throttle-debounce';
 import { throttle, debounce } from 'throttle-debounce';
 
 
 import { saveOrUpdate } from '~/client/services/page-operation';
 import { saveOrUpdate } from '~/client/services/page-operation';
@@ -16,7 +17,7 @@ import { getOptionsToSave } from '~/client/util/editor';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import {
 import {
   useCurrentPathname, useCurrentPageId,
   useCurrentPathname, useCurrentPageId,
-  useIsEditable, useIsIndentSizeForced, useIsUploadableFile, useIsUploadableImage, useEditingMarkdown,
+  useIsEditable, useIsIndentSizeForced, useIsUploadableFile, useIsUploadableImage, useEditingMarkdown, useIsNotFound,
 } from '~/stores/context';
 } from '~/stores/context';
 import {
 import {
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
@@ -51,7 +52,10 @@ let isOriginOfScrollSyncPreview = false;
 const PageEditor = React.memo((): JSX.Element => {
 const PageEditor = React.memo((): JSX.Element => {
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { data: pageId } = useCurrentPageId();
+  const router = useRouter();
+
+  const { data: isNotFound } = useIsNotFound();
+  const { data: pageId, mutate: mutateCurrentPageId } = useCurrentPageId();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
@@ -66,7 +70,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: indentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
   const { data: indentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
-  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
+  const { data: isEnabledUnsavedWarning, mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { data: isUploadableFile } = useIsUploadableFile();
   const { data: isUploadableFile } = useIsUploadableFile();
   const { data: isUploadableImage } = useIsUploadableImage();
   const { data: isUploadableImage } = useIsUploadableImage();
 
 
@@ -112,7 +116,7 @@ const PageEditor = React.memo((): JSX.Element => {
   }, [setMarkdownWithDebounce]);
   }, [setMarkdownWithDebounce]);
 
 
   // return true if the save succeeds, otherwise false.
   // return true if the save succeeds, otherwise false.
-  const save = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}): Promise<boolean> => {
+  const save = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}): Promise<IPageHasId | null> => {
     if (grantData == null || isSlackEnabled == null || currentPathname == null) {
     if (grantData == null || isSlackEnabled == null || currentPathname == null) {
       logger.error('Some materials to save are invalid', { grantData, isSlackEnabled, currentPathname });
       logger.error('Some materials to save are invalid', { grantData, isSlackEnabled, currentPathname });
       throw new Error('Some materials to save are invalid');
       throw new Error('Some materials to save are invalid');
@@ -127,10 +131,13 @@ const PageEditor = React.memo((): JSX.Element => {
     );
     );
 
 
     try {
     try {
-      await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: currentRevisionId }, markdownToSave.current);
-      await mutateCurrentPage();
-      mutateIsEnabledUnsavedWarning(false);
-      return true;
+      const { page } = await saveOrUpdate(
+        optionsToSave,
+        { pageId, path: currentPagePath || currentPathname, revisionId: currentRevisionId },
+        markdownToSave.current,
+      );
+
+      return page;
     }
     }
     catch (error) {
     catch (error) {
       logger.error('failed to save', error);
       logger.error('failed to save', error);
@@ -143,20 +150,34 @@ const PageEditor = React.memo((): JSX.Element => {
         //   lastUpdateUser: error.data.user,
         //   lastUpdateUser: error.data.user,
         // });
         // });
       }
       }
-      return false;
+      return null;
     }
     }
 
 
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  }, [grantData, isSlackEnabled, currentPathname, slackChannels, pageTags, pageId, currentPagePath, currentRevisionId, mutateCurrentPage, mutateIsEnabledUnsavedWarning]);
+  }, [grantData, isSlackEnabled, currentPathname, slackChannels, pageTags, pageId, currentPagePath, currentRevisionId]);
 
 
   const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
   const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
     if (editorMode !== EditorMode.Editor) {
     if (editorMode !== EditorMode.Editor) {
       return;
       return;
     }
     }
 
 
-    await save(opts);
+    const page = await save(opts);
+    if (page == null) {
+      return;
+    }
+    // The updateFn should be a promise or asynchronous function to handle the remote mutation
+    // it should return updated data. see: https://swr.vercel.app/docs/mutation#optimistic-updates
+    // Moreover, `async() => false` does not work since it's too fast to be calculated.
+    await mutateIsEnabledUnsavedWarning(new Promise(r => setTimeout(() => r(false), 10)), { optimisticData: () => false });
+    if (isNotFound) {
+      await router.push(`/${page._id}`);
+    }
+    else {
+      await mutateCurrentPageId(page._id);
+      await mutateCurrentPage();
+    }
     mutateEditorMode(EditorMode.View);
     mutateEditorMode(EditorMode.View);
-  }, [editorMode, save, mutateEditorMode]);
+  }, [editorMode, save, mutateIsEnabledUnsavedWarning, isNotFound, mutateEditorMode, router, mutateCurrentPageId, mutateCurrentPage]);
 
 
   const saveWithShortcut = useCallback(async() => {
   const saveWithShortcut = useCallback(async() => {
     if (editorMode !== EditorMode.Editor) {
     if (editorMode !== EditorMode.Editor) {

+ 21 - 16
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -11,7 +11,7 @@ import { throttle, debounce } from 'throttle-debounce';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
 import InterceptorManager from '~/services/interceptor-manager';
 import InterceptorManager from '~/services/interceptor-manager';
-import { useDrawioModal } from '~/stores/modal';
+import { useHandsontableModal, useDrawioModal } from '~/stores/modal';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
@@ -25,7 +25,6 @@ import EmojiPickerHelper from './EmojiPickerHelper';
 import GridEditModal from './GridEditModal';
 import GridEditModal from './GridEditModal';
 // TODO: re-impl with https://redmine.weseek.co.jp/issues/107248
 // TODO: re-impl with https://redmine.weseek.co.jp/issues/107248
 // import geu from './GridEditorUtil';
 // import geu from './GridEditorUtil';
-import HandsontableModal from './HandsontableModal';
 import LinkEditModal from './LinkEditModal';
 import LinkEditModal from './LinkEditModal';
 import mdu from './MarkdownDrawioUtil';
 import mdu from './MarkdownDrawioUtil';
 import markdownLinkUtil from './MarkdownLinkUtil';
 import markdownLinkUtil from './MarkdownLinkUtil';
@@ -116,7 +115,6 @@ class CodeMirrorEditor extends AbstractEditor {
     this.cm = React.createRef();
     this.cm = React.createRef();
     this.gridEditModal = React.createRef();
     this.gridEditModal = React.createRef();
     this.linkEditModal = React.createRef();
     this.linkEditModal = React.createRef();
-    this.handsontableModal = React.createRef();
     this.drawioModal = React.createRef();
     this.drawioModal = React.createRef();
 
 
     this.init();
     this.init();
@@ -156,7 +154,6 @@ class CodeMirrorEditor extends AbstractEditor {
     // TODO: re-impl with https://redmine.weseek.co.jp/issues/107248
     // TODO: re-impl with https://redmine.weseek.co.jp/issues/107248
     // this.showGridEditorHandler = this.showGridEditorHandler.bind(this);
     // this.showGridEditorHandler = this.showGridEditorHandler.bind(this);
     this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
     this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
-    this.showHandsonTableHandler = this.showHandsonTableHandler.bind(this);
 
 
     this.foldDrawioSection = this.foldDrawioSection.bind(this);
     this.foldDrawioSection = this.foldDrawioSection.bind(this);
     this.onSaveForDrawio = this.onSaveForDrawio.bind(this);
     this.onSaveForDrawio = this.onSaveForDrawio.bind(this);
@@ -869,11 +866,6 @@ class CodeMirrorEditor extends AbstractEditor {
     this.linkEditModal.current.show(markdownLinkUtil.getMarkdownLink(this.getCodeMirror()));
     this.linkEditModal.current.show(markdownLinkUtil.getMarkdownLink(this.getCodeMirror()));
   }
   }
 
 
-  showHandsonTableHandler() {
-    this.handsontableModal.current.show(mtu.getMarkdownTable(this.getCodeMirror()));
-  }
-
-
   // fold draw.io section (::: drawio ~ :::)
   // fold draw.io section (::: drawio ~ :::)
   foldDrawioSection() {
   foldDrawioSection() {
     const editor = this.getCodeMirror();
     const editor = this.getCodeMirror();
@@ -1016,7 +1008,13 @@ class CodeMirrorEditor extends AbstractEditor {
         color={null}
         color={null}
         size="sm"
         size="sm"
         title="Table"
         title="Table"
-        onClick={this.showHandsonTableHandler}
+        onClick={() => {
+          this.props.onClickTableBtn(
+            mtu.getMarkdownTable(this.getCodeMirror()),
+            this.getCodeMirror(),
+            this.props.editorSettings.autoFormatMarkdownTable,
+          );
+        }}
       >
       >
         <EditorIcon icon="Table" />
         <EditorIcon icon="Table" />
       </Button>,
       </Button>,
@@ -1131,11 +1129,6 @@ class CodeMirrorEditor extends AbstractEditor {
           ref={this.linkEditModal}
           ref={this.linkEditModal}
           onSave={(linkText) => { return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
           onSave={(linkText) => { return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
         />
         />
-        <HandsontableModal
-          ref={this.handsontableModal}
-          onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}
-          autoFormatMarkdownTable={this.props.editorSettings.autoFormatMarkdownTable}
-        />
       </div>
       </div>
     );
     );
   }
   }
@@ -1157,12 +1150,24 @@ CodeMirrorEditor.defaultProps = {
 
 
 const CodeMirrorEditorFc = React.forwardRef((props, ref) => {
 const CodeMirrorEditorFc = React.forwardRef((props, ref) => {
   const { open: openDrawioModal } = useDrawioModal();
   const { open: openDrawioModal } = useDrawioModal();
+  const { open: openHandsontableModal } = useHandsontableModal();
 
 
   const openDrawioModalHandler = useCallback((drawioMxFile) => {
   const openDrawioModalHandler = useCallback((drawioMxFile) => {
     openDrawioModal(drawioMxFile);
     openDrawioModal(drawioMxFile);
   }, [openDrawioModal]);
   }, [openDrawioModal]);
 
 
-  return <CodeMirrorEditor ref={ref} onClickDrawioBtn={openDrawioModalHandler} {...props} />;
+  const openTableModalHandler = useCallback((table, editor, autoFormatMarkdownTable) => {
+    openHandsontableModal(table, editor, autoFormatMarkdownTable);
+  }, [openHandsontableModal]);
+
+  return (
+    <CodeMirrorEditor
+      ref={ref}
+      onClickDrawioBtn={openDrawioModalHandler}
+      onClickTableBtn={openTableModalHandler}
+      {...props}
+    />
+  );
 });
 });
 
 
 CodeMirrorEditorFc.displayName = 'CodeMirrorEditorFc';
 CodeMirrorEditorFc.displayName = 'CodeMirrorEditorFc';

+ 1 - 1
packages/app/src/components/PageEditor/Editor.tsx

@@ -229,7 +229,7 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
 
 
   const renderNavbar = useCallback(() => {
   const renderNavbar = useCallback(() => {
     return (
     return (
-      <div className="m-0 navbar navbar-default navbar-editor" data-testId="navbar-editor" style={{ minHeight: 'unset' }}>
+      <div className="m-0 navbar navbar-default navbar-editor" data-testid="navbar-editor" style={{ minHeight: 'unset' }}>
         <ul className="pl-2 nav nav-navbar">
         <ul className="pl-2 nav nav-navbar">
           { (editorSubstance()?.getNavbarItems() ?? []).map((item, idx) => {
           { (editorSubstance()?.getNavbarItems() ?? []).map((item, idx) => {
             // eslint-disable-next-line react/no-array-index-key
             // eslint-disable-next-line react/no-array-index-key

+ 0 - 537
packages/app/src/components/PageEditor/HandsontableModal.jsx

@@ -1,537 +0,0 @@
-import React from 'react';
-
-import { HotTable } from '@handsontable/react';
-import Handsontable from 'handsontable';
-import PropTypes from 'prop-types';
-import {
-  Collapse,
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-import { debounce } from 'throttle-debounce';
-
-
-import MarkdownTable from '~/client/models/MarkdownTable';
-
-import ExpandOrContractButton from '../ExpandOrContractButton';
-
-import MarkdownTableDataImportForm from './MarkdownTableDataImportForm';
-
-import styles from './HandsontableModal.module.scss';
-import 'handsontable/dist/handsontable.full.min.css';
-
-const DEFAULT_HOT_HEIGHT = 300;
-const MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING = {
-  r: 'htRight',
-  c: 'htCenter',
-  l: 'htLeft',
-  '': '',
-};
-
-export default class HandsontableModal extends React.PureComponent {
-
-  constructor(props) {
-    super(props);
-
-    /*
-     * ## Note ##
-     * Currently, this component try to synchronize the cells data and alignment data of state.markdownTable with these of the HotTable.
-     * However, changes made by the following operations are not synchronized.
-     *
-     * 1. move columns: Alignment changes are synchronized but data changes are not.
-     * 2. move rows: Data changes are not synchronized.
-     * 3. insert columns or rows: Data changes are synchronized but alignment changes are not.
-     * 4. delete columns or rows: Data changes are synchronized but alignment changes are not.
-     *
-     * However, all operations are reflected in the data to be saved because the HotTable data is used when the save method is called.
-     */
-    this.state = {
-      show: false,
-      isDataImportAreaExpanded: false,
-      isWindowExpanded: false,
-      markdownTableOnInit: HandsontableModal.getDefaultMarkdownTable(),
-      markdownTable: HandsontableModal.getDefaultMarkdownTable(),
-      handsontableHeight: DEFAULT_HOT_HEIGHT,
-    };
-
-    this.init = this.init.bind(this);
-    this.reset = this.reset.bind(this);
-    this.cancel = this.cancel.bind(this);
-    this.save = this.save.bind(this);
-    this.afterLoadDataHandler = this.afterLoadDataHandler.bind(this);
-    this.beforeColumnResizeHandler = this.beforeColumnResizeHandler.bind(this);
-    this.afterColumnResizeHandler = this.afterColumnResizeHandler.bind(this);
-    this.modifyColWidthHandler = this.modifyColWidthHandler.bind(this);
-    this.beforeColumnMoveHandler = this.beforeColumnMoveHandler.bind(this);
-    this.afterColumnMoveHandler = this.afterColumnMoveHandler.bind(this);
-    this.synchronizeAlignment = this.synchronizeAlignment.bind(this);
-    this.alignButtonHandler = this.alignButtonHandler.bind(this);
-    this.toggleDataImportArea = this.toggleDataImportArea.bind(this);
-    this.importData = this.importData.bind(this);
-    this.expandWindow = this.expandWindow.bind(this);
-    this.contractWindow = this.contractWindow.bind(this);
-
-    // create debounced method for expanding HotTable
-    this.expandHotTableHeightWithDebounce = debounce(100, this.expandHotTableHeight);
-
-    // a Set instance that stores column indices which are resized manually.
-    // these columns will NOT be determined the width automatically by 'modifyColWidthHandler'
-    this.manuallyResizedColumnIndicesSet = new Set();
-
-    // generate setting object for HotTable instance
-    this.handsontableSettings = Object.assign(HandsontableModal.getDefaultHandsontableSetting(), {
-      contextMenu: this.createCustomizedContextMenu(),
-    });
-  }
-
-  init(markdownTable) {
-    const initMarkdownTable = markdownTable || HandsontableModal.getDefaultMarkdownTable();
-    this.setState(
-      {
-        markdownTableOnInit: initMarkdownTable,
-        markdownTable: initMarkdownTable.clone(),
-      },
-    );
-
-    this.manuallyResizedColumnIndicesSet.clear();
-  }
-
-  createCustomizedContextMenu() {
-    return {
-      items: {
-        row_above: {},
-        row_below: {},
-        col_left: {},
-        col_right: {},
-        separator1: Handsontable.plugins.ContextMenu.SEPARATOR,
-        remove_row: {},
-        remove_col: {},
-        separator2: Handsontable.plugins.ContextMenu.SEPARATOR,
-        custom_alignment: {
-          name: 'Align columns',
-          key: 'align_columns',
-          submenu: {
-            items: [
-              {
-                name: 'Left',
-                key: 'align_columns:1',
-                callback: (key, selection) => { this.align('l', selection[0].start.col, selection[0].end.col) },
-              }, {
-                name: 'Center',
-                key: 'align_columns:2',
-                callback: (key, selection) => { this.align('c', selection[0].start.col, selection[0].end.col) },
-              }, {
-                name: 'Right',
-                key: 'align_columns:3',
-                callback: (key, selection) => { this.align('r', selection[0].start.col, selection[0].end.col) },
-              },
-            ],
-          },
-        },
-      },
-    };
-  }
-
-  show(markdownTable) {
-    this.init(markdownTable);
-    this.setState({ show: true });
-  }
-
-  hide() {
-    this.setState({
-      show: false,
-      isDataImportAreaExpanded: false,
-      isWindowExpanded: false,
-    });
-  }
-
-  /**
-   * Reset table data to initial value
-   *
-   * ## Note ##
-   * It may not return completely to the initial state because of the manualColumnMove operations.
-   * https://github.com/handsontable/handsontable/issues/5591
-   */
-  reset() {
-    this.setState({ markdownTable: this.state.markdownTableOnInit.clone() });
-  }
-
-  cancel() {
-    this.hide();
-  }
-
-  save() {
-    const markdownTable = new MarkdownTable(
-      this.hotTable.hotInstance.getData(),
-      this.markdownTableOption,
-    ).normalizeCells();
-
-    if (this.props.onSave != null) {
-      this.props.onSave(markdownTable);
-    }
-
-    this.hide();
-  }
-
-  /**
-   * An afterLoadData hook
-   *
-   * This performs the following operations.
-   * - clear 'manuallyResizedColumnIndicesSet' for the first loading
-   * - synchronize the handsontable alignment to the markdowntable alignment
-   *
-   * ## Note ##
-   * The afterLoadData hook is called when one of the following states of this component are passed into the setState.
-   *
-   * - markdownTable
-   * - handsontableHeight
-   *
-   * In detail, when the setState method is called with those state passed,
-   * React will start re-render process for the HotTable of this component because the HotTable receives those state values by props.
-   * HotTable#shouldComponentUpdate is called in this re-render process and calls the updateSettings method for the Handsontable instance.
-   * In updateSettings method, the loadData method is called in some case.
-   *  (refs: https://github.com/handsontable/handsontable/blob/6.2.0/src/core.js#L1652-L1657)
-   * The updateSettings method calls in the HotTable always lead to call the loadData method because the HotTable passes data source by settings.data.
-   * After the loadData method is executed, afterLoadData hooks are called.
-   */
-  afterLoadDataHandler(initialLoad) {
-    if (initialLoad) {
-      this.manuallyResizedColumnIndicesSet.clear();
-    }
-
-    this.synchronizeAlignment();
-  }
-
-  beforeColumnResizeHandler(currentColumn) {
-    /*
-     * The following bug disturbs to use 'beforeColumnResizeHandler' to store column index -- 2018.10.23 Yuki Takei
-     * https://github.com/handsontable/handsontable/issues/3328
-     *
-     * At the moment, using 'afterColumnResizeHandler' instead.
-     */
-
-    // store column index
-    // this.manuallyResizedColumnIndicesSet.add(currentColumn);
-  }
-
-  afterColumnResizeHandler(currentColumn) {
-    /*
-     * The following bug disturbs to use 'beforeColumnResizeHandler' to store column index -- 2018.10.23 Yuki Takei
-     * https://github.com/handsontable/handsontable/issues/3328
-     *
-     * At the moment, using 'afterColumnResizeHandler' instead.
-     */
-
-    // store column index
-    this.manuallyResizedColumnIndicesSet.add(currentColumn);
-    // force re-render
-    const hotInstance = this.hotTable.hotInstance;
-    hotInstance.render();
-  }
-
-  modifyColWidthHandler(width, column) {
-    // return original width if the column index exists in 'manuallyResizedColumnIndicesSet'
-    if (this.manuallyResizedColumnIndicesSet.has(column)) {
-      return width;
-    }
-    // return fixed width if first initializing
-    return Math.max(80, Math.min(400, width));
-  }
-
-  beforeColumnMoveHandler(columns, target) {
-    // clear 'manuallyResizedColumnIndicesSet'
-    this.manuallyResizedColumnIndicesSet.clear();
-  }
-
-  /**
-   * An afterColumnMove hook.
-   *
-   * This synchronizes alignment when columns are moved by manualColumnMove
-   */
-  afterColumnMoveHandler(columns, target) {
-    const align = [].concat(this.state.markdownTable.options.align);
-    const removed = align.splice(columns[0], columns.length);
-
-    /*
-     * The following is a description of the algorithm for the alignment synchronization.
-     *
-     * Consider the case where the target is X and the columns are [2,3] and data is as follows.
-     *
-     * 0 1 2 3 4 5 (insert position number)
-     * +-+-+-+-+-+
-     * | | | | | |
-     * +-+-+-+-+-+
-     *  0 1 2 3 4  (column index number)
-     *
-     * At first, remove columns by the splice.
-     *
-     * 0 1 2   4 5
-     * +-+-+   +-+
-     * | | |   | |
-     * +-+-+   +-+
-     *  0 1     4
-     *
-     * Next, insert those columns into a new position.
-     * However the target number is a insert position number before deletion, it may be changed.
-     * These are changed as follows.
-     *
-     * Before:
-     * 0 1 2   4 5
-     * +-+-+   +-+
-     * | | |   | |
-     * +-+-+   +-+
-     *
-     * After:
-     * 0 1 2   2 3
-     * +-+-+   +-+
-     * | | |   | |
-     * +-+-+   +-+
-     *
-     * If X is 0, 1 or 2, that is, lower than columns[0], the target number is not changed.
-     * If X is 4 or 5, that is, higher than columns[columns.length - 1], the target number is modified to the original value minus columns.length.
-     *
-     */
-    let insertPosition = 0;
-    if (target <= columns[0]) {
-      insertPosition = target;
-    }
-    else if (columns[columns.length - 1] < target) {
-      insertPosition = target - columns.length;
-    }
-    align.splice(...[insertPosition, 0].concat(removed));
-
-    this.setState((prevState) => {
-      // change only align info, so share table data to avoid redundant copy
-      const newMarkdownTable = new MarkdownTable(prevState.markdownTable.table, { align });
-      return { markdownTable: newMarkdownTable };
-    }, () => {
-      this.synchronizeAlignment();
-    });
-  }
-
-  /**
-   * change the markdownTable alignment and synchronize the handsontable alignment to it
-   */
-  align(direction, startCol, endCol) {
-    this.setState((prevState) => {
-      // change only align info, so share table data to avoid redundant copy
-      const newMarkdownTable = new MarkdownTable(prevState.markdownTable.table, { align: [].concat(prevState.markdownTable.options.align) });
-      for (let i = startCol; i <= endCol; i++) {
-        newMarkdownTable.options.align[i] = direction;
-      }
-      return { markdownTable: newMarkdownTable };
-    }, () => {
-      this.synchronizeAlignment();
-    });
-  }
-
-  /**
-   * synchronize the handsontable alignment to the markdowntable alignment
-   */
-  synchronizeAlignment() {
-    if (this.hotTable == null) {
-      return;
-    }
-
-    const align = this.state.markdownTable.options.align;
-    const hotInstance = this.hotTable.hotInstance;
-
-    if (hotInstance.isDestroyed === true) {
-      return;
-    }
-
-    for (let i = 0; i < align.length; i++) {
-      for (let j = 0; j < hotInstance.countRows(); j++) {
-        hotInstance.setCellMeta(j, i, 'className', MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING[align[i]]);
-      }
-    }
-    hotInstance.render();
-  }
-
-  alignButtonHandler(direction) {
-    const selectedRange = this.hotTable.hotInstance.getSelectedRange();
-    if (selectedRange == null) return;
-
-    let startCol;
-    let endCol;
-
-    if (selectedRange[0].from.col < selectedRange[0].to.col) {
-      startCol = selectedRange[0].from.col;
-      endCol = selectedRange[0].to.col;
-    }
-    else {
-      startCol = selectedRange[0].to.col;
-      endCol = selectedRange[0].from.col;
-    }
-
-    this.align(direction, startCol, endCol);
-  }
-
-  toggleDataImportArea() {
-    this.setState({ isDataImportAreaExpanded: !this.state.isDataImportAreaExpanded });
-  }
-
-  /**
-   * Import a markdowntable
-   *
-   * ## Note ##
-   * The manualColumnMove operation affects the column order of imported data.
-   * https://github.com/handsontable/handsontable/issues/5591
-   */
-  importData(markdownTable) {
-    this.init(markdownTable);
-    this.toggleDataImportArea();
-  }
-
-  expandWindow() {
-    this.setState({ isWindowExpanded: true });
-
-    // invoke updateHotTableHeight method with delay
-    // cz. Resizing this.refs.hotTableContainer is completed after a little delay after 'isWindowExpanded' set with 'true'
-    this.expandHotTableHeightWithDebounce();
-  }
-
-  contractWindow() {
-    this.setState({ isWindowExpanded: false, handsontableHeight: DEFAULT_HOT_HEIGHT });
-  }
-
-  /**
-   * Expand the height of the Handsontable
-   *  by updating 'handsontableHeight' state
-   *  according to the height of this.refs.hotTableContainer
-   */
-  expandHotTableHeight() {
-    if (this.state.isWindowExpanded && this.hotTableContainer != null) {
-      const height = this.hotTableContainer.getBoundingClientRect().height;
-      this.setState({ handsontableHeight: height });
-    }
-  }
-
-  get markdownTableOption() {
-    return {
-      align: [].concat(this.state.markdownTable.options.align),
-      pad: this.props.autoFormatMarkdownTable !== false,
-    };
-  }
-
-  renderCloseButton() {
-    return (
-      <button type="button" className="close" onClick={this.cancel} aria-label="Close">
-        <span aria-hidden="true">&times;</span>
-      </button>
-    );
-  }
-
-  render() {
-
-    const buttons = (
-      <span>
-        {/* change order because of `float: right` by '.close' class */}
-        {this.renderCloseButton()}
-        <ExpandOrContractButton
-          isWindowExpanded={this.state.isWindowExpanded}
-          contractWindow={this.contractWindow}
-          expandWindow={this.expandWindow}
-        />
-      </span>
-    );
-
-    return (
-      <Modal
-        isOpen={this.state.show}
-        toggle={this.cancel}
-        backdrop="static"
-        keyboard={false}
-        size="lg"
-        className={`handsontable-modal ${styles['grw-handsontable']}
-          ${this.state.isWindowExpanded && `grw-modal-expanded ${styles['grw-modal-expanded']}`}`}
-      >
-        <ModalHeader tag="h4" toggle={this.cancel} close={buttons} className="bg-primary text-light">
-          Edit Table
-        </ModalHeader>
-        <ModalBody className="p-0 d-flex flex-column">
-          <div className="grw-hot-modal-navbar px-4 py-3 border-bottom">
-            <button
-              type="button"
-              className="mr-4 data-import-button btn btn-secondary"
-              data-toggle="collapse"
-              data-target="#collapseDataImport"
-              aria-expanded={this.state.isDataImportAreaExpanded}
-              onClick={this.toggleDataImportArea}
-            >
-              <span className="mr-3">Data Import</span><i className={this.state.isDataImportAreaExpanded ? 'fa fa-angle-up' : 'fa fa-angle-down'}></i>
-            </button>
-            <div role="group" className="btn-group">
-              <button type="button" className="btn btn-secondary" onClick={() => { this.alignButtonHandler('l') }}>
-                <i className="ti ti-align-left"></i>
-              </button>
-              <button type="button" className="btn btn-secondary" onClick={() => { this.alignButtonHandler('c') }}>
-                <i className="ti ti-align-center"></i>
-              </button>
-              <button type="button" className="btn btn-secondary" onClick={() => { this.alignButtonHandler('r') }}>
-                <i className="ti ti-align-right"></i>
-              </button>
-            </div>
-            <Collapse isOpen={this.state.isDataImportAreaExpanded}>
-              <div className="mt-4">
-                <MarkdownTableDataImportForm onCancel={this.toggleDataImportArea} onImport={this.importData} />
-              </div>
-            </Collapse>
-          </div>
-          <div ref={(c) => { this.hotTableContainer = c }} className="m-4 hot-table-container">
-            <HotTable
-              ref={(c) => { this.hotTable = c }}
-              data={this.state.markdownTable.table}
-              settings={this.handsontableSettings}
-              height={this.state.handsontableHeight}
-              afterLoadData={this.afterLoadDataHandler}
-              modifyColWidth={this.modifyColWidthHandler}
-              beforeColumnMove={this.beforeColumnMoveHandler}
-              beforeColumnResize={this.beforeColumnResizeHandler}
-              afterColumnResize={this.afterColumnResizeHandler}
-              afterColumnMove={this.afterColumnMoveHandler}
-            />
-          </div>
-        </ModalBody>
-        <ModalFooter className="grw-modal-footer">
-          <button type="button" className="btn btn-danger" onClick={this.reset}>Reset</button>
-          <div className="ml-auto">
-            <button type="button" className="mr-2 btn btn-secondary" onClick={this.cancel}>Cancel</button>
-            <button type="button" className="btn btn-primary" onClick={this.save}>Done</button>
-          </div>
-        </ModalFooter>
-      </Modal>
-    );
-  }
-
-  static getDefaultMarkdownTable() {
-    return new MarkdownTable(
-      [
-        ['col1', 'col2', 'col3'],
-        ['', '', ''],
-        ['', '', ''],
-      ],
-      {
-        align: ['', '', ''],
-      },
-    );
-  }
-
-  static getDefaultHandsontableSetting() {
-    return {
-      rowHeaders: true,
-      colHeaders: true,
-      manualRowMove: true,
-      manualRowResize: true,
-      manualColumnMove: true,
-      manualColumnResize: true,
-      selectionMode: 'multiple',
-      outsideClickDeselects: false,
-    };
-  }
-
-}
-
-HandsontableModal.propTypes = {
-  onSave: PropTypes.func,
-  autoFormatMarkdownTable: PropTypes.bool,
-};

+ 27 - 5
packages/app/src/components/PageEditor/HandsontableModal.module.scss

@@ -14,11 +14,33 @@
       text-align: inherit;
       text-align: inherit;
     }
     }
   }
   }
-}
 
 
-// expand .hot-table-container (with flexbox)
-.grw-modal-expanded :global {
-  .hot-table-container {
-    flex: 1;
+  // expand .hot-table-container (with flexbox)
+  .grw-modal-expanded {
+    .hot-table-container {
+      flex: 1;
+    }
+  }
+
+
+  // Prevent handsontable/handsontable #2937 (Manual column resize does not work when handsontable is loaded inside Bootstrap 3.0 Modal)
+  // see https://github.com/handsontable/handsontable/issues/2937#issuecomment-287390111
+  // This issue fixing from Handsontable v 7.0.0
+  // see: https://github.com/handsontable/handsontable/issues/2937#issuecomment-480824024
+  .modal.in .modal-dialog.handsontable-modal {
+    transform: none;
+
+    .data-import-button {
+      position: relative;
+      padding-right: 35px;
+      padding-left: 10px;
+
+      i:before {
+        position: absolute;
+        top: 6px;
+        right: 8px;
+        font-size: 20px;
+      }
+    }
   }
   }
 }
 }

+ 506 - 0
packages/app/src/components/PageEditor/HandsontableModal.tsx

@@ -0,0 +1,506 @@
+import React, { useState, useEffect } from 'react';
+
+import { HotTable } from '@handsontable/react';
+import Handsontable from 'handsontable';
+import { useTranslation } from 'next-i18next';
+import {
+  Collapse,
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import { debounce } from 'throttle-debounce';
+
+import MarkdownTable from '~/client/models/MarkdownTable';
+import mtu from '~/components/PageEditor/MarkdownTableUtil';
+import { useHandsontableModal } from '~/stores/modal';
+
+import ExpandOrContractButton from '../ExpandOrContractButton';
+
+import { MarkdownTableDataImportForm } from './MarkdownTableDataImportForm';
+
+import styles from './HandsontableModal.module.scss';
+import 'handsontable/dist/handsontable.full.min.css';
+
+const DEFAULT_HOT_HEIGHT = 300;
+const MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING = {
+  r: 'htRight',
+  c: 'htCenter',
+  l: 'htLeft',
+  '': '',
+};
+
+export const HandsontableModal = (): JSX.Element => {
+
+  const { t } = useTranslation('commons');
+  const { data: handsontableModalData, close: closeHandsontableModal } = useHandsontableModal();
+  const isOpened = handsontableModalData?.isOpened ?? false;
+  const table = handsontableModalData?.table;
+  const autoFormatMarkdownTable = handsontableModalData?.autoFormatMarkdownTable ?? false;
+  const editor = handsontableModalData?.editor;
+
+  const defaultMarkdownTable = () => {
+    return new MarkdownTable(
+      [
+        ['col1', 'col2', 'col3'],
+        ['', '', ''],
+        ['', '', ''],
+      ],
+      {
+        align: ['', '', ''],
+      },
+    );
+  };
+
+  const defaultHandsontableSetting = () => {
+    return {
+      rowHeaders: true,
+      colHeaders: true,
+      manualRowMove: true,
+      manualRowResize: true,
+      manualColumnMove: true,
+      manualColumnResize: true,
+      selectionMode: 'multiple',
+      outsideClickDeselects: false,
+    };
+  };
+
+  // A Set instance that stores column indices which are resized manually.
+  // these columns will NOT be determined the width automatically by 'modifyColWidthHandler'
+  const manuallyResizedColumnIndicesSet = new Set();
+
+  /*
+   * ## Note ##
+   * Currently, this component try to synchronize the cells data and alignment data of state.markdownTable with these of the HotTable.
+   * However, changes made by the following operations are not synchronized.
+   *
+   * 1. move columns: Alignment changes are synchronized but data changes are not.
+   * 2. move rows: Data changes are not synchronized.
+   * 3. insert columns or rows: Data changes are synchronized but alignment changes are not.
+   * 4. delete columns or rows: Data changes are synchronized but alignment changes are not.
+   *
+   * However, all operations are reflected in the data to be saved because the HotTable data is used when the save method is called.
+   */
+  const [hotTable, setHotTable] = useState<HotTable | null>();
+  const [hotTableContainer, setHotTableContainer] = useState<HTMLDivElement | null>();
+  const [isDataImportAreaExpanded, setIsDataImportAreaExpanded] = useState<boolean>(false);
+  const [isWindowExpanded, setIsWindowExpanded] = useState<boolean>(false);
+  const [markdownTable, setMarkdownTable] = useState<MarkdownTable>(defaultMarkdownTable);
+  const [markdownTableOnInit, setMarkdownTableOnInit] = useState<MarkdownTable>(defaultMarkdownTable);
+  const [handsontableHeight, setHandsontableHeight] = useState<number>(DEFAULT_HOT_HEIGHT);
+
+  useEffect(() => {
+    const initTableInstance = table == null ? defaultMarkdownTable : table.clone();
+    setMarkdownTable(table ?? defaultMarkdownTable);
+    setMarkdownTableOnInit(initTableInstance);
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [isOpened]);
+
+  const markdownTableOption = {
+    get latest() {
+      return {
+        align: [].concat(markdownTable.options.align),
+        pad: autoFormatMarkdownTable !== false,
+      };
+    },
+  };
+
+  /**
+   * Reset table data to initial value
+   *
+   * ## Note ##
+   * It may not return completely to the initial state because of the manualColumnMove operations.
+   * https://github.com/handsontable/handsontable/issues/5591
+   */
+  const reset = () => {
+    setMarkdownTable(markdownTableOnInit.clone());
+  };
+
+  const cancel = () => {
+    closeHandsontableModal();
+    setIsDataImportAreaExpanded(false);
+    setIsWindowExpanded(false);
+  };
+
+  const save = () => {
+    if (hotTable == null || editor == null) {
+      return;
+    }
+
+    const newMarkdownTable = new MarkdownTable(
+      hotTable.hotInstance.getData(),
+      markdownTableOption.latest,
+    ).normalizeCells();
+
+    mtu.replaceFocusedMarkdownTableWithEditor(editor, newMarkdownTable);
+
+    cancel();
+  };
+
+  const beforeColumnResizeHandler = (currentColumn) => {
+    /*
+     * The following bug disturbs to use 'beforeColumnResizeHandler' to store column index -- 2018.10.23 Yuki Takei
+     * https://github.com/handsontable/handsontable/issues/3328
+     *
+     * At the moment, using 'afterColumnResizeHandler' instead.
+     */
+
+    // store column index
+    // this.manuallyResizedColumnIndicesSet.add(currentColumn);
+  };
+
+  const afterColumnResizeHandler = (currentColumn) => {
+    if (hotTable == null) {
+      return;
+    }
+
+    /*
+     * The following bug disturbs to use 'beforeColumnResizeHandler' to store column index -- 2018.10.23 Yuki Takei
+     * https://github.com/handsontable/handsontable/issues/3328
+     *
+     * At the moment, using 'afterColumnResizeHandler' instead.
+     */
+
+    // store column index
+    manuallyResizedColumnIndicesSet.add(currentColumn);
+    // force re-render
+    const hotInstance = hotTable.hotInstance;
+    hotInstance.render();
+  };
+
+  const modifyColWidthHandler = (width, column) => {
+    // return original width if the column index exists in 'manuallyResizedColumnIndicesSet'
+    if (manuallyResizedColumnIndicesSet.has(column)) {
+      return width;
+    }
+    // return fixed width if first initializing
+    return Math.max(80, Math.min(400, width));
+  };
+
+  const beforeColumnMoveHandler = (columns, target) => {
+    // clear 'manuallyResizedColumnIndicesSet'
+    manuallyResizedColumnIndicesSet.clear();
+  };
+
+  /**
+   * synchronize the handsontable alignment to the markdowntable alignment
+   */
+  const synchronizeAlignment = () => {
+    if (hotTable == null) {
+      return;
+    }
+
+    const align = markdownTable.options.align;
+    const hotInstance = hotTable.hotInstance;
+
+    if (hotInstance.isDestroyed === true || align == null) {
+      return;
+    }
+
+    for (let i = 0; i < align.length; i++) {
+      for (let j = 0; j < hotInstance.countRows(); j++) {
+        hotInstance.setCellMeta(j, i, 'className', MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING[align[i]]);
+      }
+    }
+    hotInstance.render();
+  };
+
+  /**
+   * An afterLoadData hook
+   *
+   * This performs the following operations.
+   * - clear 'manuallyResizedColumnIndicesSet' for the first loading
+   * - synchronize the handsontable alignment to the markdowntable alignment
+   *
+   * ## Note ##
+   * The afterLoadData hook is called when one of the following states of this component are passed into the setState.
+   *
+   * - markdownTable
+   * - handsontableHeight
+   *
+   * In detail, when the setState method is called with those state passed,
+   * React will start re-render process for the HotTable of this component because the HotTable receives those state values by props.
+   * HotTable#shouldComponentUpdate is called in this re-render process and calls the updateSettings method for the Handsontable instance.
+   * In updateSettings method, the loadData method is called in some case.
+   *  (refs: https://github.com/handsontable/handsontable/blob/6.2.0/src/core.js#L1652-L1657)
+   * The updateSettings method calls in the HotTable always lead to call the loadData method because the HotTable passes data source by settings.data.
+   * After the loadData method is executed, afterLoadData hooks are called.
+   */
+  const afterLoadDataHandler = (initialLoad: boolean) => {
+    if (initialLoad) {
+      manuallyResizedColumnIndicesSet.clear();
+    }
+
+    synchronizeAlignment();
+  };
+
+  /**
+   * An afterColumnMove hook.
+   *
+   * This synchronizes alignment when columns are moved by manualColumnMove
+   */
+  // TODO: colums type is number[]
+  const afterColumnMoveHandler = (columns: any, target: number) => {
+    const align = [].concat(markdownTable.options.align);
+    const removed = align.splice(columns[0], columns.length);
+
+    /*
+      * The following is a description of the algorithm for the alignment synchronization.
+      *
+      * Consider the case where the target is X and the columns are [2,3] and data is as follows.
+      *
+      * 0 1 2 3 4 5 (insert position number)
+      * +-+-+-+-+-+
+      * | | | | | |
+      * +-+-+-+-+-+
+      *  0 1 2 3 4  (column index number)
+      *
+      * At first, remove columns by the splice.
+      *
+      * 0 1 2   4 5
+      * +-+-+   +-+
+      * | | |   | |
+      * +-+-+   +-+
+      *  0 1     4
+      *
+      * Next, insert those columns into a new position.
+      * However the target number is a insert position number before deletion, it may be changed.
+      * These are changed as follows.
+      *
+      * Before:
+      * 0 1 2   4 5
+      * +-+-+   +-+
+      * | | |   | |
+      * +-+-+   +-+
+      *
+      * After:
+      * 0 1 2   2 3
+      * +-+-+   +-+
+      * | | |   | |
+      * +-+-+   +-+
+      *
+      * If X is 0, 1 or 2, that is, lower than columns[0], the target number is not changed.
+      * If X is 4 or 5, that is, higher than columns[columns.length - 1], the target number is modified to the original value minus columns.length.
+      *
+      */
+    let insertPosition = 0;
+    if (target <= columns[0]) {
+      insertPosition = target;
+    }
+    else if (columns[columns.length - 1] < target) {
+      insertPosition = target - columns.length;
+    }
+
+    for (let i = 0; i < removed.length; i++) {
+      align.splice(insertPosition + i, 0, removed[i]);
+    }
+
+    setMarkdownTable((prevMarkdownTable) => {
+      // change only align info, so share table data to avoid redundant copy
+      const newMarkdownTable = new MarkdownTable(prevMarkdownTable.table, { align });
+      return newMarkdownTable;
+    });
+
+    synchronizeAlignment();
+  };
+
+  /**
+   * change the markdownTable alignment and synchronize the handsontable alignment to it
+   */
+  const align = (direction: string, startCol: number, endCol: number) => {
+    setMarkdownTable((prevMarkdownTable) => {
+      // change only align info, so share table data to avoid redundant copy
+      const newMarkdownTable = new MarkdownTable(prevMarkdownTable.table, { align: [].concat(prevMarkdownTable.options.align) });
+      for (let i = startCol; i <= endCol; i++) {
+        newMarkdownTable.options.align[i] = direction;
+      }
+      return newMarkdownTable;
+    });
+
+    synchronizeAlignment();
+  };
+
+  const alignButtonHandler = (direction: string) => {
+    if (hotTable == null) {
+      return;
+    }
+
+    const selectedRange = hotTable.hotInstance.getSelectedRange();
+    if (selectedRange == null) return;
+
+    const startCol = selectedRange[0].from.col < selectedRange[0].to.col ? selectedRange[0].from.col : selectedRange[0].to.col;
+    const endCol = selectedRange[0].from.col < selectedRange[0].to.col ? selectedRange[0].to.col : selectedRange[0].from.col;
+
+    align(direction, startCol, endCol);
+  };
+
+  const toggleDataImportArea = () => {
+    setIsDataImportAreaExpanded(!isDataImportAreaExpanded);
+  };
+
+  const init = (markdownTable: MarkdownTable) => {
+    const initMarkdownTable = markdownTable || defaultMarkdownTable;
+    setMarkdownTableOnInit(initMarkdownTable);
+    setMarkdownTable(initMarkdownTable.clone());
+    manuallyResizedColumnIndicesSet.clear();
+  };
+
+  /**
+   * Import a markdowntable
+   *
+   * ## Note ##
+   * The manualColumnMove operation affects the column order of imported data.
+   * https://github.com/handsontable/handsontable/issues/5591
+   */
+  const importData = (markdownTable: MarkdownTable) => {
+    init(markdownTable);
+    toggleDataImportArea();
+  };
+
+  /**
+   * Expand the height of the Handsontable
+   *  by updating 'handsontableHeight' state
+   *  according to the height of this.refs.hotTableContainer
+   */
+  const expandHotTableHeight = () => {
+    if (isWindowExpanded && hotTableContainer != null) {
+      const height = hotTableContainer.getBoundingClientRect().height;
+      setHandsontableHeight(height);
+    }
+  };
+
+  const expandWindow = () => {
+    setIsWindowExpanded(true);
+
+    // create debounced method for expanding HotTable
+    // invoke updateHotTableHeight method with delay
+    // cz. Resizing this.refs.hotTableContainer is completed after a little delay after 'isWindowExpanded' set with 'true'
+    debounce(100, expandHotTableHeight);
+  };
+
+  const contractWindow = () => {
+    setIsWindowExpanded(false);
+    setHandsontableHeight(DEFAULT_HOT_HEIGHT);
+  };
+
+  const createCustomizedContextMenu = () => {
+    return {
+      items: {
+        row_above: {},
+        row_below: {},
+        col_left: {},
+        col_right: {},
+        separator1: '---------',
+        remove_row: {},
+        remove_col: {},
+        separator2: '---------',
+        custom_alignment: {
+          name: 'Align columns',
+          key: 'align_columns',
+          submenu: {
+            items: [
+              {
+                name: 'Left',
+                key: 'align_columns:1',
+                callback: (key, selection) => { align('l', selection[0].start.col, selection[0].end.col) },
+              }, {
+                name: 'Center',
+                key: 'align_columns:2',
+                callback: (key, selection) => { align('c', selection[0].start.col, selection[0].end.col) },
+              }, {
+                name: 'Right',
+                key: 'align_columns:3',
+                callback: (key, selection) => { align('r', selection[0].start.col, selection[0].end.col) },
+              },
+            ],
+          },
+        },
+      },
+    };
+  };
+
+  // generate setting object for HotTable instance
+  const handsontableSettings = Object.assign(defaultHandsontableSetting(), {
+    contextMenu: createCustomizedContextMenu(),
+  });
+
+  const closeButton = (
+    <span>
+      {/* change order because of `float: right` by '.close' class */}
+      <button type="button" className="close" onClick={cancel} aria-label="Close">
+        <span aria-hidden="true">&times;</span>
+      </button>
+      <ExpandOrContractButton
+        isWindowExpanded={isWindowExpanded}
+        contractWindow={contractWindow}
+        expandWindow={expandWindow}
+      />
+    </span>
+  );
+
+  return (
+    <Modal
+      isOpen={isOpened}
+      toggle={cancel}
+      backdrop="static"
+      keyboard={false}
+      size="lg"
+      wrapClassName={`${styles['grw-handsontable']}`}
+      className={`handsontable-modal ${isWindowExpanded && 'grw-modal-expanded'}`}
+    >
+      <ModalHeader tag="h4" toggle={cancel} close={closeButton} className="bg-primary text-light">
+        {t('handsontable_modal.title')}
+      </ModalHeader>
+      <ModalBody className="p-0 d-flex flex-column">
+        <div className="grw-hot-modal-navbar px-4 py-3 border-bottom">
+          <button
+            type="button"
+            className="mr-4 data-import-button btn btn-secondary"
+            data-toggle="collapse"
+            data-target="#collapseDataImport"
+            aria-expanded={isDataImportAreaExpanded}
+            onClick={toggleDataImportArea}
+          >
+            <span className="mr-3">{t('handsontable_modal.data_import')}</span>
+            <i className={isDataImportAreaExpanded ? 'fa fa-angle-up' : 'fa fa-angle-down'}></i>
+          </button>
+          <div role="group" className="btn-group">
+            <button type="button" className="btn btn-secondary" onClick={() => { alignButtonHandler('l') }}>
+              <i className="ti ti-align-left"></i>
+            </button>
+            <button type="button" className="btn btn-secondary" onClick={() => { alignButtonHandler('c') }}>
+              <i className="ti ti-align-center"></i>
+            </button>
+            <button type="button" className="btn btn-secondary" onClick={() => { alignButtonHandler('r') }}>
+              <i className="ti ti-align-right"></i>
+            </button>
+          </div>
+          <Collapse isOpen={isDataImportAreaExpanded}>
+            <div className="mt-4">
+              <MarkdownTableDataImportForm onCancel={toggleDataImportArea} onImport={importData} />
+            </div>
+          </Collapse>
+        </div>
+        <div ref={c => setHotTableContainer(c)} className="m-4 hot-table-container">
+          <HotTable
+            ref={c => setHotTable(c)}
+            data={markdownTable.table}
+            settings={handsontableSettings as Handsontable.DefaultSettings}
+            height={handsontableHeight}
+            afterLoadData={afterLoadDataHandler}
+            modifyColWidth={modifyColWidthHandler}
+            beforeColumnMove={beforeColumnMoveHandler}
+            beforeColumnResize={beforeColumnResizeHandler}
+            afterColumnResize={afterColumnResizeHandler}
+            afterColumnMove={afterColumnMoveHandler}
+          />
+        </div>
+      </ModalBody>
+      <ModalFooter className="grw-modal-footer">
+        <button type="button" className="btn btn-danger" onClick={reset}>{t('commons:Reset')}</button>
+        <div className="ml-auto">
+          <button type="button" className="mr-2 btn btn-secondary" onClick={cancel}>{t('handsontable_modal.cancel')}</button>
+          <button type="button" className="btn btn-primary" onClick={save}>{t('handsontable_modal.done')}</button>
+        </div>
+      </ModalFooter>
+    </Modal>
+  );
+};

+ 0 - 103
packages/app/src/components/PageEditor/MarkdownTableDataImportForm.jsx

@@ -1,103 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import {
-  Button,
-  Collapse,
-} from 'reactstrap';
-
-import MarkdownTable from '~/client/models/MarkdownTable';
-
-export default class MarkdownTableDataImportForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      dataFormat: 'csv',
-      data: '',
-      parserErrorMessage: null,
-    };
-
-    this.importButtonHandler = this.importButtonHandler.bind(this);
-  }
-
-  importButtonHandler() {
-    try {
-      const markdownTable = this.convertFormDataToMarkdownTable();
-      this.props.onImport(markdownTable);
-      this.setState({ parserErrorMessage: null });
-    }
-    catch (e) {
-      this.setState({ parserErrorMessage: e.message });
-    }
-  }
-
-  convertFormDataToMarkdownTable() {
-    let result;
-    switch (this.state.dataFormat) {
-      case 'csv':
-        result = MarkdownTable.fromDSV(this.state.data, ',');
-        break;
-      case 'tsv':
-        result = MarkdownTable.fromDSV(this.state.data, '\t');
-        break;
-      case 'html':
-        result = MarkdownTable.fromHTMLTableTag(this.state.data);
-        break;
-    }
-    return result.normalizeCells();
-  }
-
-  render() {
-    return (
-      <form className="data-import-form">
-        <div className="form-group">
-          <label htmlFor="data-import-form-type-select">Select Data Format</label>
-          <select
-            id="data-import-form-type-select"
-            className="form-control"
-            placeholder="select"
-            value={this.state.dataFormat}
-            onChange={(e) => { return this.setState({ dataFormat: e.target.value }) }}
-          >
-            <option value="csv">CSV</option>
-            <option value="tsv">TSV</option>
-            <option value="html">HTML</option>
-          </select>
-        </div>
-        <div className="form-group">
-          <label htmlFor="data-import-form-type-textarea">Import Data</label>
-          <textarea
-            id="data-import-form-type-textarea"
-            className="form-control"
-            placeholder="Paste table data"
-            rows="8"
-            onChange={(e) => { return this.setState({ data: e.target.value }) }}
-          />
-        </div>
-        <Collapse isOpen={this.state.parserErrorMessage != null}>
-          <div className="form-group">
-            <label htmlFor="data-import-form-type-textarea-alert">Parse Error</label>
-            <textarea
-              id="data-import-form-type-textarea-alert"
-              className="form-control"
-              rows="4"
-              value={this.state.parserErrorMessage || ''}
-              readOnly
-            />
-          </div>
-        </Collapse>
-        <div className="d-flex justify-content-end">
-          <Button color="secondary mr-2" onClick={this.props.onCancel}>Cancel</Button>
-          <Button color="primary" onClick={this.importButtonHandler}>Import</Button>
-        </div>
-      </form>
-    );
-  }
-
-}
-
-MarkdownTableDataImportForm.propTypes = {
-  onCancel: PropTypes.func,
-  onImport: PropTypes.func,
-};

+ 98 - 0
packages/app/src/components/PageEditor/MarkdownTableDataImportForm.tsx

@@ -0,0 +1,98 @@
+import React, { useState } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Button,
+  Collapse,
+} from 'reactstrap';
+
+import MarkdownTable from '~/client/models/MarkdownTable';
+
+type MarkdownTableDataImportFormProps = {
+  onCancel: () => void,
+  onImport: (table: MarkdownTable) => void,
+}
+
+export const MarkdownTableDataImportForm = (props: MarkdownTableDataImportFormProps): JSX.Element => {
+
+  const { onCancel, onImport } = props;
+
+  const { t } = useTranslation('commons', { keyPrefix: 'handsontable_modal.data_import_form' });
+
+  const [dataFormat, setDataFormat] = useState<string>('csv');
+  const [data, setData] = useState<string>('');
+  const [parserErrorMessage, setParserErrorMessage] = useState(null);
+
+  const convertFormDataToMarkdownTable = () => {
+    let result;
+    switch (dataFormat) {
+      case 'csv':
+        result = MarkdownTable.fromDSV(data, ',');
+        break;
+      case 'tsv':
+        result = MarkdownTable.fromDSV(data, '\t');
+        break;
+      case 'html':
+        result = MarkdownTable.fromHTMLTableTag(data);
+        break;
+    }
+    return result.normalizeCells();
+  };
+
+  const importButtonHandler = () => {
+    try {
+      const markdownTable = convertFormDataToMarkdownTable();
+      onImport(markdownTable);
+      setParserErrorMessage(null);
+    }
+    catch (e) {
+      setParserErrorMessage(e.message);
+    }
+  };
+
+  return (
+    <form className="data-import-form">
+      <div className="form-group">
+        <label htmlFor="data-import-form-type-select">{t('select_data_format')}</label>
+        <select
+          id="data-import-form-type-select"
+          className="form-control"
+          placeholder="select"
+          value={dataFormat}
+          onChange={(e) => { return setDataFormat(e.target.value) }}
+        >
+          <option value="csv">CSV</option>
+          <option value="tsv">TSV</option>
+          <option value="html">HTML</option>
+        </select>
+      </div>
+      <div className="form-group">
+        <label htmlFor="data-import-form-type-textarea">{t('import_data')}</label>
+        <textarea
+          id="data-import-form-type-textarea"
+          className="form-control"
+          placeholder={t('paste_table_data')}
+          rows={8}
+          onChange={(e) => { return setData(e.target.value) }}
+        />
+      </div>
+      <Collapse isOpen={parserErrorMessage != null}>
+        <div className="form-group">
+          <label htmlFor="data-import-form-type-textarea-alert">{t('parse_error')}</label>
+          <textarea
+            id="data-import-form-type-textarea-alert"
+            className="form-control"
+            rows={4}
+            value={parserErrorMessage || ''}
+            readOnly
+          />
+        </div>
+      </Collapse>
+      <div className="d-flex justify-content-end">
+        <Button color="secondary mr-2" onClick={onCancel}>{t('cancel')}</Button>
+        <Button color="primary" onClick={importButtonHandler}>{t('import')}</Button>
+      </div>
+    </form>
+  );
+
+};

+ 34 - 21
packages/app/src/components/PageEditorByHackmd.tsx

@@ -4,8 +4,11 @@ import React, {
 
 
 import EventEmitter from 'events';
 import EventEmitter from 'events';
 
 
+import { pathUtils } from '@growi/core';
+import Link from 'next/link';
+import { useRouter } from 'next/router';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-
+import urljoin from 'url-join';
 
 
 import { saveOrUpdate } from '~/client/services/page-operation';
 import { saveOrUpdate } from '~/client/services/page-operation';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
@@ -13,7 +16,7 @@ import { apiPost } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import { getOptionsToSave } from '~/client/util/editor';
 import { IResHackmdIntegrated, IResHackmdDiscard } from '~/interfaces/hackmd';
 import { IResHackmdIntegrated, IResHackmdDiscard } from '~/interfaces/hackmd';
 import {
 import {
-  useCurrentPageId, useCurrentPathname, useHackmdUri,
+  useCurrentPageId, useCurrentPathname, useHackmdUri, useIsNotFound,
 } from '~/stores/context';
 } from '~/stores/context';
 import {
 import {
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
@@ -41,17 +44,22 @@ type HackEditorRef = {
 export const PageEditorByHackmd = (): JSX.Element => {
 export const PageEditorByHackmd = (): JSX.Element => {
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const router = useRouter();
+
+  const { data: isNotFound } = useIsNotFound();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPathname } = useCurrentPathname();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: pageId } = useCurrentPageId();
+  const { data: pageId, mutate: mutateCurrentPageId } = useCurrentPageId();
   const { data: pageTags } = usePageTagsForEditors(pageId);
   const { data: pageTags } = usePageTagsForEditors(pageId);
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { data: grant } = useSelectedGrant();
   const { data: grant } = useSelectedGrant();
   const { data: hackmdUri } = useHackmdUri();
   const { data: hackmdUri } = useHackmdUri();
 
 
+  const { returnPathForURL } = pathUtils;
+
   // pageData
   // pageData
   const { data: pageData, mutate: mutatePageData } = useSWRxCurrentPage();
   const { data: pageData, mutate: mutatePageData } = useSWRxCurrentPage();
   const revision = pageData?.revision;
   const revision = pageData?.revision;
@@ -100,30 +108,32 @@ export const PageEditorByHackmd = (): JSX.Element => {
 
 
       const markdown = await hackmdEditorRef.current.getValue();
       const markdown = await hackmdEditorRef.current.getValue();
 
 
-      await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revision?._id }, markdown);
+      const { page } = await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revision?._id }, markdown);
       await mutatePageData();
       await mutatePageData();
       await mutateTagsInfo();
       await mutateTagsInfo();
+
+      if (page == null) {
+        return;
+      }
+      // The updateFn should be a promise or asynchronous function to handle the remote mutation
+      // it should return updated data. see: https://swr.vercel.app/docs/mutation#optimistic-updates
+      // Moreover, `async() => false` does not work since it's too fast to be calculated.
+      await mutateIsEnabledUnsavedWarning(new Promise(r => setTimeout(() => r(false), 10)), { optimisticData: () => false });
+      if (isNotFound) {
+        await router.push(`/${page._id}`);
+      }
+      else {
+        await mutateCurrentPageId(page._id);
+        await mutatePageData();
+      }
       mutateEditorMode(EditorMode.View);
       mutateEditorMode(EditorMode.View);
-      mutateIsEnabledUnsavedWarning(false);
     }
     }
     catch (error) {
     catch (error) {
       logger.error('failed to save', error);
       logger.error('failed to save', error);
       toastError(error.message);
       toastError(error.message);
     }
     }
-  }, [editorMode,
-      isSlackEnabled,
-      currentPathname,
-      slackChannels,
-      grant,
-      revision,
-      pageTags,
-      pageId,
-      currentPagePath,
-      mutatePageData,
-      mutateEditorMode,
-      mutateTagsInfo,
-      mutateIsEnabledUnsavedWarning,
-  ]);
+  }, [editorMode, isSlackEnabled, currentPathname, slackChannels, grant, revision, pageTags, pageId,
+      currentPagePath, mutatePageData, mutateTagsInfo, mutateIsEnabledUnsavedWarning, isNotFound, mutateEditorMode, router, mutateCurrentPageId]);
 
 
   // set handler to save and reload Page
   // set handler to save and reload Page
   useEffect(() => {
   useEffect(() => {
@@ -346,8 +356,11 @@ export const PageEditorByHackmd = (): JSX.Element => {
               <div className="card-header bg-warning"><i className="icon-fw icon-info"></i> {t('hackmd.draft_outdated')}</div>
               <div className="card-header bg-warning"><i className="icon-fw icon-info"></i> {t('hackmd.draft_outdated')}</div>
               <div className="card-body text-center">
               <div className="card-body text-center">
                 {t('hackmd.based_on_revision')}&nbsp;
                 {t('hackmd.based_on_revision')}&nbsp;
-                <a href={`?revision=${revisionIdHackmdSynced}`}><span className="badge badge-secondary">{revisionIdHackmdSynced?.substr(-8)}</span></a>
-
+                { pageData != null && (
+                  <Link href={urljoin(returnPathForURL(pageData.path, pageData._id), `?revisionId=${revisionIdHackmdSynced}`)} prefetch={false}>
+                    <a><span className="badge badge-secondary">{revisionIdHackmdSynced?.substr(-8)}</span></a>
+                  </Link>
+                )}
                 <div className="text-center mt-3">
                 <div className="text-center mt-3">
                   <button
                   <button
                     className="btn btn-link btn-view-outdated-draft p-0"
                     className="btn btn-link btn-view-outdated-draft p-0"

+ 14 - 4
packages/app/src/components/PageHistory.tsx

@@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react';
 import { IRevisionHasPageId } from '@growi/core';
 import { IRevisionHasPageId } from '@growi/core';
 
 
 import { useCurrentPageId } from '~/stores/context';
 import { useCurrentPageId } from '~/stores/context';
-import { useSWRxPageRevisions } from '~/stores/page';
+import { useSWRxPageRevisions, useCurrentPagePath } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { PageRevisionTable } from './PageHistory/PageRevisionTable';
 import { PageRevisionTable } from './PageHistory/PageRevisionTable';
@@ -12,13 +12,14 @@ import { RevisionComparer } from './RevisionComparer/RevisionComparer';
 
 
 const logger = loggerFactory('growi:PageHistory');
 const logger = loggerFactory('growi:PageHistory');
 
 
-export const PageHistory = (): JSX.Element => {
+export const PageHistory: React.FC<{ onClose: () => void }> = ({ onClose }) => {
 
 
   const [activePage, setActivePage] = useState(1);
   const [activePage, setActivePage] = useState(1);
 
 
   const { data: currentPageId } = useCurrentPageId();
   const { data: currentPageId } = useCurrentPageId();
+  const { data: currentPagePath } = useCurrentPagePath();
 
 
-  const { data: revisionsData } = useSWRxPageRevisions(activePage, 10, currentPageId);
+  const { data: revisionsData, mutate: mutatePageRevisions } = useSWRxPageRevisions(activePage, 10, currentPageId);
 
 
   const [sourceRevision, setSourceRevision] = useState<IRevisionHasPageId>();
   const [sourceRevision, setSourceRevision] = useState<IRevisionHasPageId>();
   const [targetRevision, setTargetRevision] = useState<IRevisionHasPageId>();
   const [targetRevision, setTargetRevision] = useState<IRevisionHasPageId>();
@@ -30,9 +31,13 @@ export const PageHistory = (): JSX.Element => {
     }
     }
   }, [revisionsData]);
   }, [revisionsData]);
 
 
+  useEffect(() => {
+    mutatePageRevisions();
+  });
+
   const pagingLimit = 10;
   const pagingLimit = 10;
 
 
-  if (revisionsData == null || sourceRevision == null || targetRevision == null || currentPageId == null) {
+  if (revisionsData == null || sourceRevision == null || targetRevision == null || currentPageId == null || currentPagePath == null) {
     return (
     return (
       <div className="text-muted text-center">
       <div className="text-muted text-center">
         <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
         <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
@@ -59,8 +64,11 @@ export const PageHistory = (): JSX.Element => {
         pagingLimit={pagingLimit}
         pagingLimit={pagingLimit}
         sourceRevision={sourceRevision}
         sourceRevision={sourceRevision}
         targetRevision={targetRevision}
         targetRevision={targetRevision}
+        currentPageId={currentPageId}
+        currentPagePath={currentPagePath}
         onChangeSourceInvoked={setSourceRevision}
         onChangeSourceInvoked={setSourceRevision}
         onChangeTargetInvoked={setTargetRevision}
         onChangeTargetInvoked={setTargetRevision}
+        onClose={onClose}
       />
       />
       <div className="my-3">
       <div className="my-3">
         {pager()}
         {pager()}
@@ -69,6 +77,8 @@ export const PageHistory = (): JSX.Element => {
         sourceRevision={sourceRevision}
         sourceRevision={sourceRevision}
         targetRevision={targetRevision}
         targetRevision={targetRevision}
         currentPageId={currentPageId}
         currentPageId={currentPageId}
+        currentPagePath={currentPagePath}
+        onClose={onClose}
       />
       />
     </div>
     </div>
   );
   );

+ 11 - 4
packages/app/src/components/PageHistory/PageRevisionTable.tsx

@@ -12,15 +12,19 @@ type PageRevisionTAble = {
   pagingLimit: number,
   pagingLimit: number,
   sourceRevision: IRevisionHasId,
   sourceRevision: IRevisionHasId,
   targetRevision: IRevisionHasId,
   targetRevision: IRevisionHasId,
+  currentPageId: string,
+  currentPagePath: string,
   onChangeSourceInvoked: React.Dispatch<React.SetStateAction<IRevisionHasId | undefined>>,
   onChangeSourceInvoked: React.Dispatch<React.SetStateAction<IRevisionHasId | undefined>>,
   onChangeTargetInvoked: React.Dispatch<React.SetStateAction<IRevisionHasId | undefined>>,
   onChangeTargetInvoked: React.Dispatch<React.SetStateAction<IRevisionHasId | undefined>>,
+  onClose: () => void,
 }
 }
 
 
 export const PageRevisionTable = (props: PageRevisionTAble): JSX.Element => {
 export const PageRevisionTable = (props: PageRevisionTAble): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const {
   const {
-    revisions, pagingLimit, sourceRevision, targetRevision, onChangeSourceInvoked, onChangeTargetInvoked,
+    revisions, pagingLimit, sourceRevision, targetRevision, currentPageId, currentPagePath,
+    onChangeSourceInvoked, onChangeTargetInvoked, onClose,
   } = props;
   } = props;
 
 
   const revisionCount = revisions.length;
   const revisionCount = revisions.length;
@@ -48,9 +52,12 @@ export const PageRevisionTable = (props: PageRevisionTAble): JSX.Element => {
           <div className="d-lg-flex">
           <div className="d-lg-flex">
             <Revision
             <Revision
               revision={revision}
               revision={revision}
+              currentPageId={currentPageId}
+              currentPagePath={currentPagePath}
               isLatestRevision={revision === latestRevision}
               isLatestRevision={revision === latestRevision}
               hasDiff={hasDiff}
               hasDiff={hasDiff}
               key={`revision-history-rev-${revisionId}`}
               key={`revision-history-rev-${revisionId}`}
+              onClose={onClose}
             />
             />
             {hasDiff && (
             {hasDiff && (
               <div className="ml-md-3 mt-auto">
               <div className="ml-md-3 mt-auto">
@@ -132,9 +139,9 @@ export const PageRevisionTable = (props: PageRevisionTAble): JSX.Element => {
     <table className={`${styles['revision-history-table']} table revision-history-table`}>
     <table className={`${styles['revision-history-table']} table revision-history-table`}>
       <thead>
       <thead>
         <tr className="d-flex">
         <tr className="d-flex">
-          <th className="col">{ t('page_history.revision') }</th>
-          <th className="col-1">{ t('page_history.comparing_source') }</th>
-          <th className="col-2">{ t('page_history.comparing_target') }</th>
+          <th className="col">{t('page_history.revision')}</th>
+          <th className="col-1">{t('page_history.comparing_source')}</th>
+          <th className="col-2">{t('page_history.comparing_target')}</th>
         </tr>
         </tr>
       </thead>
       </thead>
       <tbody className="overflow-auto d-block">
       <tbody className="overflow-auto d-block">

+ 17 - 6
packages/app/src/components/PageHistory/Revision.tsx

@@ -1,8 +1,10 @@
 import React from 'react';
 import React from 'react';
 
 
-import { IRevisionHasId } from '@growi/core';
+import { IRevisionHasId, pathUtils } from '@growi/core';
 import { UserPicture } from '@growi/ui';
 import { UserPicture } from '@growi/ui';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
+import urljoin from 'url-join';
 
 
 import UserDate from '../User/UserDate';
 import UserDate from '../User/UserDate';
 import { Username } from '../User/Username';
 import { Username } from '../User/Username';
@@ -11,14 +13,21 @@ import styles from './Revision.module.scss';
 
 
 type RevisionProps = {
 type RevisionProps = {
   revision: IRevisionHasId,
   revision: IRevisionHasId,
+  currentPageId: string,
+  currentPagePath: string,
   isLatestRevision: boolean,
   isLatestRevision: boolean,
   hasDiff: boolean,
   hasDiff: boolean,
+  onClose: () => void,
 }
 }
 
 
 export const Revision = (props: RevisionProps): JSX.Element => {
 export const Revision = (props: RevisionProps): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const { revision, isLatestRevision, hasDiff } = props;
+  const {
+    revision, currentPageId, currentPagePath, isLatestRevision, hasDiff, onClose,
+  } = props;
+
+  const { returnPathForURL } = pathUtils;
 
 
   const renderSimplifiedNodiff = (revision: IRevisionHasId) => {
   const renderSimplifiedNodiff = (revision: IRevisionHasId) => {
 
 
@@ -34,7 +43,7 @@ export const Revision = (props: RevisionProps): JSX.Element => {
         </div>
         </div>
         <div className="ml-3">
         <div className="ml-3">
           <span className="text-muted small">
           <span className="text-muted small">
-            <UserDate dateTime={revision.createdAt} /> ({ t('No diff') })
+            <UserDate dateTime={revision.createdAt} /> {t('No diff')}
           </span>
           </span>
         </div>
         </div>
       </div>
       </div>
@@ -60,9 +69,11 @@ export const Revision = (props: RevisionProps): JSX.Element => {
           <div className="mb-1">
           <div className="mb-1">
             <UserDate dateTime={revision.createdAt} />
             <UserDate dateTime={revision.createdAt} />
             <br className="d-xl-none d-block" />
             <br className="d-xl-none d-block" />
-            <a className="ml-xl-3" href={`?revisionId=${revision._id}`}>
-              <i className="icon-login"></i> { t('Go to this version') }
-            </a>
+            <Link href={urljoin(returnPathForURL(currentPagePath, currentPageId), `?revisionId=${revision._id}`)} prefetch={false}>
+              <a className="ml-xl-3" onClick={onClose}>
+                <i className="icon-login"></i> {t('Go to this version')}
+              </a>
+            </Link>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>

+ 21 - 9
packages/app/src/components/PageHistory/RevisionDiff.tsx

@@ -1,9 +1,11 @@
 import React from 'react';
 import React from 'react';
 
 
-import { IRevisionHasPageId } from '@growi/core';
+import { IRevisionHasPageId, pathUtils } from '@growi/core';
 import { createPatch } from 'diff';
 import { createPatch } from 'diff';
 import { html, Diff2HtmlConfig } from 'diff2html';
 import { html, Diff2HtmlConfig } from 'diff2html';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
+import urljoin from 'url-join';
 
 
 import UserDate from '../User/UserDate';
 import UserDate from '../User/UserDate';
 
 
@@ -15,12 +17,19 @@ type RevisioinDiffProps = {
   currentRevision: IRevisionHasPageId,
   currentRevision: IRevisionHasPageId,
   previousRevision: IRevisionHasPageId,
   previousRevision: IRevisionHasPageId,
   revisionDiffOpened: boolean,
   revisionDiffOpened: boolean,
+  currentPageId: string,
+  currentPagePath: string,
+  onClose: () => void,
 }
 }
 
 
 export const RevisionDiff = (props: RevisioinDiffProps): JSX.Element => {
 export const RevisionDiff = (props: RevisioinDiffProps): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const { currentRevision, previousRevision, revisionDiffOpened } = props;
+  const {
+    currentRevision, previousRevision, revisionDiffOpened, currentPageId, currentPagePath, onClose,
+  } = props;
+
+  const { returnPathForURL } = pathUtils;
 
 
   const previousText = (currentRevision._id === previousRevision._id) ? '' : previousRevision.body;
   const previousText = (currentRevision._id === previousRevision._id) ? '' : previousRevision.body;
 
 
@@ -46,16 +55,19 @@ export const RevisionDiff = (props: RevisioinDiffProps): JSX.Element => {
           <div className="row">
           <div className="row">
             <div className="col comparison-source-wrapper pt-1 px-0">
             <div className="col comparison-source-wrapper pt-1 px-0">
               <span className="comparison-source pr-3">{t('page_history.comparing_source')}</span><UserDate dateTime={previousRevision.createdAt} />
               <span className="comparison-source pr-3">{t('page_history.comparing_source')}</span><UserDate dateTime={previousRevision.createdAt} />
-              <a href={`?revisionId=${previousRevision._id}`} className="ml-3">
-                <i className="icon-login"></i>
-              </a>
-
+              <Link href={urljoin(returnPathForURL(currentPagePath, currentPageId), `?revisionId=${previousRevision._id}`)}>
+                <a className="ml-3" onClick={onClose}>
+                  <i className="icon-login"></i>
+                </a>
+              </Link>
             </div>
             </div>
             <div className="col comparison-target-wrapper pt-1">
             <div className="col comparison-target-wrapper pt-1">
               <span className="comparison-target pr-3">{t('page_history.comparing_target')}</span><UserDate dateTime={currentRevision.createdAt} />
               <span className="comparison-target pr-3">{t('page_history.comparing_target')}</span><UserDate dateTime={currentRevision.createdAt} />
-              <a href={`?revisionId=${currentRevision._id}`} className="ml-3">
-                <i className="icon-login"></i>
-              </a>
+              <Link href={urljoin(returnPathForURL(currentPagePath, currentPageId), `?revisionId=${currentRevision._id}`)}>
+                <a className="ml-3" onClick={onClose}>
+                  <i className="icon-login"></i>
+                </a>
+              </Link>
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>

+ 4 - 2
packages/app/src/components/PageList/PageListItemL.tsx

@@ -4,7 +4,7 @@ import React, {
 } from 'react';
 } from 'react';
 
 
 
 
-import { DevidedPagePath } from '@growi/core';
+import { DevidedPagePath, pathUtils } from '@growi/core';
 import { UserPicture, PageListMeta } from '@growi/ui';
 import { UserPicture, PageListMeta } from '@growi/ui';
 import { format } from 'date-fns';
 import { format } from 'date-fns';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
@@ -55,6 +55,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     onClickItem, onCheckboxChanged, onPageDuplicated, onPageRenamed, onPageDeleted, onPagePutBacked,
     onClickItem, onCheckboxChanged, onPageDuplicated, onPageRenamed, onPageDeleted, onPagePutBacked,
   } = props;
   } = props;
 
 
+  const { returnPathForURL } = pathUtils;
+
   const [likerCount, setLikerCount] = useState(pageData.liker.length);
   const [likerCount, setLikerCount] = useState(pageData.liker.length);
   const [bookmarkCount, setBookmarkCount] = useState(pageMeta && pageMeta.bookmarkCount ? pageMeta.bookmarkCount : 0);
   const [bookmarkCount, setBookmarkCount] = useState(pageMeta && pageMeta.bookmarkCount ? pageMeta.bookmarkCount : 0);
 
 
@@ -203,7 +205,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                 <span className="h5 mb-0">
                 <span className="h5 mb-0">
                   {/* Use permanent links to care for pages with the same name (Cannot use page path url) */}
                   {/* Use permanent links to care for pages with the same name (Cannot use page path url) */}
                   <span className="grw-page-path-hierarchical-link text-break">
                   <span className="grw-page-path-hierarchical-link text-break">
-                    <Link href={encodeURI(urljoin('/', pageData._id))} prefetch={false}>
+                    <Link href={returnPathForURL(pageData.path, pageData._id)} prefetch={false}>
                       {shouldDangerouslySetInnerHTMLForPaths
                       {shouldDangerouslySetInnerHTMLForPaths
                         ? (
                         ? (
                           <a
                           <a

+ 3 - 0
packages/app/src/components/PutbackPageModal.jsx

@@ -9,6 +9,7 @@ import {
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiPost } from '~/client/util/apiv1-client';
 import { PathAlreadyExistsError } from '~/server/models/errors';
 import { PathAlreadyExistsError } from '~/server/models/errors';
 import { usePutBackPageModal } from '~/stores/modal';
 import { usePutBackPageModal } from '~/stores/modal';
+import { usePageInfoTermManager } from '~/stores/page';
 
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
 
@@ -16,6 +17,7 @@ const PutBackPageModal = () => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const { data: pageDataToRevert, close: closePutBackPageModal } = usePutBackPageModal();
   const { data: pageDataToRevert, close: closePutBackPageModal } = usePutBackPageModal();
+  const { advance: advancePi } = usePageInfoTermManager();
   const { isOpened, page } = pageDataToRevert;
   const { isOpened, page } = pageDataToRevert;
   const { pageId, path } = page;
   const { pageId, path } = page;
   const onPutBacked = pageDataToRevert.opts?.onPutBacked;
   const onPutBacked = pageDataToRevert.opts?.onPutBacked;
@@ -41,6 +43,7 @@ const PutBackPageModal = () => {
         page_id: pageId,
         page_id: pageId,
         recursively,
         recursively,
       });
       });
+      advancePi();
 
 
       if (onPutBacked != null) {
       if (onPutBacked != null) {
         onPutBacked(response.page.path);
         onPutBacked(response.page.path);

+ 6 - 4
packages/app/src/components/RevisionComparer/RevisionComparer.tsx

@@ -7,8 +7,6 @@ import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import { useCurrentPagePath } from '~/stores/page';
-
 import { RevisionDiff } from '../PageHistory/RevisionDiff';
 import { RevisionDiff } from '../PageHistory/RevisionDiff';
 
 
 import styles from './RevisionComparer.module.scss';
 import styles from './RevisionComparer.module.scss';
@@ -26,16 +24,17 @@ type RevisionComparerProps = {
   sourceRevision: IRevisionHasPageId
   sourceRevision: IRevisionHasPageId
   targetRevision: IRevisionHasPageId
   targetRevision: IRevisionHasPageId
   currentPageId?: string
   currentPageId?: string
+  currentPagePath: string
+  onClose: () => void
 }
 }
 
 
 export const RevisionComparer = (props: RevisionComparerProps): JSX.Element => {
 export const RevisionComparer = (props: RevisionComparerProps): JSX.Element => {
   const { t } = useTranslation(['translation', 'commons']);
   const { t } = useTranslation(['translation', 'commons']);
 
 
   const {
   const {
-    sourceRevision, targetRevision, currentPageId,
+    sourceRevision, targetRevision, currentPageId, currentPagePath, onClose,
   } = props;
   } = props;
 
 
-  const { data: currentPagePath } = useCurrentPagePath();
   const [dropdownOpen, setDropdownOpen] = useState(false);
   const [dropdownOpen, setDropdownOpen] = useState(false);
 
 
   const toggleDropdown = () => {
   const toggleDropdown = () => {
@@ -104,6 +103,9 @@ export const RevisionComparer = (props: RevisionComparerProps): JSX.Element => {
               revisionDiffOpened
               revisionDiffOpened
               previousRevision={sourceRevision}
               previousRevision={sourceRevision}
               currentRevision={targetRevision}
               currentRevision={targetRevision}
+              currentPageId={currentPageId}
+              currentPagePath={currentPagePath}
+              onClose={onClose}
             />
             />
           )
           )
         }
         }

+ 6 - 11
packages/app/src/components/SavePageControls.tsx

@@ -1,5 +1,7 @@
 import React, { useCallback } from 'react';
 import React, { useCallback } from 'react';
 
 
+import EventEmitter from 'events';
+
 import { pagePathUtils, PageGrant } from '@growi/core';
 import { pagePathUtils, PageGrant } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
@@ -7,30 +9,23 @@ import {
   DropdownToggle, DropdownMenu, DropdownItem,
   DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-// import PageContainer from '~/client/services/PageContainer';
-import { CustomWindow } from '~/interfaces/global';
 import { IPageGrantData } from '~/interfaces/page';
 import { IPageGrantData } from '~/interfaces/page';
 import {
 import {
   useIsEditable, useCurrentPageId, useIsAclEnabled,
   useIsEditable, useCurrentPageId, useIsAclEnabled,
 } from '~/stores/context';
 } from '~/stores/context';
-import { useIsEnabledUnsavedWarning } from '~/stores/editor';
 import { useCurrentPagePath } from '~/stores/page';
 import { useCurrentPagePath } from '~/stores/page';
 import { useSelectedGrant } from '~/stores/ui';
 import { useSelectedGrant } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import GrantSelector from './SavePageControls/GrantSelector';
 import GrantSelector from './SavePageControls/GrantSelector';
 
 
-// import { withUnstatedContainers } from './UnstatedUtils';
+declare const globalEmitter: EventEmitter;
 
 
 const logger = loggerFactory('growi:SavePageControls');
 const logger = loggerFactory('growi:SavePageControls');
 
 
-type Props = {
-  // pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-}
-
 const { isTopPage } = pagePathUtils;
 const { isTopPage } = pagePathUtils;
 
 
-export const SavePageControls = (props: Props): JSX.Element | null => {
+export const SavePageControls = (): JSX.Element | null => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isEditable } = useIsEditable();
   const { data: isEditable } = useIsEditable();
@@ -45,12 +40,12 @@ export const SavePageControls = (props: Props): JSX.Element | null => {
 
 
   const save = useCallback(async(): Promise<void> => {
   const save = useCallback(async(): Promise<void> => {
     // save
     // save
-    (window as CustomWindow).globalEmitter.emit('saveAndReturnToView');
+    globalEmitter.emit('saveAndReturnToView');
   }, []);
   }, []);
 
 
   const saveAndOverwriteScopesOfDescendants = useCallback(() => {
   const saveAndOverwriteScopesOfDescendants = useCallback(() => {
     // save
     // save
-    (window as CustomWindow).globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true });
+    globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true });
   }, []);
   }, []);
 
 
 
 

+ 1 - 0
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -262,6 +262,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
         <PageComment
         <PageComment
           rendererOptions={rendererOptions}
           rendererOptions={rendererOptions}
           pageId={page._id}
           pageId={page._id}
+          pagePath={page.path}
           revision={page.revision}
           revision={page.revision}
           currentUser={currentUser}
           currentUser={currentUser}
           isReadOnly
           isReadOnly

+ 7 - 6
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -467,12 +467,13 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
                   </UncontrolledTooltip>
                   </UncontrolledTooltip>
                 </>
                 </>
               )}
               )}
-
-              <Link href={`/${page._id}`} prefetch={false}>
-                <a className="grw-pagetree-title-anchor flex-grow-1">
-                  <p className={`text-truncate m-auto ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{nodePath.basename(page.path ?? '') || '/'}</p>
-                </a>
-              </Link>
+              { page != null && page.path != null && page._id != null && (
+                <Link href={pathUtils.returnPathForURL(page.path, page._id)} prefetch={false}>
+                  <a className="grw-pagetree-title-anchor flex-grow-1">
+                    <p className={`text-truncate m-auto ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{nodePath.basename(page.path ?? '') || '/'}</p>
+                  </a>
+                </Link>
+              )}
             </>
             </>
           )}
           )}
         {descendantCount > 0 && !isRenameInputShown && (
         {descendantCount > 0 && !isRenameInputShown && (

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

@@ -15,7 +15,7 @@ import { useIsEnabledAttachTitleHeader } from '~/stores/context';
 import {
 import {
   IPageForPageDuplicateModal, usePageDuplicateModal, usePageDeleteModal,
   IPageForPageDuplicateModal, usePageDuplicateModal, usePageDeleteModal,
 } from '~/stores/modal';
 } from '~/stores/modal';
-import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
+import { useCurrentPagePath, usePageInfoTermManager, useSWRxCurrentPage } from '~/stores/page';
 import {
 import {
   usePageTreeTermManager, useSWRxPageAncestorsChildren, useSWRxRootPage, useDescendantsPageListForCurrentPathTermManager,
   usePageTreeTermManager, useSWRxPageAncestorsChildren, useSWRxRootPage, useDescendantsPageListForCurrentPathTermManager,
 } from '~/stores/page-listing';
 } from '~/stores/page-listing';
@@ -117,6 +117,7 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   const { advance: advancePt } = usePageTreeTermManager();
   const { advance: advancePt } = usePageTreeTermManager();
   const { advance: advanceFts } = useFullTextSearchTermManager();
   const { advance: advanceFts } = useFullTextSearchTermManager();
   const { advance: advanceDpl } = useDescendantsPageListForCurrentPathTermManager();
   const { advance: advanceDpl } = useDescendantsPageListForCurrentPathTermManager();
+  const { advance: advancePi } = usePageInfoTermManager();
 
 
   const [isInitialScrollCompleted, setIsInitialScrollCompleted] = useState(false);
   const [isInitialScrollCompleted, setIsInitialScrollCompleted] = useState(false);
 
 
@@ -186,6 +187,7 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
       advancePt();
       advancePt();
       advanceFts();
       advanceFts();
       advanceDpl();
       advanceDpl();
+      advancePi();
 
 
       if (currentPagePath === pathOrPathsToDelete) {
       if (currentPagePath === pathOrPathsToDelete) {
         mutateCurrentPage();
         mutateCurrentPage();
@@ -193,7 +195,7 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
     };
     };
 
 
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
-  }, [advanceDpl, advanceFts, advancePt, currentPagePath, mutateCurrentPage, openDeleteModal, t]);
+  }, [advanceDpl, advanceFts, advancePi, advancePt, currentPagePath, mutateCurrentPage, openDeleteModal, t]);
 
 
   // ***************************  Scroll on init ***************************
   // ***************************  Scroll on init ***************************
   const scrollOnInit = useCallback(() => {
   const scrollOnInit = useCallback(() => {

+ 3 - 3
packages/app/src/components/Skelton.tsx → packages/app/src/components/Skeleton.tsx

@@ -1,18 +1,18 @@
 import React from 'react';
 import React from 'react';
 
 
-type SkeltonProps = {
+type SkeletonProps = {
   additionalClass?: string,
   additionalClass?: string,
   roundedPill?: boolean,
   roundedPill?: boolean,
 }
 }
 
 
-export const Skelton = (props: SkeltonProps): JSX.Element => {
+export const Skeleton = (props: SkeletonProps): JSX.Element => {
   const {
   const {
     additionalClass, roundedPill,
     additionalClass, roundedPill,
   } = props;
   } = props;
 
 
   return (
   return (
     <div className={`${additionalClass ?? ''}`}>
     <div className={`${additionalClass ?? ''}`}>
-      <div className={`grw-skelton h-100 w-100 ${roundedPill ?? ''}`}></div>
+      <div className={`grw-skeleton h-100 w-100 ${roundedPill ?? ''}`}></div>
     </div>
     </div>
   );
   );
 };
 };

+ 2 - 2
packages/app/src/components/UnsavedAlertDialog.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect } from 'react';
+import React, { useCallback, useEffect, memo } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
@@ -56,4 +56,4 @@ const UnsavedAlertDialog = (): JSX.Element => {
   return <></>;
   return <></>;
 };
 };
 
 
-export default UnsavedAlertDialog;
+export default memo(UnsavedAlertDialog);

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

@@ -1,4 +1,6 @@
 export const PageActionType = {
 export const PageActionType = {
+  Create: 'Create',
+  Update: 'Update',
   Rename: 'Rename',
   Rename: 'Rename',
   Duplicate: 'Duplicate',
   Duplicate: 'Duplicate',
   Delete: 'Delete',
   Delete: 'Delete',

+ 21 - 4
packages/app/src/pages/[[...path]].page.tsx

@@ -63,7 +63,7 @@ import {
   useIsEnabledStaleNotification, useIsIdenticalPath,
   useIsEnabledStaleNotification, useIsIdenticalPath,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useDisableLinkSharing,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useDisableLinkSharing,
   useDrawioUri, useHackmdUri, useDefaultIndentSize, useIsIndentSizeForced,
   useDrawioUri, useHackmdUri, useDefaultIndentSize, useIsIndentSizeForced,
-  useIsAclEnabled, useIsSearchPage,
+  useIsAclEnabled, useIsSearchPage, useTemplateTagData,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
   useIsSlackConfigured, useRendererConfig, useEditingMarkdown,
   useIsSlackConfigured, useRendererConfig, useEditingMarkdown,
   useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, useCustomizedLogoSrc, useIsContainerFluid,
   useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, useCustomizedLogoSrc, useIsContainerFluid,
@@ -81,6 +81,7 @@ const UnsavedAlertDialog = dynamic(() => import('../components/UnsavedAlertDialo
 const GrowiSubNavigationSwitcher = dynamic(() => import('../components/Navbar/GrowiSubNavigationSwitcher'), { ssr: false });
 const GrowiSubNavigationSwitcher = dynamic(() => import('../components/Navbar/GrowiSubNavigationSwitcher'), { ssr: false });
 const UsersHomePageFooter = dynamic<UsersHomePageFooterProps>(() => import('../components/UsersHomePageFooter')
 const UsersHomePageFooter = dynamic<UsersHomePageFooterProps>(() => import('../components/UsersHomePageFooter')
   .then(mod => mod.UsersHomePageFooter), { ssr: false });
   .then(mod => mod.UsersHomePageFooter), { ssr: false });
+const HandsontableModal = dynamic(() => import('../components/PageEditor/HandsontableModal').then(mod => mod.HandsontableModal), { ssr: false });
 
 
 const logger = loggerFactory('growi:pages:all');
 const logger = loggerFactory('growi:pages:all');
 
 
@@ -135,7 +136,7 @@ type Props = CommonProps & {
   redirectFrom?: string;
   redirectFrom?: string;
 
 
   // shareLinkId?: string;
   // shareLinkId?: string;
-  isLatestRevision?: boolean
+  isLatestRevision?: boolean,
 
 
   isIdenticalPathPage?: boolean,
   isIdenticalPathPage?: boolean,
   isForbidden: boolean,
   isForbidden: boolean,
@@ -143,6 +144,9 @@ type Props = CommonProps & {
   isNotCreatablePage: boolean,
   isNotCreatablePage: boolean,
   // isAbleToDeleteCompletely: boolean,
   // isAbleToDeleteCompletely: boolean,
 
 
+  templateTagData?: string[],
+  templateBodyData?: string,
+
   isSearchServiceConfigured: boolean,
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
   isSearchScopeChildrenAsDefault: boolean,
@@ -212,6 +216,8 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   useIsEnabledStaleNotification(props.isEnabledStaleNotification);
   useIsEnabledStaleNotification(props.isEnabledStaleNotification);
   useIsSearchPage(false);
   useIsSearchPage(false);
 
 
+  useTemplateTagData(props.templateTagData);
+
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
@@ -244,7 +250,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   useCurrentPathname(props.currentPathname);
   useCurrentPathname(props.currentPathname);
 
 
   const { data: currentPage } = useSWRxCurrentPage(undefined, pageWithMeta?.data ?? null); // store initial data
   const { data: currentPage } = useSWRxCurrentPage(undefined, pageWithMeta?.data ?? null); // store initial data
-  useEditingMarkdown(pageWithMeta?.data.revision?.body ?? '');
+  useEditingMarkdown(pageWithMeta?.data.revision?.body ?? props.templateBodyData ?? '');
 
 
   const { data: grantData } = useSWRxIsGrantNormalized(pageId);
   const { data: grantData } = useSWRxIsGrantNormalized(pageId);
   const { mutate: mutateSelectedGrant } = useSelectedGrant();
   const { mutate: mutateSelectedGrant } = useSelectedGrant();
@@ -334,7 +340,9 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
           </div>
           </div>
           { !props.isIdenticalPathPage && !props.isNotFound && (
           { !props.isIdenticalPathPage && !props.isNotFound && (
             <footer className="footer d-edit-none">
             <footer className="footer d-edit-none">
-              { pageWithMeta != null && !isTopPagePath && (<Comments pageId={pageId} revision={pageWithMeta.data.revision} />) }
+              { pageWithMeta != null && pagePath != null && !isTopPagePath && (
+                <Comments pageId={pageId} pagePath={pagePath} revision={pageWithMeta.data.revision} />
+              ) }
               { pageWithMeta != null && isUsersHomePage(pageWithMeta.data.path) && (
               { pageWithMeta != null && isUsersHomePage(pageWithMeta.data.path) && (
                 <UsersHomePageFooter creatorId={pageWithMeta.data.creator._id}/>
                 <UsersHomePageFooter creatorId={pageWithMeta.data.creator._id}/>
               ) }
               ) }
@@ -344,6 +352,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
 
 
           <UnsavedAlertDialog />
           <UnsavedAlertDialog />
           <DescendantsPageListModal />
           <DescendantsPageListModal />
+          <HandsontableModal />
           {shouldRenderPutbackPageModal && <PutbackPageModal />}
           {shouldRenderPutbackPageModal && <PutbackPageModal />}
         </div>
         </div>
       </BasicLayout>
       </BasicLayout>
@@ -418,6 +427,14 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
     props.isLatestRevision = page.isLatestRevision();
     props.isLatestRevision = page.isLatestRevision();
   }
   }
 
 
+  if (page == null && user != null) {
+    const templateData = await Page.findTemplate(props.currentPathname);
+    if (templateData != null) {
+      props.templateTagData = templateData.templateTags as string[];
+      props.templateBodyData = templateData.templateBody as string;
+    }
+  }
+
   props.pageWithMeta = pageWithMeta;
   props.pageWithMeta = pageWithMeta;
 }
 }
 
 

+ 0 - 1
packages/app/src/pages/admin/[...path].page.tsx

@@ -17,7 +17,6 @@ const AdminAppPage: NextPage<CommonProps> = (props) => {
   useIsMaintenanceMode(props.isMaintenanceMode);
   useIsMaintenanceMode(props.isMaintenanceMode);
   useCurrentUser(props.currentUser ?? null);
   useCurrentUser(props.currentUser ?? null);
 
 
-
   return (
   return (
     <AdminLayout>
     <AdminLayout>
       <AdminNotFoundPage />
       <AdminNotFoundPage />

+ 0 - 1
packages/app/src/pages/admin/security.page.tsx

@@ -38,7 +38,6 @@ const AdminSecuritySettingsPage: NextPage<Props> = (props) => {
   useSiteUrl(props.siteUrl);
   useSiteUrl(props.siteUrl);
   useIsMailerSetup(props.isMailerSetup);
   useIsMailerSetup(props.isMailerSetup);
 
 
-
   const title = t('security_settings.security_settings');
   const title = t('security_settings.security_settings');
   const adminSecurityContainers: Container<any>[] = [];
   const adminSecurityContainers: Container<any>[] = [];
 
 

+ 1 - 1
packages/app/src/pages/login.page.tsx

@@ -82,7 +82,7 @@ function injectEnabledStrategies(context: GetServerSidePropsContext, props: Prop
     github: configManager.getConfig('crowi', 'security:passport-github:isEnabled'),
     github: configManager.getConfig('crowi', 'security:passport-github:isEnabled'),
     facebook: false,
     facebook: false,
     twitter: configManager.getConfig('crowi', 'security:passport-twitter:isEnabled'),
     twitter: configManager.getConfig('crowi', 'security:passport-twitter:isEnabled'),
-    smal: configManager.getConfig('crowi', 'security:passport-saml:isEnabled'),
+    saml: configManager.getConfig('crowi', 'security:passport-saml:isEnabled'),
     oidc: configManager.getConfig('crowi', 'security:passport-oidc:isEnabled'),
     oidc: configManager.getConfig('crowi', 'security:passport-oidc:isEnabled'),
     basic: configManager.getConfig('crowi', 'security:passport-basic:isEnabled'),
     basic: configManager.getConfig('crowi', 'security:passport-basic:isEnabled'),
   };
   };

+ 2 - 2
packages/app/src/pages/me/[[...path]].page.tsx

@@ -53,7 +53,7 @@ const InAppNotificationPage = dynamic(
 
 
 const MePage: NextPage<Props> = (props: Props) => {
 const MePage: NextPage<Props> = (props: Props) => {
   const router = useRouter();
   const router = useRouter();
-  const { t } = useTranslation();
+  const { t } = useTranslation(['translation', 'commons']);
   const { path } = router.query;
   const { path } = router.query;
   const pagePathKeys: string[] = Array.isArray(path) ? path : ['personal-settings'];
   const pagePathKeys: string[] = Array.isArray(path) ? path : ['personal-settings'];
 
 
@@ -68,7 +68,7 @@ const MePage: NextPage<Props> = (props: Props) => {
       //   component: <MyDraftList />,
       //   component: <MyDraftList />,
       // },
       // },
       'all-in-app-notifications': {
       'all-in-app-notifications': {
-        title: t('in_app_notification.notification_list'),
+        title: t('commons:in_app_notification.notification_list'),
         component: <InAppNotificationPage />,
         component: <InAppNotificationPage />,
       },
       },
     };
     };

+ 20 - 0
packages/app/src/pages/tags.page.tsx

@@ -7,10 +7,14 @@ import dynamic from 'next/dynamic';
 import Head from 'next/head';
 import Head from 'next/head';
 
 
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
+import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { IDataTagCount } from '~/interfaces/tag';
 import type { IDataTagCount } from '~/interfaces/tag';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { UserUISettingsModel } from '~/server/models/user-ui-settings';
 import type { UserUISettingsModel } from '~/server/models/user-ui-settings';
 import { useSWRxTagsList } from '~/stores/tag';
 import { useSWRxTagsList } from '~/stores/tag';
+import {
+  usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
+} from '~/stores/ui';
 
 
 import { BasicLayout } from '../components/Layout/BasicLayout';
 import { BasicLayout } from '../components/Layout/BasicLayout';
 import {
 import {
@@ -30,7 +34,12 @@ type Props = CommonProps & {
   isSearchServiceConfigured: boolean,
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
   isSearchScopeChildrenAsDefault: boolean,
+
+  // ui
   userUISettings?: IUserUISettings
   userUISettings?: IUserUISettings
+
+  // sidebar
+  sidebarConfig: ISidebarConfig,
 };
 };
 
 
 const TagList = dynamic(() => import('~/components/TagList'), { ssr: false });
 const TagList = dynamic(() => import('~/components/TagList'), { ssr: false });
@@ -58,6 +67,12 @@ const TagPage: NextPage<CommonProps> = (props: Props) => {
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
 
 
+  usePreferDrawerModeByUser(props.userUISettings?.preferDrawerModeByUser ?? props.sidebarConfig.isSidebarDrawerMode);
+  usePreferDrawerModeOnEditByUser(props.userUISettings?.preferDrawerModeOnEditByUser);
+  useSidebarCollapsed(props.userUISettings?.isSidebarCollapsed ?? props.sidebarConfig.isSidebarClosedAtDockMode);
+  useCurrentSidebarContents(props.userUISettings?.currentSidebarContents);
+  useCurrentProductNavWidth(props.userUISettings?.currentProductNavWidth);
+
   return (
   return (
     <>
     <>
       <Head>
       <Head>
@@ -117,6 +132,11 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.isSearchServiceConfigured = searchService.isConfigured;
   props.isSearchServiceConfigured = searchService.isConfigured;
   props.isSearchServiceReachable = searchService.isReachable;
   props.isSearchServiceReachable = searchService.isReachable;
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
+
+  props.sidebarConfig = {
+    isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
+    isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
+  };
 }
 }
 
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {

+ 0 - 1
packages/app/src/pages/utils/commons.ts

@@ -6,7 +6,6 @@ import { SSRConfig, UserConfig } from 'next-i18next';
 
 
 import * as nextI18NextConfig from '^/config/next-i18next.config';
 import * as nextI18NextConfig from '^/config/next-i18next.config';
 
 
-import { SupportedActionType } from '~/interfaces/activity';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { GrowiThemes } from '~/interfaces/theme';
 import { GrowiThemes } from '~/interfaces/theme';
 
 

+ 18 - 1
packages/app/src/server/models/interfaces/page-operation.ts

@@ -1,3 +1,5 @@
+import { PageGrant } from '~/interfaces/page';
+
 import { ObjectIdLike } from '../../interfaces/mongoose-utils';
 import { ObjectIdLike } from '../../interfaces/mongoose-utils';
 
 
 export type IPageForResuming = {
 export type IPageForResuming = {
@@ -19,8 +21,23 @@ export type IUserForResuming = {
   _id: ObjectIdLike,
   _id: ObjectIdLike,
 };
 };
 
 
+export type IOptionsForUpdate = {
+  grant?: PageGrant,
+  grantUserGroupId?: ObjectIdLike,
+  isSyncRevisionToHackmd?: boolean,
+  overwriteScopesOfDescendants?: boolean,
+};
+
+export type IOptionsForCreate = {
+  format?: string,
+  grantUserGroupId?: ObjectIdLike,
+  grant?: PageGrant,
+  overwriteScopesOfDescendants?: boolean,
+  isSynchronously?: boolean,
+};
+
 export type IOptionsForResuming = {
 export type IOptionsForResuming = {
   updateMetadata?: boolean,
   updateMetadata?: boolean,
   createRedirectPage?: boolean,
   createRedirectPage?: boolean,
   prevDescendantCount?: number,
   prevDescendantCount?: number,
-};
+} & IOptionsForUpdate & IOptionsForCreate;

+ 14 - 117
packages/app/src/server/models/obsolete-page.js

@@ -1,5 +1,6 @@
 import { templateChecker, pagePathUtils, pathUtils } from '@growi/core';
 import { templateChecker, pagePathUtils, pathUtils } from '@growi/core';
 
 
+import { PageGrant } from '~/interfaces/page';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 
 
@@ -647,132 +648,28 @@ export const getPageSchema = (crowi) => {
     return { templateBody, templateTags };
     return { templateBody, templateTags };
   };
   };
 
 
-  async function pushRevision(pageData, newRevision, user) {
-    await newRevision.save();
-    debug('Successfully saved new revision', newRevision);
-
-    pageData.revision = newRevision;
-    pageData.lastUpdateUser = user;
-    pageData.updatedAt = Date.now();
-
-    return pageData.save();
-  }
-
-  async function validateAppliedScope(user, grant, grantUserGroupId) {
-    if (grant === GRANT_USER_GROUP && grantUserGroupId == null) {
-      throw new Error('grant userGroupId is not specified');
-    }
-
-    if (grant === GRANT_USER_GROUP) {
-      const UserGroupRelation = crowi.model('UserGroupRelation');
-      const count = await UserGroupRelation.countByGroupIdAndUser(grantUserGroupId, user);
-
-      if (count === 0) {
-        throw new Error('no relations were exist for group and user.');
-      }
-    }
-  }
-
-  pageSchema.statics.createV4 = async function(path, body, user, options = {}) {
-    /*
-     * v4 compatible process
-     */
-    validateCrowi();
-
-    const Page = this;
-    const Revision = crowi.model('Revision');
-    const format = options.format || 'markdown';
-    const grantUserGroupId = options.grantUserGroupId || null;
-    const expandContentWidth = crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
-
-    // sanitize path
-    path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
-
-    let grant = options.grant;
-    // force public
-    if (isTopPage(path)) {
-      grant = GRANT_PUBLIC;
-    }
-
-    const isExist = await this.count({ path });
-
-    if (isExist) {
-      throw new Error('Cannot create new page to existed path');
-    }
+  pageSchema.statics.applyScopesToDescendantsAsyncronously = async function(parentPage, user, isV4 = false) {
+    const builder = new this.PageQueryBuilder(this.find());
+    builder.addConditionToListOnlyDescendants(parentPage.path);
 
 
-    const page = new Page();
-    page.path = path;
-    page.creator = user;
-    page.lastUpdateUser = user;
-    page.status = STATUS_PUBLISHED;
-    if (expandContentWidth != null) {
-      page.expandContentWidth = expandContentWidth;
+    if (isV4) {
+      builder.addConditionAsRootOrNotOnTree();
     }
     }
-    await validateAppliedScope(user, grant, grantUserGroupId);
-    page.applyScope(user, grant, grantUserGroupId);
-
-    let savedPage = await page.save();
-    const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
-    savedPage = await pushRevision(savedPage, newRevision, user);
-    await savedPage.populateDataToShowRevision();
-
-    pageEvent.emit('create', savedPage, user);
-
-    return savedPage;
-  };
-
-  pageSchema.statics.updatePageV4 = async function(pageData, body, previousBody, user, options = {}) {
-    validateCrowi();
-
-    const Revision = crowi.model('Revision');
-    const grant = options.grant || pageData.grant; //                                  use the previous data if absence
-    const grantUserGroupId = options.grantUserGroupId || pageData.grantUserGroupId; // use the previous data if absence
-    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
-
-    await validateAppliedScope(user, grant, grantUserGroupId);
-    pageData.applyScope(user, grant, grantUserGroupId);
-
-    // update existing page
-    let savedPage = await pageData.save();
-
-    // Update revision
-    const isBodyPresent = body != null && previousBody != null;
-    const shouldUpdateBody = isBodyPresent;
-    if (shouldUpdateBody) {
-      const newRevision = await Revision.prepareRevision(pageData, body, previousBody, user);
-      savedPage = await pushRevision(savedPage, newRevision, user);
-      await savedPage.populateDataToShowRevision();
-
-      if (isSyncRevisionToHackmd) {
-        savedPage = await this.syncRevisionToHackmd(savedPage);
-      }
+    else {
+      builder.addConditionAsOnTree();
     }
     }
 
 
-
-    pageEvent.emit('update', savedPage, user);
-
-    return savedPage;
-  };
-
-  pageSchema.statics.applyScopesToDescendantsAsyncronously = async function(parentPage, user) {
-    const builder = new this.PageQueryBuilder(this.find());
-    builder.addConditionToListWithDescendants(parentPage.path);
-
     // add grant conditions
     // add grant conditions
     await addConditionToFilteringByViewerToEdit(builder, user);
     await addConditionToFilteringByViewerToEdit(builder, user);
 
 
-    // get all pages that the specified user can update
-    const pages = await builder.query.exec();
+    const grant = parentPage.grant;
 
 
-    for (const page of pages) {
-      // skip parentPage
-      if (page.id === parentPage.id) {
-        continue;
-      }
+    await builder.query.updateMany({}, {
+      grant,
+      grantedGroup: grant === PageGrant.GRANT_USER_GROUP ? parentPage.grantedGroup : null,
+      grantedUsers: grant === PageGrant.GRANT_OWNER ? [user._id] : null,
+    });
 
 
-      page.applyScope(user, parentPage.grant, parentPage.grantedGroup);
-      page.save();
-    }
   };
   };
 
 
   pageSchema.statics.removeByPath = function(path) {
   pageSchema.statics.removeByPath = function(path) {

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

@@ -33,6 +33,7 @@ export interface IPageOperation {
   options?: IOptionsForResuming,
   options?: IOptionsForResuming,
   incForUpdatingDescendantCount?: number,
   incForUpdatingDescendantCount?: number,
   unprocessableExpiryDate: Date,
   unprocessableExpiryDate: Date,
+  exPage?: IPageForResuming,
 
 
   isProcessable(): boolean
   isProcessable(): boolean
 }
 }
@@ -71,6 +72,11 @@ const optionsSchemaForResuming = new Schema<IOptionsForResuming>({
   createRedirectPage: { type: Boolean },
   createRedirectPage: { type: Boolean },
   updateMetadata: { type: Boolean },
   updateMetadata: { type: Boolean },
   prevDescendantCount: { type: Number },
   prevDescendantCount: { type: Number },
+  grant: { type: Number },
+  grantUserGroupId: { type: ObjectId, ref: 'UserGroup' },
+  format: { type: String },
+  isSyncRevisionToHackmd: { type: Boolean },
+  overwriteScopesOfDescendants: { type: Boolean },
 }, { _id: false });
 }, { _id: false });
 
 
 const schema = new Schema<PageOperationDocument, PageOperationModel>({
 const schema = new Schema<PageOperationDocument, PageOperationModel>({
@@ -89,6 +95,7 @@ const schema = new Schema<PageOperationDocument, PageOperationModel>({
   fromPath: { type: String, required: true, index: true },
   fromPath: { type: String, required: true, index: true },
   toPath: { type: String, index: true },
   toPath: { type: String, index: true },
   page: { type: pageSchemaForResuming, required: true },
   page: { type: pageSchemaForResuming, required: true },
+  exPage: { type: pageSchemaForResuming, required: false },
   user: { type: userSchemaForResuming, required: true },
   user: { type: userSchemaForResuming, required: true },
   options: { type: optionsSchemaForResuming },
   options: { type: optionsSchemaForResuming },
   incForUpdatingDescendantCount: { type: Number },
   incForUpdatingDescendantCount: { type: Number },

+ 5 - 165
packages/app/src/server/models/page.ts

@@ -340,7 +340,7 @@ export class PageQueryBuilder {
           { grant: { $ne: GRANT_SPECIFIED } },
           { grant: { $ne: GRANT_SPECIFIED } },
         ],
         ],
       });
       });
-    this.addConditionAsNotMigrated();
+    this.addConditionAsRootOrNotOnTree();
     this.addConditionAsNonRootPage();
     this.addConditionAsNonRootPage();
     this.addConditionToExcludeTrashed();
     this.addConditionToExcludeTrashed();
     await this.addConditionForParentNormalization(user);
     await this.addConditionForParentNormalization(user);
@@ -384,7 +384,7 @@ export class PageQueryBuilder {
     return this;
     return this;
   }
   }
 
 
-  addConditionAsNotMigrated(): PageQueryBuilder {
+  addConditionAsRootOrNotOnTree(): PageQueryBuilder {
     this.query = this.query
     this.query = this.query
       .and({ parent: null });
       .and({ parent: null });
 
 
@@ -956,177 +956,17 @@ export type PageCreateOptions = {
   format?: string
   format?: string
   grantUserGroupId?: ObjectIdLike
   grantUserGroupId?: ObjectIdLike
   grant?: number
   grant?: number
+  overwriteScopesOfDescendants?: boolean
 }
 }
 
 
 /*
 /*
  * Merge obsolete page model methods and define new methods which depend on crowi instance
  * Merge obsolete page model methods and define new methods which depend on crowi instance
  */
  */
-// remove type for crowi to prevent 'import/no-cycle'
-// eslint-disable-next-line import/no-anonymous-default-export
-export default (crowi): any => {
-  let pageEvent;
-  if (crowi != null) {
-    pageEvent = crowi.event('page');
-  }
-
-  const shouldUseUpdatePageV4 = (grant: number, isV5Compatible: boolean, isOnTree: boolean): boolean => {
-    const isRestricted = grant === GRANT_RESTRICTED;
-    return !isRestricted && (!isV5Compatible || !isOnTree);
-  };
-
-  schema.statics.emitPageEventUpdate = (page: IPageHasId, user: IUserHasId): void => {
-    pageEvent.emit('update', page, user);
-  };
-
-  /**
-   * A wrapper method of schema.statics.updatePage for updating grant only.
-   * @param {PageDocument} page
-   * @param {UserDocument} user
-   * @param options
-   */
-  schema.statics.updateGrant = async function(page, user, grantData: {grant: PageGrant, grantedGroup: ObjectIdLike}) {
-    const { grant, grantedGroup } = grantData;
-
-    const options = {
-      grant,
-      grantUserGroupId: grantedGroup,
-      isSyncRevisionToHackmd: false,
-    };
-
-    return this.updatePage(page, null, null, user, options);
-  };
-
-  schema.statics.updatePage = async function(
-      pageData,
-      body: string | null,
-      previousBody: string | null,
-      user,
-      options: {grant?: PageGrant, grantUserGroupId?: ObjectIdLike, isSyncRevisionToHackmd?: boolean} = {},
-  ) {
-    if (crowi.configManager == null || crowi.pageGrantService == null || crowi.pageService == null) {
-      throw Error('Crowi is not set up');
-    }
-
-    const wasOnTree = pageData.parent != null || isTopPage(pageData.path);
-    const exParent = pageData.parent;
-    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-
-    const shouldUseV4Process = shouldUseUpdatePageV4(pageData.grant, isV5Compatible, wasOnTree);
-    if (shouldUseV4Process) {
-      // v4 compatible process
-      return this.updatePageV4(pageData, body, previousBody, user, options);
-    }
-
-    const grant = options.grant ?? pageData.grant; // use the previous data if absence
-    const grantUserGroupId: undefined | ObjectIdLike = options.grantUserGroupId ?? pageData.grantedGroup?._id.toString();
-
-    const grantedUserIds = pageData.grantedUserIds || [user._id];
-    const shouldBeOnTree = grant !== GRANT_RESTRICTED;
-    const isChildrenExist = await this.count({ path: new RegExp(`^${escapeStringRegexp(addTrailingSlash(pageData.path))}`), parent: { $ne: null } });
-
-    const newPageData = pageData;
-
-    if (shouldBeOnTree) {
-      let isGrantNormalized = false;
-      try {
-        isGrantNormalized = await crowi.pageGrantService.isGrantNormalized(user, pageData.path, grant, grantedUserIds, grantUserGroupId, true);
-      }
-      catch (err) {
-        logger.error(`Failed to validate grant of page at "${pageData.path}" of grant ${grant}:`, err);
-        throw err;
-      }
-      if (!isGrantNormalized) {
-        throw Error('The selected grant or grantedGroup is not assignable to this page.');
-      }
-
-      if (!wasOnTree) {
-        const newParent = await crowi.pageService.getParentAndFillAncestorsByUser(user, newPageData.path);
-        newPageData.parent = newParent._id;
-      }
-    }
-    else {
-      if (wasOnTree && isChildrenExist) {
-        // Update children's parent with new parent
-        const newParentForChildren = await this.createEmptyPage(pageData.path, pageData.parent, pageData.descendantCount);
-        await this.updateMany(
-          { parent: pageData._id },
-          { parent: newParentForChildren._id },
-        );
-      }
-
-      newPageData.parent = null;
-      newPageData.descendantCount = 0;
-    }
-
-    newPageData.applyScope(user, grant, grantUserGroupId);
-
-    // update existing page
-    let savedPage = await newPageData.save();
-
-    // Update body
-    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
-    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
-    const isBodyPresent = body != null && previousBody != null;
-    const shouldUpdateBody = isBodyPresent;
-    if (shouldUpdateBody) {
-      const newRevision = await Revision.prepareRevision(newPageData, body, previousBody, user);
-      savedPage = await pushRevision(savedPage, newRevision, user);
-      await savedPage.populateDataToShowRevision();
-
-      if (isSyncRevisionToHackmd) {
-        savedPage = await this.syncRevisionToHackmd(savedPage);
-      }
-    }
-
-
-    this.emitPageEventUpdate(savedPage, user);
-
-    // Update ex children's parent
-    if (!wasOnTree && shouldBeOnTree) {
-      const emptyPageAtSamePath = await this.findOne({ path: pageData.path, isEmpty: true }); // this page is necessary to find children
-
-      if (isChildrenExist) {
-        if (emptyPageAtSamePath != null) {
-          // Update children's parent with new parent
-          await this.updateMany(
-            { parent: emptyPageAtSamePath._id },
-            { parent: savedPage._id },
-          );
-        }
-      }
-
-      await this.findOneAndDelete({ path: pageData.path, isEmpty: true }); // delete here
-    }
-
-    // Sub operation
-    // 1. Update descendantCount
-    const shouldPlusDescCount = !wasOnTree && shouldBeOnTree;
-    const shouldMinusDescCount = wasOnTree && !shouldBeOnTree;
-    if (shouldPlusDescCount) {
-      await crowi.pageService.updateDescendantCountOfAncestors(newPageData._id, 1, false);
-      const newDescendantCount = await this.recountDescendantCount(newPageData._id);
-      await this.updateOne({ _id: newPageData._id }, { descendantCount: newDescendantCount });
-    }
-    else if (shouldMinusDescCount) {
-      // Update from parent. Parent is null if newPageData.grant is RESTRECTED.
-      if (newPageData.grant === GRANT_RESTRICTED) {
-        await crowi.pageService.updateDescendantCountOfAncestors(exParent, -1, true);
-      }
-    }
-
-    // 2. Delete unnecessary empty pages
-    const shouldRemoveLeafEmpPages = wasOnTree && !isChildrenExist;
-    if (shouldRemoveLeafEmpPages) {
-      await this.removeLeafEmptyPagesRecursively(exParent);
-    }
-
-    return savedPage;
-  };
-
+export default function PageModel(crowi): any {
   // add old page schema methods
   // add old page schema methods
   const pageSchema = getPageSchema(crowi);
   const pageSchema = getPageSchema(crowi);
   schema.methods = { ...pageSchema.methods, ...schema.methods };
   schema.methods = { ...pageSchema.methods, ...schema.methods };
   schema.statics = { ...pageSchema.statics, ...schema.statics };
   schema.statics = { ...pageSchema.statics, ...schema.statics };
 
 
   return getOrCreateModel<PageDocument, PageModel>('Page', schema as any); // TODO: improve type
   return getOrCreateModel<PageDocument, PageModel>('Page', schema as any); // TODO: improve type
-};
+}

+ 9 - 0
packages/app/src/server/models/user-group-relation.js

@@ -93,6 +93,15 @@ class UserGroupRelation {
       .exec();
       .exec();
   }
   }
 
 
+  static async findAllUserIdsForUserGroup(userGroup) {
+    const relations = await this
+      .find({ relatedGroup: userGroup })
+      .select('relatedUser')
+      .exec();
+
+    return relations.map(r => r.relatedUser);
+  }
+
   /**
   /**
    * find all user and group relation of UserGroups
    * find all user and group relation of UserGroups
    *
    *

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

@@ -109,6 +109,7 @@ schema.statics.findGroupsWithAncestorsRecursively = async function(group, ancest
 };
 };
 
 
 /**
 /**
+ * TODO: use $graphLookup
  * Find all descendant groups starting from the UserGroups in the initial groups in "groups".
  * Find all descendant groups starting from the UserGroups in the initial groups in "groups".
  * Set "descendants" as "[]" if the initial groups are unnecessary as result.
  * Set "descendants" as "[]" if the initial groups are unnecessary as result.
  * @param groups UserGroupDocument[] including at least one UserGroup
  * @param groups UserGroupDocument[] including at least one UserGroup

+ 5 - 3
packages/app/src/server/routes/apiv3/page.js

@@ -255,7 +255,9 @@ module.exports = (crowi) => {
    */
    */
   router.get('/', certifySharedPage, accessTokenParser, loginRequired, validator.getPage, apiV3FormValidator, async(req, res) => {
   router.get('/', certifySharedPage, accessTokenParser, loginRequired, validator.getPage, apiV3FormValidator, async(req, res) => {
     const { user } = req;
     const { user } = req;
-    const { pageId, path, findAll } = req.query;
+    const {
+      pageId, path, findAll, revisionId,
+    } = req.query;
 
 
     if (pageId == null && path == null) {
     if (pageId == null && path == null) {
       return res.apiv3Err(new ErrorV3('Either parameter of path or pageId is required.', 'invalid-request'));
       return res.apiv3Err(new ErrorV3('Either parameter of path or pageId is required.', 'invalid-request'));
@@ -285,7 +287,7 @@ module.exports = (crowi) => {
 
 
     if (page != null) {
     if (page != null) {
       try {
       try {
-        page.initLatestRevisionField();
+        page.initLatestRevisionField(revisionId);
 
 
         // populate
         // populate
         page = await page.populateDataToShowRevision();
         page = await page.populateDataToShowRevision();
@@ -557,7 +559,7 @@ module.exports = (crowi) => {
     try {
     try {
       const shouldUseV4Process = false;
       const shouldUseV4Process = false;
       const grantData = { grant, grantedGroup };
       const grantData = { grant, grantedGroup };
-      data = await Page.updateGrant(page, req.user, grantData, shouldUseV4Process);
+      data = await this.crowi.pageService.updateGrant(page, req.user, grantData, shouldUseV4Process);
     }
     }
     catch (err) {
     catch (err) {
       logger.error('Error occurred while processing calcApplicableGrantData.', err);
       logger.error('Error occurred while processing calcApplicableGrantData.', err);

+ 8 - 8
packages/app/src/server/routes/apiv3/pages.js

@@ -1,4 +1,3 @@
-import { ErrorV3 } from '@growi/core';
 
 
 import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
@@ -8,6 +7,8 @@ import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { isV5ConversionError } from '../../models/vo/v5-conversion-error';
 import { isV5ConversionError } from '../../models/vo/v5-conversion-error';
 
 
+import { ErrorV3 } from '@growi/core';
+
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
 const { pathUtils, pagePathUtils } = require('@growi/core');
 const { pathUtils, pagePathUtils } = require('@growi/core');
 const express = require('express');
 const express = require('express');
@@ -298,7 +299,7 @@ module.exports = (crowi) => {
     // check whether path starts slash
     // check whether path starts slash
     path = pathUtils.addHeadingSlash(path);
     path = pathUtils.addHeadingSlash(path);
 
 
-    const options = {};
+    const options = { overwriteScopesOfDescendants };
     if (grant != null) {
     if (grant != null) {
       options.grant = grant;
       options.grant = grant;
       options.grantUserGroupId = grantUserGroupId;
       options.grantUserGroupId = grantUserGroupId;
@@ -323,11 +324,6 @@ module.exports = (crowi) => {
       revision: serializeRevisionSecurely(createdPage.revision),
       revision: serializeRevisionSecurely(createdPage.revision),
     };
     };
 
 
-    // update scopes for descendants
-    if (overwriteScopesOfDescendants) {
-      Page.applyScopesToDescendantsAsyncronously(createdPage, req.user);
-    }
-
     const parameters = {
     const parameters = {
       targetModel: SupportedTargetModel.MODEL_PAGE,
       targetModel: SupportedTargetModel.MODEL_PAGE,
       target: createdPage,
       target: createdPage,
@@ -632,7 +628,11 @@ module.exports = (crowi) => {
     // when all pages are deletable
     // when all pages are deletable
     else {
     else {
       try {
       try {
-        const pages = await crowi.pageService.emptyTrashPage(req.user, options);
+        const activityParameters = {
+          ip: req.ip,
+          endpoint: req.originalUrl,
+        };
+        const pages = await crowi.pageService.emptyTrashPage(req.user, options, activityParameters);
 
 
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 

+ 3 - 13
packages/app/src/server/routes/page.js

@@ -870,7 +870,7 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Page exists', 'already_exists'));
       return res.json(ApiResponse.error('Page exists', 'already_exists'));
     }
     }
 
 
-    const options = {};
+    const options = { overwriteScopesOfDescendants };
     if (grant != null) {
     if (grant != null) {
       options.grant = grant;
       options.grant = grant;
       options.grantUserGroupId = grantUserGroupId;
       options.grantUserGroupId = grantUserGroupId;
@@ -891,11 +891,6 @@ module.exports = function(crowi, app) {
     };
     };
     res.json(ApiResponse.success(result));
     res.json(ApiResponse.success(result));
 
 
-    // update scopes for descendants
-    if (overwriteScopesOfDescendants) {
-      Page.applyScopesToDescendantsAsyncronously(createdPage, req.user);
-    }
-
     // global notification
     // global notification
     try {
     try {
       await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, createdPage, req.user);
       await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, createdPage, req.user);
@@ -1014,7 +1009,7 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'conflict', returnLatestRevision));
       return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'conflict', returnLatestRevision));
     }
     }
 
 
-    const options = { isSyncRevisionToHackmd };
+    const options = { isSyncRevisionToHackmd, overwriteScopesOfDescendants };
     if (grant != null) {
     if (grant != null) {
       options.grant = grant;
       options.grant = grant;
       options.grantUserGroupId = grantUserGroupId;
       options.grantUserGroupId = grantUserGroupId;
@@ -1022,7 +1017,7 @@ module.exports = function(crowi, app) {
 
 
     const previousRevision = await Revision.findById(revisionId);
     const previousRevision = await Revision.findById(revisionId);
     try {
     try {
-      page = await Page.updatePage(page, pageBody, previousRevision.body, req.user, options);
+      page = await crowi.pageService.updatePage(page, pageBody, previousRevision.body, req.user, options);
     }
     }
     catch (err) {
     catch (err) {
       logger.error('error on _api/pages.update', err);
       logger.error('error on _api/pages.update', err);
@@ -1044,11 +1039,6 @@ module.exports = function(crowi, app) {
     };
     };
     res.json(ApiResponse.success(result));
     res.json(ApiResponse.success(result));
 
 
-    // update scopes for descendants
-    if (overwriteScopesOfDescendants) {
-      Page.applyScopesToDescendantsAsyncronously(page, req.user);
-    }
-
     // global notification
     // global notification
     try {
     try {
       await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_EDIT, page, req.user);
       await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_EDIT, page, req.user);

+ 1 - 1
packages/app/src/server/routes/tag.js

@@ -159,7 +159,7 @@ module.exports = function(crowi, app) {
       }
       }
 
 
       const previousRevision = await Revision.findById(revisionId);
       const previousRevision = await Revision.findById(revisionId);
-      result.savedPage = await Page.updatePage(page, previousRevision.body, previousRevision.body, req.user);
+      result.savedPage = await crowi.pageService.updatePage(page, previousRevision.body, previousRevision.body, req.user);
       await PageTagRelation.updatePageTags(pageId, tags);
       await PageTagRelation.updatePageTags(pageId, tags);
       result.tags = await PageTagRelation.listTagNamesByPage(pageId);
       result.tags = await PageTagRelation.listTagNamesByPage(pageId);
 
 

+ 1 - 1
packages/app/src/server/service/installer.ts

@@ -52,7 +52,7 @@ export class InstallerService {
   private async createPage(filePath, pagePath, owner): Promise<IPage|undefined> {
   private async createPage(filePath, pagePath, owner): Promise<IPage|undefined> {
     try {
     try {
       const markdown = fs.readFileSync(filePath);
       const markdown = fs.readFileSync(filePath);
-      return this.crowi.pageService.create(pagePath, markdown, owner, {}) as IPage;
+      return this.crowi.pageService.create(pagePath, markdown, owner, { isSynchronously: true }) as IPage;
     }
     }
     catch (err) {
     catch (err) {
       logger.error(`Failed to create ${pagePath}`, err);
       logger.error(`Failed to create ${pagePath}`, err);

+ 187 - 16
packages/app/src/server/service/page-grant.ts

@@ -1,4 +1,7 @@
-import { pagePathUtils, pathUtils, pageUtils } from '@growi/core';
+import {
+  pagePathUtils, pathUtils, pageUtils,
+  PageGrant, PageGrantCanBeOnTree,
+} from '@growi/core';
 import escapeStringRegexp from 'escape-string-regexp';
 import escapeStringRegexp from 'escape-string-regexp';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
@@ -35,6 +38,39 @@ type ComparableDescendants = {
   grantedGroupIds: ObjectIdLike[],
   grantedGroupIds: ObjectIdLike[],
 };
 };
 
 
+/**
+ * @param grantedUserGroupInfo This parameter has info to calculate whether the update operation is allowed.
+ *   - See the `calcCanOverwriteDescendants` private method for detail.
+ */
+type UpdateGrantInfo = {
+  grant: typeof PageGrant.GRANT_PUBLIC,
+} | {
+  grant: typeof PageGrant.GRANT_OWNER,
+  grantedUserId: ObjectIdLike,
+} | {
+  grant: typeof PageGrant.GRANT_USER_GROUP,
+  grantedUserGroupInfo: {
+    groupId: ObjectIdLike,
+    userIds: Set<ObjectIdLike>,
+    childrenOrItselfGroupIds: Set<ObjectIdLike>,
+  },
+};
+
+type DescendantPagesGrantInfo = {
+  grantSet: Set<number>,
+  grantedUserIds: Set<ObjectIdLike>, // all only me users of descendant pages
+  grantedUserGroupIds: Set<ObjectIdLike>, // all user groups of descendant pages
+};
+
+/**
+ * @param {ObjectIdLike} userId The _id of the operator.
+ * @param {Set<ObjectIdLike>} userGroupIds The Set of the _id of the user groups that the operator belongs.
+ */
+type OperatorGrantInfo = {
+  userId: ObjectIdLike,
+  userGroupIds: Set<ObjectIdLike>,
+};
+
 class PageGrantService {
 class PageGrantService {
 
 
   crowi!: any;
   crowi!: any;
@@ -260,7 +296,7 @@ class PageGrantService {
    * @param targetPath string of the target path
    * @param targetPath string of the target path
    * @returns ComparableDescendants
    * @returns ComparableDescendants
    */
    */
-  private async generateComparableDescendants(targetPath: string, user, includeNotMigratedPages: boolean): Promise<ComparableDescendants> {
+  private async generateComparableDescendants(targetPath: string, user, includeNotMigratedPages = false): Promise<ComparableDescendants> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
     const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
     const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
 
 
@@ -412,22 +448,22 @@ class PageGrantService {
     const isOnlyPublicApplicable = isTopPage(page.path);
     const isOnlyPublicApplicable = isTopPage(page.path);
     if (isOnlyPublicApplicable) {
     if (isOnlyPublicApplicable) {
       return {
       return {
-        [Page.GRANT_PUBLIC]: null,
+        [PageGrant.GRANT_PUBLIC]: null,
       };
       };
     }
     }
 
 
     // Increment an object (type IRecordApplicableGrant)
     // Increment an object (type IRecordApplicableGrant)
     // grant is never public, anyone with the link, nor specified
     // grant is never public, anyone with the link, nor specified
     const data: IRecordApplicableGrant = {
     const data: IRecordApplicableGrant = {
-      [Page.GRANT_RESTRICTED]: null, // any page can be restricted
+      [PageGrant.GRANT_RESTRICTED]: null, // any page can be restricted
     };
     };
 
 
     // -- Any grant is allowed if parent is null
     // -- Any grant is allowed if parent is null
     const isAnyGrantApplicable = page.parent == null;
     const isAnyGrantApplicable = page.parent == null;
     if (isAnyGrantApplicable) {
     if (isAnyGrantApplicable) {
-      data[Page.GRANT_PUBLIC] = null;
-      data[Page.GRANT_OWNER] = null;
-      data[Page.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+      data[PageGrant.GRANT_PUBLIC] = null;
+      data[PageGrant.GRANT_OWNER] = null;
+      data[PageGrant.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
       return data;
       return data;
     }
     }
 
 
@@ -440,21 +476,21 @@ class PageGrantService {
       grant, grantedUsers, grantedGroup,
       grant, grantedUsers, grantedGroup,
     } = parent;
     } = parent;
 
 
-    if (grant === Page.GRANT_PUBLIC) {
-      data[Page.GRANT_PUBLIC] = null;
-      data[Page.GRANT_OWNER] = null;
-      data[Page.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    if (grant === PageGrant.GRANT_PUBLIC) {
+      data[PageGrant.GRANT_PUBLIC] = null;
+      data[PageGrant.GRANT_OWNER] = null;
+      data[PageGrant.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     }
     }
-    else if (grant === Page.GRANT_OWNER) {
+    else if (grant === PageGrant.GRANT_OWNER) {
       const grantedUser = grantedUsers[0];
       const grantedUser = grantedUsers[0];
 
 
       const isUserApplicable = grantedUser.toString() === user._id.toString();
       const isUserApplicable = grantedUser.toString() === user._id.toString();
 
 
       if (isUserApplicable) {
       if (isUserApplicable) {
-        data[Page.GRANT_OWNER] = null;
+        data[PageGrant.GRANT_OWNER] = null;
       }
       }
     }
     }
-    else if (grant === Page.GRANT_USER_GROUP) {
+    else if (grant === PageGrant.GRANT_USER_GROUP) {
       const group = await UserGroup.findById(grantedGroup);
       const group = await UserGroup.findById(grantedGroup);
       if (group == null) {
       if (group == null) {
         throw Error('Group not found to calculate grant data.');
         throw Error('Group not found to calculate grant data.');
@@ -465,14 +501,149 @@ class PageGrantService {
       const isUserExistInGroup = await UserGroupRelation.countByGroupIdAndUser(group, user) > 0;
       const isUserExistInGroup = await UserGroupRelation.countByGroupIdAndUser(group, user) > 0;
 
 
       if (isUserExistInGroup) {
       if (isUserExistInGroup) {
-        data[Page.GRANT_OWNER] = null;
+        data[PageGrant.GRANT_OWNER] = null;
       }
       }
-      data[Page.GRANT_USER_GROUP] = { applicableGroups };
+      data[PageGrant.GRANT_USER_GROUP] = { applicableGroups };
     }
     }
 
 
     return data;
     return data;
   }
   }
 
 
+  /**
+   * see: https://dev.growi.org/635a314eac6bcd85cbf359fc
+   * @param {string} targetPath
+   * @param operator
+   * @param {UpdateGrantInfo} updateGrantInfo
+   * @returns {Promise<boolean>}
+   */
+  async canOverwriteDescendants(targetPath: string, operator: { _id: ObjectIdLike }, updateGrantInfo: UpdateGrantInfo): Promise<boolean> {
+    const UserGroupRelationModel = mongoose.model('UserGroupRelation') as any; // TODO: TypeScriptize model
+
+    const relatedGroupIds = await UserGroupRelationModel.findAllUserGroupIdsRelatedToUser(operator);
+    const operatorGrantInfo = {
+      userId: operator._id,
+      userGroupIds: new Set<ObjectIdLike>(relatedGroupIds),
+    };
+
+    const comparableDescendants = await this.generateComparableDescendants(targetPath, operator);
+
+    const grantSet = new Set<PageGrant>();
+    if (comparableDescendants.isPublicExist) {
+      grantSet.add(PageGrant.GRANT_PUBLIC);
+    }
+    if (comparableDescendants.grantedUserIds.length > 0) {
+      grantSet.add(PageGrant.GRANT_OWNER);
+    }
+    if (comparableDescendants.grantedGroupIds.length > 0) {
+      grantSet.add(PageGrant.GRANT_USER_GROUP);
+    }
+    const descendantPagesGrantInfo = {
+      grantSet,
+      grantedUserIds: new Set(comparableDescendants.grantedUserIds), // all only me users of descendant pages
+      grantedUserGroupIds: new Set(comparableDescendants.grantedGroupIds), // all user groups of descendant pages
+    };
+
+    return this.calcCanOverwriteDescendants(operatorGrantInfo, updateGrantInfo, descendantPagesGrantInfo);
+  }
+
+  async generateUpdateGrantInfoToOverwriteDescendants(operator, updateGrant: PageGrantCanBeOnTree, grantUserGroupId?: ObjectIdLike): Promise<UpdateGrantInfo> {
+    let updateGrantInfo: UpdateGrantInfo | null = null;
+
+    if (updateGrant === PageGrant.GRANT_PUBLIC) {
+      updateGrantInfo = {
+        grant: PageGrant.GRANT_PUBLIC,
+      };
+    }
+    else if (updateGrant === PageGrant.GRANT_OWNER) {
+      updateGrantInfo = {
+        grant: PageGrant.GRANT_OWNER,
+        grantedUserId: operator._id,
+      };
+    }
+    else if (updateGrant === PageGrant.GRANT_USER_GROUP) {
+      if (grantUserGroupId == null) {
+        throw Error('The parameter `grantUserGroupId` is required.');
+      }
+      const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
+      const userIds = await UserGroupRelation.findAllUserIdsForUserGroup(grantUserGroupId);
+      const childrenOrItselfGroups = await UserGroup.findGroupsWithDescendantsById(grantUserGroupId);
+      const childrenOrItselfGroupIds = childrenOrItselfGroups.map(d => d._id);
+
+      updateGrantInfo = {
+        grant: PageGrant.GRANT_USER_GROUP,
+        grantedUserGroupInfo: {
+          groupId: grantUserGroupId,
+          userIds: new Set<ObjectIdLike>(userIds),
+          childrenOrItselfGroupIds: new Set<ObjectIdLike>(childrenOrItselfGroupIds),
+        },
+      };
+    }
+
+    if (updateGrantInfo == null) {
+      throw Error('The parameter `updateGrant` must be 1, 4, or 5');
+    }
+
+    return updateGrantInfo;
+  }
+
+  private calcIsAllDescendantsGrantedByOperator(operatorGrantInfo: OperatorGrantInfo, descendantPagesGrantInfo: DescendantPagesGrantInfo): boolean {
+    if (descendantPagesGrantInfo.grantSet.has(PageGrant.GRANT_OWNER)) {
+      const isNonApplicableOwnerExist = descendantPagesGrantInfo.grantedUserIds.size >= 2
+        || !isIncludesObjectId([...descendantPagesGrantInfo.grantedUserIds], operatorGrantInfo.userId);
+      if (isNonApplicableOwnerExist) {
+        return false;
+      }
+    }
+
+    if (descendantPagesGrantInfo.grantSet.has(PageGrant.GRANT_USER_GROUP)) {
+      const isNonApplicableGroupExist = excludeTestIdsFromTargetIds(
+        [...descendantPagesGrantInfo.grantedUserGroupIds], [...operatorGrantInfo.userGroupIds],
+      ).length > 0;
+
+      if (isNonApplicableGroupExist) {
+        return false;
+      }
+    }
+
+    return true;
+  }
+
+  private calcCanOverwriteDescendants(
+      operatorGrantInfo: OperatorGrantInfo, updateGrantInfo: UpdateGrantInfo, descendantPagesGrantInfo: DescendantPagesGrantInfo,
+  ): boolean {
+    // 1. check is tree GRANTED and it returns true when GRANTED
+    //   - GRANTED is the tree with all pages granted by the operator
+    const isAllDescendantsGranted = this.calcIsAllDescendantsGrantedByOperator(operatorGrantInfo, descendantPagesGrantInfo);
+    if (isAllDescendantsGranted) {
+      return true;
+    }
+
+    // 2. if not 1. then,
+    //   - when update grant is PUBLIC, return true
+    if (updateGrantInfo.grant === PageGrant.GRANT_PUBLIC) {
+      return true;
+    }
+    //   - when update grant is ONLYME, return false
+    if (updateGrantInfo.grant === PageGrant.GRANT_OWNER) {
+      return false;
+    }
+    //   - when update grant is USER_GROUP, return true if meets 2 conditions below
+    //      a. if all descendants user groups are children or itself of update user group
+    //      b. if all descendants grantedUsers belong to update user group
+    if (updateGrantInfo.grant === PageGrant.GRANT_USER_GROUP) {
+      const isAllDescendantGroupsChildrenOrItselfOfUpdateGroup = excludeTestIdsFromTargetIds(
+        [...descendantPagesGrantInfo.grantedUserGroupIds], [...updateGrantInfo.grantedUserGroupInfo.childrenOrItselfGroupIds],
+      ).length === 0; // a.
+      const isUpdateGroupUsersIncludeAllDescendantsOwners = excludeTestIdsFromTargetIds(
+        [...descendantPagesGrantInfo.grantedUserIds], [...updateGrantInfo.grantedUserGroupInfo.userIds],
+      ).length === 0; // b.
+
+      return isAllDescendantGroupsChildrenOrItselfOfUpdateGroup && isUpdateGroupUsersIncludeAllDescendantsOwners;
+    }
+
+    return false;
+  }
+
 }
 }
 
 
 export default PageGrantService;
 export default PageGrantService;

+ 2 - 1
packages/app/src/server/service/page-operation.ts

@@ -16,6 +16,7 @@ const {
 const AUTO_UPDATE_INTERVAL_SEC = 5;
 const AUTO_UPDATE_INTERVAL_SEC = 5;
 
 
 const {
 const {
+  Create, Update,
   Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent,
   Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent,
 } = PageActionType;
 } = PageActionType;
 
 
@@ -29,7 +30,7 @@ class PageOperationService {
 
 
   async init(): Promise<void> {
   async init(): Promise<void> {
     // cleanup PageOperation documents except ones with { actionType: Rename, actionStage: Sub }
     // cleanup PageOperation documents except ones with { actionType: Rename, actionStage: Sub }
-    const types = [Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent];
+    const types = [Create, Update, Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent];
     await PageOperation.deleteByActionTypes(types);
     await PageOperation.deleteByActionTypes(types);
     await PageOperation.deleteMany({ actionType: PageActionType.Rename, actionStage: PageActionStage.Main });
     await PageOperation.deleteMany({ actionType: PageActionType.Rename, actionStage: PageActionStage.Main });
   }
   }

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików