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

Merge branch 'master' into feat/gw7759-change-logo-on-top-navigation-bar

mudana пре 3 година
родитељ
комит
cc4408a04b
100 измењених фајлова са 1061 додато и 536 уклоњено
  1. 53 1
      CHANGELOG.md
  2. 1 1
      lerna.json
  3. 1 1
      package.json
  4. 4 4
      packages/app/docker/README.md
  5. 11 11
      packages/app/package.json
  6. 1 0
      packages/app/resource/locales/en_US/translation.json
  7. 1 0
      packages/app/resource/locales/ja_JP/translation.json
  8. 2 1
      packages/app/resource/locales/zh_CN/translation.json
  9. 8 5
      packages/app/src/components/Admin/App/AwsSetting.jsx
  10. 8 3
      packages/app/src/components/Admin/ExportArchiveDataPage.jsx
  11. 0 33
      packages/app/src/components/Admin/FullTextSearchManagement.jsx
  12. 22 0
      packages/app/src/components/Admin/FullTextSearchManagement.tsx
  13. 13 7
      packages/app/src/components/Admin/ManageExternalAccount.jsx
  14. 9 6
      packages/app/src/components/ArchiveCreateModal.jsx
  15. 3 2
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  16. 4 6
      packages/app/src/components/CreateTemplateModal.jsx
  17. 1 1
      packages/app/src/components/FormattedDistanceDate.jsx
  18. 9 6
      packages/app/src/components/InAppNotification/InAppNotificationDropdown.tsx
  19. 5 4
      packages/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx
  20. 8 3
      packages/app/src/components/InstallerForm.jsx
  21. 14 8
      packages/app/src/components/LoginForm.jsx
  22. 12 4
      packages/app/src/components/Me/ApiSettings.jsx
  23. 11 4
      packages/app/src/components/Me/AssociateModal.jsx
  24. 12 4
      packages/app/src/components/Me/BasicInfoSettings.jsx
  25. 17 7
      packages/app/src/components/Me/DisassociateModal.jsx
  26. 12 4
      packages/app/src/components/Me/ExternalAccountLinkedMe.jsx
  27. 5 7
      packages/app/src/components/Me/ExternalAccountRow.jsx
  28. 12 5
      packages/app/src/components/Me/PasswordSettings.jsx
  29. 4 8
      packages/app/src/components/Me/PersonalSettings.jsx
  30. 0 36
      packages/app/src/components/Me/UserSettings.jsx
  31. 29 0
      packages/app/src/components/Me/UserSettings.tsx
  32. 14 11
      packages/app/src/components/MyDraftList/Draft.jsx
  33. 12 8
      packages/app/src/components/MyDraftList/MyDraftList.jsx
  34. 28 33
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  35. 3 7
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  36. 8 8
      packages/app/src/components/Navbar/SubNavButtons.tsx
  37. 7 10
      packages/app/src/components/Page/CopyDropdown.jsx
  38. 4 11
      packages/app/src/components/Page/ShareLinkAlert.jsx
  39. 8 9
      packages/app/src/components/Page/TrashPageAlert.jsx
  40. 15 16
      packages/app/src/components/PageAccessoriesModalControl.jsx
  41. 8 10
      packages/app/src/components/PageCreateModal.jsx
  42. 8 2
      packages/app/src/components/PageEditor/Cheatsheet.jsx
  43. 8 2
      packages/app/src/components/PageEditor/SimpleCheatsheet.jsx
  44. 20 18
      packages/app/src/components/PageEditorByHackmd.jsx
  45. 8 2
      packages/app/src/components/PageHistory/PageRevisionTable.jsx
  46. 10 3
      packages/app/src/components/PageHistory/RevisionDiff.jsx
  47. 1 0
      packages/app/src/components/PageList/PageListItemL.tsx
  48. 5 6
      packages/app/src/components/PageManagement/ApiErrorMessage.jsx
  49. 12 7
      packages/app/src/components/PageStatusAlert.jsx
  50. 12 7
      packages/app/src/components/PageTimeline.jsx
  51. 4 4
      packages/app/src/components/PasswordResetExecutionForm.jsx
  52. 4 4
      packages/app/src/components/PasswordResetRequestForm.jsx
  53. 9 10
      packages/app/src/components/RevisionComparer/RevisionComparer.jsx
  54. 4 2
      packages/app/src/components/SavePageControls.jsx
  55. 26 32
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  56. 12 7
      packages/app/src/components/ShareLink/ShareLink.jsx
  57. 12 7
      packages/app/src/components/ShareLink/ShareLinkForm.jsx
  58. 21 14
      packages/app/src/components/Sidebar/Tag.tsx
  59. 13 24
      packages/app/src/components/TagCloudBox.tsx
  60. 2 2
      packages/app/src/components/TagList.tsx
  61. 4 9
      packages/app/src/components/TrashPageList.jsx
  62. 1 1
      packages/app/src/interfaces/page.ts
  63. 2 1
      packages/app/src/server/models/attachment.js
  64. 3 2
      packages/app/src/server/models/bookmark.js
  65. 2 1
      packages/app/src/server/models/external-account.js
  66. 2 4
      packages/app/src/server/models/in-app-notification.ts
  67. 2 2
      packages/app/src/server/models/page.ts
  68. 2 2
      packages/app/src/server/models/revision.js
  69. 3 2
      packages/app/src/server/models/share-link.js
  70. 2 1
      packages/app/src/server/models/subscription.ts
  71. 3 3
      packages/app/src/server/models/update-post.ts
  72. 2 1
      packages/app/src/server/models/user-group-relation.js
  73. 4 3
      packages/app/src/server/models/user-group.ts
  74. 6 8
      packages/app/src/server/models/user-registration-order.ts
  75. 1 3
      packages/app/src/server/models/user.js
  76. 6 0
      packages/app/src/server/routes/attachment.js
  77. 18 8
      packages/app/src/server/service/import.js
  78. 8 7
      packages/app/src/server/service/page-grant.ts
  79. 3 1
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  80. 6 0
      packages/app/src/styles/_navbar.scss
  81. 6 0
      packages/app/src/styles/_on-edit.scss
  82. 1 1
      packages/app/src/styles/_override-codemirror.scss
  83. 10 0
      packages/app/src/styles/_tag.scss
  84. 10 0
      packages/app/src/styles/theme/_apply-colors-dark.scss
  85. 15 1
      packages/app/src/styles/theme/_apply-colors-light.scss
  86. 1 1
      packages/app/src/styles/theme/blackboard.scss
  87. 1 1
      packages/app/src/styles/theme/christmas.scss
  88. 2 2
      packages/app/src/styles/theme/fire-red.scss
  89. 2 2
      packages/app/src/styles/theme/future.scss
  90. 1 1
      packages/app/src/styles/theme/halloween.scss
  91. 1 1
      packages/app/src/styles/theme/hufflepuff.scss
  92. 1 1
      packages/app/src/styles/theme/jade-green.scss
  93. 1 1
      packages/app/src/styles/theme/nature.scss
  94. 1 1
      packages/app/src/styles/theme/spring.scss
  95. 1 1
      packages/app/src/styles/theme/wood.scss
  96. 28 0
      packages/app/test/cypress/integration/20-basic-features/access-to-page.spec.ts
  97. 306 2
      packages/app/test/integration/service/page-grant.test.js
  98. 1 1
      packages/codemirror-textlint/package.json
  99. 1 1
      packages/core/package.json
  100. 1 1
      packages/plugin-attachment-refs/package.json

+ 53 - 1
CHANGELOG.md

@@ -1,9 +1,47 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.0.7...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.0.8...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v5.0.8](https://github.com/weseek/growi/compare/v5.0.7...v5.0.8) - 2022-06-07
+
+### 🚀 Improvement
+
+- imprv: Fix subnavigation spacing (#5995) @yuki-takei
+- imprv: Set Content-Length header in response of attachment (#5972) @hiroki-hgs
+- imprv: Fix sidebar tag layout (#5984) @jam411
+- imprv: PageStatusAlert labels when data is outdated (#5961) @yuki-takei
+- imprv: Delete NotFoundAlert from not found page (#5919) @Shunm634-source
+
+### 🐛 Bug Fixes
+
+- fix: Too many footstamps icons are shown by lsx output 3 (#6000) @yuki-takei
+- fix: Adjust PageItemControl alignment (#5994) @yuki-takei
+- fix: CodeMirror placeholder color (#5993) @yuki-takei
+- fix: Chinese notation is broken on create new page modal (#5973) @jam411
+- fix: Document timestamps does not updated (#5979) @yuki-takei
+- fix: Slack channels are not automatically filled after setting up user trigger notification on v5.0.x (#5911) @kaoritokashiki
+- fix: Login required when viewing sharelink page (#5959) @yuki-takei
+- fix: Editor scroll sync by Preview scrolling does not work (#5949) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: Enable garbage collection at runtime with expose-gc package (#5986) @yuki-takei
+- support: Upgrade aws-sdk to v3 (#5863) @mudana-grune
+
+## [v4.5.22](https://github.com/weseek/growi/compare/v4.5.21...v4.5.22) - 2022-06-07
+
+### 🐛 Bug Fixes
+
+- fix: Fixed the bug of auto-filling unintended values into the Email field of the User settings (#5885) @Shunm634-source
+- fix: google-oauth2 Automatically bind external accounts does not work (#5891) @kaoritokashiki
+- fix: Slack channels are not automatically filled after setting up user trigger notification (#5976) @kaoritokashiki
+
+### 🧰 Maintenance
+
+- support: Enable garbage collection at runtime with expose-gc package (#5998) @kaoritokashiki
+
 ## [v5.0.7](https://github.com/weseek/growi/compare/v5.0.6...v5.0.7) - 2022-05-30
 
 ### 💎 Features
@@ -43,6 +81,13 @@
 - fix: Can not toggle textlint function on v5.0.x (#5854) @kaoritokashiki
 - fix(google-oauth2): Automatically bind external accounts  does not work on v5.0.x (#5886) @kaoritokashiki
 
+## [v4.5.21](https://github.com/weseek/growi/compare/v4.5.20...v4.5.21) - 2022-05-23
+
+### 🐛 Bug Fixes
+
+- fix: Can not toggle textlint function on v4.5.x (https://github.com/weseek/growi/pull/5855) @kaoritokashiki
+- fix: Error on searching (https://github.com/weseek/growi/pull/5873) @miya
+
 ## [v5.0.5](https://github.com/weseek/growi/compare/v5.0.4...v5.0.5) - 2022-05-16
 
 ### 💎 Features
@@ -67,6 +112,13 @@
 
 - support: Typescriptize tag model (#5778) @kaoritokashiki
 
+
+## [v4.5.20](https://github.com/weseek/growi/compare/v4.5.19...v4.5.20) - 2022-05-12
+
+### 🐛 Bug Fixes
+
+- fix: Guest user cannot access share link pages (#5819) @kaoritokashiki
+
 ## [v5.0.4](https://github.com/weseek/growi/compare/v5.0.3...v5.0.4) - 2022-04-28
 
 ### 💎 Features

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

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

@@ -10,10 +10,10 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`5.0.7`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.7/docker/Dockerfile)
-* [`5.0.7-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.7/docker/Dockerfile)
-* [`4.5.15`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.15/docker/Dockerfile)
-* [`4.5.15-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.15/docker/Dockerfile)
+* [`5.0.8`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.8/docker/Dockerfile)
+* [`5.0.8-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.8/docker/Dockerfile)
+* [`4.5.22`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.22/docker/Dockerfile)
+* [`4.5.22-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.22/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 

+ 11 - 11
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.0.8-RC.0",
+  "version": "5.0.9-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -11,7 +11,7 @@
     "clean": "npx -y shx rm -rf dist transpiled",
     "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
     "postbuild": "npx -y shx mv transpiled/src dist && npx -y shx cp -r src/server/views dist/server/ && npx -y shx rm -rf transpiled",
-    "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config --expose_gc dist/server/app.js",
+    "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config dist/server/app.js",
     "server:ci": "yarn server --ci",
     "preserver": "yarn cross-env NODE_ENV=production yarn migrate",
     "migrate": "node -r dotenv-flow/config node_modules/.bin/migrate-mongo up",
@@ -19,7 +19,7 @@
     "dev": "run-p dev:client dev:server",
     "dev:client": "yarn cross-env NODE_ENV=development webpack --config config/webpack.dev.js --progress --watch",
     "dev:client:nowatch": "yarn cross-env NODE_ENV=development webpack --config config/webpack.dev.js",
-    "dev:server": "yarn cross-env NODE_ENV=development ts-node-dev --inspect --expose-gc -r tsconfig-paths/register -r dotenv-flow/config --transpile-only src/server/app.ts",
+    "dev:server": "yarn cross-env NODE_ENV=development ts-node-dev --inspect -r tsconfig-paths/register -r dotenv-flow/config --transpile-only src/server/app.ts",
     "predev:client": "yarn cross-env NODE_ENV=development run-p resources:*",
     "predev:server": "yarn cross-env NODE_ENV=development yarn dev:migrate:up",
     "dev:migrate-mongo": "yarn cross-env NODE_ENV=development yarn ts-node node_modules/.bin/migrate-mongo",
@@ -64,11 +64,11 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.0.8-RC.0",
-    "@growi/plugin-attachment-refs": "^5.0.8-RC.0",
-    "@growi/plugin-lsx": "^5.0.8-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.8-RC.0",
-    "@growi/slack": "^5.0.8-RC.0",
+    "@growi/codemirror-textlint": "^5.0.9-RC.0",
+    "@growi/plugin-attachment-refs": "^5.0.9-RC.0",
+    "@growi/plugin-lsx": "^5.0.9-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.9-RC.0",
+    "@growi/slack": "^5.0.9-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -99,6 +99,7 @@
     "esa-node": "^0.2.2",
     "escape-string-regexp": "=4.0.0",
     "eslint-plugin-regex": "^1.8.0",
+    "expose-gc": "^1.0.0",
     "express": "^4.16.1",
     "express-bunyan-logger": "^1.3.3",
     "express-mongo-sanitize": "^2.1.0",
@@ -145,7 +146,6 @@
     "react-dnd-html5-backend": "^14.1.0",
     "react-image-crop": "^8.3.0",
     "react-multiline-clamp": "^2.0.0",
-    "react-tagcloud": "^2.1.1",
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
     "rimraf": "^3.0.0",
@@ -169,7 +169,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.0.8-RC.0",
+    "@growi/ui": "^5.0.9-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
@@ -190,7 +190,6 @@
     "diff2html": "^3.1.2",
     "eazy-logger": "^3.1.0",
     "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
-    "markdown-it-emoji-mart": "^0.1.1",
     "eslint-plugin-cypress": "^2.12.1",
     "eslint-plugin-regex": "^1.8.0",
     "file-loader": "^5.0.2",
@@ -208,6 +207,7 @@
     "markdown-it-blockdiag": "^1.1.1",
     "markdown-it-drawio-viewer": "^1.3.1",
     "markdown-it-emoji": "^1.4.0",
+    "markdown-it-emoji-mart": "^0.1.1",
     "markdown-it-footnote": "^3.0.1",
     "markdown-it-mathjax": "^2.0.0",
     "markdown-it-named-headers": "^0.0.4",

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

@@ -147,6 +147,7 @@
   "Shareable link": "Shareable link",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
   "Add tags for this page": "Add tags for this page",
+  "tag_list": "Tag list",
   "popular_tags": "Popular tags",
   "Check All tags": "check all tags",
   "You have no tag, You can set tags on pages": "You have no tag, You can set tags on pages",

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

@@ -146,6 +146,7 @@
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "Add tags for this page": "タグを付ける",
+  "tag_list": "タグ一覧",
   "popular_tags": "人気のタグ",
   "Check All tags": "全てのタグを見る",
   "You have no tag, You can set tags on pages": "使用中のタグがありません",

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

@@ -155,6 +155,7 @@
 	"Shareable link": "可分享链接",
 	"The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
 	"Add tags for this page": "添加标签",
+  "tag_list": "标签列表",
   "popular_tags": "流行标签",
   "Check All tags": "检查所有标签",
 	"You have no tag, You can set tags on pages": "你没有标签,可以在页面上设置标签",
@@ -524,7 +525,7 @@
 	"template": {
 		"modal_label": {
 			"Create/Edit Template Page": "创建/编辑模板页",
-			"Create template under": "在下面创建模板页:<br/><code><small>%s</small></code>"
+			"Create template under": "在下面创建模板页"
 		},
 		"option_label": {
 			"create/edit": "创建/编辑模板页。",

+ 8 - 5
packages/app/src/components/Admin/App/AwsSetting.jsx

@@ -1,14 +1,17 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
+
+import AdminAppContainer from '~/client/services/AdminAppContainer';
+import AppContainer from '~/client/services/AppContainer';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-import AppContainer from '~/client/services/AppContainer';
-import AdminAppContainer from '~/client/services/AdminAppContainer';
 
 function AwsSetting(props) {
-  const { t, adminAppContainer } = props;
+  const { t } = useTranslation();
+  const { adminAppContainer } = props;
   const { s3ReferenceFileWithRelayMode } = adminAppContainer.state;
 
   return (
@@ -158,4 +161,4 @@ AwsSetting.propTypes = {
   adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
 };
 
-export default withTranslation()(AwsSettingWrapper);
+export default AwsSettingWrapper;

+ 8 - 3
packages/app/src/components/Admin/ExportArchiveDataPage.jsx

@@ -1,7 +1,7 @@
 import React, { Fragment } from 'react';
 
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import * as toastr from 'toastr';
 
 
@@ -254,9 +254,14 @@ ExportArchiveDataPage.propTypes = {
   adminSocketIoContainer: PropTypes.instanceOf(AdminSocketIoContainer).isRequired,
 };
 
+const ExportArchiveDataPageWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <ExportArchiveDataPage t={t} {...props} />;
+};
+
 /**
  * Wrapper component for using unstated
  */
-const ExportArchiveDataPageWrapper = withUnstatedContainers(ExportArchiveDataPage, [AppContainer, AdminSocketIoContainer]);
+const ExportArchiveDataPageWrapper = withUnstatedContainers(ExportArchiveDataPageWrapperFC, [AppContainer, AdminSocketIoContainer]);
 
-export default withTranslation()(ExportArchiveDataPageWrapper);
+export default ExportArchiveDataPageWrapper;

+ 0 - 33
packages/app/src/components/Admin/FullTextSearchManagement.jsx

@@ -1,33 +0,0 @@
-import React, { Fragment } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-
-import ElasticsearchManagement from './ElasticsearchManagement/ElasticsearchManagement';
-
-
-class FullTextSearchManagement extends React.Component {
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <div data-testid="admin-full-text-search">
-        <h2> { t('full_text_search_management.elasticsearch_management') } </h2>
-        <ElasticsearchManagement />
-      </div>
-    );
-  }
-
-}
-
-const FullTextSearchManagementWrapper = withUnstatedContainers(FullTextSearchManagement, [AppContainer]);
-
-FullTextSearchManagement.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-export default withTranslation()(FullTextSearchManagementWrapper);

+ 22 - 0
packages/app/src/components/Admin/FullTextSearchManagement.tsx

@@ -0,0 +1,22 @@
+import React, { FC } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import ElasticsearchManagement from './ElasticsearchManagement/ElasticsearchManagement';
+
+type Props = {
+
+};
+
+const FullTextSearchManagement: FC<Props> = () => {
+  const { t } = useTranslation();
+
+  return (
+    <div data-testid="admin-full-text-search">
+      <h2> { t('full_text_search_management.elasticsearch_management') } </h2>
+      <ElasticsearchManagement />
+    </div>
+  );
+};
+
+export default FullTextSearchManagement;

+ 13 - 7
packages/app/src/components/Admin/ManageExternalAccount.jsx

@@ -1,14 +1,16 @@
 import React, { Fragment } from 'react';
+
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
-import PaginationWrapper from '../PaginationWrapper';
+import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
+import AppContainer from '~/client/services/AppContainer';
+import { toastError } from '~/client/util/apiNotification';
 
+import PaginationWrapper from '../PaginationWrapper';
 import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
+
 import ExternalAccountTable from './Users/ExternalAccountTable';
-import { toastError } from '~/client/util/apiNotification';
 
 
 class ManageExternalAccount extends React.Component {
@@ -82,7 +84,11 @@ ManageExternalAccount.propTypes = {
   adminExternalAccountsContainer: PropTypes.instanceOf(AdminExternalAccountsContainer).isRequired,
 };
 
-const ManageExternalAccountWrapper = withUnstatedContainers(ManageExternalAccount, [AppContainer, AdminExternalAccountsContainer]);
+const ManageExternalAccountWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <ManageExternalAccount t={t} {...props} />;
+};
 
+const ManageExternalAccountWrapper = withUnstatedContainers(ManageExternalAccountWrapperFC, [AppContainer, AdminExternalAccountsContainer]);
 
-export default withTranslation()(ManageExternalAccountWrapper);
+export default ManageExternalAccountWrapper;

+ 9 - 6
packages/app/src/components/ArchiveCreateModal.jsx

@@ -1,7 +1,7 @@
 import React, { useState, useCallback } from 'react';
 
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
@@ -14,7 +14,8 @@ import { withUnstatedContainers } from './UnstatedUtils';
 
 
 const ArchiveCreateModal = (props) => {
-  const { t, appContainer } = props;
+  const { t } = useTranslation();
+  const { appContainer } = props;
   const [isCommentDownload, setIsCommentDownload] = useState(false);
   const [isAttachmentFileDownload, setIsAttachmentFileDownload] = useState(false);
   const [isSubordinatedPageDownload, setIsSubordinatedPageDownload] = useState(false);
@@ -233,10 +234,7 @@ const ArchiveCreateModal = (props) => {
   );
 };
 
-const ArchiveCreateModalWrapper = withUnstatedContainers(ArchiveCreateModal, [AppContainer]);
-
 ArchiveCreateModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func,
@@ -245,4 +243,9 @@ ArchiveCreateModal.propTypes = {
   errorMessage: PropTypes.string,
 };
 
-export default withTranslation()(ArchiveCreateModalWrapper);
+/**
+ * Wrapper component for using unstated
+ */
+const ArchiveCreateModalWrapper = withUnstatedContainers(ArchiveCreateModal, [AppContainer]);
+
+export default ArchiveCreateModalWrapper;

+ 3 - 2
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -40,6 +40,7 @@ type CommonProps = {
 
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   isInstantRename?: boolean,
+  alignRight?: boolean,
 }
 
 
@@ -55,7 +56,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     pageId, isLoading,
     pageInfo, isEnableActions, forceHideMenuItems,
     onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickRevertMenuItem,
-    additionalMenuItemRenderer: AdditionalMenuItems, isInstantRename,
+    additionalMenuItemRenderer: AdditionalMenuItems, isInstantRename, alignRight,
   } = props;
 
 
@@ -205,7 +206,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
   }
 
   return (
-    <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
+    <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: undefined } }} right={alignRight}>
       {contents}
     </DropdownMenu>
   );

+ 4 - 6
packages/app/src/components/CreateTemplateModal.jsx

@@ -2,13 +2,13 @@ import React from 'react';
 
 import { pathUtils } from '@growi/core';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import urljoin from 'url-join';
 
-
 const CreateTemplateModal = (props) => {
-  const { t, path } = props;
+  const { t } = useTranslation();
+  const { path } = props;
 
   const parentPath = pathUtils.addTrailingSlash(path);
 
@@ -63,12 +63,10 @@ const CreateTemplateModal = (props) => {
   );
 };
 
-
 CreateTemplateModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
   path: PropTypes.string.isRequired,
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func.isRequired,
 };
 
-export default withTranslation()(CreateTemplateModal);
+export default CreateTemplateModal;

+ 1 - 1
packages/app/src/components/FormattedDistanceDate.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
 import { format, formatDistanceStrict, differenceInSeconds } from 'date-fns';
+import PropTypes from 'prop-types';
 import { UncontrolledTooltip } from 'reactstrap';
 
 const FormattedDistanceDate = (props) => {

+ 9 - 6
packages/app/src/components/InAppNotification/InAppNotificationDropdown.tsx

@@ -1,19 +1,22 @@
 import React, {
   useState, useEffect, FC, useCallback,
 } from 'react';
+
+import { useTranslation } from 'react-i18next';
 import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-import loggerFactory from '~/utils/logger';
 
+import SocketIoContainer from '~/client/services/SocketIoContainer';
+import { toastError } from '~/client/util/apiNotification';
 import { apiv3Post } from '~/client/util/apiv3-client';
+import { useSWRxInAppNotifications, useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
+import loggerFactory from '~/utils/logger';
+
 import { withUnstatedContainers } from '../UnstatedUtils';
+
 import InAppNotificationList from './InAppNotificationList';
-import SocketIoContainer from '~/client/services/SocketIoContainer';
-import { useSWRxInAppNotifications, useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
 
-import { toastError } from '~/client/util/apiNotification';
 
 const logger = loggerFactory('growi:InAppNotificationDropdown');
 
@@ -74,7 +77,7 @@ const InAppNotificationDropdown: FC<Props> = (props: Props) => {
   }
 
   return (
-    <Dropdown className="notification-wrapper" isOpen={isOpen} toggle={toggleDropdownHandler}>
+    <Dropdown className="notification-wrapper grw-notification-dropdown" isOpen={isOpen} toggle={toggleDropdownHandler}>
       <DropdownToggle tag="a" className="px-3 nav-link border-0 bg-transparent waves-effect waves-light">
         <i className="icon-bell" /> {badge}
       </DropdownToggle>

+ 5 - 4
packages/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx

@@ -1,14 +1,15 @@
 import React, {
   forwardRef, ForwardRefRenderFunction, useImperativeHandle,
 } from 'react';
+
 import { PagePathLabel } from '@growi/ui';
 
 import { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
-import { IInAppNotification } from '~/interfaces/in-app-notification';
 import { HasObjectId } from '~/interfaces/has-object-id';
+import { IInAppNotification } from '~/interfaces/in-app-notification';
 
-import FormattedDistanceDate from '../../FormattedDistanceDate';
 import { parseSnapshot } from '../../../models/serializers/in-app-notification-snapshot/page';
+import FormattedDistanceDate from '../../FormattedDistanceDate';
 
 interface Props {
   notification: IInAppNotification & HasObjectId
@@ -39,8 +40,8 @@ const PageModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable
   }));
 
   return (
-    <div className="p-2">
-      <div>
+    <div className="p-2 overflow-hidden">
+      <div className="text-truncate">
         <b>{actionUsers}</b> {actionMsg} <PagePathLabel path={snapshot.path} />
       </div>
       <i className={`${actionIcon} mr-2`} />

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

@@ -1,8 +1,8 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
 import i18next from 'i18next';
-import { withTranslation } from 'react-i18next';
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
 
 import { localeMetadatas } from '~/client/util/i18n';
 
@@ -214,4 +214,9 @@ InstallerForm.propTypes = {
   csrf: PropTypes.string,
 };
 
-export default withTranslation()(InstallerForm);
+const InstallerFormWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <InstallerForm t={t} {...props} />;
+};
+
+export default InstallerFormWrapperFC;

+ 14 - 8
packages/app/src/components/LoginForm.jsx

@@ -1,10 +1,11 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import ReactCardFlip from 'react-card-flip';
-
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import AppContainer from '~/client/services/AppContainer';
+
 import { withUnstatedContainers } from './UnstatedUtils';
 
 class LoginForm extends React.Component {
@@ -327,11 +328,6 @@ class LoginForm extends React.Component {
 
 }
 
-/**
- * Wrapper component for using unstated
- */
-const LoginFormWrapper = withUnstatedContainers(LoginForm, [AppContainer]);
-
 LoginForm.propTypes = {
   // i18next
   t: PropTypes.func.isRequired,
@@ -351,4 +347,14 @@ LoginForm.propTypes = {
   objOfIsExternalAuthEnableds: PropTypes.object,
 };
 
-export default withTranslation()(LoginFormWrapper);
+const LoginFormWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <LoginForm t={t} {...props} />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const LoginFormWrapper = withUnstatedContainers(LoginFormWrapperFC, [AppContainer]);
+
+export default LoginFormWrapper;

+ 12 - 4
packages/app/src/components/Me/ApiSettings.jsx

@@ -2,7 +2,7 @@
 import React from 'react';
 
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import AppContainer from '~/client/services/AppContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
@@ -97,12 +97,20 @@ class ApiSettings extends React.Component {
 
 }
 
-const ApiSettingsWrapper = withUnstatedContainers(ApiSettings, [AppContainer, PersonalContainer]);
-
 ApiSettings.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
 };
 
-export default withTranslation()(ApiSettingsWrapper);
+const ApiSettingsWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <ApiSettings t={t} {...props} />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ApiSettingsWrapper = withUnstatedContainers(ApiSettingsWrapperFC, [AppContainer, PersonalContainer]);
+
+export default ApiSettingsWrapper;

+ 11 - 4
packages/app/src/components/Me/AssociateModal.jsx

@@ -2,7 +2,7 @@
 import React from 'react';
 
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import {
   Modal,
   ModalHeader,
@@ -130,8 +130,6 @@ class AssociateModal extends React.Component {
 
 }
 
-const AssociateModalWrapper = withUnstatedContainers(AssociateModal, [AppContainer, PersonalContainer]);
-
 AssociateModal.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
@@ -141,5 +139,14 @@ AssociateModal.propTypes = {
   onClose: PropTypes.func.isRequired,
 };
 
+const AssociateModalWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <AssociateModal t={t} {...props} />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const AssociateModalWrapper = withUnstatedContainers(AssociateModalWrapperFC, [AppContainer, PersonalContainer]);
 
-export default withTranslation()(AssociateModalWrapper);
+export default AssociateModalWrapper;

+ 12 - 4
packages/app/src/components/Me/BasicInfoSettings.jsx

@@ -2,7 +2,7 @@
 import React, { Fragment } from 'react';
 
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import PersonalContainer from '~/client/services/PersonalContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
@@ -163,11 +163,19 @@ class BasicInfoSettings extends React.Component {
 
 }
 
-const BasicInfoSettingsWrapper = withUnstatedContainers(BasicInfoSettings, [PersonalContainer]);
-
 BasicInfoSettings.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
 };
 
-export default withTranslation()(BasicInfoSettingsWrapper);
+const BasicInfoSettingsWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <BasicInfoSettings t={t} {...props} />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const BasicInfoSettingsWrapper = withUnstatedContainers(BasicInfoSettingsWrapperFC, [PersonalContainer]);
+
+export default BasicInfoSettingsWrapper;

+ 17 - 7
packages/app/src/components/Me/DisassociateModal.jsx

@@ -1,19 +1,21 @@
 
 import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
 
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
 import {
   Modal,
   ModalHeader,
   ModalBody,
   ModalFooter,
 } from 'reactstrap';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { withUnstatedContainers } from '../UnstatedUtils';
 
 import AppContainer from '~/client/services/AppContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+
 
 class DisassociateModal extends React.Component {
 
@@ -71,8 +73,6 @@ class DisassociateModal extends React.Component {
 
 }
 
-const DisassociateModalWrapper = withUnstatedContainers(DisassociateModal, [AppContainer, PersonalContainer]);
-
 DisassociateModal.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
@@ -84,5 +84,15 @@ DisassociateModal.propTypes = {
 
 };
 
+const DisassociateModalWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <DisassociateModal t={t} {...props} />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const DisassociateModalWrapper = withUnstatedContainers(DisassociateModalWrapperFC, [AppContainer, PersonalContainer]);
+
 
-export default withTranslation()(DisassociateModalWrapper);
+export default DisassociateModalWrapper;

+ 12 - 4
packages/app/src/components/Me/ExternalAccountLinkedMe.jsx

@@ -2,7 +2,7 @@
 import React, { Fragment } from 'react';
 
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 
 import AppContainer from '~/client/services/AppContainer';
@@ -125,12 +125,20 @@ class ExternalAccountLinkedMe extends React.Component {
 
 }
 
-const ExternalAccountLinkedMeWrapper = withUnstatedContainers(ExternalAccountLinkedMe, [AppContainer, PersonalContainer]);
-
 ExternalAccountLinkedMe.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
 };
 
-export default withTranslation()(ExternalAccountLinkedMeWrapper);
+const ExternalAccountLinkedMeWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <ExternalAccountLinkedMe t={t} {...props} />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ExternalAccountLinkedMeWrapper = withUnstatedContainers(ExternalAccountLinkedMeWrapperFC, [AppContainer, PersonalContainer]);
+
+export default ExternalAccountLinkedMeWrapper;

+ 5 - 7
packages/app/src/components/Me/ExternalAccountRow.jsx

@@ -1,12 +1,13 @@
 
 import React from 'react';
-import PropTypes from 'prop-types';
 
-import { withTranslation } from 'react-i18next';
 import dateFnsFormat from 'date-fns/format';
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
 
 const ExternalAccountRow = (props) => {
-  const { t, account } = props;
+  const { t } = useTranslation();
+  const { account } = props;
 
   return (
     <tr>
@@ -29,12 +30,9 @@ const ExternalAccountRow = (props) => {
   );
 };
 
-
 ExternalAccountRow.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
   account: PropTypes.object.isRequired,
   openDisassociateModal: PropTypes.func.isRequired,
 };
 
-export default withTranslation()(ExternalAccountRow);
+export default ExternalAccountRow;

+ 12 - 5
packages/app/src/components/Me/PasswordSettings.jsx

@@ -2,7 +2,7 @@
 import React from 'react';
 
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import PersonalContainer from '~/client/services/PersonalContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
@@ -153,12 +153,19 @@ class PasswordSettings extends React.Component {
 
 }
 
-
-const PasswordSettingsWrapper = withUnstatedContainers(PasswordSettings, [PersonalContainer]);
-
 PasswordSettings.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
 };
 
-export default withTranslation()(PasswordSettingsWrapper);
+const PasswordSettingsWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <PasswordSettings t={t} {...props} />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const PasswordSettingsWrapper = withUnstatedContainers(PasswordSettingsWrapperFC, [PersonalContainer]);
+
+export default PasswordSettingsWrapper;

+ 4 - 8
packages/app/src/components/Me/PersonalSettings.jsx

@@ -2,7 +2,7 @@
 import React, { useMemo } from 'react';
 
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
 
@@ -13,9 +13,9 @@ import InAppNotificationSettings from './InAppNotificationSettings';
 import PasswordSettings from './PasswordSettings';
 import UserSettings from './UserSettings';
 
-const PersonalSettings = (props) => {
+const PersonalSettings = () => {
 
-  const { t } = props;
+  const { t } = useTranslation();
 
   const navTabMapping = useMemo(() => {
     return {
@@ -67,8 +67,4 @@ const PersonalSettings = (props) => {
 
 };
 
-PersonalSettings.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-};
-
-export default withTranslation()(PersonalSettings);
+export default PersonalSettings;

+ 0 - 36
packages/app/src/components/Me/UserSettings.jsx

@@ -1,36 +0,0 @@
-
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import BasicInfoSettings from './BasicInfoSettings';
-import ProfileImageSettings from './ProfileImageSettings';
-
-class UserSettings extends React.Component {
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <div data-testid="grw-user-settings">
-        <div className="mb-5">
-          <h2 className="border-bottom my-4">{t('Basic Info')}</h2>
-          <BasicInfoSettings />
-        </div>
-        <div className="mb-5">
-          <h2 className="border-bottom my-4">{t('Set Profile Image')}</h2>
-          <ProfileImageSettings />
-        </div>
-      </div>
-    );
-  }
-
-}
-
-
-UserSettings.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-};
-
-export default withTranslation()(UserSettings);

+ 29 - 0
packages/app/src/components/Me/UserSettings.tsx

@@ -0,0 +1,29 @@
+import React, { FC } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import BasicInfoSettings from './BasicInfoSettings';
+import ProfileImageSettings from './ProfileImageSettings';
+
+type Props = {
+
+};
+
+const UserSettings: FC<Props> = () => {
+  const { t } = useTranslation();
+
+  return (
+    <div data-testid="grw-user-settings">
+      <div className="mb-5">
+        <h2 className="border-bottom my-4">{t('Basic Info')}</h2>
+        <BasicInfoSettings />
+      </div>
+      <div className="mb-5">
+        <h2 className="border-bottom my-4">{t('Set Profile Image')}</h2>
+        <ProfileImageSettings />
+      </div>
+    </div>
+  );
+};
+
+export default UserSettings;

+ 14 - 11
packages/app/src/components/MyDraftList/Draft.jsx

@@ -1,18 +1,17 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
-import { withTranslation } from 'react-i18next';
+import PropTypes from 'prop-types';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
-
+import { useTranslation } from 'react-i18next';
 import {
   Collapse,
   UncontrolledTooltip,
 } from 'reactstrap';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 
 import RevisionBody from '../Page/RevisionBody';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 class Draft extends React.Component {
 
@@ -192,12 +191,6 @@ class Draft extends React.Component {
 
 }
 
-/**
- * Wrapper component for using unstated
- */
-const DraftWrapper = withUnstatedContainers(Draft, [AppContainer]);
-
-
 Draft.propTypes = {
   t: PropTypes.func.isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
@@ -209,4 +202,14 @@ Draft.propTypes = {
   clearDraft: PropTypes.func.isRequired,
 };
 
-export default withTranslation()(DraftWrapper);
+const DraftWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <Draft t={t} {...props} />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const DraftWrapper = withUnstatedContainers(DraftWrapperFC, [AppContainer]);
+
+export default DraftWrapper;

+ 12 - 8
packages/app/src/components/MyDraftList/MyDraftList.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
@@ -171,12 +171,6 @@ class MyDraftList extends React.Component {
 
 }
 
-/**
- * Wrapper component for using unstated
- */
-const MyDraftListWrapper = withUnstatedContainers(MyDraftList, [PageContainer, EditorContainer]);
-
-
 MyDraftList.propTypes = {
   t: PropTypes.func.isRequired, // react-i18next
 
@@ -184,4 +178,14 @@ MyDraftList.propTypes = {
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 
-export default withTranslation()(MyDraftListWrapper);
+const MyDraftListWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <MyDraftList t={t} {...props} />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const MyDraftListWrapper = withUnstatedContainers(MyDraftListWrapperFC, [PageContainer, EditorContainer]);
+
+export default MyDraftListWrapper;

+ 28 - 33
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -25,19 +25,18 @@ import {
 } from '~/stores/ui';
 
 import { AdditionalMenuItemsRendererProps } from '../Common/Dropdown/PageItemControl';
+import CreateTemplateModal from '../CreateTemplateModal';
 import AttachmentIcon from '../Icons/AttachmentIcon';
 import HistoryIcon from '../Icons/HistoryIcon';
+import PresentationIcon from '../Icons/PresentationIcon';
+import ShareLinkIcon from '../Icons/ShareLinkIcon';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
-import ShareLinkIcon from '../Icons/ShareLinkIcon';
 
 import { GrowiSubNavigation } from './GrowiSubNavigation';
 import PageEditorModeManager from './PageEditorModeManager';
 import { SubNavButtons } from './SubNavButtons';
 
-import PresentationIcon from '../Icons/PresentationIcon';
-import CreateTemplateModal from '../CreateTemplateModal';
-
 
 type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
   pageId: string,
@@ -246,36 +245,34 @@ const GrowiContextualSubNavigation = (props) => {
       mutateEditorMode(viewType);
     }
 
-    const className = `d-flex flex-column align-items-end justify-content-center ${isViewMode ? ' h-50' : ''}`;
-
     return (
       <>
-        <div className={className}>
+        <div className="d-flex flex-column align-items-end justify-content-center py-md-2" style={{ gap: `${isCompactMode ? '5px' : '7px'}` }}>
           { pageId != null && isViewMode && (
-            <SubNavButtons
-              isCompactMode={isCompactMode}
-              pageId={pageId}
-              shareLinkId={shareLinkId}
-              revisionId={revisionId}
-              path={path}
-              disableSeenUserInfoPopover={isSharedUser}
-              showPageControlDropdown={isAbleToShowPageManagement}
-              additionalMenuItemRenderer={props => (
-                <AdditionalMenuItems
-                  {...props}
-                  pageId={pageId}
-                  revisionId={revisionId}
-                  isLinkSharingDisabled={isLinkSharingDisabled}
-                  onClickTemplateMenuItem={templateMenuItemClickHandler}
-                />
-              )}
-              onClickDuplicateMenuItem={duplicateItemClickedHandler}
-              onClickRenameMenuItem={renameItemClickedHandler}
-              onClickDeleteMenuItem={deleteItemClickedHandler}
-            />
+            <div className="h-50">
+              <SubNavButtons
+                isCompactMode={isCompactMode}
+                pageId={pageId}
+                shareLinkId={shareLinkId}
+                revisionId={revisionId}
+                path={path}
+                disableSeenUserInfoPopover={isSharedUser}
+                showPageControlDropdown={isAbleToShowPageManagement}
+                additionalMenuItemRenderer={props => (
+                  <AdditionalMenuItems
+                    {...props}
+                    pageId={pageId}
+                    revisionId={revisionId}
+                    isLinkSharingDisabled={isLinkSharingDisabled}
+                    onClickTemplateMenuItem={templateMenuItemClickHandler}
+                  />
+                )}
+                onClickDuplicateMenuItem={duplicateItemClickedHandler}
+                onClickRenameMenuItem={renameItemClickedHandler}
+                onClickDeleteMenuItem={deleteItemClickedHandler}
+              />
+            </div>
           ) }
-        </div>
-        <div className={`${className} ${isCompactMode ? '' : 'mt-2'}`}>
           {isAbleToShowPageEditorModeManager && (
             <PageEditorModeManager
               onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}
@@ -285,7 +282,7 @@ const GrowiContextualSubNavigation = (props) => {
             />
           )}
         </div>
-        {currentUser != null && (
+        {path != null && currentUser != null && (
           <CreateTemplateModal
             path={path}
             isOpen={isPageTemplateModalShown}
@@ -302,7 +299,6 @@ const GrowiContextualSubNavigation = (props) => {
     path, templateMenuItemClickHandler, isPageTemplateModalShown,
   ]);
 
-
   if (path == null) {
     return <></>;
   }
@@ -317,7 +313,6 @@ const GrowiContextualSubNavigation = (props) => {
     updatedAt: updatedAt ?? undefined,
   };
 
-
   return (
     <GrowiSubNavigation
       page={currentPage}

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

@@ -1,19 +1,17 @@
 import React from 'react';
 
 import { IPageHasId } from '~/interfaces/page';
-
+import { IUser } from '~/interfaces/user';
 import {
   EditorMode, useEditorMode,
 } from '~/stores/ui';
 
 import TagLabels from '../Page/TagLabels';
+import PagePathNav from '../PagePathNav';
 
 import AuthorInfo from './AuthorInfo';
 import DrawerToggler from './DrawerToggler';
 
-import PagePathNav from '../PagePathNav';
-import { IUser } from '~/interfaces/user';
-
 
 type Props = {
   page: Partial<IPageHasId>,
@@ -85,9 +83,7 @@ export const GrowiSubNavigation = (props: Props): JSX.Element => {
       {/* Right side */}
       <div className="d-flex">
 
-        <div className="d-flex flex-column py-md-2" style={{ gap: `${isCompactMode ? '5px' : '0'}` }}>
-          { Controls && <Controls></Controls> }
-        </div>
+        { Controls && <Controls></Controls> }
 
         {/* Page Authors */}
         { (showPageAuthors && !isCompactMode) && (

+ 8 - 8
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -1,23 +1,22 @@
 import React, { useCallback } from 'react';
 
+import { toggleBookmark, toggleLike, toggleSubscribe } from '~/client/services/page-operation';
 import {
   IPageInfoAll, IPageToDeleteWithMeta, IPageToRenameWithMeta, isIPageInfoForEntity, isIPageInfoForOperation,
 } from '~/interfaces/page';
-
-import { useSWRxPageInfo } from '../../stores/page';
-import { useSWRBookmarkInfo } from '../../stores/bookmark';
-import { useSWRxUsersList } from '../../stores/user';
 import { useIsGuestUser } from '~/stores/context';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
 
-import SubscribeButton from '../SubscribeButton';
-import LikeButtons from '../LikeButtons';
+import { useSWRBookmarkInfo } from '../../stores/bookmark';
+import { useSWRxPageInfo } from '../../stores/page';
+import { useSWRxUsersList } from '../../stores/user';
 import BookmarkButtons from '../BookmarkButtons';
-import SeenUserInfo from '../User/SeenUserInfo';
-import { toggleBookmark, toggleLike, toggleSubscribe } from '~/client/services/page-operation';
 import {
   AdditionalMenuItemsRendererProps, ForceHideMenuItems, MenuItemType, PageItemControl,
 } from '../Common/Dropdown/PageItemControl';
+import LikeButtons from '../LikeButtons';
+import SubscribeButton from '../SubscribeButton';
+import SeenUserInfo from '../User/SeenUserInfo';
 
 
 type CommonProps = {
@@ -184,6 +183,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
       ) }
       { showPageControlDropdown && (
         <PageItemControl
+          alignRight
           pageId={pageId}
           pageInfo={pageInfo}
           isEnableActions={!isGuestUser}

+ 7 - 10
packages/app/src/components/Page/CopyDropdown.jsx

@@ -1,18 +1,16 @@
 import React, {
   useState, useMemo, useCallback,
 } from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
 
+import { pagePathUtils } from '@growi/core';
+import PropTypes from 'prop-types';
+import { CopyToClipboard } from 'react-copy-to-clipboard';
+import { useTranslation } from 'react-i18next';
 import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
   Tooltip,
 } from 'reactstrap';
 
-import { CopyToClipboard } from 'react-copy-to-clipboard';
-
-import { pagePathUtils } from '@growi/core';
 
 const { encodeSpaces } = pagePathUtils;
 
@@ -102,8 +100,9 @@ const CopyDropdown = (props) => {
   /*
    * render
    */
+  const { t } = useTranslation();
   const {
-    t, dropdownToggleId, pageId, dropdownToggleClassName, children, isShareLinkMode,
+    dropdownToggleId, pageId, dropdownToggleClassName, children, isShareLinkMode,
   } = props;
 
   const customSwitchForParamsId = `customSwitchForParams_${dropdownToggleId}`;
@@ -199,8 +198,6 @@ const CopyDropdown = (props) => {
 };
 
 CopyDropdown.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
   children: PropTypes.node.isRequired,
   dropdownToggleId: PropTypes.string.isRequired,
   pagePath: PropTypes.string.isRequired,
@@ -210,4 +207,4 @@ CopyDropdown.propTypes = {
   isShareLinkMode: PropTypes.bool,
 };
 
-export default withTranslation()(CopyDropdown);
+export default CopyDropdown;

+ 4 - 11
packages/app/src/components/Page/ShareLinkAlert.jsx

@@ -1,11 +1,9 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
-import { withTranslation } from 'react-i18next';
-
-const ShareLinkAlert = (props) => {
-  const { t } = props;
+import { useTranslation } from 'react-i18next';
 
+const ShareLinkAlert = () => {
+  const { t } = useTranslation();
 
   const shareContent = document.getElementById('is-shared-page');
   const expiredAt = shareContent.getAttribute('data-share-link-expired-at');
@@ -51,9 +49,4 @@ const ShareLinkAlert = (props) => {
   );
 };
 
-
-ShareLinkAlert.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-};
-
-export default withTranslation()(ShareLinkAlert);
+export default ShareLinkAlert;

+ 8 - 9
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -3,7 +3,7 @@ import React, { useState } from 'react';
 
 import { UserPicture } from '@growi/ui';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import PageContainer from '~/client/services/PageContainer';
 import { useCurrentUpdatedAt, useShareLinkId } from '~/stores/context';
@@ -22,7 +22,8 @@ const onDeletedHandler = (pathOrPathsToDelete, isRecursively, isCompletely) => {
 };
 
 const TrashPageAlert = (props) => {
-  const { t, pageContainer } = props;
+  const { t } = useTranslation();
+  const { pageContainer } = props;
   const {
     pageId, revisionId, path, isDeleted, lastUpdateUsername, deletedUserName, deletedAt,
   } = pageContainer.state;
@@ -141,15 +142,13 @@ const TrashPageAlert = (props) => {
   );
 };
 
+TrashPageAlert.propTypes = {
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+};
+
 /**
  * Wrapper component for using unstated
  */
 const TrashPageAlertWrapper = withUnstatedContainers(TrashPageAlert, [PageContainer]);
 
-
-TrashPageAlert.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-};
-
-export default withTranslation()(TrashPageAlertWrapper);
+export default TrashPageAlertWrapper;

+ 15 - 16
packages/app/src/components/PageAccessoriesModalControl.jsx

@@ -1,23 +1,23 @@
 import React, { Fragment, useMemo } from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
 
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 
-import PageListIcon from './Icons/PageListIcon';
-import TimeLineIcon from './Icons/TimeLineIcon';
-import HistoryIcon from './Icons/HistoryIcon';
+import { useCurrentPageId } from '~/stores/context';
+
 import AttachmentIcon from './Icons/AttachmentIcon';
+import HistoryIcon from './Icons/HistoryIcon';
+import PageListIcon from './Icons/PageListIcon';
 import ShareLinkIcon from './Icons/ShareLinkIcon';
-
+import TimeLineIcon from './Icons/TimeLineIcon';
 import { withUnstatedContainers } from './UnstatedUtils';
 
-import { useCurrentPageId } from '~/stores/context';
 
 const PageAccessoriesModalControl = (props) => {
+  const { t } = useTranslation();
   const {
-    t, pageAccessoriesContainer, isGuestUser, isSharedUser,
+    pageAccessoriesContainer, isGuestUser, isSharedUser,
   } = props;
   const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
 
@@ -92,18 +92,17 @@ const PageAccessoriesModalControl = (props) => {
     </div>
   );
 };
-/**
- * Wrapper component for using unstated
- */
-const PageAccessoriesModalControlWrapper = withUnstatedContainers(PageAccessoriesModalControl, []);
 
 PageAccessoriesModalControl.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-
   pageAccessoriesContainer: PropTypes.any,
 
   isGuestUser: PropTypes.bool.isRequired,
   isSharedUser: PropTypes.bool.isRequired,
 };
 
-export default withTranslation()(PageAccessoriesModalControlWrapper);
+/**
+ * Wrapper component for using unstated
+ */
+const PageAccessoriesModalControlWrapper = withUnstatedContainers(PageAccessoriesModalControl, []);
+
+export default PageAccessoriesModalControlWrapper;

+ 8 - 10
packages/app/src/components/PageCreateModal.jsx

@@ -5,7 +5,7 @@ import React, {
 import { pagePathUtils, pathUtils } from '@growi/core';
 import { format } from 'date-fns';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 
@@ -24,7 +24,8 @@ const {
 } = pagePathUtils;
 
 const PageCreateModal = (props) => {
-  const { t, appContainer } = props;
+  const { t } = useTranslation();
+  const { appContainer } = props;
 
   const { data: currentUser } = useCurrentUser();
 
@@ -310,16 +311,13 @@ const PageCreateModal = (props) => {
   );
 };
 
+PageCreateModal.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
 
 /**
  * Wrapper component for using unstated
  */
-const ModalControlWrapper = withUnstatedContainers(PageCreateModal, [AppContainer]);
-
-
-PageCreateModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
+const PageCreateModalWrapper = withUnstatedContainers(PageCreateModal, [AppContainer]);
 
-export default withTranslation()(ModalControlWrapper);
+export default PageCreateModalWrapper;

+ 8 - 2
packages/app/src/components/PageEditor/Cheatsheet.jsx

@@ -1,8 +1,9 @@
 /* eslint-disable max-len */
 
 import React from 'react';
+
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 class Cheatsheet extends React.Component {
 
@@ -103,4 +104,9 @@ Cheatsheet.propTypes = {
   t: PropTypes.func.isRequired, // i18next
 };
 
-export default withTranslation()(Cheatsheet);
+const CheatsheetWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <Cheatsheet t={t} {...props} />;
+};
+
+export default CheatsheetWrapperFC;

+ 8 - 2
packages/app/src/components/PageEditor/SimpleCheatsheet.jsx

@@ -1,6 +1,7 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 class SimpleCheatsheet extends React.Component {
 
@@ -50,4 +51,9 @@ SimpleCheatsheet.propTypes = {
   t: PropTypes.func.isRequired, // i18next
 };
 
-export default withTranslation()(SimpleCheatsheet);
+const SimpleCheatsheetWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <SimpleCheatsheet t={t} {...props} />;
+};
+
+export default SimpleCheatsheetWrapperFC;

+ 20 - 18
packages/app/src/components/PageEditorByHackmd.jsx

@@ -1,7 +1,7 @@
 import React, { useState, useEffect } from 'react';
 
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 
 import AppContainer from '~/client/services/AppContainer';
@@ -424,12 +424,29 @@ class PageEditorByHackmd extends React.Component {
 
 }
 
+PageEditorByHackmd.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
+  // TODO: remove this when omitting unstated is completed
+  editorMode: PropTypes.string.isRequired,
+  isSlackEnabled: PropTypes.bool.isRequired,
+  slackChannels: PropTypes.string.isRequired,
+  grant: PropTypes.number.isRequired,
+  grantGroupId: PropTypes.string,
+  grantGroupName: PropTypes.string,
+};
+
 /**
  * Wrapper component for using unstated
  */
 const PageEditorByHackmdHOCWrapper = withUnstatedContainers(PageEditorByHackmd, [AppContainer, PageContainer, EditorContainer]);
 
 const PageEditorByHackmdWrapper = (props) => {
+  const { t } = useTranslation();
   const { data: editorMode } = useEditorMode();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
@@ -445,6 +462,7 @@ const PageEditorByHackmdWrapper = (props) => {
   return (
     <PageEditorByHackmdHOCWrapper
       {...props}
+      t={t}
       editorMode={editorMode}
       isSlackEnabled={isSlackEnabled}
       slackChannels={slackChannelsData.toString()}
@@ -455,20 +473,4 @@ const PageEditorByHackmdWrapper = (props) => {
   );
 };
 
-PageEditorByHackmd.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-
-  // TODO: remove this when omitting unstated is completed
-  editorMode: PropTypes.string.isRequired,
-  isSlackEnabled: PropTypes.bool.isRequired,
-  slackChannels: PropTypes.string.isRequired,
-  grant: PropTypes.number.isRequired,
-  grantGroupId: PropTypes.string,
-  grantGroupName: PropTypes.string,
-};
-
-export default withTranslation()(PageEditorByHackmdWrapper);
+export default PageEditorByHackmdWrapper;

+ 8 - 2
packages/app/src/components/PageHistory/PageRevisionTable.jsx

@@ -1,7 +1,8 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
 
-import { withTranslation } from 'react-i18next';
 import PageHistroyContainer from '~/client/services/PageHistoryContainer';
 import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
 
@@ -162,4 +163,9 @@ PageRevisionTable.propTypes = {
   diffOpened: PropTypes.object,
 };
 
-export default withTranslation()(PageRevisionTable);
+const PageRevisionTableWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <PageRevisionTable t={t} {...props} />;
+};
+
+export default PageRevisionTableWrapperFC;

+ 10 - 3
packages/app/src/components/PageHistory/RevisionDiff.jsx

@@ -1,10 +1,12 @@
 /* eslint-disable react/no-danger */
 import React from 'react';
-import PropTypes from 'prop-types';
+
 
 import { createPatch } from 'diff';
 import { html } from 'diff2html';
-import { withTranslation } from 'react-i18next';
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
+
 import UserDate from '../User/UserDate';
 
 class RevisionDiff extends React.Component {
@@ -77,4 +79,9 @@ RevisionDiff.propTypes = {
   revisionDiffOpened: PropTypes.bool.isRequired,
 };
 
-export default withTranslation()(RevisionDiff);
+const RevisionDiffWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <RevisionDiff t={t} {...props} />;
+};
+
+export default RevisionDiffWrapperFC;

+ 1 - 0
packages/app/src/components/PageList/PageListItemL.tsx

@@ -225,6 +225,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
               {/* doropdown icon includes page control buttons */}
               <div className="ml-auto">
                 <PageItemControl
+                  alignRight
                   pageId={pageData._id}
                   pageInfo={isIPageInfoForListing(pageMeta) ? pageMeta : undefined}
                   isEnableActions={isEnableActions}

+ 5 - 6
packages/app/src/components/PageManagement/ApiErrorMessage.jsx

@@ -1,11 +1,12 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
-import { withTranslation } from 'react-i18next';
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
 
 const ApiErrorMessage = (props) => {
+  const { t } = useTranslation();
   const {
-    t, errorCode, errorMessage, targetPath,
+    errorCode, errorMessage, targetPath,
   } = props;
 
   function reload() {
@@ -71,11 +72,9 @@ const ApiErrorMessage = (props) => {
 };
 
 ApiErrorMessage.propTypes = {
-  t:            PropTypes.func.isRequired, //  i18next
-
   errorCode:    PropTypes.string,
   errorMessage: PropTypes.string,
   targetPath:   PropTypes.string,
 };
 
-export default withTranslation()(ApiErrorMessage);
+export default ApiErrorMessage;

+ 12 - 7
packages/app/src/components/PageStatusAlert.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
@@ -156,11 +156,6 @@ class PageStatusAlert extends React.Component {
 
 }
 
-/**
- * Wrapper component for using unstated
- */
-const PageStatusAlertWrapper = withUnstatedContainers(PageStatusAlert, [AppContainer, PageContainer]);
-
 PageStatusAlert.propTypes = {
   t: PropTypes.func.isRequired, // i18next
 
@@ -168,4 +163,14 @@ PageStatusAlert.propTypes = {
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
 
-export default withTranslation()(PageStatusAlertWrapper);
+const PageStatusAlertWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <PageStatusAlert t={t} {...props} />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const PageStatusAlertWrapper = withUnstatedContainers(PageStatusAlertWrapperFC, [AppContainer, PageContainer]);
+
+export default PageStatusAlertWrapper;

+ 12 - 7
packages/app/src/components/PageTimeline.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
@@ -107,11 +107,6 @@ class PageTimeline extends React.Component {
 
 }
 
-/**
- * Wrapper component for using unstated
- */
-const PageTimelineWrapper = withUnstatedContainers(PageTimeline, [AppContainer, PageContainer]);
-
 PageTimeline.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
@@ -119,4 +114,14 @@ PageTimeline.propTypes = {
   pages: PropTypes.arrayOf(PropTypes.object),
 };
 
-export default withTranslation()(PageTimelineWrapper);
+const PageTimelineWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <PageTimeline t={t} {...props} />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const PageTimelineWrapper = withUnstatedContainers(PageTimelineWrapperFC, [AppContainer, PageContainer]);
+
+export default PageTimelineWrapper;

+ 4 - 4
packages/app/src/components/PasswordResetExecutionForm.jsx

@@ -1,7 +1,7 @@
 import React, { useState } from 'react';
 
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
@@ -14,7 +14,8 @@ const logger = loggerFactory('growi:passwordReset');
 
 
 const PasswordResetExecutionForm = (props) => {
-  const { t, appContainer } = props;
+  const { t } = useTranslation();
+  const { appContainer } = props;
 
   const [newPassword, setNewPassword] = useState('');
   const [newPasswordConfirm, setNewPasswordConfirm] = useState('');
@@ -93,8 +94,7 @@ const PasswordResetExecutionForm = (props) => {
 const PasswordResetExecutionFormWrapper = withUnstatedContainers(PasswordResetExecutionForm, [AppContainer]);
 
 PasswordResetExecutionForm.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 };
 
-export default withTranslation()(PasswordResetExecutionFormWrapper);
+export default PasswordResetExecutionFormWrapper;

+ 4 - 4
packages/app/src/components/PasswordResetRequestForm.jsx

@@ -1,7 +1,7 @@
 import React, { useState } from 'react';
 
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
@@ -11,7 +11,8 @@ import { withUnstatedContainers } from './UnstatedUtils';
 
 
 const PasswordResetRequestForm = (props) => {
-  const { t, appContainer } = props;
+  const { t } = useTranslation();
+  const { appContainer } = props;
   const [email, setEmail] = useState('');
 
   const changeEmail = (inputValue) => {
@@ -62,8 +63,7 @@ const PasswordResetRequestForm = (props) => {
 const PasswordResetRequestFormWrapper = withUnstatedContainers(PasswordResetRequestForm, [AppContainer]);
 
 PasswordResetRequestForm.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 };
 
-export default withTranslation()(PasswordResetRequestFormWrapper);
+export default PasswordResetRequestFormWrapper;

+ 9 - 10
packages/app/src/components/RevisionComparer/RevisionComparer.jsx

@@ -3,7 +3,7 @@ import React, { useState } from 'react';
 import { pagePathUtils } from '@growi/core';
 import PropTypes from 'prop-types';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
@@ -31,7 +31,8 @@ const RevisionComparer = (props) => {
 
   const [dropdownOpen, setDropdownOpen] = useState(false);
 
-  const { t, revisionComparerContainer } = props;
+  const { t } = useTranslation();
+  const { revisionComparerContainer } = props;
 
   const { path, pageId } = revisionComparerContainer.pageContainer.state;
 
@@ -113,16 +114,14 @@ const RevisionComparer = (props) => {
   );
 };
 
-/**
- * Wrapper component for using unstated
- */
-const RevisionComparerWrapper = withUnstatedContainers(RevisionComparer, [RevisionComparerContainer]);
-
 RevisionComparer.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
   revisionComparerContainer: PropTypes.instanceOf(RevisionComparerContainer).isRequired,
-
   revisions: PropTypes.array,
 };
 
-export default withTranslation()(RevisionComparerWrapper);
+/**
+ * Wrapper component for using unstated
+ */
+const RevisionComparerWrapper = withUnstatedContainers(RevisionComparer, [RevisionComparerContainer]);
+
+export default RevisionComparerWrapper;

+ 4 - 2
packages/app/src/components/SavePageControls.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import {
   UncontrolledButtonDropdown, Button,
   DropdownToggle, DropdownMenu, DropdownItem,
@@ -137,6 +137,7 @@ class SavePageControls extends React.Component {
 const SavePageControlsHOCWrapper = withUnstatedContainers(SavePageControls, [AppContainer, PageContainer, EditorContainer]);
 
 const SavePageControlsWrapper = (props) => {
+  const { t } = useTranslation();
   const { data: isEditable } = useIsEditable();
   const { data: editorMode } = useEditorMode();
   const { data: grant, mutate: mutateGrant } = useSelectedGrant();
@@ -154,6 +155,7 @@ const SavePageControlsWrapper = (props) => {
 
   return (
     <SavePageControlsHOCWrapper
+      t={t}
       {...props}
       editorMode={editorMode}
       grant={grant}
@@ -185,4 +187,4 @@ SavePageControls.propTypes = {
   mutateGrantGroupName: PropTypes.func,
 };
 
-export default withTranslation()(SavePageControlsWrapper);
+export default SavePageControlsWrapper;

+ 26 - 32
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -1,33 +1,31 @@
 import React, {
   FC, useCallback, useEffect, useRef,
 } from 'react';
-import { useTranslation } from 'react-i18next';
 
+import { useTranslation } from 'react-i18next';
 import { DropdownItem } from 'reactstrap';
 
+import { exportAsMarkdown } from '~/client/services/page-operation';
+import { toastSuccess } from '~/client/util/apiNotification';
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { IPageToDeleteWithMeta, IPageToRenameWithMeta, IPageWithMeta } from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
+import {
+  usePageDuplicateModal, usePageRenameModal, usePageDeleteModal,
+} from '~/stores/modal';
+import { useDescendantsPageListForCurrentPathTermManager } from '~/stores/page';
 import { usePageTreeTermManager } from '~/stores/page-listing';
 import { useFullTextSearchTermManager } from '~/stores/search';
-import { useDescendantsPageListForCurrentPathTermManager } from '~/stores/page';
 
-import { exportAsMarkdown } from '~/client/services/page-operation';
-import { toastSuccess } from '~/client/util/apiNotification';
-
-import PageContentFooter from '../PageContentFooter';
-import PageComment from '../PageComment';
 
-import RevisionLoader from '../Page/RevisionLoader';
 import AppContainer from '../../client/services/AppContainer';
-import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
+import { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
 import { SubNavButtons } from '../Navbar/SubNavButtons';
-import { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
-
-import {
-  usePageDuplicateModal, usePageRenameModal, usePageDeleteModal,
-} from '~/stores/modal';
+import RevisionLoader from '../Page/RevisionLoader';
+import PageComment from '../PageComment';
+import PageContentFooter from '../PageContentFooter';
 
 
 type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
@@ -179,24 +177,20 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
       : page.revision._id;
 
     return (
-      <>
-        <div className="h-50 d-flex flex-column align-items-end justify-content-center">
-          <SubNavButtons
-            pageId={page._id}
-            revisionId={revisionId}
-            path={page.path}
-            showPageControlDropdown={showPageControlDropdown}
-            forceHideMenuItems={forceHideMenuItems}
-            additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
-            isCompactMode
-            onClickDuplicateMenuItem={duplicateItemClickedHandler}
-            onClickRenameMenuItem={renameItemClickedHandler}
-            onClickDeleteMenuItem={deleteItemClickedHandler}
-          />
-        </div>
-        <div className="h-50 d-flex flex-column align-items-end justify-content-center">
-        </div>
-      </>
+      <div className="d-flex flex-column align-items-end justify-content-center py-md-2">
+        <SubNavButtons
+          pageId={page._id}
+          revisionId={revisionId}
+          path={page.path}
+          showPageControlDropdown={showPageControlDropdown}
+          forceHideMenuItems={forceHideMenuItems}
+          additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
+          isCompactMode
+          onClickDuplicateMenuItem={duplicateItemClickedHandler}
+          onClickRenameMenuItem={renameItemClickedHandler}
+          onClickDeleteMenuItem={deleteItemClickedHandler}
+        />
+      </div>
     );
   }, [page, showPageControlDropdown, forceHideMenuItems, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler]);
 

+ 12 - 7
packages/app/src/components/ShareLink/ShareLink.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 
 import PageContainer from '~/client/services/PageContainer';
@@ -112,14 +112,19 @@ class ShareLink extends React.Component {
 
 }
 
-/**
- * Wrapper component for using unstated
- */
-const ShareLinkWrapper = withUnstatedContainers(ShareLink, [PageContainer]);
-
 ShareLink.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
 
-export default withTranslation()(ShareLinkWrapper);
+const ShareLinkWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <ShareLink t={t} {...props} />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ShareLinkWrapper = withUnstatedContainers(ShareLinkWrapperFC, [PageContainer]);
+
+export default ShareLinkWrapper;

+ 12 - 7
packages/app/src/components/ShareLink/ShareLinkForm.jsx

@@ -3,7 +3,7 @@ import React from 'react';
 import { isInteger } from 'core-js/fn/number';
 import { format, parse } from 'date-fns';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import PageContainer from '~/client/services/PageContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
@@ -258,15 +258,20 @@ class ShareLinkForm extends React.Component {
 
 }
 
-/**
- * Wrapper component for using unstated
- */
-const ShareLinkFormWrapper = withUnstatedContainers(ShareLinkForm, [PageContainer]);
-
 ShareLinkForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   onCloseForm: PropTypes.func,
 };
 
-export default withTranslation()(ShareLinkFormWrapper);
+const ShareLinkFormWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <ShareLinkForm t={t} {...props} />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ShareLinkFormWrapper = withUnstatedContainers(ShareLinkFormWrapperFC, [PageContainer]);
+
+export default ShareLinkFormWrapper;

+ 21 - 14
packages/app/src/components/Sidebar/Tag.tsx

@@ -10,6 +10,7 @@ import TagList from '../TagList';
 
 
 const PAGING_LIMIT = 10;
+const TAG_CLOUD_LIMIT = 20;
 
 const Tag: FC = () => {
   const [activePage, setActivePage] = useState<number>(1);
@@ -20,6 +21,9 @@ const Tag: FC = () => {
   const totalCount: number = tagDataList?.totalCount || 0;
   const isLoading = tagDataList === undefined && error == null;
 
+  const { data: tagDataCloud } = useSWRxTagsList(TAG_CLOUD_LIMIT, 0);
+  const tagCloudData: IDataTagCount[] = tagDataCloud?.data || [];
+
   const { t } = useTranslation('');
 
   const setOffsetByPageNumber = useCallback((selectedPageNumber: number) => {
@@ -44,21 +48,8 @@ const Tag: FC = () => {
           <i className="icon icon-reload"></i>
         </button>
       </div>
-      <h2 className="my-3">{t('popular_tags')}</h2>
-
-      <div className="px-3 text-center">
-        <TagCloudBox tags={tagData} />
-      </div>
 
-      <div className="d-flex justify-content-center my-5">
-        <button
-          className="btn btn-primary rounded px-5"
-          type="button"
-          onClick={() => { window.location.href = '/tags' }}
-        >
-          {t('Check All tags')}
-        </button>
-      </div>
+      <h3 className="my-3">{t('tag_list')}</h3>
 
       { isLoading
         ? (
@@ -76,6 +67,22 @@ const Tag: FC = () => {
           />
         )
       }
+
+      <div className="d-flex justify-content-center my-5">
+        <button
+          className="btn btn-primary rounded px-4"
+          type="button"
+          onClick={() => { window.location.href = '/tags' }}
+        >
+          {t('Check All tags')}
+        </button>
+      </div>
+
+      <h3 className="my-3">{t('popular_tags')}</h3>
+
+      <div className="text-center">
+        <TagCloudBox tags={tagCloudData} />
+      </div>
     </div>
   );
 

+ 13 - 24
packages/app/src/components/TagCloudBox.tsx

@@ -1,7 +1,5 @@
 import React, { FC, memo } from 'react';
 
-import { TagCloud } from 'react-tagcloud';
-
 import { IDataTagCount } from '~/interfaces/tag';
 
 type Props = {
@@ -16,34 +14,25 @@ const defaultProps = {
   isDisableRandomColor: true,
 };
 
-const MIN_FONT_SIZE = 10;
-const MAX_FONT_SIZE = 24;
 const MAX_TAG_TEXT_LENGTH = 8;
 
 const TagCloudBox: FC<Props> = memo((props:(Props & typeof defaultProps)) => {
-  const {
-    tags, minSize, maxSize, isDisableRandomColor,
-  } = props;
+  const { tags } = props;
   const maxTagTextLength: number = props.maxTagTextLength ?? MAX_TAG_TEXT_LENGTH;
 
+  const tagElements = tags.map((tag:IDataTagCount) => {
+    const tagNameFormat = (tag.name).length > maxTagTextLength ? `${(tag.name).slice(0, maxTagTextLength)}...` : tag.name;
+    return (
+      <a key={tag.name} href={`/_search?q=tag:${tag.name}`} className="grw-tag-label badge badge-secondary mr-2">
+        {tagNameFormat}
+      </a>
+    );
+  });
+
   return (
-    <>
-      <TagCloud
-        minSize={minSize ?? MIN_FONT_SIZE}
-        maxSize={maxSize ?? MAX_FONT_SIZE}
-        tags={tags.map((tag:IDataTagCount) => {
-          return {
-            // text truncation
-            value: (tag.name).length > maxTagTextLength ? `${(tag.name).slice(0, maxTagTextLength)}...` : tag.name,
-            count: tag.count,
-          };
-        })}
-        disableRandomColor={isDisableRandomColor}
-        style={{ cursor: 'pointer' }}
-        className="simple-cloud text-secondary"
-        onClick={(target) => { window.location.href = `/_search?q=tag:${encodeURIComponent(target.value)}` }}
-      />
-    </>
+    <div className="grw-popular-tag-labels">
+      {tagElements}
+    </div>
   );
 
 });

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

@@ -38,7 +38,7 @@ const TagList: FC<TagListProps> = (props:(TagListProps & typeof defaultProps)) =
           href={`/_search?q=tag:${encodeURIComponent(tag.name)}`}
           className={tagListClasses}
         >
-          <div className="text-truncate">{tag.name}</div>
+          <div className="text-truncate list-tag-name">{tag.name}</div>
           <div className="ml-4 my-auto py-1 px-2 list-tag-count badge badge-secondary text-white">{tag.count}</div>
         </a>
       );
@@ -51,7 +51,7 @@ const TagList: FC<TagListProps> = (props:(TagListProps & typeof defaultProps)) =
 
   return (
     <>
-      <ul className="list-group text-left mb-4">
+      <ul className="list-group text-left mb-5">
         {generateTagList(tagData)}
       </ul>
       {isPaginationShown

+ 4 - 9
packages/app/src/components/TrashPageList.jsx

@@ -1,7 +1,6 @@
 import React, { useMemo } from 'react';
 
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
@@ -9,8 +8,8 @@ import EmptyTrashButton from './EmptyTrashButton';
 import PageListIcon from './Icons/PageListIcon';
 
 
-const TrashPageList = (props) => {
-  const { t } = props;
+const TrashPageList = () => {
+  const { t } = useTranslation();
 
   const navTabMapping = useMemo(() => {
     return {
@@ -34,8 +33,4 @@ const TrashPageList = (props) => {
   );
 };
 
-TrashPageList.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-};
-
-export default withTranslation()(TrashPageList);
+export default TrashPageList;

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

@@ -35,7 +35,7 @@ export interface IPage {
 export const PageGrant = {
   GRANT_PUBLIC: 1,
   GRANT_RESTRICTED: 2,
-  GRANT_SPECIFIED: 3,
+  GRANT_SPECIFIED: 3, // DEPRECATED
   GRANT_OWNER: 4,
   GRANT_USER_GROUP: 5,
 };

+ 2 - 1
packages/app/src/server/models/attachment.js

@@ -34,7 +34,6 @@ module.exports = function(crowi) {
     originalName: { type: String },
     fileFormat: { type: String, required: true },
     fileSize: { type: Number, default: 0 },
-    createdAt: { type: Date, default: Date.now },
     temporaryUrlCached: { type: String },
     temporaryUrlExpiredAt: { type: Date },
     attachmentType: {
@@ -42,6 +41,8 @@ module.exports = function(crowi) {
       enum: AttachmentType,
       default: undefined,
     },
+  }, {
+    timestamps: { createdAt: true, updatedAt: false },
   });
   attachmentSchema.plugin(uniqueValidator);
   attachmentSchema.plugin(mongoosePaginate);

+ 3 - 2
packages/app/src/server/models/bookmark.js

@@ -16,7 +16,8 @@ module.exports = function(crowi) {
   bookmarkSchema = new mongoose.Schema({
     page: { type: ObjectId, ref: 'Page', index: true },
     user: { type: ObjectId, ref: 'User', index: true },
-    createdAt: { type: Date, default: Date.now },
+  }, {
+    timestamps: { createdAt: true, updatedAt: false },
   });
   bookmarkSchema.index({ page: 1, user: 1 }, { unique: true });
   bookmarkSchema.plugin(mongoosePaginate);
@@ -61,7 +62,7 @@ module.exports = function(crowi) {
   bookmarkSchema.statics.add = async function(page, user) {
     const Bookmark = this;
 
-    const newBookmark = new Bookmark({ page, user, createdAt: Date.now() });
+    const newBookmark = new Bookmark({ page, user });
 
     try {
       const bookmark = await newBookmark.save();

+ 2 - 1
packages/app/src/server/models/external-account.js

@@ -15,7 +15,8 @@ const schema = new mongoose.Schema({
   providerType: { type: String, required: true },
   accountId: { type: String, required: true },
   user: { type: ObjectId, ref: 'User', required: true },
-  createdAt: { type: Date, default: Date.now, required: true },
+}, {
+  timestamps: { createdAt: true, updatedAt: false },
 });
 // compound index
 schema.index({ providerType: 1, accountId: 1 }, { unique: true });

+ 2 - 4
packages/app/src/server/models/in-app-notification.ts

@@ -71,14 +71,12 @@ const inAppNotificationSchema = new Schema<InAppNotificationDocument, InAppNotif
     index: true,
     require: true,
   },
-  createdAt: {
-    type: Date,
-    default: new Date(),
-  },
   snapshot: {
     type: String,
     require: true,
   },
+}, {
+  timestamps: { createdAt: true, updatedAt: false },
 });
 inAppNotificationSchema.plugin(mongoosePaginate);
 

+ 2 - 2
packages/app/src/server/models/page.ts

@@ -102,11 +102,11 @@ const schema = new Schema<PageDocument, PageModel>({
   pageIdOnHackmd: { type: String },
   revisionHackmdSynced: { type: ObjectId, ref: 'Revision' }, // the revision that is synced to HackMD
   hasDraftOnHackmd: { type: Boolean }, // set true if revision and revisionHackmdSynced are same but HackMD document has modified
-  createdAt: { type: Date, default: new Date() },
-  updatedAt: { type: Date, default: new Date() },
+  updatedAt: { type: Date, default: Date.now }, // Do not use timetamps for updatedAt because it breaks 'updateMetadata: false' option
   deleteUser: { type: ObjectId, ref: 'User' },
   deletedAt: { type: Date },
 }, {
+  timestamps: { createdAt: true, updatedAt: false },
   toJSON: { getters: true },
   toObject: { getters: true },
 });

+ 2 - 2
packages/app/src/server/models/revision.js

@@ -28,8 +28,9 @@ module.exports = function(crowi) {
     },
     format: { type: String, default: 'markdown' },
     author: { type: ObjectId, ref: 'User' },
-    createdAt: { type: Date, default: Date.now },
     hasDiffToPrev: { type: Boolean },
+  }, {
+    timestamps: { createdAt: true, updatedAt: false },
   });
   revisionSchema.plugin(mongoosePaginate);
 
@@ -55,7 +56,6 @@ module.exports = function(crowi) {
     newRevision.body = body;
     newRevision.format = format;
     newRevision.author = user._id;
-    newRevision.createdAt = Date.now();
     if (pageData.revision != null) {
       newRevision.hasDiffToPrev = body !== previousBody;
     }

+ 3 - 2
packages/app/src/server/models/share-link.js

@@ -2,8 +2,8 @@
 /* eslint-disable no-return-await */
 
 const mongoose = require('mongoose');
-const uniqueValidator = require('mongoose-unique-validator');
 const mongoosePaginate = require('mongoose-paginate-v2');
+const uniqueValidator = require('mongoose-unique-validator');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
@@ -19,7 +19,8 @@ const schema = new mongoose.Schema({
   },
   expiredAt: { type: Date },
   description: { type: String },
-  createdAt: { type: Date, default: Date.now, required: true },
+}, {
+  timestamps: { createdAt: true, updatedAt: false },
 });
 schema.plugin(mongoosePaginate);
 schema.plugin(uniqueValidator);

+ 2 - 1
packages/app/src/server/models/subscription.ts

@@ -50,7 +50,8 @@ const subscriptionSchema = new Schema<SubscriptionDocument, SubscriptionModel>({
     require: true,
     enum: AllSubscriptionStatusType,
   },
-  createdAt: { type: Date, default: new Date() },
+}, {
+  timestamps: true,
 });
 
 subscriptionSchema.methods.isSubscribing = function() {

+ 3 - 3
packages/app/src/server/models/update-post.ts

@@ -1,9 +1,9 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
 
+import { getOrCreateModel } from '@growi/core';
 import {
   Types, Schema, Model, Document,
 } from 'mongoose';
-import { getOrCreateModel } from '@growi/core';
 
 export interface IUpdatePost {
   pathPattern: string
@@ -36,7 +36,8 @@ const updatePostSchema = new Schema<UpdatePostDocument, UpdatePostModel>({
   channel: { type: String, required: true },
   provider: { type: String, required: true },
   creator: { type: Schema.Types.ObjectId, ref: 'User', index: true },
-  createdAt: { type: Date, default: new Date(Date.now()) },
+}, {
+  timestamps: true,
 });
 
 updatePostSchema.statics.normalizeChannelName = function(channel) {
@@ -115,7 +116,6 @@ updatePostSchema.statics.createUpdatePost = async function(pathPattern, channel,
     channel: this.normalizeChannelName(channel),
     provider,
     creator,
-    createdAt: Date.now(),
   });
 };
 

+ 2 - 1
packages/app/src/server/models/user-group-relation.js

@@ -12,7 +12,8 @@ const ObjectId = mongoose.Schema.Types.ObjectId;
 const schema = new mongoose.Schema({
   relatedGroup: { type: ObjectId, ref: 'UserGroup', required: true },
   relatedUser: { type: ObjectId, ref: 'User', required: true },
-  createdAt: { type: Date, default: Date.now, required: true },
+}, {
+  timestamps: { createdAt: true, updatedAt: false },
 });
 schema.plugin(mongoosePaginate);
 schema.plugin(uniqueValidator);

+ 4 - 3
packages/app/src/server/models/user-group.ts

@@ -1,8 +1,8 @@
+import { getOrCreateModel } from '@growi/core';
 import mongoose, {
-  Types, Schema, Model, Document,
+  Schema, Model, Document,
 } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
-import { getOrCreateModel } from '@growi/core';
 
 import { IUserGroup } from '~/interfaces/user';
 
@@ -22,9 +22,10 @@ const ObjectId = mongoose.Schema.Types.ObjectId;
 
 const schema = new Schema<UserGroupDocument, UserGroupModel>({
   name: { type: String, required: true, unique: true },
-  createdAt: { type: Date, default: new Date() },
   parent: { type: ObjectId, ref: 'UserGroup', index: true },
   description: { type: String, default: '' },
+}, {
+  timestamps: true,
 });
 schema.plugin(mongoosePaginate);
 

+ 6 - 8
packages/app/src/server/models/user-registration-order.ts

@@ -1,11 +1,12 @@
+import crypto from 'crypto';
+
+import { getOrCreateModel } from '@growi/core';
+import { addHours } from 'date-fns';
 import {
   Schema, Model, Document,
 } from 'mongoose';
-
-import { addHours } from 'date-fns';
 import uniqueValidator from 'mongoose-unique-validator';
-import crypto from 'crypto';
-import { getOrCreateModel } from '@growi/core';
+
 
 export interface IUserRegistrationOrder {
   token: string,
@@ -35,10 +36,7 @@ const schema = new Schema<UserRegistrationOrderDocument, UserRegistrationOrderMo
   isRevoked: { type: Boolean, default: false, required: true },
   expiredAt: { type: Date, default: expiredAt, required: true },
 }, {
-  timestamps: {
-    createdAt: true,
-    updatedAt: false,
-  },
+  timestamps: true,
 });
 schema.plugin(uniqueValidator);
 

+ 1 - 3
packages/app/src/server/models/user.js

@@ -67,11 +67,11 @@ module.exports = function(crowi) {
     status: {
       type: Number, required: true, default: STATUS_ACTIVE, index: true,
     },
-    createdAt: { type: Date, default: Date.now },
     lastLoginAt: { type: Date },
     admin: { type: Boolean, default: 0, index: true },
     isInvitationEmailSended: { type: Boolean, default: false },
   }, {
+    timestamps: true,
     toObject: {
       transform: (doc, ret, opt) => {
         return omitInsecureAttributes(ret);
@@ -542,7 +542,6 @@ module.exports = function(crowi) {
     newUser.username = tmpUsername;
     newUser.email = email;
     newUser.setPassword(password);
-    newUser.createdAt = Date.now();
     newUser.status = STATUS_INVITED;
 
     const globalLang = configManager.getConfig('crowi', 'app:globalLang');
@@ -632,7 +631,6 @@ module.exports = function(crowi) {
     if (lang != null) {
       newUser.lang = lang;
     }
-    newUser.createdAt = Date.now();
     newUser.status = status || decideUserStatusOnRegistration();
 
     newUser.save((err, userData) => {

+ 6 - 0
packages/app/src/server/routes/attachment.js

@@ -232,6 +232,12 @@ module.exports = function(crowi, app) {
       'Last-Modified': attachment.createdAt.toUTCString(),
     });
 
+    if (!attachment.fileSize) {
+      res.set({
+        'Content-Length': attachment.fileSize,
+      });
+    }
+
     // download
     if (forceDownload) {
       res.set({

+ 18 - 8
packages/app/src/server/service/import.js

@@ -1,23 +1,24 @@
+import gc from 'expose-gc/function';
+
 import loggerFactory from '~/utils/logger';
 
-const logger = loggerFactory('growi:services:ImportService'); // eslint-disable-line no-unused-vars
 const fs = require('fs');
 const path = require('path');
-
-const isIsoDate = require('is-iso-date');
-const parseISO = require('date-fns/parseISO');
-
 const { Writable, Transform } = require('stream');
+
 const JSONStream = require('JSONStream');
+const parseISO = require('date-fns/parseISO');
+const isIsoDate = require('is-iso-date');
+const mongoose = require('mongoose');
 const streamToPromise = require('stream-to-promise');
 const unzipper = require('unzipper');
 
-const mongoose = require('mongoose');
+const CollectionProgressingStatus = require('../models/vo/collection-progressing-status');
+const { createBatchStream } = require('../util/batch-stream');
 
 const { ObjectId } = mongoose.Types;
 
-const { createBatchStream } = require('../util/batch-stream');
-const CollectionProgressingStatus = require('../models/vo/collection-progressing-status');
+const logger = loggerFactory('growi:services:ImportService'); // eslint-disable-line no-unused-vars
 
 
 const BULK_IMPORT_SIZE = 100;
@@ -286,6 +287,15 @@ class ImportService {
 
           emitProgressEvent(collectionProgress, errors);
 
+          try {
+            // First aid to prevent unexplained memory leaks
+            logger.info('global.gc() invoked.');
+            gc();
+          }
+          catch (err) {
+            logger.error('fail garbage collection: ', err);
+          }
+
           callback();
         },
         final(callback) {

+ 8 - 7
packages/app/src/server/service/page-grant.ts

@@ -408,19 +408,20 @@ class PageGrantService {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
 
+    // -- Public only if top page
+    const isOnlyPublicApplicable = isTopPage(page.path);
+    if (isOnlyPublicApplicable) {
+      return {
+        [Page.GRANT_PUBLIC]: null,
+      };
+    }
+
     // Increment an object (type IRecordApplicableGrant)
     // grant is never public, anyone with the link, nor specified
     const data: IRecordApplicableGrant = {
       [Page.GRANT_RESTRICTED]: null, // any page can be restricted
     };
 
-    // -- Public only if top page
-    const isOnlyPublicApplicable = isTopPage(page.path);
-    if (isOnlyPublicApplicable) {
-      data[Page.GRANT_PUBLIC] = null;
-      return data;
-    }
-
     // -- Any grant is allowed if parent is null
     const isAnyGrantApplicable = page.parent == null;
     if (isAnyGrantApplicable) {

+ 3 - 1
packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -3,6 +3,7 @@ import { URL } from 'url';
 
 import elasticsearch6 from '@elastic/elasticsearch6';
 import elasticsearch7 from '@elastic/elasticsearch7';
+import gc from 'expose-gc/function';
 import mongoose from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 
@@ -593,7 +594,8 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
         if (invokeGarbageCollection) {
           try {
             // First aid to prevent unexplained memory leaks
-            global.gc();
+            logger.info('global.gc() invoked.');
+            gc();
           }
           catch (err) {
             logger.error('fail garbage collection: ', err);

+ 6 - 0
packages/app/src/styles/_navbar.scss

@@ -63,6 +63,12 @@
       }
     }
   }
+
+  .grw-notification-dropdown {
+    .dropdown-menu {
+      max-width: 70vw;
+    }
+  }
 }
 
 .grw-navbar-bottom {

+ 6 - 0
packages/app/src/styles/_on-edit.scss

@@ -181,6 +181,12 @@ body.on-edit {
     }
   }
 
+  .grw-copy-dropdown {
+    .btn-copy {
+      padding: 3px !important; // overwrite padding
+    }
+  }
+
   &.builtin-editor {
     /*****************
     * Editor styles

+ 1 - 1
packages/app/src/styles/_override-codemirror.scss

@@ -41,7 +41,7 @@
   }
 
   // overwrite .CodeMirror-placeholder
-  pre.CodeMirror-placeholder {
+  pre.CodeMirror-line-like.CodeMirror-placeholder {
     color: $text-muted;
   }
 }

+ 10 - 0
packages/app/src/styles/_tag.scss

@@ -12,6 +12,16 @@
   }
 }
 
+.grw-popular-tag-labels {
+  text-align: left;
+
+  .grw-tag-label {
+    font-size: 10px;
+    font-weight: normal;
+    border-radius: $border-radius;
+  }
+}
+
 #edit-tag-modal {
   .form-control {
     height: auto;

+ 10 - 0
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -467,6 +467,16 @@ ul.pagination {
   }
 }
 
+/*
+ * GROWI popular tags
+ */
+.grw-popular-tag-labels {
+  .grw-tag-label {
+    color: $color-tags;
+    background-color: $bgcolor-tags;
+  }
+}
+
 /*
  * admin settings
  */

+ 15 - 1
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -178,7 +178,11 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
 .grw-sidebar {
   // List
   @include override-list-group-item($color-list, $bgcolor-sidebar-list-group, $color-list-hover, $bgcolor-list-hover, $color-list-active, $bgcolor-list-active);
-
+  // sidebar-centent-bg
+  .grw-navigation-wrap {
+    // Drop a shadow on the light theme. The dark theme makes '$ bgcolor-sidebar-context' brighter than the body.
+    box-shadow: 0px 0px 3px rgba(black, 0.24);
+  }
   // Pagetree
   .grw-pagetree {
     @include override-list-group-item-for-pagetree(
@@ -356,6 +360,16 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
   }
 }
 
+/*
+ * GROWI popular tags
+ */
+.grw-popular-tag-labels {
+  .grw-tag-label {
+    color: $color-tags;
+    background-color: $bgcolor-tags;
+  }
+}
+
 /*
 * grw-side-contents
 */

+ 1 - 1
packages/app/src/styles/theme/blackboard.scss

@@ -60,7 +60,7 @@ html[dark] {
   $color-resize-button-hover: $color-global;
   $bgcolor-resize-button-hover: darken($bgcolor-resize-button, 5%);
   // Sidebar contents
-  $bgcolor-sidebar-context: $subthemecolor;
+  $bgcolor-sidebar-context: lighten($subthemecolor, 8%);
   $color-sidebar-context: $color-global;
   // Sidebar list group
   // $bgcolor-sidebar-list-group: #; // optional

+ 1 - 1
packages/app/src/styles/theme/christmas.scss

@@ -85,7 +85,7 @@ html[dark] {
   $color-resize-button-hover: $color-reversal;
   $bgcolor-resize-button-hover: lighten($bgcolor-resize-button, 5%);
   $color-sidebar-context: $linktext;
-  $bgcolor-sidebar-context: #f4f6fc;
+  $bgcolor-sidebar-context: #f4fcf6;
   // Sidebar list group
   $bgcolor-sidebar-list-group: #fafbff; // optional
 

+ 2 - 2
packages/app/src/styles/theme/fire-red.scss

@@ -56,7 +56,7 @@ html[light] {
   $bgcolor-resize-button-hover: lighten($bgcolor-resize-button, 5%);
   // Sidebar contents
   $color-sidebar-context: $color-global;
-  $bgcolor-sidebar-context: #ebebeb;
+  $bgcolor-sidebar-context: #ececec;
   // Sidebar list group
   // $bgcolor-sidebar-list-group: #; // optional
 
@@ -152,7 +152,7 @@ html[dark] {
   $color-resize-button-hover: $color-global;
   $bgcolor-resize-button-hover: darken($bgcolor-resize-button, 5%);
   // Sidebar contents
-  $bgcolor-sidebar-context: #2e2e2e;
+  $bgcolor-sidebar-context: #413f3f;
   $color-sidebar-context: $color-global;
   // Sidebar list group
   // $bgcolor-sidebar-list-group: #; // optional

+ 2 - 2
packages/app/src/styles/theme/future.scss

@@ -55,8 +55,8 @@ html[dark] {
   $text-shadow-sidebar-nav-item-active: 0px 0px 10px #969494; // optional
 
   // Sidebar contents
-  $color-sidebar-context: #00c2c4;
-  $bgcolor-sidebar-context: #020b0b;
+  $color-sidebar-context: #2cfbff;
+  $bgcolor-sidebar-context: #184040;
 
   // Sidebar list group
   $bgcolor-sidebar-list-group: #162126; // optional

+ 1 - 1
packages/app/src/styles/theme/halloween.scss

@@ -80,7 +80,7 @@ html[dark] {
 
   // Sidebar contents
   $color-sidebar-context: #aa97cb;
-  $bgcolor-sidebar-context: #1d2126;
+  $bgcolor-sidebar-context: #302b3c;
 
   // Sidebar list group
   $bgcolor-sidebar-list-group: #2c2926; // optional

+ 1 - 1
packages/app/src/styles/theme/hufflepuff.scss

@@ -216,7 +216,7 @@ html[dark] {
   $bgcolor-resize-button-hover: darken($bgcolor-resize-button, 7%);
   // Sidebar contents
   $color-sidebar-context: $color-global;
-  $bgcolor-sidebar-context: $subthemecolor;
+  $bgcolor-sidebar-context: lighten($themedark, 5%);
   // Sidebar list group
   $bgcolor-sidebar-list-group: lighten($subthemecolor, 5%);
 

+ 1 - 1
packages/app/src/styles/theme/jade-green.scss

@@ -152,7 +152,7 @@ html[dark] {
   $color-resize-button-hover: $color-global;
   $bgcolor-resize-button-hover: darken($bgcolor-resize-button, 5%);
   // Sidebar contents
-  $bgcolor-sidebar-context: #2e2e2e;
+  $bgcolor-sidebar-context: #3c403c;
   $color-sidebar-context: $color-global;
   // Sidebar list group
   // $bgcolor-sidebar-list-group: #; // optional

+ 1 - 1
packages/app/src/styles/theme/nature.scss

@@ -72,7 +72,7 @@ html[dark] {
   $bgcolor-sidebar: #188f64;
   // Sidebar contents
   $color-sidebar-context: #7e0044;
-  $bgcolor-sidebar-context: #f7f9e9;
+  $bgcolor-sidebar-context: #f4f5ec;
   // Sidebar resize button
   $color-resize-button: white;
   $bgcolor-resize-button: $themecolor;

+ 1 - 1
packages/app/src/styles/theme/spring.scss

@@ -73,7 +73,7 @@ html[dark] {
   $bgcolor-resize-button-hover: lighten($bgcolor-resize-button, 5%);
   // Sidebar contents
   $color-sidebar-context: $subthemecolor;
-  $bgcolor-sidebar-context: #f4f6fc;
+  $bgcolor-sidebar-context: #fdfffe;
   // Sidebar list group
   $bgcolor-sidebar-list-group: #fafbff; // optional
 

+ 1 - 1
packages/app/src/styles/theme/wood.scss

@@ -91,7 +91,7 @@ html[dark] {
   $bgcolor-sidebar: $themecolor;
   // Sidebar contents
   $color-sidebar-context: #9d7406;
-  $bgcolor-sidebar-context: lighten($themecolor, 32%);
+  $bgcolor-sidebar-context: lighten($themecolor, 38%);
   // Sidebar list group
   $bgcolor-sidebar-list-group: rgba(#f7f5f1, 0.5);
   // Sidebar resize button

+ 28 - 0
packages/app/test/cypress/integration/20-basic-features/access-to-page.spec.ts

@@ -146,3 +146,31 @@ context('Access to Template Editing Mode', () => {
 
 });
 
+context('Access to /me/all-in-app-notifications', () => {
+  const ssPrefix = 'in-app-notifications-';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+    // collapse sidebar
+    cy.collapseSidebar(true);
+  });
+
+  it('All In-App Notification list is successfully loaded', () => {
+    cy.visit('/');
+    cy.get('.notification-wrapper > a').click();
+    cy.get('.notification-wrapper > .dropdown-menu > a').click();
+
+    cy.get('#all-in-app-notifications').should('be.visible');
+
+    cy.screenshot(`${ssPrefix}-see-all`, { capture: 'viewport' });
+
+    cy.get('.grw-custom-nav-tab > div > ul > li:nth-child(2) > a').click();
+
+    cy.screenshot(`${ssPrefix}-see-unread`, { capture: 'viewport' });
+   });
+
+})
+

+ 306 - 2
packages/app/test/integration/service/page-grant.test.js

@@ -1,8 +1,10 @@
 import mongoose from 'mongoose';
 
-import { getInstance } from '../setup-crowi';
+import { PageGrant } from '~/interfaces/page';
 import UserGroup from '~/server/models/user-group';
 
+import { getInstance } from '../setup-crowi';
+
 /*
  * There are 3 grant types to test.
  * GRANT_PUBLIC, GRANT_OWNER, GRANT_USER_GROUP
@@ -29,7 +31,9 @@ describe('PageGrantService', () => {
   let groupChild;
 
   let rootPage;
-
+  let rootPublicPage;
+  let rootOnlyMePage;
+  let rootOnlyInsideTheGroup;
   let emptyPage1;
   let emptyPage2;
   let emptyPage3;
@@ -42,6 +46,21 @@ describe('PageGrantService', () => {
   const pageRootPublicPath = '/Public';
   const pageRootGroupParentPath = '/GroupParent';
 
+  const v4PageRootOnlyMePagePath = '/v4OnlyMe';
+  const v4PageRootAnyoneWithTheLinkPagePath = '/v4AnyoneWithTheLink';
+  const v4PageRootOnlyInsideTheGroupPagePath = '/v4OnlyInsideTheGroup';
+
+  const pagePublicOnlyMePath = `${pageRootPublicPath}/OnlyMe`;
+  const pagePublicAnyoneWithTheLinkPath = `${pageRootPublicPath}/AnyoneWithTheLink`;
+  const pagePublicOnlyInsideTheGroupPath = `${pageRootPublicPath}/OnlyInsideTheGroup`;
+
+  const pageOnlyMePublicPath = `${v4PageRootOnlyMePagePath}/Public`;
+  const pageOnlyMeAnyoneWithTheLinkPath = `${v4PageRootOnlyMePagePath}/AnyoneWithTheLink`;
+  const pageOnlyMeOnlyInsideTheGroupPath = `${v4PageRootOnlyMePagePath}/OnlyInsideTheGroup`;
+
+  const pageOnlyInsideTheGroupPublicPath = `${v4PageRootOnlyInsideTheGroupPagePath}/Public`;
+  const pageOnlyInsideTheGroupOnlyMePath = `${v4PageRootOnlyInsideTheGroupPagePath}/OnlyMe`;
+  const pageOnlyInsideTheGroupAnyoneWithTheLinkPath = `${v4PageRootOnlyInsideTheGroupPagePath}/AnyoneWithTheLink`;
   let pageE1Public;
   let pageE2User1;
   let pageE3GroupParent;
@@ -151,6 +170,98 @@ describe('PageGrantService', () => {
       },
     ]);
 
+    await Page.insertMany([
+      // Root Page
+      {
+        path: rootPage,
+        grant: Page.GRANT_PUBLIC,
+        parent: null,
+      },
+      // OnlyMe v4
+      {
+        path: v4PageRootOnlyMePagePath,
+        grant: Page.GRANT_OWNER,
+        grantedUsers: [user1._id],
+        parent: null,
+      },
+      // AnyoneWithTheLink v4
+      {
+        path: v4PageRootAnyoneWithTheLinkPagePath,
+        grant: Page.GRANT_RESTRICTED,
+        parent: null,
+      },
+      // OnlyInsideTheGroup v4
+      {
+        path: v4PageRootOnlyInsideTheGroupPagePath,
+        grant: Page.GRANT_USER_GROUP,
+        parent: null,
+        grantedGroup: groupParent._id,
+      },
+    ]);
+
+    rootPublicPage = await Page.findOne({ path: pageRootPublicPath });
+    rootOnlyMePage = await Page.findOne({ path: v4PageRootOnlyMePagePath });
+    rootOnlyInsideTheGroup = await Page.findOne({ path: v4PageRootOnlyInsideTheGroupPagePath });
+
+
+    // Leaf pages (Depth: 2)
+    await Page.insertMany([
+      /*
+      * Parent is public
+      */
+      {
+        path: pagePublicOnlyMePath,
+        grant: Page.GRANT_OWNER,
+        parent: rootPublicPage._id,
+      },
+      {
+        path: pagePublicAnyoneWithTheLinkPath,
+        grant: Page.GRANT_RESTRICTED,
+        parent: rootPublicPage._id,
+      },
+      {
+        path: pagePublicOnlyInsideTheGroupPath,
+        grant: Page.GRANT_USER_GROUP,
+        parent: rootPublicPage._id,
+      },
+      /*
+      * Parent is onlyMe
+      */
+      {
+        path: pageOnlyMePublicPath,
+        grant: Page.GRANT_PUBLIC,
+        parent: rootOnlyMePage._id,
+      },
+      {
+        path: pageOnlyMeAnyoneWithTheLinkPath,
+        grant: Page.GRANT_RESTRICTED,
+        parent: rootOnlyMePage._id,
+      },
+      {
+        path: pageOnlyMeOnlyInsideTheGroupPath,
+        grant: Page.GRANT_USER_GROUP,
+        parent: rootOnlyMePage._id,
+      },
+      /*
+      * Parent is OnlyInsideTheGroup
+      */
+      {
+        path: pageOnlyInsideTheGroupPublicPath,
+        grant: Page.GRANT_PUBLIC,
+        parent: rootOnlyInsideTheGroup._id,
+      },
+      {
+        path: pageOnlyInsideTheGroupOnlyMePath,
+        grant: Page.GRANT_PUBLIC,
+        parent: rootOnlyInsideTheGroup._id,
+      },
+      {
+        path: pageOnlyInsideTheGroupAnyoneWithTheLinkPath,
+        grant: Page.GRANT_PUBLIC,
+        parent: rootOnlyInsideTheGroup._id,
+      },
+    ]);
+
     emptyPage1 = await Page.findOne({ path: emptyPagePath1 });
     emptyPage2 = await Page.findOne({ path: emptyPagePath2 });
     emptyPage3 = await Page.findOne({ path: emptyPagePath3 });
@@ -360,4 +471,197 @@ describe('PageGrantService', () => {
     });
   });
 
+
+  describe('Test for calcApplicableGrantData', () => {
+    test('Only Public is Applicable in case of top page', async() => {
+      const result = await pageGrantService.calcApplicableGrantData(rootPage, user1);
+
+      expect(result).toStrictEqual(
+        {
+          [PageGrant.GRANT_PUBLIC]: null,
+        },
+      );
+    });
+
+    // parent property of all private pages is null
+    test('Any grant is allowed if parent is null', async() => {
+      const userGroupRelation = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user1);
+
+      // OnlyMe
+      const rootOnlyMePage = await Page.findOne({ path: v4PageRootOnlyMePagePath });
+      const rootOnlyMePageRes = await pageGrantService.calcApplicableGrantData(rootOnlyMePage, user1);
+      expect(rootOnlyMePageRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_PUBLIC]: null,
+          [PageGrant.GRANT_RESTRICTED]: null,
+          [PageGrant.GRANT_OWNER]: null,
+          [PageGrant.GRANT_USER_GROUP]: userGroupRelation,
+        },
+      );
+
+      // AnyoneWithTheLink
+      const rootAnyoneWithTheLinkPage = await Page.findOne({ path: v4PageRootAnyoneWithTheLinkPagePath });
+      const anyoneWithTheLinkRes = await pageGrantService.calcApplicableGrantData(rootAnyoneWithTheLinkPage, user1);
+      expect(anyoneWithTheLinkRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_PUBLIC]: null,
+          [PageGrant.GRANT_RESTRICTED]: null,
+          [PageGrant.GRANT_OWNER]: null,
+          [PageGrant.GRANT_USER_GROUP]: userGroupRelation,
+        },
+      );
+
+      // OnlyInsideTheGroup
+      const rootOnlyInsideTheGroupPage = await Page.findOne({ path: v4PageRootOnlyInsideTheGroupPagePath });
+      const onlyInsideTheGroupRes = await pageGrantService.calcApplicableGrantData(rootOnlyInsideTheGroupPage, user1);
+      expect(onlyInsideTheGroupRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_PUBLIC]: null,
+          [PageGrant.GRANT_RESTRICTED]: null,
+          [PageGrant.GRANT_OWNER]: null,
+          [PageGrant.GRANT_USER_GROUP]: userGroupRelation,
+        },
+      );
+    });
+
+
+    test('Any grant is allowed if parent is public', async() => {
+      const userGroupRelation = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user1);
+
+      // OnlyMe
+      const publicOnlyMePage = await Page.findOne({ path: pagePublicOnlyMePath });
+      const publicOnlyMeRes = await pageGrantService.calcApplicableGrantData(publicOnlyMePage, user1);
+      expect(publicOnlyMeRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_PUBLIC]: null,
+          [PageGrant.GRANT_RESTRICTED]: null,
+          [PageGrant.GRANT_OWNER]: null,
+          [PageGrant.GRANT_USER_GROUP]: userGroupRelation,
+        },
+      );
+
+      // AnyoneWithTheLink
+      const publicAnyoneWithTheLinkPage = await Page.findOne({ path: pagePublicAnyoneWithTheLinkPath });
+      const publicAnyoneWithTheLinkRes = await pageGrantService.calcApplicableGrantData(publicAnyoneWithTheLinkPage, user1);
+      expect(publicAnyoneWithTheLinkRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_PUBLIC]: null,
+          [PageGrant.GRANT_RESTRICTED]: null,
+          [PageGrant.GRANT_OWNER]: null,
+          [PageGrant.GRANT_USER_GROUP]: userGroupRelation,
+        },
+      );
+
+      // OnlyInsideTheGroup
+      const publicOnlyInsideTheGroupPage = await Page.findOne({ path: pagePublicOnlyInsideTheGroupPath });
+      const publicOnlyInsideTheGroupRes = await pageGrantService.calcApplicableGrantData(publicOnlyInsideTheGroupPage, user1);
+      expect(publicOnlyInsideTheGroupRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_PUBLIC]: null,
+          [PageGrant.GRANT_RESTRICTED]: null,
+          [PageGrant.GRANT_OWNER]: null,
+          [PageGrant.GRANT_USER_GROUP]: userGroupRelation,
+        },
+      );
+    });
+
+
+    test('Only "GRANT_OWNER" is allowed if the user is the parent page\'s grantUser', async() => {
+      // Public
+      const onlyMePublicPage = await Page.findOne({ path: pageOnlyMePublicPath });
+      const onlyMePublicRes = await pageGrantService.calcApplicableGrantData(onlyMePublicPage, user1);
+      expect(onlyMePublicRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_RESTRICTED]: null,
+          [PageGrant.GRANT_OWNER]: null,
+        },
+      );
+
+      // AnyoneWithTheLink
+      const onlyMeAnyoneWithTheLinkPage = await Page.findOne({ path: pageOnlyMeAnyoneWithTheLinkPath });
+      const onlyMeAnyoneWithTheLinkRes = await pageGrantService.calcApplicableGrantData(onlyMeAnyoneWithTheLinkPage, user1);
+      expect(onlyMeAnyoneWithTheLinkRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_RESTRICTED]: null,
+          [PageGrant.GRANT_OWNER]: null,
+        },
+      );
+
+      // OnlyInsideTheGroup
+      const publicOnlyInsideTheGroupPage = await Page.findOne({ path: pageOnlyMeOnlyInsideTheGroupPath });
+      const publicOnlyInsideTheGroupRes = await pageGrantService.calcApplicableGrantData(publicOnlyInsideTheGroupPage, user1);
+      expect(publicOnlyInsideTheGroupRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_RESTRICTED]: null,
+          [PageGrant.GRANT_OWNER]: null,
+        },
+      );
+    });
+
+    test('"GRANT_OWNER" is not allowed if the user is not the parent page\'s grantUser', async() => {
+      // Public
+      const onlyMePublicPage = await Page.findOne({ path: pageOnlyMePublicPath });
+      const onlyMePublicRes = await pageGrantService.calcApplicableGrantData(onlyMePublicPage, user2);
+      expect(onlyMePublicRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_RESTRICTED]: null,
+        },
+      );
+
+      // AnyoneWithTheLink
+      const onlyMeAnyoneWithTheLinkPage = await Page.findOne({ path: pageOnlyMeAnyoneWithTheLinkPath });
+      const onlyMeAnyoneWithTheLinkRes = await pageGrantService.calcApplicableGrantData(onlyMeAnyoneWithTheLinkPage, user2);
+      expect(onlyMeAnyoneWithTheLinkRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_RESTRICTED]: null,
+        },
+      );
+
+      // OnlyInsideTheGroup
+      const publicOnlyInsideTheGroupPage = await Page.findOne({ path: pageOnlyMeOnlyInsideTheGroupPath });
+      const publicOnlyInsideTheGroupRes = await pageGrantService.calcApplicableGrantData(publicOnlyInsideTheGroupPage, user2);
+      expect(publicOnlyInsideTheGroupRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_RESTRICTED]: null,
+        },
+      );
+    });
+
+    test('"GRANT_USER_GROUP" is allowed if the parent\'s grant is GRANT_USER_GROUP and the user is included in the group', async() => {
+      const applicableGroups = await UserGroupRelation.findGroupsWithDescendantsByGroupAndUser(groupParent, user1);
+
+      // Public
+      const onlyInsideGroupPublicPage = await Page.findOne({ path: pageOnlyInsideTheGroupPublicPath });
+      const onlyInsideGroupPublicRes = await pageGrantService.calcApplicableGrantData(onlyInsideGroupPublicPage, user1);
+      expect(onlyInsideGroupPublicRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_RESTRICTED]: null,
+          [PageGrant.GRANT_OWNER]: null,
+          [PageGrant.GRANT_USER_GROUP]: { applicableGroups },
+        },
+      );
+
+      // OnlyMe
+      const onlyInsideTheGroupOnlyMePage = await Page.findOne({ path: pageOnlyInsideTheGroupOnlyMePath });
+      const onlyInsideTheGroupOnlyMeRes = await pageGrantService.calcApplicableGrantData(onlyInsideTheGroupOnlyMePage, user1);
+      expect(onlyInsideTheGroupOnlyMeRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_RESTRICTED]: null,
+          [PageGrant.GRANT_OWNER]: null,
+          [PageGrant.GRANT_USER_GROUP]: { applicableGroups },
+        },
+      );
+
+      // AnyoneWithTheLink
+      const onlyInsideTheGroupAnyoneWithTheLinkPage = await Page.findOne({ path: pageOnlyInsideTheGroupAnyoneWithTheLinkPath });
+      const onlyInsideTheGroupAnyoneWithTheLinkRes = await pageGrantService.calcApplicableGrantData(onlyInsideTheGroupAnyoneWithTheLinkPage, user1);
+      expect(onlyInsideTheGroupAnyoneWithTheLinkRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_RESTRICTED]: null,
+          [PageGrant.GRANT_OWNER]: null,
+          [PageGrant.GRANT_USER_GROUP]: { applicableGroups },
+        },
+      );
+    });
+  });
 });

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

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

+ 1 - 1
packages/core/package.json

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

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

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

Неке датотеке нису приказане због велике количине промена