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

Merge branch 'master' into fix/106082-fomment-attachment

Yuken Tezuka 3 лет назад
Родитель
Сommit
d4a0046463
79 измененных файлов с 927 добавлено и 429 удалено
  1. 17 21
      .github/workflows/ci-app-prod.yml
  2. 16 1
      CHANGELOG.md
  3. 2 2
      packages/app/docker/README.md
  4. 5 1
      packages/app/public/static/locales/en_US/admin.json
  5. 7 12
      packages/app/public/static/locales/en_US/translation.json
  6. 7 3
      packages/app/public/static/locales/ja_JP/admin.json
  7. 7 12
      packages/app/public/static/locales/ja_JP/translation.json
  8. 5 1
      packages/app/public/static/locales/zh_CN/admin.json
  9. 7 12
      packages/app/public/static/locales/zh_CN/translation.json
  10. 4 5
      packages/app/src/components/Admin/Customize/CustomizeLogoSetting.tsx
  11. 6 1
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  12. 54 13
      packages/app/src/components/Common/ImageCropModal.tsx
  13. 76 23
      packages/app/src/components/LoginForm.tsx
  14. 3 4
      packages/app/src/components/Me/ProfileImageSettings.tsx
  15. 15 13
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  16. 1 1
      packages/app/src/components/Page/TagLabels.tsx
  17. 101 29
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  18. 94 0
      packages/app/src/components/PageEditor/DrawioCommunicationHelper.ts
  19. 72 18
      packages/app/src/components/PageEditor/DrawioModal.tsx
  20. 1 1
      packages/app/src/components/PageEditor/Editor.tsx
  21. 9 13
      packages/app/src/components/PageEditor/EmojiPicker.tsx
  22. 28 39
      packages/app/src/components/PageEditor/EmojiPickerHelper.ts
  23. 1 0
      packages/app/src/components/PageList/PageListItemL.tsx
  24. 1 1
      packages/app/src/components/PrivateLegacyPages.tsx
  25. 1 1
      packages/app/src/components/SavePageControls.tsx
  26. 1 1
      packages/app/src/components/Sidebar/RecentChanges.tsx
  27. 1 1
      packages/app/src/interfaces/activity.ts
  28. 5 0
      packages/app/src/interfaces/errors/login-error.ts
  29. 3 0
      packages/app/src/interfaces/errors/v3-error.ts
  30. 17 0
      packages/app/src/pages/_error.page.tsx
  31. 10 2
      packages/app/src/pages/login.page.tsx
  32. 39 5
      packages/app/src/pages/share/[[...path]].page.tsx
  33. 1 1
      packages/app/src/pages/utils/commons.ts
  34. 2 2
      packages/app/src/server/middlewares/apiv3-form-validator.ts
  35. 7 12
      packages/app/src/server/models/vo/error-v3.js
  36. 2 3
      packages/app/src/server/routes/apiv3/app-settings.js
  37. 2 2
      packages/app/src/server/routes/apiv3/attachment.js
  38. 2 2
      packages/app/src/server/routes/apiv3/customize-setting.js
  39. 1 1
      packages/app/src/server/routes/apiv3/forgot-password.js
  40. 2 1
      packages/app/src/server/routes/apiv3/healthcheck.js
  41. 1 2
      packages/app/src/server/routes/apiv3/import.js
  42. 1 1
      packages/app/src/server/routes/apiv3/installer.ts
  43. 2 2
      packages/app/src/server/routes/apiv3/markdown-setting.js
  44. 2 2
      packages/app/src/server/routes/apiv3/notification-setting.js
  45. 1 1
      packages/app/src/server/routes/apiv3/page-listing.ts
  46. 3 2
      packages/app/src/server/routes/apiv3/page.js
  47. 2 2
      packages/app/src/server/routes/apiv3/pages.js
  48. 2 1
      packages/app/src/server/routes/apiv3/response.js
  49. 3 2
      packages/app/src/server/routes/apiv3/revisions.js
  50. 2 2
      packages/app/src/server/routes/apiv3/search.js
  51. 2 2
      packages/app/src/server/routes/apiv3/security-setting.js
  52. 2 2
      packages/app/src/server/routes/apiv3/share-links.js
  53. 2 2
      packages/app/src/server/routes/apiv3/slack-integration-legacy-settings.js
  54. 1 2
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  55. 1 2
      packages/app/src/server/routes/apiv3/slack-integration.js
  56. 2 1
      packages/app/src/server/routes/apiv3/user-activation.ts
  57. 2 1
      packages/app/src/server/routes/apiv3/user-group-relation.js
  58. 2 1
      packages/app/src/server/routes/apiv3/user-group.js
  59. 2 3
      packages/app/src/server/routes/apiv3/user-ui-settings.ts
  60. 2 1
      packages/app/src/server/routes/apiv3/users.js
  61. 1 1
      packages/app/src/server/routes/index.js
  62. 17 10
      packages/app/src/server/routes/login-passport.js
  63. 15 1
      packages/app/src/server/service/installer.ts
  64. 2 2
      packages/app/src/stores/context.tsx
  65. 0 60
      packages/app/src/stores/modal.tsx
  66. 0 15
      packages/app/src/utils/drawio-config.ts
  67. 9 0
      packages/app/test/cypress/integration/20-basic-features/access-to-page.spec.ts
  68. 11 4
      packages/app/test/cypress/integration/20-basic-features/access-to-pagelist.spec.ts
  69. 82 10
      packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts
  70. 14 10
      packages/app/test/cypress/integration/21-basic-features-for-guest/access-to-page.spec.ts
  71. 53 12
      packages/app/test/cypress/integration/30-search/search.spec.ts
  72. 2 0
      packages/app/test/cypress/integration/40-admin/access-to-admin-page.spec.ts
  73. 21 3
      packages/app/test/cypress/integration/50-sidebar/access-to-side-bar.spec.ts
  74. 8 3
      packages/app/test/cypress/integration/50-sidebar/switching-sidebar-mode.spec.ts
  75. 2 2
      packages/app/test/cypress/support/commands.ts
  76. 1 1
      packages/app/test/cypress/support/index.ts
  77. 1 0
      packages/core/src/index.ts
  78. 15 0
      packages/core/src/models/vo/error-apiv3.ts
  79. 1 1
      packages/core/src/utils/template-checker.ts

+ 17 - 21
.github/workflows/ci-app-prod.yml

@@ -3,8 +3,7 @@ name: Node CI for app production
 on:
   push:
     branches:
-      # - master
-      - support/apply-nextjs-2
+      - master
     paths:
       - .github/workflows/ci-app-prod.yml
       - .github/workflows/reusable-app-prod.yml
@@ -21,8 +20,7 @@ on:
       - packages/plugin-**
   pull_request:
     branches:
-      # - master
-      - support/apply-nextjs-2
+      - master
     types: [opened, reopened, synchronize]
     paths:
       - .github/workflows/ci-app-prod.yml
@@ -42,8 +40,7 @@ on:
 jobs:
 
   test-prod-node14:
-    # uses: weseek/growi/.github/workflows/reusable-app-prod.yml@support/master
-    uses: weseek/growi/.github/workflows/reusable-app-prod.yml@support/apply-nextjs-2
+    uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
     with:
       node-version: 14.x
       skip-cypress: true
@@ -52,8 +49,7 @@ jobs:
 
 
   test-prod-node16:
-    # uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
-    uses: weseek/growi/.github/workflows/reusable-app-prod.yml@support/apply-nextjs-2
+    uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
     with:
       node-version: 16.x
       # skip-cypress: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) && contains( github.event.pull_request.labels.*.name, 'github_actions' ) }}
@@ -63,19 +59,19 @@ jobs:
       SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
-  # run-reg-suit-node16:
-  #   needs: [test-prod-node16]
+  run-reg-suit-node16:
+    needs: [test-prod-node16]
 
-  #   uses: weseek/growi/.github/workflows/reusable-app-reg-suit.yml@master
+    uses: weseek/growi/.github/workflows/reusable-app-reg-suit.yml@master
 
-  #   if: always()
+    if: always()
 
-  #   with:
-  #     node-version: 16.x
-  #     skip-reg-suit: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) && contains( github.event.pull_request.labels.*.name, 'github_actions' ) }}
-  #     cypress-report-artifact-name: Cypress report
-  #   secrets:
-  #     REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: ${{ secrets.REG_NOTIFY_GITHUB_PLUGIN_CLIENTID }}
-  #     AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
-  #     AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
-  #     SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+    with:
+      node-version: 16.x
+      skip-reg-suit: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) && contains( github.event.pull_request.labels.*.name, 'github_actions' ) }}
+      cypress-report-artifact-name: Cypress report
+    secrets:
+      REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: ${{ secrets.REG_NOTIFY_GITHUB_PLUGIN_CLIENTID }}
+      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
+      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 16 - 1
CHANGELOG.md

@@ -1,9 +1,24 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.1.4...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.1.5...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v5.1.5](https://github.com/weseek/growi/compare/v5.1.4...v5.1.5) - 2022-10-04
+
+### 💎 Features
+
+- feat: Add option to not use crop modal on brand logo upload (#6677) @Yohei-Shiina
+
+### 🚀 Improvement
+
+- imprv: Emoji picker performance for v5.x (#6689) @hakumizuki
+
+### 🐛 Bug Fixes
+
+- fix(auditlog): Attachment download is displayed even if the filter is unchecked (#6688) @miya
+- fix: firstName and lastName japanese translations in SAML  (#6631) @kaoritokashiki
+
 ## [v5.1.4](https://github.com/weseek/growi/compare/v5.1.3...v5.1.4) - 2022-09-12
 
 ### 💎 Features

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

@@ -11,8 +11,8 @@ Supported tags and respective Dockerfile links
 ------------------------------------------------
 
 * [`6.0.0`, `6.0`, `6`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v6.0.0/packages/app/docker/Dockerfile)
-* [`5.1.4`, `5.1`, `5`](https://github.com/weseek/growi/blob/v5.1.4/packages/app/docker/Dockerfile)
-* [`5.1.4-nocdn`, `5.1-nocdn`, `5-nocdn`](https://github.com/weseek/growi/blob/v5.1.4/packages/app/docker/Dockerfile)
+* [`5.1.5`, `5.1`, `5`](https://github.com/weseek/growi/blob/v5.1.5/packages/app/docker/Dockerfile)
+* [`5.1.5-nocdn`, `5.1-nocdn`, `5-nocdn`](https://github.com/weseek/growi/blob/v5.1.5/packages/app/docker/Dockerfile)
 * [`4.5.23`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)
 * [`4.5.23-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)
 

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

@@ -907,6 +907,10 @@
     "PAGE_DELETE_COMPLETELY": "Delete completely page",
     "PAGE_REVERT": "Revert page",
     "PAGE_EMPTY_TRASH": "Empty trash",
+    "PAGE_RECURSIVELY_RENAME": "Recursive page rename",
+    "PAGE_RECURSIVELY_DELETE": "Recursive page delete",
+    "PAGE_RECURSIVELY_DELETE_COMPLETELY": "Recursive page delete completely",
+    "PAGE_RECURSIVELY_REVERT": "Recursive page revert",
     "PAGE_SUBSCRIBE": "Subscribe page",
     "PAGE_UNSUBSCRIBE": "Unsubscribe page",
     "PAGE_EXPORT": "Export page",
@@ -924,7 +928,7 @@
     "SHARE_LINK_NOT_FOUND": "Page view (Not found share link)",
     "ATTACHMENT_ADD": "Add Attachment",
     "ATTACHMENT_REMOVE": "Delete Attachment",
-    "ACTION_ATTACHMENT_DOWNLOAD": "Download Attachment",
+    "ATTACHMENT_DOWNLOAD": "Download Attachment",
     "SEARCH_PAGE": "Page Search",
     "SEARCH_PAGE_VIEW": "Page view(Search results page)",
     "ADMIN_APP_SETTING_UPDATE": "Update App Settings",

+ 7 - 12
packages/app/public/static/locales/en_US/translation.json

@@ -674,7 +674,9 @@
   "login": {
     "Sign in error": "Login error",
     "Registration successful": "Registration successful",
-    "Setup": "Setup"
+    "Setup": "Setup",
+    "enabled_ldap_has_configuration_problem":"LDAP is enabled but the configuration has something wrong.",
+    "set_env_var_for_logs": "(Please set the environment variables <code>DEBUG=crowi:service:PassportService</code> to get the logs)"
   },
   "invited": {
     "discription_heading": "Create Account",
@@ -691,16 +693,7 @@
     "fail_to_save_access_token": "Failed to save access_token. Please try again.",
     "fail_to_fetch_access_token": "Failed to fetch access_token. Please do connect again.",
     "successfully_disconnected": "Successfully Disconnected!",
-    "strategy_has_not_been_set_up": {
-      "LocalStrategy"  : "LocalStrategy has not been set up",
-      "LdapStrategy"   : "LdapStrategy has not been set up",
-      "GoogleStrategy" : "GoogleStrategy has not been set up",
-      "GitHubStrategy" : "GitHubStrategy has not been set up",
-      "TwitterStrategy": "TwitterStrategy has not been set up",
-      "OidcStrategy"   : "OidcStrategy has not been set up",
-      "SamlStrategy"   : "SamlStrategy has not been set up",
-      "Basic"          : "Basic has not been set up"
-    },
+    "strategy_has_not_been_set_up": "{{strategy}} has not been set up",
     "ldap_user_not_valid": "Ldap user is no valid",
     "external_account_not_exist": "Failed to find or create External account",
     "maximum_number_of_users": "Can not register more than the maximum number of users.",
@@ -734,7 +727,8 @@
     "Password field is required": "Password field is required.",
     "Username or E-mail has invalid characters": "Username or E-mail has invalid characters.",
     "Password minimum character should be more than 6 characters": "Password minimum character should be more than 6 characters.",
-    "user_not_found": "User not found."
+    "user_not_found": "User not found.",
+    "provider_duplicated_username_exception": "<p><strong><i class='icon-fw icon-ban'></i>DuplicatedUsernameException occured</strong></p><p class='mb-0'> Your {{ failedProviderForDuplicatedUsernameException }} authentication was succeeded, but a new user could not be created. See the issue <a href='https://github.com/weseek/growi/issues/193'>#193</a>.</p>"
   },
   "grid_edit":{
     "create_bootstrap_4_grid":"Create Bootstrap 4 Grid",
@@ -821,6 +815,7 @@
   "crop_image_modal": {
     "image_crop": "Image Crop",
     "crop": "Crop",
+    "save": "Save",
     "reset": "Reset",
     "cancel": "Cancel"
   },

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

@@ -225,8 +225,8 @@
       "attrMapId": "ID",
       "attrMapUsername": "ユーザー名",
       "attrMapMail": "メールアドレス",
-      "attrMapFirstName": "",
-      "attrMapLastName": "",
+      "attrMapFirstName": "",
+      "attrMapLastName": "",
       "ABLCRule": "ルール"
     }
   },
@@ -919,6 +919,10 @@
     "PAGE_DELETE_COMPLETELY": "ページの完全削除",
     "PAGE_REVERT": "ページを元に戻す",
     "PAGE_EMPTY_TRASH": "ゴミ箱を空にする",
+    "PAGE_RECURSIVELY_RENAME": "再帰的なページのリネーム",
+    "PAGE_RECURSIVELY_DELETE": "再帰的なページの削除",
+    "PAGE_RECURSIVELY_DELETE_COMPLETELY": "再起的なページの完全削除",
+    "PAGE_RECURSIVELY_REVERT": "再起的なページの復元",
     "PAGE_SUBSCRIBE": "ページをサブスクライブ",
     "PAGE_UNSUBSCRIBE": "ページをアンサブスクライブ",
     "PAGE_EXPORT": "マークダウン形式でページをエクスポート",
@@ -936,7 +940,7 @@
     "SHARE_LINK_NOT_FOUND": "ページ閲覧(存在しない共有リンク)",
     "ATTACHMENT_ADD": "添付データの追加",
     "ATTACHMENT_REMOVE": "添付データの削除",
-    "ACTION_ATTACHMENT_DOWNLOAD": "添付データのダウンロード",
+    "ATTACHMENT_DOWNLOAD": "添付データのダウンロード",
     "SEARCH_PAGE": "ページの検索",
     "SEARCH_PAGE_VIEW": "ページ閲覧(検索結果ページ)",
     "ADMIN_APP_SETTING_UPDATE": "アプリ設定の更新",

+ 7 - 12
packages/app/public/static/locales/ja_JP/translation.json

@@ -665,7 +665,9 @@
   "login": {
     "Sign in error": "ログインエラー",
     "Registration successful": "登録完了",
-    "Setup": "セットアップ"
+    "Setup": "セットアップ",
+    "enabled_ldap_has_configuration_problem":"LDAPは有効ですが、設定に問題があります。",
+    "set_env_var_for_logs": "(ログを取得するためには、環境変数 <code>DEBUG=crowi:service:PassportService</code> を設定してください。)"
   },
   "invited": {
     "discription_heading": "アカウント作成",
@@ -682,16 +684,7 @@
     "fail_to_save_access_token": "アクセストークンの保存に失敗しました、再度お試しください。",
     "fail_to_fetch_access_token": "アクセストークンの取得に失敗しました、再度お試しください。",
     "successfully_disconnected": "切断に成功しました!",
-    "strategy_has_not_been_set_up": {
-      "LocalStrategy"  : "LocalStrategy はセットアップされていません。",
-      "LdapStrategy"   : "LdapStrategy はセットアップされていません。",
-      "GoogleStrategy" : "GoogleStrategy はセットアップされていません。",
-      "GitHubStrategy" : "GitHubStrategy はセットアップされていません。",
-      "TwitterStrategy": "TwitterStrategy はセットアップされていません。",
-      "OidcStrategy"   : "OidcStrategy はセットアップされていません。",
-      "SamlStrategy"   : "SamlStrategy はセットアップされていません。",
-      "Basic"          : "Basic はセットアップされていません。"
-    },
+    "strategy_has_not_been_set_up": "{{strategy}} はセットアップされていません。",
     "ldap_user_not_valid": "Ldap user is no valid",
     "external_account_not_exist": "外部アカウントが見つからない、または作成に失敗しました",
     "maximum_number_of_users": "ユーザー数が上限を超えたためアクティベートできません。",
@@ -725,7 +718,8 @@
     "Password field is required": "パスワードの欄は必ず入力してください",
     "Username or E-mail has invalid characters": "ユーザー名または、メールアドレスに無効な文字があります",
     "Password minimum character should be more than 6 characters": "パスワードの最小文字数は6文字以上です",
-    "user_not_found": "ユーザーが見つかりません"
+    "user_not_found": "ユーザーが見つかりません",
+    "provider_duplicated_username_exception": "<p><strong><i class='icon-fw icon-ban'></i>エラー: DuplicatedUsernameException</strong></p><p class='mb-0'> {{ failedProviderForDuplicatedUsernameException }} 認証は成功しましたが、新しいユーザーを作成できませんでした。詳しくは<a href='https://github.com/weseek/growi/issues/193'>こちら: #193</a>.</p>"
   },
   "grid_edit":{
     "create_bootstrap_4_grid":"Bootstrap 4 グリッドを作成",
@@ -812,6 +806,7 @@
   "crop_image_modal": {
     "image_crop": "画像の切り抜き",
     "crop": "トリミング",
+    "save": "保存",
     "reset": "リセット",
     "cancel": "キャンセル"
   },

+ 5 - 1
packages/app/public/static/locales/zh_CN/admin.json

@@ -885,6 +885,10 @@
     "PAGE_DELETE_COMPLETELY": "彻底删除页面",
     "PAGE_REVERT": "还原页面",
     "PAGE_EMPTY_TRASH": "清空垃圾箱",
+    "PAGE_RECURSIVELY_RENAME": "递归页面重命名",
+    "PAGE_RECURSIVELY_DELETE": "递归页面删除",
+    "PAGE_RECURSIVELY_DELETE_COMPLETELY": "递归页面完全删除",
+    "PAGE_RECURSIVELY_REVERT": "递归页面还原",
     "PAGE_SUBSCRIBE": "订阅页面",
     "PAGE_UNSUBSCRIBE": "退订页面",
     "PAGE_EXPORT": "导出页面",
@@ -902,7 +906,7 @@
     "SHARE_LINK_NOT_FOUND": "页面浏览量(未找到分享链接)",
     "ATTACHMENT_ADD": "添加附件",
     "ATTACHMENT_REMOVE": "删除附件",
-    "ACTION_ATTACHMENT_DOWNLOAD": "下载附件",
+    "ATTACHMENT_DOWNLOAD": "下载附件",
     "SEARCH_PAGE": "页面搜索",
     "SEARCH_PAGE_VIEW": "页面浏览量(搜索结果页面)",
     "ADMIN_APP_SETTING_UPDATE": "更新应用设置",

+ 7 - 12
packages/app/public/static/locales/zh_CN/translation.json

@@ -721,7 +721,9 @@
 	"login": {
 		"Sign in error": "登录错误",
 		"Registration successful": "注册成功",
-		"Setup": "安装程序"
+		"Setup": "安装程序",
+    "enabled_ldap_has_configuration_problem":"启用了LDAP,但配置有问题。",
+    "set_env_var_for_logs": "(请设置环境变量 <code>DEBUG=crowi:service:PassportService</code> 以获得日志。)"
 	},
   "invited": {
     "discription_heading": "创建账户",
@@ -738,16 +740,7 @@
 		"fail_to_save_access_token": "无法保存访问令牌。请再试一次。",
 		"fail_to_fetch_access_token": "无法获取访问令牌。请重新连接。",
 		"successfully_disconnected": "成功断开连接!",
-    "strategy_has_not_been_set_up": {
-      "LocalStrategy"  : "LocalStrategy 尚未设置",
-      "LdapStrategy"   : "LdapStrategy 尚未设置",
-      "GoogleStrategy" : "GoogleStrategy 尚未设置",
-      "GitHubStrategy" : "GitHubStrategy 尚未设置",
-      "TwitterStrategy": "TwitterStrategy 尚未设置",
-      "OidcStrategy"   : "OidcStrategy 尚未设置",
-      "SamlStrategy"   : "SamlStrategy 尚未设置",
-      "Basic"          : "Basic 尚未设置"
-     },
+    "strategy_has_not_been_set_up": "{{strategy}} 尚未设置",
     "ldap_user_not_valid": "Ldap user is no valid",
     "external_account_not_exist": "查找或创建外部账户失败",
 		"maximum_number_of_users": "注册的用户数不能超过最大值。",
@@ -781,7 +774,8 @@
     "Password field is required": "密码字段是必需的",
     "Username or E-mail has invalid characters": "用户名或电子邮件有无效的字符",
     "Password minimum character should be more than 6 characters": "密码最小字符应超过6个字符",
-    "user_not_found": "未找到用户"
+    "user_not_found": "未找到用户",
+    "provider_duplicated_username_exception": "<p><strong><i class='icon-fw icon-ban'></i>发生了重复用户名异常</strong></p><p class='mb-0'> 你的 {{ failedProviderForDuplicatedUsernameException }} 认证成功了,但不能创建新的用户。参见问题<a href='https://github.com/weseek/growi/issues/193'>#193</a>.</p>"
 	},
   "grid_edit":{
     "create_bootstrap_4_grid":"创建Bootstrap 4网格",
@@ -868,6 +862,7 @@
   "crop_image_modal": {
     "image_crop": "图像裁剪",
     "crop": "修剪",
+    "save": "节省",
     "reset": "重启",
     "cancel": "取消"
   },

+ 4 - 5
packages/app/src/components/Admin/Customize/CustomizeLogoSetting.tsx

@@ -66,7 +66,6 @@ const CustomizeLogoSetting = (): JSX.Element => {
     }
   }, [t, isDefaultLogo, customizedLogoSrc]);
 
-
   const onClickDeleteBtn = useCallback(async() => {
     try {
       await apiv3Delete('/customize-setting/delete-brand-logo');
@@ -80,7 +79,8 @@ const CustomizeLogoSetting = (): JSX.Element => {
     }
   }, [t]);
 
-  const onCropCompleted = useCallback(async(croppedImage) => {
+
+  const processImageCompletedHandler = useCallback(async(croppedImage) => {
     try {
       const formData = new FormData();
       formData.append('file', croppedImage);
@@ -93,10 +93,8 @@ const CustomizeLogoSetting = (): JSX.Element => {
       setRetrieveError(err);
       throw new Error('Failed to upload brand logo');
     }
-    setIsImageCropModalShow(false);
   }, [t]);
 
-
   return (
     <React.Fragment>
       <div className="row">
@@ -172,8 +170,9 @@ const CustomizeLogoSetting = (): JSX.Element => {
         isShow={isImageCropModalShow}
         src={uploadLogoSrc}
         onModalClose={() => setIsImageCropModalShow(false)}
-        onCropCompleted={onCropCompleted}
+        onImageProcessCompleted={processImageCompletedHandler}
         isCircular={false}
+        showCropOption={false}
       />
     </React.Fragment>
   );

+ 6 - 1
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -245,7 +245,12 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
   }
 
   return (
-    <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: undefined } }} right={alignRight}>
+    <DropdownMenu
+      data-testid="page-item-control-menu"
+      positionFixed
+      modifiers={{ preventOverflow: { boundariesElement: undefined } }}
+      right={alignRight}
+    >
       {contents}
     </DropdownMenu>
   );

+ 54 - 13
packages/app/src/components/Common/ImageCropModal.tsx

@@ -33,17 +33,19 @@ type Props = {
   isShow: boolean,
   src: string | ArrayBuffer | null,
   onModalClose: () => void,
-  onCropCompleted: (res: any) => void,
+  onImageProcessCompleted: (res: any) => void,
   isCircular: boolean,
+  showCropOption: boolean
 }
 const ImageCropModal: FC<Props> = (props: Props) => {
 
   const {
-    isShow, src, onModalClose, onCropCompleted, isCircular,
+    isShow, src, onModalClose, onImageProcessCompleted, isCircular, showCropOption,
   } = props;
 
-  const [imageRef, setImageRef] = useState<HTMLImageElement>();
+  const [imageRef, setImageRef] = useState<HTMLImageElement | null>(null);
   const [cropOptions, setCropOtions] = useState<CropOptions>(null);
+  const [isCropImage, setIsCropImage] = useState<boolean>(true);
   const { t } = useTranslation();
   const reset = useCallback(() => {
     if (imageRef) {
@@ -93,31 +95,70 @@ const ImageCropModal: FC<Props> = (props: Props) => {
     }
   };
 
-  const crop = async() => {
-    // crop immages
+  // Convert base64 Image to blob
+  const convertBase64ToBlob = async(base64Image: string) => {
+    const base64Response = await fetch(base64Image);
+    return base64Response.blob();
+  };
+
+
+  // Clear image and set isImageCrop true on modal close
+  const onModalCloseHandler = async() => {
+    setImageRef(null);
+    setIsCropImage(true);
+    onModalClose();
+  };
+
+  // Process and save image
+  // Cropping image is optional
+  // If crop is active , the saved image is cropped image (png). Otherwise, the original image will be saved (Original size and file type)
+  const processAndSaveImage = async() => {
     if (imageRef && cropOptions?.width && cropOptions.height) {
-      const result = await getCroppedImg(imageRef, cropOptions);
-      onCropCompleted(result);
+      const processedImage = isCropImage ? await getCroppedImg(imageRef, cropOptions) : await convertBase64ToBlob(imageRef.src);
+      // Save image to database
+      onImageProcessCompleted(processedImage);
     }
+    onModalCloseHandler();
   };
 
   return (
-    <Modal isOpen={isShow} toggle={onModalClose}>
-      <ModalHeader tag="h4" toggle={onModalClose} className="bg-info text-light">
+    <Modal isOpen={isShow} toggle={onModalCloseHandler}>
+      <ModalHeader tag="h4" toggle={onModalCloseHandler} className="bg-info text-light">
         {t('crop_image_modal.image_crop')}
       </ModalHeader>
       <ModalBody className="my-4">
-        <ReactCrop src={src} crop={cropOptions} onImageLoaded={onImageLoaded} onChange={onCropChange} circularCrop={isCircular} />
+        {
+          isCropImage
+            ? (<ReactCrop src={src} crop={cropOptions} onImageLoaded={onImageLoaded} onChange={onCropChange} circularCrop={isCircular} />)
+            : (<img style={{ maxWidth: imageRef?.width }} src={imageRef?.src} />)
+        }
       </ModalBody>
       <ModalFooter>
         <button type="button" className="btn btn-outline-danger rounded-pill mr-auto" onClick={reset}>
           {t('crop_image_modal.reset')}
         </button>
-        <button type="button" className="btn btn-outline-secondary rounded-pill mr-2" onClick={onModalClose}>
+        { !showCropOption && (
+          <div className="mr-auto">
+            <div className="custom-control custom-switch ">
+              <input
+                id="cropImageOption"
+                className="custom-control-input mr-auto"
+                type="checkbox"
+                checked={isCropImage}
+                onChange={() => { setIsCropImage(!isCropImage) }}
+              />
+              <label className="custom-control-label" htmlFor="cropImageOption">
+                { t('crop_image_modal.image_crop') }
+              </label>
+            </div>
+          </div>
+        )
+        }
+        <button type="button" className="btn btn-outline-secondary rounded-pill mr-2" onClick={onModalCloseHandler}>
           {t('crop_image_modal.cancel')}
         </button>
-        <button type="button" className="btn btn-outline-primary rounded-pill" onClick={crop}>
-          {t('crop_image_modal.crop')}
+        <button type="button" className="btn btn-outline-primary rounded-pill" onClick={processAndSaveImage}>
+          { isCropImage ? t('crop_image_modal.crop') : t('crop_image_modal.save') }
         </button>
       </ModalFooter>
     </Modal>

+ 76 - 23
packages/app/src/components/LoginForm.tsx

@@ -7,6 +7,8 @@ import { useRouter } from 'next/router';
 import ReactCardFlip from 'react-card-flip';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
+import { LoginErrorCode } from '~/interfaces/errors/login-error';
+import { IErrorV3 } from '~/interfaces/errors/v3-error';
 
 type LoginFormProps = {
   username?: string,
@@ -19,6 +21,7 @@ type LoginFormProps = {
   isPasswordResetEnabled: boolean,
   isLocalStrategySetup: boolean,
   isLdapStrategySetup: boolean,
+  isLdapSetupFailed: boolean,
   objOfIsExternalAuthEnableds?: any,
   isMailerSetup?: boolean
 }
@@ -27,7 +30,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
   const router = useRouter();
 
   const {
-    isLocalStrategySetup, isLdapStrategySetup, isPasswordResetEnabled, isRegistrationEnabled,
+    isLocalStrategySetup, isLdapStrategySetup, isLdapSetupFailed, isPasswordResetEnabled, isRegistrationEnabled,
     isEmailAuthenticationEnabled, registrationMode, registrationWhiteList, isMailerSetup, objOfIsExternalAuthEnableds,
   } = props;
   const isLocalOrLdapStrategiesEnabled = isLocalStrategySetup || isLdapStrategySetup;
@@ -38,13 +41,13 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
   // For Login
   const [usernameForLogin, setUsernameForLogin] = useState('');
   const [passwordForLogin, setPasswordForLogin] = useState('');
-  const [loginErrors, setLoginErrors] = useState<Error[]>([]);
+  const [loginErrors, setLoginErrors] = useState<IErrorV3[]>([]);
   // For Register
   const [usernameForRegister, setUsernameForRegister] = useState('');
   const [nameForRegister, setNameForRegister] = useState('');
   const [emailForRegister, setEmailForRegister] = useState('');
   const [passwordForRegister, setPasswordForRegister] = useState('');
-  const [registerErrors, setRegisterErrors] = useState<Error[]>([]);
+  const [registerErrors, setRegisterErrors] = useState<IErrorV3[]>([]);
 
   useEffect(() => {
     const { hash } = window.location;
@@ -60,8 +63,14 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     window.location.href = `/passport/${auth}`;
   }, []);
 
+  const resetLoginErrors = useCallback(() => {
+    if (loginErrors.length === 0) return;
+    setLoginErrors([]);
+  }, [loginErrors.length]);
+
   const handleLoginWithLocalSubmit = useCallback(async(e) => {
     e.preventDefault();
+    resetLoginErrors();
 
     const loginForm = {
       username: usernameForLogin,
@@ -78,25 +87,73 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     }
     return;
 
-  }, [passwordForLogin, router, usernameForLogin]);
+  }, [passwordForLogin, resetLoginErrors, router, usernameForLogin]);
+
+  // separate errors based on error code
+  const separateErrorsBasedOnErrorCode = useCallback((errors: IErrorV3[]) => {
+    const loginErrorListForDangerouslySetInnerHTML: IErrorV3[] = [];
+    const loginErrorList: IErrorV3[] = [];
+
+    errors.forEach((err) => {
+      if (err.code === LoginErrorCode.PROVIDER_DUPLICATED_USERNAME_EXCEPTION) {
+        loginErrorListForDangerouslySetInnerHTML.push(err);
+      }
+      else {
+        loginErrorList.push(err);
+      }
+    });
+
+    return [loginErrorListForDangerouslySetInnerHTML, loginErrorList];
+  }, []);
+
+  // wrap error elements which use dangerouslySetInnerHtml
+  const generateDangerouslySetErrors = useCallback((errors: IErrorV3[]): JSX.Element => {
+    if (errors == null || errors.length === 0) return <></>;
+    return (
+      <div className="alert alert-danger">
+        {errors.map((err, index) => {
+          return <small key={index} dangerouslySetInnerHTML={{ __html: t(err.message, err.args) }}></small>;
+        })}
+      </div>
+    );
+  }, [t]);
+
+  // wrap error elements which do not use dangerouslySetInnerHtml
+  const generateSafelySetErrors = useCallback((errors: IErrorV3[]): JSX.Element => {
+    if (errors == null || errors.length === 0) return <></>;
+    return (
+      <ul className="alert alert-danger">
+        {errors.map((err, index) => {
+          return (
+            <li key={index}>
+              {t(err.message, err.args)}<br/>
+            </li>);
+        })}
+      </ul>
+    );
+  }, [t]);
 
   const renderLocalOrLdapLoginForm = useCallback(() => {
     const { isLdapStrategySetup } = props;
+
+    // separate login errors into two arrays based on error code
+    const [loginErrorListForDangerouslySetInnerHTML, loginErrorList] = separateErrorsBasedOnErrorCode(loginErrors);
+    // Generate login error elements using dangerouslySetInnerHTML
+    const loginErrorElementWithDangerouslySetInnerHTML = generateDangerouslySetErrors(loginErrorListForDangerouslySetInnerHTML);
+    // Generate login error elements using <ul>, <li>
+    const loginErrorElement = generateSafelySetErrors(loginErrorList);
+
     return (
       <>
-        {
-          loginErrors != null && loginErrors.length > 0 && (
-            <p className="alert alert-danger">
-              {loginErrors.map((err, index) => {
-                return (
-                  <span key={index}>
-                    {t(err.message)}<br/>
-                  </span>
-                );
-              })}
-            </p>
-          )
-        }
+        {isLdapSetupFailed && (
+          <div className="alert alert-warning small">
+            <strong><i className="icon-fw icon-info"></i>{t('login.enabled_ldap_has_configuration_problem')}</strong><br/>
+            <span dangerouslySetInnerHTML={{ __html: t('login.set_env_var_for_logs') }}></span>
+          </div>
+        )}
+        {loginErrorElementWithDangerouslySetInnerHTML}
+        {loginErrorElement}
+
         <form role="form" onSubmit={handleLoginWithLocalSubmit} id="login-form">
           <div className="input-group">
             <div className="input-group-prepend">
@@ -137,7 +194,8 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
         </form>
       </>
     );
-  }, [handleLoginWithLocalSubmit, loginErrors, props, t]);
+  }, [generateDangerouslySetErrors, generateSafelySetErrors, handleLoginWithLocalSubmit,
+      isLdapSetupFailed, loginErrors, props, separateErrorsBasedOnErrorCode, t]);
 
   const renderExternalAuthInput = useCallback((auth) => {
     const authIconNames = {
@@ -222,11 +280,6 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     return;
   }, [emailForRegister, nameForRegister, passwordForRegister, router, usernameForRegister]);
 
-  const resetLoginErrors = useCallback(() => {
-    if (loginErrors.length === 0) return;
-    setLoginErrors([]);
-  }, [loginErrors.length]);
-
   const resetRegisterErrors = useCallback(() => {
     if (registerErrors.length === 0) return;
     setRegisterErrors([]);

+ 3 - 4
packages/app/src/components/Me/ProfileImageSettings.tsx

@@ -42,7 +42,7 @@ const ProfileImageSettings = (): JSX.Element => {
     setShowImageCropModal(true);
   }, []);
 
-  const cropCompletedHandler = useCallback(async(croppedImage) => {
+  const processImageCompletedHandler = useCallback(async(croppedImage) => {
     try {
       const formData = new FormData();
       formData.append('file', croppedImage);
@@ -53,8 +53,6 @@ const ProfileImageSettings = (): JSX.Element => {
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
       setUploadedPictureSrc((response as any).attachment.filePathProxied);
 
-      // close modal
-      setShowImageCropModal(false);
     }
     catch (err) {
       toastError(err);
@@ -158,8 +156,9 @@ const ProfileImageSettings = (): JSX.Element => {
         isShow={showImageCropModal}
         src={imageCropSrc}
         onModalClose={() => setShowImageCropModal(false)}
-        onCropCompleted={cropCompletedHandler}
+        onImageProcessCompleted={processImageCompletedHandler}
         isCircular
+        showCropOption
       />
 
       <div className="row my-3">

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

@@ -385,19 +385,21 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     : currentPage?.path;
 
   return (
-    <GrowiSubNavigation
-      pagePath={pagePath}
-      pageId={currentPage?._id}
-      showDrawerToggler={isDrawerMode}
-      showTagLabel={isAbleToShowTagLabel}
-      isGuestUser={isGuestUser}
-      isDrawerMode={isDrawerMode}
-      isCompactMode={isCompactMode}
-      tags={isViewMode ? tagsInfoData?.tags : tagsForEditors}
-      tagsUpdatedHandler={isViewMode ? tagsUpdatedHandlerForViewMode : tagsUpdatedHandlerForEditMode}
-      rightComponent={RightComponent}
-      additionalClasses={['container-fluid']}
-    />
+    <div data-testid="grw-contextual-sub-nav">
+      <GrowiSubNavigation
+        pagePath={pagePath}
+        pageId={currentPage?._id}
+        showDrawerToggler={isDrawerMode}
+        showTagLabel={isAbleToShowTagLabel}
+        isGuestUser={isGuestUser}
+        isDrawerMode={isDrawerMode}
+        isCompactMode={isCompactMode}
+        tags={isViewMode ? tagsInfoData?.tags : tagsForEditors}
+        tagsUpdatedHandler={isViewMode ? tagsUpdatedHandlerForViewMode : tagsUpdatedHandlerForEditMode}
+        rightComponent={RightComponent}
+        additionalClasses={['container-fluid']}
+      />
+    </div>
   );
 };
 

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

@@ -36,7 +36,7 @@ export const TagLabels:FC<Props> = (props: Props) => {
 
   return (
     <>
-      <div className={`${styles['grw-tag-labels']} grw-tag-labels d-flex align-items-center`}>
+      <div className={`${styles['grw-tag-labels']} grw-tag-labels d-flex align-items-center`} data-testid="grw-tag-labels">
         <i className="tag-icon icon-tag mr-2"></i>
         <RenderTagLabels
           tags={tags}

+ 101 - 29
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -7,6 +7,7 @@ import * as loadCssSync from 'load-css-file';
 import PropTypes from 'prop-types';
 import { Button } from 'reactstrap';
 import * as loadScript from 'simple-load-script';
+import { throttle, debounce } from 'throttle-debounce';
 import urljoin from 'url-join';
 
 import InterceptorManager from '~/services/interceptor-manager';
@@ -106,7 +107,9 @@ class CodeMirrorEditor extends AbstractEditor {
       isCheatsheetModalShown: false,
       additionalClassSet: new Set(),
       isEmojiPickerShown: false,
-      emojiSearchText: null,
+      emojiSearchText: '',
+      startPosWithEmojiPickerModeTurnedOn: null,
+      isEmojiPickerMode: false,
     };
 
     this.cm = React.createRef();
@@ -132,7 +135,16 @@ class CodeMirrorEditor extends AbstractEditor {
     this.pasteHandler = this.pasteHandler.bind(this);
     this.cursorHandler = this.cursorHandler.bind(this);
     this.changeHandler = this.changeHandler.bind(this);
-    this.keyUpHandler = this.keyUpHandler.bind(this);
+    this.turnOnEmojiPickerMode = this.turnOnEmojiPickerMode.bind(this);
+    this.turnOffEmojiPickerMode = this.turnOffEmojiPickerMode.bind(this);
+    this.windowClickHandler = this.windowClickHandler.bind(this);
+    this.keyDownHandler = this.keyDownHandler.bind(this);
+    this.keyDownHandlerForEmojiPicker = this.keyDownHandlerForEmojiPicker.bind(this);
+    this.keyDownHandlerForEmojiPickerThrottled = throttle(400, this.keyDownHandlerForEmojiPicker);
+    this.showEmojiPicker = this.showEmojiPicker.bind(this);
+    this.keyPressHandlerForEmojiPicker = this.keyPressHandlerForEmojiPicker.bind(this);
+    this.keyPressHandlerForEmojiPickerThrottled = debounce(50, throttle(200, this.keyPressHandlerForEmojiPicker));
+    this.keyPressHandler = this.keyPressHandler.bind(this);
 
     this.updateCheatsheetStates = this.updateCheatsheetStates.bind(this);
 
@@ -146,7 +158,6 @@ class CodeMirrorEditor extends AbstractEditor {
 
     this.foldDrawioSection = this.foldDrawioSection.bind(this);
     this.onSaveForDrawio = this.onSaveForDrawio.bind(this);
-    this.checkWhetherEmojiPickerShouldBeShown = this.checkWhetherEmojiPickerShouldBeShown.bind(this);
 
   }
 
@@ -180,6 +191,13 @@ class CodeMirrorEditor extends AbstractEditor {
     }
     this.emojiPickerHelper = new EmojiPickerHelper(this.getCodeMirror());
 
+    // HACKME: Find a better way to handle onClick for Editor
+    document.addEventListener('click', this.windowClickHandler);
+  }
+
+  componentWillUnmount() {
+    // HACKME: Find a better way to handle onClick for Editor
+    document.removeEventListener('click', this.windowClickHandler);
   }
 
   componentWillReceiveProps(nextProps) {
@@ -583,10 +601,82 @@ class CodeMirrorEditor extends AbstractEditor {
 
   }
 
-  keyUpHandler(editor, event) {
-    if (event.key !== 'Backspace') {
-      this.checkWhetherEmojiPickerShouldBeShown();
+  turnOnEmojiPickerMode(pos) {
+    this.setState({
+      isEmojiPickerMode: true,
+      startPosWithEmojiPickerModeTurnedOn: pos,
+    });
+  }
+
+  turnOffEmojiPickerMode() {
+    this.setState({
+      isEmojiPickerMode: false,
+    });
+  }
+
+  showEmojiPicker(initialSearchingText) {
+    // show emoji picker with a stored word
+    this.setState({
+      isEmojiPickerShown: true,
+      emojiSearchText: initialSearchingText ?? '',
+    });
+
+    const resetStartPos = initialSearchingText == null;
+    if (resetStartPos) {
+      this.setState({ startPosWithEmojiPickerModeTurnedOn: null });
     }
+
+    this.turnOffEmojiPickerMode();
+  }
+
+  keyPressHandlerForEmojiPicker(editor, event) {
+    const char = event.key;
+    const isEmojiPickerMode = this.state.isEmojiPickerMode;
+
+    // evaluate whether emoji picker mode to be turned on
+    if (!isEmojiPickerMode) {
+      const startPos = this.emojiPickerHelper.shouldModeTurnOn(char);
+      if (startPos == null) {
+        return;
+      }
+
+      this.turnOnEmojiPickerMode(startPos);
+      return;
+    }
+
+    // evaluate whether EmojiPicker to be opened
+    const startPos = this.state.startPosWithEmojiPickerModeTurnedOn;
+    if (this.emojiPickerHelper.shouldOpen(startPos)) {
+      const initialSearchingText = this.emojiPickerHelper.getInitialSearchingText(startPos);
+      this.showEmojiPicker(initialSearchingText);
+      return;
+    }
+
+    this.turnOffEmojiPickerMode();
+  }
+
+  keyPressHandler(editor, event) {
+    this.keyPressHandlerForEmojiPickerThrottled(editor, event);
+  }
+
+  keyDownHandlerForEmojiPicker(editor, event) {
+    const key = event.key;
+
+    if (!this.state.isEmojiPickerMode) {
+      return;
+    }
+
+    if (['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'BackSpace'].includes(key)) {
+      this.turnOffEmojiPickerMode();
+    }
+  }
+
+  keyDownHandler(editor, event) {
+    this.keyDownHandlerForEmojiPickerThrottled(editor, event);
+  }
+
+  windowClickHandler() {
+    this.turnOffEmojiPickerMode();
   }
 
   /**
@@ -610,26 +700,6 @@ class CodeMirrorEditor extends AbstractEditor {
 
   }
 
-  /**
-   * Show emoji picker component when emoji pattern (`:` + searchWord ) found
-   * eg `:a`, `:ap`
-   */
-  checkWhetherEmojiPickerShouldBeShown() {
-    const searchWord = this.emojiPickerHelper.getEmoji();
-
-    if (searchWord == null) {
-      this.setState({ isEmojiPickerShown: false });
-      this.setState({ emojiSearchText: null });
-    }
-    else {
-      this.setState({ emojiSearchText: searchWord });
-      // Show emoji picker after user stop typing
-      setTimeout(() => {
-        this.setState({ isEmojiPickerShown: true });
-      }, 700);
-    }
-  }
-
   /**
    * update states which related to cheatsheet
    * @param {boolean} isGfmModeTmp (use state.isGfmMode if null is set)
@@ -709,7 +779,8 @@ class CodeMirrorEditor extends AbstractEditor {
         <div className="text-left">
           <div className="mb-2 d-none d-md-block">
             <EmojiPicker
-              onClose={() => this.setState({ isEmojiPickerShown: false, emojiSearchText: null })}
+              onClose={() => this.setState({ isEmojiPickerShown: false })}
+              onSelected={emoji => this.emojiPickerHelper.addEmoji(emoji, this.state.startPosWithEmojiPickerModeTurnedOn)}
               emojiSearchText={emojiSearchText}
               emojiPickerHelper={this.emojiPickerHelper}
               isOpen={this.state.isEmojiPickerShown}
@@ -959,7 +1030,7 @@ class CodeMirrorEditor extends AbstractEditor {
         color={null}
         bssize="small"
         title="Emoji"
-        onClick={() => this.setState({ isEmojiPickerShown: true })}
+        onClick={() => this.showEmojiPicker()}
       >
         <EditorIcon icon="Emoji" />
       </Button>,
@@ -1039,7 +1110,8 @@ class CodeMirrorEditor extends AbstractEditor {
               this.props.onDragEnter(event);
             }
           }}
-          onKeyUp={this.keyUpHandler}
+          onKeyPress={this.keyPressHandler}
+          onKeyDown={this.keyDownHandler}
         />
 
         { this.renderLoadingKeymapOverlay() }

+ 94 - 0
packages/app/src/components/PageEditor/DrawioCommunicationHelper.ts

@@ -0,0 +1,94 @@
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:cli:DrawioCommunicationHelper');
+
+export type DrawioConfig = {
+  css: string,
+  customFonts: string[],
+}
+
+export type DrawioCommunicationCallbackOptions = {
+  onClose?: () => void;
+  onSave?: (drawioData: string) => void;
+}
+
+export class DrawioCommunicationHelper {
+
+  drawioUri: string;
+
+  drawioConfig: DrawioConfig;
+
+  callbackOpts?: DrawioCommunicationCallbackOptions;
+
+
+  constructor(drawioUri: string, drawioConfig: DrawioConfig, callbackOpts?: DrawioCommunicationCallbackOptions) {
+    this.drawioUri = drawioUri;
+    this.drawioConfig = drawioConfig;
+    this.callbackOpts = callbackOpts;
+  }
+
+  onReceiveMessage(event: MessageEvent, drawioMxFile: string): void {
+
+    // check origin
+    if (event.origin != null && this.drawioUri != null) {
+      const originUrl = new URL(event.origin);
+      const drawioUrl = new URL(this.drawioUri);
+
+      if (originUrl.origin !== drawioUrl.origin) {
+        logger.debug(`Skipping the event because the origins are mismatched. expected: '${drawioUrl.origin}', actual: '${originUrl.origin}'`);
+        return;
+      }
+    }
+
+    if (event.data === 'ready') {
+      event.source?.postMessage(drawioMxFile, { targetOrigin: '*' });
+      return;
+    }
+
+    if (event.data === '{"event":"configure"}') {
+      if (event.source == null) {
+        return;
+      }
+
+      // refs:
+      //  * https://desk.draw.io/support/solutions/articles/16000103852-how-to-customise-the-draw-io-interface
+      //  * https://desk.draw.io/support/solutions/articles/16000042544-how-does-embed-mode-work-
+      //  * https://desk.draw.io/support/solutions/articles/16000058316-how-to-configure-draw-io-
+      event.source.postMessage(JSON.stringify({
+        action: 'configure',
+        config: this.drawioConfig,
+      }), { targetOrigin: '*' });
+
+      return;
+    }
+
+    if (typeof event.data === 'string' && event.data.match(/mxfile/)) {
+      if (event.data.length > 0) {
+        const parser = new DOMParser();
+        const dom = parser.parseFromString(event.data, 'text/xml');
+        const drawioData = dom.getElementsByTagName('diagram')[0].innerHTML;
+
+        /*
+        * Saving Drawio will be implemented by the following tasks
+        * https://redmine.weseek.co.jp/issues/100845
+        * https://redmine.weseek.co.jp/issues/104507
+        */
+
+        this.callbackOpts?.onSave?.(drawioData);
+      }
+
+      this.callbackOpts?.onClose?.();
+
+      return;
+    }
+
+    if (typeof event.data === 'string' && event.data.length === 0) {
+      this.callbackOpts?.onClose?.();
+      return;
+    }
+
+    // NOTHING DONE. (Receive unknown iframe message.)
+  }
+
+}

+ 72 - 18
packages/app/src/components/PageEditor/DrawioModal.tsx

@@ -1,4 +1,6 @@
 import React, {
+  useCallback,
+  useEffect,
   useMemo,
 } from 'react';
 
@@ -13,25 +15,42 @@ import { useDrawioUri } from '~/stores/context';
 import { useDrawioModal } from '~/stores/modal';
 import { usePersonalSettings } from '~/stores/personal-settings';
 
+import { DrawioCommunicationHelper } from './DrawioCommunicationHelper';
+
+
+const headerColor = '#334455';
+const fontFamily = "Lato, -apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif";
+
+const drawioConfig = {
+  css: `
+  .geMenubarContainer { background-color: ${headerColor} !important; }
+  .geMenubar { background-color: ${headerColor} !important; }
+  .geEditor { font-family: ${fontFamily} !important; }
+  html td.mxPopupMenuItem {
+    font-family: ${fontFamily} !important;
+    font-size: 8pt !important;
+  }
+  `,
+  customFonts: ['Lato', 'Charter'],
+};
+
 
 type Props = {
   // onSave: (drawioData) => void,
 };
 
 export const DrawioModal = (props: Props): JSX.Element => {
-  const { data: growiDrawioUri } = useDrawioUri();
+  const { data: drawioUri } = useDrawioUri();
   const { data: personalSettingsInfo } = usePersonalSettings();
 
-
   const { data: drawioModalData, close: closeDrawioModal } = useDrawioModal();
   const isOpened = drawioModalData?.isOpened ?? false;
 
-  const cancel = () => {
-    closeDrawioModal();
-  };
+  const drawioUriWithParams = useMemo(() => {
+    if (drawioUri == null) {
+      return undefined;
+    }
 
-  const drawioUrl = useMemo(() => {
-    const drawioUri = growiDrawioUri || 'https://embed.diagrams.net/';
     const url = new URL(drawioUri);
 
     // refs: https://desk.draw.io/support/solutions/articles/16000042546-what-url-parameters-are-supported-
@@ -42,13 +61,46 @@ export const DrawioModal = (props: Props): JSX.Element => {
     url.searchParams.append('configure', '1');
 
     return url;
-  }, [growiDrawioUri, personalSettingsInfo?.lang]);
+  }, [drawioUri, personalSettingsInfo?.lang]);
+
+  const drawioCommunicationHelper = useMemo(() => {
+    if (drawioUri == null) {
+      return undefined;
+    }
 
+    return new DrawioCommunicationHelper(
+      drawioUri,
+      drawioConfig,
+      { onClose: closeDrawioModal },
+    );
+  }, [closeDrawioModal, drawioUri]);
+
+  const receiveMessageHandler = useCallback((event: MessageEvent) => {
+    if (drawioModalData == null) {
+      return;
+    }
+
+    drawioCommunicationHelper?.onReceiveMessage(event, drawioModalData.drawioMxFile);
+  }, [drawioCommunicationHelper, drawioModalData]);
+
+  useEffect(() => {
+    if (isOpened) {
+      window.addEventListener('message', receiveMessageHandler);
+    }
+    else {
+      window.removeEventListener('message', receiveMessageHandler);
+    }
+
+    // clean up
+    return function() {
+      window.removeEventListener('message', receiveMessageHandler);
+    };
+  }, [isOpened, receiveMessageHandler]);
 
   return (
     <Modal
       isOpen={isOpened}
-      toggle={cancel}
+      toggle={() => closeDrawioModal()}
       backdrop="static"
       className="drawio-modal grw-body-only-modal-expanded"
       size="xl"
@@ -62,15 +114,17 @@ export const DrawioModal = (props: Props): JSX.Element => {
           </div>
         </div>
         {/* iframe */}
-        <div className="w-100 h-100 position-absolute d-flex">
-          { isOpened && (
-            <iframe
-              src={drawioUrl.href}
-              className="border-0 flex-grow-1"
-            >
-            </iframe>
-          ) }
-        </div>
+        { drawioUriWithParams != null && (
+          <div className="w-100 h-100 position-absolute d-flex">
+            { isOpened && (
+              <iframe
+                src={drawioUriWithParams.href}
+                className="border-0 flex-grow-1"
+              >
+              </iframe>
+            ) }
+          </div>
+        ) }
       </ModalBody>
     </Modal>
   );

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

@@ -38,7 +38,7 @@ export type EditorPropsType = {
   onDragEnter?: (event: any) => void,
   onMarkdownHelpButtonClicked?: () => void,
   onAddAttachmentButtonClicked?: () => void,
-  onScroll?: ({ line: number }) => void,
+  onScroll?: (line: { line: number }) => void,
   onScrollCursorIntoView?: (line: number) => void,
   onSave?: () => Promise<void>,
   onPasteFiles?: (event: Event) => void,

+ 9 - 13
packages/app/src/components/PageEditor/EmojiPicker.tsx

@@ -1,4 +1,4 @@
-import React, { FC } from 'react';
+import React, { FC, useCallback } from 'react';
 
 import { Picker } from 'emoji-mart';
 import { Modal } from 'reactstrap';
@@ -9,6 +9,7 @@ import EmojiPickerHelper, { getEmojiTranslation } from './EmojiPickerHelper';
 
 type Props = {
   onClose: () => void,
+  onSelected: (emoji: string) => void,
   emojiSearchText: string,
   emojiPickerHelper: EmojiPickerHelper,
   isOpen: boolean
@@ -17,30 +18,25 @@ type Props = {
 const EmojiPicker: FC<Props> = (props: Props) => {
 
   const {
-    onClose, emojiSearchText, emojiPickerHelper, isOpen,
+    onClose, onSelected, emojiSearchText, emojiPickerHelper, isOpen,
   } = props;
 
   const { resolvedTheme } = useNextThemes();
 
   // Set search emoji input and trigger search
-  const searchEmoji = () => {
+  const searchEmoji = useCallback(() => {
     const input = window.document.querySelector('[id^="emoji-mart-search"]') as HTMLInputElement;
     const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
     valueSetter?.call(input, emojiSearchText);
     const event = new Event('input', { bubbles: true });
     input.dispatchEvent(event);
     input.focus();
-  };
-
-  const selectEmoji = (emoji) => {
-    if (emojiSearchText !== null) {
-      emojiPickerHelper.addEmojiOnSearch(emoji);
-    }
-    else {
-      emojiPickerHelper.addEmoji(emoji);
-    }
+  }, [emojiSearchText]);
+
+  const selectEmoji = useCallback((emoji) => {
+    onSelected(emoji);
     onClose();
-  };
+  }, [onClose, onSelected]);
 
 
   const translation = getEmojiTranslation();

+ 28 - 39
packages/app/src/components/PageEditor/EmojiPickerHelper.ts

@@ -1,22 +1,22 @@
 import { CSSProperties } from 'react';
 
+import { Position } from 'codemirror';
 import i18n from 'i18next';
 
-// https://regex101.com/r/Gqhor8/1
-const EMOJI_PATTERN = new RegExp(/\B:[^:\s]+/);
+// https://regex101.com/r/x5LbOZ/1
+const EMOJI_PATTERN = new RegExp(/^:[a-z0-9-+_]+$/);
 
 export default class EmojiPickerHelper {
 
   editor;
 
-  pattern: RegExp;
+  pattern: string;
 
   constructor(editor) {
     this.editor = editor;
-    this.pattern = EMOJI_PATTERN;
   }
 
-  setStyle = ():CSSProperties => {
+  setStyle = (): CSSProperties => {
     const offset = 20;
     const emojiPickerHeight = 420;
     const cursorPos = this.editor.cursorCoords(true);
@@ -36,53 +36,42 @@ export default class EmojiPickerHelper {
     };
   };
 
-  getSearchCursor = () => {
-    const currentPos = this.editor.getCursor();
-    const sc = this.editor.getSearchCursor(this.pattern, currentPos, { multiline: false });
-    return sc;
-  };
+  shouldModeTurnOn = (char: string): Position | null | undefined => {
+    if (char !== ':') {
+      return null;
+    }
 
-  // Add emoji when triggered by search
-  addEmojiOnSearch = (emoji) => {
     const currentPos = this.editor.getCursor();
-    const sc = this.getSearchCursor();
+    const sc = this.editor.getSearchCursor(':', currentPos, { multiline: false });
     if (sc.findPrevious()) {
-      sc.replace(`${emoji.colons} `, this.editor.getTokenAt(currentPos).string);
-      this.editor.focus();
-      this.editor.refresh();
+      return sc.pos.from;
     }
   };
 
+  shouldOpen = (startPos: Position): boolean => {
+    const currentPos = this.editor.getCursor();
+    const rangeStr = this.editor.getRange(startPos, currentPos);
+
+    return EMOJI_PATTERN.test(rangeStr);
+  };
 
-  // Add emoji when triggered by click emoji icon on top of editor
-  addEmoji = (emoji) => {
+  getInitialSearchingText = (startPos: Position): void => {
     const currentPos = this.editor.getCursor();
-    const doc = this.editor.getDoc();
-    doc.replaceRange(`${emoji.colons} `, currentPos);
-    this.editor.focus();
-    this.editor.refresh();
+    const rangeStr = this.editor.getRange(startPos, currentPos);
+
+    return rangeStr.slice(1); // return without the heading ':'
   };
 
-  getEmoji = () => {
-    const sc = this.getSearchCursor();
+  addEmoji = (emoji: { colons: string }, startPosToReplace: Position|null): void => {
     const currentPos = this.editor.getCursor();
 
-    if (sc.findPrevious()) {
-      const isInputtingEmoji = (currentPos.line === sc.to().line && currentPos.ch === sc.to().ch);
-      // current search cursor position
-      if (!isInputtingEmoji) {
-        return;
-      }
-      const pos = {
-        line: sc.to().line,
-        ch: sc.to().ch,
-      };
-      const currentSearchText = sc.matches(true, pos).match[0];
-      const searchWord = currentSearchText.replace(':', '');
-      return searchWord;
-    }
+    const from = startPosToReplace ?? currentPos;
+    const to = currentPos;
 
-    return;
+    const doc = this.editor.getDoc();
+    doc.replaceRange(`${emoji.colons} `, from, to);
+    this.editor.focus();
+    this.editor.refresh();
   };
 
 }

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

@@ -164,6 +164,7 @@ 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}`}
+      data-testid="page-list-item-L"
       onClick={clickHandler}
     >
       <div className="text-break w-100">

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

@@ -74,7 +74,7 @@ const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.
 
   if (isSuccess) {
     return (
-      <div className="card border-success mt-3">
+      <div className="card border-success mt-3" data-testid="search-result-private-legacy-pages">
         <div className="card-body">
           <h2 className="card-title text-success">{t('private_legacy_pages.nopages_title')}</h2>
           <p className="card-text">

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

@@ -85,7 +85,7 @@ export const SavePageControls = (props: Props): JSX.Element | null => {
       }
 
       <UncontrolledButtonDropdown direction="up">
-        <Button id="caret" color="primary" className="btn-submit" onClick={save}>
+        <Button data-testid="save-page-btn" id="caret" color="primary" className="btn-submit" onClick={save}>
           {labelSubmitButton}
         </Button>
         <DropdownToggle caret color="primary" />

+ 1 - 1
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -36,7 +36,7 @@ const PageItemLower = memo(({ page }: PageItemProps): JSX.Element => {
         <div className="icon-bubble mr-1 d-inline-block"></div>
         <div className="mr-2 grw-list-counts d-inline-block">{page.commentCount}</div>
       </div>
-      <div className="grw-formatted-distance-date small mt-auto">
+      <div className="grw-formatted-distance-date small mt-auto" data-hide-in-vrt>
         <FormattedDistanceDate id={page._id} date={page.updatedAt} />
       </div>
     </div>

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

@@ -67,7 +67,7 @@ const ACTION_SHARE_LINK_EXPIRED_PAGE_VIEW = 'SHARE_LINK_EXPIRED_PAGE_VIEW';
 const ACTION_SHARE_LINK_NOT_FOUND = 'SHARE_LINK_NOT_FOUND';
 const ACTION_ATTACHMENT_ADD = 'ATTACHMENT_ADD';
 const ACTION_ATTACHMENT_REMOVE = 'ATTACHMENT_REMOVE';
-const ACTION_ATTACHMENT_DOWNLOAD = 'ACTION_ATTACHMENT_DOWNLOAD';
+const ACTION_ATTACHMENT_DOWNLOAD = 'ATTACHMENT_DOWNLOAD';
 const ACTION_SEARCH_PAGE = 'SEARCH_PAGE';
 const ACTION_SEARCH_PAGE_VIEW = 'SEARCH_PAGE_VIEW';
 const ACTION_ADMIN_APP_SETTINGS_UPDATE = 'ADMIN_APP_SETTING_UPDATE';

+ 5 - 0
packages/app/src/interfaces/errors/login-error.ts

@@ -0,0 +1,5 @@
+export const LoginErrorCode = {
+  PROVIDER_DUPLICATED_USERNAME_EXCEPTION: 'provider-duplicated-username-exception',
+} as const;
+
+export type LoginErrorCode = typeof LoginErrorCode[keyof typeof LoginErrorCode];

+ 3 - 0
packages/app/src/interfaces/errors/v3-error.ts

@@ -0,0 +1,3 @@
+import { ErrorV3 } from '@growi/core';
+
+export type IErrorV3 = ErrorV3

+ 17 - 0
packages/app/src/pages/_error.page.tsx

@@ -0,0 +1,17 @@
+import { NextPageContext } from 'next';
+import Error, { ErrorProps } from 'next/error';
+
+export default function ErrorPage(props: ErrorProps): JSX.Element {
+  return <Error {...props} />;
+}
+
+// add getInitialProps to disable "https://nextjs.org/docs/messages/prerender-error"
+//   Error: Export encountered errors on following paths:
+//     /_error: /404
+//     /_error: /500
+// see: https://github.com/vercel/next.js/issues/23568#issuecomment-814971318
+ErrorPage.getInitialProps = (ctx: NextPageContext) => {
+  const { res, err } = ctx;
+  const statusCode = res?.statusCode ?? err?.statusCode ?? 500;
+  return { statusCode };
+};

+ 10 - 2
packages/app/src/pages/login.page.tsx

@@ -26,6 +26,9 @@ type Props = CommonProps & {
   isMailerSetup: boolean,
   enabledStrategies: unknown,
   registrationWhiteList: string[],
+  isLocalStrategySetup: boolean,
+  isLdapStrategySetup: boolean,
+  isLdapSetupFailed: boolean,
 };
 
 const LoginPage: NextPage<Props> = (props: Props) => {
@@ -43,8 +46,9 @@ const LoginPage: NextPage<Props> = (props: Props) => {
       <LoginForm
         // Todo: These props should be set properly. https://redmine.weseek.co.jp/issues/104847
         objOfIsExternalAuthEnableds={props.enabledStrategies}
-        isLocalStrategySetup={true}
-        isLdapStrategySetup={true}
+        isLocalStrategySetup={props.isLocalStrategySetup}
+        isLdapStrategySetup={props.isLdapStrategySetup}
+        isLdapSetupFailed={props.isLdapSetupFailed}
         isEmailAuthenticationEnabled={false}
         isRegistrationEnabled={true}
         registrationWhiteList={props.registrationWhiteList}
@@ -92,9 +96,13 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
   const {
     mailService,
     configManager,
+    passportService,
   } = crowi;
 
   props.isMailerSetup = mailService.isMailerSetup;
+  props.isLocalStrategySetup = passportService.isLocalStrategySetup;
+  props.isLdapStrategySetup = passportService.isLdapStrategySetup;
+  props.isLdapSetupFailed = configManager.getConfig('crowi', 'security:passport-ldap:isEnabled') && !props.isLdapStrategySetup;
   props.registrationWhiteList = configManager.getConfig('crowi', 'security:registrationWhiteList');
 }
 

+ 39 - 5
packages/app/src/pages/share/[[...path]].page.tsx

@@ -10,6 +10,7 @@ import dynamic from 'next/dynamic';
 import { ShareLinkLayout } from '~/components/Layout/ShareLinkLayout';
 import GrowiContextualSubNavigation from '~/components/Navbar/GrowiContextualSubNavigation';
 import { Page } from '~/components/Page';
+import { SupportedAction, SupportedActionType } from '~/interfaces/activity';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { RendererConfig } from '~/interfaces/services/renderer';
 import { IShareLinkHasId } from '~/interfaces/share-link';
@@ -17,11 +18,14 @@ import {
   useCurrentUser, useCurrentPagePath, useCurrentPathname, useCurrentPageId, useRendererConfig,
   useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault,
 } from '~/stores/context';
+import loggerFactory from '~/utils/logger';
 
 import {
   CommonProps, getServerSideCommonProps, useCustomTitle, getNextI18NextConfig,
 } from '../utils/commons';
 
+const logger = loggerFactory('growi:next-page:share');
+
 const ShareLinkAlert = dynamic(() => import('~/components/Page/ShareLinkAlert'), { ssr: false });
 const ForbiddenPage = dynamic(() => import('~/components/ForbiddenPage'), { ssr: false });
 
@@ -130,9 +134,40 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
   props._nextI18Next = nextI18NextConfig._nextI18Next;
 }
 
+function getAction(props: Props): SupportedActionType {
+  let action: SupportedActionType;
+  if (props.isExpired) {
+    action = SupportedAction.ACTION_SHARE_LINK_EXPIRED_PAGE_VIEW;
+  }
+  else if (props.shareLink == null) {
+    action = SupportedAction.ACTION_SHARE_LINK_NOT_FOUND;
+  }
+  else {
+    action = SupportedAction.ACTION_SHARE_LINK_PAGE_VIEW;
+  }
+
+  return action;
+}
+
+async function addActivity(context: GetServerSidePropsContext, action: SupportedActionType): Promise<void> {
+  const req: CrowiRequest = context.req as CrowiRequest;
+
+  const parameters = {
+    ip: req.ip,
+    endpoint: req.originalUrl,
+    action,
+    user: req.user?._id,
+    snapshot: {
+      username: req.user?.username,
+    },
+  };
+
+  await req.crowi.activityService.createActivity(parameters);
+}
+
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
   const req = context.req as CrowiRequest<IUserHasId & any>;
-  const { user, crowi } = req;
+  const { user, crowi, params } = req;
   const result = await getServerSideCommonProps(context);
 
   if (!('props' in result)) {
@@ -144,22 +179,21 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
     props.currentUser = user.toObject();
   }
 
-  const { linkId } = req.params;
   try {
     const ShareLinkModel = crowi.model('ShareLink');
-    const shareLink = await ShareLinkModel.findOne({ _id: linkId }).populate('relatedPage');
+    const shareLink = await ShareLinkModel.findOne({ _id: params.linkId }).populate('relatedPage');
     if (shareLink != null) {
       props.isExpired = shareLink.isExpired();
       props.shareLink = shareLink.toObject();
     }
   }
   catch (err) {
-    //
+    logger.error(err);
   }
 
   injectServerConfigurations(context, props);
-  // await injectUserUISettings(context, props);
   await injectNextI18NextConfigurations(context, props);
+  await addActivity(context, getAction(props));
 
   return {
     props,

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

@@ -4,10 +4,10 @@ import { SSRConfig, UserConfig } from 'next-i18next';
 
 import * as nextI18NextConfig from '^/config/next-i18next.config';
 
+import { SupportedActionType } from '~/interfaces/activity';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { GrowiThemes } from '~/interfaces/theme';
 
-
 export type CommonProps = {
   namespacesRequired: string[], // i18next
   currentPathname: string,

+ 2 - 2
packages/app/src/server/middlewares/apiv3-form-validator.ts

@@ -1,11 +1,11 @@
+import { ErrorV3 } from '@growi/core';
 import { NextFunction, Request, Response } from 'express';
+
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:middlewares:ApiV3FormValidator');
 const { validationResult } = require('express-validator');
 
-const ErrorV3 = require('../models/vo/error-apiv3');
-
 export const apiV3FormValidator = (req: Request, res: Response & { apiv3Err }, next: NextFunction): void => {
   logger.debug('req.query', req.query);
   logger.debug('req.params', req.params);

+ 7 - 12
packages/app/src/server/models/vo/error-apiv3.js → packages/app/src/server/models/vo/error-v3.js

@@ -1,3 +1,6 @@
+// The jsdoc below is left here intentionally to be referenced by swagger's $ref in a file under src/server/routes/apiv3/**/*.js such as healthcheck.js
+// The actual ErrorV3 Class is moved to packages/core/models/vo/error-v3.js
+
 /**
  * @swagger
  *
@@ -15,17 +18,9 @@
  *            example: 'someapi-error-with-something'
  *          stack:
  *            type: object
+ *          args:
+ *            type: object
+ *            example: { name: 'Josh', age: 20 }
  */
 
-class ErrorV3 extends Error {
-
-  constructor(message = '', code = '', stack = undefined) {
-    super(); // do not provide message to the super constructor
-    this.message = message;
-    this.code = code;
-    this.stack = stack;
-  }
-
-}
-
-module.exports = ErrorV3;
+export {};

+ 2 - 3
packages/app/src/server/routes/apiv3/app-settings.js

@@ -1,3 +1,4 @@
+import { ErrorV3 } from '@growi/core';
 import { body } from 'express-validator';
 
 import { i18n } from '^/config/next-i18next.config';
@@ -11,14 +12,12 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:app-settings');
 
+const { pathUtils } = require('@growi/core');
 const debug = require('debug')('growi:routes:admin');
 const express = require('express');
-const { pathUtils } = require('@growi/core');
 
 const router = express.Router();
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 /**
  * @swagger
  *  tags:

+ 2 - 2
packages/app/src/server/routes/apiv3/attachment.js

@@ -1,16 +1,16 @@
+import { ErrorV3 } from '@growi/core';
+
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:attachment'); // eslint-disable-line no-unused-vars
-
 const express = require('express');
 
 const router = express.Router();
 const { query } = require('express-validator');
 
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
-const ErrorV3 = require('../../models/vo/error-apiv3');
 
 /**
  * @swagger

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

@@ -1,5 +1,7 @@
 /* eslint-disable no-unused-vars */
 
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import { AttachmentType } from '~/server/interfaces/attachment';
 import loggerFactory from '~/utils/logger';
@@ -17,8 +19,6 @@ const router = express.Router();
 const { body, query } = require('express-validator');
 const multer = require('multer');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 
 /**
  * @swagger

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

@@ -1,10 +1,10 @@
+import { ErrorV3 } from '@growi/core';
 import { format, subSeconds } from 'date-fns';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import injectResetOrderByTokenMiddleware from '~/server/middlewares/inject-reset-order-by-token-middleware';
 import PasswordResetOrder from '~/server/models/password-reset-order';
-import ErrorV3 from '~/server/models/vo/error-apiv3';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';

+ 2 - 1
packages/app/src/server/routes/apiv3/healthcheck.js

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:routes:apiv3:healthcheck'); // eslint-disable-line no-unused-vars
@@ -7,7 +9,6 @@ const express = require('express');
 const router = express.Router();
 
 const noCache = require('nocache');
-const ErrorV3 = require('../../models/vo/error-apiv3');
 
 /**
  * @swagger

+ 1 - 2
packages/app/src/server/routes/apiv3/import.js

@@ -1,3 +1,4 @@
+import { ErrorV3 } from '@growi/core';
 import mongoose from 'mongoose';
 
 import { SupportedAction } from '~/interfaces/activity';
@@ -16,8 +17,6 @@ const multer = require('multer');
 
 const GrowiArchiveImportOption = require('~/models/admin/growi-archive-import-option');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 
 const router = express.Router();
 

+ 1 - 1
packages/app/src/server/routes/apiv3/installer.ts

@@ -1,7 +1,7 @@
+import { ErrorV3 } from '@growi/core';
 import express, { Request, Router } from 'express';
 
 import { SupportedAction } from '~/interfaces/activity';
-import ErrorV3 from '~/server/models/vo/error-apiv3';
 import loggerFactory from '~/utils/logger';
 
 import Crowi from '../../crowi';

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

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
@@ -13,8 +15,6 @@ const router = express.Router();
 
 const { body } = require('express-validator');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 const validator = {
   lineBreak: [
     body('isEnabledLinebreaks').isBoolean(),

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

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
@@ -16,8 +18,6 @@ const router = express.Router();
 
 const { body } = require('express-validator');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 const validator = {
   userNotification: [
     body('pathPattern').isString().trim(),

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

@@ -1,3 +1,4 @@
+import { ErrorV3 } from '@growi/core';
 import express, { Request, Router } from 'express';
 import { query, oneOf } from 'express-validator';
 import mongoose from 'mongoose';
@@ -9,7 +10,6 @@ import loggerFactory from '~/utils/logger';
 import Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { PageModel } from '../../models/page';
-import ErrorV3 from '../../models/vo/error-apiv3';
 import PageService from '../../service/page';
 
 import { ApiV3Response } from './interfaces/apiv3-response';

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

@@ -1,4 +1,6 @@
-import { pagePathUtils, AllSubscriptionStatusType, SubscriptionStatusType } from '@growi/core';
+import {
+  pagePathUtils, AllSubscriptionStatusType, SubscriptionStatusType, ErrorV3,
+} from '@growi/core';
 
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
@@ -15,7 +17,6 @@ const { body, query, param } = require('express-validator');
 
 const router = express.Router();
 const { convertToNewAffiliationPath, isTopPage } = pagePathUtils;
-const ErrorV3 = require('../../models/vo/error-apiv3');
 
 /**
  * @swagger

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

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 import loggerFactory from '~/utils/logger';
@@ -13,8 +15,6 @@ const { body } = require('express-validator');
 const { query } = require('express-validator');
 const mongoose = require('mongoose');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 const { isCreatablePage } = pagePathUtils;
 
 const router = express.Router();

+ 2 - 1
packages/app/src/server/routes/apiv3/response.js

@@ -1,6 +1,7 @@
+import { ErrorV3 } from '@growi/core';
+
 import { toArrayIfNot } from '~/utils/array-utils';
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
 
 const addCustomFunctionToResponse = (express, crowi) => {
 

+ 3 - 2
packages/app/src/server/routes/apiv3/revisions.js

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
@@ -5,10 +7,9 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 const logger = loggerFactory('growi:routes:apiv3:pages');
 
 const express = require('express');
-
 const { query, param } = require('express-validator');
+
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
-const ErrorV3 = require('../../models/vo/error-apiv3');
 
 const router = express.Router();
 

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

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
@@ -14,8 +16,6 @@ const router = express.Router();
 
 const noCache = require('nocache');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 /**
  * @swagger
  *  tags:

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

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import { PageDeleteConfigValue } from '~/interfaces/page-delete-config';
 import loggerFactory from '~/utils/logger';
@@ -16,8 +18,6 @@ const router = express.Router();
 
 const { body } = require('express-validator');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 const validator = {
   generalSetting: [
     body('sessionMaxAge').optional({ checkFalsy: true }).trim().isInt(),

+ 2 - 2
packages/app/src/server/routes/apiv3/share-links.js

@@ -1,5 +1,7 @@
 // TODO remove this setting after implemented all
 /* eslint-disable no-unused-vars */
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import loggerFactory from '~/utils/logger';
@@ -15,8 +17,6 @@ const router = express.Router();
 
 const { body, query, param } = require('express-validator');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 const validator = {};
 
 const today = new Date();

+ 2 - 2
packages/app/src/server/routes/apiv3/slack-integration-legacy-settings.js

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
@@ -14,8 +16,6 @@ const router = express.Router();
 
 const { body } = require('express-validator');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 const validator = {
   slackConfiguration: [
     body('webhookUrl').if(value => value != null).isString().trim(),

+ 1 - 2
packages/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -1,3 +1,4 @@
+import { ErrorV3 } from '@growi/core';
 
 import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
@@ -20,8 +21,6 @@ const { body, query, param } = require('express-validator');
 const mongoose = require('mongoose');
 const urljoin = require('url-join');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 const logger = loggerFactory('growi:routes:apiv3:slack-integration-settings');
 
 const router = express.Router();

+ 1 - 2
packages/app/src/server/routes/apiv3/slack-integration.js

@@ -1,10 +1,9 @@
+import { ErrorV3 } from '@growi/core';
 import createError from 'http-errors';
 
 import { SlackCommandHandlerError } from '~/server/models/vo/slack-command-handler-error';
 import loggerFactory from '~/utils/logger';
 
-import ErrorV3 from '../../models/vo/error-apiv3';
-
 import {
   markdownSectionBlock, InvalidGrowiCommandError, generateRespondUtil, supportedGrowiCommands,
 } from '@growi/slack';

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

@@ -1,7 +1,8 @@
 import path from 'path';
+
+import { ErrorV3 } from '@growi/core';
 import * as express from 'express';
 import { body, validationResult } from 'express-validator';
-import ErrorV3 from '../../models/vo/error-apiv3';
 
 const PASSOWRD_MINIMUM_NUMBER = 8;
 // validation rules for complete registration form

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

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:routes:apiv3:user-group-relation'); // eslint-disable-line no-unused-vars
@@ -5,7 +7,6 @@ const logger = loggerFactory('growi:routes:apiv3:user-group-relation'); // eslin
 const express = require('express');
 const { query } = require('express-validator');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
 const { serializeUserGroupRelationSecurely } = require('../../models/serializers/user-group-relation-serializer');
 
 const router = express.Router();

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

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import UserGroup from '~/server/models/user-group';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
@@ -18,7 +20,6 @@ const { sanitizeQuery } = require('express-validator');
 const mongoose = require('mongoose');
 
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
-const ErrorV3 = require('../../models/vo/error-apiv3');
 const { toPagingLimit, toPagingOffset } = require('../../util/express-validator/sanitizer');
 
 const { ObjectId } = mongoose.Types;

+ 2 - 3
packages/app/src/server/routes/apiv3/user-ui-settings.ts

@@ -1,13 +1,12 @@
+import { ErrorV3 } from '@growi/core';
 import express from 'express';
 import { body } from 'express-validator';
-import { AllSidebarContentsType } from '~/interfaces/ui';
 
+import { AllSidebarContentsType } from '~/interfaces/ui';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-
 import UserUISettings from '../../models/user-ui-settings';
-import ErrorV3 from '../../models/vo/error-apiv3';
 
 const logger = loggerFactory('growi:routes:apiv3:user-ui-settings');
 

+ 2 - 1
packages/app/src/server/routes/apiv3/users.js

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import Activity from '~/server/models/activity';
 import loggerFactory from '~/utils/logger';
@@ -19,7 +21,6 @@ const { isEmail } = require('validator');
 
 const { serializePageSecurely } = require('../../models/serializers/page-serializer');
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
-const ErrorV3 = require('../../models/vo/error-apiv3');
 
 const PAGE_ITEMS = 50;
 

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

@@ -207,7 +207,7 @@ module.exports = function(crowi, app) {
   app.get('/me/*'                                 , loginRequiredStrictly, injectUserUISettings, next.delegateToNext);
   // external-accounts
   // my in-app-notifications
-  // app.get('/me/all-in-app-notifications'   , loginRequiredStrictly, allInAppNotifications.list);
+  // app.get('/me/all-in-app-notifications'   , loginRequiredStrictly, injectUserUISettings, allInAppNotifications.list);
   // app.get('/me/external-accounts'               , loginRequiredStrictly, injectUserUISettings, me.externalAccounts.list);
   // // my drafts
   // app.get('/me/drafts'                          , loginRequiredStrictly, injectUserUISettings, me.drafts.list);

+ 17 - 10
packages/app/src/server/routes/login-passport.js

@@ -1,8 +1,11 @@
+
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
+import { LoginErrorCode } from '~/interfaces/errors/login-error';
 import { NullUsernameToBeRegisteredError } from '~/server/models/errors';
 import loggerFactory from '~/utils/logger';
 
-import ErrorV3 from '../models/vo/error-apiv3';
 
 /* eslint-disable no-use-before-define */
 
@@ -67,7 +70,7 @@ module.exports = function(crowi, app) {
       /* eslint-disable no-else-return */
       if (err instanceof NullUsernameToBeRegisteredError) {
         logger.error(err.message);
-        throw Error(err.message);
+        throw new ErrorV3(err.message);
       }
       else if (err.name === 'DuplicatedUsernameException') {
         if (isSameEmailTreatedAsIdenticalUser || isSameUsernameTreatedAsIdenticalUser) {
@@ -76,11 +79,13 @@ module.exports = function(crowi, app) {
           return ExternalAccount.associate(providerId, userInfo.id, err.user);
         }
         logger.error('provider-DuplicatedUsernameException', providerId);
-        throw Error(`provider-DuplicatedUsernameException: ${providerId}`);
+
+        throw new ErrorV3('message.provider_duplicated_username_exception', LoginErrorCode.PROVIDER_DUPLICATED_USERNAME_EXCEPTION,
+          undefined, { failedProviderForDuplicatedUsernameException: providerId });
       }
       else if (err.name === 'UserUpperLimitException') {
         logger.error(err.message);
-        throw Error(err.message);
+        throw new ErrorV3(err.message);
       }
       /* eslint-enable no-else-return */
     }
@@ -138,21 +143,23 @@ module.exports = function(crowi, app) {
 
   const cannotLoginErrorHadnler = (req, res, next) => {
     // this is called when all login method is somehow failed without invoking 'return next(<any Error>)'
-    const err = res.locals.err != null ? res.locals.err : Error('message.sign_in_failure');
+    const err = new ErrorV3('message.sign_in_failure');
     return next(err);
   };
 
   /**
    * middleware for login failure
+   * @param {*} error
    * @param {*} req
    * @param {*} res
+   * @param {*} next
    */
   const loginFailure = (error, req, res, next) => {
 
     const parameters = { action: SupportedAction.ACTION_USER_LOGIN_FAILURE };
     activityEvent.emit('update', res.locals.activity._id, parameters);
 
-    return res.apiv3Err(error, error.code);
+    return res.apiv3Err(error);
   };
 
   /**
@@ -180,7 +187,7 @@ module.exports = function(crowi, app) {
   const loginWithLdap = async(req, res, next) => {
     if (!passportService.isLdapStrategySetup) {
       debug('LdapStrategy has not been set up');
-      return res.apiv3Err('message.strategy_has_not_been_set_up.LdapStrategy', 405);
+      return next();
     }
 
     if (!req.form.isValid) {
@@ -201,7 +208,7 @@ module.exports = function(crowi, app) {
 
     // check groups for LDAP
     if (!isValidLdapUserByGroupFilter(ldapAccountInfo)) {
-      return next(ErrorV3('message.ldap_user_not_valid', 400));
+      return next(new ErrorV3('message.ldap_user_not_valid'));
     }
 
     /*
@@ -234,7 +241,7 @@ module.exports = function(crowi, app) {
 
     // just in case the returned value is null or undefined
     if (externalAccount == null) {
-      return next(Error('message.external_account_not_exist'));
+      return next(new ErrorV3('message.external_account_not_exist'));
     }
 
     const user = await externalAccount.getPopulatedUser();
@@ -315,7 +322,7 @@ module.exports = function(crowi, app) {
   const loginWithLocal = (req, res, next) => {
     if (!passportService.isLocalStrategySetup) {
       debug('LocalStrategy has not been set up');
-      return res.apiv3Err('message.strategy_has_not_been_set_up.LocalStrategy', 405);
+      return res.apiv3Err(new ErrorV3('message.strategy_has_not_been_set_up', '', undefined, { strategy: 'LocalStrategy' }), 405);
     }
 
     if (!req.form.isValid) {

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

@@ -1,6 +1,7 @@
 import path from 'path';
 
 import { Lang } from '@growi/core';
+import { addSeconds } from 'date-fns';
 import ExtensibleCustomError from 'extensible-custom-error';
 import fs from 'graceful-fs';
 import mongoose from 'mongoose';
@@ -78,7 +79,20 @@ export class InstallerService {
         // TODO typescriptize models/user.js and remove eslint-disable-next-line
         // eslint-disable-next-line @typescript-eslint/no-explicit-any
         const Page = mongoose.model('Page') as any;
-        await Page.updateMany({}, { createdAt: initialPagesCreatedAt, updatedAt: initialPagesCreatedAt });
+
+        // Increment timestamp to avoid difference for order in VRT
+        const pagePaths = ['/Sandbox', '/Sandbox/Bootstrap4', '/Sandbox/Diagrams', '/Sandbox/Math'];
+        const promises = pagePaths.map(async(path: string, idx: number) => {
+          const date = addSeconds(initialPagesCreatedAt, idx);
+          return Page.update(
+            { path },
+            {
+              createdAt: date,
+              updatedAt: date,
+            },
+          );
+        });
+        await Promise.all(promises);
       }
       catch (err) {
         logger.error('Failed to update createdAt', err);

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

@@ -130,8 +130,8 @@ export const useRevisionIdHackmdSynced = (initialData?: Nullable<any>): SWRRespo
   return useStaticSWR<Nullable<any>, Error>('revisionIdHackmdSynced', initialData);
 };
 
-export const useDrawioUri = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
-  return useStaticSWR<Nullable<string>, Error>('drawioUri', initialData);
+export const useDrawioUri = (initialData?: string): SWRResponse<string, Error> => {
+  return useStaticSWR('drawioUri', initialData, { fallbackData: 'https://embed.diagrams.net/' });
 };
 
 export const useHackmdUri = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {

+ 0 - 60
packages/app/src/stores/modal.tsx

@@ -5,7 +5,6 @@ import {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
 } from '~/interfaces/ui';
 import { IUserGroupHasId } from '~/interfaces/user';
-import { dwawioConfig } from '~/utils/drawio-config';
 
 import { useStaticSWR } from './use-static-swr';
 
@@ -453,7 +452,6 @@ type DrawioModalStatus = {
 type DrawioModalStatusUtils = {
   open(drawioMxFile: string): void,
   close(): void,
-  receiveFromDrawio(event, drawioMxFile: string): void,
 }
 
 export const useDrawioModal = (status?: DrawioModalStatus): SWRResponse<DrawioModalStatus, Error> & DrawioModalStatusUtils => {
@@ -467,64 +465,7 @@ export const useDrawioModal = (status?: DrawioModalStatus): SWRResponse<DrawioMo
     swrResponse.mutate({ isOpened: false, drawioMxFile: '' });
   };
 
-  const receiveFromDrawio = (event, drawioMxFile: string) => {
-
-    if (event.data === 'ready') {
-      event.source.postMessage(drawioMxFile, '*');
-      return;
-    }
-
-    if (event.data === '{"event":"configure"}') {
-      if (event.source == null) {
-        return;
-      }
-
-      // refs:
-      //  * https://desk.draw.io/support/solutions/articles/16000103852-how-to-customise-the-draw-io-interface
-      //  * https://desk.draw.io/support/solutions/articles/16000042544-how-does-embed-mode-work-
-      //  * https://desk.draw.io/support/solutions/articles/16000058316-how-to-configure-draw-io-
-      event.source.postMessage(JSON.stringify({
-        action: 'configure',
-        config: dwawioConfig,
-      }), '*');
-
-      return;
-    }
-
-    if (typeof event.data === 'string' && event.data.match(/mxfile/)) {
-      if (event.data.length > 0) {
-        const parser = new DOMParser();
-        const dom = parser.parseFromString(event.data, 'text/xml');
-        const drawioData = dom.getElementsByTagName('diagram')[0].innerHTML;
-
-        /*
-        * Saving Drawio will be implemented by the following tasks
-        * https://redmine.weseek.co.jp/issues/100845
-        * https://redmine.weseek.co.jp/issues/104507
-        */
-
-        // if (props.onSave != null) {
-        //   props.onSave(drawioData);
-        // }
-      }
-
-      window.removeEventListener('message', () => receiveFromDrawio);
-      close();
-
-      return;
-    }
-
-    if (typeof event.data === 'string' && event.data.length === 0) {
-      close();
-
-      return;
-    }
-
-    // NOTHING DONE. (Receive unknown iframe message.)
-  };
-
   const open = (drawioMxFile: string): void => {
-    window.addEventListener('message', e => receiveFromDrawio(e, drawioMxFile));
     swrResponse.mutate({ isOpened: true, drawioMxFile });
   };
 
@@ -532,6 +473,5 @@ export const useDrawioModal = (status?: DrawioModalStatus): SWRResponse<DrawioMo
     ...swrResponse,
     open,
     close,
-    receiveFromDrawio,
   };
 };

+ 0 - 15
packages/app/src/utils/drawio-config.ts

@@ -1,15 +0,0 @@
-const headerColor = '#334455';
-const fontFamily = "Lato, -apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif";
-
-export const dwawioConfig = {
-  css: `
-  .geMenubarContainer { background-color: ${headerColor} !important; }
-  .geMenubar { background-color: ${headerColor} !important; }
-  .geEditor { font-family: ${fontFamily} !important; }
-  html td.mxPopupMenuItem {
-    font-family: ${fontFamily} !important;
-    font-size: 8pt !important;
-  }
-  `,
-  customFonts: ['Lato', 'Charter'],
-};

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

@@ -21,6 +21,11 @@ context('Access to page', () => {
     // hide fab
     cy.getByTestid('grw-fab-container').invoke('attr', 'style', 'display: none');
 
+    // remove animation for screenshot
+    // remove 'blink' class because ::after element cannot be operated
+    // https://stackoverflow.com/questions/5041494/selecting-and-manipulating-css-pseudo-elements-such-as-before-and-after-usin/21709814#21709814
+    cy.get('#Headers').invoke('removeClass', 'blink');
+
     cy.screenshot(`${ssPrefix}-sandbox-headers`);
   });
 
@@ -28,6 +33,8 @@ context('Access to page', () => {
     cy.visit('/Sandbox/Math');
 
     cy.get('mjx-container').should('be.visible');
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(2000); // wait for 2 seconds for MathJax.typesetPromise();
 
     cy.screenshot(`${ssPrefix}-sandbox-math`);
   });
@@ -59,6 +66,8 @@ context('Access to /me page', () => {
 
   it('/me is successfully loaded', () => {
     cy.visit('/me', {  });
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(500); // wait loading image
     cy.screenshot(`${ssPrefix}-me`);
   });
 

+ 11 - 4
packages/app/test/cypress/integration/20-basic-features/access-to-pagelist.spec.ts

@@ -12,8 +12,7 @@ context('Access to pagelist', () => {
   it('Page list modal is successfully opened ', () => {
     cy.visit('/');
     cy.getByTestid('pageListButton').click({force: true});
-    cy.getByTestid('page-accessories-modal').parent().should('have.class','show');
-    cy.screenshot(`${ssPrefix}1-open-pagelist-modal`);
+    cy.getByTestid('page-accessories-modal').should('be.visible').screenshot(`${ssPrefix}1-open-pagelist-modal`);
   });
 
   it('Successfully duplicate a page from page list', () => {
@@ -21,8 +20,10 @@ context('Access to pagelist', () => {
     cy.getByTestid('pageListButton').click({force: true});
     cy.getByTestid('page-accessories-modal').parent().should('have.class','show').within(() => {
       cy.getByTestid('open-page-item-control-btn').first().click();
-      cy.screenshot(`${ssPrefix}2-click-on-three-dots-menu`);
-      cy.get('.dropdown-menu').should('have.class', 'show').first().within(() => {
+      cy.getByTestid('page-item-control-menu').should('have.class', 'show').first().within(() => {
+        // eslint-disable-next-line cypress/no-unnecessary-waiting
+        cy.wait(300);
+        cy.screenshot(`${ssPrefix}2-open-page-item-control-menu`);
         cy.getByTestid('open-page-duplicate-modal-btn').click();
       });
     });
@@ -50,6 +51,7 @@ context('Access to pagelist', () => {
     cy.getByTestid('page-accessories-modal').parent().should('have.class','show').within(() => {
       cy.get('button.close').eq(0).click();
     });
+
     cy.screenshot(`${ssPrefix}7-page-list-modal-size-fullscreen`, {capture: 'viewport'});
 
     cy.getByTestid('page-accessories-modal').parent().should('have.class','show').within(() => {
@@ -76,6 +78,8 @@ context('Access to timeline', () => {
     cy.getByTestid('page-accessories-modal').parent().should('have.class','show').within(() => {
       cy.get('.nav-title > li').eq(1).find('a').click();
     });
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(500); // wait for loading wiki
     cy.screenshot(`${ssPrefix}1-timeline-list`, {capture: 'viewport'});
   });
 
@@ -86,6 +90,9 @@ context('Access to timeline', () => {
       cy.get('.nav-title > li').eq(1).find('a').click();
       cy.get('button.close').eq(0).click();
     });
+    cy.get('.modal').should('be.visible').scrollTo('top');
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(500); // wait for loading wiki
     cy.screenshot(`${ssPrefix}2-timeline-list-fullscreen`, {capture: 'viewport'});
     cy.getByTestid('page-accessories-modal').parent().should('have.class','show').within(() => {
       cy.get('button.close').eq(1).click();

+ 82 - 10
packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts

@@ -9,6 +9,7 @@ context('Switch Sidebar content', () => {
   });
 
   it('PageTree is successfully shown', () => {
+    cy.collapseSidebar(false);
     cy.visit('/page');
     cy.getByTestid('grw-sidebar-nav-primary-page-tree').click();
     // eslint-disable-next-line cypress/no-unnecessary-waiting
@@ -28,20 +29,75 @@ context('Modal for page operation', () => {
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
+    cy.collapseSidebar(true);
   });
+  it("PageCreateModal is shown and closed successfully", () => {
+    cy.visit('/');
+    cy.getByTestid('newPageBtn').click();
 
-  it("PageCreateModal is shown successfully", () => {
-    cy.visit('/me');
+    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
+      cy.screenshot(`${ssPrefix}new-page-modal-opened`);
+      cy.get('button.close').click();
 
+    });
+    cy.screenshot(`${ssPrefix}page-create-modal-closed`, {capture: 'viewport'});
+  });
+  it("Successfully Create Today's page", () => {
+    const pageName = "Today's page";
+    cy.visit('/');
     cy.getByTestid('newPageBtn').click();
 
-    cy.getByTestid('page-create-modal').should('be.visible').screenshot(`${ssPrefix}-open`);
+    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
+      cy.get('.page-today-input2').type(pageName);
+      cy.screenshot(`${ssPrefix}today-add-page-name`);
+      cy.getByTestid('btn-create-memo').click();
+    });
+    cy.getByTestid('page-editor').should('be.visible');
+    cy.getByTestid('save-page-btn').click();
+    cy.get('body').should('not.have.class', 'on-edit');
 
-    cy.getByTestid('row-create-page-under-below').find('input.form-control').clear().type('/new-page');
-    cy.getByTestid('btn-create-page-under-below').click();
+    cy.getByTestid('grw-contextual-sub-nav').should('be.visible');
+    cy.screenshot(`${ssPrefix}create-today-page`);
+  });
+  it('Successfully create page under specific path', () => {
+    const pageName = 'child';
 
+    cy.visit('/SandBox');
+    cy.getByTestid('newPageBtn').click();
+
+    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
+      cy.get('.rbt-input-main').type(pageName);
+      cy.screenshot(`${ssPrefix}under-path-add-page-name`);
+      cy.getByTestid('btn-create-page-under-below').click();
+    });
     cy.getByTestid('page-editor').should('be.visible');
-    cy.screenshot(`${ssPrefix}-create-clicked`, {capture: 'viewport'});
+    cy.getByTestid('save-page-btn').click();
+    cy.get('body').should('not.have.class', 'on-edit');
+
+    cy.getByTestid('grw-contextual-sub-nav').should('be.visible');
+    cy.screenshot(`${ssPrefix}create-page-under-specific-page`);
+  });
+
+  it('Trying to create template page under the root page fail', () => {
+    cy.visit('/');
+    cy.getByTestid('newPageBtn').click();
+
+    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
+      cy.get('#template-type').click();
+      cy.get('#template-type').next().find('button:eq(0)').click({force: true});
+      cy.get('#dd-template-type').next().find('button').click({force: true});
+    });
+    cy.get('.toast-error').should('be.visible').invoke('attr', 'style', 'opacity: 1');
+    cy.screenshot(`${ssPrefix}create-template-for-children-error`, {capture: 'viewport'});
+    cy.get('.toast-error').should('be.visible').click();
+
+    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
+      cy.get('#template-type').click();
+      cy.get('#template-type').next().find('button:eq(1)').click({force: true});
+      cy.get('#dd-template-type').next().find('button').click({force: true});
+    });
+    cy.get('.toast-error').should('be.visible').invoke('attr', 'style', 'opacity: 1');
+    cy.screenshot(`${ssPrefix}create-template-for-descendants-error`, {capture: 'viewport'});
   });
 
   it('PageDeleteModal is shown successfully', () => {
@@ -89,6 +145,7 @@ context('Open presentation modal', () => {
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
+    cy.collapseSidebar(true);
   });
 
   it('PresentationModal for "/" is shown successfully', () => {
@@ -115,6 +172,7 @@ context('Page Accessories Modal', () => {
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
+    cy.collapseSidebar(true);
   });
 
   it('Page History is shown successfully', () => {
@@ -160,6 +218,7 @@ context('Tag Oprations', () =>{
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
+    cy.collapseSidebar(true);
   });
 
   it('Successfully add new tag', () => {
@@ -188,8 +247,10 @@ context('Tag Oprations', () =>{
       cy.get('div.modal-footer > button').click();
     });
 
+    cy.get('.toast').should('be.visible').trigger('mouseover');
     cy.get('.grw-taglabels-container > form > a').contains(tag).should('exist');
-
+    /* eslint-disable cypress/no-unnecessary-waiting */
+    cy.wait(150); // wait for toastr to change its color occured by mouseover
     cy.screenshot(`${ssPrefix}4-click-done`, {capture: 'viewport'});
 
   });
@@ -203,9 +264,14 @@ context('Tag Oprations', () =>{
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
+    cy.get('#wiki').should('be.visible');
+    // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
+    cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
     cy.screenshot(`${ssPrefix}1-click-tag-name`, {capture: 'viewport'});
 
     cy.getByTestid('open-page-item-control-btn').first().click({force: true});
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(1500); // for wait rendering pagelist info
     cy.screenshot(`${ssPrefix}2-click-three-dots-menu`, {capture: 'viewport'});
 
     cy.getByTestid('open-page-duplicate-modal-btn').first().click({force: true});
@@ -233,24 +299,29 @@ context('Tag Oprations', () =>{
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
+    cy.get('#wiki').should('be.visible');
     cy.screenshot(`${ssPrefix}1-click-tag-name`, {capture: 'viewport'});
 
     cy.getByTestid('search-result-list').within(() => {
       cy.get('.list-group-item').each(($row) => {
         if($row.find('a').text() === oldPageName){
           cy.wrap($row).within(() => {
-            cy.getByTestid('open-page-item-control-btn').click();
+            cy.getByTestid('open-page-item-control-btn').first().click();
+            cy.getByTestid('page-item-control-menu').should('have.class', 'show').first().within(() => {
+            // eslint-disable-next-line cypress/no-unnecessary-waiting
+            cy.wait(300);
+            cy.screenshot(`${ssPrefix}2-open-page-item-control-menu`);
+            })
           });
         }
       });
     });
-    cy.screenshot(`${ssPrefix}2-click-three-dots-menu`, {capture: 'viewport'});
 
     cy.getByTestid('search-result-list').within(() => {
       cy.get('.list-group-item').each(($row) => {
         if($row.find('a').text() === oldPageName){
           cy.wrap($row).within(() => {
-            cy.getByTestid('open-page-move-rename-modal-btn').click();
+            cy.getByTestid('open-page-move-rename-modal-btn').click({force: true});
           });
         }
       });
@@ -265,6 +336,7 @@ context('Tag Oprations', () =>{
     });
 
     cy.visit(`/${newPageName}`);
+    cy.getByTestid('grw-tag-labels').should('be.visible');
     cy.screenshot(`${ssPrefix}4-new-page-name-applied`, {capture: 'viewport'});
   });
 

+ 14 - 10
packages/app/test/cypress/integration/21-basic-features-for-guest/access-to-page.spec.ts

@@ -1,18 +1,18 @@
 context('Access to page by guest', () => {
   const ssPrefix = 'access-to-page-by-guest-';
 
-  beforeEach(() => {
-    // collapse sidebar
-    cy.collapseSidebar(true);
-  });
-
   it('/Sandbox is successfully loaded', () => {
     cy.visit('/Sandbox', {  });
+    cy.collapseSidebar(true, true);
     cy.screenshot(`${ssPrefix}-sandbox`);
   });
 
   it('/Sandbox with anchor hash is successfully loaded', () => {
     cy.visit('/Sandbox#Headers');
+    cy.collapseSidebar(true, true);
+
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(500);
 
     // hide fab
     cy.getByTestid('grw-fab-container').invoke('attr', 'style', 'display: none');
@@ -22,14 +22,22 @@ context('Access to page by guest', () => {
 
   it('/Sandbox/Math is successfully loaded', () => {
     cy.visit('/Sandbox/Math');
+    cy.collapseSidebar(true, true);
 
     cy.get('mjx-container').should('be.visible');
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(2000); // wait for 2 seconds for MathJax.typesetPromise();
 
     cy.screenshot(`${ssPrefix}-sandbox-math`);
   });
 
   it('/Sandbox with edit is successfully loaded', () => {
     cy.visit('/Sandbox#edit');
+    cy.collapseSidebar(true, true);
+
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(1000);
+
     cy.screenshot(`${ssPrefix}-sandbox-edit-page`);
   })
 
@@ -55,13 +63,9 @@ context('Access to /me page', () => {
 context('Access to special pages by guest', () => {
   const ssPrefix = 'access-to-special-pages-by-guest-';
 
-  beforeEach(() => {
-    // collapse sidebar
-    cy.collapseSidebar(true);
-  });
-
   it('/trash is successfully loaded', () => {
     cy.visit('/trash', {  });
+    cy.collapseSidebar(true, true);
     cy.getByTestid('trash-page-list').should('be.visible');
     cy.screenshot(`${ssPrefix}-trash`);
   });

+ 53 - 12
packages/app/test/cypress/integration/30-search/search.spec.ts

@@ -16,8 +16,10 @@ context('Access to search result page', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-
-    cy.screenshot(`${ssPrefix}-with-q`);
+    cy.get('#wiki').should('be.visible');
+    // for avoid mismatch by auto scrolling
+    cy.get('.search-result-content-body-container').scrollTo('top');
+    cy.screenshot(`${ssPrefix}with-q`);
   });
 
   it('checkboxes behaviors', () => {
@@ -26,27 +28,31 @@ context('Access to search result page', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
+    cy.get('#wiki').should('be.visible');
+    // for avoid mismatch by auto scrolling
+    cy.get('.search-result-content-body-container').scrollTo('top');
+
+    // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
+    cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
 
     cy.getByTestid('cb-select').first().click({force: true});
-    cy.screenshot(`${ssPrefix}-the-first-checkbox-on`);
+    cy.screenshot(`${ssPrefix}the-first-checkbox-on`);
     cy.getByTestid('cb-select').first().click({force: true});
-    cy.screenshot(`${ssPrefix}-the-first-checkbox-off`);
+    cy.screenshot(`${ssPrefix}the-first-checkbox-off`);
 
     // click select all checkbox
     cy.getByTestid('cb-select-all').click({force: true});
-    cy.screenshot(`${ssPrefix}-the-select-all-checkbox-1`);
+    cy.screenshot(`${ssPrefix}the-select-all-checkbox-1`);
     cy.getByTestid('cb-select').first().click({force: true});
-    cy.screenshot(`${ssPrefix}-the-select-all-checkbox-2`);
+    cy.screenshot(`${ssPrefix}the-select-all-checkbox-2`);
     cy.getByTestid('cb-select').first().click({force: true});
-    cy.screenshot(`${ssPrefix}-the-select-all-checkbox-3`);
+    cy.screenshot(`${ssPrefix}the-select-all-checkbox-3`);
     cy.getByTestid('cb-select-all').click({force: true});
-    cy.screenshot(`${ssPrefix}-the-select-all-checkbox-4`);
+    cy.screenshot(`${ssPrefix}the-select-all-checkbox-4`);
   });
 
 });
 
-
-
 context('Access to legacy private pages', () => {
   const ssPrefix = 'access-to-legacy-private-pages-directly-';
 
@@ -63,8 +69,9 @@ context('Access to legacy private pages', () => {
     cy.visit('/_private-legacy-pages');
 
     cy.getByTestid('search-result-base').should('be.visible');
+    cy.getByTestid('search-result-private-legacy-pages').should('be.visible');
 
-    cy.screenshot(`${ssPrefix}-shown`);
+    cy.screenshot(`${ssPrefix}shown`);
   });
 
 });
@@ -98,9 +105,20 @@ context('Search all pages', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
+    cy.get('#wiki').should('be.visible');
+    // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
+    cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
+    // for avoid mismatch by auto scrolling
+    cy.get('.search-result-content-body-container').scrollTo('top');
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(1500);
     cy.screenshot(`${ssPrefix}3-search-page-results`, { capture: 'viewport'});
 
     cy.getByTestid('open-page-item-control-btn').eq(1).click();
+    cy.getByTestid('search-result-content').should('be.visible');
+    cy.get('#wiki').should('be.visible');
+    // for avoid mismatch by auto scrolling
+    cy.get('.search-result-content-body-container').scrollTo('top');
     cy.screenshot(`${ssPrefix}4-click-three-dots-menu`, {capture: 'viewport'});
 
     //Add bookmark
@@ -158,12 +176,18 @@ context('Search all pages', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-
+    cy.get('#wiki').should('be.visible');
+    // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
+    cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
     cy.screenshot(`${ssPrefix}2-search-with-tag-result`, {capture: 'viewport'});
+
     cy.getByTestid('open-page-item-control-btn').first().click();
+    cy.getByTestid('search-result-content').should('be.visible');
+    cy.get('#wiki').should('be.visible');
     cy.screenshot(`${ssPrefix}3-click-three-dots-menu-search-with-tag`, {capture: 'viewport'});
 
   });
+
   it('Successfully order page search results by tag', () => {
     const tag = 'help';
 
@@ -172,6 +196,9 @@ context('Search all pages', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
+    cy.get('#wiki').should('be.visible');
+    // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
+    cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
     cy.screenshot(`${ssPrefix}1-tag-order-click-tag-name`, {capture: 'viewport'});
 
     cy.get('.grw-search-page-nav').within(() => {
@@ -182,6 +209,7 @@ context('Search all pages', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
+    cy.get('#wiki').should('be.visible');
     cy.screenshot(`${ssPrefix}2-tag-order-by-relevance`);
 
     cy.get('.grw-search-page-nav').within(() => {
@@ -192,6 +220,7 @@ context('Search all pages', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
+    cy.get('#wiki').should('be.visible');
     cy.screenshot(`${ssPrefix}3-tag-order-by-creation-date`);
 
     cy.get('.grw-search-page-nav').within(() => {
@@ -202,6 +231,7 @@ context('Search all pages', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
+    cy.get('#wiki').should('be.visible');
     cy.screenshot(`${ssPrefix}4-tag-order-by-last-update-date`);
   });
 
@@ -235,9 +265,20 @@ context('Search current tree with "prefix":', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
+    cy.get('#wiki').should('be.visible');
+    // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
+    cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
+    // for avoid mismatch by auto scrolling
+    cy.get('.search-result-content-body-container').scrollTo('top');
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(1500);
     cy.screenshot(`${ssPrefix}3-search-page-results`, { capture: 'viewport'});
 
     cy.getByTestid('open-page-item-control-btn').first().click();
+    cy.getByTestid('search-result-content').should('be.visible');
+    cy.get('#wiki').should('be.visible');
+    // for avoid mismatch by auto scrolling
+    cy.get('.search-result-content-body-container').scrollTo('top');
     cy.screenshot(`${ssPrefix}4-click-three-dots-menu`, {capture: 'viewport'});
   });
 

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

@@ -51,6 +51,8 @@ context('Access to Admin page', () => {
   it('/admin/customize is successfully loaded', () => {
     cy.visit('/admin/customize');
     cy.getByTestid('admin-customize').should('be.visible');
+    /* eslint-disable cypress/no-unnecessary-waiting */
+    cy.wait(500); // wait for loading layout image
     cy.screenshot(`${ssPrefix}-admin-customize`);
   });
 

+ 21 - 3
packages/app/test/cypress/integration/50-sidebar/access-to-side-bar.spec.ts

@@ -27,13 +27,20 @@ context('Access to sidebar', () => {
     });
 
     cy.getByTestid('grw-recent-changes').should('be.visible');
+    cy.get('.list-group-item').should('be.visible');
 
-    cy.getByTestid('grw-contextual-navigation-sub').screenshot(`${ssPrefix}recent-changes-1-page-list`);
+    // Avoid blackout misalignment
+    cy.scrollTo('center');
+    cy.screenshot(`${ssPrefix}recent-changes-1-page-list`);
 
     cy.get('#grw-sidebar-contents-wrapper').within(() => {
       cy.get('#recentChangesResize').click({force: true});
-      cy.screenshot(`${ssPrefix}recent-changes-2-switch-sidebar-size`);
+      cy.get('.list-group-item').should('be.visible');
     });
+
+    // Avoid blackout misalignment
+    cy.scrollTo('center');
+    cy.screenshot(`${ssPrefix}recent-changes-2-switch-sidebar-size`);
   });
 
   it('Successfully create a custom sidebar page', () => {
@@ -52,8 +59,17 @@ context('Access to sidebar', () => {
     cy.get('.grw-sidebar-content-header.h5').find('a').click();
     cy.get('.CodeMirror textarea').type(content, {force: true});
     cy.screenshot(`${ssPrefix}custom-sidebar-2-custom-sidebar-editor`);
-    cy.get('.dropup > .btn-submit').click();
+    cy.getByTestid('save-page-btn').click();
     cy.get('body').should('not.have.class', 'on-edit');
+
+    // What to do when UserUISettings is not saved in time
+    cy.getByTestid('grw-sidebar-nav-primary-custom-sidebar').then(($el) => {
+      if (!$el.hasClass('active')) {
+        cy.wrap($el).click();
+      }
+    });
+
+    cy.get('.grw-custom-sidebar-content').should('be.visible');
     cy.getByTestid('grw-contextual-navigation-sub').screenshot(`${ssPrefix}custom-sidebar-3-custom-sidebar-created`);
   });
 
@@ -142,6 +158,7 @@ context('Access to sidebar', () => {
 
   it('Successfully access to My Drafts page', () => {
     cy.visit('/');
+    cy.collapseSidebar(true);
     cy.get('.grw-sidebar-nav-secondary-container').within(() => {
       cy.get('a[href*="/me/drafts"]').click();
     });
@@ -159,6 +176,7 @@ context('Access to sidebar', () => {
 
   it('Successfully access to trash page', () => {
     cy.visit('/');
+    cy.collapseSidebar(true);
     cy.get('.grw-sidebar-nav-secondary-container').within(() => {
       cy.get('a[href*="/trash"]').click();
     });

+ 8 - 3
packages/app/test/cypress/integration/50-sidebar/switching-sidebar-mode.spec.ts

@@ -21,13 +21,18 @@ context('Switch sidebar mode', () => {
 
   it('Switching sidebar mode', () => {
     cy.visit('/');
-    cy.get('.grw-apperance-mode-dropdown').click();
+    cy.collapseSidebar(true, true)
+    cy.get('.grw-apperance-mode-dropdown').first().click();
 
     cy.get('[for="swSidebarMode"]').click({force: true});
-    cy.screenshot(`${ssPrefix}-switch-sidebar-mode`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-switch-sidebar-mode`, {
+      blackout: ['#revision-toc', '[data-hide-in-vrt=true]'],
+    })
 
     cy.get('[for="swSidebarMode"]').click({force: true});
-    cy.screenshot(`${ssPrefix}-switch-sidebar-mode-back`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-switch-sidebar-mode-back`, {
+      blackout: ['#revision-toc', '[data-hide-in-vrt=true]'],
+    })
   });
 
 });

+ 2 - 2
packages/app/test/cypress/support/commands.ts

@@ -40,9 +40,9 @@ Cypress.Commands.add('login', (username, password) => {
 
 let isSidebarCollapsed: boolean | undefined;
 
-Cypress.Commands.add('collapseSidebar', (isCollapsed) => {
+Cypress.Commands.add('collapseSidebar', (isCollapsed, force=false) => {
 
-  if (isSidebarCollapsed === isCollapsed) {
+  if (!force && isSidebarCollapsed === isCollapsed) {
     return;
   }
 

+ 1 - 1
packages/app/test/cypress/support/index.ts

@@ -36,7 +36,7 @@ declare global {
     interface Chainable {
        getByTestid(selector: string, options?: Partial<Loggable & Timeoutable & Withinable & Shadow>): Chainable<JQuery<Element>>,
        login(username: string, password: string): Chainable<void>,
-       collapseSidebar(isCollapsed: boolean): Chainable<void>,
+       collapseSidebar(isCollapsed: boolean, force?: boolean): Chainable<void>,
     }
   }
 }

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

@@ -24,6 +24,7 @@ export * from './interfaces/tag';
 export * from './interfaces/user';
 export * from './plugin/service/tag-cache-manager';
 export * from './models/devided-page-path';
+export * from './models/vo/error-apiv3';
 export * from './service/localstorage-manager';
 export * from './utils/basic-interceptor';
 export * from './utils/browser-utils';

+ 15 - 0
packages/core/src/models/vo/error-apiv3.ts

@@ -0,0 +1,15 @@
+export class ErrorV3 extends Error {
+
+  code: string;
+
+  args?: any;
+
+  constructor(message = '', code = '', stack = undefined, args = undefined) {
+    super(); // do not provide message to the super constructor
+    this.message = message;
+    this.code = code;
+    this.stack = stack;
+    this.args = args;
+  }
+
+}

+ 1 - 1
packages/core/src/utils/template-checker.ts

@@ -3,7 +3,7 @@
  */
 
 export function checkTemplatePath(path: string): boolean {
-  if (path.match(/.*\/_{1,2}template$/)) {
+  if (path.match(/^.*\/_{1,2}template$/)) {
     return true;
   }