فهرست منبع

Merge branch 'master' into imprv/show-page-control-menu-on-empty-page

yohei0125 4 سال پیش
والد
کامیت
8b8677ef04
100فایلهای تغییر یافته به همراه565 افزوده شده و 318 حذف شده
  1. 27 0
      .eslintrc.js
  2. 11 0
      CHANGELOG.md
  3. 1 1
      package.json
  4. 1 0
      packages/app/resource/locales/en_US/admin/admin.json
  5. 3 1
      packages/app/resource/locales/en_US/notifications/passwordReset.txt
  6. 0 0
      packages/app/resource/locales/en_US/notifications/passwordResetSuccessful.txt
  7. 3 1
      packages/app/resource/locales/en_US/notifications/userActivation.txt
  8. 1 1
      packages/app/resource/locales/en_US/translation.json
  9. 1 0
      packages/app/resource/locales/ja_JP/admin/admin.json
  10. 3 1
      packages/app/resource/locales/ja_JP/notifications/passwordReset.txt
  11. 3 1
      packages/app/resource/locales/ja_JP/notifications/userActivation.txt
  12. 1 1
      packages/app/resource/locales/ja_JP/translation.json
  13. 1 0
      packages/app/resource/locales/zh_CN/admin/admin.json
  14. 3 1
      packages/app/resource/locales/zh_CN/notifications/passwordReset.txt
  15. 0 0
      packages/app/resource/locales/zh_CN/notifications/passwordResetSuccessful.txt
  16. 3 1
      packages/app/resource/locales/zh_CN/notifications/userActivation.txt
  17. 1 1
      packages/app/resource/locales/zh_CN/translation.json
  18. 11 0
      packages/app/src/client/services/PersonalContainer.js
  19. 4 1
      packages/app/src/components/Admin/SlackIntegration/BotTypeCard.jsx
  20. 25 1
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  21. 21 0
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  22. 24 10
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  23. 13 0
      packages/app/src/components/Me/BasicInfoSettings.jsx
  24. 27 8
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  25. 18 17
      packages/app/src/components/Navbar/PageEditorModeManager.jsx
  26. 5 3
      packages/app/src/components/Page/DisplaySwitcher.tsx
  27. 4 4
      packages/app/src/components/PageCreateModal.jsx
  28. 15 23
      packages/app/src/components/PageList/PageListItemL.tsx
  29. 5 2
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  30. 1 1
      packages/app/src/components/SearchPage/SortControl.tsx
  31. 5 1
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  32. 11 2
      packages/app/src/server/models/password-reset-order.ts
  33. 12 3
      packages/app/src/server/models/user-registration-order.ts
  34. 17 0
      packages/app/src/server/models/user.js
  35. 7 5
      packages/app/src/server/routes/apiv3/forgot-password.js
  36. 2 0
      packages/app/src/server/routes/apiv3/personal-setting.js
  37. 1 1
      packages/app/src/server/routes/apiv3/user-group.js
  38. 5 1
      packages/app/src/server/routes/user-activation.ts
  39. 2 2
      packages/app/src/server/service/slack-command-handler/create-page-service.js
  40. 37 4
      packages/app/src/server/service/slack-command-handler/keep.js
  41. 5 0
      packages/app/src/styles/_page-accessories-control.scss
  42. 2 2
      packages/app/src/styles/_page-tree.scss
  43. 0 4
      packages/app/src/styles/_search.scss
  44. 12 0
      packages/app/src/styles/style-app.scss
  45. 8 3
      packages/app/test/cypress/integration/2-basic-features/access-to-page.spec.ts
  46. 0 14
      packages/app/test/cypress/integration/2-basic-features/use-tools.spec.ts
  47. 5 0
      packages/app/test/cypress/integration/4-admin/access-to-admin-page.spec.ts
  48. 2 2
      packages/codemirror-textlint/src/index.ts
  49. 2 2
      packages/core/src/index.js
  50. 1 1
      packages/core/src/test/plugin/service/tag-cache-manager.test.js
  51. 1 0
      packages/core/src/utils/page-path-utils.ts
  52. 1 1
      packages/plugin-attachment-refs/src/client-entry.js
  53. 4 5
      packages/plugin-attachment-refs/src/client/js/components/AttachmentList.jsx
  54. 1 2
      packages/plugin-attachment-refs/src/client/js/components/ExtractedAttachments.jsx
  55. 3 3
      packages/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPostRenderInterceptor.js
  56. 1 1
      packages/plugin-lsx/src/client-entry.js
  57. 4 4
      packages/plugin-lsx/src/client/js/components/Lsx.jsx
  58. 3 2
      packages/plugin-lsx/src/client/js/components/LsxPageList/LsxListView.jsx
  59. 2 3
      packages/plugin-lsx/src/client/js/components/LsxPageList/LsxPage.jsx
  60. 2 2
      packages/plugin-lsx/src/client/js/components/LsxPageList/PagePathWrapper.jsx
  61. 2 2
      packages/plugin-lsx/src/client/js/util/Interceptor/LsxPostRenderInterceptor.js
  62. 1 1
      packages/plugin-lsx/src/client/js/util/Interceptor/LsxPreRenderInterceptor.js
  63. 1 0
      packages/slack/src/interfaces/growi-interaction-processor.ts
  64. 2 1
      packages/slack/src/middlewares/parse-slack-interaction-request.ts
  65. 2 2
      packages/slack/src/middlewares/verify-growi-to-slack-request.ts
  66. 4 3
      packages/slack/src/middlewares/verify-slack-request.ts
  67. 4 3
      packages/slack/src/utils/check-communicable.ts
  68. 3 1
      packages/slack/src/utils/interaction-payload-accessor.ts
  69. 2 1
      packages/slack/src/utils/post-ephemeral-errors.ts
  70. 2 1
      packages/slack/src/utils/respond-util-factory.ts
  71. 8 7
      packages/slackbot-proxy/src/Server.ts
  72. 9 12
      packages/slackbot-proxy/src/controllers/growi-to-slack.ts
  73. 11 13
      packages/slackbot-proxy/src/controllers/slack.ts
  74. 1 2
      packages/slackbot-proxy/src/controllers/top.ts
  75. 1 1
      packages/slackbot-proxy/src/entities/installation.ts
  76. 1 0
      packages/slackbot-proxy/src/entities/order.ts
  77. 1 0
      packages/slackbot-proxy/src/entities/relation.ts
  78. 1 1
      packages/slackbot-proxy/src/interfaces/growi-to-slack/growi-req.ts
  79. 0 1
      packages/slackbot-proxy/src/middlewares/GlobalHttpErrorHandlingMiddleware.ts
  80. 0 2
      packages/slackbot-proxy/src/middlewares/slack-to-growi/authorizer.ts
  81. 1 1
      packages/slackbot-proxy/src/middlewares/slack-to-growi/extract-growi-uri-from-req.ts
  82. 1 2
      packages/slackbot-proxy/src/middlewares/slack-to-growi/join-to-conversation.ts
  83. 1 2
      packages/slackbot-proxy/src/middlewares/slack-to-growi/parse-interaction-req.ts
  84. 1 0
      packages/slackbot-proxy/src/middlewares/slack-to-growi/url-verification.ts
  85. 0 1
      packages/slackbot-proxy/src/models/errors.ts
  86. 4 3
      packages/slackbot-proxy/src/services/LinkSharedService.ts
  87. 9 6
      packages/slackbot-proxy/src/services/RegisterService.ts
  88. 3 5
      packages/slackbot-proxy/src/services/RelationsService.ts
  89. 2 2
      packages/slackbot-proxy/src/services/SelectGrowiService.ts
  90. 2 2
      packages/slackbot-proxy/src/services/SystemInformationService.ts
  91. 5 4
      packages/slackbot-proxy/src/services/UnregisterService.ts
  92. 2 0
      packages/slackbot-proxy/src/services/growi-uri-injector/ActionsBlockPayloadDelegator.ts
  93. 2 0
      packages/slackbot-proxy/src/services/growi-uri-injector/SectionBlockPayloadDelegator.ts
  94. 1 0
      packages/slackbot-proxy/src/services/growi-uri-injector/ViewInteractionPayloadDelegator.ts
  95. 1 0
      packages/slackbot-proxy/src/services/growi-uri-injector/block-elements/ButtonActionPayloadDelegator.ts
  96. 1 0
      packages/slackbot-proxy/src/services/growi-uri-injector/block-elements/CheckboxesActionPayloadDelegator.ts
  97. 1 1
      packages/slackbot-proxy/src/utils/welcome-message.ts
  98. 2 0
      packages/ui/src/components/Attachment/Attachment.jsx
  99. 0 85
      packages/ui/src/components/PagePath/PageListMeta.jsx
  100. 76 0
      packages/ui/src/components/PagePath/PageListMeta.tsx

+ 27 - 0
.eslintrc.js

@@ -16,6 +16,33 @@ module.exports = {
   ],
   rules: {
     'import/prefer-default-export': 'off',
+    'import/order': [
+      'warn',
+      {
+        pathGroups: [
+          {
+            pattern: 'react',
+            group: 'builtin',
+            position: 'before',
+          },
+          {
+            pattern: '^/**',
+            group: 'parent',
+            position: 'before',
+          },
+          {
+            pattern: '~/**',
+            group: 'parent',
+            position: 'before',
+          },
+        ],
+        alphabetize: {
+          order: 'asc',
+        },
+        pathGroupsExcludedImportTypes: ['react'],
+        'newlines-between': 'always',
+      },
+    ],
     '@typescript-eslint/no-explicit-any': 'off',
     indent: [
       'error',

+ 11 - 0
CHANGELOG.md

@@ -54,6 +54,17 @@
 - support: update nanoid yarn.lock v3.1.30 to v3.2.0 (#5216) @LuqmanHakim-Grune
 - support: update validator version (#5562) @LuqmanHakim-Grune
 
+## [v4.5.16](https://github.com/weseek/growi/compare/v4.5.15...v4.5.16) - 2022-04-06
+
+### 💎 Features
+
+- feat: Support Elasticsearch 7 (#5613) @Yohei-Shiina
+
+### 🐛 Bug Fixes
+
+- fix: Domain whitelist is not respected (fix #5408) (#5488) @yuto-oweseek
+- fix: Add tags to pages restricted by specified groups on View mode (for v4.5.x) (#5487) @yuto-oweseek
+
 ## [v4.5.15](https://github.com/weseek/growi/compare/v4.5.14...v4.5.15) - 2022-02-17
 
 ### 🚀 Improvement

+ 1 - 1
package.json

@@ -59,7 +59,7 @@
     "@typescript-eslint/parser": "^4.28.5",
     "cypress": "^9.2.0",
     "eslint": "^7.31.0",
-    "eslint-config-weseek": "^1.1.0",
+    "eslint-config-weseek": "^2.1.0",
     "eslint-import-resolver-typescript": "^2.4.0",
     "eslint-plugin-import": "^2.23.4",
     "eslint-plugin-jest": "^24.3.2",

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

@@ -472,6 +472,7 @@
   "user_group_management": {
     "create_group": "Create new group",
     "add_child_group": "Add child group",
+    "remove_child_group": "Remove",
     "deny_create_group": "You can't create a new group with the current settings.",
     "group_name": "Group name",
     "group_example": "e.g. : Group1",

+ 3 - 1
packages/app/resource/locales/en_US/notifications/passwordReset.txt

@@ -2,9 +2,11 @@ Password Reset
 
 Hi, {{ email }}
 
-A request has been received to change the password your GROWI account {{ appTitle }}.
+A request has been received to change the password your GROWI ({{ appTitle }}) account.
 To reset your password, click on the link below.
 
 {{ url }}
 
+This link will expire in 10 minutes at  {{ expiredAt }}.
+
 If you did not request a password reset, you can safely ignore this email.

+ 0 - 0
packages/app/resource/locales/en_US/notifications/PasswordResetSuccessful.txt → packages/app/resource/locales/en_US/notifications/passwordResetSuccessful.txt


+ 3 - 1
packages/app/resource/locales/en_US/notifications/userActivation.txt

@@ -2,9 +2,11 @@ Account confirmation
 
 Hi, {{ email }}
 
-An acount has been created in GROWI {{ appTitle }}.
+An acount has been created in GROWI ({{ appTitle }}).
 To activate your account, click on the link below.
 
 {{ url }}
 
+This link will expire in 1 hour at  {{ expiredAt }}.
+
 If you did not created the account, you can safely ignore this email.

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

@@ -687,7 +687,7 @@
     "max_age_desc": "Specifies the number (in milliseconds) to expire users session.<br>Default: 2592000000 (30days)",
     "max_age_caution": "Restarting the server is required after you modify this value.",
     "forced_update_desc": "Settings have been forcibly changed. Previous setting: ",
-    "page_delete_rights_caution": "The \"Delete / Delete All\" permission (including descendant pages) is forced to be stronger than the \"Delete / Completely Delete\" permission. <br> <br> Anyone > Admin and autor > Admin only",
+    "page_delete_rights_caution": "The \"Delete / Delete All\" permission (including descendant pages) is forced to be stronger than the \"Delete / Completely Delete\" permission. <br> <br> Admin only > Admin and autor > Anyone",
     "Authentication mechanism settings": "Authentication Mechanism Settings",
     "setup_is_not_yet_complete": "Setup is not yet complete",
     "alert_siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the {{link}}",

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

@@ -471,6 +471,7 @@
   "user_group_management": {
     "create_group": "新規グループの作成",
     "add_child_group": "子グループの追加",
+    "remove_child_group": "解除",
     "deny_create_group": "新規グループの作成はできません。",
     "group_name": "グループ名",
     "group_example": "例: Group1",

+ 3 - 1
packages/app/resource/locales/ja_JP/notifications/passwordReset.txt

@@ -2,9 +2,11 @@
 
 こんにちは, {{ email }}
 
-あなたのGROWIアカウント {{ appTitle }} から、パスワード再設定のリクエストがありました。
+あなたのGROWI ({{ appTitle }}) アカウントから、パスワード再設定のリクエストがありました。
 パスワードをリセットするには、以下のリンクをクリックしてください。
 
 {{ url }}
 
+このリンクは10分後の {{ expiredAt }} に失効します。
+
 もしこのリクエストに心当たりがない場合は、このメールを無視してください。

+ 3 - 1
packages/app/resource/locales/ja_JP/notifications/userActivation.txt

@@ -2,10 +2,12 @@
 
 {{ email }} さん
 
-GROWI {{ appTitle }} で仮登録が完了いたしました。
+GROWI ({{ appTitle }}) で仮登録が完了いたしました。
 
 ご本人様確認のため、下記リンクをクリックし、アカウントの本登録を完了させて下さい。
 
 {{ url }}
 
+このリンクは1時間後の {{ expiredAt }} に失効します。
+
 ※当メールの内容に心当たりがない場合は、このメールを無視してください。

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

@@ -686,7 +686,7 @@
     "max_age_desc": "ユーザーのセッション情報の有効期間をミリ秒で指定できます。<br>デフォルト値: 2592000000 (30日間)",
     "max_age_caution": "この値を変更した後は、サーバーを再起動する必要があります。",
     "forced_update_desc": "設定が強制変更されました。前回の設定: ",
-    "page_delete_rights_caution": "「(子孫ページを含む)ゴミ箱に入れる操作 / 完全に削除する」の権限は、「ゴミ箱に入れる操作 / 完全に削除する」よりも強い権限になるように強制されます。 <br><br> 誰でも可能 > 管理者とページ作者が可能 > 管理者のみ可能",
+    "page_delete_rights_caution": "「(子孫ページを含む)ゴミ箱に入れる操作 / 完全に削除する」の権限は、「ゴミ箱に入れる操作 / 完全に削除する」よりも強い権限になるように強制されます。 <br><br> 管理者のみ可能 > 管理者とページ作者が可能 > 誰でも可能",
     "Authentication mechanism settings": "認証機構設定",
     "setup_is_not_yet_complete":"セットアップはまだ完了してません",
     "alert_siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",

+ 1 - 0
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -481,6 +481,7 @@
   "user_group_management": {
     "create_group": "创建新组",
     "add_child_group": "添加一个子组",
+    "remove_child_group": "移除",
     "deny_create_group": "不能用当前设置创建新组。",
     "group_name": "组名",
     "group_example": "e.g.:第1组",

+ 3 - 1
packages/app/resource/locales/zh_CN/notifications/passwordReset.txt

@@ -2,9 +2,11 @@
 
 嗨,{{ email }}
 
-已收到更改您 GROWI 帐户 {{appTitle}} 密码的请求。
+已收到更改您 GROWI ({{appTitle}}) 帐户 密码的请求。
 要重置密码,请单击下面的链接。
 
 {{ url }}
 
+这个链接在10分钟后的{ expiredAt }}失效。
+
 如果您没有要求重置密码,则可以放心地忽略此电子邮件。

+ 0 - 0
packages/app/resource/locales/zh_CN/notifications/PasswordResetSuccessful.txt → packages/app/resource/locales/zh_CN/notifications/passwordResetSuccessful.txt


+ 3 - 1
packages/app/resource/locales/zh_CN/notifications/userActivation.txt

@@ -2,9 +2,11 @@
 
 致{{ email }},
 
-已使用 GROWI {{ appTitle }} 创建帐户。
+已使用 GROWI ({{ appTitle }}) 创建帐户。
 单击下面的链接以激活您的帐户。
 
 {{ url }}
 
+这个链接将在1小时后即{{ expiredAt }}失效。
+
 如果您尚未创建,请忽略此电子邮件。

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

@@ -645,7 +645,7 @@
     "max_age_desc": "指定使用户会话过期的数量(以毫秒为单位)。<br>默认值: 2592000000 (30天)",
     "max_age_caution": "修改该值后需要重启服务器。",
     "forced_update_desc": "设置已被强行更改。以前的设置: ",
-    "page_delete_rights_caution": "\"删除/全部删除\"权限(包括后代页面)被强制强于\"删除/完全删除\"权限。 <br> <br> 任何人 > 管理员|作者 > 仅管理员",
+    "page_delete_rights_caution": "\"删除/全部删除\"权限(包括后代页面)被强制强于\"删除/完全删除\"权限。 <br> <br> 仅管理员 > 管理员|作者 > 何人",
 		"Authentication mechanism settings": "身份验证机制设置",
 		"setup_is_not_yet_complete": "安装尚未完成",
 		"alert_siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",

+ 11 - 0
packages/app/src/client/services/PersonalContainer.js

@@ -30,6 +30,7 @@ export default class PersonalContainer extends Container {
       uploadedPictureSrc: this.getUploadedPictureSrc(this.appContainer.currentUser),
       externalAccounts: [],
       apiToken: '',
+      slackMemberId: '',
     };
 
   }
@@ -55,6 +56,7 @@ export default class PersonalContainer extends Container {
         lang: currentUser.lang,
         isGravatarEnabled: currentUser.isGravatarEnabled,
         apiToken: currentUser.apiToken,
+        slackMemberId: currentUser.slackMemberId,
       });
     }
     catch (err) {
@@ -114,6 +116,13 @@ export default class PersonalContainer extends Container {
     this.setState({ email: inputValue });
   }
 
+  /**
+   * Change Slack Member ID
+   */
+  changeSlackMemberId(inputValue) {
+    this.setState({ slackMemberId: inputValue });
+  }
+
   /**
    * Change isEmailPublished
    */
@@ -147,6 +156,7 @@ export default class PersonalContainer extends Container {
         email: this.state.email,
         isEmailPublished: this.state.isEmailPublished,
         lang: this.state.lang,
+        slackMemberId: this.state.slackMemberId,
       });
       const { updatedUser } = response.data;
 
@@ -155,6 +165,7 @@ export default class PersonalContainer extends Container {
         email: updatedUser.email,
         isEmailPublished: updatedUser.isEmailPublished,
         lang: updatedUser.lang,
+        slackMemberId: updatedUser.slackMemberId,
       });
     }
     catch (err) {

+ 4 - 1
packages/app/src/components/Admin/SlackIntegration/BotTypeCard.jsx

@@ -69,7 +69,10 @@ const BotTypeCard = (props) => {
       <div className="card-body p-4">
         <div className="card-text">
           <div className="my-2">
-            <img className="d-block mx-auto mb-4" src={`/images/slack-integration/slackbot-difficulty-level-${botDetails[props.botType].setUp}.svg`}></img>
+            <img
+              className="bot-difficulty-icon d-block mx-auto mb-4"
+              src={`/images/slack-integration/slackbot-difficulty-level-${botDetails[props.botType].setUp}.svg`}
+            />
             <div className="d-flex justify-content-between mb-3">
               <span>{t('admin:slack_integration.selecting_bot_types.multiple_workspaces_integration')}</span>
               <img className="bot-type-disc" src={`/images/slack-integration/${botDetails[props.botType].multiWSIntegration}.png`} alt="" />

+ 25 - 1
packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -1,6 +1,7 @@
 import React, {
   FC, useState, useCallback, useEffect,
 } from 'react';
+
 import { useTranslation } from 'react-i18next';
 import { TFunctionResult } from 'i18next';
 import dateFnsFormat from 'date-fns/format';
@@ -16,6 +17,7 @@ type Props = {
   childUserGroups: IUserGroupHasId[],
   isAclEnabled: boolean,
   onEdit?: (userGroup: IUserGroupHasId) => void | Promise<void>,
+  onRemove?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onDelete?: (userGroup: IUserGroupHasId) => void | Promise<void>,
 };
 
@@ -73,7 +75,7 @@ const UserGroupTable: FC<Props> = (props: Props) => {
     });
   };
 
-  const onClickEdit = (e) => {
+  const onClickEdit = async(e) => {
     if (props.onEdit == null) {
       return;
     }
@@ -86,6 +88,25 @@ const UserGroupTable: FC<Props> = (props: Props) => {
     props.onEdit(userGroup);
   };
 
+  const onClickRemove = async(e) => {
+    if (props.onRemove == null) {
+      return;
+    }
+
+    const userGroup = findUserGroup(e);
+    if (userGroup == null) {
+      return;
+    }
+
+    try {
+      await props.onRemove(userGroup);
+      userGroup.parent = null;
+    }
+    catch {
+      //
+    }
+  };
+
   const onClickDelete = (e) => { // no preventDefault
     if (props.onDelete == null) {
       return;
@@ -179,6 +200,9 @@ const UserGroupTable: FC<Props> = (props: Props) => {
                           <button className="dropdown-item" type="button" role="button" onClick={onClickEdit} data-user-group-id={group._id}>
                             <i className="icon-fw icon-note"></i> {t('Edit')}
                           </button>
+                          <button className="dropdown-item" type="button" role="button" onClick={onClickRemove} data-user-group-id={group._id}>
+                            <i className="icon-fw fa fa-chain-broken"></i> {t('admin:user_group_management.remove_child_group')}
+                          </button>
                           <button className="dropdown-item" type="button" role="button" onClick={onClickDelete} data-user-group-id={group._id}>
                             <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
                           </button>

+ 21 - 0
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -267,6 +267,26 @@ const UserGroupDetailPage: FC = () => {
     }
   }, [mutateChildUserGroups, setSelectedUserGroup, setDeleteModalShown]);
 
+  const removeChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
+    try {
+      await apiv3Put(`/user-groups/${userGroupData._id}`, {
+        name: userGroupData.name,
+        description: userGroupData.description,
+        parentId: null,
+      });
+
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+
+      // mutate
+      mutateChildUserGroups();
+      mutateSelectableChildUserGroups();
+    }
+    catch (err) {
+      toastError(err);
+      throw err;
+    }
+  }, [t, mutateChildUserGroups, mutateSelectableChildUserGroups]);
+
   /*
    * Dependencies
    */
@@ -337,6 +357,7 @@ const UserGroupDetailPage: FC = () => {
         childUserGroups={grandChildUserGroups}
         isAclEnabled={isAclEnabled ?? false}
         onEdit={showUpdateModal}
+        onRemove={removeChildUserGroup}
         onDelete={showDeleteModal}
         userGroupRelations={childUserGroupRelations}
       />

+ 24 - 10
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -134,32 +134,46 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
         {/* Bookmark */}
         { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
-          <DropdownItem onClick={bookmarkItemClickedHandler}>
-            <i className="fa fa-fw fa-bookmark-o"></i>
+          <DropdownItem
+            onClick={bookmarkItemClickedHandler}
+            className="grw-page-control-dropdown-item"
+          >
+            <i className="fa fa-fw fa-bookmark-o grw-page-control-dropdown-icon"></i>
             { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
           </DropdownItem>
         ) }
 
         {/* Duplicate */}
         { !forceHideMenuItems?.includes(MenuItemType.DUPLICATE) && isEnableActions && (
-          <DropdownItem onClick={duplicateItemClickedHandler} data-testid="open-page-duplicate-modal-btn">
-            <i className="icon-fw icon-docs"></i>
+          <DropdownItem
+            onClick={duplicateItemClickedHandler}
+            data-testid="open-page-duplicate-modal-btn"
+            className="grw-page-control-dropdown-item"
+          >
+            <i className="icon-fw icon-docs grw-page-control-dropdown-icon"></i>
             {t('Duplicate')}
           </DropdownItem>
         ) }
 
         {/* Move/Rename */}
         { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && pageInfo.isMovable && (
-          <DropdownItem onClick={renameItemClickedHandler} data-testid="open-page-move-rename-modal-btn">
-            <i className="icon-fw  icon-action-redo"></i>
+          <DropdownItem
+            onClick={renameItemClickedHandler}
+            data-testid="open-page-move-rename-modal-btn"
+            className="grw-page-control-dropdown-item"
+          >
+            <i className="icon-fw icon-action-redo grw-page-control-dropdown-icon"></i>
             {t(isInstantRename ? 'Rename' : 'Move/Rename')}
           </DropdownItem>
         ) }
 
         {/* Revert */}
         { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && pageInfo.isRevertible && (
-          <DropdownItem onClick={revertItemClickedHandler}>
-            <i className="icon-fw  icon-action-undo"></i>
+          <DropdownItem
+            onClick={revertItemClickedHandler}
+            className="grw-page-control-dropdown-item"
+          >
+            <i className="icon-fw icon-action-undo grw-page-control-dropdown-icon"></i>
             {t('modal_putback.label.Put Back Page')}
           </DropdownItem>
         ) }
@@ -177,12 +191,12 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           <>
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             <DropdownItem
-              className={`pt-2 ${pageInfo.isDeletable ? 'text-danger' : ''}`}
+              className={`pt-2 grw-page-control-dropdown-item ${pageInfo.isDeletable ? 'text-danger' : ''}`}
               disabled={!pageInfo.isDeletable}
               onClick={deleteItemClickedHandler}
               data-testid="open-page-delete-modal-btn"
             >
-              <i className="icon-fw icon-trash"></i>
+              <i className="icon-fw icon-trash grw-page-control-dropdown-icon"></i>
               {t('Delete')}
             </DropdownItem>
           </>

+ 13 - 0
packages/app/src/components/Me/BasicInfoSettings.jsx

@@ -128,6 +128,19 @@ class BasicInfoSettings extends React.Component {
             }
           </div>
         </div>
+        <div className="form-group row">
+          <label htmlFor="userForm[slackMemberId]" className="text-left text-md-right col-md-3 col-form-label">{t('Slack Member ID')}</label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="text"
+              key={personalContainer.state.slackMemberId}
+              name="userForm[slackMemberId]"
+              defaultValue={personalContainer.state.slackMemberId}
+              onChange={(e) => { personalContainer.changeSlackMemberId(e.target.value) }}
+            />
+          </div>
+        </div>
 
         <div className="row my-3">
           <div className="offset-4 col-5">

+ 27 - 8
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -77,14 +77,20 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
       <DropdownItem
         onClick={() => openPresentationModal(hrefForPresentationModal)}
         data-testid="open-presentation-modal-btn"
+        className="grw-page-control-dropdown-item"
       >
-        <i className="icon-fw"><PresentationIcon /></i>
+        <i className="icon-fw grw-page-control-dropdown-icon">
+          <PresentationIcon />
+        </i>
         { t('Presentation Mode') }
       </DropdownItem>
 
       {/* Export markdown */}
-      <DropdownItem onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}>
-        <i className="icon-fw icon-cloud-download"></i>
+      <DropdownItem
+        onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}
+        className="grw-page-control-dropdown-item"
+      >
+        <i className="icon-fw icon-cloud-download grw-page-control-dropdown-icon"></i>
         {t('export_bulk.export_page_markdown')}
       </DropdownItem>
 
@@ -97,31 +103,44 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
       <DropdownItem
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.PageHistory)}
         disabled={isGuestUser || isSharedUser}
+        className="grw-page-control-dropdown-item"
       >
-        <span className="mr-1"><HistoryIcon /></span>
+        <span className="grw-page-control-dropdown-icon">
+          <HistoryIcon />
+        </span>
         {t('History')}
       </DropdownItem>
 
       <DropdownItem
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.Attachment)}
+        className="grw-page-control-dropdown-item"
       >
-        <span className="mr-1"><AttachmentIcon /></span>
+        <span className="grw-page-control-dropdown-icon">
+          <AttachmentIcon />
+        </span>
         {t('attachment_data')}
       </DropdownItem>
 
       <DropdownItem
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
         disabled={isGuestUser || isSharedUser || isLinkSharingDisabled}
+        className="grw-page-control-dropdown-item"
       >
-        <span className="mr-1"><ShareLinkIcon /></span>
+        <span className="grw-page-control-dropdown-icon">
+          <ShareLinkIcon />
+        </span>
         {t('share_links.share_link_management')}
       </DropdownItem>
 
       <DropdownItem divider />
 
       {/* Create template */}
-      <DropdownItem onClick={openPageTemplateModalHandler}>
-        <i className="icon-fw icon-magic-wand"></i> { t('template.option_label.create/edit') }
+      <DropdownItem
+        onClick={openPageTemplateModalHandler}
+        className="grw-page-control-dropdown-item"
+      >
+        <i className="icon-fw icon-magic-wand grw-page-control-dropdown-icon"></i>
+        { t('template.option_label.create/edit') }
       </DropdownItem>
     </>
   );

+ 18 - 17
packages/app/src/components/Navbar/PageEditorModeManager.jsx

@@ -48,16 +48,15 @@ function PageEditorModeManager(props) {
   const isAdmin = appContainer.isAdmin;
   const isHackmdEnabled = appContainer.config.env.HACKMD_URI != null;
   const showHackmdBtn = isHackmdEnabled || isAdmin;
-  const showHackmdDisabledTooltip = isAdmin && !isHackmdEnabled && editorMode !== EditorMode.HackMD;
 
   const pageEditorModeButtonClickedHandler = useCallback((viewType) => {
-    if (isBtnDisabled) {
+    if (isBtnDisabled || !isHackmdEnabled) {
       return;
     }
     if (onPageEditorModeButtonClicked != null) {
       onPageEditorModeButtonClicked(viewType);
     }
-  }, [isBtnDisabled, onPageEditorModeButtonClicked]);
+  }, [isBtnDisabled, isHackmdEnabled, onPageEditorModeButtonClicked]);
 
   return (
     <>
@@ -88,15 +87,22 @@ function PageEditorModeManager(props) {
           />
         )}
         {(!isDeviceSmallerThanMd || editorMode === EditorMode.View) && showHackmdBtn && (
-          <PageEditorModeButtonWrapper
-            editorMode={editorMode}
-            isBtnDisabled={isBtnDisabled}
-            onClick={pageEditorModeButtonClickedHandler}
-            targetMode={EditorMode.HackMD}
-            icon={<i className="fa fa-file-text-o" />}
-            label={t('hackmd.hack_md')}
-            id="grw-page-editor-mode-manager-hackmd-button"
-          />
+          <>
+            <PageEditorModeButtonWrapper
+              editorMode={editorMode}
+              isBtnDisabled={isBtnDisabled || !isHackmdEnabled}
+              onClick={pageEditorModeButtonClickedHandler}
+              targetMode={EditorMode.HackMD}
+              icon={<i className="fa fa-file-text-o" />}
+              label={t('hackmd.hack_md')}
+              id="grw-page-editor-mode-manager-hackmd-button"
+            />
+            { !isHackmdEnabled && (
+              <UncontrolledTooltip placement="top" target="grw-page-editor-mode-manager-hackmd-button" fade={false}>
+                {t('hackmd.not_set_up')}
+              </UncontrolledTooltip>
+            )}
+          </>
         )}
       </div>
       {isBtnDisabled && (
@@ -104,11 +110,6 @@ function PageEditorModeManager(props) {
           {t('Not available for guest')}
         </UncontrolledTooltip>
       )}
-      {!isBtnDisabled && showHackmdDisabledTooltip && (
-        <UncontrolledTooltip placement="top" target="grw-page-editor-mode-manager-hackmd-button" fade={false}>
-          {t('hackmd.not_set_up')}
-        </UncontrolledTooltip>
-      )}
     </>
   );
 

+ 5 - 3
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -70,7 +70,9 @@ const DisplaySwitcher = (): JSX.Element => {
                         className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
                         onClick={() => openDescendantPageListModal(currentPath)}
                       >
-                        <PageListIcon />
+                        <div className="grw-page-accessories-control-icon">
+                          <PageListIcon />
+                        </div>
                         {t('page_list')}
                         <span></span> {/* for a count badge */}
                       </button>
@@ -79,13 +81,13 @@ const DisplaySwitcher = (): JSX.Element => {
 
                   {/* Comments */}
                   { getCommentListDom != null && !isTopPagePath && (
-                    <div className="mt-2">
+                    <div className="grw-page-accessories-control mt-2">
                       <button
                         type="button"
                         className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
                         onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
                       >
-                        <i className="mr-2 icon-fw icon-bubbles"></i>
+                        <i className="icon-fw icon-bubbles grw-page-accessories-control-icon"></i>
                         <span>Comments</span>
                         <span></span> {/* for a count badge */}
                       </button>

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

@@ -1,4 +1,3 @@
-
 import React, {
   useEffect, useState, useMemo, useCallback,
 } from 'react';
@@ -35,7 +34,8 @@ const PageCreateModal = (props) => {
   const isReachable = config.isSearchServiceReachable;
   const pathname = path || '';
   const userPageRootPath = userPageRoot(appContainer.currentUser);
-  const pageNameInputInitialValue = isCreatablePage(pathname) ? pathUtils.addTrailingSlash(pathname) : '/';
+  const isCreatable = isCreatablePage(pathname) || isUsersHomePage(pathname);
+  const pageNameInputInitialValue = isCreatable ? pathUtils.addTrailingSlash(pathname) : '/';
   const now = format(new Date(), 'yyyy/MM/dd');
 
   const [todayInput1, setTodayInput1] = useState(t('Memo'));
@@ -46,8 +46,8 @@ const PageCreateModal = (props) => {
 
   // ensure pageNameInput is synced with selectedPagePath || currentPagePath
   useEffect(() => {
-    setPageNameInput(isCreatablePage(pathname) ? pathUtils.addTrailingSlash(pathname) : '/');
-  }, [pathname]);
+    setPageNameInput(isCreatable ? pathUtils.addTrailingSlash(pathname) : '/');
+  }, [pathname, isCreatable]);
 
   const checkIsUsersHomePageDebounce = useMemo(() => {
     const checkIsUsersHomePage = () => {

+ 15 - 23
packages/app/src/components/PageList/PageListItemL.tsx

@@ -1,6 +1,6 @@
 import React, {
-  forwardRef,
-  ForwardRefRenderFunction, memo, useCallback, useImperativeHandle, useRef,
+  forwardRef, useState,
+  ForwardRefRenderFunction, memo, useCallback, useImperativeHandle, useRef, useEffect,
 } from 'react';
 
 import { useTranslation } from 'react-i18next';
@@ -49,13 +49,15 @@ type Props = {
 
 const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (props: Props, ref): JSX.Element => {
   const {
-    // todo: refactoring variable name to clear what changed
     page: { data: pageData, meta: pageMeta }, isSelected, isEnableActions,
     forceHideMenuItems,
     showPageUpdatedTime,
     onClickItem, onCheckboxChanged, onPageDuplicated, onPageRenamed, onPageDeleted, onPagePutBacked,
   } = props;
 
+  const [likerCount, setLikerCount] = useState(pageData.liker.length);
+  const [bookmarkCount, setBookmarkCount] = useState(pageMeta && pageMeta.bookmarkCount ? pageMeta.bookmarkCount : 0);
+
   const { t } = useTranslation();
 
   const inputRef = useRef<HTMLInputElement>(null);
@@ -97,6 +99,14 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
 
   const lastUpdateDate = format(new Date(pageData.updatedAt), 'yyyy/MM/dd HH:mm:ss');
 
+  useEffect(() => {
+    if (isIPageInfoForEntity(pageInfo) && pageInfo != null) {
+      // likerCount
+      setLikerCount(pageInfo.likerIds?.length ?? 0);
+      // bookmarkCount
+      setBookmarkCount(pageInfo.bookmarkCount ?? 0);
+    }
+  }, [pageInfo]);
 
   // click event handler
   const clickHandler = useCallback(() => {
@@ -147,22 +157,6 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
 
   const shouldDangerouslySetInnerHTMLForPaths = elasticSearchResult != null && elasticSearchResult.highlightedPath != null;
 
-  let likerCount;
-  if (isSelected && isIPageInfoForEntity(pageInfo)) {
-    likerCount = pageInfo.likerIds?.length;
-  }
-  else {
-    likerCount = pageData.liker.length;
-  }
-
-  let bookmarkCount;
-  if (isSelected && isIPageInfoForEntity(pageInfo)) {
-    bookmarkCount = pageInfo.bookmarkCount;
-  }
-  else {
-    bookmarkCount = pageMeta?.bookmarkCount;
-  }
-
   const canRenderESSnippet = elasticSearchResult != null && elasticSearchResult.snippet != null;
   const canRenderRevisionSnippet = revisionShortBody != null;
 
@@ -170,11 +164,9 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     <li
       key={pageData._id}
       className={`list-group-item d-flex align-items-center px-3 px-md-1 ${styleListGroupItem} ${styleActive}`}
+      onClick={clickHandler}
     >
-      <div
-        className="text-break w-100"
-        onClick={clickHandler}
-      >
+      <div className="text-break w-100">
         <div className="d-flex">
           {/* checkbox */}
           {onCheckboxChanged != null && (

+ 5 - 2
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -42,8 +42,11 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
 
   return (
     // Export markdown
-    <DropdownItem onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}>
-      <i className="icon-fw icon-cloud-download"></i>
+    <DropdownItem
+      onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}
+      className="grw-page-control-dropdown-item"
+    >
+      <i className="icon-fw icon-cloud-download grw-page-control-dropdown-icon"></i>
       {t('export_bulk.export_page_markdown')}
     </DropdownItem>
   );

+ 1 - 1
packages/app/src/components/SearchPage/SortControl.tsx

@@ -38,7 +38,7 @@ const SortControl: FC <Props> = (props: Props) => {
         <div className="border rounded-right">
           <button
             type="button"
-            className="btn dropdown-toggle search-sort-option-btn py-1"
+            className="btn dropdown-toggle py-1"
             data-toggle="dropdown"
           >
             <span className="mr-2 text-secondary">{t(`search_result.sort_axis.${sort}`)}</span>

+ 5 - 1
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -101,7 +101,7 @@ type ItemCountProps = {
 const ItemCount: FC<ItemCountProps> = (props:ItemCountProps) => {
   return (
     <>
-      <span className="grw-pagetree-count px-2 badge badge-pill badge-light">
+      <span className="grw-pagetree-count badge badge-pill badge-light">
         {props.descendantCount}
       </span>
     </>
@@ -195,6 +195,10 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
       await mutateChildren();
 
+      if (onRenamed != null) {
+        onRenamed();
+      }
+
       // force open
       setIsOpen(true);
     }

+ 11 - 2
packages/app/src/server/models/password-reset-order.ts

@@ -2,6 +2,7 @@ import mongoose, {
   Schema, Model, Document,
 } from 'mongoose';
 
+import { addMinutes } from 'date-fns';
 import uniqueValidator from 'mongoose-unique-validator';
 import crypto from 'crypto';
 import { getOrCreateModel } from '@growi/core';
@@ -28,13 +29,21 @@ export interface PasswordResetOrderModel extends Model<PasswordResetOrderDocumen
   createPasswordResetOrder(email: string): PasswordResetOrderDocument
 }
 
+const expiredAt = (): Date => {
+  return addMinutes(new Date(), 10);
+};
+
 const schema = new Schema<PasswordResetOrderDocument, PasswordResetOrderModel>({
   token: { type: String, required: true, unique: true },
   email: { type: String, required: true },
   relatedUser: { type: ObjectId, ref: 'User' },
   isRevoked: { type: Boolean, default: false, required: true },
-  createdAt: { type: Date, default: new Date(Date.now()), required: true },
-  expiredAt: { type: Date, default: new Date(Date.now() + 600000), required: true },
+  expiredAt: { type: Date, default: expiredAt, required: true },
+}, {
+  timestamps: {
+    createdAt: true,
+    updatedAt: false,
+  },
 });
 schema.plugin(uniqueValidator);
 

+ 12 - 3
packages/app/src/server/models/user-registration-order.ts

@@ -1,7 +1,8 @@
-import mongoose, {
+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';
@@ -24,12 +25,20 @@ export interface UserRegistrationOrderModel extends Model<UserRegistrationOrderD
   createUserRegistrationOrder(email: string): UserRegistrationOrderDocument
 }
 
+const expiredAt = (): Date => {
+  return addHours(new Date(), 1);
+};
+
 const schema = new Schema<UserRegistrationOrderDocument, UserRegistrationOrderModel>({
   token: { type: String, required: true, unique: true },
   email: { type: String, required: true },
   isRevoked: { type: Boolean, default: false, required: true },
-  createdAt: { type: Date, default: new Date(Date.now()), required: true },
-  expiredAt: { type: Date, default: new Date(Date.now() + 600000), required: true },
+  expiredAt: { type: Date, default: expiredAt, required: true },
+}, {
+  timestamps: {
+    createdAt: true,
+    updatedAt: false,
+  },
 });
 schema.plugin(uniqueValidator);
 

+ 17 - 0
packages/app/src/server/models/user.js

@@ -47,6 +47,7 @@ module.exports = function(crowi) {
     name: { type: String },
     username: { type: String, required: true, unique: true },
     email: { type: String, unique: true, sparse: true },
+    slackMemberId: { type: String, unique: true, sparse: true },
     // === Crowi settings
     // username: { type: String, index: true },
     // email: { type: String, required: true, index: true },
@@ -689,6 +690,22 @@ module.exports = function(crowi) {
     user.save();
   };
 
+  userSchema.statics.findUserBySlackMemberId = async function(slackMemberId) {
+    const user = this.findOne({ slackMemberId });
+    if (user == null) {
+      throw new Error('User not found');
+    }
+    return user;
+  };
+
+  userSchema.statics.findUsersBySlackMemberIds = async function(slackMemberIds) {
+    const users = this.find({ slackMemberId: { $in: slackMemberIds } });
+    if (users.length === 0) {
+      throw new Error('No user found');
+    }
+    return users;
+  };
+
   class UserUpperLimitException {
 
     constructor() {

+ 7 - 5
packages/app/src/server/routes/apiv3/forgot-password.js

@@ -1,3 +1,4 @@
+import { format } from 'date-fns';
 import rateLimit from 'express-rate-limit';
 
 import PasswordResetOrder from '~/server/models/password-reset-order';
@@ -45,23 +46,23 @@ module.exports = (crowi) => {
 
   const checkPassportStrategyMiddleware = checkForgotPasswordEnabledMiddlewareFactory(crowi, true);
 
-  async function sendPasswordResetEmail(txtFileName, i18n, email, url) {
+  async function sendPasswordResetEmail(txtFileName, i18n, email, url, expiredAt) {
     return mailService.send({
       to: email,
-      subject: txtFileName,
+      subject: '[GROWI] Password Reset',
       template: path.join(crowi.localeDir, `${i18n}/notifications/${txtFileName}.txt`),
       vars: {
         appTitle: appService.getAppTitle(),
         email,
         url,
+        expiredAt,
       },
     });
   }
 
   router.post('/', checkPassportStrategyMiddleware, async(req, res) => {
     const { email } = req.body;
-    const grobalLang = configManager.getConfig('crowi', 'app:globalLang');
-    const i18n = req.language || grobalLang;
+    const i18n = configManager.getConfig('crowi', 'app:globalLang');
     const appUrl = appService.getSiteUrl();
 
     try {
@@ -76,7 +77,8 @@ module.exports = (crowi) => {
       const passwordResetOrderData = await PasswordResetOrder.createPasswordResetOrder(email);
       const url = new URL(`/forgot-password/${passwordResetOrderData.token}`, appUrl);
       const oneTimeUrl = url.href;
-      await sendPasswordResetEmail('passwordReset', i18n, email, oneTimeUrl);
+      const expiredAt = format(passwordResetOrderData.expiredAt, 'yyyy/MM/dd HH:mm');
+      await sendPasswordResetEmail('passwordReset', i18n, email, oneTimeUrl, expiredAt);
       return res.apiv3();
     }
     catch (err) {

+ 2 - 0
packages/app/src/server/routes/apiv3/personal-setting.js

@@ -84,6 +84,7 @@ module.exports = (crowi) => {
         }),
       body('lang').isString().isIn(listLocaleIds()),
       body('isEmailPublished').isBoolean(),
+      body('slackMemberId').optional().isString(),
     ],
     imageType: [
       body('isGravatarEnabled').isBoolean(),
@@ -226,6 +227,7 @@ module.exports = (crowi) => {
       user.email = req.body.email;
       user.lang = req.body.lang;
       user.isEmailPublished = req.body.isEmailPublished;
+      user.slackMemberId = req.body.slackMemberId;
 
       const updatedUser = await user.save();
       req.i18n.changeLanguage(req.body.lang);

+ 1 - 1
packages/app/src/server/routes/apiv3/user-group.js

@@ -49,7 +49,7 @@ module.exports = (crowi) => {
     update: [
       body('name', 'Group name must be a string').optional().trim().isString(),
       body('description', 'Group description must be a string').optional().isString(),
-      body('parentId', 'parentId must be a string').optional().isString(),
+      body('parentId', 'ParentId must be a string or null').optional({ nullable: true }).isString(),
       body('forceUpdateParents', 'forceUpdateParents must be a boolean').optional().isBoolean(),
     ],
     delete: [

+ 5 - 1
packages/app/src/server/routes/user-activation.ts

@@ -1,5 +1,7 @@
 import path from 'path';
+import { format } from 'date-fns';
 import { body, validationResult } from 'express-validator';
+
 import UserRegistrationOrder from '../models/user-registration-order';
 
 export const form = (req, res): void => {
@@ -20,17 +22,19 @@ async function makeRegistrationEmailToken(email, crowi) {
   const appUrl = appService.getSiteUrl();
 
   const userRegistrationOrder = await UserRegistrationOrder.createUserRegistrationOrder(email);
+  const expiredAt = format(userRegistrationOrder.expiredAt, 'yyyy/MM/dd HH:mm');
   const url = new URL(`/user-activation/${userRegistrationOrder.token}`, appUrl);
   const oneTimeUrl = url.href;
   const txtFileName = 'userActivation';
 
   return mailService.send({
     to: email,
-    subject: txtFileName,
+    subject: '[GROWI] User Activation',
     template: path.join(localeDir, `${i18n}/notifications/${txtFileName}.txt`),
     vars: {
       appTitle: appService.getAppTitle(),
       email,
+      expiredAt,
       url: oneTimeUrl,
     },
   });

+ 2 - 2
packages/app/src/server/service/slack-command-handler/create-page-service.js

@@ -11,7 +11,7 @@ class CreatePageService {
     this.crowi = crowi;
   }
 
-  async createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil) {
+  async createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil, userId) {
     const Page = this.crowi.model('Page');
     const reshapedContentsBody = reshapeContentsBody(contentsBody);
 
@@ -20,7 +20,7 @@ class CreatePageService {
     const normalizedPath = pathUtils.normalizePath(sanitizedPath);
 
     // generate a dummy id because Operation to create a page needs ObjectId
-    const dummyObjectIdOfUser = new mongoose.Types.ObjectId();
+    const dummyObjectIdOfUser = userId != null ? userId : new mongoose.Types.ObjectId();
     const page = await Page.create(normalizedPath, reshapedContentsBody, dummyObjectIdOfUser, {});
 
     // Send a message when page creation is complete

+ 37 - 4
packages/app/src/server/service/slack-command-handler/keep.js

@@ -12,6 +12,7 @@ module.exports = (crowi) => {
   const createPageService = new CreatePageService(crowi);
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
+  const { User } = crowi.models;
 
   handler.handleCommand = async function(growiCommand, client, body, respondUtil) {
     await respondUtil.respond({
@@ -32,8 +33,9 @@ module.exports = (crowi) => {
   handler.createPage = async function(client, payload, interactionPayloadAccessor, respondUtil) {
     let result = [];
     const channelId = payload.channel.id; // this must exist since the type is always block_actions
-    const userChannelId = payload.user.id;
+    const user = await User.findUserBySlackMemberId(payload.user.id);
 
+    const userId = user != null ? user._id : null;
     // validate form
     const { path, oldest, newest } = await this.keepValidateForm(client, payload, interactionPayloadAccessor);
     // get messages
@@ -43,7 +45,7 @@ module.exports = (crowi) => {
 
     const contentsBody = cleanedContents.join('');
     // create and send url message
-    await this.keepCreatePageAndSendPreview(client, interactionPayloadAccessor, path, userChannelId, contentsBody, respondUtil);
+    await this.keepCreatePageAndSendPreview(client, interactionPayloadAccessor, path, userId, contentsBody, respondUtil);
   };
 
   handler.keepValidateForm = async function(client, payload, interactionPayloadAccessor) {
@@ -137,10 +139,41 @@ module.exports = (crowi) => {
     return result;
   };
 
+  /**
+   * Get all growi users from messages
+   * @param {*} messages (array of messages)
+   * @returns users object with matching Slack Member ID
+   */
+  handler.getGrowiUsersFromMessages = async function(messages) {
+    const users = messages.map((message) => {
+      return message.user;
+    });
+    const growiUsers = await User.findUsersBySlackMemberIds(users);
+    return growiUsers;
+  };
+  /**
+   * Convert slack member ID to growi user if slack member ID is found in messages
+   * @param {*} messages
+   */
+  handler.injectGrowiUsernameToMessages = async function(messages) {
+    const growiUsers = await this.getGrowiUsersFromMessages(messages);
+
+    messages.map(async(message) => {
+      const growiUser = growiUsers.find(user => user.slackMemberId === message.user);
+      if (growiUser != null) {
+        message.user = `${growiUser.name} (@${growiUser.username})`;
+      }
+      else {
+        message.user = `This slack member ID is not registered (${message.user})`;
+      }
+    });
+  };
+
   handler.keepCleanMessages = async function(messages) {
     const cleanedContents = [];
     let lastMessage = {};
     const grwTzoffset = crowi.appService.getTzoffset() * 60;
+    await this.injectGrowiUsernameToMessages(messages);
     messages
       .sort((a, b) => {
         return a.ts - b.ts;
@@ -164,8 +197,8 @@ module.exports = (crowi) => {
     return cleanedContents;
   };
 
-  handler.keepCreatePageAndSendPreview = async function(client, interactionPayloadAccessor, path, userChannelId, contentsBody, respondUtil) {
-    await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil);
+  handler.keepCreatePageAndSendPreview = async function(client, interactionPayloadAccessor, path, userId, contentsBody, respondUtil) {
+    await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil, userId);
 
     // TODO: contentsBody text characters must be less than 3001
     // send preview to dm

+ 5 - 0
packages/app/src/styles/_page-accessories-control.scss

@@ -8,4 +8,9 @@
       height: 16px;
     }
   }
+  .grw-page-accessories-control-icon {
+    display: flex;
+    justify-content: center;
+    width: 20px;
+  }
 }

+ 2 - 2
packages/app/src/styles/_page-tree.scss

@@ -50,8 +50,8 @@ $grw-pagetree-item-padding-left: 10px;
       }
 
       .grw-pagetree-count {
-        width: auto;
-        padding: 0.1rem 0;
+        min-width: 28px;
+        padding: 0.1rem 0.5rem;
         font-size: 12px;
       }
     }

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

@@ -142,10 +142,6 @@
     padding-bottom: unset;
   }
 
-  // To fix the sort options position
-  .search-sort-option-btn {
-    min-width: 150px;
-  }
   .search-control-include-options {
     .card-body {
       padding: 5px 10px;

+ 12 - 0
packages/app/src/styles/style-app.scss

@@ -150,3 +150,15 @@
     content: 'Ctrl';
   }
 }
+
+.grw-page-control-dropdown-item {
+  display: flex !important;
+  align-items: center;
+
+  .grw-page-control-dropdown-icon {
+    display: flex;
+    justify-content: center;
+    width: 25px;
+  }
+
+}

+ 8 - 3
packages/app/test/cypress/integration/2-basic-features/access-to-page.spec.ts

@@ -18,9 +18,14 @@ context('Access to page', () => {
 
   it('/Sandbox with anchor hash is successfully loaded', () => {
     cy.visit('/Sandbox#Headers');
-    cy.screenshot(`${ssPrefix}-sandbox-headers`, {
-      disableTimersAndAnimations: false,
-    });
+
+    // wait until opacity is 1.
+    cy.getByTestid('grw-fab-create-page')
+      .should('be.visible')
+      .should('have.class', 'fadeInUp')
+      .should('have.css', 'opacity', '1');
+
+    cy.screenshot(`${ssPrefix}-sandbox-headers`);
   });
 
   it('/Sandbox/Math is successfully loaded', () => {

+ 0 - 14
packages/app/test/cypress/integration/2-basic-features/use-tools.spec.ts

@@ -11,7 +11,6 @@ context('Switch Sidebar content', () => {
   it('PageTree is successfully shown', () => {
     cy.visit('/page');
     cy.getByTestid('grw-sidebar-nav-primary-page-tree').click();
-    cy.screenshot(`${ssPrefix}-pagetree-before-load`, { capture: 'viewport' });
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(1500);
     cy.screenshot(`${ssPrefix}-pagetree-after-load`, { capture: 'viewport' });
@@ -105,17 +104,4 @@ context('Open presentation modal', () => {
     cy.screenshot(`${ssPrefix}-open-top`);
   });
 
-  it('PresentationModal for "/Sandbox/Bootstrap4" is shown successfully', () => {
-    cy.visit('/Sandbox/Bootstrap4');
-
-    cy.get('#grw-subnav-container').within(() => {
-      cy.getByTestid('open-page-item-control-btn').click({force: true});
-      cy.getByTestid('open-presentation-modal-btn').click({force: true});
-    });
-
-    // eslint-disable-next-line cypress/no-unnecessary-waiting
-    cy.wait(1500);
-    cy.screenshot(`${ssPrefix}-open-bootstrap4`);
-  });
-
 });

+ 5 - 0
packages/app/test/cypress/integration/4-admin/access-to-admin-page.spec.ts

@@ -77,6 +77,11 @@ context('Access to Admin page', () => {
   it('/admin/slack-integration is successfully loaded', () => {
     cy.visit('/admin/slack-integration');
     cy.getByTestid('admin-slack-integration').should('be.visible');
+
+    cy.get('img.bot-difficulty-icon')
+      .should('have.length', 3)
+      .should('be.visible');
+
     cy.screenshot(`${ssPrefix}-admin-slack-integration`);
   });
 

+ 2 - 2
packages/codemirror-textlint/src/index.ts

@@ -1,6 +1,7 @@
+import textlintRuleNoUnmatchedPair from '@textlint-rule/textlint-rule-no-unmatched-pair';
 import { TextlintKernel, TextlintKernelRule, TextlintRuleOptions } from '@textlint/kernel';
+import { AsyncLinter, Annotation } from 'codemirror/addon/lint/lint';
 import textlintToCodeMirror from 'textlint-message-to-codemirror';
-import textlintRuleNoUnmatchedPair from '@textlint-rule/textlint-rule-no-unmatched-pair';
 import textlintRuleCommonMisspellings from 'textlint-rule-common-misspellings';
 import textlintRuleDateWeekdayMismatch from 'textlint-rule-date-weekday-mismatch';
 // import textlintRuleEnCapitalization from 'textlint-rule-en-capitalization';  // omit because en-pos package is too big
@@ -28,7 +29,6 @@ import textlintRulePreferTariTari from 'textlint-rule-prefer-tari-tari';
 import textlintRuleSentenceLength from 'textlint-rule-sentence-length';
 import textlintRuleUseSiUnits from 'textlint-rule-use-si-units';
 
-import { AsyncLinter, Annotation } from 'codemirror/addon/lint/lint';
 import { loggerFactory } from './utils/logger';
 
 type RulesConfigObj = {

+ 2 - 2
packages/core/src/index.js

@@ -1,9 +1,9 @@
-import * as _pathUtils from './utils/path-utils';
+import * as _customTagUtils from './plugin/util/custom-tag-utils';
 import * as _envUtils from './utils/env-utils';
 import * as _pagePathUtils from './utils/page-path-utils';
 import * as _pageUtils from './utils/page-utils';
+import * as _pathUtils from './utils/path-utils';
 import * as _templateChecker from './utils/template-checker';
-import * as _customTagUtils from './plugin/util/custom-tag-utils';
 
 // export utils
 export const pathUtils = _pathUtils;

+ 1 - 1
packages/core/src/test/plugin/service/tag-cache-manager.test.js

@@ -3,8 +3,8 @@
 // import each from 'jest-each';
 jest.mock('~/service/localstorage-manager');
 
-import LocalStorageManager from '~/service/localstorage-manager';
 import TagCacheManager from '~/plugin/service/tag-cache-manager';
+import LocalStorageManager from '~/service/localstorage-manager';
 /* eslint-enable import/first */
 
 describe('TagCacheManager.constructor', () => {

+ 1 - 0
packages/core/src/utils/page-path-utils.ts

@@ -1,6 +1,7 @@
 import nodePath from 'path';
 
 import escapeStringRegexp from 'escape-string-regexp';
+
 import { addTrailingSlash } from './path-utils';
 
 /**

+ 1 - 1
packages/plugin-attachment-refs/src/client-entry.js

@@ -1,5 +1,5 @@
-import RefsPreRenderInterceptor from './client/js/util/Interceptor/RefsPreRenderInterceptor';
 import RefsPostRenderInterceptor from './client/js/util/Interceptor/RefsPostRenderInterceptor';
+import RefsPreRenderInterceptor from './client/js/util/Interceptor/RefsPreRenderInterceptor';
 
 export default (appContainer) => {
   // add interceptors

+ 4 - 5
packages/plugin-attachment-refs/src/client/js/components/AttachmentList.jsx

@@ -1,16 +1,15 @@
-import React from 'react';
+import { Attachment } from '@growi/ui';
+import axios from 'axios'; // import axios from growi dependencies
 import PropTypes from 'prop-types';
+import React from 'react';
 
 // eslint-disable-next-line import/no-unresolved
-import axios from 'axios'; // import axios from growi dependencies
-
-import { Attachment } from '@growi/ui';
 
+import styles from '../../css/index.css';
 import RefsContext from '../util/RefsContext';
 import TagCacheManagerFactory from '../util/TagCacheManagerFactory';
 
 // eslint-disable-next-line no-unused-vars
-import styles from '../../css/index.css';
 
 import ExtractedAttachments from './ExtractedAttachments';
 

+ 1 - 2
packages/plugin-attachment-refs/src/client/js/components/ExtractedAttachments.jsx

@@ -1,6 +1,5 @@
-import React from 'react';
 import PropTypes from 'prop-types';
-
+import React from 'react';
 import Carousel, { Modal, ModalGateway } from 'react-images';
 
 import RefsContext from '../util/RefsContext';

+ 3 - 3
packages/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPostRenderInterceptor.js

@@ -1,12 +1,12 @@
+import { BasicInterceptor } from '@growi/core';
 import React from 'react';
 import ReactDOM from 'react-dom';
 
-import { BasicInterceptor } from '@growi/core';
 
-import RefsContext from '../RefsContext';
+import AttachmentList from '../../components/AttachmentList';
 import GalleryContext from '../GalleryContext';
+import RefsContext from '../RefsContext';
 
-import AttachmentList from '../../components/AttachmentList';
 
 /**
  * The interceptor for refs

+ 1 - 1
packages/plugin-lsx/src/client-entry.js

@@ -1,6 +1,6 @@
 import { LsxLogoutInterceptor } from './client/js/util/Interceptor/LsxLogoutInterceptor';
-import { LsxPreRenderInterceptor } from './client/js/util/Interceptor/LsxPreRenderInterceptor';
 import { LsxPostRenderInterceptor } from './client/js/util/Interceptor/LsxPostRenderInterceptor';
+import { LsxPreRenderInterceptor } from './client/js/util/Interceptor/LsxPreRenderInterceptor';
 
 export default (appContainer) => {
   // add interceptors

+ 4 - 4
packages/plugin-lsx/src/client/js/components/Lsx.jsx

@@ -1,17 +1,17 @@
-import React from 'react';
-import PropTypes from 'prop-types';
 
 import * as url from 'url';
 
 import { pathUtils } from '@growi/core';
+import PropTypes from 'prop-types';
+import React from 'react';
 
 // eslint-disable-next-line no-unused-vars
 import styles from '../../css/index.css';
-
 import { LsxContext } from '../util/LsxContext';
 import { TagCacheManagerFactory } from '../util/TagCacheManagerFactory';
-import { PageNode } from './PageNode';
+
 import { LsxListView } from './LsxPageList/LsxListView';
+import { PageNode } from './PageNode';
 
 export class Lsx extends React.Component {
 

+ 3 - 2
packages/plugin-lsx/src/client/js/components/LsxPageList/LsxListView.jsx

@@ -1,8 +1,9 @@
-import React from 'react';
 import PropTypes from 'prop-types';
+import React from 'react';
 
-import { PageNode } from '../PageNode';
 import { LsxContext } from '../../util/LsxContext';
+import { PageNode } from '../PageNode';
+
 import { LsxPage } from './LsxPage';
 
 export class LsxListView extends React.Component {

+ 2 - 3
packages/plugin-lsx/src/client/js/components/LsxPageList/LsxPage.jsx

@@ -1,9 +1,8 @@
-import React from 'react';
-import PropTypes from 'prop-types';
 
 import { pathUtils } from '@growi/core';
-
 import { PageListMeta } from '@growi/ui';
+import PropTypes from 'prop-types';
+import React from 'react';
 
 import { LsxContext } from '../../util/LsxContext';
 import { PageNode } from '../PageNode';

+ 2 - 2
packages/plugin-lsx/src/client/js/components/LsxPageList/PagePathWrapper.jsx

@@ -1,7 +1,7 @@
-import React from 'react';
+import { PagePathLabel } from '@growi/ui';
 import PropTypes from 'prop-types';
+import React from 'react';
 
-import { PagePathLabel } from '@growi/ui';
 
 export class PagePathWrapper extends React.Component {
 

+ 2 - 2
packages/plugin-lsx/src/client/js/util/Interceptor/LsxPostRenderInterceptor.js

@@ -1,10 +1,10 @@
+import { BasicInterceptor } from '@growi/core';
 import React from 'react';
 import ReactDOM from 'react-dom';
 
-import { BasicInterceptor } from '@growi/core';
 
-import { LsxContext } from '../LsxContext';
 import { Lsx } from '../../components/Lsx';
+import { LsxContext } from '../LsxContext';
 
 /**
  * The interceptor for lsx

+ 1 - 1
packages/plugin-lsx/src/client/js/util/Interceptor/LsxPreRenderInterceptor.js

@@ -1,5 +1,5 @@
-import ReactDOM from 'react-dom';
 import { customTagUtils, BasicInterceptor } from '@growi/core';
+import ReactDOM from 'react-dom';
 
 /**
  * The interceptor for lsx

+ 1 - 0
packages/slack/src/interfaces/growi-interaction-processor.ts

@@ -1,4 +1,5 @@
 import { AuthorizeResult } from '@slack/oauth';
+
 import { InteractionPayloadAccessor } from '../utils/interaction-payload-accessor';
 
 

+ 2 - 1
packages/slack/src/middlewares/parse-slack-interaction-request.ts

@@ -1,7 +1,8 @@
 import { Response, NextFunction } from 'express';
-import { InteractionPayloadAccessor } from '../utils/interaction-payload-accessor';
 
 import { RequestFromSlack } from '../interfaces/request-from-slack';
+import { InteractionPayloadAccessor } from '../utils/interaction-payload-accessor';
+
 
 export const parseSlackInteractionRequest = (req: RequestFromSlack, res: Response, next: NextFunction): Record<string, any> | void => {
   // There is no payload in the request from slack

+ 2 - 2
packages/slack/src/middlewares/verify-growi-to-slack-request.ts

@@ -1,8 +1,8 @@
 import { Response, NextFunction } from 'express';
-
 import createError from 'http-errors';
-import loggerFactory from '../utils/logger';
+
 import { RequestFromGrowi } from '../interfaces/request-between-growi-and-proxy';
+import loggerFactory from '../utils/logger';
 
 const logger = loggerFactory('@growi/slack:middlewares:verify-growi-to-slack-request');
 

+ 4 - 3
packages/slack/src/middlewares/verify-slack-request.ts

@@ -1,10 +1,11 @@
 import { createHmac, timingSafeEqual } from 'crypto';
-import { stringify } from 'qs';
-import { Response, NextFunction } from 'express';
 
+import { Response, NextFunction } from 'express';
 import createError from 'http-errors';
-import loggerFactory from '../utils/logger';
+import { stringify } from 'qs';
+
 import { RequestFromSlack } from '../interfaces/request-from-slack';
+import loggerFactory from '../utils/logger';
 
 const logger = loggerFactory('@growi/slack:middlewares:verify-slack-request');
 

+ 4 - 3
packages/slack/src/utils/check-communicable.ts

@@ -1,11 +1,12 @@
-import axios, { AxiosError } from 'axios';
 
 import { WebClient } from '@slack/web-api';
+import axios, { AxiosError } from 'axios';
 
-import { generateWebClient } from './webclient-factory';
 import { ConnectionStatus } from '../interfaces/connection-status';
-import { requiredScopes } from './required-scopes';
+
 import { markdownSectionBlock } from './block-kit-builder';
+import { requiredScopes } from './required-scopes';
+import { generateWebClient } from './webclient-factory';
 
 /**
  * Check whether the HTTP server responds or not.

+ 3 - 1
packages/slack/src/utils/interaction-payload-accessor.ts

@@ -1,6 +1,8 @@
 import assert from 'assert';
-import { IInteractionPayloadAccessor } from '../interfaces/request-from-slack';
+
 import { IChannel } from '../interfaces/channel';
+import { IInteractionPayloadAccessor } from '../interfaces/request-from-slack';
+
 import loggerFactory from './logger';
 
 const logger = loggerFactory('@growi/slack:utils:interaction-payload-accessor');

+ 2 - 1
packages/slack/src/utils/post-ephemeral-errors.ts

@@ -1,7 +1,8 @@
 import { WebAPICallResult } from '@slack/web-api';
-import { respond } from './response-url';
 
 import { markdownSectionBlock } from './block-kit-builder';
+import { respond } from './response-url';
+
 
 export const respondRejectedErrors = async(
     rejectedResults: PromiseRejectedResult[],

+ 2 - 1
packages/slack/src/utils/respond-util-factory.ts

@@ -1,7 +1,8 @@
 import axios from 'axios';
 import urljoin from 'url-join';
-import { RespondBodyForResponseUrl } from '../interfaces/response-url';
+
 import { IRespondUtil } from '../interfaces/respond-util';
+import { RespondBodyForResponseUrl } from '../interfaces/response-url';
 
 type AxiosOptions = {
   headers?: {

+ 8 - 7
packages/slackbot-proxy/src/Server.ts

@@ -1,26 +1,27 @@
-import { Configuration, Inject, InjectorService } from '@tsed/di';
-import { HttpServer, PlatformApplication } from '@tsed/common';
 import '@tsed/platform-express'; // !! DO NOT MODIFY !!
 import '@tsed/typeorm'; // !! DO NOT MODIFY !! -- https://github.com/tsedio/tsed/issues/1332#issuecomment-837840612
 import '@tsed/swagger';
 
+import { createTerminus } from '@godaddy/terminus';
+import { HttpServer, PlatformApplication } from '@tsed/common';
+import { Configuration, Inject, InjectorService } from '@tsed/di';
 import bodyParser from 'body-parser';
 import compress from 'compression';
 import cookieParser from 'cookie-parser';
-import methodOverride from 'method-override';
-import helmet from 'helmet';
 import { Express } from 'express';
 import expressBunyanLogger from 'express-bunyan-logger';
-
+import helmet from 'helmet';
+import methodOverride from 'method-override';
 import { ConnectionOptions, getConnectionManager } from 'typeorm';
-import { createTerminus } from '@godaddy/terminus';
 
 import swaggerSettingsForDev from '~/config/swagger/config.dev';
 import swaggerSettingsForProd from '~/config/swagger/config.prod';
+import loggerFactory from '~/utils/logger';
+
 import { GlobalHttpErrorHandlingMiddleware } from './middlewares/GlobalHttpErrorHandlingMiddleware';
+
 import './filters/CustomHttpErrorFilter';
 import './filters/ResourceNotFoundFilter';
-import loggerFactory from '~/utils/logger';
 
 export const rootDir = __dirname;
 const isProduction = process.env.NODE_ENV === 'production';

+ 9 - 12
packages/slackbot-proxy/src/controllers/growi-to-slack.ts

@@ -1,28 +1,25 @@
+import {
+  verifyGrowiToSlackRequest, getConnectionStatuses, getConnectionStatus, REQUEST_TIMEOUT_FOR_PTOG, generateWebClient, BlockKitRequest,
+} from '@growi/slack';
+import { ErrorCode, WebAPICallResult } from '@slack/web-api';
 import {
   Controller, Get, Post, Inject, Req, Res, UseBefore, PathParams, Put, QueryParams,
 } from '@tsed/common';
 import axios from 'axios';
-import createError from 'http-errors';
 import { addHours } from 'date-fns';
+import createError from 'http-errors';
 
-import { ErrorCode, WebAPICallResult } from '@slack/web-api';
-
-import {
-  verifyGrowiToSlackRequest, getConnectionStatuses, getConnectionStatus, REQUEST_TIMEOUT_FOR_PTOG, generateWebClient, BlockKitRequest,
-} from '@growi/slack';
-
-import { WebclientRes, AddWebclientResponseToRes } from '~/middlewares/growi-to-slack/add-webclient-response-to-res';
 
 import { GrowiReq } from '~/interfaces/growi-to-slack/growi-req';
+import { WebclientRes, AddWebclientResponseToRes } from '~/middlewares/growi-to-slack/add-webclient-response-to-res';
 import { InstallationRepository } from '~/repositories/installation';
-import { RelationRepository } from '~/repositories/relation';
 import { OrderRepository } from '~/repositories/order';
-
+import { RelationRepository } from '~/repositories/relation';
 import { InstallerService } from '~/services/InstallerService';
-import loggerFactory from '~/utils/logger';
-import { ViewInteractionPayloadDelegator } from '~/services/growi-uri-injector/ViewInteractionPayloadDelegator';
 import { ActionsBlockPayloadDelegator } from '~/services/growi-uri-injector/ActionsBlockPayloadDelegator';
 import { SectionBlockPayloadDelegator } from '~/services/growi-uri-injector/SectionBlockPayloadDelegator';
+import { ViewInteractionPayloadDelegator } from '~/services/growi-uri-injector/ViewInteractionPayloadDelegator';
+import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('slackbot-proxy:controllers:growi-to-slack');

+ 11 - 13
packages/slackbot-proxy/src/controllers/slack.ts

@@ -1,11 +1,3 @@
-import {
-  Controller, Get, Inject, PlatformResponse, Post, Req, Res, UseBefore,
-} from '@tsed/common';
-
-import axios from 'axios';
-
-import { WebAPICallResult } from '@slack/web-api';
-import { Installation } from '@slack/oauth';
 
 
 import {
@@ -14,23 +6,29 @@ import {
   parseSlackInteractionRequest, verifySlackRequest,
   respond, supportedGrowiCommands, IChannelOptionalId,
 } from '@growi/slack';
+import { Installation } from '@slack/oauth';
+import { WebAPICallResult } from '@slack/web-api';
+import {
+  Controller, Get, Inject, PlatformResponse, Post, Req, Res, UseBefore,
+} from '@tsed/common';
+import axios from 'axios';
 
 import { Relation } from '~/entities/relation';
 import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
-import { InstallationRepository } from '~/repositories/installation';
-import { RelationRepository } from '~/repositories/relation';
-import { OrderRepository } from '~/repositories/order';
 import { AddSigningSecretToReq } from '~/middlewares/slack-to-growi/add-signing-secret-to-req';
 import {
   AuthorizeCommandMiddleware, AuthorizeInteractionMiddleware, AuthorizeEventsMiddleware,
 } from '~/middlewares/slack-to-growi/authorizer';
-import { UrlVerificationMiddleware } from '~/middlewares/slack-to-growi/url-verification';
 import { ExtractGrowiUriFromReq } from '~/middlewares/slack-to-growi/extract-growi-uri-from-req';
+import { UrlVerificationMiddleware } from '~/middlewares/slack-to-growi/url-verification';
+import { InstallationRepository } from '~/repositories/installation';
+import { OrderRepository } from '~/repositories/order';
+import { RelationRepository } from '~/repositories/relation';
 import { InstallerService } from '~/services/InstallerService';
-import { SelectGrowiService } from '~/services/SelectGrowiService';
 import { LinkSharedService } from '~/services/LinkSharedService';
 import { RegisterService } from '~/services/RegisterService';
 import { RelationsService } from '~/services/RelationsService';
+import { SelectGrowiService } from '~/services/SelectGrowiService';
 import { UnregisterService } from '~/services/UnregisterService';
 import loggerFactory from '~/utils/logger';
 import { postInstallSuccessMessage, postWelcomeMessageOnce } from '~/utils/welcome-message';

+ 1 - 2
packages/slackbot-proxy/src/controllers/top.ts

@@ -1,10 +1,9 @@
+import { requiredScopes } from '@growi/slack';
 import {
   Controller, Get, Inject, View,
 } from '@tsed/common';
-
 import readPkgUp from 'read-pkg-up';
 
-import { requiredScopes } from '@growi/slack';
 import { InstallerService } from '~/services/InstallerService';
 
 const isOfficialMode = process.env.OFFICIAL_MODE === 'true';

+ 1 - 1
packages/slackbot-proxy/src/entities/installation.ts

@@ -1,3 +1,4 @@
+import { Installation as SlackInstallation } from '@slack/oauth';
 import {
   Required,
 } from '@tsed/schema';
@@ -5,7 +6,6 @@ import {
   Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn,
 } from 'typeorm';
 
-import { Installation as SlackInstallation } from '@slack/oauth';
 
 @Entity()
 export class Installation {

+ 1 - 0
packages/slackbot-proxy/src/entities/order.ts

@@ -1,6 +1,7 @@
 import {
   Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn, ManyToOne,
 } from 'typeorm';
+
 import { Installation } from './installation';
 
 @Entity()

+ 1 - 0
packages/slackbot-proxy/src/entities/relation.ts

@@ -2,6 +2,7 @@ import { differenceInMilliseconds } from 'date-fns';
 import {
   Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn, ManyToOne, Index,
 } from 'typeorm';
+
 import { Installation } from './installation';
 
 export interface PermissionSettingsInterface {

+ 1 - 1
packages/slackbot-proxy/src/interfaces/growi-to-slack/growi-req.ts

@@ -1,4 +1,4 @@
-import { Req } from '@tsed/common';
 import { RequestFromGrowi } from '@growi/slack';
+import { Req } from '@tsed/common';
 
 export type GrowiReq = Req & RequestFromGrowi;

+ 0 - 1
packages/slackbot-proxy/src/middlewares/GlobalHttpErrorHandlingMiddleware.ts

@@ -1,7 +1,6 @@
 import {
   Err, Middleware, Next, PlatformContext, PlatformResponse,
 } from '@tsed/common';
-
 import { HttpError, isHttpError } from 'http-errors';
 
 @Middleware()

+ 0 - 2
packages/slackbot-proxy/src/middlewares/slack-to-growi/authorizer.ts

@@ -2,9 +2,7 @@ import { AuthorizeResult, InstallationQuery } from '@slack/oauth';
 import {
   IMiddleware, Inject, Middleware, Next, Req, Res,
 } from '@tsed/common';
-
 import Logger from 'bunyan';
-
 import createError from 'http-errors';
 
 import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';

+ 1 - 1
packages/slackbot-proxy/src/middlewares/slack-to-growi/extract-growi-uri-from-req.ts

@@ -3,8 +3,8 @@ import {
 } from '@tsed/common';
 
 import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
-import { ViewInteractionPayloadDelegator } from '~/services/growi-uri-injector/ViewInteractionPayloadDelegator';
 import { ActionsBlockPayloadDelegator } from '~/services/growi-uri-injector/ActionsBlockPayloadDelegator';
+import { ViewInteractionPayloadDelegator } from '~/services/growi-uri-injector/ViewInteractionPayloadDelegator';
 
 
 @Middleware()

+ 1 - 2
packages/slackbot-proxy/src/middlewares/slack-to-growi/join-to-conversation.ts

@@ -2,10 +2,9 @@ import { generateWebClient } from '@growi/slack';
 import {
   IMiddleware, Middleware, Req,
 } from '@tsed/common';
-
 import Logger from 'bunyan';
-import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
 
+import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
 import loggerFactory from '~/utils/logger';
 
 const logger: Logger = loggerFactory('slackbot-proxy:middlewares:JoinToConversationsMiddleware');

+ 1 - 2
packages/slackbot-proxy/src/middlewares/slack-to-growi/parse-interaction-req.ts

@@ -1,9 +1,8 @@
+import { RequestFromSlack } from '@growi/slack';
 import {
   IMiddleware, Middleware, Next, Req,
 } from '@tsed/common';
 
-import { RequestFromSlack } from '@growi/slack';
-
 
 @Middleware()
 export class ParseInteractionPayloadMiddleare implements IMiddleware {

+ 1 - 0
packages/slackbot-proxy/src/middlewares/slack-to-growi/url-verification.ts

@@ -1,6 +1,7 @@
 import {
   IMiddleware, Middleware, Req, Res, Next,
 } from '@tsed/common';
+
 import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
 
 

+ 0 - 1
packages/slackbot-proxy/src/models/errors.ts

@@ -1,5 +1,4 @@
 import ExtensibleCustomError from 'extensible-custom-error';
-
 import { HttpError } from 'http-errors';
 
 export class InvalidUrlError extends ExtensibleCustomError {

+ 4 - 3
packages/slackbot-proxy/src/services/LinkSharedService.ts

@@ -1,9 +1,10 @@
-import axios from 'axios';
-import { Inject, Service } from '@tsed/di';
 import { GrowiEventProcessor, REQUEST_TIMEOUT_FOR_PTOG } from '@growi/slack';
 import { WebClient } from '@slack/web-api';
-import loggerFactory from '~/utils/logger';
+import { Inject, Service } from '@tsed/di';
+import axios from 'axios';
+
 import { RelationRepository } from '~/repositories/relation';
+import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('slackbot-proxy:services:LinkSharedService');
 

+ 9 - 6
packages/slackbot-proxy/src/services/RegisterService.ts

@@ -1,18 +1,21 @@
-import { Inject, Service } from '@tsed/di';
-import {
-  WebClient, LogLevel, Block, ConversationsSelect,
-} from '@slack/web-api';
 import {
   markdownSectionBlock, markdownHeaderBlock, inputSectionBlock, GrowiCommand, inputBlock,
   respond, GrowiCommandProcessor, GrowiInteractionProcessor,
   getInteractionIdRegexpFromCommandName, InteractionHandledResult, InteractionPayloadAccessor,
 } from '@growi/slack';
 import { AuthorizeResult } from '@slack/oauth';
-import { OrderRepository } from '~/repositories/order';
-import { InvalidUrlError } from '../models/errors';
+import {
+  WebClient, LogLevel, Block, ConversationsSelect,
+} from '@slack/web-api';
+import { Inject, Service } from '@tsed/di';
+
+
 import { InstallationRepository } from '~/repositories/installation';
+import { OrderRepository } from '~/repositories/order';
 import loggerFactory from '~/utils/logger';
 
+import { InvalidUrlError } from '../models/errors';
+
 const logger = loggerFactory('slackbot-proxy:services:RegisterService');
 
 const isProduction = process.env.NODE_ENV === 'production';

+ 3 - 5
packages/slackbot-proxy/src/services/RelationsService.ts

@@ -1,14 +1,12 @@
+import {
+  REQUEST_TIMEOUT_FOR_PTOG, getSupportedGrowiActionsRegExp, IChannelOptionalId, permissionParser,
+} from '@growi/slack';
 import { Inject, Service } from '@tsed/di';
-
 import axios from 'axios';
 import { addHours } from 'date-fns';
 
-import {
-  REQUEST_TIMEOUT_FOR_PTOG, getSupportedGrowiActionsRegExp, IChannelOptionalId, permissionParser,
-} from '@growi/slack';
 import { Relation, PermissionSettingsInterface } from '~/entities/relation';
 import { RelationRepository } from '~/repositories/relation';
-
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('slackbot-proxy:services:RelationsService');

+ 2 - 2
packages/slackbot-proxy/src/services/SelectGrowiService.ts

@@ -1,4 +1,3 @@
-import { Inject, Service } from '@tsed/di';
 
 import {
   getInteractionIdRegexpFromCommandName,
@@ -6,11 +5,12 @@ import {
   InteractionHandledResult, markdownSectionBlock, replaceOriginal, respond, InteractionPayloadAccessor,
 } from '@growi/slack';
 import { AuthorizeResult } from '@slack/oauth';
+import { Inject, Service } from '@tsed/di';
 
 import { Installation } from '~/entities/installation';
 import { Relation } from '~/entities/relation';
-import { RelationRepository } from '~/repositories/relation';
 import { InstallationRepository } from '~/repositories/installation';
+import { RelationRepository } from '~/repositories/relation';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('slackbot-proxy:services:UnregisterService');

+ 2 - 2
packages/slackbot-proxy/src/services/SystemInformationService.ts

@@ -1,12 +1,12 @@
 import { Inject, Service } from '@tsed/di';
-
 import readPkgUp from 'read-pkg-up';
 
 import { SystemInformation } from '~/entities/system-information';
 import { SystemInformationRepository } from '~/repositories/system-information';
-import { RelationsService } from './RelationsService';
 import loggerFactory from '~/utils/logger';
 
+import { RelationsService } from './RelationsService';
+
 const logger = loggerFactory('slackbot-proxy:services:SystemInformationService');
 
 @Service()

+ 5 - 4
packages/slackbot-proxy/src/services/UnregisterService.ts

@@ -1,16 +1,17 @@
-import axios from 'axios';
-import { Inject, Service } from '@tsed/di';
-import { MultiStaticSelect } from '@slack/web-api';
 import {
   actionsBlock, buttonElement, getInteractionIdRegexpFromCommandName,
   GrowiCommand, GrowiCommandProcessor, GrowiInteractionProcessor,
   inputBlock, InteractionHandledResult, markdownSectionBlock, respond, InteractionPayloadAccessor, replaceOriginal,
 } from '@growi/slack';
 import { AuthorizeResult } from '@slack/oauth';
+import { MultiStaticSelect } from '@slack/web-api';
+import { Inject, Service } from '@tsed/di';
+import axios from 'axios';
 import { DeleteResult } from 'typeorm';
-import { RelationRepository } from '~/repositories/relation';
+
 import { Installation } from '~/entities/installation';
 import { InstallationRepository } from '~/repositories/installation';
+import { RelationRepository } from '~/repositories/relation';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('slackbot-proxy:services:UnregisterService');

+ 2 - 0
packages/slackbot-proxy/src/services/growi-uri-injector/ActionsBlockPayloadDelegator.ts

@@ -1,7 +1,9 @@
 import { Inject, OnInit, Service } from '@tsed/di';
+
 import {
   GrowiUriInjector, GrowiUriWithOriginalData, TypedBlock,
 } from '~/interfaces/growi-uri-injector';
+
 import { ButtonActionPayloadDelegator } from './block-elements/ButtonActionPayloadDelegator';
 import { CheckboxesActionPayloadDelegator } from './block-elements/CheckboxesActionPayloadDelegator';
 

+ 2 - 0
packages/slackbot-proxy/src/services/growi-uri-injector/SectionBlockPayloadDelegator.ts

@@ -1,7 +1,9 @@
 import { Inject, OnInit, Service } from '@tsed/di';
+
 import {
   GrowiUriInjector, GrowiUriWithOriginalData, TypedBlock,
 } from '~/interfaces/growi-uri-injector';
+
 import { ButtonActionPayloadDelegator } from './block-elements/ButtonActionPayloadDelegator';
 import { CheckboxesActionPayloadDelegator } from './block-elements/CheckboxesActionPayloadDelegator';
 

+ 1 - 0
packages/slackbot-proxy/src/services/growi-uri-injector/ViewInteractionPayloadDelegator.ts

@@ -1,4 +1,5 @@
 import { Service } from '@tsed/di';
+
 import {
   GrowiUriInjector, GrowiUriWithOriginalData, isGrowiUriWithOriginalData, TypedBlock,
 } from '~/interfaces/growi-uri-injector';

+ 1 - 0
packages/slackbot-proxy/src/services/growi-uri-injector/block-elements/ButtonActionPayloadDelegator.ts

@@ -1,4 +1,5 @@
 import { Service } from '@tsed/di';
+
 import { GrowiUriWithOriginalData, GrowiUriInjector, TypedBlock } from '~/interfaces/growi-uri-injector';
 
 

+ 1 - 0
packages/slackbot-proxy/src/services/growi-uri-injector/block-elements/CheckboxesActionPayloadDelegator.ts

@@ -1,4 +1,5 @@
 import { Service } from '@tsed/di';
+
 import { GrowiUriWithOriginalData, GrowiUriInjector, TypedBlock } from '~/interfaces/growi-uri-injector';
 
 

+ 1 - 1
packages/slackbot-proxy/src/utils/welcome-message.ts

@@ -1,5 +1,5 @@
-import { ChatPostMessageResponse, WebClient } from '@slack/web-api';
 import { markdownSectionBlock } from '@growi/slack';
+import { ChatPostMessageResponse, WebClient } from '@slack/web-api';
 
 export const postWelcomeMessageOnce = async(client: WebClient, channel: string): Promise<void|ChatPostMessageResponse> => {
   const history = await client.conversations.history({

+ 2 - 0
packages/ui/src/components/Attachment/Attachment.jsx

@@ -1,6 +1,8 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
 
+
 import { UserPicture } from '../User/UserPicture';
 
 export class Attachment extends React.Component {

+ 0 - 85
packages/ui/src/components/PagePath/PageListMeta.jsx

@@ -1,85 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { templateChecker, pagePathUtils } from '@growi/core';
-import { FootstampIcon } from '../SearchPage/FootstampIcon';
-
-const { isTopPage } = pagePathUtils;
-const { checkTemplatePath } = templateChecker;
-
-export class PageListMeta extends React.Component {
-
-  render() {
-    const { page, shouldSpaceOutIcon } = this.props;
-
-    // top check
-    let topLabel;
-    if (isTopPage(page.path)) {
-      topLabel = <span className={`badge badge-info ${shouldSpaceOutIcon ? 'mr-3' : ''} top-label`}>TOP</span>;
-    }
-
-    // template check
-    let templateLabel;
-    if (checkTemplatePath(page.path)) {
-      templateLabel = <span className={`badge badge-info ${shouldSpaceOutIcon ? 'mr-3' : ''}`}>TMPL</span>;
-    }
-
-    let commentCount;
-    if (page.commentCount != null && page.commentCount > 0) {
-      commentCount = <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}><i className="icon-bubble" />{page.commentCount}</span>;
-    }
-
-    // liker count section
-    let likedCount;
-    if (this.props.likerCount > 0) {
-      likedCount = this.props.likerCount;
-    }
-    else if (page.liker != null && page.liker.length > 0) {
-      likedCount = page.liker.length;
-    }
-
-    let likerCount;
-    if (likedCount > 0) {
-      likerCount = <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}><i className="fa fa-heart-o" />{likedCount}</span>;
-    }
-
-    let locked;
-    if (page.grant !== 1) {
-      locked = <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}><i className="icon-lock" /></span>;
-    }
-
-    let seenUserCount;
-    if (page.seenUserCount > 0) {
-      seenUserCount = (
-        <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}>
-          <i className="footstamp-icon"><FootstampIcon /></i>
-          {page.seenUsers.length}
-        </span>
-      );
-    }
-
-    let bookmarkCount;
-    if (this.props.bookmarkCount > 0) {
-      bookmarkCount = <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}><i className="fa fa-bookmark-o" />{this.props.bookmarkCount}</span>;
-    }
-
-    return (
-      <span className="page-list-meta">
-        {topLabel}
-        {templateLabel}
-        {seenUserCount}
-        {commentCount}
-        {likerCount}
-        {locked}
-        {bookmarkCount}
-      </span>
-    );
-  }
-
-}
-
-PageListMeta.propTypes = {
-  page: PropTypes.object.isRequired,
-  likerCount: PropTypes.number,
-  bookmarkCount: PropTypes.number,
-  shouldSpaceOutIcon: PropTypes.bool,
-};

+ 76 - 0
packages/ui/src/components/PagePath/PageListMeta.tsx

@@ -0,0 +1,76 @@
+import React, { FC } from 'react';
+
+import { IPageHasId } from '@growi/app/src/interfaces/page';
+import { templateChecker, pagePathUtils } from '@growi/core';
+
+import { FootstampIcon } from '../SearchPage/FootstampIcon';
+
+const { isTopPage } = pagePathUtils;
+const { checkTemplatePath } = templateChecker;
+
+type PageListMetaProps = {
+  page: IPageHasId,
+  likerCount?: number,
+  bookmarkCount?: number,
+  shouldSpaceOutIcon?: boolean,
+}
+
+export const PageListMeta: FC<PageListMetaProps> = (props: PageListMetaProps) => {
+
+  const { page, shouldSpaceOutIcon } = props;
+
+  // top check
+  let topLabel;
+  if (isTopPage(page.path)) {
+    topLabel = <span className={`badge badge-info ${shouldSpaceOutIcon ? 'mr-3' : ''} top-label`}>TOP</span>;
+  }
+
+  // template check
+  let templateLabel;
+  if (checkTemplatePath(page.path)) {
+    templateLabel = <span className={`badge badge-info ${shouldSpaceOutIcon ? 'mr-3' : ''}`}>TMPL</span>;
+  }
+
+  let commentCount;
+  if (page.commentCount > 0) {
+    commentCount = <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}><i className="icon-bubble" />{page.commentCount}</span>;
+  }
+
+  let likerCount;
+  if (props.likerCount != null && props.likerCount > 0) {
+    likerCount = <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}><i className="fa fa-heart-o" />{props.likerCount}</span>;
+  }
+
+  let locked;
+  if (page.grant !== 1) {
+    locked = <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}><i className="icon-lock" /></span>;
+  }
+
+  let seenUserCount;
+  if (page.seenUsers.length > 0) {
+    seenUserCount = (
+      <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}>
+        <i className="footstamp-icon"><FootstampIcon /></i>
+        {page.seenUsers.length}
+      </span>
+    );
+  }
+
+  let bookmarkCount;
+  if (props.bookmarkCount != null && props.bookmarkCount > 0) {
+    bookmarkCount = <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}><i className="fa fa-bookmark-o" />{props.bookmarkCount}</span>;
+  }
+
+  return (
+    <span className="page-list-meta">
+      {topLabel}
+      {templateLabel}
+      {seenUserCount}
+      {commentCount}
+      {likerCount}
+      {locked}
+      {bookmarkCount}
+    </span>
+  );
+
+};

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است