2
0
Эх сурвалжийг харах

Merge branch 'master' into feat/rich-attachment

ryoji-s 2 жил өмнө
parent
commit
904335a259
100 өөрчлөгдсөн 3156 нэмэгдсэн , 405 устгасан
  1. 11 9
      .devcontainer/Dockerfile
  2. 2 2
      .devcontainer/docker-compose.yml
  3. 1 0
      .gitattributes
  4. 1 0
      README.md
  5. 1 0
      README_JP.md
  6. 1 0
      apps/app/.env.development
  7. 3 2
      apps/app/package.json
  8. BIN
      apps/app/public/static/fonts/Lato-Bold-latin-ext.woff2
  9. BIN
      apps/app/public/static/fonts/Lato-Bold-latin.woff2
  10. BIN
      apps/app/public/static/fonts/Lato-Regular-latin-ext.woff2
  11. BIN
      apps/app/public/static/fonts/Lato-Regular-latin.woff2
  12. BIN
      apps/app/public/static/fonts/PressStart2P-latin-ext.woff2
  13. BIN
      apps/app/public/static/fonts/PressStart2P-latin.woff2
  14. 11 1
      apps/app/public/static/locales/en_US/admin.json
  15. 32 6
      apps/app/public/static/locales/en_US/commons.json
  16. 0 5
      apps/app/public/static/locales/en_US/meta.json
  17. 57 4
      apps/app/public/static/locales/en_US/translation.json
  18. 11 1
      apps/app/public/static/locales/ja_JP/admin.json
  19. 32 7
      apps/app/public/static/locales/ja_JP/commons.json
  20. 0 5
      apps/app/public/static/locales/ja_JP/meta.json
  21. 55 3
      apps/app/public/static/locales/ja_JP/translation.json
  22. 11 1
      apps/app/public/static/locales/zh_CN/admin.json
  23. 32 6
      apps/app/public/static/locales/zh_CN/commons.json
  24. 0 5
      apps/app/public/static/locales/zh_CN/meta.json
  25. 53 3
      apps/app/public/static/locales/zh_CN/translation.json
  26. 3 0
      apps/app/resource/fonts/PressStart2P-latin.woff2
  27. 3 0
      apps/app/resource/fonts/SourceHanCodeJP-Regular.woff2
  28. 1 1
      apps/app/resource/locales/en_US/sandbox.md
  29. 1 1
      apps/app/resource/locales/ja_JP/sandbox.md
  30. 1 1
      apps/app/resource/locales/zh_CN/sandbox.md
  31. 0 120
      apps/app/resource/search/mappings-es6.json
  32. 2 2
      apps/app/resource/search/mappings-es8-for-ci.json
  33. 115 0
      apps/app/resource/search/mappings-es8.json
  34. 0 1
      apps/app/src/client/services/AdminGoogleSecurityContainer.js
  35. 14 3
      apps/app/src/client/services/page-operation.ts
  36. 46 0
      apps/app/src/client/util/bookmark-utils.ts
  37. 32 0
      apps/app/src/client/util/input-validator.ts
  38. 8 0
      apps/app/src/components/Admin/App/AppSettingsPageContents.tsx
  39. 113 0
      apps/app/src/components/Admin/App/QuestionnaireSettings.tsx
  40. 2 3
      apps/app/src/components/Admin/Common/AdminUpdateButtonRow.tsx
  41. 25 32
      apps/app/src/components/BookmarkButtons.tsx
  42. 297 0
      apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx
  43. 63 0
      apps/app/src/components/Bookmarks/BookmarkFolderItemControl.tsx
  44. 199 0
      apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx
  45. 27 0
      apps/app/src/components/Bookmarks/BookmarkFolderMenuItem.tsx
  46. 30 0
      apps/app/src/components/Bookmarks/BookmarkFolderNameInput.tsx
  47. 85 0
      apps/app/src/components/Bookmarks/BookmarkFolderTree.module.scss
  48. 134 0
      apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx
  49. 162 0
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  50. 23 0
      apps/app/src/components/Bookmarks/BookmarkMoveToRootBtn.tsx
  51. 73 0
      apps/app/src/components/Bookmarks/DragAndDropWrapper.tsx
  52. 9 16
      apps/app/src/components/Common/ClosableTextInput.tsx
  53. 3 1
      apps/app/src/components/Common/Dropdown/PageItemControl.tsx
  54. 70 0
      apps/app/src/components/DeleteBookmarkFolderModal.tsx
  55. 17 0
      apps/app/src/components/Icons/CompressIcon.tsx
  56. 18 0
      apps/app/src/components/Icons/ExpandIcon.tsx
  57. 37 0
      apps/app/src/components/Icons/FolderIcon.tsx
  58. 16 0
      apps/app/src/components/Icons/FolderPlusIcon.tsx
  59. 1 3
      apps/app/src/components/Icons/TriangleIcon.tsx
  60. 2 0
      apps/app/src/components/Layout/BasicLayout.tsx
  61. 102 0
      apps/app/src/components/Me/OtherSettings.tsx
  62. 16 2
      apps/app/src/components/Me/PersonalSettings.jsx
  63. 32 9
      apps/app/src/components/Navbar/PersonalDropdown.jsx
  64. 5 19
      apps/app/src/components/Navbar/SubNavButtons.tsx
  65. 1 2
      apps/app/src/components/NotFoundPage.tsx
  66. 1 1
      apps/app/src/components/PageAttachment.tsx
  67. 3 3
      apps/app/src/components/PageAttachment/PageAttachmentList.tsx
  68. 25 7
      apps/app/src/components/PageComment/CommentEditor.tsx
  69. 9 1
      apps/app/src/components/PageEditor.tsx
  70. 1 1
      apps/app/src/components/PageEditor/CodeMirrorEditor.jsx
  71. 3 1
      apps/app/src/components/PageEditor/CodeMirrorEditor.module.scss
  72. 2 2
      apps/app/src/components/PageEditor/DrawioModal.tsx
  73. 0 79
      apps/app/src/components/PageList/BookmarkList.tsx
  74. 5 1
      apps/app/src/components/PageList/PageListItemL.tsx
  75. 5 2
      apps/app/src/components/PageList/PageListItemS.tsx
  76. 7 1
      apps/app/src/components/PrivateLegacyPages.tsx
  77. 8 0
      apps/app/src/components/ReactMarkdownComponents/CodeBlock.tsx
  78. 4 3
      apps/app/src/components/ReactMarkdownComponents/NextLink.tsx
  79. 28 0
      apps/app/src/components/Sidebar/Bookmarks.tsx
  80. 59 0
      apps/app/src/components/Sidebar/Bookmarks/BookmarkContents.tsx
  81. 15 20
      apps/app/src/components/Sidebar/PageTree/Item.tsx
  82. 4 0
      apps/app/src/components/Sidebar/SidebarContents.tsx
  83. 1 0
      apps/app/src/components/Sidebar/SidebarNav.tsx
  84. 0 1
      apps/app/src/components/StaffCredit/StaffCredit.module.scss
  85. 9 1
      apps/app/src/components/StaffCredit/StaffCredit.tsx
  86. 96 0
      apps/app/src/components/UsersHomePageFooter.module.scss
  87. 21 5
      apps/app/src/components/UsersHomePageFooter.tsx
  88. 152 0
      apps/app/src/features/questionnaire/client/components/ProactiveQuestionnaireModal.tsx
  89. 47 0
      apps/app/src/features/questionnaire/client/components/Question.tsx
  90. 165 0
      apps/app/src/features/questionnaire/client/components/QuestionnaireModal.tsx
  91. 9 0
      apps/app/src/features/questionnaire/client/components/QuestionnaireModalManager.module.scss
  92. 46 0
      apps/app/src/features/questionnaire/client/components/QuestionnaireModalManager.tsx
  93. 85 0
      apps/app/src/features/questionnaire/client/components/QuestionnaireToast.tsx
  94. 79 0
      apps/app/src/features/questionnaire/client/services/guest-questionnaire-answer-status.ts
  95. 40 0
      apps/app/src/features/questionnaire/client/stores/model.tsx
  96. 23 0
      apps/app/src/features/questionnaire/client/stores/questionnaire.tsx
  97. 4 0
      apps/app/src/features/questionnaire/interfaces/answer.ts
  98. 25 0
      apps/app/src/features/questionnaire/interfaces/condition.ts
  99. 59 0
      apps/app/src/features/questionnaire/interfaces/growi-info.ts
  100. 14 0
      apps/app/src/features/questionnaire/interfaces/proactive-questionnaire-answer.ts

+ 11 - 9
.devcontainer/Dockerfile

@@ -37,15 +37,17 @@ RUN sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable
 RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
 
 RUN apt-get update \
-   && apt-get -y install --no-install-recommends git-lfs \
-      # Chrome
-      google-chrome-stable \
-      # for Cypress
-      libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb fonts-noto-cjk \
-   # Clean up
-   && apt-get autoremove -y \
-   && apt-get clean -y \
-   && rm -rf /var/lib/apt/lists/*
+    && apt-get -y install --no-install-recommends git-lfs \
+
+    # Uncomment below lines to install Chrome and libs for Cypress
+    # --- works only on AMD64 ---
+    # && apt-get -y install --no-install-recommends google-chrome-stable \
+    #    libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb fonts-noto-cjk \
+
+    # Clean up
+    && apt-get autoremove -y \
+    && apt-get clean -y \
+    && rm -rf /var/lib/apt/lists/*
 ENV DEBIAN_FRONTEND=dialog
 
 RUN yarn global add turbo

+ 2 - 2
.devcontainer/docker-compose.yml

@@ -49,7 +49,7 @@ services:
       context: ../../growi-docker-compose/elasticsearch
       dockerfile: ./Dockerfile
       args:
-        - version=7.17.9
+        - version=8.7.0
     restart: unless-stopped
     ports:
       - 9200:9200
@@ -67,7 +67,7 @@ services:
 
   #need to adjust kibana version based on elasticsearch version (use same version as elasticsearch version)
   kibana:
-    image: docker.elastic.co/kibana/kibana:7.17.9
+    image: docker.elastic.co/kibana/kibana:8.7.0
     restart: unless-stopped
     environment:
       ELASTICSEARCH_HOSTS: 'http://elasticsearch:9200'

+ 1 - 0
.gitattributes

@@ -1 +1,2 @@
 *.gz filter=lfs diff=lfs merge=lfs -text
+*.woff2 filter=lfs diff=lfs merge=lfs -text

+ 1 - 0
README.md

@@ -83,6 +83,7 @@ See [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/ad
 - Node.js v16.x or v18.x
 - npm 6.x
 - yarn
+- [Turborepo](https://turbo.build/repo)
 - MongoDB 4.x
 
 ### Optional Dependencies

+ 1 - 0
README_JP.md

@@ -82,6 +82,7 @@ Crowi からの移行は **[こちら](https://docs.growi.org/en/admin-guide/mig
 - Node.js v16.x or v18.x
 - npm 6.x
 - yarn
+- [Turborepo](https://turbo.build/repo)
 - MongoDB 4.x
 
 ### オプションの依存関係

+ 1 - 0
apps/app/.env.development

@@ -16,6 +16,7 @@ ELASTICSEARCH_REJECT_UNAUTHORIZED=true
 HACKMD_URI="http://localhost:3010"
 HACKMD_URI_FOR_SERVER="http://hackmd:3000"
 OGP_URI="http://ogp:8088"
+QUESTIONNAIRE_SERVER_ORIGIN="http://host.docker.internal:3003"
 # DRAWIO_URI="http://localhost:8080/?offline=1&https=0"
 # S2SMSG_PUBSUB_SERVER_TYPE=nchan
 # PUBLISH_OPEN_API=true

+ 3 - 2
apps/app/package.json

@@ -55,14 +55,14 @@
     "@aws-sdk/client-s3": "^3.58.0",
     "@aws-sdk/s3-request-presigner": "^3.58.0",
     "@browser-bunyan/console-formatted-stream": "^1.8.0",
-    "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.8",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
+    "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@growi/core": "^6.1.0-RC.0",
     "@growi/hackmd": "^6.1.0-RC.0",
-    "@growi/remark-attachment-refs": "^6.1.0-RC.0",
     "@growi/preset-themes": "^6.1.0-RC.0",
+    "@growi/remark-attachment-refs": "^6.1.0-RC.0",
     "@growi/remark-drawio": "^6.1.0-RC.0",
     "@growi/remark-growi-directive": "^6.1.0-RC.0",
     "@growi/remark-lsx": "^6.1.0-RC.0",
@@ -133,6 +133,7 @@
     "next-superjson": "^0.0.4",
     "next-themes": "^0.2.1",
     "nocache": "^3.0.1",
+    "node-cron": "^3.0.2",
     "nodemailer": "^6.6.2",
     "nodemailer-ses-transport": "~1.5.0",
     "openid-client": "^5.4.0",

BIN
apps/app/public/static/fonts/Lato-Bold-latin-ext.woff2


BIN
apps/app/public/static/fonts/Lato-Bold-latin.woff2


BIN
apps/app/public/static/fonts/Lato-Regular-latin-ext.woff2


BIN
apps/app/public/static/fonts/Lato-Regular-latin.woff2


BIN
apps/app/public/static/fonts/PressStart2P-latin-ext.woff2


BIN
apps/app/public/static/fonts/PressStart2P-latin.woff2


+ 11 - 1
apps/app/public/static/locales/en_US/admin.json

@@ -387,7 +387,17 @@
     "enable": "Enable",
     "disable": "Disable",
     "use_env_var_if_empty": "If the value in the database is empty, the value of the environment variable <code>{{variable}}</code> is used.",
-    "note_for_the_only_env_option": "The GCS Settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> ."
+    "note_for_the_only_env_option": "The GCS Settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
+    "questionnaire_settings": "Questionnaire settings",
+    "questionnaire_settings_explanation": "This will enable/disable questionnaires on the whole system. When enabled, users can also enable/disable questionnaires individually from \"Other Settings\" in the personal settings page.",
+    "about_data_sent": "About information sent",
+    "docs_link": "https://docs.growi.org/en/admin-guide/management-cookbook/app-settings.html#questionnaire-settings",
+    "learn_more": "Learn more",
+    "other_info_will_be_sent": "Along with the questionnaire answer, information necessary to improve GROWI will be sent. Personal user info will not be included in the data sent.",
+    "we_will_use_the_data_to_improve_growi": "We will use the data to improve GROWI experience as much as possible.",
+    "anonymize_app_site_url": "Anonymize app site URL in data sent",
+    "url_anonymization_explanation": "The app site URL included in the questionnaire answer will be anonymized. By enabling this, the GROWI application that sends the questionnaire answer will not be identified.",
+    "enable_questionnaire": "Enable questionnaire"
   },
   "markdown_settings": {
     "markdown_settings": "Markdown Settings",

+ 32 - 6
apps/app/public/static/locales/en_US/commons.json

@@ -10,13 +10,14 @@
     "display_name": "English"
   },
   "toaster": {
-    "create_succeeded": "Succeeded to create {{target}}",
+    "add_succeeded": "Succeeded to add {{target}}",
     "create_failed": "Failed to create {{target}}",
-    "update_successed": "Succeeded to update {{target}}",
-    "update_failed": "Failed to update {{target}}",
-
+    "create_succeeded": "Succeeded to create {{target}}",
+    "delete_succeeded": "Succeeded to delete {{target}}",
+    "remove_share_link": "Succeeded to remove {{count}} share links",
     "remove_share_link_success": "Succeeded to remove {{shareLinkId}}",
-    "remove_share_link": "Succeeded to remove {{count}} share links"
+    "update_failed": "Failed to update {{target}}",
+    "update_successed": "Succeeded to update {{target}}"
   },
   "alert": {
     "siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the {{link}}",
@@ -60,7 +61,8 @@
     "color_mode": "Color mode",
     "sidebar_mode": "Sidebar mode",
     "sidebar_mode_editor": "Sidebar mode on editor",
-    "use_os_settings": "Use OS settings"
+    "use_os_settings": "Use OS settings",
+    "feedback": "Feedback"
   },
 
   "copy_to_clipboard": {
@@ -95,6 +97,30 @@
     }
   },
 
+  "questionnaire_modal": {
+    "required": "Required",
+    "submit": "Submit",
+    "close": "Close",
+    "title": "GROWI questionnaire for service improvement",
+    "more_satisfied_services": "We hope that GROWI customers will be even more satisfied",
+    "strive_to_improve_services": "once we improve our services based on your feedback.",
+    "length_of_experience": {
+      "more_than_two_years": "More than 2 years",
+      "one_to_two_years": "More than 1 year but less than 2 years",
+      "six_months_to_one_year": "More than 6 months but less than 1 year",
+      "three_months_to_six_months": "More than 3 months but less than 6 months",
+      "one_month_to_three_months": "More than 1 month but less than 3 months",
+      "less_than_one_month": "Less than 1 month"
+    },
+    "satisfaction_with_growi": "Satisfaction with GROWI",
+    "history_of_growi_usage": "History of GROWI usage",
+    "occupation": "Occupation",
+    "position": "Position",
+    "comment_on_growi": "Comment on GROWI",
+    "successfully_submitted": "Your survey has been submitted.",
+    "thanks_for_answering": "Thank you very much for answering."
+  },
+
   "not_found_page": {
     "page_not_exist": "This page does not exist."
   },

+ 0 - 5
apps/app/public/static/locales/en_US/meta.json

@@ -1,5 +0,0 @@
-{
-  "id": "en_US",
-  "displayName": "English",
-  "aliases": ["en"]
-}

+ 57 - 4
apps/app/public/static/locales/en_US/translation.json

@@ -50,6 +50,8 @@
   "Sign up with Google Account": "Sign up with Google Account",
   "Sign in with Google Account": "Sign in with Google Account",
   "Sign up with this Google Account": "Sign up with this Google Account",
+  "Select": "Select",
+  "Required": "Required",
   "Example": "Example",
   "Taro Yamada": "John Doe",
   "List View": "List",
@@ -135,6 +137,7 @@
   "edited this page": "edited this page.",
   "List Drafts": "Drafts",
   "Deleted Pages": "Deleted Pages",
+  "Questionnaire": "Questionnaire",
   "Disassociate": "Disassociate",
   "No bookmarks yet": "No bookmarks yet",
   "add_bookmark": "Add to Bookmarks",
@@ -155,8 +158,12 @@
     "error_message": "Some values ​​are incorrect",
     "required": "%s is required",
     "invalid_syntax": "The syntax of %s is invalid.",
-    "title_required": "Title is required."
+    "title_required": "Title is required.",
+    "field_required": "{{target}} is required"
   },
+  "page_name": "Page name",
+  "folder_name": "Folder name",
+  "field": "field",
   "not_creatable_page": {
     "could_not_creata_path": "Couldn't create path."
   },
@@ -232,6 +239,7 @@
     "link_sharing_is_disabled": "Link sharing is disabled"
   },
   "API Settings": "API settings",
+  "Other Settings": "Other Settings",
   "API Token Settings": "API token settings",
   "Current API Token": "Current API token",
   "Update API Token": "Update API token",
@@ -436,8 +444,19 @@
   "toaster": {
     "file_upload_succeeded": "File upload succeeded.",
     "file_upload_failed": "File upload failed.",
-    "save_succeeded": "Saved successfully",
-    "issue_share_link": "Succeeded to issue new share link"
+    "initialize_successed": "Succeeded to initialize {{target}}",
+    "give_user_admin": "Succeeded to give {{username}} admin",
+    "remove_user_admin": "Succeeded to remove {{username}} admin",
+    "activate_user_success": "Succeeded to activating {{username}}",
+    "deactivate_user_success": "Succeeded to deactivate {{username}}",
+    "remove_user_success": "Succeeded to removing {{username}}",
+    "remove_external_user_success": "Succeeded to remove {{accountId}}",
+    "remove_share_link_success": "Succeeded to remove {{shareLinkId}}",
+    "issue_share_link": "Succeeded to issue new share link",
+    "remove_share_link": "Succeeded to remove {{count}} share links",
+    "switch_disable_link_sharing_success": "Succeeded to update share link setting",
+    "failed_to_reset_password":"Failed to reset password",
+    "save_succeeded": "Saved successfully"
   },
   "template": {
     "modal_label": {
@@ -502,7 +521,7 @@
     "discard_changes": "Discard changes of HackMD",
     "integration_failed": "HackMD Integration failed",
     "fail_to_connect": "GROWI client failed to connect to GROWI agent for HackMD.",
-    "check_configuration": "Check your configuration following <a href='https://docs.growi.org/guide/admin-cookbook/integrate-with-hackmd.html'>the manual</a>.",
+    "check_configuration": "Check your configuration following <a href='https://docs.growi.org/en/admin-guide/admin-cookbook/integrate-with-hackmd.html'>the manual</a>.",
     "not_initialized": "HackmdEditor component has not initialized",
     "someone_editing": "Someone editing this page on HackMD",
     "this_page_has_draft": "This page has a draft on HackMD",
@@ -762,10 +781,44 @@
     "bookmarks": "Bookmarks",
     "recently_created": "Recently Created"
   },
+  "bookmark_folder":{
+    "bookmark_folder": "bookmark folder",
+    "bookmark": "bookmark",
+    "delete_modal": {
+      "modal_header_label": "Delete Bookmark Folder",
+      "modal_body_description": "Delete this bookmark folder and its contents",
+      "modal_body_alert": "Deleted folder and its contents cannot be recovered",
+      "modal_footer_button": "Delete Folder"
+    },
+    "input_placeholder": "Input folder name",
+    "new_folder": "New Folder",
+    "delete": "Delete Folder",
+    "drop_item_here": "Drag and drop item here",
+    "cancel_bookmark": "Un-bookmark this page",
+    "move_to_root": "Move to the root",
+    "root": "root (default)"
+  },
   "v5_page_migration": {
     "page_tree_not_avaliable" : "Page tree feature is not available yet.",
     "go_to_settings": "Go to settings to enable the feature"
   },
+  "questionnaire": {
+    "give_us_feedback": "Give us feedback for improvements",
+    "thank_you_for_answering": "Thank you for answering",
+    "additional_feedback": "Send us additional feedback from the user icon dropdown.",
+    "dont_show_again": "Don`t show again",
+    "deny": "Don't answer",
+    "agree": "Agree",
+    "disagree": "Disagree",
+    "answer": "Answer",
+    "no_answer": "No answer",
+    "settings": "Questionnaire settings",
+    "failed_to_send": "Failed to send feedback",
+    "denied": "The questionnaire won't be shown again",
+    "personal_settings_explanation": "Questionnaires for improving GROWI will be shown. If you have other feedbacks, you can send them from the user icon dropdown.",
+    "enable_questionnaire": "Enable questionnaire",
+    "disabled_by_admin": "Questionnaire is disabled by admin"
+  },
   "tag_edit_modal": {
     "edit_tags": "Edit Tags",
     "done": "Done",

+ 11 - 1
apps/app/public/static/locales/ja_JP/admin.json

@@ -395,7 +395,17 @@
     "enable": "有効",
     "disable": "無効",
     "use_env_var_if_empty": "データベース側の値が空の場合、環境変数 <code>{{variable}}</code> の値を利用します",
-    "note_for_the_only_env_option": "現在GCS設定は環境変数の値によって制限されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください"
+    "note_for_the_only_env_option": "現在GCS設定は環境変数の値によって制限されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください",
+    "questionnaire_settings": "アンケート設定",
+    "questionnaire_settings_explanation": "システム全体でアンケート機能を有効/無効にします。有効の場合、各ユーザーはユーザー設定ページの「その他の設定」から個別にアンケート機能を有効/無効にできます。",
+    "about_data_sent": "送信される情報について",
+    "docs_link": "https://docs.growi.org/ja/admin-guide/management-cookbook/app-settings.html#%E3%82%A2%E3%83%B3%E3%82%B1%E3%83%BC%E3%83%88%E8%A8%AD%E5%AE%9A",
+    "learn_more": "詳細",
+    "other_info_will_be_sent": "アンケートの回答と合わせて、GROWI の改善に必要な情報を送信します。送信されるデータにユーザーの個人情報は含まれません。",
+    "we_will_use_the_data_to_improve_growi": "私たちはそれらを活用し、最大限ユーザーの体験を向上させるよう努めます。",
+    "anonymize_app_site_url": "サイト URL を匿名化して送信する",
+    "url_anonymization_explanation": "アンケート回答データに含まれるサイト URL が匿名化されます。この設定を有効にすることで、アンケート回答データの送信元である GROWI アプリケーションが特定されなくなります。",
+    "enable_questionnaire": "アンケートを有効にする"
   },
   "markdown_settings": {
     "markdown_settings": "マークダウン設定",

+ 32 - 7
apps/app/public/static/locales/ja_JP/commons.json

@@ -5,18 +5,18 @@
   "Reset": "リセット",
   "Sign out": "ログアウト",
   "New": "作成",
-
   "meta": {
     "display_name": "日本語"
   },
   "toaster": {
-    "create_succeeded": "新しい{{target}}が作成されました",
+    "add_succeeded": "新しい{{target}}が追加されました",
+    "delete_succeeded": "{{target}} の削除に成功しました",
     "create_failed": "{{target}}の作成に失敗しました",
-    "update_successed": "{{target}}を更新しました",
+    "create_succeeded": "新しい{{target}}が作成されました",
     "update_failed": "{{target}}の更新に失敗しました",
-
-    "remove_share_link_success": "{{shareLinkId}}を削除しました",
-    "remove_share_link": "共有リンクを{{count}}件削除しました"
+    "update_successed": "{{target}}を更新しました",
+    "remove_share_link": "共有リンクを{{count}}件削除しました",
+    "remove_share_link_success": "{{shareLinkId}}を削除しました"
   },
   "alert": {
     "siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
@@ -60,7 +60,8 @@
     "color_mode": "カラーモード",
     "sidebar_mode": "サイドバーモード",
     "sidebar_mode_editor": "サイドバーモード(編集時)",
-    "use_os_settings": "OS設定を利用する"
+    "use_os_settings": "OS設定を利用する",
+    "feedback": "ご意見・ご要望"
   },
 
   "copy_to_clipboard": {
@@ -95,6 +96,30 @@
     }
   },
 
+  "questionnaire_modal": {
+    "required": "必須",
+    "submit": "送信",
+    "close": "閉じる",
+    "title": "GROWI サービス改善のためのアンケート",
+    "more_satisfied_services": "GROWI をご利用の皆さまに更にご満足いただけるよう",
+    "strive_to_improve_services": "皆さまからのご意見を参考にサービス改善に努めてまいります。",
+    "length_of_experience": {
+      "more_than_two_years": "2年以上",
+      "one_to_two_years": "1年以上2年未満",
+      "six_months_to_one_year": "6ヶ月以上1年未満",
+      "three_months_to_six_months": "3ヶ月以上6ヶ月未満",
+      "one_month_to_three_months": "1ヶ月以上3ヶ月未満",
+      "less_than_one_month": "1ヶ月未満"
+    },
+    "satisfaction_with_growi": "GROWI の満足度",
+    "history_of_growi_usage": "GROWI の利用歴",
+    "occupation": "職種",
+    "position": "役職",
+    "comment_on_growi": "GROWI へのコメント",
+    "successfully_submitted": "アンケートの送信が完了しました。",
+    "thanks_for_answering": "アンケートのご回答誠にありがとうございました。"
+  },
+
   "not_found_page": {
     "page_not_exist": "このページは存在しません。"
   },

+ 0 - 5
apps/app/public/static/locales/ja_JP/meta.json

@@ -1,5 +0,0 @@
-{
-  "id": "ja_JP",
-  "displayName": "日本語",
-  "aliases": ["ja"]
-}

+ 55 - 3
apps/app/public/static/locales/ja_JP/translation.json

@@ -47,6 +47,8 @@
   "Sign up with Google Account": "Google で登録",
   "Sign in with Google Account": "Google でログイン",
   "Sign up with this Google Account": "この Google アカウントで登録します",
+  "Select": "選択してください",
+  "Required": "必須",
   "Example": "例",
   "Taro Yamada": "山田 太郎",
   "List View": "リスト表示",
@@ -157,8 +159,12 @@
     "error_message": "いくつかの値が設定されていません",
     "required": "%sに値を入力してください",
     "invalid_syntax": "%sの構文が不正です",
-    "title_required": "タイトルを入力してください"
+    "title_required": "タイトルを入力してください",
+    "field_required": "{{target}}に値を入力してください"
   },
+  "page_name": "ページ名",
+  "folder_name": "フォルダ名",
+  "field": "フィールド",
   "not_creatable_page": {
     "could_not_creata_path": "パスを作成できませんでした。"
   },
@@ -234,6 +240,7 @@
     "link_sharing_is_disabled": "リンクのシェアは無効化されています"
   },
   "API Settings": "API設定",
+  "Other Settings": "その他の設定",
   "API Token Settings": "API Token設定",
   "Current API Token": "現在のAPI Token",
   "Update API Token": "API Tokenを更新",
@@ -470,8 +477,19 @@
   "toaster": {
     "file_upload_succeeded": "ファイルをアップロードしました",
     "file_upload_failed": "ファイルのアップロードに失敗しました",
-    "save_succeeded": "保存に成功しました",
-    "issue_share_link": "共有リンクを作成しました"
+    "initialize_successed": "{{target}}を初期化しました",
+    "give_user_admin": "{{username}}を管理者に設定しました",
+    "remove_user_admin": "{{username}}を管理者から外しました",
+    "activate_user_success": "{{username}}を有効化しました",
+    "deactivate_user_success": "{{username}}を無効化しました",
+    "remove_user_success": "{{username}}を削除しました",
+    "remove_external_user_success": "{{accountId}}を削除しました",
+    "remove_share_link_success": "{{shareLinkId}}を削除しました",
+    "issue_share_link": "共有リンクを作成しました",
+    "remove_share_link": "共有リンクを{{count}}件削除しました",
+    "switch_disable_link_sharing_success": "共有リンクの設定を変更しました",
+    "failed_to_reset_password":"パスワードのリセットに失敗しました",
+    "save_succeeded": "保存に成功しました"
   },
   "template": {
     "modal_label": {
@@ -796,10 +814,44 @@
     "bookmarks": "ブックマーク",
     "recently_created": "最近作成したページ"
   },
+  "bookmark_folder":{
+    "bookmark_folder": "ブックマークフォルダ",
+    "bookmark": "ブックマーク",
+    "delete_modal": {
+      "modal_header_label": "ブックマークフォルダを削除",
+      "modal_body_description": "このブックマークフォルダと配下のブックマークを削除する",
+      "modal_body_alert": "削除されたフォルダとその内容は復元できません",
+      "modal_footer_button": "フォルダを削除"
+    },
+    "input_placeholder": "フォルダ名を入力してください",
+    "new_folder": "新しいフォルダ",
+    "delete": "フォルダを削除",
+    "drop_item_here": "ルートに配置する",
+    "cancel_bookmark": "このページのブックマークを解除",
+    "move_to_root": "ルートに配置する",
+    "root": "root (default)"
+  },
   "v5_page_migration": {
     "page_tree_not_avaliable" : "Page Tree 機能は現在使用できません。",
     "go_to_settings": "設定する"
   },
+  "questionnaire": {
+    "give_us_feedback": "GROWI の改善のために、アンケートにご協力ください",
+    "thank_you_for_answering": "ご回答ありがとうございます",
+    "additional_feedback": "その他ご意見ご要望はユーザーアイコンのドロップダウンからお願い致します。",
+    "dont_show_again": "今後このアンケートを表示しない",
+    "deny": "回答しない",
+    "agree": "そう思う",
+    "disagree": "そう思わない",
+    "answer": "回答する",
+    "no_answer": "わからない",
+    "settings": "アンケート設定",
+    "failed_to_send": "回答送信に失敗しました",
+    "denied": "このアンケートは今後表示されません",
+    "personal_settings_explanation": "GROWI 改善のためのアンケートが表示されるようになります。ご意見ご要望はユーザーアイコンのドロップダウンからお願いいたします。",
+    "enable_questionnaire": "アンケートを有効にする",
+    "disabled_by_admin": "管理者によってアンケートは無効化されています"
+  },
   "tag_edit_modal": {
     "edit_tags": "タグの編集",
     "done": "完了",

+ 11 - 1
apps/app/public/static/locales/zh_CN/admin.json

@@ -395,7 +395,17 @@
     "enable": "启用",
     "disable": "停用",
     "use_env_var_if_empty": "如果数据库中的值为空,则环境变量的值 <cod>{{variable}}</code> 启用。",
-    "note_for_the_only_env_option": "The GCS settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> ."
+    "note_for_the_only_env_option": "The GCS settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
+    "questionnaire_settings": "问卷设置",
+    "questionnaire_settings_explanation": "这将在整个系统上启用/禁用问卷。 启用后,用户还可以在个人设置页面的“其他设置”中单独启用/禁用问卷调查。",
+    "about_data_sent": "关于发送的信息",
+    "docs_link": "https://docs.growi.org/en/admin-guide/management-cookbook/app-settings.html#questionnaire-settings",
+    "learn_more": "细节",
+    "other_info_will_be_sent": "与问卷回答一起,将发送改进 GROWI 所需的信息。个人用户信息将不包含在发送的数据中。",
+    "we_will_use_the_data_to_improve_growi": "我们将使用这些数据尽可能地改善 GROWI 体验。",
+    "anonymize_app_site_url": "在发送的数据中匿名应用程序站点 URL",
+    "url_anonymization_explanation": "问卷答案中包含的应用站点URL将被匿名化,启用后将不会识别发送问卷答案的GROWI应用。",
+    "enable_questionnaire": "启用问卷"
   },
   "markdown_settings": {
     "markdown_settings": "Markdown设置",

+ 32 - 6
apps/app/public/static/locales/zh_CN/commons.json

@@ -10,13 +10,14 @@
     "display_name": "简体中文"
   },
   "toaster": {
-    "create_succeeded": "Succeeded to create {{target}}",
+    "add_succeeded": "Succeeded to add {{target}}",
     "create_failed": "Failed to create {{target}}",
-    "update_successed": "Succeeded to update {{target}}",
-    "update_failed": "Failed to update {{target}}",
-
+    "create_succeeded": "Succeeded to create {{target}}",
+    "delete_succeeded": "Succeeded to delete {{target}}",
+    "remove_share_link": "Succeeded to remove {{count}} share links",
     "remove_share_link_success": "Succeeded to remove {{shareLinkId}}",
-    "remove_share_link": "Succeeded to remove {{count}} share links"
+    "update_failed": "Failed to update {{target}}",
+    "update_successed": "Succeeded to update {{target}}"
   },
   "alert": {
     "siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",
@@ -60,7 +61,8 @@
 		"color_mode": "颜色模式",
 		"sidebar_mode": "边栏模式",
 		"sidebar_mode_editor": "编辑器上的边栏模式",
-		"use_os_settings": "使用操作系统设置"
+		"use_os_settings": "使用操作系统设置",
+    "feedback": "意见和要求"
   },
 
 	"copy_to_clipboard": {
@@ -95,6 +97,30 @@
     }
   },
 
+  "questionnaire_modal": {
+    "required": "必需的",
+    "submit": "发送",
+    "close": "Close",
+    "title": "改善服务的GROWI调查表",
+    "more_satisfied_services": "我们希望让使用GROWI的人更加满意",
+    "strive_to_improve_services": "我们将利用你的反馈来改善我们的服务。",
+    "length_of_experience": {
+      "more_than_two_years": "2年以上",
+      "one_to_two_years": "超过1年但少于2年",
+      "six_months_to_one_year": "超过6个月但少于1年",
+      "three_months_to_six_months": "超过3个月但少于6个月",
+      "one_month_to_three_months": "超过1个月但少于3个月",
+      "less_than_one_month": "不到1个月"
+    },
+    "satisfaction_with_growi": "对GROWI的满意程度",
+    "history_of_growi_usage": "GROWI的使用历史",
+    "occupation": "职位",
+    "position": "职业类型",
+    "comment_on_growi": "关于GROWI的评论",
+    "successfully_submitted": "问卷已经发出。",
+    "thanks_for_answering": "非常感谢您完成问卷调查。"
+  },
+
   "not_found_page": {
     "page_not_exist": "该页面不存在"
   },

+ 0 - 5
apps/app/public/static/locales/zh_CN/meta.json

@@ -1,5 +0,0 @@
-{
-	"id": "zh_CN",
-	"displayName": "简体中文",
-  "aliases": ["zh","zh-HK","zh-CN","zh-TW","zh-hk","zh-cn","zh-tw"]
-}

+ 53 - 3
apps/app/public/static/locales/zh_CN/translation.json

@@ -49,6 +49,8 @@
 	"Sign up with this Google Account": "Sign up with this Google Account",
 	"Example": "例如",
 	"Taro Yamada": "John Doe",
+  "Select": "请选择",
+  "Required": "必需的",
 	"List View": "列表",
 	"Timeline View": "时间线",
   "History": "历史",
@@ -163,8 +165,12 @@
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
 		"invalid_syntax": "%s的语法无效。",
-    "title_required": "标题是必需的。"
+    "title_required": "标题是必需的。",
+    "field_required": "{{target}} 是必需的"
   },
+  "page_name": "页面名称",
+  "folder_name": "文件夹名称",
+  "field": "字段",
   "not_creatable_page": {
     "could_not_creata_path": "无法创建路径"
   },
@@ -224,6 +230,7 @@
 		"password_is_not_set": "密码未设置"
 	},
 	"API Settings": "API设置",
+  "Other Settings": "其他设置",
 	"API Token Settings": "API token 设置",
 	"Current API Token": "当前 API token",
 	"Update API Token": "更新 API token",
@@ -426,6 +433,15 @@
 	"toaster": {
     "file_upload_succeeded": "文件上传成功",
     "file_upload_failed": "文件上传失败",
+    "initialize_successed": "Succeeded to initialize {{target}}",
+		"give_user_admin": "Succeeded to give {{username}} admin",
+    "remove_user_admin": "Succeeded to remove {{username}} admin ",
+		"activate_user_success": "Succeeded to activating {{username}}",
+		"deactivate_user_success": "Succeeded to deactivate {{username}}",
+		"remove_user_success": "Succeeded to removing {{username}} ",
+    "remove_external_user_success": "Succeeded to remove {{accountId}} ",
+    "switch_disable_link_sharing_success": "成功更新分享链接设置",
+    "failed_to_reset_password":"Failed to reset password",
     "save_succeeded": "已成功保存",
     "issue_share_link": "Succeeded to issue new share link"
   },
@@ -492,11 +508,11 @@
 		"discard_changes": "Discard changes of HackMD",
 		"integration_failed": "HackMD Integration failed",
 		"fail_to_connect": "GROWI client failed to connect to GROWI agent for HackMD.",
-		"check_configuration": "Check your configuration following <a href='https://docs.growi.org/guide/admin-cookbook/integrate-with-hackmd.html'>the manual</a>.",
+		"check_configuration": "Check your configuration following <a href='https://docs.growi.org/en/admin-guide/admin-cookbook/integrate-with-hackmd.html'>the manual</a>.",
 		"not_initialized": "HackmdEditor component has not initialized",
 		"someone_editing": "Someone editing this page on HackMD",
     "this_page_has_draft": "This page has a draft on HackMD",
-    "need_to_associate_with_growi_to_use_hackmd_refer_to_this": "若要使用HackMD的多人同时编辑功能,请先关联HackMD和GROWI。详情请参考<a href='https://docs.growi.org/cn/admin-guide/admin-cookbook/integrate-with-hackmd.html'>这里</a>。",
+    "need_to_associate_with_growi_to_use_hackmd_refer_to_this": "若要使用HackMD的多人同时编辑功能,请先关联HackMD和GROWI。详情请参考<a href='https://docs.growi.org/en/admin-guide/admin-cookbook/integrate-with-hackmd.html'>这里</a>。",
     "need_to_make_page": "To use HackMD, please make a new page from the <a href='#edit'>built-in editor.</a>"
   },
   "slack_notification": {
@@ -768,6 +784,40 @@
     "bookmarks": "书签",
     "recently_created": "最近创建页面"
   },
+  "bookmark_folder": {
+    "bookmark_folder": "书签文件夹",
+    "bookmark": "书签",
+    "delete_modal": {
+      "modal_header_label": "删除书签文件夹",
+      "modal_body_description": "删除此书签文件夹及其内容",
+      "modal_body_alert": "已删除的文件夹及其内容无法恢复",
+      "modal_footer_button": "删除文件夹"
+    },
+    "input_placeholder": "输入文件夹名称",
+    "new_folder": "新建文件夹",
+    "delete": "删除文件夹",
+    "drop_item_here": "将项目拖放到此处",
+    "cancel_bookmark": "取消收藏此页面",
+    "move_to_root": "移动到根部",
+    "root": "root (default)"
+  },
+  "questionnaire": {
+    "give_us_feedback": "向我们提供反馈以进行改进",
+    "thank_you_for_answering": "谢谢你的回答",
+    "additional_feedback": "从用户图标下拉菜单向我们发送更多反馈。",
+    "dont_show_again": "不再显示",
+    "deny": "不要回答",
+    "agree": "同意",
+    "disagree": "不同意",
+    "answer": "答案是",
+    "no_answer": "没有答案",
+    "settings": "问卷设置",
+    "failed_to_send": "无法发送反馈",
+    "denied": "问卷不会再显示",
+    "personal_settings_explanation": "将展示改进 GROWI 的问卷。 如果您有其他反馈,可以从用户图标下拉菜单中发送。",
+    "enable_questionnaire": "启用问卷",
+    "disabled_by_admin": "问卷已被管理员禁用"
+  },
   "v5_page_migration": {
     "page_tree_not_avaliable": "Page Tree 功能不可用",
     "go_to_settings": "进入设置,启用该功能"

+ 3 - 0
apps/app/resource/fonts/PressStart2P-latin.woff2

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:965686370a3ddd3956adc0cf955459e32492373fd552ca94338cf8e2a2c932ea
+size 12440

+ 3 - 0
apps/app/resource/fonts/SourceHanCodeJP-Regular.woff2

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d4b582d043b48510614205db1f1bc1519a70acbe0d89f53843e9131fe7f655b8
+size 3333108

+ 1 - 1
apps/app/resource/locales/en_US/sandbox.md

@@ -401,7 +401,7 @@ Content Cell,Content Cell
 
 # :memo: Footnote
 
-You can write a reference [^1] to a footnote. You can also add an inline footnote^[Inline_footnote].
+You can write a reference [^1] to a footnote.
 
 Long footnotes can be written as [^longnote].
 

+ 1 - 1
apps/app/resource/locales/ja_JP/sandbox.md

@@ -400,7 +400,7 @@ Content Cell,Content Cell
 
 # :memo: Footnote
 
-脚注への参照[^1]を書くことができます。また、インラインの脚注^[インラインで記述できる脚注です]を入れる事も出来ます。
+脚注への参照[^1]を書くことができます。
 
 長い脚注は[^longnote]のように書くことができます。
 

+ 1 - 1
apps/app/resource/locales/zh_CN/sandbox.md

@@ -401,7 +401,7 @@ Content Cell,Content Cell
 
 # :memo: Footnote
 
-You can write a reference [^1] to a footnote. You can also add an inline footnote^[Inline_footnote].
+You can write a reference [^1] to a footnote.
 
 Long footnotes can be written as [^longnote].
 

+ 0 - 120
apps/app/resource/search/mappings-es6.json

@@ -1,120 +0,0 @@
-{
-  "settings": {
-    "analysis": {
-      "filter": {
-        "english_stop": {
-          "type":       "stop",
-          "stopwords":  "_english_"
-        }
-      },
-      "tokenizer": {
-        "edge_ngram_tokenizer": {
-          "type": "edge_ngram",
-          "min_gram": 2,
-          "max_gram": 20,
-          "token_chars": ["letter", "digit"]
-        }
-      },
-      "analyzer": {
-        "japanese": {
-          "tokenizer": "kuromoji_tokenizer",
-          "char_filter" : ["icu_normalizer"]
-        },
-        "english_edge_ngram": {
-          "tokenizer": "edge_ngram_tokenizer",
-          "filter": [
-            "lowercase",
-            "english_stop"
-          ]
-        }
-      }
-    }
-  },
-  "mappings": {
-    "pages": {
-      "properties" : {
-        "path": {
-          "type": "text",
-          "fields": {
-            "raw": {
-              "type": "text",
-              "analyzer": "keyword"
-            },
-            "ja": {
-              "type": "text",
-              "analyzer": "japanese"
-            },
-            "en": {
-              "type": "text",
-              "analyzer": "english_edge_ngram",
-              "search_analyzer": "standard"
-            }
-          }
-        },
-        "body": {
-          "type": "text",
-          "fields": {
-            "ja": {
-              "type": "text",
-              "analyzer": "japanese"
-            },
-            "en": {
-              "type": "text",
-              "analyzer": "english_edge_ngram",
-              "search_analyzer": "standard"
-            }
-          }
-        },
-        "comments": {
-          "type": "text",
-          "fields": {
-            "ja": {
-              "type": "text",
-              "analyzer": "japanese"
-            },
-            "en": {
-              "type": "text",
-              "analyzer": "english_edge_ngram",
-              "search_analyzer": "standard"
-            }
-          }
-        },
-        "username": {
-          "type": "keyword"
-        },
-        "comment_count": {
-          "type": "integer"
-        },
-        "bookmark_count": {
-          "type": "integer"
-        },
-        "seenUsers_count":{
-          "type": "integer"
-        },
-        "like_count": {
-          "type": "integer"
-        },
-        "grant": {
-          "type": "integer"
-        },
-        "granted_users": {
-          "type": "keyword"
-        },
-        "granted_group": {
-          "type": "keyword"
-        },
-        "created_at": {
-          "type": "date",
-          "format": "dateOptionalTime"
-        },
-        "updated_at": {
-          "type": "date",
-          "format": "dateOptionalTime"
-        },
-        "tag_names": {
-          "type": "keyword"
-        }
-      }
-    }
-  }
-}

+ 2 - 2
apps/app/resource/search/mappings-es7-for-ci.json → apps/app/resource/search/mappings-es8-for-ci.json

@@ -104,11 +104,11 @@
       },
       "created_at": {
         "type": "date",
-        "format": "dateOptionalTime"
+        "format": "date_optional_time"
       },
       "updated_at": {
         "type": "date",
-        "format": "dateOptionalTime"
+        "format": "date_optional_time"
       },
       "tag_names": {
         "type": "keyword"

+ 115 - 0
apps/app/resource/search/mappings-es8.json

@@ -0,0 +1,115 @@
+{
+  "settings": {
+    "analysis": {
+      "filter": {
+        "english_stop": {
+          "type":       "stop",
+          "stopwords":  "_english_"
+        }
+      },
+      "tokenizer": {
+        "edge_ngram_tokenizer": {
+          "type": "edge_ngram",
+          "min_gram": 2,
+          "max_gram": 20,
+          "token_chars": ["letter", "digit"]
+        }
+      },
+      "analyzer": {
+        "japanese": {
+          "tokenizer": "kuromoji_tokenizer",
+          "char_filter" : ["icu_normalizer"]
+        },
+        "english_edge_ngram": {
+          "tokenizer": "edge_ngram_tokenizer",
+          "filter": [
+            "lowercase",
+            "english_stop"
+          ]
+        }
+      }
+    }
+  },
+  "mappings": {
+    "properties" : {
+      "path": {
+        "type": "text",
+        "fields": {
+          "raw": {
+            "type": "text",
+            "analyzer": "keyword"
+          },
+          "ja": {
+            "type": "text",
+            "analyzer": "japanese"
+          },
+          "en": {
+            "type": "text",
+            "analyzer": "english_edge_ngram",
+            "search_analyzer": "standard"
+          }
+        }
+      },
+      "body": {
+        "type": "text",
+        "fields": {
+          "ja": {
+            "type": "text",
+            "analyzer": "japanese"
+          },
+          "en": {
+            "type": "text",
+            "analyzer": "english_edge_ngram",
+            "search_analyzer": "standard"
+          }
+        }
+      },
+      "comments": {
+        "type": "text",
+        "fields": {
+          "ja": {
+            "type": "text",
+            "analyzer": "japanese"
+          },
+          "en": {
+            "type": "text",
+            "analyzer": "english_edge_ngram",
+            "search_analyzer": "standard"
+          }
+        }
+      },
+      "username": {
+        "type": "keyword"
+      },
+      "comment_count": {
+        "type": "integer"
+      },
+      "bookmark_count": {
+        "type": "integer"
+      },
+      "like_count": {
+        "type": "integer"
+      },
+      "grant": {
+        "type": "integer"
+      },
+      "granted_users": {
+        "type": "keyword"
+      },
+      "granted_group": {
+        "type": "keyword"
+      },
+      "created_at": {
+        "type": "date",
+        "format": "date_optional_time"
+      },
+      "updated_at": {
+        "type": "date",
+        "format": "date_optional_time"
+      },
+      "tag_names": {
+        "type": "keyword"
+      }
+    }
+  }
+}

+ 0 - 1
apps/app/src/client/services/AdminGoogleSecurityContainer.js

@@ -90,7 +90,6 @@ export default class AdminGoogleSecurityContainer extends Container {
    */
   async updateGoogleSetting() {
     const { googleClientId, googleClientSecret, isSameEmailTreatedAsIdenticalUser } = this.state;
-    console.log('updateGoogleSetting', isSameEmailTreatedAsIdenticalUser);
 
     let requestParams = {
       googleClientId, googleClientSecret, isSameEmailTreatedAsIdenticalUser,

+ 14 - 3
apps/app/src/client/services/page-operation.ts

@@ -176,7 +176,11 @@ export const useSaveOrUpdate = (): SaveOrUpdateFunction => {
   }, [mutateIsEnabledUnsavedWarning]);
 };
 
-export const useUpdateStateAfterSave = (pageId: string|undefined|null): (() => Promise<void>) | undefined => {
+export type UpdateStateAfterSaveOption = {
+  supressEditingMarkdownMutation: boolean,
+}
+
+export const useUpdateStateAfterSave = (pageId: string|undefined|null, opts?: UpdateStateAfterSaveOption): (() => Promise<void>) | undefined => {
   const { mutate: mutateCurrentPageId } = useCurrentPageId();
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
@@ -198,7 +202,12 @@ export const useUpdateStateAfterSave = (pageId: string|undefined|null): (() => P
 
     if (updatedPage == null) { return }
 
-    mutateEditingMarkdown(updatedPage.revision.body);
+    // supress to mutate only when updated from built-in editor
+    // and see: https://github.com/weseek/growi/pull/7118
+    const supressEditingMarkdownMutation = opts?.supressEditingMarkdownMutation ?? false;
+    if (!supressEditingMarkdownMutation) {
+      mutateEditingMarkdown(updatedPage.revision.body);
+    }
 
     const remoterevisionData = {
       remoteRevisionId: updatedPage.revision._id,
@@ -210,7 +219,9 @@ export const useUpdateStateAfterSave = (pageId: string|undefined|null): (() => P
     };
 
     setRemoteLatestPageData(remoterevisionData);
-  }, [mutateCurrentPage, mutateCurrentPageId, mutateEditingMarkdown, mutateTagsInfo, pageId, setRemoteLatestPageData, syncTagsInfoForEditor]);
+  },
+  // eslint-disable-next-line max-len
+  [pageId, mutateTagsInfo, syncTagsInfoForEditor, mutateCurrentPageId, mutateCurrentPage, opts?.supressEditingMarkdownMutation, setRemoteLatestPageData, mutateEditingMarkdown]);
 };
 
 export const unlink = async(path: string): Promise<void> => {

+ 46 - 0
apps/app/src/client/util/bookmark-utils.ts

@@ -0,0 +1,46 @@
+import { IRevision, Ref } from '@growi/core';
+
+import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
+
+import { apiv3Delete, apiv3Post, apiv3Put } from './apiv3-client';
+
+// Check if bookmark folder item has children
+export const hasChildren = (item: BookmarkFolderItems | BookmarkFolderItems[]): boolean => {
+  if (item === null) {
+    return false;
+  }
+  if (Array.isArray(item)) {
+    return item.length > 0;
+  }
+  return item.children && item.children.length > 0;
+};
+
+// Add new folder helper
+export const addNewFolder = async(name: string, parent: string | null): Promise<void> => {
+  await apiv3Post('/bookmark-folder', { name, parent });
+};
+
+// Put bookmark to a folder
+export const addBookmarkToFolder = async(pageId: string, folderId: string | null): Promise<void> => {
+  await apiv3Post('/bookmark-folder/add-boookmark-to-folder', { pageId, folderId });
+};
+
+// Delete bookmark folder
+export const deleteBookmarkFolder = async(bookmarkFolderId: string): Promise<void> => {
+  await apiv3Delete(`/bookmark-folder/${bookmarkFolderId}`);
+};
+
+// Rename page from bookmark item control
+export const renamePage = async(pageId: string, revisionId: Ref<IRevision>, newPagePath: string): Promise<void> => {
+  await apiv3Put('/pages/rename', { pageId, revisionId, newPagePath });
+};
+
+// Update bookmark by isBookmarked status
+export const toggleBookmark = async(pageId: string, status: boolean): Promise<void> => {
+  await apiv3Put('/bookmark-folder/update-bookmark', { pageId, status });
+};
+
+// Update Bookmark folder
+export const updateBookmarkFolder = async(bookmarkFolderId: string, name: string, parent: string | null): Promise<void> => {
+  await apiv3Put('/bookmark-folder', { bookmarkFolderId, name, parent });
+};

+ 32 - 0
apps/app/src/client/util/input-validator.ts

@@ -0,0 +1,32 @@
+export const AlertType = {
+  WARNING: 'warning',
+  ERROR: 'error',
+} as const;
+
+export type AlertType = typeof AlertType[keyof typeof AlertType];
+
+export const ValidationTarget = {
+  FOLDER: 'folder_name',
+  PAGE: 'page_name',
+  DEFAULT: 'field',
+};
+
+export type ValidationTarget = typeof ValidationTarget[keyof typeof ValidationTarget];
+
+export type AlertInfo = {
+  type?: AlertType
+  message?: string,
+  target?: string
+}
+
+export const inputValidator = async(title: string | null, target?: string): Promise<AlertInfo | null> => {
+  const validationTarget = target || ValidationTarget.DEFAULT;
+  if (title == null || title === '' || title.trim() === '') {
+    return {
+      type: AlertType.WARNING,
+      message: 'form_validation.field_required',
+      target: validationTarget,
+    };
+  }
+  return null;
+};

+ 8 - 0
apps/app/src/components/Admin/App/AppSettingsPageContents.tsx

@@ -14,6 +14,7 @@ import AppSetting from './AppSetting';
 import FileUploadSetting from './FileUploadSetting';
 import MailSetting from './MailSetting';
 import { MaintenanceMode } from './MaintenanceMode';
+import QuestionnaireSettings from './QuestionnaireSettings';
 import SiteUrlSetting from './SiteUrlSetting';
 import V5PageMigration from './V5PageMigration';
 
@@ -107,6 +108,13 @@ const AppSettingsPageContents = (props: Props) => {
         </div>
       </div>
 
+      <div className="row mt-5">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header">{t('admin:app_setting.questionnaire_settings')}</h2>
+          <QuestionnaireSettings />
+        </div>
+      </div>
+
       <div className="row">
         <div className="col-lg-12">
           <h2 className="admin-setting-header" id="maintenance-mode">{t('admin:maintenance_mode.maintenance_mode')}</h2>

+ 113 - 0
apps/app/src/components/Admin/App/QuestionnaireSettings.tsx

@@ -0,0 +1,113 @@
+import {
+  useState, useCallback, useEffect,
+} from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useSWRxAppSettings } from '~/stores/admin/app-settings';
+
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+const QuestionnaireSettings = (): JSX.Element => {
+  const { t } = useTranslation(['admin', 'commons']);
+
+  const { data, error, mutate } = useSWRxAppSettings();
+
+  const [isQuestionnaireEnabled, setIsQuestionnaireEnabled] = useState(data?.isQuestionnaireEnabled);
+  const onChangeIsQuestionnaireEnabledHandler = useCallback(() => {
+    setIsQuestionnaireEnabled(prev => !prev);
+  }, []);
+
+  const [isAppSiteUrlHashed, setIsAppSiteUrlHashed] = useState(data?.isAppSiteUrlHashed);
+  const onChangeisAppSiteUrlHashedHandler = useCallback(() => {
+    setIsAppSiteUrlHashed(prev => !prev);
+  }, []);
+
+  const onSubmitHandler = useCallback(async() => {
+    try {
+      await apiv3Put('/app-settings/questionnaire-settings', {
+        isQuestionnaireEnabled,
+        isAppSiteUrlHashed,
+      });
+      toastSuccess(t('commons:toaster.update_successed', { target: t('app_setting.questionnaire_settings') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+    mutate();
+  }, [isAppSiteUrlHashed, isQuestionnaireEnabled, mutate, t]);
+
+  // Sync SWR value and state
+  useEffect(() => {
+    setIsQuestionnaireEnabled(data?.isQuestionnaireEnabled);
+    setIsAppSiteUrlHashed(data?.isAppSiteUrlHashed);
+  }, [data, data?.isAppSiteUrlHashed, data?.isQuestionnaireEnabled]);
+
+  const isLoading = data === undefined && error === undefined;
+
+  return (
+    <div id="questionnaire-settings" className="mb-5">
+      <p className="card well">
+        <div className="mb-4">{t('app_setting.questionnaire_settings_explanation')}</div>
+        <span>
+          <div className="mb-2">
+            <span className="text-info mr-2"><i className="icon-info icon-fw"></i>{t('app_setting.about_data_sent')}</span>
+            <a href={t('app_setting.docs_link')} rel="noreferrer" target="_blank" className="d-inline">
+              {t('app_setting.learn_more')} <i className="icon-share-alt"></i>
+            </a>
+          </div>
+          {t('app_setting.other_info_will_be_sent')}<br />
+          {t('app_setting.we_will_use_the_data_to_improve_growi')}
+        </span>
+      </p>
+
+      {isLoading && <div className="text-muted text-center mb-5">
+        <i className="fa fa-2x fa-spinner fa-pulse mr-1" />
+      </div>}
+
+      {!isLoading && (
+        <>
+          <div className="row my-3">
+            <div className="custom-control custom-switch custom-checkbox-info col-md-5 offset-md-5">
+              <input
+                type="checkbox"
+                className="custom-control-input"
+                id="isQuestionnaireEnabled"
+                checked={isQuestionnaireEnabled}
+                onChange={onChangeIsQuestionnaireEnabledHandler}
+              />
+              <label className="custom-control-label" htmlFor="isQuestionnaireEnabled">
+                {t('app_setting.enable_questionnaire')}
+              </label>
+            </div>
+          </div>
+
+          <div className="row my-4">
+            <div className="custom-control custom-checkbox custom-checkbox-info col-md-5 offset-md-5">
+              <input
+                type="checkbox"
+                className="custom-control-input"
+                id="isAppSiteUrlHashed"
+                checked={isAppSiteUrlHashed}
+                onChange={onChangeisAppSiteUrlHashedHandler}
+                disabled={!isQuestionnaireEnabled}
+              />
+              <label className="custom-control-label" htmlFor="isAppSiteUrlHashed">
+                {t('app_setting.anonymize_app_site_url')}
+              </label>
+              <p className="form-text text-muted small">
+                {t('app_setting.url_anonymization_explanation')}
+              </p>
+            </div>
+          </div>
+
+          <AdminUpdateButtonRow onClick={onSubmitHandler}/>
+        </>
+      )}
+    </div>
+  );
+};
+
+export default QuestionnaireSettings;

+ 2 - 3
apps/app/src/components/Admin/Common/AdminUpdateButtonRow.tsx

@@ -4,8 +4,7 @@ import { useTranslation } from 'next-i18next';
 
 type Props = {
   onClick: () => void,
-  disabled: boolean,
-
+  disabled?: boolean,
 }
 
 const AdminUpdateButtonRow = (props: Props): JSX.Element => {
@@ -14,7 +13,7 @@ const AdminUpdateButtonRow = (props: Props): JSX.Element => {
   return (
     <div className="row my-3">
       <div className="mx-auto">
-        <button type="button" className="btn btn-primary" onClick={props.onClick} disabled={props.disabled}>{ t('Update') }</button>
+        <button type="button" className="btn btn-primary" onClick={props.onClick} disabled={props.disabled ?? false}>{ t('Update') }</button>
       </div>
     </div>
   );

+ 25 - 32
apps/app/src/components/BookmarkButtons.tsx

@@ -1,29 +1,32 @@
-import React, { FC, useState, useCallback } from 'react';
+import React, {
+  FC, useState, useCallback,
+} from 'react';
 
 import { useTranslation } from 'next-i18next';
-import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
+import {
+  UncontrolledTooltip, Popover, PopoverBody, DropdownToggle,
+} from 'reactstrap';
 
+import { IBookmarkInfo } from '~/interfaces/bookmark-info';
 import { useIsGuestUser } from '~/stores/context';
 
 import { IUser } from '../interfaces/user';
 
+import { BookmarkFolderMenu } from './Bookmarks/BookmarkFolderMenu';
 import UserPictureList from './User/UserPictureList';
 
 import styles from './BookmarkButtons.module.scss';
 
 interface Props {
-  bookmarkCount?: number
-  isBookmarked?: boolean
   bookmarkedUsers?: IUser[]
   hideTotalNumber?: boolean
-  onBookMarkClicked: ()=>void;
+  bookmarkInfo? : IBookmarkInfo
 }
 
-const BookmarkButtons: FC<Props> = (props: Props) => {
+export const BookmarkButtons: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
-
   const {
-    bookmarkCount, isBookmarked, bookmarkedUsers, hideTotalNumber,
+    bookmarkedUsers, hideTotalNumber, bookmarkInfo,
   } = props;
 
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
@@ -34,33 +37,25 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
     setIsPopoverOpen(!isPopoverOpen);
   };
 
-  const handleClick = async() => {
-    if (props.onBookMarkClicked != null) {
-      props.onBookMarkClicked();
-    }
-  };
-
   const getTooltipMessage = useCallback(() => {
 
-    if (isBookmarked) {
-      return 'tooltip.cancel_bookmark';
+    if (isGuestUser) {
+      return 'Not available for guest';
     }
     return 'tooltip.bookmark';
-  }, [isBookmarked]);
+  }, [isGuestUser]);
+
 
   return (
     <div className={`btn-group btn-group-bookmark ${styles['btn-group-bookmark']}`} role="group" aria-label="Bookmark buttons">
-      <button
-        type="button"
-        id="bookmark-button"
-        onClick={handleClick}
-        className={`shadow-none btn btn-bookmark border-0
-          ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
-      >
-        <i className={`fa ${isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
-      </button>
-
-      <UncontrolledTooltip data-testid="bookmark-button-tooltip" placement="top" target="bookmark-button" fade={false}>
+      <BookmarkFolderMenu >
+        <DropdownToggle id='bookmark-dropdown-btn' color="transparent" className={`shadow-none btn btn-bookmark border-0
+          ${bookmarkInfo?.isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}>
+          <i className={`fa ${bookmarkInfo?.isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
+        </DropdownToggle>
+      </BookmarkFolderMenu>
+
+      <UncontrolledTooltip placement="top" data-testid="bookmark-button-tooltip" target="bookmark-dropdown-btn" fade={false}>
         {t(getTooltipMessage())}
       </UncontrolledTooltip>
 
@@ -70,9 +65,9 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
             type="button"
             id="po-total-bookmarks"
             className={`shadow-none btn btn-bookmark border-0
-              total-bookmarks ${props.isBookmarked ? 'active' : ''}`}
+              total-bookmarks ${bookmarkInfo?.isBookmarked ? 'active' : ''}`}
           >
-            {bookmarkCount ?? 0}
+            {bookmarkInfo?.sumOfBookmarks ?? 0}
           </button>
           { bookmarkedUsers != null && (
             <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-bookmarks" toggle={togglePopover} trigger="legacy">
@@ -88,5 +83,3 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
     </div>
   );
 };
-
-export default BookmarkButtons;

+ 297 - 0
apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx

@@ -0,0 +1,297 @@
+import {
+  FC, useCallback, useState,
+} from 'react';
+
+import { DropdownToggle } from 'reactstrap';
+
+import {
+  addBookmarkToFolder, addNewFolder, hasChildren, updateBookmarkFolder,
+} from '~/client/util/bookmark-utils';
+import { toastError } from '~/client/util/toastr';
+import { FolderIcon } from '~/components/Icons/FolderIcon';
+import { TriangleIcon } from '~/components/Icons/TriangleIcon';
+import {
+  BookmarkFolderItems, DragItemDataType, DragItemType, DRAG_ITEM_TYPE,
+} from '~/interfaces/bookmark-info';
+import { IPageToDeleteWithMeta } from '~/interfaces/page';
+import { onDeletedBookmarkFolderFunction } from '~/interfaces/ui';
+import { useBookmarkFolderDeleteModal } from '~/stores/modal';
+
+import { BookmarkFolderItemControl } from './BookmarkFolderItemControl';
+import { BookmarkFolderNameInput } from './BookmarkFolderNameInput';
+import { BookmarkItem } from './BookmarkItem';
+import { DragAndDropWrapper } from './DragAndDropWrapper';
+
+type BookmarkFolderItemProps = {
+  bookmarkFolder: BookmarkFolderItems
+  isOpen?: boolean
+  level: number
+  root: string
+  isUserHomePage?: boolean
+  onClickDeleteBookmarkHandler: (pageToDelete: IPageToDeleteWithMeta) => void
+  bookmarkFolderTreeMutation: () => void
+}
+
+export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkFolderItemProps) => {
+  const BASE_FOLDER_PADDING = 15;
+  const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
+  const {
+    bookmarkFolder, isOpen: _isOpen = false, level, root, isUserHomePage,
+    onClickDeleteBookmarkHandler, bookmarkFolderTreeMutation,
+  } = props;
+
+  const {
+    name, _id: folderId, children, parent, bookmarks,
+  } = bookmarkFolder;
+
+  const [targetFolder, setTargetFolder] = useState<string | null>(folderId);
+  const [isOpen, setIsOpen] = useState(_isOpen);
+  const [isRenameAction, setIsRenameAction] = useState<boolean>(false);
+  const [isCreateAction, setIsCreateAction] = useState<boolean>(false);
+
+  const { open: openDeleteBookmarkFolderModal } = useBookmarkFolderDeleteModal();
+
+  const childrenExists = hasChildren(children);
+
+  const paddingLeft = BASE_FOLDER_PADDING * level;
+
+  const loadChildFolder = useCallback(async() => {
+    setIsOpen(!isOpen);
+    setTargetFolder(folderId);
+  }, [folderId, isOpen]);
+
+  // Rename for bookmark folder handler
+  const onPressEnterHandlerForRename = useCallback(async(folderName: string) => {
+    try {
+      await updateBookmarkFolder(folderId, folderName, parent);
+      bookmarkFolderTreeMutation();
+      setIsRenameAction(false);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [bookmarkFolderTreeMutation, folderId, parent]);
+
+  // Create new folder / subfolder handler
+  const onPressEnterHandlerForCreate = useCallback(async(folderName: string) => {
+    try {
+      await addNewFolder(folderName, targetFolder);
+      setIsOpen(true);
+      setIsCreateAction(false);
+      bookmarkFolderTreeMutation();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [bookmarkFolderTreeMutation, targetFolder]);
+
+  const onClickPlusButton = useCallback(async(e) => {
+    e.stopPropagation();
+    if (!isOpen && childrenExists) {
+      setIsOpen(true);
+    }
+    setIsCreateAction(true);
+  }, [childrenExists, isOpen]);
+
+  const itemDropHandler = async(item: DragItemDataType, dragItemType: string | symbol | null) => {
+    if (dragItemType === DRAG_ITEM_TYPE.FOLDER) {
+      try {
+        if (item.bookmarkFolder != null) {
+          await updateBookmarkFolder(item.bookmarkFolder._id, item.bookmarkFolder.name, bookmarkFolder._id);
+          bookmarkFolderTreeMutation();
+        }
+      }
+      catch (err) {
+        toastError(err);
+      }
+    }
+    else {
+      try {
+        if (item != null) {
+          await addBookmarkToFolder(item._id, bookmarkFolder._id);
+          bookmarkFolderTreeMutation();
+        }
+      }
+      catch (err) {
+        toastError(err);
+      }
+    }
+  };
+
+  const isDropable = (item: DragItemDataType, type: string | null| symbol): boolean => {
+    if (type === DRAG_ITEM_TYPE.FOLDER) {
+      if (item.bookmarkFolder.parent === bookmarkFolder._id || item.bookmarkFolder._id === bookmarkFolder._id) {
+        return false;
+      }
+
+      // Maximum folder hierarchy of 2 levels
+      // If the drop source folder has child folders, the drop source folder cannot be moved because the drop source folder hierarchy is already 2.
+      // If the destination folder has a parent, the source folder cannot be moved because the destination folder hierarchy is already 2.
+      if (item.bookmarkFolder.children.length !== 0 || bookmarkFolder.parent != null) {
+        return false;
+      }
+
+      return item.root !== root || item.level >= level;
+    }
+
+    if (item.parentFolder != null && item.parentFolder._id === bookmarkFolder._id) {
+      return false;
+    }
+    return true;
+  };
+
+  const renderChildFolder = () => {
+    return isOpen && children?.map((childFolder) => {
+      return (
+        <div key={childFolder._id} className="grw-foldertree-item-children">
+          <BookmarkFolderItem
+            key={childFolder._id}
+            bookmarkFolder={childFolder}
+            level={level + 1}
+            root={root}
+            isUserHomePage={isUserHomePage}
+            onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+            bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
+          />
+        </div>
+      );
+    });
+  };
+
+  const renderBookmarkItem = () => {
+    return isOpen && bookmarks?.map((bookmark) => {
+      return (
+        <BookmarkItem
+          key={bookmark._id}
+          bookmarkedPage={bookmark.page}
+          level={level + 1}
+          parentFolder={bookmarkFolder}
+          canMoveToRoot={true}
+          onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+          bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
+        />
+      );
+    });
+  };
+
+  const onClickRenameHandler = useCallback(() => {
+    setIsRenameAction(true);
+  }, []);
+
+  const onClickDeleteHandler = useCallback(() => {
+    const bookmarkFolderDeleteHandler: onDeletedBookmarkFolderFunction = (folderId) => {
+      if (typeof folderId !== 'string') {
+        return;
+      }
+      bookmarkFolderTreeMutation();
+    };
+
+    if (bookmarkFolder == null) {
+      return;
+    }
+    openDeleteBookmarkFolderModal(bookmarkFolder, { onDeleted: bookmarkFolderDeleteHandler });
+  }, [bookmarkFolder, bookmarkFolderTreeMutation, openDeleteBookmarkFolderModal]);
+
+  const onClickMoveToRootHandlerForBookmarkFolderItemControl = useCallback(async() => {
+    try {
+      await updateBookmarkFolder(bookmarkFolder._id, bookmarkFolder.name, null);
+      bookmarkFolderTreeMutation();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [bookmarkFolder._id, bookmarkFolder.name, bookmarkFolderTreeMutation]);
+
+  return (
+    <div id={`grw-bookmark-folder-item-${folderId}`} className="grw-foldertree-item-container">
+      <DragAndDropWrapper
+        key={folderId}
+        type={acceptedTypes}
+        item={props}
+        useDragMode={true}
+        useDropMode={true}
+        onDropItem={itemDropHandler}
+        isDropable={isDropable}
+      >
+        <li
+          className={'list-group-item list-group-item-action border-0 py-0 pr-3 d-flex align-items-center'}
+          onClick={loadChildFolder}
+          style={{ paddingLeft }}
+        >
+          <div className="grw-triangle-container d-flex justify-content-center">
+            {childrenExists && (
+              <button
+                type="button"
+                className={`grw-foldertree-triangle-btn btn ${isOpen ? 'grw-foldertree-open' : ''}`}
+                onClick={loadChildFolder}
+              >
+                <div className="d-flex justify-content-center">
+                  <TriangleIcon />
+                </div>
+              </button>
+            )}
+          </div>
+          {
+            <div>
+              <FolderIcon isOpen={isOpen} />
+            </div>
+          }
+          {isRenameAction ? (
+            <BookmarkFolderNameInput
+              onClickOutside={() => setIsRenameAction(false)}
+              onPressEnter={onPressEnterHandlerForRename}
+              value={name}
+            />
+          ) : (
+            <>
+              <div className='grw-foldertree-title-anchor pl-2' >
+                <p className={'text-truncate m-auto '}>{name}</p>
+              </div>
+            </>
+          )}
+          <div className="grw-foldertree-control d-flex">
+            <BookmarkFolderItemControl
+              onClickRename={onClickRenameHandler}
+              onClickDelete={onClickDeleteHandler}
+              onClickMoveToRoot={bookmarkFolder.parent != null
+                ? onClickMoveToRootHandlerForBookmarkFolderItemControl
+                : undefined
+              }
+            >
+              <div onClick={e => e.stopPropagation()}>
+                <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">
+                  <i className="icon-options fa fa-rotate-90 p-1"></i>
+                </DropdownToggle>
+              </div>
+            </BookmarkFolderItemControl>
+            {/* Maximum folder hierarchy of 2 levels */}
+            {!(bookmarkFolder.parent != null) && (
+              <button
+                id='create-bookmark-folder-button'
+                type="button"
+                className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
+                onClick={onClickPlusButton}
+              >
+                <i className="icon-plus d-block p-0" />
+              </button>
+            )}
+          </div>
+        </li>
+      </DragAndDropWrapper>
+      {isCreateAction && (
+        <div className="flex-fill">
+          <BookmarkFolderNameInput
+            onClickOutside={() => setIsCreateAction(false)}
+            onPressEnter={onPressEnterHandlerForCreate}
+          />
+        </div>
+      )}
+      {
+        renderChildFolder()
+      }
+      {
+        renderBookmarkItem()
+      }
+    </div>
+  );
+};

+ 63 - 0
apps/app/src/components/Bookmarks/BookmarkFolderItemControl.tsx

@@ -0,0 +1,63 @@
+import React, { useState } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Dropdown, DropdownItem, DropdownMenu, DropdownToggle,
+} from 'reactstrap';
+
+export const BookmarkFolderItemControl: React.FC<{
+  children?: React.ReactNode
+  onClickMoveToRoot?: () => Promise<void>
+  onClickRename: () => void
+  onClickDelete: () => void
+}> = ({
+  children,
+  onClickMoveToRoot,
+  onClickRename,
+  onClickDelete,
+}): JSX.Element => {
+  const { t } = useTranslation();
+  const [isOpen, setIsOpen] = useState(false);
+
+  return (
+    <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
+      { children ?? (
+        <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center">
+          <i className="icon-options"></i>
+        </DropdownToggle>
+      ) }
+      <DropdownMenu
+        modifiers={{ preventOverflow: { boundariesElement: 'viewport' } }}
+        container="body"
+        style={{ zIndex: 1055 }} /* make it larger than $zindex-modal of bootstrap */
+      >
+        {onClickMoveToRoot && (
+          <DropdownItem
+            onClick={onClickMoveToRoot}
+            className="grw-page-control-dropdown-item"
+          >
+            <i className="fa fa-fw fa-bookmark-o grw-page-control-dropdown-icon"></i>
+            {t('bookmark_folder.move_to_root')}
+          </DropdownItem>
+        )}
+        <DropdownItem
+          onClick={onClickRename}
+          className="grw-page-control-dropdown-item"
+        >
+          <i className="icon-fw icon-action-redo grw-page-control-dropdown-icon"></i>
+          {t('Rename')}
+        </DropdownItem>
+
+        <DropdownItem divider/>
+
+        <DropdownItem
+          className='pt-2 grw-page-control-dropdown-item text-danger'
+          onClick={onClickDelete}
+        >
+          <i className="icon-fw icon-trash grw-page-control-dropdown-icon"></i>
+          {t('Delete')}
+        </DropdownItem>
+      </DropdownMenu>
+    </Dropdown>
+  );
+};

+ 199 - 0
apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx

@@ -0,0 +1,199 @@
+import React, { useCallback, useMemo, useState } from 'react';
+
+import { getCustomModifiers } from '@growi/ui/dist/utils';
+import { useTranslation } from 'next-i18next';
+import { DropdownItem, DropdownMenu, UncontrolledDropdown } from 'reactstrap';
+
+import { addBookmarkToFolder, toggleBookmark } from '~/client/util/bookmark-utils';
+import { toastError } from '~/client/util/toastr';
+import { useSWRBookmarkInfo, useSWRxCurrentUserBookmarks } from '~/stores/bookmark';
+import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
+import { useSWRxCurrentPage, useSWRxPageInfo } from '~/stores/page';
+
+import { BookmarkFolderMenuItem } from './BookmarkFolderMenuItem';
+
+export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ children }): JSX.Element => {
+  const { t } = useTranslation();
+
+  const [selectedItem, setSelectedItem] = useState<string | null>(null);
+  const [isOpen, setIsOpen] = useState(false);
+
+  const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild();
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { data: bookmarkInfo, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(currentPage?._id);
+  const { mutate: mutateUserBookmarks } = useSWRxCurrentUserBookmarks();
+  const { mutate: mutatePageInfo } = useSWRxPageInfo(currentPage?._id);
+
+  const isBookmarked = bookmarkInfo?.isBookmarked ?? false;
+
+  const isBookmarkFolderExists = useMemo((): boolean => {
+    return bookmarkFolders != null && bookmarkFolders.length > 0;
+  }, [bookmarkFolders]);
+
+  const toggleBookmarkHandler = useCallback(async() => {
+    try {
+      if (currentPage != null) {
+        await toggleBookmark(currentPage._id, isBookmarked);
+      }
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [currentPage, isBookmarked]);
+
+  const onUnbookmarkHandler = useCallback(async() => {
+    await toggleBookmarkHandler();
+    setIsOpen(false);
+    setSelectedItem(null);
+    mutateUserBookmarks();
+    mutateBookmarkInfo();
+    mutateBookmarkFolders();
+    mutatePageInfo();
+  }, [mutateBookmarkFolders, mutateBookmarkInfo, mutatePageInfo, mutateUserBookmarks, toggleBookmarkHandler]);
+
+  const toggleHandler = useCallback(async() => {
+    setIsOpen(!isOpen);
+
+    if (isOpen && bookmarkFolders != null) {
+      bookmarkFolders.forEach((bookmarkFolder) => {
+        bookmarkFolder.bookmarks.forEach((bookmark) => {
+          if (bookmark.page._id === currentPage?._id) {
+            setSelectedItem(bookmarkFolder._id);
+          }
+        });
+      });
+    }
+
+    if (selectedItem == null) {
+      setSelectedItem('root');
+    }
+
+    if (!isOpen && !isBookmarked) {
+      try {
+        await toggleBookmarkHandler();
+        mutateUserBookmarks();
+        mutateBookmarkInfo();
+        mutatePageInfo();
+      }
+      catch (err) {
+        toastError(err);
+      }
+    }
+  },
+  [isOpen, bookmarkFolders, selectedItem, isBookmarked, currentPage?._id, toggleBookmarkHandler, mutateUserBookmarks, mutateBookmarkInfo, mutatePageInfo]);
+
+  const onMenuItemClickHandler = useCallback(async(e, itemId: string) => {
+    e.stopPropagation();
+
+    setSelectedItem(itemId);
+
+    try {
+      if (isBookmarked) {
+        await toggleBookmarkHandler();
+      }
+      if (currentPage != null) {
+        await addBookmarkToFolder(currentPage._id, itemId === 'root' ? null : itemId);
+      }
+      mutateUserBookmarks();
+      mutateBookmarkFolders();
+      mutateBookmarkInfo();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [mutateBookmarkFolders, isBookmarked, currentPage, mutateBookmarkInfo, mutateUserBookmarks, toggleBookmarkHandler]);
+
+  const renderBookmarkMenuItem = () => {
+    return (
+      <>
+        <DropdownItem
+          toggle={false}
+          onClick={onUnbookmarkHandler}
+          className={'grw-bookmark-folder-menu-item text-danger'}
+        >
+          <i className="fa fa-bookmark"></i>{' '}
+          <span className="mx-2 ">
+            {t('bookmark_folder.cancel_bookmark')}
+          </span>
+        </DropdownItem>
+
+        {isBookmarkFolderExists && (
+          <>
+            <DropdownItem divider />
+            <div key='root'>
+              <div
+                className="dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action border-0 py-0"
+                tabIndex={0}
+                role="menuitem"
+                onClick={e => onMenuItemClickHandler(e, 'root')}
+              >
+                <BookmarkFolderMenuItem
+                  itemId='root'
+                  itemName={t('bookmark_folder.root')}
+                  isSelected={selectedItem === 'root'}
+                />
+              </div>
+            </div>
+            {bookmarkFolders?.map(folder => (
+              <>
+                <div key={folder._id}>
+                  <div
+                    className="dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action border-0 py-0"
+                    style={{ paddingLeft: '40px' }}
+                    tabIndex={0}
+                    role="menuitem"
+                    onClick={e => onMenuItemClickHandler(e, folder._id)}
+                  >
+                    <BookmarkFolderMenuItem
+                      itemId={folder._id}
+                      itemName={folder.name}
+                      isSelected={selectedItem === folder._id}
+                    />
+                  </div>
+                </div>
+                <>
+                  {folder.children?.map(child => (
+                    <div key={child._id}>
+                      <div
+                        className='dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action border-0 py-0'
+                        style={{ paddingLeft: '60px' }}
+                        tabIndex={0}
+                        role="menuitem"
+                        onClick={e => onMenuItemClickHandler(e, child._id)}>
+                        <BookmarkFolderMenuItem
+                          itemId={child._id}
+                          itemName={child.name}
+                          isSelected={selectedItem === child._id}
+                        />
+                      </div>
+                    </div>
+                  ))}
+                </>
+              </>
+            ))}
+          </>
+        )}
+      </>
+    );
+  };
+
+  return (
+    <UncontrolledDropdown
+      isOpen={isOpen}
+      onToggle={toggleHandler}
+      direction={isBookmarkFolderExists ? 'up' : 'down'}
+      className='grw-bookmark-folder-dropdown'
+    >
+      {children}
+      <DropdownMenu
+        right
+        persist
+        positionFixed
+        className='grw-bookmark-folder-menu'
+        modifiers={getCustomModifiers(true)}
+      >
+        { renderBookmarkMenuItem() }
+      </DropdownMenu>
+    </UncontrolledDropdown>
+  );
+};

+ 27 - 0
apps/app/src/components/Bookmarks/BookmarkFolderMenuItem.tsx

@@ -0,0 +1,27 @@
+import React from 'react';
+
+export const BookmarkFolderMenuItem: React.FC<{
+  itemId: string
+  itemName: string
+  isSelected: boolean
+}> = ({
+  itemId,
+  itemName,
+  isSelected,
+}) => {
+  return (
+    <div className='d-flex justify-content-start grw-bookmark-folder-menu-item-title'>
+      <input
+        type="radio"
+        checked={isSelected}
+        name="bookmark-folder-menu-item"
+        id={`bookmark-folder-menu-item-${itemId}`}
+        onChange={e => e.stopPropagation()}
+        onClick={e => e.stopPropagation()}
+      />
+      <label htmlFor={`bookmark-folder-menu-item-${itemId}`} className='p-2 m-0'>
+        {itemName}
+      </label>
+    </div>
+  );
+};

+ 30 - 0
apps/app/src/components/Bookmarks/BookmarkFolderNameInput.tsx

@@ -0,0 +1,30 @@
+import { useTranslation } from 'next-i18next';
+
+import { inputValidator, ValidationTarget } from '~/client/util/input-validator';
+import ClosableTextInput from '~/components/Common/ClosableTextInput';
+
+
+type Props = {
+  onClickOutside: () => void
+  onPressEnter: (folderName: string) => void
+  value?: string
+}
+
+export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
+  const {
+    onClickOutside, onPressEnter, value,
+  } = props;
+  const { t } = useTranslation();
+
+  return (
+    <div className="flex-fill folder-name-input">
+      <ClosableTextInput
+        value={ value }
+        placeholder={t('bookmark_folder.input_placeholder')}
+        onClickOutside={onClickOutside}
+        onPressEnter={onPressEnter}
+        validationTarget={ValidationTarget.FOLDER}
+      />
+    </div>
+  );
+};

+ 85 - 0
apps/app/src/components/Bookmarks/BookmarkFolderTree.module.scss

@@ -0,0 +1,85 @@
+$grw-foldertree-item-padding-left: 15px;
+$grw-bookmark-item-padding-left: 35px;
+
+.grw-folder-tree-container :global {
+  .grw-foldertree-item-container, .grw-drop-item-area {
+    & .grw-accept-drop-item {
+      border-style: dashed !important;
+      border-width: 0.15rem !important;
+    }
+  }
+
+  .grw-drop-item-area {
+    padding: 1rem;
+    & .grw-accept-drop-item {
+      padding: 0.7rem;
+    }
+  }
+  .grw-drag-drop-container > .grw-drop-item-area {
+    margin: 1rem;
+    border-style: dashed !important;
+    border-width: 0.15rem !important;
+  }
+}
+
+.grw-foldertree :global {
+
+  .btn-page-item-control .icon-plus::before {
+    font-size: 18px;
+  }
+
+  .list-group-item {
+    .grw-visible-on-hover {
+      display: none;
+    }
+
+    &:hover {
+      .grw-visible-on-hover {
+        display: block;
+      }
+    }
+
+    .grw-foldertree-triangle-btn {
+      background-color: transparent;
+      transition: all 0.2s ease-out;
+      transform: rotate(0deg);
+
+      &.grw-foldertree-open {
+        transform: rotate(90deg);
+      }
+    }
+
+    .grw-foldertree-title-anchor {
+      width: 100%;
+      overflow: hidden;
+      text-decoration: none;
+    }
+  }
+
+  .grw-foldertree-item-container {
+    .grw-triangle-container {
+      min-width: 35px;
+      height: 40px;
+    }
+
+    .grw-bookmark-item-list{
+      min-width: 30px;
+      height: 35px;
+
+      .picture {
+        width: 16px;
+        height: 16px;
+        vertical-align: text-bottom;
+
+        &.picture-md {
+          width: 20px;
+          height: 20px;
+        }
+      }
+
+      .grw-foldertree-control{
+        margin-left: auto;
+      }
+    }
+  }
+}

+ 134 - 0
apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx

@@ -0,0 +1,134 @@
+
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { toastSuccess } from '~/client/util/toastr';
+import { IPageToDeleteWithMeta } from '~/interfaces/page';
+import { OnDeletedFunction } from '~/interfaces/ui';
+import { useSWRxCurrentUserBookmarks, useSWRBookmarkInfo } from '~/stores/bookmark';
+import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
+import { usePageDeleteModal } from '~/stores/modal';
+import { useSWRxCurrentPage } from '~/stores/page';
+
+import { BookmarkFolderItem } from './BookmarkFolderItem';
+import { BookmarkItem } from './BookmarkItem';
+
+import styles from './BookmarkFolderTree.module.scss';
+
+// type DragItemDataType = {
+//   bookmarkFolder: BookmarkFolderItems
+//   level: number
+//   parentFolder: BookmarkFolderItems | null
+//  } & IPageHasId
+
+export const BookmarkFolderTree: React.FC<{isUserHomePage?: boolean}> = ({ isUserHomePage }) => {
+  // const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
+  const { t } = useTranslation();
+
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(currentPage?._id);
+  const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild();
+  const { data: userBookmarks, mutate: mutateUserBookmarks } = useSWRxCurrentUserBookmarks();
+  const { open: openDeleteModal } = usePageDeleteModal();
+
+  const bookmarkFolderTreeMutation = useCallback(() => {
+    mutateUserBookmarks();
+    mutateBookmarkInfo();
+    mutateBookmarkFolders();
+  }, [mutateBookmarkFolders, mutateBookmarkInfo, mutateUserBookmarks]);
+
+  const onClickDeleteBookmarkHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
+    const pageDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, _isRecursively, isCompletely) => {
+      if (typeof pathOrPathsToDelete !== 'string') return;
+
+      toastSuccess(isCompletely ? t('deleted_pages_completely', { pathOrPathsToDelete }) : t('deleted_pages', { pathOrPathsToDelete }));
+
+      bookmarkFolderTreeMutation();
+    };
+    openDeleteModal([pageToDelete], { onDeleted: pageDeletedHandler });
+  }, [openDeleteModal, t, bookmarkFolderTreeMutation]);
+
+  /* TODO: update in bookmarks folder v2. */
+  // const itemDropHandler = async(item: DragItemDataType, dragType: string | null | symbol) => {
+  //   if (dragType === DRAG_ITEM_TYPE.FOLDER) {
+  //     try {
+  //       await updateBookmarkFolder(item.bookmarkFolder._id, item.bookmarkFolder.name, null);
+  //       await mutateBookmarkData();
+  //       toastSuccess(t('toaster.update_successed', { target: t('bookmark_folder.bookmark_folder'), ns: 'commons' }));
+  //     }
+  //     catch (err) {
+  //       toastError(err);
+  //     }
+  //   }
+  //   else {
+  //     try {
+  //       await addBookmarkToFolder(item._id, null);
+  //       await mutateUserBookmarks();
+  //       toastSuccess(t('toaster.add_succeeded', { target: t('bookmark_folder.bookmark'), ns: 'commons' }));
+  //     }
+  //     catch (err) {
+  //       toastError(err);
+  //     }
+  //   }
+
+  // };
+  // const isDroppable = (item: DragItemDataType, dragType: string | null | symbol) => {
+  //   if (dragType === DRAG_ITEM_TYPE.FOLDER) {
+  //     const isRootFolder = item.level === 0;
+  //     return !isRootFolder;
+  //   }
+  //   const isRootBookmark = item.parentFolder == null;
+  //   return !isRootBookmark;
+
+  // };
+
+  return (
+    <div className={`grw-folder-tree-container ${styles['grw-folder-tree-container']}` } >
+      <ul className={`grw-foldertree ${styles['grw-foldertree']} list-group px-2 py-2`}>
+        {bookmarkFolders?.map((bookmarkFolder) => {
+          return (
+            <BookmarkFolderItem
+              key={bookmarkFolder._id}
+              bookmarkFolder={bookmarkFolder}
+              isOpen={false}
+              level={0}
+              root={bookmarkFolder._id}
+              isUserHomePage={isUserHomePage}
+              onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+              bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
+            />
+          );
+        })}
+        {userBookmarks?.map(userBookmark => (
+          <div key={userBookmark._id} className="grw-foldertree-item-container grw-root-bookmarks">
+            <BookmarkItem
+              key={userBookmark._id}
+              bookmarkedPage={userBookmark}
+              level={0}
+              parentFolder={null}
+              canMoveToRoot={false}
+              onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+              bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
+            />
+          </div>
+        ))}
+      </ul>
+      {/* TODO: update in bookmarks folder v2. Also delete drop_item_here in translation.json, if don't need it. */}
+      {/* {bookmarkFolderData != null && bookmarkFolderData.length > 0 && (
+        <DragAndDropWrapper
+          useDropMode={true}
+          type={acceptedTypes}
+          onDropItem={itemDropHandler}
+          isDropable={isDroppable}
+        >
+          <div className="grw-drop-item-area">
+            <div className="d-flex flex-column align-items-center">
+              {t('bookmark_folder.drop_item_here')}
+            </div>
+          </div>
+        </DragAndDropWrapper>
+      )} */}
+    </div>
+  );
+};

+ 162 - 0
apps/app/src/components/Bookmarks/BookmarkItem.tsx

@@ -0,0 +1,162 @@
+import React, { useCallback, useState } from 'react';
+
+import nodePath from 'path';
+
+import { DevidedPagePath, pathUtils } from '@growi/core';
+import { useTranslation } from 'react-i18next';
+import { UncontrolledTooltip, DropdownToggle } from 'reactstrap';
+
+import { unbookmark } from '~/client/services/page-operation';
+import { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils';
+import { ValidationTarget } from '~/client/util/input-validator';
+import { toastError } from '~/client/util/toastr';
+import { BookmarkFolderItems, DragItemDataType, DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
+import { IPageHasId, IPageInfoAll, IPageToDeleteWithMeta } from '~/interfaces/page';
+import { useSWRxPageInfo } from '~/stores/page';
+
+import ClosableTextInput from '../Common/ClosableTextInput';
+import { MenuItemType, PageItemControl } from '../Common/Dropdown/PageItemControl';
+import { PageListItemS } from '../PageList/PageListItemS';
+
+import { BookmarkMoveToRootBtn } from './BookmarkMoveToRootBtn';
+import { DragAndDropWrapper } from './DragAndDropWrapper';
+
+type Props = {
+  bookmarkedPage: IPageHasId,
+  level: number,
+  parentFolder: BookmarkFolderItems | null,
+  canMoveToRoot: boolean,
+  onClickDeleteBookmarkHandler: (pageToDelete: IPageToDeleteWithMeta) => void,
+  bookmarkFolderTreeMutation: () => void
+}
+
+export const BookmarkItem = (props: Props): JSX.Element => {
+  const BASE_FOLDER_PADDING = 15;
+  const BASE_BOOKMARK_PADDING = 20;
+
+  const { t } = useTranslation();
+
+  const {
+    bookmarkedPage, onClickDeleteBookmarkHandler,
+    parentFolder, level, canMoveToRoot, bookmarkFolderTreeMutation,
+  } = props;
+
+  const [isRenameInputShown, setRenameInputShown] = useState(false);
+
+  const { data: fetchedPageInfo } = useSWRxPageInfo(bookmarkedPage._id);
+
+  const dPagePath = new DevidedPagePath(bookmarkedPage.path, false, true);
+  const { latter: pageTitle, former: formerPagePath } = dPagePath;
+  const bookmarkItemId = `bookmark-item-${bookmarkedPage._id}`;
+  const paddingLeft = BASE_BOOKMARK_PADDING + (BASE_FOLDER_PADDING * (level + 1));
+  const dragItem: Partial<DragItemDataType> = {
+    ...bookmarkedPage, parentFolder,
+  };
+
+  const onClickMoveToRootHandler = useCallback(async() => {
+    try {
+      await addBookmarkToFolder(bookmarkedPage._id, null);
+      bookmarkFolderTreeMutation();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [bookmarkFolderTreeMutation, bookmarkedPage._id]);
+
+  const bookmarkMenuItemClickHandler = useCallback(async() => {
+    await unbookmark(bookmarkedPage._id);
+    bookmarkFolderTreeMutation();
+  }, [bookmarkedPage._id, bookmarkFolderTreeMutation]);
+
+  const renameMenuItemClickHandler = useCallback(() => {
+    setRenameInputShown(true);
+  }, []);
+
+  const pressEnterForRenameHandler = useCallback(async(inputText: string) => {
+    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(bookmarkedPage.path ?? ''));
+    const newPagePath = nodePath.resolve(parentPath, inputText);
+    if (newPagePath === bookmarkedPage.path) {
+      setRenameInputShown(false);
+      return;
+    }
+
+    try {
+      setRenameInputShown(false);
+      await renamePage(bookmarkedPage._id, bookmarkedPage.revision, newPagePath);
+      bookmarkFolderTreeMutation();
+    }
+    catch (err) {
+      setRenameInputShown(true);
+      toastError(err);
+    }
+  }, [bookmarkedPage, bookmarkFolderTreeMutation]);
+
+  const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
+    if (bookmarkedPage._id == null || bookmarkedPage.path == null) {
+      throw Error('_id and path must not be null.');
+    }
+
+    const pageToDelete: IPageToDeleteWithMeta = {
+      data: {
+        _id: bookmarkedPage._id,
+        revision: bookmarkedPage.revision as string,
+        path: bookmarkedPage.path,
+      },
+      meta: pageInfo,
+    };
+
+    onClickDeleteBookmarkHandler(pageToDelete);
+  }, [bookmarkedPage._id, bookmarkedPage.path, bookmarkedPage.revision, onClickDeleteBookmarkHandler]);
+
+  return (
+    <DragAndDropWrapper
+      item={dragItem}
+      type={[DRAG_ITEM_TYPE.BOOKMARK]}
+      useDragMode={true}
+    >
+      <li
+        className="grw-bookmark-item-list list-group-item list-group-item-action border-0 py-0 mr-auto d-flex align-items-center"
+        key={bookmarkedPage._id}
+        id={bookmarkItemId}
+        style={{ paddingLeft }}
+      >
+        { isRenameInputShown ? (
+          <ClosableTextInput
+            value={nodePath.basename(bookmarkedPage.path ?? '')}
+            placeholder={t('Input page name')}
+            onClickOutside={() => { setRenameInputShown(false) }}
+            onPressEnter={pressEnterForRenameHandler}
+            validationTarget={ValidationTarget.PAGE}
+          />
+        ) : <PageListItemS page={bookmarkedPage} pageTitle={pageTitle}/>}
+        <div className='grw-foldertree-control'>
+          <PageItemControl
+            pageId={bookmarkedPage._id}
+            isEnableActions
+            pageInfo={fetchedPageInfo}
+            forceHideMenuItems={[MenuItemType.DUPLICATE]}
+            onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
+            onClickRenameMenuItem={renameMenuItemClickHandler}
+            onClickDeleteMenuItem={deleteMenuItemClickHandler}
+            additionalMenuItemOnTopRenderer={canMoveToRoot
+              ? () => <BookmarkMoveToRootBtn pageId={bookmarkedPage._id} onClickMoveToRootHandler={onClickMoveToRootHandler}/>
+              : undefined}
+          >
+            <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">
+              <i className="icon-options fa fa-rotate-90 p-1"></i>
+            </DropdownToggle>
+          </PageItemControl>
+        </div>
+        <UncontrolledTooltip
+          modifiers={{ preventOverflow: { boundariesElement: 'window' } }}
+          autohide={false}
+          placement="right"
+          target={bookmarkItemId}
+          fade={false}
+        >
+          {formerPagePath !== null ? `${formerPagePath}/` : '/'}
+        </UncontrolledTooltip>
+      </li>
+    </DragAndDropWrapper>
+  );
+};

+ 23 - 0
apps/app/src/components/Bookmarks/BookmarkMoveToRootBtn.tsx

@@ -0,0 +1,23 @@
+import React from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { DropdownItem } from 'reactstrap';
+
+export const BookmarkMoveToRootBtn: React.FC<{
+  pageId: string
+  onClickMoveToRootHandler: (pageId: string) => Promise<void>
+}> = React.memo(({ pageId, onClickMoveToRootHandler }) => {
+  const { t } = useTranslation();
+
+  return (
+    <DropdownItem
+      onClick={() => onClickMoveToRootHandler(pageId)}
+      className="grw-page-control-dropdown-item"
+      data-testid="add-remove-bookmark-btn"
+    >
+      <i className="fa fa-fw fa-bookmark-o grw-page-control-dropdown-icon"></i>
+      {t('bookmark_folder.move_to_root')}
+    </DropdownItem>
+  );
+});
+BookmarkMoveToRootBtn.displayName = 'BookmarkMoveToRootBtn';

+ 73 - 0
apps/app/src/components/Bookmarks/DragAndDropWrapper.tsx

@@ -0,0 +1,73 @@
+import React, { ReactNode } from 'react';
+
+import { useDrag, useDrop } from 'react-dnd';
+
+import { DragItemDataType } from '~/interfaces/bookmark-info';
+
+type DragAndDropWrapperProps = {
+  item?: Partial<DragItemDataType>
+  type: string[]
+  children: ReactNode
+  useDragMode?: boolean
+  useDropMode?: boolean
+  onDropItem?:(item: DragItemDataType, type: string | null | symbol) => Promise<void>
+  isDropable?:(item: Partial<DragItemDataType>, type: string | null | symbol) => boolean
+}
+
+export const DragAndDropWrapper = (props: DragAndDropWrapperProps): JSX.Element => {
+  const {
+    item, children, useDragMode, useDropMode, type, onDropItem, isDropable,
+  } = props;
+
+
+  const acceptedTypes = type;
+  const sourcetype: string | symbol = type[0];
+
+
+  const [, dragRef] = useDrag({
+    type: sourcetype,
+    item,
+    collect: monitor => ({
+      isDragging: monitor.isDragging(),
+      canDrag: monitor.canDrag(),
+    }),
+  });
+
+  const [{ isOver }, dropRef] = useDrop(() => ({
+    accept: acceptedTypes,
+    drop: (item: DragItemDataType, monitor) => {
+      const itemType: string | null | symbol = monitor.getItemType();
+      if (onDropItem != null) {
+        onDropItem(item, itemType);
+      }
+    },
+    canDrop: (item, monitor) => {
+      const itemType: string | null | symbol = monitor.getItemType();
+      if (isDropable != null) {
+        return isDropable(item, itemType);
+      }
+      return false;
+    },
+    collect: monitor => ({
+      isOver: monitor.isOver({ shallow: true }) && monitor.canDrop(),
+    }),
+  }));
+
+
+  const getRef = (c: HTMLDivElement | null) => {
+    if (useDragMode && useDropMode) {
+      return [dragRef(c), dropRef(c)];
+    } if (useDragMode) {
+      return dragRef(c);
+    } if (useDropMode) {
+      return dropRef(c);
+    }
+    return null;
+  };
+
+  return (
+    <div ref={c => getRef(c)} className={`grw-drag-drop-container ${isOver ? 'grw-accept-drop-item' : ''}` }>
+      {children}
+    </div>
+  );
+};

+ 9 - 16
apps/app/src/components/Common/ClosableTextInput.tsx

@@ -4,40 +4,33 @@ import React, {
 
 import { useTranslation } from 'next-i18next';
 
-export const AlertType = {
-  WARNING: 'warning',
-  ERROR: 'error',
-} as const;
-
-export type AlertType = typeof AlertType[keyof typeof AlertType];
-
-export type AlertInfo = {
-  type?: AlertType
-  message?: string
-}
+import { AlertInfo, AlertType, inputValidator } from '~/client/util/input-validator';
 
 type ClosableTextInputProps = {
   value?: string
   placeholder?: string
-  inputValidator?(text: string): AlertInfo | Promise<AlertInfo> | null
+  validationTarget?: string,
   onPressEnter?(inputText: string | null): void
   onClickOutside?(): void
 }
 
 const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextInputProps) => {
   const { t } = useTranslation();
-  const inputRef = useRef<HTMLInputElement>(null);
+  const { validationTarget } = props;
 
+  const inputRef = useRef<HTMLInputElement>(null);
   const [inputText, setInputText] = useState(props.value);
   const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
   const [isAbleToShowAlert, setIsAbleToShowAlert] = useState(false);
   const [isComposing, setComposing] = useState(false);
 
+
   const createValidation = async(inputText: string) => {
-    if (props.inputValidator != null) {
-      const alertInfo = await props.inputValidator(inputText);
-      setAlertInfo(alertInfo);
+    const alertInfo = await inputValidator(inputText, validationTarget);
+    if (alertInfo && alertInfo.message != null && alertInfo.target != null) {
+      alertInfo.message = t(alertInfo.message, { target: t(alertInfo.target) });
     }
+    setAlertInfo(alertInfo);
   };
 
   const onChangeHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {

+ 3 - 1
apps/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -1,4 +1,6 @@
-import React, { useState, useCallback, useEffect } from 'react';
+import React, {
+  useState, useCallback, useEffect,
+} from 'react';
 
 import { getCustomModifiers } from '@growi/ui/dist/utils';
 import { useTranslation } from 'next-i18next';

+ 70 - 0
apps/app/src/components/DeleteBookmarkFolderModal.tsx

@@ -0,0 +1,70 @@
+
+import React, { FC } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Modal, ModalBody, ModalFooter, ModalHeader,
+} from 'reactstrap';
+
+import { deleteBookmarkFolder } from '~/client/util/bookmark-utils';
+import { toastError } from '~/client/util/toastr';
+import { FolderIcon } from '~/components/Icons/FolderIcon';
+import { useBookmarkFolderDeleteModal } from '~/stores/modal';
+
+
+const DeleteBookmarkFolderModal: FC = () => {
+  const { t } = useTranslation();
+  const { data: deleteBookmarkFolderModalData, close: closeBookmarkFolderDeleteModal } = useBookmarkFolderDeleteModal();
+  const isOpened = deleteBookmarkFolderModalData?.isOpened ?? false;
+
+  async function deleteBookmark() {
+    if (deleteBookmarkFolderModalData == null || deleteBookmarkFolderModalData.bookmarkFolder == null) {
+      return;
+    }
+    if (deleteBookmarkFolderModalData.bookmarkFolder != null) {
+      try {
+        await deleteBookmarkFolder(deleteBookmarkFolderModalData.bookmarkFolder._id);
+        const onDeleted = deleteBookmarkFolderModalData.opts?.onDeleted;
+        if (onDeleted != null) {
+          onDeleted(deleteBookmarkFolderModalData.bookmarkFolder._id);
+        }
+        closeBookmarkFolderDeleteModal();
+      }
+      catch (err) {
+        toastError(err);
+      }
+    }
+  }
+  async function onClickDeleteButton() {
+    await deleteBookmark();
+  }
+
+  return (
+    <Modal size="md" isOpen={isOpened} toggle={closeBookmarkFolderDeleteModal} data-testid="page-delete-modal" className="grw-create-page">
+      <ModalHeader tag="h4" toggle={closeBookmarkFolderDeleteModal} className="bg-danger text-light">
+        <i className="icon-fw icon-trash"></i>
+        {t('bookmark_folder.delete_modal.modal_header_label')}
+      </ModalHeader>
+      <ModalBody>
+        <div className="form-group pb-1">
+          <label>{ t('bookmark_folder.delete_modal.modal_body_description') }:</label><br />
+          <FolderIcon isOpen={false}/> {deleteBookmarkFolderModalData?.bookmarkFolder?.name}
+        </div>
+        {t('bookmark_folder.delete_modal.modal_body_alert')}
+      </ModalBody>
+      <ModalFooter>
+        <button
+          type="button"
+          className="btn btn-danger"
+          onClick={onClickDeleteButton}
+        >
+          <i className="mr-1 icon-trash" aria-hidden="true"></i>
+          {t('bookmark_folder.delete_modal.modal_footer_button')}
+        </button>
+      </ModalFooter>
+    </Modal>
+
+  );
+};
+
+export { DeleteBookmarkFolderModal };

+ 17 - 0
apps/app/src/components/Icons/CompressIcon.tsx

@@ -0,0 +1,17 @@
+import React from 'react';
+
+export const CompressIcon = ():JSX.Element => {
+  return (
+    <svg xmlns="http://www.w3.org/2000/svg"
+      width="18"
+      height="18"
+      viewBox="0 0 45 45"
+    >
+      <path
+        fill="currentColor"
+        d="M22.45 44v-7.9l-3.85 3.8-2.1-2.1 7.45-7.4 7.35 7.4-2.1
+            2.1-3.75-3.8V44ZM8.05 27.5v-3H40v3Zm0-6.05v-3H40v3Zm15.9-5.85-7.4-7.4 2.1-2.1
+            3.75 3.8V2h3v7.9l3.85-3.8 2.1 2.1Z"/>
+    </svg>
+  );
+};

+ 18 - 0
apps/app/src/components/Icons/ExpandIcon.tsx

@@ -0,0 +1,18 @@
+import React from 'react';
+
+export const ExpandIcon = (): JSX.Element => {
+  return (
+    <svg xmlns="http://www.w3.org/2000/svg"
+      width="18"
+      height="18"
+      viewBox="0 0 45 45"
+    >
+      <path
+        fill="currentColor"
+        d="M8.1 44v-3h31.8v3Zm16-4.5-7.6-7.6 2.15-2.15
+            3.95 3.95V14.3l-3.95 3.95-2.15-2.15 7.6-7.6 7.6 7.6-2.15
+            2.15-3.95-3.95v19.4l3.95-3.95 2.15 2.15ZM8.1 7V4h31.8v3Z"
+      />
+    </svg>
+  );
+};

+ 37 - 0
apps/app/src/components/Icons/FolderIcon.tsx

@@ -0,0 +1,37 @@
+import React from 'react';
+
+type Props = {
+  isOpen: boolean
+}
+export const FolderIcon = (props: Props): JSX.Element => {
+  const { isOpen } = props;
+
+  return (
+    <>
+      {!isOpen ? (
+        <svg
+          width ="20"
+          height ="20"
+          viewBox="0 0 24 24"
+        >
+          <path fill="currentColor"
+            d="M20,18H4V8H20M20,6H12L10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6Z" />
+        </svg>
+      ) : (
+        <svg
+          width="20"
+          height="20"
+          viewBox="0 0 24 24"
+        >
+          <path
+            fill="currentColor"
+            d="M6.1,10L4,18V8H21A2,2 0 0,0 19,6H12L10,4H4A2,2 0 0,0 2,6V18A2,2 0 0,0 4,
+            20H19C19.9,20 20.7,19.4 20.9,18.5L23.2,10H6.1M19,18H6L7.6,12H20.6L19,18Z"
+          />
+        </svg>
+      )
+      }
+    </>
+  );
+
+};

+ 16 - 0
apps/app/src/components/Icons/FolderPlusIcon.tsx

@@ -0,0 +1,16 @@
+import React from 'react';
+
+export const FolderPlusIcon = (): JSX.Element => (
+  <svg
+    width="18"
+    height="18"
+    viewBox="0 0 24 24"
+  >
+    <path
+      fill="currentColor"
+      d="M13 19C13 19.34 13.04 19.67 13.09 20H4C2.9 20 2 19.11 2 18V6C2 4.89 2.89 4 4 4H10L12 6H20C21.1 6 22
+      6.89 22 8V13.81C21.39 13.46 20.72 13.22 20 13.09V8H4V18H13.09C13.04 18.33 13 18.66 13 19M20 18V15H18V18H15V20H18V23H20V20H23V18H20Z"
+    />
+
+  </svg>
+);

+ 1 - 3
apps/app/src/components/Icons/TriangleIcon.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 
-const TriangleIcon = (): JSX.Element => (
+export const TriangleIcon = (): JSX.Element => (
   <svg
     xmlns="http://www.w3.org/2000/svg"
     width="12"
@@ -13,5 +13,3 @@ const TriangleIcon = (): JSX.Element => (
     </g>
   </svg>
 );
-
-export default TriangleIcon;

+ 2 - 0
apps/app/src/components/Layout/BasicLayout.tsx

@@ -23,6 +23,7 @@ const PageDeleteModal = dynamic(() => import('../PageDeleteModal'), { ssr: false
 const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false });
 const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
 const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal'), { ssr: false });
+const DeleteBookmarkFolderModal = dynamic(() => import('../DeleteBookmarkFolderModal').then(mod => mod.DeleteBookmarkFolderModal), { ssr: false });
 // Fab
 const Fab = dynamic(() => import('../Fab').then(mod => mod.Fab), { ssr: false });
 
@@ -57,6 +58,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
         <PageRenameModal />
         <PageAccessoriesModal />
         <DeleteAttachmentModal />
+        <DeleteBookmarkFolderModal />
       </DndProvider>
 
       <PagePresentationModal />

+ 102 - 0
apps/app/src/components/Me/OtherSettings.tsx

@@ -0,0 +1,102 @@
+import {
+  useState, useEffect, useCallback,
+} from 'react';
+
+import { useTranslation } from 'next-i18next';
+import { UncontrolledTooltip } from 'reactstrap';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useSWRxIsQuestionnaireEnabled } from '~/features/questionnaire/client/stores/questionnaire';
+import { useCurrentUser } from '~/stores/context';
+
+const OtherSettings = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: currentUser, error: errorCurrentUser } = useCurrentUser();
+  const { data: growiIsQuestionnaireEnabled } = useSWRxIsQuestionnaireEnabled();
+
+  const [isQuestionnaireEnabled, setIsQuestionnaireEnabled] = useState(currentUser?.isQuestionnaireEnabled);
+
+  const onChangeIsQuestionnaireEnabledHandler = useCallback(async() => {
+    setIsQuestionnaireEnabled(prev => !prev);
+  }, []);
+
+  const onClickUpdateIsQuestionnaireEnabledHandler = useCallback(async() => {
+    try {
+      await apiv3Put('/personal-setting/questionnaire-settings', {
+        isQuestionnaireEnabled,
+      });
+      toastSuccess(t('toaster.update_successed', { target: t('questionnaire.settings'), ns: 'commons' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [isQuestionnaireEnabled, t]);
+
+  // Sync currentUser and state
+  useEffect(() => {
+    setIsQuestionnaireEnabled(currentUser?.isQuestionnaireEnabled);
+  }, [currentUser?.isQuestionnaireEnabled]);
+
+  const isLoadingCurrentUser = currentUser === undefined && errorCurrentUser === undefined;
+
+  return (
+    <>
+      <h2 className="border-bottom my-4">{t('questionnaire.settings')}</h2>
+
+      {isLoadingCurrentUser && <div className="text-muted text-center mb-5">
+        <i className="fa fa-2x fa-spinner fa-pulse mr-1" />
+      </div>}
+
+      <div className="form-group row">
+        <div className="offset-md-3 col-md-6 text-left">
+          {!isLoadingCurrentUser && (
+            <div className="custom-control custom-switch custom-checkbox-primary">
+              <span id="grw-questionnaire-settings-toggle-wrapper">
+                <input
+                  type="checkbox"
+                  className="custom-control-input"
+                  id="isQuestionnaireEnabled"
+                  checked={growiIsQuestionnaireEnabled && isQuestionnaireEnabled}
+                  onChange={onChangeIsQuestionnaireEnabledHandler}
+                  disabled={!growiIsQuestionnaireEnabled}
+                />
+                <label className="custom-control-label" htmlFor="isQuestionnaireEnabled">
+                  {t('questionnaire.enable_questionnaire')}
+                </label>
+              </span>
+              <p className="form-text text-muted small">
+                {t('questionnaire.personal_settings_explanation')}
+              </p>
+              {!growiIsQuestionnaireEnabled && <UncontrolledTooltip placement="bottom" target="grw-questionnaire-settings-toggle-wrapper">
+                {t('questionnaire.disabled_by_admin')}
+              </UncontrolledTooltip> }
+            </div>
+          )}
+        </div>
+      </div>
+
+      <div className="row my-3">
+        <div className="offset-4 col-5">
+          <span className="d-inline-block" id="grw-questionnaire-settings-update-btn-wrapper">
+            <button
+              data-testid="grw-questionnaire-settings-update-btn"
+              type="button"
+              className="btn btn-primary"
+              onClick={onClickUpdateIsQuestionnaireEnabledHandler}
+              disabled={!growiIsQuestionnaireEnabled}
+              style={growiIsQuestionnaireEnabled ? {} : { pointerEvents: 'none' }}
+            >
+              {t('Update')}
+            </button>
+          </span>
+          {!growiIsQuestionnaireEnabled && <UncontrolledTooltip placement="bottom" target="grw-questionnaire-settings-update-btn-wrapper">
+            {t('questionnaire.disabled_by_admin')}
+          </UncontrolledTooltip>}
+        </div>
+      </div>
+    </>
+  );
+};
+
+export default OtherSettings;

+ 16 - 2
apps/app/src/components/Me/PersonalSettings.jsx

@@ -9,6 +9,7 @@ import ApiSettings from './ApiSettings';
 // import { EditorSettings } from './EditorSettings';
 import ExternalAccountLinkedMe from './ExternalAccountLinkedMe';
 import InAppNotificationSettings from './InAppNotificationSettings';
+import OtherSettings from './OtherSettings';
 import PasswordSettings from './PasswordSettings';
 import UserSettings from './UserSettings';
 
@@ -48,14 +49,27 @@ const PersonalSettings = () => {
         Content: InAppNotificationSettings,
         i18n: t('in_app_notification_settings.in_app_notification_settings'),
       },
+      other_settings: {
+        Icon: () => <i className="icon-fw icon-settings"></i>,
+        Content: OtherSettings,
+        i18n: t('Other Settings'),
+      },
     };
   }, [t]);
 
-  const onPasswordSettings = window.location.hash === '#password';
+  const getDefaultTabIndex = () => {
+    // e.g) '/me#password_settings' sets password settings tab as default
+    const tab = window.location.hash?.substring(1);
+    let defaultTabIndex;
+    Object.keys(navTabMapping).forEach((key, i) => {
+      if (key === tab) { defaultTabIndex = i }
+    });
+    return defaultTabIndex;
+  };
 
   return (
     <div data-testid="grw-personal-settings">
-      <CustomNavAndContents defaultTabIndex={onPasswordSettings && 2} navTabMapping={navTabMapping} navigationMode="both" tabContentClasses={['px-0']} />
+      <CustomNavAndContents defaultTabIndex={getDefaultTabIndex()} navTabMapping={navTabMapping} navigationMode="both" tabContentClasses={['px-0']} />
     </div>
   );
 

+ 32 - 9
apps/app/src/components/Navbar/PersonalDropdown.jsx

@@ -1,7 +1,8 @@
-import React, { useRef } from 'react';
+import { useRef, useState } from 'react';
 
 import { UserPicture } from '@growi/ui/dist/components/User/UserPicture';
 import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
 import Link from 'next/link';
 import { useRipple } from 'react-use-ripple';
 
@@ -9,15 +10,23 @@ import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
 import { useCurrentUser } from '~/stores/context';
 
+const ProactiveQuestionnaireModal = dynamic(() => import('~/features/questionnaire/client/components/ProactiveQuestionnaireModal'), { ssr: false });
+
 const PersonalDropdown = () => {
   const { t } = useTranslation('commons');
   const { data: currentUser } = useCurrentUser();
 
+  const [isQuestionnaireModalOpen, setQuestionnaireModalOpen] = useState(false);
+
   // ripple
   const buttonRef = useRef(null);
   useRipple(buttonRef, { rippleColor: 'rgba(255, 255, 255, 0.3)' });
 
-  const user = currentUser || {};
+  if (currentUser == null) {
+    return <div className="text-muted text-center mb-5">
+      <i className="fa fa-2x fa-spinner fa-pulse mr-1" />
+    </div>;
+  }
 
   const logoutHandler = async() => {
     try {
@@ -35,27 +44,27 @@ const PersonalDropdown = () => {
       {/* remove .dropdown-toggle for hide caret */}
       {/* See https://stackoverflow.com/a/44577512/13183572 */}
       <button className="bg-transparent border-0 nav-link" type="button" ref={buttonRef} data-toggle="dropdown" data-testid="personal-dropdown-button">
-        <UserPicture user={user} noLink noTooltip /><span className="ml-1 d-none d-lg-inline-block">&nbsp;{user.name}</span>
+        <UserPicture user={currentUser} noLink noTooltip /><span className="ml-1 d-none d-lg-inline-block">&nbsp;{currentUser.name}</span>
       </button>
 
       {/* Menu */}
       <div className="dropdown-menu dropdown-menu-right" data-testid="personal-dropdown-menu">
 
         <div className="px-4 pt-3 pb-2 text-center">
-          <UserPicture user={user} size="lg" noLink noTooltip />
+          <UserPicture user={currentUser} size="lg" noLink noTooltip />
 
           <h5 className="mt-2">
-            {user.name}
+            {currentUser.name}
           </h5>
 
           <div className="my-2">
-            <i className="icon-user icon-fw"></i>{user.username}<br />
-            <i className="icon-envelope icon-fw"></i><span className="grw-email-sm">{user.email}</span>
+            <i className="icon-user icon-fw"></i>{currentUser.username}<br />
+            <i className="icon-envelope icon-fw"></i><span className="grw-email-sm">{currentUser.email}</span>
           </div>
 
           <div className="btn-group btn-block mt-2" role="group">
             <Link
-              href={`/user/${user.username}`}
+              href={`/user/${currentUser.username}`}
               className="btn btn-sm btn-outline-secondary col"
               data-testid="grw-personal-dropdown-menu-user-home"
             >
@@ -73,9 +82,23 @@ const PersonalDropdown = () => {
 
         <div className="dropdown-divider"></div>
 
-        <button type="button" className="dropdown-item" onClick={logoutHandler}><i className="icon-fw icon-power"></i>{t('Sign out')}</button>
+        <button
+          data-testid="grw-proactive-questionnaire-modal-toggle-btn"
+          type="button"
+          className="dropdown-item"
+          onClick={() => setQuestionnaireModalOpen(true)}>
+          <i className="icon-fw icon-pencil"></i>{t('personal_dropdown.feedback')}
+        </button>
+
+        <div className="dropdown-divider"></div>
+
+        <button type="button" className="dropdown-item" onClick={logoutHandler}>
+          <i className="icon-fw icon-power"></i>{t('Sign out')}
+        </button>
       </div>
 
+      <ProactiveQuestionnaireModal isOpen={isQuestionnaireModalOpen} onClose={() => setQuestionnaireModalOpen(false)} />
+
     </>
   );
 

+ 5 - 19
apps/app/src/components/Navbar/SubNavButtons.tsx

@@ -4,7 +4,7 @@ import { useTranslation } from 'next-i18next';
 import { DropdownItem } from 'reactstrap';
 
 import {
-  toggleBookmark, toggleLike, toggleSubscribe,
+  toggleLike, toggleSubscribe,
 } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/toastr';
 import {
@@ -16,7 +16,7 @@ import { IPageForPageDuplicateModal } from '~/stores/modal';
 import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { useSWRxPageInfo } from '../../stores/page';
 import { useSWRxUsersList } from '../../stores/user';
-import BookmarkButtons from '../BookmarkButtons';
+import { BookmarkButtons } from '../BookmarkButtons';
 import {
   AdditionalMenuItemsRendererProps, ForceHideMenuItems, MenuItemType,
   PageItemControl,
@@ -93,7 +93,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
 
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
 
-  const { data: bookmarkInfo, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageId);
+  const { data: bookmarkInfo } = useSWRBookmarkInfo(pageId);
 
   const likerIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.likerIds ?? []).slice(0, 15) : [];
   const seenUserIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.seenUserIds ?? []).slice(0, 15) : [];
@@ -127,18 +127,6 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
     mutatePageInfo();
   }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
 
-  const bookmarkClickHandler = useCallback(async() => {
-    if (isGuestUser == null || isGuestUser) {
-      return;
-    }
-    if (!isIPageInfoForOperation(pageInfo)) {
-      return;
-    }
-
-    await toggleBookmark(pageId, pageInfo.isBookmarked);
-    mutatePageInfo();
-    mutateBookmarkInfo();
-  }, [isGuestUser, mutateBookmarkInfo, mutatePageInfo, pageId, pageInfo]);
 
   const duplicateMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
     if (onClickDuplicateMenuItem == null || path == null) {
@@ -214,7 +202,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
   }
 
   const {
-    sumOfLikers, sumOfSeenUsers, isLiked, bookmarkCount, isBookmarked,
+    sumOfLikers, sumOfSeenUsers, isLiked,
   } = pageInfo;
 
   const forceHideMenuItemsWithBookmark = forceHideMenuItems ?? [];
@@ -240,10 +228,8 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
       {revisionId != null && (
         <BookmarkButtons
           hideTotalNumber={isCompactMode}
-          bookmarkCount={bookmarkCount}
-          isBookmarked={isBookmarked}
           bookmarkedUsers={bookmarkInfo?.bookmarkedUsers}
-          onBookMarkClicked={bookmarkClickHandler}
+          bookmarkInfo={bookmarkInfo}
         />
       )}
       {revisionId != null && !isCompactMode && (

+ 1 - 2
apps/app/src/components/NotFoundPage.tsx

@@ -8,7 +8,6 @@ import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
 import { PageTimeline } from './PageTimeline';
 
-
 type NotFoundPageProps = {
   path: string,
 }
@@ -22,7 +21,7 @@ const NotFoundPage = (props: NotFoundPageProps): JSX.Element => {
     return {
       pagelist: {
         Icon: PageListIcon,
-        Content: () => <DescendantsPageList path={path} />,
+        Content: () => <DescendantsPageList path={path}/>,
         i18n: t('page_list'),
       },
       timeLine: {

+ 1 - 1
apps/app/src/components/PageAttachment.tsx

@@ -2,7 +2,7 @@ import React, {
   useCallback, useMemo, useState,
 } from 'react';
 
-import { IAttachmentHasId } from '^/../../packages/core/dist';
+import { IAttachmentHasId } from '@growi/core';
 
 import { useSWRxAttachments } from '~/stores/attachment';
 import { useIsGuestUser } from '~/stores/context';

+ 3 - 3
apps/app/src/components/PageAttachment/PageAttachmentList.tsx

@@ -1,13 +1,13 @@
 import React from 'react';
 
-import { HasObjectId, IAttachment } from '@growi/core';
+import { IAttachmentHasId } from '@growi/core';
 import { Attachment } from '@growi/ui/dist/components/Attachment';
 import { useTranslation } from 'next-i18next';
 
 type Props = {
-  attachments: (IAttachment & HasObjectId)[],
+  attachments: (IAttachmentHasId)[],
   inUse: { [id: string]: boolean },
-  onAttachmentDeleteClicked: (attachment: IAttachment & HasObjectId) => void,
+  onAttachmentDeleteClicked: (attachment: IAttachmentHasId) => void,
   isUserLoggedIn?: boolean,
 }
 

+ 25 - 7
apps/app/src/components/PageComment/CommentEditor.tsx

@@ -11,19 +11,18 @@ import * as toastr from 'toastr';
 
 import { apiPostForm } from '~/client/util/apiv1-client';
 import { IEditorMethods } from '~/interfaces/editor-methods';
-import { useSWRxPageComment } from '~/stores/comment';
+import { useSWRxPageComment, useSWRxEditingCommentsNum } from '~/stores/comment';
 import {
   useCurrentUser, useIsSlackConfigured,
   useIsUploadableFile, useIsUploadableImage,
 } from '~/stores/context';
-import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
+import { useSWRxSlackChannels, useIsSlackEnabled, useIsEnabledUnsavedWarning } from '~/stores/editor';
 import { useCurrentPagePath } from '~/stores/page';
 
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
 import { NotAvailableForGuest } from '../NotAvailableForGuest';
 import Editor from '../PageEditor/Editor';
 
-
 import { CommentPreview } from './CommentPreview';
 
 import styles from './CommentEditor.module.scss';
@@ -70,12 +69,18 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
   const { data: isSlackConfigured } = useIsSlackConfigured();
   const { data: isUploadableFile } = useIsUploadableFile();
   const { data: isUploadableImage } = useIsUploadableImage();
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
+  const {
+    increment: incrementEditingCommentsNum,
+    decrement: decrementEditingCommentsNum,
+  } = useSWRxEditingCommentsNum();
 
   const [isReadyToUse, setIsReadyToUse] = useState(!isForNewComment);
   const [comment, setComment] = useState(commentBody ?? '');
   const [activeTab, setActiveTab] = useState('comment_editor');
   const [error, setError] = useState();
   const [slackChannels, setSlackChannels] = useState<string>('');
+  const [incremented, setIncremented] = useState(false);
 
   const editorRef = useRef<IEditorMethods>(null);
 
@@ -102,7 +107,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     setSlackChannels(slackChannels);
   }, []);
 
-  const initializeEditor = useCallback(() => {
+  const initializeEditor = useCallback(async() => {
     setComment('');
     setActiveTab('comment_editor');
     setError(undefined);
@@ -110,7 +115,11 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     // reset value
     if (editorRef.current == null) { return }
     editorRef.current.setValue('');
-  }, [initializeSlackEnabled]);
+    const editingCommentsNum = await decrementEditingCommentsNum();
+    if (editingCommentsNum === 0) {
+      mutateIsEnabledUnsavedWarning(false); // must be after clearing comment or else onChange will override bool
+    }
+  }, [initializeSlackEnabled, mutateIsEnabledUnsavedWarning, decrementEditingCommentsNum]);
 
   const cancelButtonClickedHandler = useCallback(() => {
     // change state to not ready
@@ -119,10 +128,12 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
       setIsReadyToUse(false);
     }
 
+    initializeEditor();
+
     if (onCancelButtonClicked != null) {
       onCancelButtonClicked();
     }
-  }, [isForNewComment, onCancelButtonClicked]);
+  }, [isForNewComment, onCancelButtonClicked, initializeEditor]);
 
   const postCommentHandler = useCallback(async() => {
     try {
@@ -237,7 +248,14 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     );
   }, []);
 
-  const onChangeHandler = useCallback((newValue: string) => setComment(newValue), []);
+  const onChangeHandler = useCallback((newValue: string, isClean: boolean) => {
+    setComment(newValue);
+    if (!isClean && !incremented) {
+      incrementEditingCommentsNum();
+      setIncremented(true);
+    }
+    mutateIsEnabledUnsavedWarning(!isClean);
+  }, [mutateIsEnabledUnsavedWarning, incrementEditingCommentsNum, incremented]);
 
   const renderReady = () => {
     const commentPreview = getCommentHtml();

+ 9 - 1
apps/app/src/components/PageEditor.tsx

@@ -108,7 +108,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const saveOrUpdate = useSaveOrUpdate();
 
-  const updateStateAfterSave = useUpdateStateAfterSave(pageId);
+  const updateStateAfterSave = useUpdateStateAfterSave(pageId, { supressEditingMarkdownMutation: true });
 
   const currentRevisionId = currentPage?.revision?._id;
 
@@ -517,6 +517,14 @@ const PageEditor = React.memo((): JSX.Element => {
     }
   }, [initialValue, isIndentSizeForced, mutateCurrentIndentSize]);
 
+  // when transitioning to a different page, if the initialValue is the same,
+  // UnControlled CodeMirror value does not reset, so explicitly set the value to initialValue
+  useEffect(() => {
+    if (currentPagePath != null) {
+      editorRef.current?.setValue(initialValue);
+    }
+  }, [currentPagePath, initialValue]);
+
   if (!isEditable) {
     return <></>;
   }

+ 1 - 1
apps/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -571,7 +571,7 @@ class CodeMirrorEditor extends AbstractEditor {
 
   changeHandler(editor, data, value) {
     if (this.props.onChange != null) {
-      const isClean = data.origin == null || editor.isClean();
+      const isClean = data.origin == null || editor.isClean() || value === this.props.value;
       this.props.onChange(value, isClean);
     }
 

+ 3 - 1
apps/app/src/components/PageEditor/CodeMirrorEditor.module.scss

@@ -12,6 +12,8 @@
   @import '~codemirror/theme/eclipse';
 
   .CodeMirror {
+    font-family: var(--font-family-monospace);
+
     pre.CodeMirror-line.grw-cm-header-line {
       padding-top: 0.16em;
       padding-bottom: 0.08em;
@@ -97,7 +99,7 @@
       .card-body {
         min-width: 30em;
         padding-bottom: 0;
-        font-family: monospace;
+        font-family: var(--font-family-monospace);
         color: bs.$text-muted;
       }
       ul > li {

+ 2 - 2
apps/app/src/components/PageEditor/DrawioModal.tsx

@@ -20,7 +20,7 @@ import { type DrawioConfig, DrawioCommunicationHelper } from './DrawioCommunicat
 const logger = loggerFactory('growi:components:DrawioModal');
 
 const headerColor = '#334455';
-const fontFamily = "Lato, -apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif";
+const fontFamily = "-apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif";
 
 const drawioConfig: DrawioConfig = {
   css: `
@@ -32,7 +32,7 @@ const drawioConfig: DrawioConfig = {
     font-size: 8pt !important;
   }
   `,
-  customFonts: ['Lato', 'Charter'],
+  customFonts: ['Charter'],
   compressXml: true,
 };
 

+ 0 - 79
apps/app/src/components/PageList/BookmarkList.tsx

@@ -1,79 +0,0 @@
-import React, { useState, useCallback, useEffect } from 'react';
-
-import { useTranslation } from 'next-i18next';
-
-import { apiv3Get } from '~/client/util/apiv3-client';
-import { toastError } from '~/client/util/toastr';
-import { MyBookmarkList } from '~/interfaces/bookmark-info';
-import loggerFactory from '~/utils/logger';
-
-import PaginationWrapper from '../PaginationWrapper';
-
-import { PageListItemS } from './PageListItemS';
-
-const logger = loggerFactory('growi:BookmarkList');
-
-type BookmarkListProps = {
-  userId: string
-}
-
-export const BookmarkList = (props: BookmarkListProps): JSX.Element => {
-  const { userId } = props;
-
-  const { t } = useTranslation();
-  const [pages, setPages] = useState<MyBookmarkList>([]);
-  const [activePage, setActivePage] = useState(1);
-  const [totalItemsCount, setTotalItemsCount] = useState(0);
-  const [pagingLimit, setPagingLimit] = useState(10);
-
-  const setPageNumber = (selectedPageNumber) => {
-    setActivePage(selectedPageNumber);
-  };
-
-  const getMyBookmarkList = useCallback(async() => {
-    const page = activePage;
-
-    try {
-      const res = await apiv3Get(`/bookmarks/${userId}`, { page });
-      const { paginationResult } = res.data;
-
-      setPages(paginationResult.docs);
-      setTotalItemsCount(paginationResult.totalDocs);
-      setPagingLimit(paginationResult.limit);
-    }
-    catch (error) {
-      logger.error('failed to fetch data', error);
-      toastError(error);
-    }
-  }, [activePage, userId]);
-
-  useEffect(() => {
-    getMyBookmarkList();
-  }, [getMyBookmarkList]);
-
-  return (
-    <div className="bookmarks-list-container">
-      {pages.length === 0 ? t('No bookmarks yet') : (
-        <>
-          <ul className="page-list-ul page-list-ul-flat mb-3">
-
-            {pages.map(page => (
-              <li key={`my-bookmarks:${page._id}`} className="mt-4">
-                <PageListItemS page={page.page} />
-              </li>
-            ))}
-
-          </ul>
-          <PaginationWrapper
-            activePage={activePage}
-            changePage={setPageNumber}
-            totalItemsCount={totalItemsCount}
-            pagingLimit={pagingLimit}
-            align="center"
-            size="sm"
-          />
-        </>
-      )}
-    </div>
-  );
-};

+ 5 - 1
apps/app/src/components/PageList/PageListItemL.tsx

@@ -24,6 +24,7 @@ import {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
 } from '~/interfaces/ui';
 import LinkedPagePath from '~/models/linked-page-path';
+import { useSWRBookmarkInfo, useSWRxCurrentUserBookmarks } from '~/stores/bookmark';
 import {
   usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
 } from '~/stores/modal';
@@ -88,7 +89,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
 
   const shouldFetch = isSelected && (pageData != null || pageMeta != null);
   const { data: pageInfo } = useSWRxPageInfo(shouldFetch ? pageData?._id : null);
-
+  const { mutate: mutateCurrentUserBookmark } = useSWRxCurrentUserBookmarks();
+  const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageData?._id);
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
   const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
 
@@ -125,6 +127,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
     const bookmarkOperation = _newValue ? bookmark : unbookmark;
     await bookmarkOperation(_pageId);
+    mutateCurrentUserBookmark();
+    mutateBookmarkInfo();
   };
 
   const duplicateMenuItemClickHandler = useCallback(() => {

+ 5 - 2
apps/app/src/components/PageList/PageListItemS.tsx

@@ -10,13 +10,16 @@ import { IPageHasId } from '~/interfaces/page';
 type PageListItemSProps = {
   page: IPageHasId,
   noLink?: boolean,
+  pageTitle?: string
 }
 
 export const PageListItemS = (props: PageListItemSProps): JSX.Element => {
 
-  const { page, noLink = false } = props;
+  const { page, noLink = false, pageTitle } = props;
 
-  let pagePathElement = <PagePathLabel path={page.path} additionalClassNames={['mx-1']} />;
+  const path = pageTitle != null ? pageTitle : page.path;
+
+  let pagePathElement = <PagePathLabel path={path} additionalClassNames={['mx-1']} />;
   if (!noLink) {
     pagePathElement = <a className="text-break" href={page.path}>{pagePathElement}</a>;
   }

+ 7 - 1
apps/app/src/components/PrivateLegacyPages.tsx

@@ -436,7 +436,13 @@ const PrivateLegacyPages = (): JSX.Element => {
         ref={searchPageBaseRef}
         pages={data?.data}
         onSelectedPagesByCheckboxesChanged={selectedPagesByCheckboxesChangedHandler}
-        forceHideMenuItems={[MenuItemType.BOOKMARK, MenuItemType.RENAME, MenuItemType.DUPLICATE, MenuItemType.REVERT, MenuItemType.PATH_RECOVERY]}
+        forceHideMenuItems={[
+          MenuItemType.BOOKMARK,
+          MenuItemType.RENAME,
+          MenuItemType.DUPLICATE,
+          MenuItemType.REVERT,
+          MenuItemType.PATH_RECOVERY,
+        ]}
         // Components
         searchControl={searchControl}
         searchResultListHead={searchResultListHead}

+ 8 - 0
apps/app/src/components/ReactMarkdownComponents/CodeBlock.tsx

@@ -7,6 +7,14 @@ import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
 import styles from './CodeBlock.module.scss';
 
 
+// remove font-family
+Object.entries<object>(oneDark).forEach(([key, value]) => {
+  if ('fontFamily' in value) {
+    delete oneDark[key].fontFamily;
+  }
+});
+
+
 function extractChildrenToIgnoreReactNode(children: ReactNode): ReactNode {
 
   if (children == null) {

+ 4 - 3
apps/app/src/components/ReactMarkdownComponents/NextLink.tsx

@@ -24,13 +24,14 @@ const isExternalLink = (href: string, siteUrl: string | undefined): boolean => {
 
 type Props = Omit<LinkProps, 'href'> & {
   children: React.ReactNode,
+  id?: string,
   href?: string,
   className?: string,
 };
 
 export const NextLink = (props: Props): JSX.Element => {
   const {
-    href, children, className, ...rest
+    id, href, children, className, ...rest
   } = props;
 
   const { data: siteUrl } = useSiteUrl();
@@ -47,13 +48,13 @@ export const NextLink = (props: Props): JSX.Element => {
   // when href is an anchor link
   if (isAnchorLink(href)) {
     return (
-      <a href={href} className={className} {...dataAttributes}>{children}</a>
+      <a id={id} href={href} className={className} {...dataAttributes}>{children}</a>
     );
   }
 
   if (isExternalLink(href, siteUrl)) {
     return (
-      <a href={href} className={className} target="_blank" rel="noopener noreferrer" {...dataAttributes}>
+      <a id={id} href={href} className={className} target="_blank" rel="noopener noreferrer" {...dataAttributes}>
         {children}&nbsp;<i className='icon-share-alt small'></i>
       </a>
     );

+ 28 - 0
apps/app/src/components/Sidebar/Bookmarks.tsx

@@ -0,0 +1,28 @@
+
+import React from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { useIsGuestUser } from '~/stores/context';
+
+import { BookmarkContents } from './Bookmarks/BookmarkContents';
+
+export const Bookmarks = () : JSX.Element => {
+  const { t } = useTranslation();
+  const { data: isGuestUser } = useIsGuestUser();
+
+  return (
+    <>
+      <div className="grw-sidebar-content-header p-3">
+        <h3 className="mb-0">{t('Bookmarks')}</h3>
+      </div>
+      {isGuestUser ? (
+        <h4 className="pl-3">
+          { t('Not available for guest') }
+        </h4>
+      ) : (
+        <BookmarkContents />
+      )}
+    </>
+  );
+};

+ 59 - 0
apps/app/src/components/Sidebar/Bookmarks/BookmarkContents.tsx

@@ -0,0 +1,59 @@
+import React, { useCallback, useState } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { toastError } from '~/client/util/toastr';
+import { BookmarkFolderNameInput } from '~/components/Bookmarks/BookmarkFolderNameInput';
+import { BookmarkFolderTree } from '~/components/Bookmarks/BookmarkFolderTree';
+import { FolderPlusIcon } from '~/components/Icons/FolderPlusIcon';
+import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
+
+export const BookmarkContents = (): JSX.Element => {
+
+  const { t } = useTranslation();
+  const [isCreateAction, setIsCreateAction] = useState<boolean>(false);
+  const { mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild();
+
+  const onClickNewBookmarkFolder = useCallback(() => {
+    setIsCreateAction(true);
+  }, []);
+
+  const onClickonClickOutsideHandler = useCallback(() => {
+    setIsCreateAction(false);
+  }, []);
+
+  const onPressEnterHandlerForCreate = useCallback(async(folderName: string) => {
+    try {
+      await apiv3Post('/bookmark-folder', { name: folderName, parent: null });
+      await mutateBookmarkFolders();
+      setIsCreateAction(false);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [mutateBookmarkFolders]);
+
+  return (
+    <>
+      <div className="col-8 mb-2 ">
+        <button
+          className="btn btn-block btn-outline-secondary rounded-pill d-flex justify-content-start align-middle"
+          onClick={onClickNewBookmarkFolder}
+        >
+          <FolderPlusIcon />
+          <span className="mx-2 ">{t('bookmark_folder.new_folder')}</span>
+        </button>
+      </div>
+      {isCreateAction && (
+        <div className="col-12 mb-2 ">
+          <BookmarkFolderNameInput
+            onClickOutside={onClickonClickOutsideHandler}
+            onPressEnter={onPressEnterHandlerForCreate}
+          />
+        </div>
+      )}
+      <BookmarkFolderTree />
+    </>
+  );
+};

+ 15 - 20
apps/app/src/components/Sidebar/PageTree/Item.tsx

@@ -14,19 +14,21 @@ import { UncontrolledTooltip, DropdownToggle } from 'reactstrap';
 
 import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/page-operation';
 import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
+import { ValidationTarget } from '~/client/util/input-validator';
 import { toastWarning, toastError, toastSuccess } from '~/client/util/toastr';
-import TriangleIcon from '~/components/Icons/TriangleIcon';
+import { TriangleIcon } from '~/components/Icons/TriangleIcon';
 import { NotAvailableForGuest } from '~/components/NotAvailableForGuest';
 import {
   IPageHasId, IPageInfoAll, IPageToDeleteWithMeta,
 } from '~/interfaces/page';
+import { useSWRBookmarkInfo, useSWRxCurrentUserBookmarks } from '~/stores/bookmark';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
 import { mutatePageTree, useSWRxPageChildren } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 import { shouldRecoverPagePaths } from '~/utils/page-operation';
 
-import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
+import ClosableTextInput from '../../Common/ClosableTextInput';
 import CountBadge from '../../Common/CountBadge';
 import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
 
@@ -63,12 +65,6 @@ const markTarget = (children: ItemNode[], targetPathOrId?: Nullable<string>): vo
   });
 };
 
-
-const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
-  const bookmarkOperation = _newValue ? bookmark : unbookmark;
-  await bookmarkOperation(_pageId);
-};
-
 /**
  * Return new page path after the droppedPagePath is moved under the newParentPagePath
  * @param droppedPagePath
@@ -126,6 +122,8 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const [isCreating, setCreating] = useState(false);
 
   const { data, mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
+  const { mutate: mutateCurrentUserBookmarks } = useSWRxCurrentUserBookmarks();
+  const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(page._id);
 
   // descendantCount
   const { getDescCount } = usePageTreeDescCountMap();
@@ -258,6 +256,13 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     }
   }, [hasDescendants]);
 
+  const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
+    const bookmarkOperation = _newValue ? bookmark : unbookmark;
+    await bookmarkOperation(_pageId);
+    mutateCurrentUserBookmarks();
+    mutateBookmarkInfo();
+  };
+
   const duplicateMenuItemClickHandler = useCallback((): void => {
     if (onClickDuplicateMenuItem == null) {
       return;
@@ -365,16 +370,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     }
   };
 
-  const inputValidator = (title: string | null): AlertInfo | null => {
-    if (title == null || title === '' || title.trim() === '') {
-      return {
-        type: AlertType.WARNING,
-        message: t('form_validation.title_required'),
-      };
-    }
-
-    return null;
-  };
 
   /**
    * Users do not need to know if all pages have been renamed.
@@ -455,7 +450,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
                   placeholder={t('Input page name')}
                   onClickOutside={() => { setRenameInputShown(false) }}
                   onPressEnter={onPressEnterForRenameHandler}
-                  inputValidator={inputValidator}
+                  validationTarget={ValidationTarget.PAGE}
                 />
               </NotDraggableForClosableTextInput>
             </div>
@@ -529,7 +524,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
               placeholder={t('Input page name')}
               onClickOutside={() => { setNewPageInputShown(false) }}
               onPressEnter={onPressEnterForCreateHandler}
-              inputValidator={inputValidator}
+              validationTarget={ValidationTarget.PAGE}
             />
           </NotDraggableForClosableTextInput>
         </div>

+ 4 - 0
apps/app/src/components/Sidebar/SidebarContents.tsx

@@ -3,6 +3,7 @@ import React, { memo } from 'react';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { useCurrentSidebarContents } from '~/stores/ui';
 
+import { Bookmarks } from './Bookmarks';
 import CustomSidebar from './CustomSidebar';
 import PageTree from './PageTree';
 import RecentChanges from './RecentChanges';
@@ -22,6 +23,9 @@ export const SidebarContents = memo(() => {
     case SidebarContentsType.TAG:
       Contents = Tag;
       break;
+    case SidebarContentsType.BOOKMARKS:
+      Contents = Bookmarks;
+      break;
     default:
       Contents = PageTree;
   }

+ 1 - 0
apps/app/src/components/Sidebar/SidebarNav.tsx

@@ -105,6 +105,7 @@ export const SidebarNav: FC<Props> = (props: Props) => {
         <PrimaryItem contents={SidebarContentsType.TAG} label="Tags" iconName="local_offer" onItemSelected={onItemSelected} />
         {/* <PrimaryItem id="favorite" label="Favorite" iconName="icon-star" /> */}
         {/* eslint-enable max-len */}
+        <PrimaryItem contents={SidebarContentsType.BOOKMARKS} label="Bookmarks" iconName="bookmark" onItemSelected={onItemSelected} />
       </div>
       <div className="grw-sidebar-nav-secondary-container">
         {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}

+ 0 - 1
apps/app/src/components/StaffCredit/StaffCredit.module.scss

@@ -36,7 +36,6 @@
   h6,
   .dev-position,
   .dev-name {
-    font-family: 'Press Start 2P', Lato, -apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif;
     color: white;
   }
 

+ 9 - 1
apps/app/src/components/StaffCredit/StaffCredit.tsx

@@ -1,5 +1,6 @@
 import React, { useCallback, useState } from 'react';
 
+import localFont from 'next/font/local';
 import { animateScroll } from 'react-scroll';
 import {
   Modal, ModalBody,
@@ -16,6 +17,13 @@ import styles from './StaffCredit.module.scss';
 const logger = loggerFactory('growi:cli:StaffCredit');
 
 
+// define fonts
+const pressStart2P = localFont({
+  src: '../../../resource/fonts/PressStart2P-latin.woff2',
+  display: 'block',
+});
+
+
 type Props = {
   onClosed?: () => void,
 }
@@ -124,7 +132,7 @@ const StaffCredit = (props: Props): JSX.Element => {
       isOpen={isLoaded}
       toggle={closeHandler}
       scrollable
-      className={`staff-credit ${styles['staff-credit']}`}
+      className={`staff-credit ${styles['staff-credit']} ${pressStart2P.className}`}
       onOpened={openedHandler}
     >
       <ModalBody id="modalBody" className="credit-curtain">

+ 96 - 0
apps/app/src/components/UsersHomePageFooter.module.scss

@@ -1,11 +1,107 @@
 @use '@growi/ui/src/styles/molecules/page_list';
+@use '~/styles/variables' as var;
+$grw-sidebar-content-header-height: 58px;
+$grw-sidebar-content-footer-height: 50px;
 
 .user-page-footer :global {
   .grw-user-page-list-m {
+    .list-group{
+      .list-group-item {
+        .grw-visible-on-hover {
+          display: none;
+        }
+
+        &:hover {
+          .grw-visible-on-hover {
+            display: block;
+          }
+        }
+        .grw-triangle-container{
+          svg {
+            width: 12px;
+            height: 12px;
+          }
+        }
+        svg{
+          width: 20px;
+          height: 20px;
+        }
+        min-height: 40px;
+        border-radius: 0px;
+
+
+        &.grw-bookmark-item-list {
+          .picture {
+            width: 16px;
+            height: 16px;
+            vertical-align: text-bottom;
+
+            &.picture-md {
+              width: 20px;
+              height: 20px;
+            }
+          }
+          svg{
+            width: 14px;
+            height: 14px;
+          }
+          .grw-foldertree-control{
+            margin-left: 1rem;
+          }
+        }
+      }
+
+
+    }
+
+    .grw-foldertree-item-container {
+      input {
+        max-width: 25%;
+      }
+    }
+    .grw-foldertree-title-anchor{
+      width: fit-content !important;
+      margin-right: 20px;
+    }
     svg {
       width: 35px;
       height: 35px;
       margin-bottom: 6px;
     }
+    .new-bookmark-folder{
+      max-height: 30px;
+      svg {
+        width: 18px;
+        height: 18px;
+      }
+    }
+    .grw-expand-compress-btn {
+      max-height: 40px;
+      svg {
+        width: 18px;
+        height: 18px;
+        margin-bottom: 3px;
+      }
+    }
+    .grw-folder-tree-container {
+      .grw-drop-item-area {
+        padding: 1rem;
+        .grw-accept-drop-item {
+          padding: 0.5rem;
+          border-style: dashed;
+          border-width: 0.15rem;
+        }
+      }
+    }
   }
 }
+
+.grw-bookarks-contents-compressed {
+  max-height: calc(70vh - (var.$grw-navbar-height + var.$grw-navbar-border-width + $grw-sidebar-content-header-height + $grw-sidebar-content-footer-height));
+  overflow-y: scroll;
+}
+
+.grw-bookarks-contents-expanded {
+  max-height: calc(100vh - (var.$grw-navbar-height + var.$grw-navbar-border-width + $grw-sidebar-content-header-height + $grw-sidebar-content-footer-height));
+  overflow-y: scroll;
+}

+ 21 - 5
apps/app/src/components/UsersHomePageFooter.tsx

@@ -1,12 +1,15 @@
-import React from 'react';
+import React, { useState } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
 import { RecentlyCreatedIcon } from '~/components/Icons/RecentlyCreatedIcon';
-import { BookmarkList } from '~/components/PageList/BookmarkList';
 import { RecentCreated } from '~/components/RecentCreated/RecentCreated';
 import styles from '~/components/UsersHomePageFooter.module.scss';
 
+import { BookmarkFolderTree } from './Bookmarks/BookmarkFolderTree';
+import { CompressIcon } from './Icons/CompressIcon';
+import { ExpandIcon } from './Icons/ExpandIcon';
+
 export type UsersHomePageFooterProps = {
   creatorId: string,
 }
@@ -14,16 +17,29 @@ export type UsersHomePageFooterProps = {
 export const UsersHomePageFooter = (props: UsersHomePageFooterProps): JSX.Element => {
   const { t } = useTranslation();
   const { creatorId } = props;
+  const [isExpanded, setIsExpanded] = useState<boolean>(false);
 
   return (
     <div className={`container-lg user-page-footer py-5 ${styles['user-page-footer']}`}>
       <div className="grw-user-page-list-m d-edit-none">
-        <h2 id="bookmarks-list" className="grw-user-page-header border-bottom pb-2 mb-3">
+        <h2 id="bookmarks-list" className="grw-user-page-header border-bottom pb-2 mb-3 d-flex">
           <i style={{ fontSize: '1.3em' }} className="fa fa-fw fa-bookmark-o"></i>
           {t('footer.bookmarks')}
+          <span className="ml-auto pl-2 ">
+            <button
+              className={`btn btn-sm grw-expand-compress-btn ${isExpanded ? 'active' : ''}`}
+              onClick={() => setIsExpanded(!isExpanded)}
+            >
+              { isExpanded
+                ? <ExpandIcon/>
+                : <CompressIcon />
+              }
+            </button>
+          </span>
         </h2>
-        <div id="user-bookmark-list" className={`page-list ${styles['page-list']}`}>
-          <BookmarkList userId={creatorId} />
+        {/* TODO: In bookmark folders v1, the button to create a new folder does not exist. The button should be included in the bookmark component. */}
+        <div className={`${isExpanded ? `${styles['grw-bookarks-contents-expanded']}` : `${styles['grw-bookarks-contents-compressed']}`}`}>
+          <BookmarkFolderTree isUserHomePage={true} />
         </div>
       </div>
       <div className="grw-user-page-list-m mt-5 d-edit-none">

+ 152 - 0
apps/app/src/features/questionnaire/client/components/ProactiveQuestionnaireModal.tsx

@@ -0,0 +1,152 @@
+import { useState, useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Modal, ModalBody,
+} from 'reactstrap';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+
+type ModalProps = {
+  isOpen: boolean,
+  onClose: () => void,
+};
+
+const QuestionnaireCompletionModal = (props: ModalProps): JSX.Element => {
+  const { t } = useTranslation('commons');
+
+  const { isOpen, onClose } = props;
+
+  return (
+    <Modal
+      size="lg"
+      isOpen={isOpen}
+      toggle={onClose}
+      centered
+    >
+      <ModalBody className="bg-primary overflow-hidden p-0" style={{ borderRadius: 8 }}>
+        <div className="bg-white m-2 p-4" style={{ borderRadius: 8 }}>
+          <div className="text-center">
+            <h2 className="my-4">{t('questionnaire_modal.title')}</h2>
+            <p className="mb-1">{t('questionnaire_modal.successfully_submitted')}</p>
+            <p>{t('questionnaire_modal.thanks_for_answering')}</p>
+          </div>
+          <div className="text-center my-3">
+            <span style={{ cursor: 'pointer', textDecoration: 'underline' }} onClick={onClose}>{t('Close')}</span>
+          </div>
+        </div>
+      </ModalBody>
+    </Modal>
+  );
+};
+
+const ProactiveQuestionnaireModal = (props: ModalProps): JSX.Element => {
+  const { t } = useTranslation('commons');
+
+  const { isOpen, onClose } = props;
+
+  const [isQuestionnaireCompletionModal, setQuestionnaireCompletionModal] = useState(false);
+
+  const submitHandler = useCallback(async(e) => {
+    e.preventDefault();
+
+    const formData = e.target.elements;
+
+    const {
+      satisfaction: { value: satisfaction },
+      lengthOfExperience: { value: lengthOfExperience },
+      occupation: { value: occupation },
+      position: { value: position },
+      commentText: { value: commentText },
+    } = formData;
+
+    const sendValues = {
+      satisfaction: Number(satisfaction),
+      lengthOfExperience,
+      occupation,
+      position,
+      commentText,
+    };
+
+    apiv3Post('/questionnaire/proactive/answer', sendValues);
+
+    onClose();
+    setQuestionnaireCompletionModal(true);
+  }, [onClose]);
+
+  return (
+    <>
+      <Modal
+        data-testid="grw-proactive-questionnaire-modal"
+        size="lg"
+        isOpen={isOpen}
+        toggle={onClose}
+        centered
+      >
+        <ModalBody className="bg-primary overflow-hidden p-0" style={{ borderRadius: 8 }}>
+          <div className="bg-white m-2 p-4" style={{ borderRadius: 8 }}>
+            <div className="text-center">
+              <h2 className="my-4">{t('questionnaire_modal.title')}</h2>
+              <p className="mb-1">{t('questionnaire_modal.more_satisfied_services')}</p>
+              <p>{t('questionnaire_modal.strive_to_improve_services')}</p>
+            </div>
+            <form className="px-5" onSubmit={submitHandler}>
+              <div className="form-group row mt-5">
+                <label className="col-sm-5 col-form-label" htmlFor="satisfaction">
+                  <span className="badge badge-primary mr-2">{t('questionnaire_modal.required')}</span>{t('questionnaire_modal.satisfaction_with_growi')}
+                </label>
+                <select className="col-sm-7 form-control" name="satisfaction" id="satisfaction" required>
+                  <option value="">▼ {t('Select')}</option>
+                  <option>1</option>
+                  <option>2</option>
+                  <option>3</option>
+                  <option>4</option>
+                  <option>5</option>
+                </select>
+              </div>
+              <div className="form-group row mt-3">
+                <label className="col-sm-5 col-form-label" htmlFor="lengthOfExperience">{t('questionnaire_modal.history_of_growi_usage')}</label>
+                <select
+                  name="lengthOfExperience"
+                  id="lengthOfExperience"
+                  className="col-sm-7 form-control"
+                >
+                  <option value="">▼ {t('Select')}</option>
+                  <option>{t('questionnaire_modal.length_of_experience.more_than_two_years')}</option>
+                  <option>{t('questionnaire_modal.length_of_experience.one_to_two_years')}</option>
+                  <option>{t('questionnaire_modal.length_of_experience.six_months_to_one_year')}</option>
+                  <option>{t('questionnaire_modal.length_of_experience.three_months_to_six_months')}</option>
+                  <option>{t('questionnaire_modal.length_of_experience.one_month_to_three_months')}</option>
+                  <option>{t('questionnaire_modal.length_of_experience.less_than_one_month')}</option>
+                </select>
+              </div>
+              <div className="form-group row mt-3">
+                <label className="col-sm-5 col-form-label" htmlFor="occupation">{t('questionnaire_modal.occupation')}</label>
+                <input className="col-sm-7 form-control" type="text" name="occupation" id="occupation" />
+              </div>
+              <div className="form-group row mt-3">
+                <label className="col-sm-5 col-form-label" htmlFor="position">{t('questionnaire_modal.position')}</label>
+                <input className="col-sm-7 form-control" type="text" name="position" id="position" />
+              </div>
+              <div className="form-group row mt-3">
+                <label className="col-sm-5 col-form-label" htmlFor="commentText">
+                  <span className="badge badge-primary mr-2">{t('questionnaire_modal.required')}</span>{t('questionnaire_modal.comment_on_growi')}
+                </label>
+                <textarea className="col-sm-7 form-control" name="commentText" id="commentText" rows={5} required/>
+              </div>
+              <div className="text-center mt-5">
+                <button type="submit" className="btn btn-primary">{t('questionnaire_modal.submit')}</button>
+              </div>
+              <div className="text-center my-3">
+                <span style={{ cursor: 'pointer', textDecoration: 'underline' }} onClick={onClose}>{t('questionnaire_modal.close')}</span>
+              </div>
+            </form>
+          </div>
+        </ModalBody>
+      </Modal>
+      <QuestionnaireCompletionModal isOpen={isQuestionnaireCompletionModal} onClose={() => setQuestionnaireCompletionModal(false)} />
+    </>
+  );
+};
+
+export default ProactiveQuestionnaireModal;

+ 47 - 0
apps/app/src/features/questionnaire/client/components/Question.tsx

@@ -0,0 +1,47 @@
+import { useCurrentUser } from '~/stores/context';
+
+import { IQuestionHasId } from '../../interfaces/question';
+
+type QuestionProps = {
+  question: IQuestionHasId,
+  inputNamePrefix: string,
+}
+
+const Question = ({ question, inputNamePrefix }: QuestionProps): JSX.Element => {
+  const { data: currentUser } = useCurrentUser();
+  const lang = currentUser?.lang;
+
+  const questionText = lang === 'en_US' ? question.text.en_US : question.text.ja_JP;
+
+  return <div className="row mt-4">
+    <div className="col-6 d-flex align-items-center">
+      <span>
+        {questionText}
+      </span>
+    </div>
+    <div className="col-6 d-flex align-items-center pl-0">
+      <div className="btn-group btn-group-toggle flex-fill grw-questionnaire-btn-group" data-toggle="buttons">
+        <label className="btn btn-outline-primary active mr-4 rounded">
+          <input type="radio" name={`${inputNamePrefix + question._id}`} id={`${question._id}-noAnswer`} value='0' defaultChecked/> -
+        </label>
+        <label className="btn btn-outline-primary rounded-left">
+          <input type="radio" name={`${inputNamePrefix + question._id}`} id={`${question._id}-option1`} value='1'/> 1
+        </label>
+        <label className="btn btn-outline-primary">
+          <input type="radio" name={`${inputNamePrefix + question._id}`} id={`${question._id}-option2`} value='2'/> 2
+        </label>
+        <label className="btn btn-outline-primary">
+          <input type="radio" name={`${inputNamePrefix + question._id}`} id={`${question._id}-option3`} value='3'/> 3
+        </label>
+        <label className="btn btn-outline-primary">
+          <input type="radio" name={`${inputNamePrefix + question._id}`} id={`${question._id}-option4`} value='4'/> 4
+        </label>
+        <label className="btn btn-outline-primary">
+          <input type="radio" name={`${inputNamePrefix + question._id}`} id={`${question._id}-option5`} value='5'/> 5
+        </label>
+      </div>
+    </div>
+  </div>;
+};
+
+export default Question;

+ 165 - 0
apps/app/src/features/questionnaire/client/components/QuestionnaireModal.tsx

@@ -0,0 +1,165 @@
+import { useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import { Modal, ModalBody } from 'reactstrap';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useQuestionnaireModal } from '~/features/questionnaire/client/stores/model';
+import { IAnswer } from '~/features/questionnaire/interfaces/answer';
+import { StatusType } from '~/features/questionnaire/interfaces/questionnaire-answer-status';
+import { IQuestionnaireOrderHasId } from '~/features/questionnaire/interfaces/questionnaire-order';
+import { useCurrentUser } from '~/stores/context';
+import loggerFactory from '~/utils/logger';
+
+import { GuestQuestionnaireAnswerStatusService } from '../services/guest-questionnaire-answer-status';
+
+import Question from './Question';
+
+const logger = loggerFactory('growi:QuestionnaireModal');
+
+type QuestionnaireModalProps = {
+  questionnaireOrder: IQuestionnaireOrderHasId
+}
+
+const QuestionnaireModal = ({ questionnaireOrder }: QuestionnaireModalProps): JSX.Element => {
+  const { data: currentUser } = useCurrentUser();
+  const lang = currentUser?.lang;
+
+  const { data: questionnaireModalData, close: closeQuestionnaireModal } = useQuestionnaireModal();
+  const isOpened = questionnaireModalData?.openedQuestionnaireId === questionnaireOrder._id;
+
+  const inputNamePrefix = 'question-';
+
+  const { t } = useTranslation(['translation', 'commons']);
+
+  const sendAnswer = useCallback(async(answers: IAnswer[]) => {
+    try {
+      await apiv3Put('/questionnaire/answer', {
+        questionnaireOrderId: questionnaireOrder._id,
+        answers,
+      });
+      if (currentUser == null) {
+        GuestQuestionnaireAnswerStatusService.setStatus(questionnaireOrder._id, StatusType.answered);
+      }
+      toastSuccess(
+        <>
+          <div className="font-weight-bold">{t('questionnaire.thank_you_for_answering')}</div>
+          <div className="pt-2">{t('questionnaire.additional_feedback')}</div>
+        </>,
+        {
+          autoClose: 3000,
+          closeButton: true,
+        },
+      );
+    }
+    catch (e) {
+      logger.error(e);
+      toastError(t('questionnaire.failed_to_send'));
+    }
+  }, [questionnaireOrder._id, t, currentUser]);
+
+  const submitHandler = useCallback(async(event) => {
+    event.preventDefault();
+
+    const answers: IAnswer[] = questionnaireOrder.questions.map((question) => {
+      const answerValue = event.target[`${inputNamePrefix + question._id}`].value;
+      return { question: question._id, value: answerValue };
+    });
+
+    sendAnswer(answers);
+
+    const shouldCloseToast = true;
+    closeQuestionnaireModal(shouldCloseToast);
+  }, [closeQuestionnaireModal, questionnaireOrder.questions, sendAnswer]);
+
+  const denyBtnClickHandler = useCallback(async() => {
+    try {
+      apiv3Put('/questionnaire/deny', {
+        questionnaireOrderId: questionnaireOrder._id,
+      });
+      if (currentUser == null) {
+        GuestQuestionnaireAnswerStatusService.setStatus(questionnaireOrder._id, StatusType.denied);
+      }
+      toastSuccess(t('questionnaire.denied'));
+    }
+    catch (e) {
+      logger.error(e);
+    }
+    const shouldCloseToast = true;
+    closeQuestionnaireModal(shouldCloseToast);
+  }, [closeQuestionnaireModal, questionnaireOrder._id, t, currentUser]);
+
+  // No showing toasts since not important
+  const closeBtnClickHandler = useCallback(async(shouldCloseToast: boolean) => {
+    closeQuestionnaireModal(shouldCloseToast);
+
+    try {
+      await apiv3Put('/questionnaire/skip', {
+        questionnaireOrderId: questionnaireOrder._id,
+      });
+      if (currentUser == null) {
+        GuestQuestionnaireAnswerStatusService.setStatus(questionnaireOrder._id, StatusType.skipped);
+      }
+    }
+    catch (e) {
+      logger.error(e);
+    }
+  }, [closeQuestionnaireModal, questionnaireOrder._id, currentUser]);
+
+  const closeBtnClickHandlerClosingToast = useCallback(async() => {
+    closeBtnClickHandler(true);
+  }, [closeBtnClickHandler]);
+
+  const questionnaireOrderTitle = lang === 'en_US' ? questionnaireOrder.title.en_US : questionnaireOrder.title.ja_JP;
+
+  return (<Modal
+    size="lg"
+    isOpen={isOpened}
+    toggle={closeBtnClickHandlerClosingToast}
+    centered
+  >
+    <form onSubmit={submitHandler}>
+      <ModalBody className="bg-primary overflow-hidden p-0" style={{ borderRadius: 8 }}>
+        <div className="bg-white m-2 p-4" style={{ borderRadius: 8 }}>
+          <div className="text-center mb-2">
+            <h2 className="my-4">{questionnaireOrderTitle}</h2>
+            <p className="mb-1">{t('commons:questionnaire_modal.more_satisfied_services')}</p>
+            <p>{t('commons:questionnaire_modal.strive_to_improve_services')}</p>
+          </div>
+          <div className="container">
+            <div className="row mt-4">
+              <div className="col-md-2 offset-md-5 font-weight-bold text-right align-items-center p-0">{t('questionnaire.no_answer')}</div>
+              <div className="col-md-5 d-flex justify-content-between align-items-center">
+                <span className="font-weight-bold">{t('questionnaire.disagree')}</span>
+                <span className="font-weight-bold">{t('questionnaire.agree')}</span>
+              </div>
+            </div>
+            {questionnaireOrder.questions?.map((question) => {
+              return <Question question={question} inputNamePrefix={inputNamePrefix} key={question._id}/>;
+            })}
+          </div>
+          <div className="text-center mt-5">
+            <button type="submit" className="btn btn-primary">{t('questionnaire.answer')}</button>
+          </div>
+          <div className="text-center cursor-pointer text-decoration-underline my-3">
+            <span style={{ cursor: 'pointer', textDecoration: 'underline' }} onClick={denyBtnClickHandler}>{t('questionnaire.dont_show_again')}</span>
+          </div>
+
+          {currentUser?.admin && (
+            <a href="/admin/app#questionnaire-settings">
+              <i className="material-icons mr-1" >admin_panel_settings</i>
+            </a>
+          )}
+          {currentUser != null && (
+            <a href="/me#other_settings">
+              <i className="material-icons" >settings</i>
+            </a>
+          )}
+        </div>
+      </ModalBody>
+    </form>
+  </Modal>);
+};
+
+export default QuestionnaireModal;

+ 9 - 0
apps/app/src/features/questionnaire/client/components/QuestionnaireModalManager.module.scss

@@ -0,0 +1,9 @@
+@use '~/styles/bootstrap/init' as bs;
+
+.grw-questionnaire-toasts :global {
+  position: fixed;
+  right: 20px;
+  bottom: 52px;
+  z-index: bs.$zindex-fixed + 1;
+  width: 230px;
+}

+ 46 - 0
apps/app/src/features/questionnaire/client/components/QuestionnaireModalManager.tsx

@@ -0,0 +1,46 @@
+import { useCallback } from 'react';
+
+import { useCurrentUser } from '~/stores/context';
+
+
+import { StatusType } from '../../interfaces/questionnaire-answer-status';
+import { IQuestionnaireOrderHasId } from '../../interfaces/questionnaire-order';
+import { GuestQuestionnaireAnswerStatusService } from '../services/guest-questionnaire-answer-status';
+import { useSWRxQuestionnaireOrders } from '../stores/questionnaire';
+
+import QuestionnaireModal from './QuestionnaireModal';
+import QuestionnaireToast from './QuestionnaireToast';
+
+import styles from './QuestionnaireModalManager.module.scss';
+
+const QuestionnaireModalManager = ():JSX.Element => {
+  const { data: questionnaireOrders } = useSWRxQuestionnaireOrders();
+  const { data: currentUser } = useCurrentUser();
+
+  const questionnaireOrdersToShow = useCallback((questionnaireOrders: IQuestionnaireOrderHasId[] | undefined) => {
+    const guestQuestionnaireAnswerStorage = GuestQuestionnaireAnswerStatusService.getStorage();
+    if (currentUser != null || guestQuestionnaireAnswerStorage == null) {
+      return questionnaireOrders;
+    }
+
+    return questionnaireOrders?.filter((questionnaireOrder) => {
+      const localAnswerStatus = guestQuestionnaireAnswerStorage[questionnaireOrder._id];
+      return localAnswerStatus == null || localAnswerStatus.status === StatusType.not_answered;
+    });
+  }, [currentUser]);
+
+  return <>
+    {questionnaireOrders?.map((questionnaireOrder) => {
+      return <QuestionnaireModal
+        questionnaireOrder={questionnaireOrder}
+        key={questionnaireOrder._id} />;
+    })}
+    <div className={styles['grw-questionnaire-toasts']}>
+      {questionnaireOrdersToShow(questionnaireOrders)?.map((questionnaireOrder) => {
+        return <QuestionnaireToast questionnaireOrder={questionnaireOrder} key={questionnaireOrder._id}/>;
+      })}
+    </div>
+  </>;
+};
+
+export default QuestionnaireModalManager;

+ 85 - 0
apps/app/src/features/questionnaire/client/components/QuestionnaireToast.tsx

@@ -0,0 +1,85 @@
+import { useCallback, useState } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastSuccess } from '~/client/util/toastr';
+import { useQuestionnaireModal } from '~/features/questionnaire/client/stores/model';
+import { useCurrentUser } from '~/stores/context';
+import loggerFactory from '~/utils/logger';
+
+import { StatusType } from '../../interfaces/questionnaire-answer-status';
+import { IQuestionnaireOrderHasId } from '../../interfaces/questionnaire-order';
+import { GuestQuestionnaireAnswerStatusService } from '../services/guest-questionnaire-answer-status';
+
+const logger = loggerFactory('growi:QuestionnaireToast');
+
+type QuestionnaireToastProps = {
+  questionnaireOrder: IQuestionnaireOrderHasId,
+}
+
+const QuestionnaireToast = ({ questionnaireOrder }: QuestionnaireToastProps): JSX.Element => {
+  const { open: openQuestionnaireModal } = useQuestionnaireModal();
+  const { data: currentUser } = useCurrentUser();
+  const lang = currentUser?.lang;
+
+  const [isOpen, setIsOpen] = useState(true);
+
+  const { t } = useTranslation();
+
+  const answerBtnClickHandler = useCallback(() => {
+    openQuestionnaireModal(questionnaireOrder._id, () => setIsOpen(false));
+  }, [openQuestionnaireModal, questionnaireOrder._id]);
+
+  const denyBtnClickHandler = useCallback(async() => {
+    // Immediately close
+    setIsOpen(false);
+
+    try {
+      await apiv3Put('/questionnaire/deny', {
+        questionnaireOrderId: questionnaireOrder._id,
+      });
+      if (currentUser == null) {
+        GuestQuestionnaireAnswerStatusService.setStatus(questionnaireOrder._id, StatusType.denied);
+      }
+      toastSuccess(t('questionnaire.denied'));
+    }
+    catch (e) {
+      logger.error(e);
+    }
+  }, [questionnaireOrder._id, t, currentUser]);
+
+  // No showing toasts since not important
+  const closeBtnClickHandler = useCallback(async() => {
+    setIsOpen(false);
+
+    try {
+      await apiv3Put('/questionnaire/skip', {
+        questionnaireOrderId: questionnaireOrder._id,
+      });
+      if (currentUser == null) {
+        GuestQuestionnaireAnswerStatusService.setStatus(questionnaireOrder._id, StatusType.skipped);
+      }
+    }
+    catch (e) {
+      logger.error(e);
+    }
+  }, [questionnaireOrder._id, currentUser]);
+
+  const questionnaireOrderShortTitle = lang === 'en_US' ? questionnaireOrder.shortTitle.en_US : questionnaireOrder.shortTitle.ja_JP;
+
+  return <div className={`toast ${isOpen ? 'show' : 'hide'}`}>
+    <div className="toast-header bg-primary">
+      <strong className="mr-auto text-light">{questionnaireOrderShortTitle}</strong>
+      <button type="button" className="ml-2 mb-1 close" onClick={closeBtnClickHandler}>
+        <span aria-hidden="true" className="text-light">&times;</span>
+      </button>
+    </div>
+    <div className="toast-body bg-light d-flex justify-content-end">
+      <button type="button" className="btn btn-secondary mr-3" onClick={answerBtnClickHandler}>{t('questionnaire.answer')}</button>
+      <button type="button" className="btn btn-secondary" onClick={denyBtnClickHandler}>{t('questionnaire.deny')}</button>
+    </div>
+  </div>;
+};
+
+export default QuestionnaireToast;

+ 79 - 0
apps/app/src/features/questionnaire/client/services/guest-questionnaire-answer-status.ts

@@ -0,0 +1,79 @@
+// A service to manage questionnaire answer statuses for guest user.
+// Saves statuses in localStorage.
+
+import { StatusType } from '../../interfaces/questionnaire-answer-status';
+
+interface GuestQuestionnaireAnswerStatus {
+  status: StatusType
+  updatedDate: string
+}
+
+interface GuestQuestionnaireAnswerStatusStorage {
+  [key: string]: GuestQuestionnaireAnswerStatus
+}
+
+const storageKey = 'guestQuestionnaireAnswerStatuses';
+const DAYS_UNTIL_EXPIRATION = 30;
+
+/**
+ * Get all answer statuses stored in localStorage as GuestQuestionnaireAnswerStatusStorage,
+ * and update outdated information.
+ */
+const getStorage = (): GuestQuestionnaireAnswerStatusStorage | null => {
+  if (typeof window === 'undefined') { return null }
+
+  const currentStorage = localStorage.getItem(storageKey);
+
+  if (currentStorage == null) { return null }
+
+  const storageJson: GuestQuestionnaireAnswerStatusStorage = JSON.parse(currentStorage);
+  // delete status if outdated to prevent localStorage overflow
+  // change skipped to not_answered if different date than when skipped
+  Object.keys(storageJson).forEach((key) => {
+    const answerStatus = storageJson[key];
+    const updatedDate = new Date(answerStatus.updatedDate);
+    const expirationDate = new Date(updatedDate.setDate(updatedDate.getDate() + DAYS_UNTIL_EXPIRATION));
+    if (expirationDate < new Date()) {
+      delete storageJson[key];
+    }
+    else if (answerStatus.status === StatusType.skipped
+          && new Date().toDateString() !== answerStatus.updatedDate) {
+      storageJson[key] = {
+        status: StatusType.not_answered,
+        updatedDate: new Date().toDateString(),
+      };
+    }
+  });
+
+  return storageJson;
+};
+
+/**
+ * Set answer status for questionnaire order in GuestQuestionnaireAnswerStatusStorage,
+ * and save it in localStorage.
+ */
+const setStatus = (questionnaireOrderId: string, status: StatusType): void => {
+  if (typeof window === 'undefined') { return }
+
+  const guestQuestionnaireAnswerStatus: GuestQuestionnaireAnswerStatus = {
+    status,
+    updatedDate: new Date().toDateString(),
+  };
+
+  const storage = getStorage();
+
+  if (storage != null) {
+    storage[questionnaireOrderId] = guestQuestionnaireAnswerStatus;
+    localStorage.setItem(storageKey, JSON.stringify(storage));
+    return;
+  }
+
+  const initialStorage: GuestQuestionnaireAnswerStatusStorage = { [questionnaireOrderId]: guestQuestionnaireAnswerStatus };
+  localStorage.setItem(storageKey, JSON.stringify(initialStorage));
+
+};
+
+export const GuestQuestionnaireAnswerStatusService = {
+  setStatus,
+  getStorage,
+};

+ 40 - 0
apps/app/src/features/questionnaire/client/stores/model.tsx

@@ -0,0 +1,40 @@
+import { SWRResponse } from 'swr';
+
+import { useStaticSWR } from '~/stores/use-static-swr';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:stores:modal');
+
+/*
+* QuestionnaireModals
+*/
+type QuestionnaireModalStatuses = {
+  openedQuestionnaireId: string | null,
+  closeToast?: () => void | Promise<void>,
+}
+
+type QuestionnaireModalStatusUtils = {
+  open(string: string, closeToast: () => void | Promise<void>): Promise<QuestionnaireModalStatuses | undefined>
+  close(shouldCloseToast?: boolean): Promise<QuestionnaireModalStatuses | undefined>
+}
+
+export const useQuestionnaireModal = (status?: QuestionnaireModalStatuses): SWRResponse<QuestionnaireModalStatuses, Error> & QuestionnaireModalStatusUtils => {
+  const initialData: QuestionnaireModalStatuses = { openedQuestionnaireId: null };
+  const swrResponse = useStaticSWR<QuestionnaireModalStatuses, Error>('questionnaireModalStatus', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (questionnaireOrderId: string, closeToast: () => void | Promise<void>) => swrResponse.mutate({
+      openedQuestionnaireId: questionnaireOrderId,
+      closeToast,
+    }),
+    close: (shouldCloseToast?: boolean) => {
+      if (shouldCloseToast) {
+        swrResponse.data?.closeToast?.();
+        if (swrResponse.data?.closeToast === undefined) logger.debug('Tried to run `swrResponse.data?.closeToast` but it was `undefined`');
+      }
+
+      return swrResponse.mutate({ openedQuestionnaireId: null });
+    },
+  };
+};

+ 23 - 0
apps/app/src/features/questionnaire/client/stores/questionnaire.tsx

@@ -0,0 +1,23 @@
+import useSWR, { SWRResponse } from 'swr';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+
+import { IQuestionnaireOrderHasId } from '../../interfaces/questionnaire-order';
+
+export const useSWRxQuestionnaireOrders = (): SWRResponse<IQuestionnaireOrderHasId[], Error> => {
+  return useSWR(
+    '/questionnaire/orders',
+    endpoint => apiv3Get(endpoint).then((response) => {
+      return response.data.questionnaireOrders;
+    }),
+  );
+};
+
+export const useSWRxIsQuestionnaireEnabled = (): SWRResponse<boolean, Error> => {
+  return useSWR(
+    '/questionnaire/is-enabled',
+    endpoint => apiv3Get(endpoint)
+      .then(response => !!response.data.isEnabled)
+      .catch(() => false),
+  );
+};

+ 4 - 0
apps/app/src/features/questionnaire/interfaces/answer.ts

@@ -0,0 +1,4 @@
+export interface IAnswer<ID = string> {
+  question: ID
+  value: string
+}

+ 25 - 0
apps/app/src/features/questionnaire/interfaces/condition.ts

@@ -0,0 +1,25 @@
+import { HasObjectId } from '@growi/core';
+
+import { GrowiServiceType } from './growi-info';
+import { UserType } from './user-info';
+
+
+interface UserCondition {
+  types: UserType[] // user types to show questionnaire
+  daysSinceCreation?: {
+    moreThanOrEqualTo?: number
+    lessThanOrEqualTo?: number
+  }
+}
+
+interface GrowiCondition {
+  types: GrowiServiceType[] // GROWI types to show questionnaire in
+  versionRegExps: string[] // GROWI versions to show questionnaire in
+}
+
+export interface ICondition {
+  user: UserCondition
+  growi: GrowiCondition
+}
+
+export type IConditionHasId = ICondition & HasObjectId;

+ 59 - 0
apps/app/src/features/questionnaire/interfaces/growi-info.ts

@@ -0,0 +1,59 @@
+import * as os from 'node:os';
+
+export const GrowiServiceType = {
+  cloud: 'cloud',
+  privateCloud: 'private-cloud',
+  onPremise: 'on-premise',
+  others: 'others',
+} as const;
+export const GrowiWikiType = { open: 'open', closed: 'closed' } as const;
+export const GrowiAttachmentType = {
+  aws: 'aws',
+  gcs: 'gcs',
+  gcp: 'gcp',
+  gridfs: 'gridfs',
+  mongo: 'mongo',
+  mongodb: 'mongodb',
+  local: 'local',
+  none: 'none',
+} as const;
+export const GrowiDeploymentType = {
+  officialHelmChart: 'official-helm-chart',
+  growiDockerCompose: 'growi-docker-compose',
+  node: 'node',
+  others: 'others',
+} as const;
+export const GrowiExternalAuthProviderType = {
+  ldap: 'ldap',
+  saml: 'saml',
+  oicd: 'oidc',
+  google: 'google',
+  github: 'github',
+} as const;
+
+export type GrowiServiceType = typeof GrowiServiceType[keyof typeof GrowiServiceType]
+type GrowiWikiType = typeof GrowiWikiType[keyof typeof GrowiWikiType]
+export type GrowiAttachmentType = typeof GrowiAttachmentType[keyof typeof GrowiAttachmentType]
+export type GrowiDeploymentType = typeof GrowiDeploymentType[keyof typeof GrowiDeploymentType]
+export type GrowiExternalAuthProviderType = typeof GrowiExternalAuthProviderType[keyof typeof GrowiExternalAuthProviderType]
+
+interface IGrowiOSInfo {
+  type?: ReturnType<typeof os.type>
+  platform?: ReturnType<typeof os.platform>
+  arch?: ReturnType<typeof os.arch>
+  totalmem?: ReturnType<typeof os.totalmem>
+}
+
+export interface IGrowiInfo {
+  version: string
+  appSiteUrl?: string
+  appSiteUrlHashed: string
+  type: GrowiServiceType
+  currentUsersCount: number
+  currentActiveUsersCount: number
+  wikiType: GrowiWikiType
+  attachmentType: GrowiAttachmentType
+  activeExternalAccountTypes?: GrowiExternalAuthProviderType[]
+  osInfo?: IGrowiOSInfo
+  deploymentType?: GrowiDeploymentType
+}

+ 14 - 0
apps/app/src/features/questionnaire/interfaces/proactive-questionnaire-answer.ts

@@ -0,0 +1,14 @@
+import { IGrowiInfo } from './growi-info';
+import { IUserInfo } from './user-info';
+
+
+export interface IProactiveQuestionnaireAnswer {
+  satisfaction: number,
+  commentText: string,
+  growiInfo: IGrowiInfo,
+  userInfo: IUserInfo,
+  answeredAt: Date,
+  lengthOfExperience?: string,
+  position?: string,
+  occupation?: string,
+}

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно