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

Merge branch 'master' into feat/gw7774-app-notification-when-containing-username-in-comment

Mudana-Grune 3 лет назад
Родитель
Сommit
ad1180ca16
100 измененных файлов с 1908 добавлено и 1522 удалено
  1. 1 1
      packages/app/config/logger/config.dev.js
  2. 15 0
      packages/app/resource/Contributor.js
  3. 27 0
      packages/app/resource/locales/en_US/translation.json
  4. 27 0
      packages/app/resource/locales/ja_JP/translation.json
  5. 27 0
      packages/app/resource/locales/zh_CN/translation.json
  6. 4 2
      packages/app/src/client/app.jsx
  7. 14 13
      packages/app/src/client/services/AdminAppContainer.js
  8. 6 5
      packages/app/src/client/services/AdminBasicSecurityContainer.js
  9. 11 11
      packages/app/src/client/services/AdminCustomizeContainer.js
  10. 5 5
      packages/app/src/client/services/AdminExternalAccountsContainer.js
  11. 9 8
      packages/app/src/client/services/AdminGeneralSecurityContainer.js
  12. 6 5
      packages/app/src/client/services/AdminGitHubSecurityContainer.js
  13. 16 13
      packages/app/src/client/services/AdminGoogleSecurityContainer.js
  14. 3 4
      packages/app/src/client/services/AdminHomeContainer.js
  15. 9 7
      packages/app/src/client/services/AdminImportContainer.js
  16. 5 3
      packages/app/src/client/services/AdminLdapSecurityContainer.js
  17. 5 2
      packages/app/src/client/services/AdminLocalSecurityContainer.js
  18. 7 5
      packages/app/src/client/services/AdminMarkDownContainer.js
  19. 10 6
      packages/app/src/client/services/AdminNotificationContainer.js
  20. 6 4
      packages/app/src/client/services/AdminOidcSecurityContainer.js
  21. 6 5
      packages/app/src/client/services/AdminSamlSecurityContainer.js
  22. 4 2
      packages/app/src/client/services/AdminSlackIntegrationLegacyContainer.js
  23. 6 4
      packages/app/src/client/services/AdminTwitterSecurityContainer.js
  24. 13 8
      packages/app/src/client/services/AdminUsersContainer.js
  25. 0 91
      packages/app/src/client/services/AppContainer.js
  26. 9 6
      packages/app/src/client/services/CommentContainer.js
  27. 6 1
      packages/app/src/client/services/ContextExtractor.tsx
  28. 2 77
      packages/app/src/client/services/EditorContainer.js
  29. 9 9
      packages/app/src/client/services/PageContainer.js
  30. 3 2
      packages/app/src/client/services/PageHistoryContainer.js
  31. 11 8
      packages/app/src/client/services/PersonalContainer.js
  32. 3 2
      packages/app/src/client/services/RevisionComparerContainer.js
  33. 3 1
      packages/app/src/client/services/TagContainer.js
  34. 1 1
      packages/app/src/client/util/editor.ts
  35. 3 3
      packages/app/src/client/util/interceptor/drawio-interceptor.js
  36. 5 4
      packages/app/src/components/Admin/Customize/CustomizeLayoutSetting.jsx
  37. 11 8
      packages/app/src/components/Admin/ElasticsearchManagement/ElasticsearchManagement.jsx
  38. 6 3
      packages/app/src/components/Admin/ExportArchiveData/SelectCollectionsModal.jsx
  39. 10 7
      packages/app/src/components/Admin/ExportArchiveDataPage.jsx
  40. 9 7
      packages/app/src/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  41. 5 2
      packages/app/src/components/Admin/ImportData/GrowiArchive/UploadForm.jsx
  42. 7 4
      packages/app/src/components/Admin/ImportData/GrowiArchiveSection.jsx
  43. 8 5
      packages/app/src/components/Admin/Notification/GlobalNotificationList.jsx
  44. 10 6
      packages/app/src/components/Admin/Notification/ManageGlobalNotification.jsx
  45. 7 5
      packages/app/src/components/Admin/Security/GoogleSecuritySettingContents.jsx
  46. 7 5
      packages/app/src/components/Admin/Security/LdapAuthTest.jsx
  47. 8 6
      packages/app/src/components/Admin/Security/ShareLinkSetting.jsx
  48. 12 8
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx
  49. 8 4
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySecretTokenSection.jsx
  50. 9 5
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx
  51. 5 3
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx
  52. 5 3
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcessWithoutProxy.jsx
  53. 12 7
      packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  54. 17 11
      packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx
  55. 13 13
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  56. 7 4
      packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.jsx
  57. 5 2
      packages/app/src/components/Admin/Users/PasswordResetModal.jsx
  58. 8 4
      packages/app/src/components/Admin/Users/SendInvitationEmailButton.jsx
  59. 6 2
      packages/app/src/components/ArchiveCreateModal.jsx
  60. 2 1
      packages/app/src/components/Me/ApiSettings.jsx
  61. 6 25
      packages/app/src/components/Me/EditorSettings.tsx
  62. 9 14
      packages/app/src/components/Me/PasswordSettings.jsx
  63. 6 7
      packages/app/src/components/MyDraftList/MyDraftList.jsx
  64. 3 1
      packages/app/src/components/Navbar/PageEditorModeManager.jsx
  65. 28 22
      packages/app/src/components/Page.jsx
  66. 278 0
      packages/app/src/components/Page/FixPageGrantAlert.tsx
  67. 7 4
      packages/app/src/components/Page/RevisionLoader.jsx
  68. 15 3
      packages/app/src/components/Page/RevisionRenderer.jsx
  69. 5 2
      packages/app/src/components/PageAttachment.jsx
  70. 9 7
      packages/app/src/components/PageComment.tsx
  71. 25 8
      packages/app/src/components/PageComment/CommentEditor.jsx
  72. 54 38
      packages/app/src/components/PageEditor.jsx
  73. 42 34
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  74. 8 14
      packages/app/src/components/PageEditor/DownloadDictModal.tsx
  75. 43 32
      packages/app/src/components/PageEditor/Editor.jsx
  76. 5 8
      packages/app/src/components/PageEditor/EmojiPicker.tsx
  77. 8 7
      packages/app/src/components/PageEditor/HandsontableModal.jsx
  78. 10 11
      packages/app/src/components/PageEditor/LinkEditModal.jsx
  79. 4 3
      packages/app/src/components/PageEditor/MarkdownTableInterceptor.js
  80. 0 453
      packages/app/src/components/PageEditor/OptionsSelector.jsx
  81. 410 0
      packages/app/src/components/PageEditor/OptionsSelector.tsx
  82. 19 22
      packages/app/src/components/PageEditor/Preview.tsx
  83. 12 13
      packages/app/src/components/PageEditorByHackmd.jsx
  84. 12 17
      packages/app/src/components/PageList/BookmarkList.jsx
  85. 5 4
      packages/app/src/components/PageTimeline.jsx
  86. 7 3
      packages/app/src/components/PasswordResetExecutionForm.jsx
  87. 5 2
      packages/app/src/components/PasswordResetRequestForm.jsx
  88. 44 8
      packages/app/src/components/PrivateLegacyPages.tsx
  89. 5 12
      packages/app/src/components/RecentCreated/RecentCreated.jsx
  90. 18 9
      packages/app/src/components/RevisionComparer/RevisionComparer.jsx
  91. 0 249
      packages/app/src/components/SavePageControls/GrantSelector.jsx
  92. 228 0
      packages/app/src/components/SavePageControls/GrantSelector.tsx
  93. 14 14
      packages/app/src/components/ShareLink/ShareLink.jsx
  94. 10 11
      packages/app/src/components/ShareLink/ShareLinkForm.jsx
  95. 6 2
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  96. 6 7
      packages/app/src/components/StaffCredit/StaffCredit.jsx
  97. 12 13
      packages/app/src/components/UncontrolledCodeMirror.tsx
  98. 4 0
      packages/app/src/interfaces/common.ts
  99. 31 0
      packages/app/src/interfaces/editor-settings.ts
  100. 1 0
      packages/app/src/interfaces/errors/v5-conversion-error.ts

+ 1 - 1
packages/app/config/logger/config.dev.js

@@ -12,7 +12,7 @@ module.exports = {
   // 'growi:crow:dev': 'debug',
   'growi:crowi:express-init': 'debug',
   'growi:models:external-account': 'debug',
-  // 'growi:routes:login': 'debug',
+  'growi:routes:login': 'debug',
   'growi:routes:login-passport': 'debug',
   'growi:middleware:safe-redirect': 'debug',
   'growi:service:PassportService': 'debug',

+ 15 - 0
packages/app/resource/Contributor.js

@@ -12,6 +12,11 @@ const contributors = [
           { position: 'Soncho 2nd', name: 'yusuketk' },
           { position: 'Paladin', name: 'itizawa' },
           { position: 'Valkyrie', name: 'kaoritokashiki' },
+          { position: 'Slime', name: 'TatsuyaIse' },
+          { position: 'Knight', name: 'Yohei-Shiina' },
+          { position: 'Titan', name: 'ryoh15' },
+          { position: 'Haberion', name: 'hakumizuki' },
+          { position: 'Undefined', name: 'miya' },
         ],
       },
       {
@@ -37,6 +42,7 @@ const contributors = [
           { name: 'ryuichi-e' },
           { name: 'N1koge' },
           { name: 'Ertai87' },
+          { name: 'takayuki-t' },
           { name: 'zahmis' },
           { name: 'takeru0001' },
           { name: 'Shu Katabe' },
@@ -46,6 +52,15 @@ const contributors = [
           { name: 'stevenfukase' },
           { name: 'miya' },
           { name: 'kaho819' },
+          { name: 'yuto-oweseek' },
+          { name: 'maow89126' },
+          { name: 'kntowd' },
+          { name: 'yukendev' },
+          { name: 'asami-n' },
+          { name: 'ryohi15' },
+          { name: 'yoshiro-s' },
+          { name: 'kuimac' },
+          { name: 'akira-sugiyama' },
         ],
       },
     ],

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

@@ -664,6 +664,8 @@
     },
     "by_path_modal": {
       "title": "Convert to new v5 compatible format",
+      "alert": "This operation cannot be undone, and pages that the user cannot view are also subject to processing.",
+      "checkbox_label": "Understood",
       "description": "Enter a path and all pages under that path will be converted to v5 compatible format.",
       "button_label": "Convert",
       "success": "Successfully requested conversion.",
@@ -1075,5 +1077,30 @@
     "select_group": "Select group",
     "belonging_to_no_group": "Could not find the groups you belong to.",
     "manage_user_groups": "Manage user groups"
+  },
+  "fix_page_grant": {
+    "modal": {
+      "no_grant_available": "The list of selectable permissions could not be found. Please modify the permissions on the parent page first and try again.",
+      "need_to_fix_grant": "The permissions associated with this page must be modified in order to use the functionality correctly. <br> Please select from the options below to make the change.",
+      "grant_label": {
+        "isForbidden": "Authority not allowed to view",
+        "currentPageGrantLabel": "Authorization for this page: ",
+        "parentPageGrantLabel": "Authority of parent page: ",
+        "docLink": "For more information on modifying permissions, please refer to <a href='https://docs.growi.org/ja/admin-guide/admin-cookbook/integrate-with-hackmd.html'>こちらのリンク</a>"
+      },
+      "radio_btn": {
+        "restrected": "Only those who know the link",
+        "only_me": "only to oneself",
+        "grant_group": "Only specific groups"
+      },
+      "select_group_default_text": "Select Group",
+      "alert_message_select_group": "No group selected",
+      "btn_label": "Conversion",
+      "title": "Modify authority"
+    },
+    "alert": {
+      "description": "You need to modify the permission settings for this page.",
+      "btn_label": "Revision"
+    }
   }
 }

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

@@ -664,6 +664,8 @@
     },
     "by_path_modal": {
       "title": "新しい v5 互換形式への変換",
+      "alert": "この操作は取り消すことができず、ユーザーが閲覧できないページも処理の対象になります。",
+      "checkbox_label": "理解しました",
       "description": "パスを入力することで、そのパスの配下のページを全て v5 互換形式に変換します",
       "button_label": "変換",
       "success": "正常に変換を開始しました",
@@ -1068,5 +1070,30 @@
     "select_group": "グループを選ぶ",
     "belonging_to_no_group": "所属しているグループが見つかりませんでした。",
     "manage_user_groups": "グループ管理"
+  },
+  "fix_page_grant": {
+    "modal": {
+      "no_grant_available": "選択可能な権限のリストが見つかりませんでした。まず親ページの権限を修正したのちに再試行してください。",
+      "need_to_fix_grant": "正しく機能を使用するためにはこのページに紐づく権限を修正する必要があります。 <br> 下記の選択肢から選んで変更してください。",
+      "grant_label": {
+        "isForbidden": "権限の閲覧が許可されていません",
+        "currentPageGrantLabel": "このページの権限: ",
+        "parentPageGrantLabel": "親のページの権限: ",
+        "docLink": "権限の修正についての詳細は<a href='https://docs.growi.org/ja/admin-guide/admin-cookbook/integrate-with-hackmd.html'>こちらのリンク</a>を参照してください"
+      },
+      "radio_btn": {
+        "restrected": "リンクを知っている人のみ",
+        "only_me": "自分のみ",
+        "grant_group": "特定グループのみ"
+      },
+      "select_group_default_text": "グループを選択",
+      "alert_message_select_group": "グループが選択されていません",
+      "btn_label": "変換",
+      "title": "権限を修正"
+    },
+    "alert": {
+      "description": "このページの権限設定を修正する必要があります。",
+      "btn_label": "修正"
+    }
   }
 }

+ 27 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -951,6 +951,8 @@
     },
     "by_path_modal": {
       "title": "转换为新的v5兼容格式",
+      "alert": "这一操作不能被撤销,用户不能查看的页面也要进行处理。",
+      "checkbox_label": "明白了",
       "description": "输入一个路径,该路径下的所有页面将被转换为v5兼容格式。",
       "button_label": "转换",
       "success": "成功地请求转换。",
@@ -1078,5 +1080,30 @@
     "select_group": "选择组别",
     "belonging_to_no_group": "无法找到你所属的团体。",
     "manage_user_groups": "管理用户组"
+  },
+  "fix_page_grant": {
+    "modal": {
+      "no_grant_available": "无法找到可选择的权限列表。 请先修改父页的权限,然后再试一次。",
+      "need_to_fix_grant": "为了正确使用该功能,需要修改与该页面相关的权限。 <br> 请从以下选项中选择进行更改。",
+      "grant_label": {
+        "isForbidden": "无权查看的机构",
+        "currentPageGrantLabel": "本页的权限: ",
+        "parentPageGrantLabel": "父页的权限: ",
+        "docLink": "关于修改授权的更多信息,请参见此<a href='https://docs.growi.org/ja/admin-guide/admin-cookbook/integrate-with-hackmd.html'>此链接</a>"
+      },
+      "radio_btn": {
+        "restrected": "只有那些知道链接的人",
+        "only_me": "只对自己说",
+        "grant_group": "仅限特定群体"
+      },
+      "select_group_default_text": "选择组别",
+      "alert_message_select_group": "未选择组别",
+      "btn_label": "蜕变",
+      "title": "修改后的授权书"
+    },
+    "alert": {
+      "description": "本页的授权设置需要修改。",
+      "btn_label": "修改"
+    }
   }
 }

+ 4 - 2
packages/app/src/client/app.jsx

@@ -33,6 +33,7 @@ import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationS
 import NotFoundPage from '../components/NotFoundPage';
 import Page from '../components/Page';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
+import FixPageGrantAlert from '../components/Page/FixPageGrantAlert';
 import NotFoundAlert from '../components/Page/NotFoundAlert';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
@@ -40,7 +41,6 @@ import TrashPageAlert from '../components/Page/TrashPageAlert';
 import PageComment from '../components/PageComment';
 import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
 import PageContentFooter from '../components/PageContentFooter';
-import { defaultEditorOptions, defaultPreviewOptions } from '../components/PageEditor/OptionsSelector';
 import BookmarkList from '../components/PageList/BookmarkList';
 import PageStatusAlert from '../components/PageStatusAlert';
 import PageTimeline from '../components/PageTimeline';
@@ -66,7 +66,7 @@ const pageContainer = new PageContainer(appContainer);
 const pageHistoryContainer = new PageHistoryContainer(appContainer, pageContainer);
 const revisionComparerContainer = new RevisionComparerContainer(appContainer, pageContainer);
 const commentContainer = new CommentContainer(appContainer);
-const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
+const editorContainer = new EditorContainer(appContainer);
 const tagContainer = new TagContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
 const injectableContainers = [
@@ -99,6 +99,8 @@ Object.assign(componentMappings, {
 
   'trash-page-alert': <TrashPageAlert />,
 
+  'fix-page-grant-alert': <FixPageGrantAlert />,
+
   'trash-page-list-container': <TrashPageList />,
 
   'not-found-page': <NotFoundPage />,

+ 14 - 13
packages/app/src/client/services/AdminAppContainer.js

@@ -1,15 +1,16 @@
 import { Container } from 'unstated';
 
+import { apiv3Get, apiv3Post, apiv3Put } from '../util/apiv3-client';
+
 /**
  * Service container for admin app setting page (AppSettings.jsx)
  * @extends {Container} unstated Container
  */
 export default class AdminAppContainer extends Container {
 
-  constructor(appContainer) {
+  constructor() {
     super();
 
-    this.appContainer = appContainer;
     this.dummyTitle = 0;
     this.dummyTitleForError = 1;
 
@@ -75,7 +76,7 @@ export default class AdminAppContainer extends Container {
    * retrieve app sttings data
    */
   async retrieveAppSettingsData() {
-    const response = await this.appContainer.apiv3.get('/app-settings/');
+    const response = await apiv3Get('/app-settings/');
     const { appSettingsParams } = response.data;
 
     this.setState({
@@ -326,7 +327,7 @@ export default class AdminAppContainer extends Container {
    * @return {Array} Appearance
    */
   async updateAppSettingHandler() {
-    const response = await this.appContainer.apiv3.put('/app-settings/app-setting', {
+    const response = await apiv3Put('/app-settings/app-setting', {
       title: this.state.title,
       confidential: this.state.confidential,
       globalLang: this.state.globalLang,
@@ -344,7 +345,7 @@ export default class AdminAppContainer extends Container {
    * @return {Array} Appearance
    */
   async updateSiteUrlSettingHandler() {
-    const response = await this.appContainer.apiv3.put('/app-settings/site-url-setting', {
+    const response = await apiv3Put('/app-settings/site-url-setting', {
       siteUrl: this.state.siteUrl,
     });
     const { siteUrlSettingParams } = response.data;
@@ -369,7 +370,7 @@ export default class AdminAppContainer extends Container {
    * @return {Array} Appearance
    */
   async updateSmtpSetting() {
-    const response = await this.appContainer.apiv3.put('/app-settings/smtp-setting', {
+    const response = await apiv3Put('/app-settings/smtp-setting', {
       fromAddress: this.state.fromAddress,
       transmissionMethod: this.state.transmissionMethod,
       smtpHost: this.state.smtpHost,
@@ -388,7 +389,7 @@ export default class AdminAppContainer extends Container {
    * @return {Array} Appearance
    */
   async updateSesSetting() {
-    const response = await this.appContainer.apiv3.put('/app-settings/ses-setting', {
+    const response = await apiv3Put('/app-settings/ses-setting', {
       fromAddress: this.state.fromAddress,
       transmissionMethod: this.state.transmissionMethod,
       sesAccessKeyId: this.state.sesAccessKeyId,
@@ -404,7 +405,7 @@ export default class AdminAppContainer extends Container {
    * @memberOf AdminAppContainer
    */
   async sendTestEmail() {
-    return this.appContainer.apiv3.post('/app-settings/smtp-test');
+    return apiv3Post('/app-settings/smtp-test');
   }
 
   /**
@@ -434,7 +435,7 @@ export default class AdminAppContainer extends Container {
       requestParams.s3ReferenceFileWithRelayMode = this.state.s3ReferenceFileWithRelayMode;
     }
 
-    const response = await this.appContainer.apiv3.put('/app-settings/file-upload-setting', requestParams);
+    const response = await apiv3Put('/app-settings/file-upload-setting', requestParams);
     const { responseParams } = response.data;
     return this.setState(responseParams);
   }
@@ -445,7 +446,7 @@ export default class AdminAppContainer extends Container {
    * @return {Array} Appearance
    */
   async updatePluginSettingHandler() {
-    const response = await this.appContainer.apiv3.put('/app-settings/plugin-setting', {
+    const response = await apiv3Put('/app-settings/plugin-setting', {
       isEnabledPlugins: this.state.isEnabledPlugins,
     });
     const { pluginSettingParams } = response.data;
@@ -457,17 +458,17 @@ export default class AdminAppContainer extends Container {
    * @memberOf AdminAppContainer
    */
   async v5PageMigrationHandler() {
-    const response = await this.appContainer.apiv3.post('/app-settings/v5-schema-migration');
+    const response = await apiv3Post('/app-settings/v5-schema-migration');
     const { isV5Compatible } = response.data;
     return { isV5Compatible };
   }
 
   async startMaintenanceMode() {
-    await this.appContainer.apiv3.post('/app-settings/maintenance-mode', { flag: true });
+    await apiv3Post('/app-settings/maintenance-mode', { flag: true });
   }
 
   async endMaintenanceMode() {
-    await this.appContainer.apiv3.post('/app-settings/maintenance-mode', { flag: false });
+    await apiv3Post('/app-settings/maintenance-mode', { flag: false });
   }
 
 }

+ 6 - 5
packages/app/src/client/services/AdminBasicSecurityContainer.js

@@ -1,8 +1,10 @@
 import { Container } from 'unstated';
-import loggerFactory from '~/utils/logger';
 
+import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
+import { apiv3Get, apiv3Put } from '../util/apiv3-client';
+
 const logger = loggerFactory('growi:security:AdminTwitterSecurityContainer');
 
 /**
@@ -11,10 +13,9 @@ const logger = loggerFactory('growi:security:AdminTwitterSecurityContainer');
  */
 export default class AdminBasicSecurityContainer extends Container {
 
-  constructor(appContainer) {
+  constructor() {
     super();
 
-    this.appContainer = appContainer;
     this.dummyIsSameUsernameTreatedAsIdenticalUser = 0;
     this.dummyIsSameUsernameTreatedAsIdenticalUserForError = 1;
 
@@ -31,7 +32,7 @@ export default class AdminBasicSecurityContainer extends Container {
    */
   async retrieveSecurityData() {
     try {
-      const response = await this.appContainer.apiv3.get('/security-setting/');
+      const response = await apiv3Get('/security-setting/');
       const { basicAuth } = response.data.securityParams;
       this.setState({
         isSameUsernameTreatedAsIdenticalUser: basicAuth.isSameUsernameTreatedAsIdenticalUser,
@@ -65,7 +66,7 @@ export default class AdminBasicSecurityContainer extends Container {
     let requestParams = { isSameUsernameTreatedAsIdenticalUser: this.state.isSameUsernameTreatedAsIdenticalUser };
 
     requestParams = await removeNullPropertyFromObject(requestParams);
-    const response = await this.appContainer.apiv3.put('/security-setting/basic', requestParams);
+    const response = await apiv3Put('/security-setting/basic', requestParams);
     const { securitySettingParams } = response.data;
 
     this.setState({

+ 11 - 11
packages/app/src/client/services/AdminCustomizeContainer.js

@@ -3,6 +3,7 @@ import { Container } from 'unstated';
 import loggerFactory from '~/utils/logger';
 
 import { toastError } from '../util/apiNotification';
+import { apiv3Get, apiv3Put } from '../util/apiv3-client';
 
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:services:AdminCustomizeContainer');
@@ -13,10 +14,9 @@ const logger = loggerFactory('growi:services:AdminCustomizeContainer');
  */
 export default class AdminCustomizeContainer extends Container {
 
-  constructor(appContainer) {
+  constructor() {
     super();
 
-    this.appContainer = appContainer;
     this.dummyCurrentTheme = 0;
     this.dummyCurrentThemeForError = 1;
 
@@ -76,7 +76,7 @@ export default class AdminCustomizeContainer extends Container {
    */
   async retrieveCustomizeData() {
     try {
-      const response = await this.appContainer.apiv3.get('/customize-setting/');
+      const response = await apiv3Get('/customize-setting/');
       const { customizeParams } = response.data;
 
       this.setState({
@@ -246,7 +246,7 @@ export default class AdminCustomizeContainer extends Container {
   async previewTheme(themeName) {
     try {
       // get theme asset path
-      const response = await this.appContainer.apiv3.get('/customize-setting/theme/asset-path', { themeName });
+      const response = await apiv3Get('/customize-setting/theme/asset-path', { themeName });
       const { assetPath } = response.data;
 
       const themeLink = document.getElementById('grw-theme-link');
@@ -274,7 +274,7 @@ export default class AdminCustomizeContainer extends Container {
    */
   async updateCustomizeTheme() {
     try {
-      const response = await this.appContainer.apiv3.put('/customize-setting/theme', {
+      const response = await apiv3Put('/customize-setting/theme', {
         themeType: this.state.currentTheme,
       });
       const { customizedParams } = response.data;
@@ -294,7 +294,7 @@ export default class AdminCustomizeContainer extends Container {
    */
   async updateCustomizeFunction() {
     try {
-      const response = await this.appContainer.apiv3.put('/customize-setting/function', {
+      const response = await apiv3Put('/customize-setting/function', {
         isEnabledTimeline: this.state.isEnabledTimeline,
         isSavedStatesOfTabChanges: this.state.isSavedStatesOfTabChanges,
         isEnabledAttachTitleHeader: this.state.isEnabledAttachTitleHeader,
@@ -332,7 +332,7 @@ export default class AdminCustomizeContainer extends Container {
    */
   async updateHighlightJsStyle() {
     try {
-      const response = await this.appContainer.apiv3.put('/customize-setting/highlight', {
+      const response = await apiv3Put('/customize-setting/highlight', {
         highlightJsStyle: this.state.currentHighlightJsStyleId,
         highlightJsStyleBorder: this.state.isHighlightJsStyleBorderEnabled,
       });
@@ -354,7 +354,7 @@ export default class AdminCustomizeContainer extends Container {
    */
   async updateCustomizeTitle() {
     try {
-      const response = await this.appContainer.apiv3.put('/customize-setting/customize-title', {
+      const response = await apiv3Put('/customize-setting/customize-title', {
         customizeTitle: this.state.currentCustomizeTitle,
       });
       const { customizedParams } = response.data;
@@ -374,7 +374,7 @@ export default class AdminCustomizeContainer extends Container {
    */
   async updateCustomizeHeader() {
     try {
-      const response = await this.appContainer.apiv3.put('/customize-setting/customize-header', {
+      const response = await apiv3Put('/customize-setting/customize-header', {
         customizeHeader: this.state.currentCustomizeHeader,
       });
       const { customizedParams } = response.data;
@@ -394,7 +394,7 @@ export default class AdminCustomizeContainer extends Container {
    */
   async updateCustomizeCss() {
     try {
-      const response = await this.appContainer.apiv3.put('/customize-setting/customize-css', {
+      const response = await apiv3Put('/customize-setting/customize-css', {
         customizeCss: this.state.currentCustomizeCss,
       });
       const { customizedParams } = response.data;
@@ -415,7 +415,7 @@ export default class AdminCustomizeContainer extends Container {
    */
   async updateCustomizeScript() {
     try {
-      const response = await this.appContainer.apiv3.put('/customize-setting/customize-script', {
+      const response = await apiv3Put('/customize-setting/customize-script', {
         customizeScript: this.state.currentCustomizeScript,
       });
       const { customizedParams } = response.data;

+ 5 - 5
packages/app/src/client/services/AdminExternalAccountsContainer.js

@@ -2,6 +2,8 @@ import { Container } from 'unstated';
 
 import loggerFactory from '~/utils/logger';
 
+import { apiv3Delete, apiv3Get } from '../util/apiv3-client';
+
 
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:services:AdminexternalaccountsContainer');
@@ -12,11 +14,9 @@ const logger = loggerFactory('growi:services:AdminexternalaccountsContainer');
  */
 export default class AdminExternalAccountsContainer extends Container {
 
-  constructor(appContainer) {
+  constructor() {
     super();
 
-    this.appContainer = appContainer;
-
     this.state = {
       externalAccounts: [],
       totalAccounts: 0,
@@ -42,7 +42,7 @@ export default class AdminExternalAccountsContainer extends Container {
   async retrieveExternalAccountsByPagingNum(selectedPage) {
 
     const params = { page: selectedPage };
-    const { data } = await this.appContainer.apiv3.get('/users/external-accounts', params);
+    const { data } = await apiv3Get('/users/external-accounts', params);
 
     if (data.paginateResult == null) {
       throw new Error('data must conclude \'paginateResult\' property.');
@@ -64,7 +64,7 @@ export default class AdminExternalAccountsContainer extends Container {
    * @param {string} externalAccountId id of the External Account to be removed
    */
   async removeExternalAccountById(externalAccountId) {
-    const res = await this.appContainer.apiv3.delete(`/users/external-accounts/${externalAccountId}/remove`);
+    const res = await apiv3Delete(`/users/external-accounts/${externalAccountId}/remove`);
     const deletedUserData = res.data.externalAccount;
     await this.retrieveExternalAccountsByPagingNum(this.state.activePage);
     return deletedUserData.accountId;

+ 9 - 8
packages/app/src/client/services/AdminGeneralSecurityContainer.js

@@ -4,9 +4,11 @@ import {
   PageSingleDeleteConfigValue, PageSingleDeleteCompConfigValue,
   PageRecursiveDeleteConfigValue, PageRecursiveDeleteCompConfigValue,
 } from '~/interfaces/page-delete-config';
-import { toastError } from '../util/apiNotification';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
+import { toastError } from '../util/apiNotification';
+import { apiv3Get, apiv3Put } from '../util/apiv3-client';
+
 /**
  * Service container for admin security page (SecuritySetting.jsx)
  * @extends {Container} unstated Container
@@ -16,7 +18,6 @@ export default class AdminGeneralSecurityContainer extends Container {
   constructor(appContainer) {
     super();
 
-    this.appContainer = appContainer;
     this.dummyCurrentRestrictGuestMode = 0;
     this.dummyCurrentRestrictGuestModeForError = 1;
 
@@ -64,7 +65,7 @@ export default class AdminGeneralSecurityContainer extends Container {
 
   async retrieveSecurityData() {
     await this.retrieveSetupStratedies();
-    const response = await this.appContainer.apiv3.get('/security-setting/');
+    const response = await apiv3Get('/security-setting/');
     const { generalSetting, shareLinkSetting, generalAuth } = response.data.securityParams;
     this.setState({
       currentRestrictGuestMode: generalSetting.restrictGuestMode,
@@ -215,7 +216,7 @@ export default class AdminGeneralSecurityContainer extends Container {
     };
 
     requestParams = await removeNullPropertyFromObject(requestParams);
-    const response = await this.appContainer.apiv3.put('/security-setting/general-setting', requestParams);
+    const response = await apiv3Put('/security-setting/general-setting', requestParams);
     const { securitySettingParams } = response.data;
     return securitySettingParams;
   }
@@ -227,7 +228,7 @@ export default class AdminGeneralSecurityContainer extends Container {
     const requestParams = {
       disableLinkSharing: !this.state.disableLinkSharing,
     };
-    const response = await this.appContainer.apiv3.put('/security-setting/share-link-setting', requestParams);
+    const response = await apiv3Put('/security-setting/share-link-setting', requestParams);
     this.setDisableLinkSharing(!this.state.disableLinkSharing);
     return response;
   }
@@ -238,7 +239,7 @@ export default class AdminGeneralSecurityContainer extends Container {
   async switchAuthentication(stateVariableName, authId) {
     const isEnabled = !this.state[stateVariableName];
     try {
-      await this.appContainer.apiv3.put('/security-setting/authentication/enabled', {
+      await apiv3Put('/security-setting/authentication/enabled', {
         isEnabled,
         authId,
       });
@@ -255,7 +256,7 @@ export default class AdminGeneralSecurityContainer extends Container {
    */
   async retrieveSetupStratedies() {
     try {
-      const response = await this.appContainer.apiv3.get('/security-setting/authentication');
+      const response = await apiv3Get('/security-setting/authentication');
       const { setupStrategies } = response.data;
       this.setState({ setupStrategies });
     }
@@ -273,7 +274,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       page,
     };
 
-    const { data } = await this.appContainer.apiv3.get('/security-setting/all-share-links', params);
+    const { data } = await apiv3Get('/security-setting/all-share-links', params);
 
     if (data.paginateResult == null) {
       throw new Error('data must conclude \'paginateResult\' property.');

+ 6 - 5
packages/app/src/client/services/AdminGitHubSecurityContainer.js

@@ -1,10 +1,12 @@
-import { Container } from 'unstated';
-
 import { pathUtils } from '@growi/core';
+import { Container } from 'unstated';
 import urljoin from 'url-join';
+
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
+import { apiv3Get, apiv3Put } from '../util/apiv3-client';
+
 const logger = loggerFactory('growi:security:AdminGitHubSecurityContainer');
 
 /**
@@ -16,7 +18,6 @@ export default class AdminGitHubSecurityContainer extends Container {
   constructor(appContainer) {
     super();
 
-    this.appContainer = appContainer;
     this.dummyGithubClientId = 0;
     this.dummyGithubClientIdForError = 1;
 
@@ -36,7 +37,7 @@ export default class AdminGitHubSecurityContainer extends Container {
    */
   async retrieveSecurityData() {
     try {
-      const response = await this.appContainer.apiv3.get('/security-setting/');
+      const response = await apiv3Get('/security-setting/');
       const { githubOAuth } = response.data.securityParams;
       this.setState({
         githubClientId: githubOAuth.githubClientId,
@@ -88,7 +89,7 @@ export default class AdminGitHubSecurityContainer extends Container {
     let requestParams = { githubClientId, githubClientSecret, isSameUsernameTreatedAsIdenticalUser };
 
     requestParams = await removeNullPropertyFromObject(requestParams);
-    const response = await this.appContainer.apiv3.put('/security-setting/github-oauth', requestParams);
+    const response = await apiv3Put('/security-setting/github-oauth', requestParams);
     const { securitySettingParams } = response.data;
 
     this.setState({

+ 16 - 13
packages/app/src/client/services/AdminGoogleSecurityContainer.js

@@ -1,10 +1,12 @@
-import { Container } from 'unstated';
-
 import { pathUtils } from '@growi/core';
+import { Container } from 'unstated';
 import urljoin from 'url-join';
+
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
+import { apiv3Get, apiv3Put } from '../util/apiv3-client';
+
 const logger = loggerFactory('growi:security:AdminGoogleSecurityContainer');
 
 /**
@@ -16,7 +18,6 @@ export default class AdminGoogleSecurityContainer extends Container {
   constructor(appContainer) {
     super();
 
-    this.appContainer = appContainer;
     this.dummyGoogleClientId = 0;
     this.dummyGoogleClientIdForError = 1;
 
@@ -26,7 +27,7 @@ export default class AdminGoogleSecurityContainer extends Container {
       // set dummy value tile for using suspense
       googleClientId: this.dummyGoogleClientId,
       googleClientSecret: '',
-      isSameUsernameTreatedAsIdenticalUser: false,
+      isSameEmailTreatedAsIdenticalUser: false,
     };
 
 
@@ -37,12 +38,12 @@ export default class AdminGoogleSecurityContainer extends Container {
    */
   async retrieveSecurityData() {
     try {
-      const response = await this.appContainer.apiv3.get('/security-setting/');
+      const response = await apiv3Get('/security-setting/');
       const { googleOAuth } = response.data.securityParams;
       this.setState({
         googleClientId: googleOAuth.googleClientId,
         googleClientSecret: googleOAuth.googleClientSecret,
-        isSameUsernameTreatedAsIdenticalUser: googleOAuth.isSameUsernameTreatedAsIdenticalUser,
+        isSameEmailTreatedAsIdenticalUser: googleOAuth.isSameEmailTreatedAsIdenticalUser,
       });
     }
     catch (err) {
@@ -74,30 +75,32 @@ export default class AdminGoogleSecurityContainer extends Container {
   }
 
   /**
-   * Switch isSameUsernameTreatedAsIdenticalUser
+   * Switch isSameEmailTreatedAsIdenticalUser
    */
-  switchIsSameUsernameTreatedAsIdenticalUser() {
-    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
+  switchIsSameEmailTreatedAsIdenticalUser() {
+    this.setState({ isSameEmailTreatedAsIdenticalUser: !this.state.isSameEmailTreatedAsIdenticalUser });
   }
 
+
   /**
    * Update googleSetting
    */
   async updateGoogleSetting() {
-    const { googleClientId, googleClientSecret, isSameUsernameTreatedAsIdenticalUser } = this.state;
+    const { googleClientId, googleClientSecret, isSameEmailTreatedAsIdenticalUser } = this.state;
+    console.log('updateGoogleSetting', isSameEmailTreatedAsIdenticalUser);
 
     let requestParams = {
-      googleClientId, googleClientSecret, isSameUsernameTreatedAsIdenticalUser,
+      googleClientId, googleClientSecret, isSameEmailTreatedAsIdenticalUser,
     };
 
     requestParams = await removeNullPropertyFromObject(requestParams);
-    const response = await this.appContainer.apiv3.put('/security-setting/google-oauth', requestParams);
+    const response = await apiv3Put('/security-setting/google-oauth', requestParams);
     const { securitySettingParams } = response.data;
 
     this.setState({
       googleClientId: securitySettingParams.googleClientId,
       googleClientSecret: securitySettingParams.googleClientSecret,
-      isSameUsernameTreatedAsIdenticalUser: securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
+      isSameEmailTreatedAsIdenticalUser: securitySettingParams.isSameEmailTreatedAsIdenticalUser,
     });
     return response;
   }

+ 3 - 4
packages/app/src/client/services/AdminHomeContainer.js

@@ -3,6 +3,7 @@ import { Container } from 'unstated';
 import loggerFactory from '~/utils/logger';
 
 import { toastError } from '../util/apiNotification';
+import { apiv3Get } from '../util/apiv3-client';
 
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:services:AdminHomeContainer');
@@ -13,11 +14,9 @@ const logger = loggerFactory('growi:services:AdminHomeContainer');
  */
 export default class AdminHomeContainer extends Container {
 
-  constructor(appContainer) {
+  constructor() {
     super();
 
-    this.appContainer = appContainer;
-
     this.copyStateValues = {
       DEFAULT: 'default',
       DONE: 'done',
@@ -53,7 +52,7 @@ export default class AdminHomeContainer extends Container {
    */
   async retrieveAdminHomeData() {
     try {
-      const response = await this.appContainer.apiv3.get('/admin-home/');
+      const response = await apiv3Get('/admin-home/');
       const { adminHomeParams } = response.data;
 
       this.setState(prevState => ({

+ 9 - 7
packages/app/src/client/services/AdminImportContainer.js

@@ -3,6 +3,8 @@ import { Container } from 'unstated';
 import loggerFactory from '~/utils/logger';
 
 import { toastSuccess, toastError } from '../util/apiNotification';
+import { apiPost } from '../util/apiv1-client';
+import { apiv3Get } from '../util/apiv3-client';
 
 const logger = loggerFactory('growi:appSettings');
 
@@ -48,7 +50,7 @@ export default class AdminImportContainer extends Container {
    * retrieve app sttings data
    */
   async retrieveImportSettingsData() {
-    const response = await this.appContainer.apiv3.get('/import/');
+    const response = await apiv3Get('/import/');
     const {
       importSettingsParams,
     } = response.data;
@@ -73,7 +75,7 @@ export default class AdminImportContainer extends Container {
         'importer:esa:team_name': this.state.esaTeamName,
         'importer:esa:access_token': this.state.esaAccessToken,
       };
-      await this.appContainer.apiPost('/admin/import/esa', params);
+      await apiPost('/admin/import/esa', params);
       toastSuccess('Import posts from esa success.');
     }
     catch (err) {
@@ -88,7 +90,7 @@ export default class AdminImportContainer extends Container {
         'importer:esa:team_name': this.state.esaTeamName,
         'importer:esa:access_token': this.state.esaAccessToken,
       };
-      await this.appContainer.apiPost('/admin/import/testEsaAPI', params);
+      await apiPost('/admin/import/testEsaAPI', params);
       toastSuccess('Test connection to esa success.');
     }
     catch (error) {
@@ -102,7 +104,7 @@ export default class AdminImportContainer extends Container {
       'importer:esa:access_token': this.state.esaAccessToken,
     };
     try {
-      await this.appContainer.apiPost('/admin/settings/importerEsa', params);
+      await apiPost('/admin/settings/importerEsa', params);
       toastSuccess('Updated');
     }
     catch (err) {
@@ -117,7 +119,7 @@ export default class AdminImportContainer extends Container {
         'importer:qiita:team_name': this.state.qiitaTeamName,
         'importer:qiita:access_token': this.state.qiitaAccessToken,
       };
-      await this.appContainer.apiPost('/admin/import/qiita', params);
+      await apiPost('/admin/import/qiita', params);
       toastSuccess('Import posts from qiita:team success.');
     }
     catch (err) {
@@ -133,7 +135,7 @@ export default class AdminImportContainer extends Container {
         'importer:qiita:team_name': this.state.qiitaTeamName,
         'importer:qiita:access_token': this.state.qiitaAccessToken,
       };
-      await this.appContainer.apiPost('/admin/import/testQiitaAPI', params);
+      await apiPost('/admin/import/testQiitaAPI', params);
       toastSuccess('Test connection to qiita:team success.');
     }
     catch (err) {
@@ -148,7 +150,7 @@ export default class AdminImportContainer extends Container {
       'importer:qiita:access_token': this.state.qiitaAccessToken,
     };
     try {
-      await this.appContainer.apiPost('/admin/settings/importerQiita', params);
+      await apiPost('/admin/settings/importerQiita', params);
       toastSuccess('Updated');
     }
     catch (err) {

+ 5 - 3
packages/app/src/client/services/AdminLdapSecurityContainer.js

@@ -1,8 +1,10 @@
 import { Container } from 'unstated';
-import loggerFactory from '~/utils/logger';
 
+import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
+import { apiv3Get, apiv3Put } from '../util/apiv3-client';
+
 const logger = loggerFactory('growi:services:AdminLdapSecurityContainer');
 
 /**
@@ -42,7 +44,7 @@ export default class AdminLdapSecurityContainer extends Container {
    */
   async retrieveSecurityData() {
     try {
-      const response = await this.appContainer.apiv3.get('/security-setting/');
+      const response = await apiv3Get('/security-setting/');
       const { ldapAuth } = response.data.securityParams;
       this.setState({
         serverUrl: ldapAuth.serverUrl,
@@ -183,7 +185,7 @@ export default class AdminLdapSecurityContainer extends Container {
     };
 
     requestParams = await removeNullPropertyFromObject(requestParams);
-    const response = await this.appContainer.apiv3.put('/security-setting/ldap', requestParams);
+    const response = await apiv3Put('/security-setting/ldap', requestParams);
     const { securitySettingParams } = response.data;
 
     this.setState({

+ 5 - 2
packages/app/src/client/services/AdminLocalSecurityContainer.js

@@ -1,6 +1,9 @@
 import { Container } from 'unstated';
+
 import loggerFactory from '~/utils/logger';
 
+import { apiv3Get, apiv3Put } from '../util/apiv3-client';
+
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:services:AdminLocalSecurityContainer');
 /**
@@ -30,7 +33,7 @@ export default class AdminLocalSecurityContainer extends Container {
 
   async retrieveSecurityData() {
     try {
-      const response = await this.appContainer.apiv3.get('/security-setting/');
+      const response = await apiv3Get('/security-setting/');
       const { localSetting } = response.data.securityParams;
       this.setState({
         useOnlyEnvVars: localSetting.useOnlyEnvVarsForSomeOptions,
@@ -89,7 +92,7 @@ export default class AdminLocalSecurityContainer extends Container {
    */
   async updateLocalSecuritySetting() {
     const { registrationWhiteList, isPasswordResetEnabled, isEmailAuthenticationEnabled } = this.state;
-    const response = await this.appContainer.apiv3.put('/security-setting/local-setting', {
+    const response = await apiv3Put('/security-setting/local-setting', {
       registrationMode: this.state.registrationMode,
       registrationWhiteList,
       isPasswordResetEnabled,

+ 7 - 5
packages/app/src/client/services/AdminMarkDownContainer.js

@@ -1,5 +1,7 @@
 import { Container } from 'unstated';
 
+import { apiv3Get, apiv3Put } from '../util/apiv3-client';
+
 /**
  * Service container for admin markdown setting page (MarkDownSetting.jsx)
  * @extends {Container} unstated Container
@@ -43,7 +45,7 @@ export default class AdminMarkDownContainer extends Container {
    * retrieve markdown data
    */
   async retrieveMarkdownData() {
-    const response = await this.appContainer.apiv3.get('/markdown-setting/');
+    const response = await apiv3Get('/markdown-setting/');
     const { markdownParams } = response.data;
 
     this.setState({
@@ -93,7 +95,7 @@ export default class AdminMarkDownContainer extends Container {
    */
   async updateLineBreakSetting() {
 
-    const response = await this.appContainer.apiv3.put('/markdown-setting/lineBreak', {
+    const response = await apiv3Put('/markdown-setting/lineBreak', {
       isEnabledLinebreaks: this.state.isEnabledLinebreaks,
       isEnabledLinebreaksInComments: this.state.isEnabledLinebreaksInComments,
     });
@@ -106,7 +108,7 @@ export default class AdminMarkDownContainer extends Container {
    */
   async updateIndentSetting() {
 
-    const response = await this.appContainer.apiv3.put('/markdown-setting/indent', {
+    const response = await apiv3Put('/markdown-setting/indent', {
       adminPreferredIndentSize: this.state.adminPreferredIndentSize,
       isIndentSizeForced: this.state.isIndentSizeForced,
     });
@@ -123,7 +125,7 @@ export default class AdminMarkDownContainer extends Container {
     tagWhiteList = Array.isArray(tagWhiteList) ? tagWhiteList : tagWhiteList.split(',');
     attrWhiteList = Array.isArray(attrWhiteList) ? attrWhiteList : attrWhiteList.split(',');
 
-    const response = await this.appContainer.apiv3.put('/markdown-setting/xss', {
+    const response = await apiv3Put('/markdown-setting/xss', {
       isEnabledXss: this.state.isEnabledXss,
       xssOption: this.state.xssOption,
       tagWhiteList,
@@ -138,7 +140,7 @@ export default class AdminMarkDownContainer extends Container {
    */
   async updatePresentationSetting() {
 
-    const response = await this.appContainer.apiv3.put('/markdown-setting/presentation', {
+    const response = await apiv3Put('/markdown-setting/presentation', {
       pageBreakSeparator: this.state.pageBreakSeparator,
       pageBreakCustomSeparator: this.state.pageBreakCustomSeparator,
     });

+ 10 - 6
packages/app/src/client/services/AdminNotificationContainer.js

@@ -1,5 +1,9 @@
 import { Container } from 'unstated';
 
+import {
+  apiv3Delete, apiv3Get, apiv3Post, apiv3Put,
+} from '../util/apiv3-client';
+
 /**
  * Service container for admin Notification setting page (NotificationSetting.jsx)
  * @extends {Container} unstated Container
@@ -37,7 +41,7 @@ export default class AdminNotificationContainer extends Container {
    * Retrieve notificationData
    */
   async retrieveNotificationData() {
-    const response = await this.appContainer.apiv3.get('/notification-setting/');
+    const response = await apiv3Get('/notification-setting/');
     const { notificationParams } = response.data;
 
     this.setState({
@@ -57,7 +61,7 @@ export default class AdminNotificationContainer extends Container {
    * @memberOf SlackAppConfiguration
    */
   async updateSlackAppConfiguration() {
-    const response = await this.appContainer.apiv3.put('/notification-setting/slack-configuration', {
+    const response = await apiv3Put('/notification-setting/slack-configuration', {
       webhookUrl: this.state.webhookUrl,
       isIncomingWebhookPrioritized: this.state.isIncomingWebhookPrioritized,
       slackToken: this.state.slackToken,
@@ -71,7 +75,7 @@ export default class AdminNotificationContainer extends Container {
    * @memberOf SlackAppConfiguration
    */
   async addNotificationPattern(pathPattern, channel) {
-    const response = await this.appContainer.apiv3.post('/notification-setting/user-notification', {
+    const response = await apiv3Post('/notification-setting/user-notification', {
       pathPattern,
       channel,
     });
@@ -83,7 +87,7 @@ export default class AdminNotificationContainer extends Container {
    * Delete user trigger notification pattern
    */
   async deleteUserTriggerNotificationPattern(notificatiionId) {
-    const response = await this.appContainer.apiv3.delete(`/notification-setting/user-notification/${notificatiionId}`);
+    const response = await apiv3Delete(`/notification-setting/user-notification/${notificatiionId}`);
     const deletedNotificaton = response.data;
     await this.retrieveNotificationData();
     return deletedNotificaton;
@@ -108,7 +112,7 @@ export default class AdminNotificationContainer extends Container {
    * @memberOf SlackAppConfiguration
    */
   async updateGlobalNotificationForPages() {
-    const response = await this.appContainer.apiv3.put('/notification-setting/notify-for-page-grant/', {
+    const response = await apiv3Put('/notification-setting/notify-for-page-grant/', {
       isNotificationForOwnerPageEnabled: this.state.isNotificationForOwnerPageEnabled,
       isNotificationForGroupPageEnabled: this.state.isNotificationForGroupPageEnabled,
     });
@@ -120,7 +124,7 @@ export default class AdminNotificationContainer extends Container {
    * Delete global notification pattern
    */
   async deleteGlobalNotificationPattern(notificatiionId) {
-    const response = await this.appContainer.apiv3.delete(`/notification-setting/global-notification/${notificatiionId}`);
+    const response = await apiv3Delete(`/notification-setting/global-notification/${notificatiionId}`);
     const deletedNotificaton = response.data;
     await this.retrieveNotificationData();
     return deletedNotificaton;

+ 6 - 4
packages/app/src/client/services/AdminOidcSecurityContainer.js

@@ -1,10 +1,12 @@
-import { Container } from 'unstated';
-
 import { pathUtils } from '@growi/core';
+import { Container } from 'unstated';
 import urljoin from 'url-join';
+
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
+import { apiv3Get, apiv3Put } from '../util/apiv3-client';
+
 const logger = loggerFactory('growi:services:AdminLdapSecurityContainer');
 
 /**
@@ -51,7 +53,7 @@ export default class AdminOidcSecurityContainer extends Container {
    */
   async retrieveSecurityData() {
     try {
-      const response = await this.appContainer.apiv3.get('/security-setting/');
+      const response = await apiv3Get('/security-setting/');
       const { oidcAuth } = response.data.securityParams;
       this.setState({
         oidcProviderName: oidcAuth.oidcProviderName,
@@ -261,7 +263,7 @@ export default class AdminOidcSecurityContainer extends Container {
     };
 
     requestParams = await removeNullPropertyFromObject(requestParams);
-    const response = await this.appContainer.apiv3.put('/security-setting/oidc', requestParams);
+    const response = await apiv3Put('/security-setting/oidc', requestParams);
     const { securitySettingParams } = response.data;
 
     this.setState({

+ 6 - 5
packages/app/src/client/services/AdminSamlSecurityContainer.js

@@ -1,11 +1,12 @@
-import { Container } from 'unstated';
-
-
 import { pathUtils } from '@growi/core';
+import { Container } from 'unstated';
 import urljoin from 'url-join';
+
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
+import { apiv3Get, apiv3Put } from '../util/apiv3-client';
+
 const logger = loggerFactory('growi:security:AdminSamlSecurityContainer');
 
 /**
@@ -57,7 +58,7 @@ export default class AdminSamlSecurityContainer extends Container {
    */
   async retrieveSecurityData() {
     try {
-      const response = await this.appContainer.apiv3.get('/security-setting/');
+      const response = await apiv3Get('/security-setting/');
       const { samlAuth } = response.data.securityParams;
       this.setState({
         missingMandatoryConfigKeys: samlAuth.missingMandatoryConfigKeys,
@@ -195,7 +196,7 @@ export default class AdminSamlSecurityContainer extends Container {
     };
 
     requestParams = await removeNullPropertyFromObject(requestParams);
-    const response = await this.appContainer.apiv3.put('/security-setting/saml', requestParams);
+    const response = await apiv3Put('/security-setting/saml', requestParams);
     const { securitySettingParams } = response.data;
 
     this.setState({

+ 4 - 2
packages/app/src/client/services/AdminSlackIntegrationLegacyContainer.js

@@ -1,5 +1,7 @@
 import { Container } from 'unstated';
 
+import { apiv3Get, apiv3Put } from '../util/apiv3-client';
+
 /**
  * Service container for admin LegacySlackIntegration setting page (LegacySlackIntegration.jsx)
  * @extends {Container} unstated Container
@@ -35,7 +37,7 @@ export default class AdminSlackIntegrationLegacyContainer extends Container {
    * Retrieve notificationData
    */
   async retrieveData() {
-    const response = await this.appContainer.apiv3.get('/slack-integration-legacy-settings/');
+    const response = await apiv3Get('/slack-integration-legacy-settings/');
     const { slackIntegrationParams } = response.data;
 
     this.setState({
@@ -79,7 +81,7 @@ export default class AdminSlackIntegrationLegacyContainer extends Container {
    * @memberOf SlackAppConfiguration
    */
   async updateSlackAppConfiguration() {
-    const response = await this.appContainer.apiv3.put('/slack-integration-legacy-settings/', {
+    const response = await apiv3Put('/slack-integration-legacy-settings/', {
       webhookUrl: this.state.webhookUrl,
       isIncomingWebhookPrioritized: this.state.isIncomingWebhookPrioritized,
       slackToken: this.state.slackToken,

+ 6 - 4
packages/app/src/client/services/AdminTwitterSecurityContainer.js

@@ -1,10 +1,12 @@
-import { Container } from 'unstated';
-
 import { pathUtils } from '@growi/core';
+import { Container } from 'unstated';
 import urljoin from 'url-join';
+
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
+import { apiv3Get, apiv3Put } from '../util/apiv3-client';
+
 const logger = loggerFactory('growi:security:AdminTwitterSecurityContainer');
 
 /**
@@ -36,7 +38,7 @@ export default class AdminTwitterSecurityContainer extends Container {
    */
   async retrieveSecurityData() {
     try {
-      const response = await this.appContainer.apiv3.get('/security-setting/');
+      const response = await apiv3Get('/security-setting/');
       const { twitterOAuth } = response.data.securityParams;
       this.setState({
         twitterConsumerKey: twitterOAuth.twitterConsumerKey,
@@ -88,7 +90,7 @@ export default class AdminTwitterSecurityContainer extends Container {
     let requestParams = { twitterConsumerKey, twitterConsumerSecret, isSameUsernameTreatedAsIdenticalUser };
 
     requestParams = await removeNullPropertyFromObject(requestParams);
-    const response = await this.appContainer.apiv3.put('/security-setting/twitter-oauth', requestParams);
+    const response = await apiv3Put('/security-setting/twitter-oauth', requestParams);
     const { securitySettingParams } = response.data;
 
     this.setState({

+ 13 - 8
packages/app/src/client/services/AdminUsersContainer.js

@@ -1,7 +1,12 @@
-import { Container } from 'unstated';
 import { debounce } from 'throttle-debounce';
+import { Container } from 'unstated';
+
 import loggerFactory from '~/utils/logger';
 
+import {
+  apiv3Delete, apiv3Get, apiv3Post, apiv3Put,
+} from '../util/apiv3-client';
+
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:services:AdminUserGroupDetailContainer');
 
@@ -135,7 +140,7 @@ export default class AdminUsersContainer extends Container {
       // Even if email is hidden, it will be displayed on admin page.
       forceIncludeAttributes: ['email'],
     };
-    const { data } = await this.appContainer.apiv3.get('/users', params);
+    const { data } = await apiv3Get('/users', params);
 
     if (data.paginateResult == null) {
       throw new Error('data must conclude \'paginateResult\' property.');
@@ -159,7 +164,7 @@ export default class AdminUsersContainer extends Container {
    * @param {bool} sendEmail
    */
   async createUserInvited(shapedEmailList, sendEmail) {
-    const response = await this.appContainer.apiv3.post('/users/invite', {
+    const response = await apiv3Post('/users/invite', {
       shapedEmailList,
       sendEmail,
     });
@@ -205,7 +210,7 @@ export default class AdminUsersContainer extends Container {
    * @return {string} username
    */
   async giveUserAdmin(userId) {
-    const response = await this.appContainer.apiv3.put(`/users/${userId}/giveAdmin`);
+    const response = await apiv3Put(`/users/${userId}/giveAdmin`);
     const { username } = response.data.userData;
     await this.retrieveUsersByPagingNum(this.state.activePage);
     return username;
@@ -218,7 +223,7 @@ export default class AdminUsersContainer extends Container {
    * @return {string} username
    */
   async removeUserAdmin(userId) {
-    const response = await this.appContainer.apiv3.put(`/users/${userId}/removeAdmin`);
+    const response = await apiv3Put(`/users/${userId}/removeAdmin`);
     const { username } = response.data.userData;
     await this.retrieveUsersByPagingNum(this.state.activePage);
     return username;
@@ -231,7 +236,7 @@ export default class AdminUsersContainer extends Container {
    * @return {string} username
    */
   async activateUser(userId) {
-    const response = await this.appContainer.apiv3.put(`/users/${userId}/activate`);
+    const response = await apiv3Put(`/users/${userId}/activate`);
     const { username } = response.data.userData;
     await this.retrieveUsersByPagingNum(this.state.activePage);
     return username;
@@ -244,7 +249,7 @@ export default class AdminUsersContainer extends Container {
    * @return {string} username
    */
   async deactivateUser(userId) {
-    const response = await this.appContainer.apiv3.put(`/users/${userId}/deactivate`);
+    const response = await apiv3Put(`/users/${userId}/deactivate`);
     const { username } = response.data.userData;
     await this.retrieveUsersByPagingNum(this.state.activePage);
     return username;
@@ -257,7 +262,7 @@ export default class AdminUsersContainer extends Container {
    * @return {object} removedUserData
    */
   async removeUser(userId) {
-    const response = await this.appContainer.apiv3.delete(`/users/${userId}/remove`);
+    const response = await apiv3Delete(`/users/${userId}/remove`);
     const removedUserData = response.data.userData;
     await this.retrieveUsersByPagingNum(this.state.activePage);
     return removedUserData;

+ 0 - 91
packages/app/src/client/services/AppContainer.js

@@ -2,19 +2,7 @@ import { Container } from 'unstated';
 
 import InterceptorManager from '~/services/interceptor-manager';
 
-import {
-  apiDelete, apiGet, apiPost, apiRequest,
-} from '../util/apiv1-client';
-import {
-  apiv3Delete, apiv3Get, apiv3Post, apiv3Put,
-} from '../util/apiv3-client';
 import GrowiRenderer from '../util/GrowiRenderer';
-
-import {
-  mediaQueryListForDarkMode,
-  applyColorScheme,
-} from '../util/color-scheme';
-
 import { i18nFactory } from '../util/i18n';
 
 /**
@@ -26,10 +14,6 @@ export default class AppContainer extends Container {
   constructor() {
     super();
 
-    this.state = {
-      preferDarkModeByMediaQuery: false,
-    };
-
     // get csrf token from body element
     // DO NOT REMOVE: uploading attachment data requires appContainer.csrfToken
     const body = document.querySelector('body');
@@ -37,9 +21,6 @@ export default class AppContainer extends Container {
 
     this.config = JSON.parse(document.getElementById('growi-context-hydrate').textContent || '{}');
 
-    const userAgent = window.navigator.userAgent.toLowerCase();
-    this.isMobile = /iphone|ipad|android/.test(userAgent);
-
     const currentUserElem = document.getElementById('growi-current-user');
     if (currentUserElem != null) {
       this.currentUser = JSON.parse(currentUserElem.textContent);
@@ -57,23 +38,6 @@ export default class AppContainer extends Container {
     this.containerInstances = {};
     this.componentInstances = {};
     this.rendererInstances = {};
-
-    this.apiGet = apiGet;
-    this.apiPost = apiPost;
-    this.apiDelete = apiDelete;
-    this.apiRequest = apiRequest;
-
-    this.apiv3Get = apiv3Get;
-    this.apiv3Post = apiv3Post;
-    this.apiv3Put = apiv3Put;
-    this.apiv3Delete = apiv3Delete;
-
-    this.apiv3 = {
-      get: apiv3Get,
-      post: apiv3Post,
-      put: apiv3Put,
-      delete: apiv3Delete,
-    };
   }
 
   /**
@@ -84,27 +48,18 @@ export default class AppContainer extends Container {
   }
 
   initApp() {
-    this.initMediaQueryForColorScheme();
-
     this.injectToWindow();
   }
 
   initContents() {
     const body = document.querySelector('body');
 
-    this.isAdmin = body.dataset.isAdmin === 'true';
-
     this.isDocSaved = true;
 
     this.originRenderer = new GrowiRenderer(this);
 
     this.interceptorManager = new InterceptorManager();
 
-    if (this.currentUser != null) {
-      // remove old user cache
-      this.removeOldUserCache();
-    }
-
     const isPluginEnabled = body.dataset.pluginEnabled === 'true';
     if (isPluginEnabled) {
       this.initPlugins();
@@ -113,18 +68,6 @@ export default class AppContainer extends Container {
     this.injectToWindow();
   }
 
-  async initMediaQueryForColorScheme() {
-    const switchStateByMediaQuery = async(mql) => {
-      const preferDarkMode = mql.matches;
-      this.setState({ preferDarkModeByMediaQuery: preferDarkMode });
-
-      applyColorScheme();
-    };
-
-    // add event listener
-    mediaQueryListForDarkMode.addListener(switchStateByMediaQuery);
-  }
-
   initPlugins() {
     const growiPlugin = window.growiPlugin;
     growiPlugin.installAll(this, this.originRenderer);
@@ -221,28 +164,6 @@ export default class AppContainer extends Container {
     return this.componentInstances[id];
   }
 
-  /**
-   *
-   * @param {string} breakpoint id of breakpoint
-   * @param {function} handler event handler for media query
-   * @param {boolean} invokeOnInit invoke handler after the initialization if true
-   */
-  addBreakpointListener(breakpoint, handler, invokeOnInit = false) {
-    document.addEventListener('DOMContentLoaded', () => {
-      // get the value of '--breakpoint-*'
-      const breakpointPixel = parseInt(window.getComputedStyle(document.documentElement).getPropertyValue(`--breakpoint-${breakpoint}`), 10);
-
-      const mediaQuery = window.matchMedia(`(min-width: ${breakpointPixel}px)`);
-
-      // add event listener
-      mediaQuery.addListener(handler);
-      // initialize
-      if (invokeOnInit) {
-        handler(mediaQuery);
-      }
-    });
-  }
-
   getOriginRenderer() {
     return this.originRenderer;
   }
@@ -266,18 +187,6 @@ export default class AppContainer extends Container {
   }
 
 
-  removeOldUserCache() {
-    if (window.localStorage.userByName == null) {
-      return;
-    }
-
-    const keys = ['userByName', 'userById', 'users', 'lastFetched'];
-
-    keys.forEach((key) => {
-      window.localStorage.removeItem(key);
-    });
-  }
-
   launchHandsontableModal(componentKind, beginLineNumber, endLineNumber) {
     let targetComponent;
     switch (componentKind) {

+ 9 - 6
packages/app/src/client/services/CommentContainer.js

@@ -2,6 +2,9 @@ import { Container } from 'unstated';
 
 import loggerFactory from '~/utils/logger';
 
+import { apiGet, apiPost } from '../util/apiv1-client';
+import { apiv3Put } from '../util/apiv3-client';
+
 const logger = loggerFactory('growi:services:CommentContainer');
 
 /**
@@ -67,7 +70,7 @@ export default class CommentContainer extends Container {
     const { pageId } = this.getPageContainer().state;
 
     // get data (desc order array)
-    const res = await this.appContainer.apiGet('/comments.get', { page_id: pageId });
+    const res = await apiGet('/comments.get', { page_id: pageId });
     if (res.ok) {
       const comments = res.comments;
       this.setState({ comments });
@@ -89,7 +92,7 @@ export default class CommentContainer extends Container {
     }
 
     try {
-      await this.appContainer.apiv3Put('/users/update.imageUrlCache', { userIds: noImageCacheUserIds });
+      await apiv3Put('/users/update.imageUrlCache', { userIds: noImageCacheUserIds });
     }
     catch (err) {
       // Error alert doesn't apear, because user don't need to notice this error.
@@ -103,7 +106,7 @@ export default class CommentContainer extends Container {
   postComment(comment, isMarkdown, replyTo, isSlackEnabled, slackChannels) {
     const { pageId, revisionId } = this.getPageContainer().state;
 
-    return this.appContainer.apiPost('/comments.add', {
+    return apiPost('/comments.add', {
       commentForm: {
         comment,
         page_id: pageId,
@@ -129,7 +132,7 @@ export default class CommentContainer extends Container {
   putComment(comment, isMarkdown, commentId, author) {
     const { pageId, revisionId } = this.getPageContainer().state;
 
-    return this.appContainer.apiPost('/comments.update', {
+    return apiPost('/comments.update', {
       commentForm: {
         comment,
         is_markdown: isMarkdown,
@@ -145,7 +148,7 @@ export default class CommentContainer extends Container {
   }
 
   deleteComment(comment) {
-    return this.appContainer.apiPost('/comments.remove', { comment_id: comment._id })
+    return apiPost('/comments.remove', { comment_id: comment._id })
       .then((res) => {
         if (res.ok) {
           this.findAndSplice(comment);
@@ -163,7 +166,7 @@ export default class CommentContainer extends Container {
     formData.append('path', pagePath);
     formData.append('page_id', pageId);
 
-    return this.appContainer.apiPost(endpoint, formData);
+    return apiPost(endpoint, formData);
   }
 
 }

+ 6 - 1
packages/app/src/client/services/ContextExtractor.tsx

@@ -16,8 +16,9 @@ import {
   useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
-  useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
+  useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
   useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
+  useDefaultIndentSize, useIsIndentSizeForced,
 } from '../../stores/context';
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
@@ -70,6 +71,7 @@ const ContextExtractorOnce: FC = () => {
   const isForbidden = forbiddenContent != null;
   const pageUser = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
   const hasChildren = JSON.parse(mainContent?.getAttribute('data-page-has-children') || jsonNull);
+  const hasParent = JSON.parse(mainContent?.getAttribute('data-has-parent') || jsonNull);
   const templateTagData = mainContent?.getAttribute('data-template-tags') || null;
   const shareLinksNumber = mainContent?.getAttribute('data-share-links-number');
   const shareLinkId = JSON.parse(mainContent?.getAttribute('data-share-link-id') || jsonNull);
@@ -109,6 +111,8 @@ const ContextExtractorOnce: FC = () => {
   useIsSearchServiceConfigured(configByContextHydrate.isSearchServiceConfigured);
   useIsSearchServiceReachable(configByContextHydrate.isSearchServiceReachable);
   useIsEnabledAttachTitleHeader(configByContextHydrate.isEnabledAttachTitleHeader);
+  useIsIndentSizeForced(configByContextHydrate.isIndentSizeForced);
+  useDefaultIndentSize(configByContextHydrate.adminPreferredIndentSize);
 
 
   // Page
@@ -141,6 +145,7 @@ const ContextExtractorOnce: FC = () => {
   useNotFoundTargetPathOrId(notFoundTargetPathOrId);
   useIsNotFoundPermalink(isNotFoundPermalink);
   useIsSearchPage(isSearchPage);
+  useHasParent(hasParent);
 
   // Navigation
   usePreferDrawerModeByUser();

+ 2 - 77
packages/app/src/client/services/EditorContainer.js

@@ -4,46 +4,27 @@ import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:services:EditorContainer');
 
+
 /**
  * Service container related to options for Editor/Preview
  * @extends {Container} unstated Container
  */
 export default class EditorContainer extends Container {
 
-  constructor(appContainer, defaultEditorOptions, defaultPreviewOptions) {
+  constructor(appContainer) {
     super();
 
     this.appContainer = appContainer;
     this.appContainer.registerContainer(this);
-    this.retrieveEditorSettings = this.retrieveEditorSettings.bind(this);
-
-    const mainContent = document.querySelector('#content-main');
-
-    if (mainContent == null) {
-      logger.debug('#content-main element is not exists');
-      return;
-    }
 
     this.state = {
       tags: null,
-
-      editorOptions: {},
-      previewOptions: {},
-
-      // Defaults to null to show modal when not in DB
-      isTextlintEnabled: null,
-      textlintRules: [],
-
-      indentSize: this.appContainer.config.adminPreferredIndentSize || 4,
     };
 
     this.isSetBeforeunloadEventHandler = false;
 
     this.initDrafts();
 
-    this.editorOptions = null;
-    this.initEditorOptions('editorOptions', 'editorOptions', defaultEditorOptions);
-    this.initEditorOptions('previewOptions', 'previewOptions', defaultPreviewOptions);
   }
 
   /**
@@ -78,30 +59,6 @@ export default class EditorContainer extends Container {
     }
   }
 
-  initEditorOptions(stateKey, localStorageKey, defaultOptions) {
-    // load from localStorage
-    const optsStr = window.localStorage[localStorageKey];
-
-    let loadedOpts = {};
-    // JSON.parseparse
-    if (optsStr != null) {
-      try {
-        loadedOpts = JSON.parse(optsStr);
-      }
-      catch (e) {
-        this.localStorage.removeItem(localStorageKey);
-      }
-    }
-
-    // set to state obj
-    this.state[stateKey] = Object.assign(defaultOptions, loadedOpts);
-  }
-
-  saveOptsToLocalStorage() {
-    window.localStorage.setItem('editorOptions', JSON.stringify(this.state.editorOptions));
-    window.localStorage.setItem('previewOptions', JSON.stringify(this.state.previewOptions));
-  }
-
   setCaretLine(line) {
     const pageEditor = this.appContainer.getComponentInstance('PageEditor');
     if (pageEditor != null) {
@@ -116,19 +73,11 @@ export default class EditorContainer extends Container {
     }
   }
 
-  // TODO: Remove when SWR is complete
   getCurrentOptionsToSave() {
     const opt = {
-      // isSlackEnabled: this.state.isSlackEnabled,
-      // slackChannels: this.state.slackChannels,
-      // grant: this.state.grant,
       pageTags: this.state.tags,
     };
 
-    // if (this.state.grantGroupId != null) {
-    //   opt.grantUserGroupId = this.state.grantGroupId;
-    // }
-
     return opt;
   }
 
@@ -175,28 +124,4 @@ export default class EditorContainer extends Container {
     return null;
   }
 
-
-  /**
-   * Retrieve Editor Settings
-   */
-  async retrieveEditorSettings() {
-    if (this.appContainer.isGuestUser) {
-      return;
-    }
-
-    const { data } = await this.appContainer.apiv3Get('/personal-setting/editor-settings');
-
-    if (data?.textlintSettings == null) {
-      return;
-    }
-
-    // Defaults to null to show modal when not in DB
-    const { isTextlintEnabled = null, textlintRules = [] } = data.textlintSettings;
-
-    this.setState({
-      isTextlintEnabled,
-      textlintRules,
-    });
-  }
-
 }

+ 9 - 9
packages/app/src/client/services/PageContainer.js

@@ -1,19 +1,19 @@
-import { Container } from 'unstated';
-
-
+import { pagePathUtils } from '@growi/core';
 import * as entities from 'entities';
 import * as toastr from 'toastr';
-import { pagePathUtils } from '@growi/core';
+import { Container } from 'unstated';
+
 
-import loggerFactory from '~/utils/logger';
 import { EditorMode } from '~/stores/ui';
+import loggerFactory from '~/utils/logger';
 
 import { toastError } from '../util/apiNotification';
+import { apiPost } from '../util/apiv1-client';
+import { apiv3Post } from '../util/apiv3-client';
 import {
   DetachCodeBlockInterceptor,
   RestoreCodeBlockInterceptor,
 } from '../util/interceptor/detach-code-blocks';
-
 import {
   DrawioInterceptor,
 } from '../util/interceptor/drawio-interceptor';
@@ -121,7 +121,7 @@ export default class PageContainer extends Container {
     if (unlinkPageButton != null) {
       unlinkPageButton.addEventListener('click', async() => {
         try {
-          const res = await this.appContainer.apiPost('/pages.unlink', { path });
+          const res = await apiPost('/pages.unlink', { path });
           window.location.href = encodeURI(`${res.path}?unlinked=true`);
         }
         catch (err) {
@@ -371,7 +371,7 @@ export default class PageContainer extends Container {
       body: markdown,
     });
 
-    const res = await this.appContainer.apiv3Post('/pages/', params);
+    const res = await apiv3Post('/pages/', params);
     const { page, tags, revision } = res.data;
 
     return { page, tags, revision };
@@ -387,7 +387,7 @@ export default class PageContainer extends Container {
       body: markdown,
     });
 
-    const res = await this.appContainer.apiPost('/pages.update', params);
+    const res = await apiPost('/pages.update', params);
     if (!res.ok) {
       throw new Error(res.error);
     }

+ 3 - 2
packages/app/src/client/services/PageHistoryContainer.js

@@ -3,6 +3,7 @@ import { Container } from 'unstated';
 import loggerFactory from '~/utils/logger';
 
 import { toastError } from '../util/apiNotification';
+import { apiv3Get } from '../util/apiv3-client';
 
 const logger = loggerFactory('growi:PageHistoryContainer');
 
@@ -60,7 +61,7 @@ export default class PageHistoryContainer extends Container {
     }
 
     // Get one more for the bottom display
-    const res = await this.appContainer.apiv3Get('/revisions/list', {
+    const res = await apiv3Get('/revisions/list', {
       pageId, shareLinkId, page, limit: pagingLimitForApiParam,
     });
     const rev = res.data.docs;
@@ -147,7 +148,7 @@ export default class PageHistoryContainer extends Container {
     }
 
     try {
-      const res = await this.appContainer.apiv3Get(`/revisions/${revision._id}`, { pageId, shareLinkId });
+      const res = await apiv3Get(`/revisions/${revision._id}`, { pageId, shareLinkId });
       this.setState({
         revisions: this.state.revisions.map((rev) => {
           // comparing ObjectId

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

@@ -2,6 +2,9 @@ import { Container } from 'unstated';
 
 import loggerFactory from '~/utils/logger';
 
+import { apiPost } from '../util/apiv1-client';
+import { apiv3Get, apiv3Put } from '../util/apiv3-client';
+
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:services:PersonalContainer');
 
@@ -47,7 +50,7 @@ export default class PersonalContainer extends Container {
    */
   async retrievePersonalData() {
     try {
-      const response = await this.appContainer.apiv3.get('/personal-setting/');
+      const response = await apiv3Get('/personal-setting/');
       const { currentUser } = response.data;
       this.setState({
         name: currentUser.name,
@@ -90,7 +93,7 @@ export default class PersonalContainer extends Container {
    */
   async retrieveExternalAccounts() {
     try {
-      const response = await this.appContainer.apiv3.get('/personal-setting/external-accounts');
+      const response = await apiv3Get('/personal-setting/external-accounts');
       const { externalAccounts } = response.data;
 
       this.setState({ externalAccounts });
@@ -151,7 +154,7 @@ export default class PersonalContainer extends Container {
    */
   async updateBasicInfo() {
     try {
-      const response = await this.appContainer.apiv3.put('/personal-setting/', {
+      const response = await apiv3Put('/personal-setting/', {
         name: this.state.name,
         email: this.state.email,
         isEmailPublished: this.state.isEmailPublished,
@@ -181,7 +184,7 @@ export default class PersonalContainer extends Container {
    */
   async updateProfileImage() {
     try {
-      const response = await this.appContainer.apiv3.put('/personal-setting/image-type', {
+      const response = await apiv3Put('/personal-setting/image-type', {
         isGravatarEnabled: this.state.isGravatarEnabled,
       });
       const { userData } = response.data;
@@ -204,7 +207,7 @@ export default class PersonalContainer extends Container {
       const formData = new FormData();
       formData.append('file', file);
       formData.append('_csrf', this.appContainer.csrfToken);
-      const response = await this.appContainer.apiPost('/attachments.uploadProfileImage', formData);
+      const response = await apiPost('/attachments.uploadProfileImage', formData);
       this.setState({ isUploadedPicture: true, uploadedPictureSrc: response.attachment.filePathProxied });
     }
     catch (err) {
@@ -219,7 +222,7 @@ export default class PersonalContainer extends Container {
    */
   async deleteProfileImage() {
     try {
-      await this.appContainer.apiPost('/attachments.removeProfileImage', { _csrf: this.appContainer.csrfToken });
+      await apiPost('/attachments.removeProfileImage', { _csrf: this.appContainer.csrfToken });
       this.setState({ isUploadedPicture: false, uploadedPictureSrc: DEFAULT_IMAGE });
     }
     catch (err) {
@@ -234,7 +237,7 @@ export default class PersonalContainer extends Container {
    */
   async associateLdapAccount(account) {
     try {
-      await this.appContainer.apiv3.put('/personal-setting/associate-ldap', account);
+      await apiv3Put('/personal-setting/associate-ldap', account);
     }
     catch (err) {
       this.setState({ retrieveError: err });
@@ -248,7 +251,7 @@ export default class PersonalContainer extends Container {
    */
   async disassociateLdapAccount(account) {
     try {
-      await this.appContainer.apiv3.put('/personal-setting/disassociate-ldap', account);
+      await apiv3Put('/personal-setting/disassociate-ldap', account);
     }
     catch (err) {
       this.setState({ retrieveError: err });

+ 3 - 2
packages/app/src/client/services/RevisionComparerContainer.js

@@ -3,6 +3,7 @@ import { Container } from 'unstated';
 import loggerFactory from '~/utils/logger';
 
 import { toastError } from '../util/apiNotification';
+import { apiv3Get } from '../util/apiv3-client';
 
 const logger = loggerFactory('growi:PageHistoryContainer');
 
@@ -75,7 +76,7 @@ export default class RevisionComparerContainer extends Container {
     const { pageId, shareLinkId } = this.pageContainer.state;
 
     try {
-      const res = await this.appContainer.apiv3Get('/revisions/list', {
+      const res = await apiv3Get('/revisions/list', {
         pageId, shareLinkId, page: 1, limit: 1,
       });
       return res.data.docs[0];
@@ -96,7 +97,7 @@ export default class RevisionComparerContainer extends Container {
     const { pageId, shareLinkId } = this.pageContainer.state;
 
     try {
-      const res = await this.appContainer.apiv3Get(`/revisions/${revisionId}`, {
+      const res = await apiv3Get(`/revisions/${revisionId}`, {
         pageId, shareLinkId,
       });
       return res.data.revision;

+ 3 - 1
packages/app/src/client/services/TagContainer.js

@@ -2,6 +2,8 @@ import { Container } from 'unstated';
 
 import loggerFactory from '~/utils/logger';
 
+import { apiGet } from '../util/apiv1-client';
+
 const logger = loggerFactory('growi:services:TagContainer');
 
 /**
@@ -48,7 +50,7 @@ export default class TagContainer extends Container {
     let tags = [];
     // when the page exists or shared page
     if (pageId != null && shareLinkId == null) {
-      const res = await this.appContainer.apiGet('/pages.getPageTag', { pageId });
+      const res = await apiGet('/pages.getPageTag', { pageId });
       tags = res.tags;
     }
     // when the page not exist

+ 1 - 1
packages/app/src/client/util/editor.ts

@@ -4,7 +4,7 @@ type OptionsToSave = {
   isSlackEnabled: boolean;
   slackChannels: string;
   grant: number;
-  pageTags: string[];
+  pageTags: string[] | null;
   grantUserGroupId: string | null;
   grantUserGroupName: string | null;
 };

+ 3 - 3
packages/app/src/client/util/interceptor/drawio-interceptor.js

@@ -1,8 +1,9 @@
 /* eslint-disable import/prefer-default-export */
 import React from 'react';
+
+import { BasicInterceptor } from '@growi/core';
 import ReactDOM from 'react-dom';
 import { Provider } from 'unstated';
-import { BasicInterceptor } from '@growi/core';
 
 import Drawio from '~/components/Drawio';
 
@@ -103,8 +104,7 @@ export class DrawioInterceptor extends BasicInterceptor {
    */
   drawioPostRender(contextName, context) {
     const isPreview = (contextName === 'postRenderPreviewHtml');
-    const editorContainer = this.appContainer.getContainer('EditorContainer');
-    const renderDrawioInRealtime = editorContainer.state.previewOptions.renderDrawioInRealtime;
+    const renderDrawioInRealtime = context.editorSettings?.renderDrawioInRealtime;
 
     Object.keys(context.DrawioMap).forEach((domId) => {
       const elem = document.getElementById(domId);

+ 5 - 4
packages/app/src/components/Admin/Customize/CustomizeLayoutSetting.jsx

@@ -1,10 +1,11 @@
 import React, { useCallback, useEffect, useState } from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
 import AppContainer from '~/client/services/AppContainer';
-
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import { isDarkMode as isDarkModeByUtil } from '~/client/util/color-scheme';
 
 const isDarkMode = isDarkModeByUtil();
@@ -18,14 +19,14 @@ const CustomizeLayoutSetting = (props) => {
 
   const retrieveData = useCallback(async() => {
     try {
-      const res = await appContainer.apiv3Get('/customize-setting/layout');
+      const res = await apiv3Get('/customize-setting/layout');
       setIsContainerFluid(res.data.isContainerFluid);
     }
     catch (err) {
       setRetrieveError(err);
       toastError(err);
     }
-  }, [appContainer]);
+  }, []);
 
   useEffect(() => {
     retrieveData();
@@ -33,7 +34,7 @@ const CustomizeLayoutSetting = (props) => {
 
   const onClickSubmit = async() => {
     try {
-      await appContainer.apiv3Put('/customize-setting/layout', { isContainerFluid });
+      await apiv3Put('/customize-setting/layout', { isContainerFluid });
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.layout') }));
       retrieveData();
     }

+ 11 - 8
packages/app/src/components/Admin/ElasticsearchManagement/ElasticsearchManagement.jsx

@@ -1,16 +1,19 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
 import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
+import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Get, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
 
-import StatusTable from './StatusTable';
-import ReconnectControls from './ReconnectControls';
 import NormalizeIndicesControls from './NormalizeIndicesControls';
 import RebuildIndexControls from './RebuildIndexControls';
+import ReconnectControls from './ReconnectControls';
+import StatusTable from './StatusTable';
 
 class ElasticsearchManagement extends React.Component {
 
@@ -70,7 +73,7 @@ class ElasticsearchManagement extends React.Component {
     const { appContainer } = this.props;
 
     try {
-      const { data } = await appContainer.apiv3Get('/search/indices');
+      const { data } = await apiv3Get('/search/indices');
       const { info } = data;
 
       this.setState({
@@ -105,7 +108,7 @@ class ElasticsearchManagement extends React.Component {
     this.setState({ isReconnectingProcessing: true });
 
     try {
-      await appContainer.apiv3Post('/search/connection');
+      await apiv3Post('/search/connection');
     }
     catch (e) {
       toastError(e);
@@ -120,7 +123,7 @@ class ElasticsearchManagement extends React.Component {
     const { appContainer } = this.props;
 
     try {
-      await appContainer.apiv3Put('/search/indices', { operation: 'normalize' });
+      await apiv3Put('/search/indices', { operation: 'normalize' });
     }
     catch (e) {
       toastError(e);
@@ -137,7 +140,7 @@ class ElasticsearchManagement extends React.Component {
     this.setState({ isRebuildingProcessing: true });
 
     try {
-      await appContainer.apiv3Put('/search/indices', { operation: 'rebuild' });
+      await apiv3Put('/search/indices', { operation: 'rebuild' });
       toastSuccess('Rebuilding is requested');
     }
     catch (e) {

+ 6 - 3
packages/app/src/components/Admin/ExportArchiveData/SelectCollectionsModal.jsx

@@ -1,4 +1,5 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import {
@@ -6,8 +7,10 @@ import {
 } from 'reactstrap';
 import * as toastr from 'toastr';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
+import { apiPost } from '~/client/util/apiv1-client';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
 // import { toastSuccess, toastError } from '~/client/util/apiNotification';
 
 
@@ -67,8 +70,8 @@ class SelectCollectionsModal extends React.Component {
     e.preventDefault();
 
     try {
-      // TODO: use appContainer.apiv3.post
-      const result = await this.props.appContainer.apiPost('/v3/export', { collections: Array.from(this.state.selectedCollections) });
+      // TODO: use apiv3Post
+      const result = await apiPost('/v3/export', { collections: Array.from(this.state.selectedCollections) });
       // TODO: toastSuccess, toastError
 
       if (!result.ok) {

+ 10 - 7
packages/app/src/components/Admin/ExportArchiveDataPage.jsx

@@ -1,19 +1,22 @@
 import React, { Fragment } from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import * as toastr from 'toastr';
 
 
+import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
+import AppContainer from '~/client/services/AppContainer';
+import { apiDelete, apiGet } from '~/client/util/apiv1-client';
+
 import { withUnstatedContainers } from '../UnstatedUtils';
 // import { toastSuccess, toastError } from '~/client/util/apiNotification';
 
-import AppContainer from '~/client/services/AppContainer';
-import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
 
 import LabeledProgressBar from './Common/LabeledProgressBar';
-
-import SelectCollectionsModal from './ExportArchiveData/SelectCollectionsModal';
 import ArchiveFilesTable from './ExportArchiveData/ArchiveFilesTable';
+import SelectCollectionsModal from './ExportArchiveData/SelectCollectionsModal';
+
 
 const IGNORED_COLLECTION_NAMES = [
   'sessions',
@@ -45,8 +48,8 @@ class ExportArchiveDataPage extends React.Component {
     // TODO:: use apiv3.get
     // eslint-disable-next-line no-unused-vars
     const [{ collections }, { status }] = await Promise.all([
-      this.props.appContainer.apiGet('/v3/mongo/collections', {}),
-      this.props.appContainer.apiGet('/v3/export/status', {}),
+      apiGet('/v3/mongo/collections', {}),
+      apiGet('/v3/export/status', {}),
     ]);
     // TODO: toastSuccess, toastError
 
@@ -118,7 +121,7 @@ class ExportArchiveDataPage extends React.Component {
 
   async onZipFileStatRemove(fileName) {
     try {
-      await this.props.appContainer.apiDelete(`/v3/export/${fileName}`, {});
+      await apiDelete(`/v3/export/${fileName}`, {});
 
       this.setState((prevState) => {
         return {

+ 9 - 7
packages/app/src/components/Admin/ImportData/GrowiArchive/ImportForm.jsx

@@ -1,20 +1,22 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
+import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
+import AppContainer from '~/client/services/AppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Post } from '~/client/util/apiv3-client';
 import GrowiArchiveImportOption from '~/models/admin/growi-archive-import-option';
 import ImportOptionForPages from '~/models/admin/import-option-for-pages';
 import ImportOptionForRevisions from '~/models/admin/import-option-for-revisions';
 
 import { withUnstatedContainers } from '../../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
 
 
-import ImportCollectionItem, { DEFAULT_MODE, MODE_RESTRICTED_COLLECTION } from './ImportCollectionItem';
-import ImportCollectionConfigurationModal from './ImportCollectionConfigurationModal';
 import ErrorViewer from './ErrorViewer';
+import ImportCollectionConfigurationModal from './ImportCollectionConfigurationModal';
+import ImportCollectionItem, { DEFAULT_MODE, MODE_RESTRICTED_COLLECTION } from './ImportCollectionItem';
 
 
 const GROUPS_PAGE = [
@@ -300,8 +302,8 @@ class ImportForm extends React.Component {
     });
 
     try {
-      // TODO: use appContainer.apiv3.post
-      await appContainer.apiv3Post('/import', {
+      // TODO: use apiv3Post
+      await apiv3Post('/import', {
         fileName,
         collections: Array.from(selectedCollections),
         optionsMap,

+ 5 - 2
packages/app/src/components/Admin/ImportData/GrowiArchive/UploadForm.jsx

@@ -1,10 +1,13 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-import { withUnstatedContainers } from '../../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import { toastError } from '~/client/util/apiNotification';
+import { apiv3Post } from '~/client/util/apiv3-client';
+
+import { withUnstatedContainers } from '../../../UnstatedUtils';
 
 class UploadForm extends React.Component {
 
@@ -32,7 +35,7 @@ class UploadForm extends React.Component {
     formData.append('file', this.inputRef.current.files[0]);
 
     try {
-      const { data } = await this.props.appContainer.apiv3Post('/import/upload', formData);
+      const { data } = await apiv3Post('/import/upload', formData);
       // TODO: toastSuccess, toastError
       this.props.onUpload(data);
     }

+ 7 - 4
packages/app/src/components/Admin/ImportData/GrowiArchiveSection.jsx

@@ -1,14 +1,17 @@
 import React, { Fragment } from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import * as toastr from 'toastr';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
+import { apiv3Delete, apiv3Get } from '~/client/util/apiv3-client';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
 // import { toastSuccess, toastError } from '~/client/util/apiNotification';
 
-import UploadForm from './GrowiArchive/UploadForm';
 import ImportForm from './GrowiArchive/ImportForm';
+import UploadForm from './GrowiArchive/UploadForm';
 
 class GrowiArchiveSection extends React.Component {
 
@@ -32,7 +35,7 @@ class GrowiArchiveSection extends React.Component {
 
   async componentWillMount() {
     // get uploaded file status
-    const res = await this.props.appContainer.apiv3Get('/import/status');
+    const res = await apiv3Get('/import/status');
 
     if (res.data.zipFileStat != null) {
       const { fileName, innerFileStats } = res.data.zipFileStat;
@@ -55,7 +58,7 @@ class GrowiArchiveSection extends React.Component {
   async discardData() {
     try {
       const { fileName } = this.state;
-      await this.props.appContainer.apiv3Delete('/import/all');
+      await apiv3Delete('/import/all');
       this.resetState();
 
       // TODO: toastSuccess, toastError

+ 8 - 5
packages/app/src/components/Admin/Notification/GlobalNotificationList.jsx

@@ -1,18 +1,21 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import urljoin from 'url-join';
-import loggerFactory from '~/utils/logger';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
+import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
+import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Put } from '~/client/util/apiv3-client';
+import loggerFactory from '~/utils/logger';
 
-import AppContainer from '~/client/services/AppContainer';
-import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
+import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import NotificationDeleteModal from './NotificationDeleteModal';
 import NotificationTypeIcon from './NotificationTypeIcon';
 
+
 const logger = loggerFactory('growi:GolobalNotificationList');
 
 class GlobalNotificationList extends React.Component {
@@ -34,7 +37,7 @@ class GlobalNotificationList extends React.Component {
     const { t } = this.props;
     const isEnabled = !notification.isEnabled;
     try {
-      await this.props.appContainer.apiv3.put(`/notification-setting/global-notification/${notification._id}/enabled`, {
+      await apiv3Put(`/notification-setting/global-notification/${notification._id}/enabled`, {
         isEnabled,
       });
       toastSuccess(t('notification_setting.toggle_notification', { path: notification.triggerPath }));

+ 10 - 6
packages/app/src/components/Admin/Notification/ManageGlobalNotification.jsx

@@ -1,16 +1,20 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import urljoin from 'url-join';
 
+import AppContainer from '~/client/services/AppContainer';
+import { toastError } from '~/client/util/apiNotification';
+import { apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
 import loggerFactory from '~/utils/logger';
 
-import { toastError } from '~/client/util/apiNotification';
 
-import TriggerEventCheckBox from './TriggerEventCheckBox';
-import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
-import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+import TriggerEventCheckBox from './TriggerEventCheckBox';
+
 
 const logger = loggerFactory('growi:manageGlobalNotification');
 
@@ -81,10 +85,10 @@ class ManageGlobalNotification extends React.Component {
 
     try {
       if (this.state.globalNotificationId != null) {
-        await this.props.appContainer.apiv3.put(`/notification-setting/global-notification/${this.state.globalNotificationId}`, requestParams);
+        await apiv3Put(`/notification-setting/global-notification/${this.state.globalNotificationId}`, requestParams);
       }
       else {
-        await this.props.appContainer.apiv3.post('/notification-setting/global-notification', requestParams);
+        await apiv3Post('/notification-setting/global-notification', requestParams);
       }
       window.location.href = urljoin(window.location.origin, '/admin/notification#global-notification');
     }

+ 7 - 5
packages/app/src/components/Admin/Security/GoogleSecuritySettingContents.jsx

@@ -1,14 +1,16 @@
 /* eslint-disable react/no-danger */
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
 
-import AppContainer from '~/client/services/AppContainer';
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
+import AppContainer from '~/client/services/AppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
 
 class GoogleSecurityManagementContents extends React.Component {
 
@@ -135,8 +137,8 @@ class GoogleSecurityManagementContents extends React.Component {
                     id="bindByUserNameGoogle"
                     className="custom-control-input"
                     type="checkbox"
-                    checked={adminGoogleSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser || false}
-                    onChange={() => { adminGoogleSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                    checked={adminGoogleSecurityContainer.state.isSameEmailTreatedAsIdenticalUser || false}
+                    onChange={() => { adminGoogleSecurityContainer.switchIsSameEmailTreatedAsIdenticalUser() }}
                   />
                   <label
                     className="custom-control-label"

+ 7 - 5
packages/app/src/components/Admin/Security/LdapAuthTest.jsx

@@ -1,13 +1,15 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-import loggerFactory from '~/utils/logger';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
+import AdminLdapSecurityContainer from '~/client/services/AdminLdapSecurityContainer';
+import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiPost } from '~/client/util/apiv1-client';
+import loggerFactory from '~/utils/logger';
 
-import AppContainer from '~/client/services/AppContainer';
-import AdminLdapSecurityContainer from '~/client/services/AdminLdapSecurityContainer';
+import { withUnstatedContainers } from '../../UnstatedUtils';
 
 const logger = loggerFactory('growi:security:AdminLdapSecurityContainer');
 
@@ -41,7 +43,7 @@ class LdapAuthTest extends React.Component {
    */
   async testLdapCredentials() {
     try {
-      const response = await this.props.appContainer.apiPost('/login/testLdap', {
+      const response = await apiPost('/login/testLdap', {
         loginForm: {
           username: this.props.username,
           password: this.props.password,

+ 8 - 6
packages/app/src/components/Admin/Security/ShareLinkSetting.jsx

@@ -1,17 +1,19 @@
 import React, { Fragment } from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
+import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
+import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Delete } from '~/client/util/apiv3-client';
 
 import PaginationWrapper from '../../PaginationWrapper';
+import ShareLinkList from '../../ShareLink/ShareLinkList';
+import { withUnstatedContainers } from '../../UnstatedUtils';
 
-import AppContainer from '~/client/services/AppContainer';
-import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 
 import DeleteAllShareLinksModal from './DeleteAllShareLinksModal';
-import ShareLinkList from '../../ShareLink/ShareLinkList';
 
 
 const Pager = (props) => {
@@ -80,7 +82,7 @@ class ShareLinkSetting extends React.Component {
     const { t, appContainer } = this.props;
 
     try {
-      const res = await appContainer.apiv3Delete('/share-links/all');
+      const res = await apiv3Delete('/share-links/all');
       const { deletedCount } = res.data;
       toastSuccess(t('toaster.remove_share_link', { count: deletedCount }));
     }
@@ -95,7 +97,7 @@ class ShareLinkSetting extends React.Component {
     const { shareLinksActivePage } = adminGeneralSecurityContainer.state;
 
     try {
-      const res = await appContainer.apiv3Delete(`/share-links/${shareLinkId}`);
+      const res = await apiv3Delete(`/share-links/${shareLinkId}`);
       const { deletedShareLink } = res.data;
       toastSuccess(t('toaster.remove_share_link_success', { shareLinkId: deletedShareLink._id }));
     }

+ 12 - 8
packages/app/src/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx

@@ -1,15 +1,19 @@
 import React, { useState, useEffect, useCallback } from 'react';
-import { useTranslation } from 'react-i18next';
+
 import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
 
-import loggerFactory from '~/utils/logger';
 import AppContainer from '~/client/services/AppContainer';
-import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Delete, apiv3Put } from '~/client/util/apiv3-client';
+import loggerFactory from '~/utils/logger';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
 import CustomBotWithProxyConnectionStatus from './CustomBotWithProxyConnectionStatus';
-import WithProxyAccordions from './WithProxyAccordions';
 import DeleteSlackBotSettingsModal from './DeleteSlackBotSettingsModal';
 import { SlackAppIntegrationControl } from './SlackAppIntegrationControl';
+import WithProxyAccordions from './WithProxyAccordions';
 
 const logger = loggerFactory('growi:cli:SlackIntegration:CustomBotWithProxySettings');
 
@@ -42,7 +46,7 @@ const CustomBotWithProxySettings = (props) => {
     }
 
     try {
-      await appContainer.apiv3.put(`/slack-integration-settings/slack-app-integrations/${slackIntegrationToChange._id}/make-primary`);
+      await apiv3Put(`/slack-integration-settings/slack-app-integrations/${slackIntegrationToChange._id}/make-primary`);
       if (onPrimaryUpdated != null) {
         onPrimaryUpdated();
       }
@@ -52,11 +56,11 @@ const CustomBotWithProxySettings = (props) => {
       toastError(err, 'Failed to change isPrimary');
       logger.error('Failed to change isPrimary', err);
     }
-  }, [appContainer.apiv3, t, onPrimaryUpdated]);
+  }, [t, onPrimaryUpdated]);
 
   const deleteSlackAppIntegrationHandler = async() => {
     try {
-      await appContainer.apiv3.delete(`/slack-integration-settings/slack-app-integrations/${integrationIdToDelete}`);
+      await apiv3Delete(`/slack-integration-settings/slack-app-integrations/${integrationIdToDelete}`);
       if (props.onDeleteSlackAppIntegration != null) {
         props.onDeleteSlackAppIntegration();
       }
@@ -70,7 +74,7 @@ const CustomBotWithProxySettings = (props) => {
 
   const updateProxyUri = async() => {
     try {
-      await appContainer.apiv3.put('/slack-integration-settings/proxy-uri', {
+      await apiv3Put('/slack-integration-settings/proxy-uri', {
         proxyUri: newProxyServerUri,
       });
       toastSuccess(t('toaster.update_successed', { target: 'Proxy URL' }));

+ 8 - 4
packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySecretTokenSection.jsx

@@ -1,10 +1,14 @@
 import React, { useState, useEffect } from 'react';
-import { useTranslation } from 'react-i18next';
+
 import PropTypes from 'prop-types';
-import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+import { useTranslation } from 'react-i18next';
+
 import AppContainer from '~/client/services/AppContainer';
-import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Put } from '~/client/util/apiv3-client';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 
 const CustomBotWithoutProxySecretTokenSection = (props) => {
@@ -26,7 +30,7 @@ const CustomBotWithoutProxySecretTokenSection = (props) => {
 
   const updatedSecretToken = async() => {
     try {
-      await appContainer.apiv3.put('/slack-integration-settings/without-proxy/update-settings', {
+      await apiv3Put('/slack-integration-settings/without-proxy/update-settings', {
         slackSigningSecret: inputSigningSecret,
         slackBotToken: inputBotToken,
       });

+ 9 - 5
packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx

@@ -1,13 +1,18 @@
 import React, { useState } from 'react';
+
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
-import Accordion from '../Common/Accordion';
+
 import AppContainer from '~/client/services/AppContainer';
+import { apiv3Post } from '~/client/util/apiv3-client';
+
 import { withUnstatedContainers } from '../../UnstatedUtils';
-import MessageBasedOnConnection from './MessageBasedOnConnection';
+import Accordion from '../Common/Accordion';
+
 import CustomBotWithoutProxySecretTokenSection from './CustomBotWithoutProxySecretTokenSection';
-import { addLogs } from './slak-integration-util';
 import ManageCommandsProcessWithoutProxy from './ManageCommandsProcessWithoutProxy';
+import MessageBasedOnConnection from './MessageBasedOnConnection';
+import { addLogs } from './slak-integration-util';
 
 
 export const botInstallationStep = {
@@ -34,7 +39,7 @@ const CustomBotWithoutProxySettingsAccordion = (props) => {
 
   const testConnection = async() => {
     try {
-      await appContainer.apiv3.post('/slack-integration-settings/without-proxy/test', { channel: testChannel });
+      await apiv3Post('/slack-integration-settings/without-proxy/test', { channel: testChannel });
       setIsLatestConnectionSuccess(true);
       if (onTestConnectionInvoked != null) {
         onTestConnectionInvoked();
@@ -130,7 +135,6 @@ const CustomBotWithoutProxySettingsAccordion = (props) => {
         <ManageCommandsProcessWithoutProxy
           commandPermission={commandPermission}
           eventActionsPermission={eventActionsPermission}
-          apiv3Put={props.appContainer.apiv3.put}
         />
       </Accordion>
       <Accordion

+ 5 - 3
packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx

@@ -1,7 +1,10 @@
 import React, { useCallback, useState } from 'react';
+
+import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse, defaultSupportedSlackEventActions } from '@growi/slack';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
-import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse, defaultSupportedSlackEventActions } from '@growi/slack';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
 import loggerFactory from '~/utils/logger';
 
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
@@ -170,7 +173,7 @@ PermissionSettingForEachPermissionTypeComponent.propTypes = {
 
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 const ManageCommandsProcess = ({
-  apiv3Put, slackAppIntegrationId, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands, permissionsForSlackEventActions,
+  slackAppIntegrationId, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands, permissionsForSlackEventActions,
 }) => {
   const { t } = useTranslation();
 
@@ -401,7 +404,6 @@ const ManageCommandsProcess = ({
 };
 
 ManageCommandsProcess.propTypes = {
-  apiv3Put: PropTypes.func,
   slackAppIntegrationId: PropTypes.string.isRequired,
   permissionsForBroadcastUseCommands: PropTypes.object.isRequired,
   permissionsForSingleUseCommands: PropTypes.object.isRequired,

+ 5 - 3
packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcessWithoutProxy.jsx

@@ -1,7 +1,10 @@
 import React, { useCallback, useEffect, useState } from 'react';
+
+import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse, defaultSupportedSlackEventActions } from '@growi/slack';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
-import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse, defaultSupportedSlackEventActions } from '@growi/slack';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
 import loggerFactory from '~/utils/logger';
 
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
@@ -153,7 +156,7 @@ SinglePermissionSettingComponent.propTypes = {
 
 
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission, eventActionsPermission }) => {
+const ManageCommandsProcessWithoutProxy = ({ commandPermission, eventActionsPermission }) => {
   const { t } = useTranslation();
   const [editingCommandPermission, setEditingCommandPermission] = useState({});
   const [editingEventActionsPermission, setEditingEventActionsPermission] = useState({});
@@ -267,7 +270,6 @@ const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission, eventA
 };
 
 ManageCommandsProcessWithoutProxy.propTypes = {
-  apiv3Put: PropTypes.func,
   commandPermission: PropTypes.object,
   eventActionsPermission: PropTypes.object,
 };

+ 12 - 7
packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx

@@ -1,17 +1,22 @@
 import React, { useState, useEffect, useCallback } from 'react';
+
+import { SlackbotType } from '@growi/slack';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 
-import { SlackbotType } from '@growi/slack';
 
-import loggerFactory from '~/utils/logger';
 import AppContainer from '~/client/services/AppContainer';
-import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Delete, apiv3Put } from '~/client/util/apiv3-client';
+import loggerFactory from '~/utils/logger';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+
 import CustomBotWithProxyConnectionStatus from './CustomBotWithProxyConnectionStatus';
-import WithProxyAccordions from './WithProxyAccordions';
 import DeleteSlackBotSettingsModal from './DeleteSlackBotSettingsModal';
 import { SlackAppIntegrationControl } from './SlackAppIntegrationControl';
+import WithProxyAccordions from './WithProxyAccordions';
 
 const logger = loggerFactory('growi:cli:SlackIntegration:OfficialBotSettings');
 
@@ -38,7 +43,7 @@ const OfficialBotSettings = (props) => {
     }
 
     try {
-      await appContainer.apiv3.put(`/slack-integration-settings/slack-app-integrations/${slackIntegrationToChange._id}/make-primary`);
+      await apiv3Put(`/slack-integration-settings/slack-app-integrations/${slackIntegrationToChange._id}/make-primary`);
       if (onPrimaryUpdated != null) {
         onPrimaryUpdated();
       }
@@ -48,10 +53,10 @@ const OfficialBotSettings = (props) => {
       toastError(err, 'Failed to change isPrimary');
       logger.error('Failed to change isPrimary', err);
     }
-  }, [appContainer.apiv3, t, onPrimaryUpdated]);
+  }, [t, onPrimaryUpdated]);
 
   const deleteSlackAppIntegrationHandler = async() => {
-    await appContainer.apiv3.delete(`/slack-integration-settings/slack-app-integrations/${integrationIdToDelete}`);
+    await apiv3Delete(`/slack-integration-settings/slack-app-integrations/${integrationIdToDelete}`);
     try {
       if (props.onDeleteSlackAppIntegration != null) {
         props.onDeleteSlackAppIntegration();

+ 17 - 11
packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx

@@ -1,19 +1,25 @@
 import React, { useState, useEffect, useCallback } from 'react';
+
+import { SlackbotType } from '@growi/slack';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 
-import { SlackbotType } from '@growi/slack';
 
 import AppContainer from '~/client/services/AppContainer';
-import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import {
+  apiv3Delete, apiv3Get, apiv3Post, apiv3Put,
+} from '~/client/util/apiv3-client';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
 
-import OfficialBotSettings from './OfficialBotSettings';
-import CustomBotWithoutProxySettings from './CustomBotWithoutProxySettings';
-import CustomBotWithProxySettings from './CustomBotWithProxySettings';
-import ConfirmBotChangeModal from './ConfirmBotChangeModal';
 import BotTypeCard from './BotTypeCard';
+import ConfirmBotChangeModal from './ConfirmBotChangeModal';
+import CustomBotWithProxySettings from './CustomBotWithProxySettings';
+import CustomBotWithoutProxySettings from './CustomBotWithoutProxySettings';
 import DeleteSlackBotSettingsModal from './DeleteSlackBotSettingsModal';
+import OfficialBotSettings from './OfficialBotSettings';
+
 
 const botTypes = Object.values(SlackbotType);
 
@@ -40,7 +46,7 @@ const SlackIntegration = (props) => {
 
   const fetchSlackIntegrationData = useCallback(async() => {
     try {
-      const { data } = await appContainer.apiv3.get('/slack-integration-settings');
+      const { data } = await apiv3Get('/slack-integration-settings');
       const {
         slackSigningSecret,
         slackBotToken,
@@ -71,11 +77,11 @@ const SlackIntegration = (props) => {
     finally {
       setIsLoading(false);
     }
-  }, [appContainer.apiv3]);
+  }, []);
 
   const resetAllSettings = async() => {
     try {
-      await appContainer.apiv3.delete('/slack-integration-settings/bot-type');
+      await apiv3Delete('/slack-integration-settings/bot-type');
       fetchSlackIntegrationData();
       toastSuccess(t('admin:slack_integration.bot_all_reset_successful'));
     }
@@ -86,7 +92,7 @@ const SlackIntegration = (props) => {
 
   const createSlackIntegrationData = async() => {
     try {
-      await appContainer.apiv3.post('/slack-integration-settings/slack-app-integrations');
+      await apiv3Post('/slack-integration-settings/slack-app-integrations');
       fetchSlackIntegrationData();
       toastSuccess(t('admin:slack_integration.adding_slack_ws_integration_settings_successful'));
     }
@@ -106,7 +112,7 @@ const SlackIntegration = (props) => {
 
   const changeCurrentBotSettings = async(botType) => {
     try {
-      await appContainer.apiv3.put('/slack-integration-settings/bot-type', {
+      await apiv3Put('/slack-integration-settings/bot-type', {
         currentBotType: botType,
       });
       setSelectedBotType(null);

+ 13 - 13
packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -1,21 +1,23 @@
 /* eslint-disable react/prop-types */
 import React, { useState, useCallback } from 'react';
-import PropTypes from 'prop-types';
-import { useTranslation } from 'react-i18next';
-import { CopyToClipboard } from 'react-copy-to-clipboard';
 
 import { SlackbotType } from '@growi/slack';
-
+import PropTypes from 'prop-types';
+import { CopyToClipboard } from 'react-copy-to-clipboard';
+import { useTranslation } from 'react-i18next';
 import { Tooltip } from 'reactstrap';
+
+import AppContainer from '~/client/services/AppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Put } from '~/client/util/apiv3-client';
 import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import AppContainer from '~/client/services/AppContainer';
 import Accordion from '../Common/Accordion';
-import { addLogs } from './slak-integration-util';
-import MessageBasedOnConnection from './MessageBasedOnConnection';
+
 import ManageCommandsProcess from './ManageCommandsProcess';
+import MessageBasedOnConnection from './MessageBasedOnConnection';
+import { addLogs } from './slak-integration-util';
 
 const logger = loggerFactory('growi:SlackIntegration:WithProxyAccordionsWrapper');
 
@@ -147,7 +149,7 @@ const GeneratingTokensAndRegisteringProxyServiceProcess = withUnstatedContainers
 
   const regenerateTokensHandler = async() => {
     try {
-      await appContainer.apiv3.put(`/slack-integration-settings/slack-app-integrations/${slackAppIntegrationId}/regenerate-tokens`);
+      await apiv3Put(`/slack-integration-settings/slack-app-integrations/${slackAppIntegrationId}/regenerate-tokens`);
       if (props.onUpdateTokens != null) {
         props.onUpdateTokens();
       }
@@ -342,7 +344,6 @@ const WithProxyAccordions = (props) => {
     '③': {
       title: 'manage_permission',
       content: <ManageCommandsProcess
-        apiv3Put={props.appContainer.apiv3.put}
         slackAppIntegrationId={props.slackAppIntegrationId}
         permissionsForBroadcastUseCommands={props.permissionsForBroadcastUseCommands}
         permissionsForSingleUseCommands={props.permissionsForSingleUseCommands}
@@ -352,7 +353,7 @@ const WithProxyAccordions = (props) => {
     '④': {
       title: 'test_connection',
       content: <TestProcess
-        apiv3Post={props.appContainer.apiv3.post}
+        apiv3Post={props.apiv3Post}
         slackAppIntegrationId={props.slackAppIntegrationId}
         onSubmitForm={submitForm}
         onSubmitFormFailed={submitFormFailed}
@@ -387,7 +388,6 @@ const WithProxyAccordions = (props) => {
     '⑤': {
       title: 'manage_permission',
       content: <ManageCommandsProcess
-        apiv3Put={props.appContainer.apiv3.put}
         slackAppIntegrationId={props.slackAppIntegrationId}
         permissionsForBroadcastUseCommands={props.permissionsForBroadcastUseCommands}
         permissionsForSingleUseCommands={props.permissionsForSingleUseCommands}
@@ -397,7 +397,7 @@ const WithProxyAccordions = (props) => {
     '⑥': {
       title: 'test_connection',
       content: <TestProcess
-        apiv3Post={props.appContainer.apiv3.post}
+        apiv3Post={props.apiv3Post}
         slackAppIntegrationId={props.slackAppIntegrationId}
         onSubmitForm={submitForm}
         onSubmitFormFailed={submitFormFailed}

+ 7 - 4
packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.jsx

@@ -1,13 +1,16 @@
 import React, { Fragment } from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
+import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
+import AppContainer from '~/client/services/AppContainer';
+import { toastError } from '~/client/util/apiNotification';
+import { apiv3Get } from '~/client/util/apiv3-client';
+
 import PageListItemS from '../../PageList/PageListItemS';
 import PaginationWrapper from '../../PaginationWrapper';
 import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
-import { toastError } from '~/client/util/apiNotification';
 
 class UserGroupPageList extends React.Component {
 
@@ -33,7 +36,7 @@ class UserGroupPageList extends React.Component {
     const offset = (pageNum - 1) * limit;
 
     try {
-      const res = await this.props.appContainer.apiv3.get(`/user-groups/${this.props.adminUserGroupDetailContainer.state.userGroup._id}/pages`, {
+      const res = await apiv3Get(`/user-groups/${this.props.adminUserGroupDetailContainer.state.userGroup._id}/pages`, {
         limit,
         offset,
       });

+ 5 - 2
packages/app/src/components/Admin/Users/PasswordResetModal.jsx

@@ -1,13 +1,16 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
+import AppContainer from '~/client/services/AppContainer';
 import { toastError } from '~/client/util/apiNotification';
+import { apiv3Put } from '~/client/util/apiv3-client';
+
 import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
 
 class PasswordResetModal extends React.Component {
 
@@ -25,7 +28,7 @@ class PasswordResetModal extends React.Component {
   async resetPassword() {
     const { t, appContainer, userForPasswordResetModal } = this.props;
     try {
-      const res = await appContainer.apiv3Put('/users/reset-password', { id: userForPasswordResetModal._id });
+      const res = await apiv3Put('/users/reset-password', { id: userForPasswordResetModal._id });
       const { newPassword } = res.data;
       this.setState({ temporaryPassword: newPassword, isPasswordResetDone: true });
     }

+ 8 - 4
packages/app/src/components/Admin/Users/SendInvitationEmailButton.jsx

@@ -1,9 +1,13 @@
 import React from 'react';
-import { useTranslation } from 'react-i18next';
+
 import PropTypes from 'prop-types';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import AppContainer from '~/client/services/AppContainer';
+import { useTranslation } from 'react-i18next';
+
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+import AppContainer from '~/client/services/AppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Put } from '~/client/util/apiv3-client';
+
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 const SendInvitationEmailButton = (props) => {
@@ -16,7 +20,7 @@ const SendInvitationEmailButton = (props) => {
 
   const onClickSendInvitationEmailButton = async() => {
     try {
-      const res = await appContainer.apiv3Put('users/send-invitation-email', { id: user._id });
+      const res = await apiv3Put('/users/send-invitation-email', { id: user._id });
       const { failedToSendEmail } = res.data;
       if (failedToSendEmail == null) {
         const msg = `Email has been sent<br>・${user.email}`;

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

@@ -1,12 +1,16 @@
 import React, { useState, useCallback } from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
+
 import AppContainer from '~/client/services/AppContainer';
-import { withUnstatedContainers } from './UnstatedUtils';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Post } from '~/client/util/apiv3-client';
+
+import { withUnstatedContainers } from './UnstatedUtils';
 
 
 const ArchiveCreateModal = (props) => {
@@ -56,7 +60,7 @@ const ArchiveCreateModal = (props) => {
 
   async function done() {
     try {
-      await appContainer.apiv3Post('/page/archive', {
+      await apiv3Post('/page/archive', {
         rootPagePath: props.path,
         isCommentDownload,
         isAttachmentFileDownload,

+ 2 - 1
packages/app/src/components/Me/ApiSettings.jsx

@@ -7,6 +7,7 @@ import { withTranslation } from 'react-i18next';
 import AppContainer from '~/client/services/AppContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Put } from '~/client/util/apiv3-client';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 
@@ -23,7 +24,7 @@ class ApiSettings extends React.Component {
     const { t, appContainer, personalContainer } = this.props;
 
     try {
-      await appContainer.apiv3Put('/personal-setting/api-token');
+      await apiv3Put('/personal-setting/api-token');
 
       await personalContainer.retrievePersonalData();
       toastSuccess(t('toaster.update_successed', { target: t('page_me_apitoken.api_token') }));

+ 6 - 25
packages/app/src/components/Me/EditorSettings.tsx

@@ -3,17 +3,13 @@ import React, {
   FC, SetStateAction, useCallback, useEffect, useState,
 } from 'react';
 
-import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 
-import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 
 
 type EditorSettingsBodyProps = {
-  appContainer: AppContainer
 }
 
 type RuleListGroupProps = {
@@ -153,7 +149,7 @@ const japaneseRulesMenuItems = [
 
 const RuleListGroup: FC<RuleListGroupProps> = ({
   title, ruleList, textlintRules, setTextlintRules,
-}) => {
+}: RuleListGroupProps) => {
   const { t } = useTranslation();
 
   const isCheckedRule = (ruleName: string) => (
@@ -200,21 +196,12 @@ const RuleListGroup: FC<RuleListGroupProps> = ({
 };
 
 
-RuleListGroup.propTypes = {
-  title: PropTypes.string.isRequired,
-  ruleList: PropTypes.array.isRequired,
-  textlintRules: PropTypes.array.isRequired,
-  setTextlintRules: PropTypes.func.isRequired,
-};
-
-
-const EditorSettingsBody: FC<EditorSettingsBodyProps> = (props) => {
+export const EditorSettings: FC<EditorSettingsBodyProps> = () => {
   const { t } = useTranslation();
-  const { appContainer } = props;
   const [textlintRules, setTextlintRules] = useState<LintRule[]>([]);
 
   const initializeEditorSettings = useCallback(async() => {
-    const { data } = await appContainer.apiv3Get('/personal-setting/editor-settings');
+    const { data } = await apiv3Get('/personal-setting/editor-settings');
     const retrievedRules: LintRule[] = data?.textlintSettings?.textlintRules;
 
     // If database is empty, add default rules to state
@@ -234,7 +221,7 @@ const EditorSettingsBody: FC<EditorSettingsBodyProps> = (props) => {
       setTextlintRules([...defaultCommonRules, ...defaultJapaneseRules]);
     }
 
-  }, [appContainer]);
+  }, []);
 
   useEffect(() => {
     initializeEditorSettings();
@@ -242,7 +229,7 @@ const EditorSettingsBody: FC<EditorSettingsBodyProps> = (props) => {
 
   const updateRulesHandler = async() => {
     try {
-      const { data } = await appContainer.apiv3Put('/personal-setting/editor-settings', { textlintSettings: { textlintRules: [...textlintRules] } });
+      const { data } = await apiv3Put('/personal-setting/editor-settings', { textlintSettings: { textlintRules: [...textlintRules] } });
       setTextlintRules(data.textlintSettings.textlintRules);
       toastSuccess(t('toaster.update_successed', { target: 'Updated Textlint Settings' }));
     }
@@ -285,9 +272,3 @@ const EditorSettingsBody: FC<EditorSettingsBodyProps> = (props) => {
     </div>
   );
 };
-
-export const EditorSettings = withUnstatedContainers(EditorSettingsBody, [AppContainer]);
-
-EditorSettingsBody.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};

+ 9 - 14
packages/app/src/components/Me/PasswordSettings.jsx

@@ -4,20 +4,18 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-import AppContainer from '~/client/services/AppContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 class PasswordSettings extends React.Component {
 
-  constructor(appContainer) {
+  constructor() {
     super();
 
-    this.appContainer = appContainer;
-
     this.state = {
       retrieveError: null,
       oldPassword: '',
@@ -32,10 +30,8 @@ class PasswordSettings extends React.Component {
   }
 
   async componentDidMount() {
-    const { appContainer } = this.props;
-
     try {
-      const res = await appContainer.apiv3Get('/personal-setting/is-password-set');
+      const res = await apiv3Get('/personal-setting/is-password-set');
       const { isPasswordSet } = res.data;
       this.setState({ isPasswordSet });
     }
@@ -47,11 +43,11 @@ class PasswordSettings extends React.Component {
   }
 
   async onClickSubmit() {
-    const { t, appContainer, personalContainer } = this.props;
+    const { t, personalContainer } = this.props;
     const { oldPassword, newPassword, newPasswordConfirm } = this.state;
 
     try {
-      await appContainer.apiv3Put('/personal-setting/password', {
+      await apiv3Put('/personal-setting/password', {
         oldPassword, newPassword, newPasswordConfirm,
       });
       this.setState({ oldPassword: '', newPassword: '', newPasswordConfirm: '' });
@@ -99,9 +95,6 @@ class PasswordSettings extends React.Component {
           <div className="row mb-3">
             <label htmlFor="oldPassword" className="col-md-3 text-md-right">{ t('personal_settings.current_password') }</label>
             <div className="col-md-5">
-              {/* to prevent autocomplete username into userForm[email] in BasicInfoSettings component */}
-              {/* https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion */}
-              <input type="password" autoComplete="new-password" style={{ display: 'none' }} />
               <input
                 className="form-control"
                 type="password"
@@ -115,6 +108,9 @@ class PasswordSettings extends React.Component {
         <div className="row mb-3">
           <label htmlFor="newPassword" className="col-md-3 text-md-right">{t('personal_settings.new_password') }</label>
           <div className="col-md-5">
+            {/* to prevent autocomplete username into userForm[email] in BasicInfoSettings component */}
+            {/* https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion */}
+            <input type="password" autoComplete="new-password" style={{ display: 'none' }} />
             <input
               className="form-control"
               type="password"
@@ -159,11 +155,10 @@ class PasswordSettings extends React.Component {
 }
 
 
-const PasswordSettingsWrapper = withUnstatedContainers(PasswordSettings, [AppContainer, PersonalContainer]);
+const PasswordSettingsWrapper = withUnstatedContainers(PasswordSettings, [PersonalContainer]);
 
 PasswordSettings.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
 };
 

+ 6 - 7
packages/app/src/components/MyDraftList/MyDraftList.jsx

@@ -1,14 +1,14 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
+import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
 import EditorContainer from '~/client/services/EditorContainer';
+import PageContainer from '~/client/services/PageContainer';
+import { apiGet } from '~/client/util/apiv1-client';
 
 import PaginationWrapper from '../PaginationWrapper';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 import Draft from './Draft';
 
@@ -49,7 +49,7 @@ class MyDraftList extends React.Component {
       return;
     }
 
-    const res = await this.props.appContainer.apiGet('/pages.exist', {
+    const res = await apiGet('/pages.exist', {
       pagePaths: JSON.stringify(Object.keys(draftsAsObj)),
     });
 
@@ -174,13 +174,12 @@ class MyDraftList extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const MyDraftListWrapper = withUnstatedContainers(MyDraftList, [AppContainer, PageContainer, EditorContainer]);
+const MyDraftListWrapper = withUnstatedContainers(MyDraftList, [PageContainer, EditorContainer]);
 
 
 MyDraftList.propTypes = {
   t: PropTypes.func.isRequired, // react-i18next
 
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };

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

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 
 import AppContainer from '~/client/services/AppContainer';
+import { useCurrentUser } from '~/stores/context';
 import { EditorMode, useIsDeviceSmallerThanMd } from '~/stores/ui';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
@@ -45,8 +46,9 @@ function PageEditorModeManager(props) {
 
   const { t } = useTranslation();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+  const { data: currentUser } = useCurrentUser();
 
-  const isAdmin = appContainer.isAdmin;
+  const isAdmin = currentUser?.isAdmin;
   const isHackmdEnabled = appContainer.config.env.HACKMD_URI != null;
   const showHackmdBtn = isHackmdEnabled || isAdmin;
 

+ 28 - 22
packages/app/src/components/Page.jsx

@@ -1,30 +1,30 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
-import loggerFactory from '~/utils/logger';
 
-import { withUnstatedContainers } from './UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-import EditorContainer from '~/client/services/EditorContainer';
 
 import MarkdownTable from '~/client/models/MarkdownTable';
+import AppContainer from '~/client/services/AppContainer';
+import EditorContainer from '~/client/services/EditorContainer';
+import PageContainer from '~/client/services/PageContainer';
+import { getOptionsToSave } from '~/client/util/editor';
+import {
+  useCurrentPagePath, useIsGuestUser, useSlackChannels,
+} from '~/stores/context';
+import { useIsSlackEnabled } from '~/stores/editor';
+import {
+  useEditorMode, useIsMobile, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
+} from '~/stores/ui';
+import loggerFactory from '~/utils/logger';
 
-import LinkEditModal from './PageEditor/LinkEditModal';
 import RevisionRenderer from './Page/RevisionRenderer';
+import DrawioModal from './PageEditor/DrawioModal';
 import GridEditModal from './PageEditor/GridEditModal';
 import HandsontableModal from './PageEditor/HandsontableModal';
-import DrawioModal from './PageEditor/DrawioModal';
-import mtu from './PageEditor/MarkdownTableUtil';
+import LinkEditModal from './PageEditor/LinkEditModal';
 import mdu from './PageEditor/MarkdownDrawioUtil';
-
-import { getOptionsToSave } from '~/client/util/editor';
-
-// TODO: remove this when omitting unstated is completed
-import {
-  useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
-} from '~/stores/ui';
-import { useIsSlackEnabled } from '~/stores/editor';
-import { useCurrentPagePath, useSlackChannels } from '~/stores/context';
+import mtu from './PageEditor/MarkdownTableUtil';
+import { withUnstatedContainers } from './UnstatedUtils';
 
 const logger = loggerFactory('growi:Page');
 
@@ -143,9 +143,9 @@ class Page extends React.Component {
   }
 
   render() {
-    const { appContainer, pageContainer, pagePath } = this.props;
-    const { isMobile } = appContainer;
-    const isLoggedIn = appContainer.currentUser != null;
+    const {
+      pageContainer, pagePath, isMobile, isGuestUser,
+    } = this.props;
     const { markdown, revisionId } = pageContainer.state;
 
     return (
@@ -155,7 +155,7 @@ class Page extends React.Component {
           <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} pagePath={pagePath} />
         )}
 
-        { isLoggedIn && (
+        { !isGuestUser && (
           <>
             <GridEditModal ref={this.gridEditModal} />
             <LinkEditModal ref={this.LinkEditModal} />
@@ -177,6 +177,8 @@ Page.propTypes = {
 
   pagePath: PropTypes.string.isRequired,
   editorMode: PropTypes.string.isRequired,
+  isGuestUser: PropTypes.bool.isRequired,
+  isMobile: PropTypes.bool,
   isSlackEnabled: PropTypes.bool.isRequired,
   slackChannels: PropTypes.string.isRequired,
   grant: PropTypes.number.isRequired,
@@ -187,13 +189,15 @@ Page.propTypes = {
 const PageWrapper = (props) => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: editorMode } = useEditorMode();
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isMobile } = useIsMobile();
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: slackChannels } = useSlackChannels();
   const { data: grant } = useSelectedGrant();
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName } = useSelectedGrantGroupName();
 
-  if (currentPagePath == null || editorMode == null) {
+  if (currentPagePath == null || editorMode == null || isGuestUser == null) {
     return null;
   }
 
@@ -202,6 +206,8 @@ const PageWrapper = (props) => {
       {...props}
       pagePath={currentPagePath}
       editorMode={editorMode}
+      isGuestUser={isGuestUser}
+      isMobile={isMobile}
       isSlackEnabled={isSlackEnabled}
       slackChannels={slackChannels}
       grant={grant}

+ 278 - 0
packages/app/src/components/Page/FixPageGrantAlert.tsx

@@ -0,0 +1,278 @@
+import React, { useEffect, useState, useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { toastError, toastSuccess } from '~/client/util/apiNotification';
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { PageGrant, IPageGrantData } from '~/interfaces/page';
+import { IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
+import { useCurrentPageId, useHasParent } from '~/stores/context';
+import { useSWRxApplicableGrant, useSWRxIsGrantNormalized } from '~/stores/page';
+
+type ModalProps = {
+  isOpen: boolean
+  pageId: string
+  dataApplicableGrant: IRecordApplicableGrant
+  currentAndParentPageGrantData: IResIsGrantNormalizedGrantData
+  close(): void
+}
+
+const FixPageGrantModal = (props: ModalProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    isOpen, pageId, dataApplicableGrant, currentAndParentPageGrantData, close,
+  } = props;
+
+  const [selectedGrant, setSelectedGrant] = useState<PageGrant>(PageGrant.GRANT_RESTRICTED);
+  const [selectedGroup, setSelectedGroup] = useState<{_id: string, name: string} | undefined>(undefined); // TODO: Typescriptize model
+
+  // Alert message state
+  const [shouldShowModalAlert, setShowModalAlert] = useState<boolean>(false);
+
+  const applicableGroups = dataApplicableGrant[PageGrant.GRANT_USER_GROUP]?.applicableGroups;
+
+  // Reset state when opened
+  useEffect(() => {
+    if (isOpen) {
+      setSelectedGrant(PageGrant.GRANT_RESTRICTED);
+      setSelectedGroup(undefined);
+      setShowModalAlert(false);
+    }
+  }, [isOpen]);
+
+  const submit = async() => {
+    // Validate input values
+    if (selectedGrant === PageGrant.GRANT_USER_GROUP && selectedGroup == null) {
+      setShowModalAlert(true);
+      return;
+    }
+
+    close();
+
+    try {
+      await apiv3Put(`/page/${pageId}/grant`, {
+        grant: selectedGrant,
+        grantedGroup: selectedGroup?._id,
+      });
+
+      toastSuccess(t('Successfully updated'));
+    }
+    catch (err) {
+      toastError(t('Failed to update'));
+    }
+  };
+
+  const getGrantLabel = useCallback((isForbidden: boolean, grantData?: IPageGrantData): string => {
+
+    if (isForbidden) {
+      return t('fix_page_grant.modal.grant_label.isForbidden');
+    }
+
+    if (grantData == null) {
+      return t('fix_page_grant.modal.grant_label.isForbidden');
+    }
+
+    if (grantData.grant === 4) {
+      return t('fix_page_grant.modal.radio_btn.only_me');
+    }
+
+    if (grantData.grant === 5) {
+      if (grantData.grantedGroup == null) {
+        return t('fix_page_grant.modal.grant_label.isForbidden');
+      }
+      return `${t('fix_page_grant.modal.radio_btn.grant_group')}: (${grantData.grantedGroup.name})`;
+    }
+
+    throw Error('cannnot get grant label'); // this error can't be throwed
+  }, [t]);
+
+  const renderGrantDataLabel = useCallback(() => {
+    const { isForbidden, currentPageGrant, parentPageGrant } = currentAndParentPageGrantData;
+
+    const currentGrantLabel = getGrantLabel(false, currentPageGrant);
+    const parentGrantLabel = getGrantLabel(isForbidden, parentPageGrant);
+
+    return (
+      <>
+        <p className="mt-3">{ t('fix_page_grant.modal.grant_label.parentPageGrantLabel') + parentGrantLabel }</p>
+        <p>{ t('fix_page_grant.modal.grant_label.currentPageGrantLabel') + currentGrantLabel }</p>
+        {/* eslint-disable-next-line react/no-danger */}
+        <p dangerouslySetInnerHTML={{ __html: t('fix_page_grant.modal.grant_label.docLink') }} />
+      </>
+    );
+  }, [t, currentAndParentPageGrantData, getGrantLabel]);
+
+  const renderModalBodyAndFooter = () => {
+    const isGrantAvailable = Object.keys(dataApplicableGrant || {}).length > 0;
+
+    if (!isGrantAvailable) {
+      return (
+        <p className="m-5">
+          { t('fix_page_grant.modal.no_grant_available') }
+        </p>
+      );
+    }
+
+    return (
+      <>
+        <ModalBody>
+          <div className="form-group">
+            {/* eslint-disable-next-line react/no-danger */}
+            <p className="mb-2" dangerouslySetInnerHTML={{ __html: t('fix_page_grant.modal.need_to_fix_grant') }} />
+
+            {/* grant data label */}
+            {renderGrantDataLabel()}
+
+            <div className="ml-2">
+              <div className="custom-control custom-radio mb-3">
+                <input
+                  className="custom-control-input"
+                  name="grantRestricted"
+                  id="grantRestricted"
+                  type="radio"
+                  disabled={!(PageGrant.GRANT_RESTRICTED in dataApplicableGrant)}
+                  checked={selectedGrant === PageGrant.GRANT_RESTRICTED}
+                  onChange={() => setSelectedGrant(PageGrant.GRANT_RESTRICTED)}
+                />
+                <label className="custom-control-label" htmlFor="grantRestricted">
+                  { t('fix_page_grant.modal.radio_btn.restrected') }
+                </label>
+              </div>
+              <div className="custom-control custom-radio mb-3">
+                <input
+                  className="custom-control-input"
+                  name="grantUser"
+                  id="grantUser"
+                  type="radio"
+                  disabled={!(PageGrant.GRANT_OWNER in dataApplicableGrant)}
+                  checked={selectedGrant === PageGrant.GRANT_OWNER}
+                  onChange={() => setSelectedGrant(PageGrant.GRANT_OWNER)}
+                />
+                <label className="custom-control-label" htmlFor="grantUser">
+                  { t('fix_page_grant.modal.radio_btn.only_me') }
+                </label>
+              </div>
+              <div className="custom-control custom-radio d-flex mb-3">
+                <input
+                  className="custom-control-input"
+                  name="grantUserGroup"
+                  id="grantUserGroup"
+                  type="radio"
+                  disabled={!(PageGrant.GRANT_USER_GROUP in dataApplicableGrant)}
+                  checked={selectedGrant === PageGrant.GRANT_USER_GROUP}
+                  onChange={() => setSelectedGrant(PageGrant.GRANT_USER_GROUP)}
+                />
+                <label className="custom-control-label" htmlFor="grantUserGroup">
+                  { t('fix_page_grant.modal.radio_btn.grant_group') }
+                </label>
+                <div className="dropdown ml-2">
+                  <button
+                    type="button"
+                    className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
+                    data-toggle="dropdown"
+                    disabled={selectedGrant !== PageGrant.GRANT_USER_GROUP} // disable when its radio input is not selected
+                  >
+                    <span className="float-left ml-2">
+                      {
+                        selectedGroup == null
+                          ? t('fix_page_grant.modal.select_group_default_text')
+                          : selectedGroup.name
+                      }
+                    </span>
+                  </button>
+                  <div className="dropdown-menu">
+                    {
+                      applicableGroups != null && applicableGroups.map(g => (
+                        <button
+                          className="dropdown-item"
+                          type="button"
+                          onClick={() => setSelectedGroup(g)}
+                        >
+                          {g.name}
+                        </button>
+                      ))
+                    }
+                  </div>
+                </div>
+              </div>
+              {
+                shouldShowModalAlert && (
+                  <p className="alert alert-warning">
+                    {t('fix_page_grant.modal.alert_message')}
+                  </p>
+                )
+              }
+            </div>
+          </div>
+        </ModalBody>
+        <ModalFooter>
+          <button type="button" className="btn btn-primary" onClick={submit}>
+            { t('fix_page_grant.modal.btn_label') }
+          </button>
+        </ModalFooter>
+      </>
+    );
+  };
+
+  return (
+    <Modal size="lg" isOpen={isOpen} toggle={close} className="grw-create-page">
+      <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
+        { t('fix_page_grant.modal.title') }
+      </ModalHeader>
+      {renderModalBodyAndFooter()}
+    </Modal>
+  );
+};
+
+const FixPageGrantAlert = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  const [isOpen, setOpen] = useState<boolean>(false);
+
+  const { data: pageId } = useCurrentPageId();
+  const { data: hasParent } = useHasParent();
+  const { data: dataIsGrantNormalized } = useSWRxIsGrantNormalized(pageId);
+  const { data: dataApplicableGrant } = useSWRxApplicableGrant(pageId);
+
+  // Dependencies
+  if (!hasParent) {
+    return <></>;
+  }
+  if (dataIsGrantNormalized?.isGrantNormalized == null || dataIsGrantNormalized.isGrantNormalized) {
+    return <></>;
+  }
+
+  return (
+    <>
+      <div className="alert alert-warning py-3 pl-4 d-flex flex-column flex-lg-row">
+        <div className="flex-grow-1 d-flex align-items-center">
+          <i className="icon-fw icon-exclamation ml-1" aria-hidden="true" />
+          {t('fix_page_grant.alert.description')}
+        </div>
+        <div className="d-flex align-items-end align-items-lg-center">
+          <button type="button" className="btn btn-info btn-sm rounded-pill px-3" onClick={() => setOpen(true)}>
+            {t('fix_page_grant.alert.btn_label')}
+          </button>
+        </div>
+      </div>
+
+      {
+        pageId != null && dataApplicableGrant != null && (
+          <FixPageGrantModal
+            isOpen={isOpen}
+            pageId={pageId}
+            dataApplicableGrant={dataApplicableGrant}
+            currentAndParentPageGrantData={dataIsGrantNormalized.grantData}
+            close={() => setOpen(false)}
+          />
+        )
+      }
+    </>
+  );
+};
+
+export default FixPageGrantAlert;

+ 7 - 4
packages/app/src/components/Page/RevisionLoader.jsx

@@ -1,16 +1,19 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
+import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { Waypoint } from 'react-waypoint';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
-import GrowiRenderer from '~/client/util/GrowiRenderer';
 import AppContainer from '~/client/services/AppContainer';
+import GrowiRenderer from '~/client/util/GrowiRenderer';
+import { apiv3Get } from '~/client/util/apiv3-client';
 import loggerFactory from '~/utils/logger';
 
+import { withUnstatedContainers } from '../UnstatedUtils';
+
 import RevisionRenderer from './RevisionRenderer';
 
+
 /**
  * Load data from server and render RevisionBody component
  */
@@ -47,7 +50,7 @@ class LegacyRevisionLoader extends React.Component {
 
     // load data with REST API
     try {
-      const res = await this.props.appContainer.apiv3Get(`/revisions/${revisionId}`, { pageId });
+      const res = await apiv3Get(`/revisions/${revisionId}`, { pageId });
 
       this.setState({
         markdown: res.data?.revision?.body,

+ 15 - 3
packages/app/src/components/Page/RevisionRenderer.jsx

@@ -1,13 +1,17 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import GrowiRenderer from '~/client/util/GrowiRenderer';
-import { addSmoothScrollEvent } from '~/client/util/smooth-scroll';
 import { blinkElem } from '~/client/util/blink-section-header';
+import { addSmoothScrollEvent } from '~/client/util/smooth-scroll';
+import { useEditorSettings } from '~/stores/editor';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 import RevisionBody from './RevisionBody';
+
 import { loggerFactory } from '^/../codemirror-textlint/src/utils/logger';
 
 const logger = loggerFactory('components:Page:RevisionRenderer');
@@ -29,6 +33,7 @@ class LegacyRevisionRenderer extends React.PureComponent {
     this.currentRenderingContext = {
       markdown: this.props.markdown,
       pagePath: this.props.pagePath,
+      editorSettings: this.editorSettings,
       currentPathname: decodeURIComponent(window.location.pathname),
     };
   }
@@ -173,6 +178,7 @@ LegacyRevisionRenderer.propTypes = {
   pagePath: PropTypes.string.isRequired,
   highlightKeywords: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
   additionalClassName: PropTypes.string,
+  editorSettings: PropTypes.any,
 };
 
 /**
@@ -183,7 +189,13 @@ const LegacyRevisionRendererWrapper = withUnstatedContainers(LegacyRevisionRende
 
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 const RevisionRenderer = (props) => {
-  return <LegacyRevisionRendererWrapper {...props} />;
+  const { data: editorSettings } = useEditorSettings();
+
+  if (editorSettings == null) {
+    return <></>;
+  }
+
+  return <LegacyRevisionRendererWrapper {...props} editorSettings={editorSettings} />;
 };
 
 RevisionRenderer.propTypes = {

+ 5 - 2
packages/app/src/components/PageAttachment.jsx

@@ -6,12 +6,15 @@ import { withTranslation } from 'react-i18next';
 
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
+import { apiPost } from '~/client/util/apiv1-client';
+import { apiv3Get } from '~/client/util/apiv3-client';
 
 import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
 import PageAttachmentList from './PageAttachment/PageAttachmentList';
 import PaginationWrapper from './PaginationWrapper';
 import { withUnstatedContainers } from './UnstatedUtils';
 
+
 class PageAttachment extends React.Component {
 
   constructor(props) {
@@ -40,7 +43,7 @@ class PageAttachment extends React.Component {
 
     if (!pageId) { return }
 
-    const res = await this.props.appContainer.apiv3Get('/attachment/list', { pageId, page });
+    const res = await apiv3Get('/attachment/list', { pageId, page });
     const attachments = res.data.paginateResult.docs;
     const totalAttachments = res.data.paginateResult.totalDocs;
     const pagingLimit = res.data.paginateResult.limit;
@@ -88,7 +91,7 @@ class PageAttachment extends React.Component {
       deleting: true,
     });
 
-    this.props.appContainer.apiPost('/attachments.remove', { attachment_id: attachmentId })
+    apiPost('/attachments.remove', { attachment_id: attachmentId })
       .then((res) => {
         this.setState({
           attachments: this.state.attachments.filter((at) => {

+ 9 - 7
packages/app/src/components/PageComment.tsx

@@ -4,17 +4,19 @@ import React, {
 
 import { Button } from 'reactstrap';
 
-import CommentEditor from './PageComment/CommentEditor';
-import Comment from './PageComment/Comment';
-import ReplayComments from './PageComment/ReplayComments';
-import DeleteCommentModal from './PageComment/DeleteCommentModal';
 
 import AppContainer from '~/client/services/AppContainer';
 import { toastError } from '~/client/util/apiNotification';
+import { apiPost } from '~/client/util/apiv1-client';
 
+import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
 import { useSWRxPageComment } from '../stores/comment';
 
-import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
+
+import Comment from './PageComment/Comment';
+import CommentEditor from './PageComment/CommentEditor';
+import DeleteCommentModal from './PageComment/DeleteCommentModal';
+import ReplayComments from './PageComment/ReplayComments';
 
 type Props = {
   appContainer: AppContainer,
@@ -97,14 +99,14 @@ const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
   const onDeleteComment = useCallback(async() => {
     if (commentToBeDeleted == null) return;
     try {
-      await appContainer.apiPost('/comments.remove', { comment_id: commentToBeDeleted._id });
+      await apiPost('/comments.remove', { comment_id: commentToBeDeleted._id });
       onDeleteCommentAfterOperation();
     }
     catch (error:unknown) {
       setErrorMessageOnDelete(error as string);
       toastError(`error: ${error}`);
     }
-  }, [appContainer, commentToBeDeleted, onDeleteCommentAfterOperation]);
+  }, [commentToBeDeleted, onDeleteCommentAfterOperation]);
 
   const generateCommentInnerElement = (comment: ICommentHasId) => (
     <Comment

+ 25 - 8
packages/app/src/components/PageComment/CommentEditor.jsx

@@ -13,6 +13,8 @@ import CommentContainer from '~/client/services/CommentContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
 import GrowiRenderer from '~/client/util/GrowiRenderer';
+import { useCurrentUser } from '~/stores/context';
+import { useIsMobile } from '~/stores/ui';
 
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
 import NotAvailableForGuest from '../NotAvailableForGuest';
@@ -271,7 +273,7 @@ class CommentEditor extends React.Component {
   }
 
   renderReady() {
-    const { appContainer, commentContainer } = this.props;
+    const { appContainer, commentContainer, isMobile } = this.props;
     const { activeTab } = this.state;
 
     const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : null;
@@ -305,7 +307,7 @@ class CommentEditor extends React.Component {
                 value={this.state.comment}
                 isGfmMode={this.state.isMarkdown}
                 lineNumbers={false}
-                isMobile={appContainer.isMobile}
+                isMobile={isMobile}
                 isUploadable={this.state.isUploadable}
                 isUploadableFile={this.state.isUploadableFile}
                 onChange={this.updateState}
@@ -381,14 +383,14 @@ class CommentEditor extends React.Component {
   }
 
   render() {
-    const { appContainer } = this.props;
+    const { currentUser } = this.props;
     const { isReadyToUse } = this.state;
 
     return (
       <div className="form page-comment-form">
         <div className="comment-form">
           <div className="comment-form-user">
-            <UserPicture user={appContainer.currentUser} noLink noTooltip />
+            <UserPicture user={currentUser} noLink noTooltip />
           </div>
           <div className="comment-form-main">
             { !isReadyToUse
@@ -403,6 +405,11 @@ class CommentEditor extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const CommentEditorHOCWrapper = withUnstatedContainers(CommentEditor, [AppContainer, PageContainer, EditorContainer, CommentContainer]);
+
 CommentEditor.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
@@ -410,6 +417,8 @@ CommentEditor.propTypes = {
   commentContainer: PropTypes.instanceOf(CommentContainer).isRequired,
 
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
+  currentUser: PropTypes.instanceOf(Object),
+  isMobile: PropTypes.bool,
   isForNewComment: PropTypes.bool,
   replyTo: PropTypes.string,
   currentCommentId: PropTypes.string,
@@ -419,9 +428,17 @@ CommentEditor.propTypes = {
   onCommentButtonClicked: PropTypes.func,
 };
 
-/**
- * Wrapper component for using unstated
- */
-const CommentEditorWrapper = withUnstatedContainers(CommentEditor, [AppContainer, PageContainer, EditorContainer, CommentContainer]);
+const CommentEditorWrapper = (props) => {
+  const { data: isMobile } = useIsMobile();
+  const { data: currentUser } = useCurrentUser();
+
+  return (
+    <CommentEditorHOCWrapper
+      {...props}
+      currentUser={currentUser}
+      isMobile={isMobile}
+    />
+  );
+};
 
 export default CommentEditorWrapper;

+ 54 - 38
packages/app/src/components/PageEditor.jsx

@@ -1,30 +1,31 @@
 import React from 'react';
-import PropTypes from 'prop-types';
-import detectIndent from 'detect-indent';
 
-import { throttle, debounce } from 'throttle-debounce';
 import { envUtils } from '@growi/core';
-import loggerFactory from '~/utils/logger';
+import detectIndent from 'detect-indent';
+import PropTypes from 'prop-types';
+import { throttle, debounce } from 'throttle-debounce';
 
 import AppContainer from '~/client/services/AppContainer';
+import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
+import { apiGet, apiPost } from '~/client/util/apiv1-client';
+import { getOptionsToSave } from '~/client/util/editor';
+import { useIsEditable, useIsIndentSizeForced, useSlackChannels } from '~/stores/context';
+import { useCurrentIndentSize, useIsSlackEnabled, useIsTextlintEnabled } from '~/stores/editor';
+import {
+  useEditorMode, useIsMobile, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
+} from '~/stores/ui';
+import loggerFactory from '~/utils/logger';
 
-import { withUnstatedContainers } from './UnstatedUtils';
+
+import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
 import Editor from './PageEditor/Editor';
 import Preview from './PageEditor/Preview';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
-import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
+import { withUnstatedContainers } from './UnstatedUtils';
 
-import EditorContainer from '~/client/services/EditorContainer';
-
-import { getOptionsToSave } from '~/client/util/editor';
 
 // TODO: remove this when omitting unstated is completed
-import {
-  useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
-} from '~/stores/ui';
-import { useIsEditable, useSlackChannels } from '~/stores/context';
-import { useIsSlackEnabled } from '~/stores/editor';
 
 const logger = loggerFactory('growi:PageEditor');
 
@@ -74,10 +75,10 @@ class PageEditor extends React.Component {
 
     // Detect indent size from contents (only when users are allowed to change it)
     // TODO: https://youtrack.weseek.co.jp/issue/GW-5368
-    if (!this.props.appContainer.config.isIndentSizeForced && this.state.markdown) {
+    if (!props.isIndentSizeForced && this.state.markdown) {
       const detectedIndent = detectIndent(this.state.markdown);
       if (detectedIndent.type === 'space' && new Set([2, 4]).has(detectedIndent.amount)) {
-        this.props.editorContainer.setState({ indentSize: detectedIndent.amount });
+        props.mutateCurrentIndentSize(detectedIndent.amount);
       }
     }
   }
@@ -170,7 +171,7 @@ class PageEditor extends React.Component {
     } = this.props;
 
     try {
-      let res = await appContainer.apiGet('/attachments.limit', {
+      let res = await apiGet('/attachments.limit', {
         fileSize: file.size,
       });
 
@@ -187,7 +188,7 @@ class PageEditor extends React.Component {
         formData.append('page_id', pageContainer.state.pageId);
       }
 
-      res = await appContainer.apiPost('/attachments.add', formData);
+      res = await apiPost('/attachments.add', formData);
       const attachment = res.attachment;
       const fileName = attachment.originalName;
 
@@ -329,8 +330,6 @@ class PageEditor extends React.Component {
     const config = this.props.appContainer.getConfig();
     const noCdn = envUtils.toBoolean(config.env.NO_CDN);
 
-    const { path } = this.props.pageContainer.state;
-
     return (
       <div className="d-flex flex-wrap">
         <div className="page-editor-editor-container flex-grow-1 flex-basis-0 mw-0">
@@ -338,9 +337,11 @@ class PageEditor extends React.Component {
             ref={(c) => { this.editor = c }}
             value={this.state.markdown}
             noCdn={noCdn}
-            isMobile={this.props.appContainer.isMobile}
+            isMobile={this.props.isMobile}
             isUploadable={this.state.isUploadable}
             isUploadableFile={this.state.isUploadableFile}
+            isTextlintEnabled={this.props.isTextlintEnabled}
+            indentSize={this.props.indentSize}
             onScroll={this.onEditorScroll}
             onScrollCursorIntoView={this.onEditorScrollCursorIntoView}
             onChange={this.onMarkdownChanged}
@@ -371,6 +372,28 @@ class PageEditor extends React.Component {
 
 }
 
+PageEditor.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
+  isEditable: PropTypes.bool.isRequired,
+
+  // TODO: remove this when omitting unstated is completed
+  editorMode: PropTypes.string.isRequired,
+  isMobile: PropTypes.bool,
+  isSlackEnabled: PropTypes.bool.isRequired,
+  slackChannels: PropTypes.string.isRequired,
+  grant: PropTypes.number.isRequired,
+  grantGroupId: PropTypes.string,
+  grantGroupName: PropTypes.string,
+  mutateGrant: PropTypes.func,
+  isTextlintEnabled: PropTypes.bool,
+  isIndentSizeForced: PropTypes.bool,
+  indentSize: PropTypes.number,
+  mutateCurrentIndentSize: PropTypes.func,
+};
+
 /**
  * Wrapper component for using unstated
  */
@@ -379,11 +402,15 @@ const PageEditorHOCWrapper = withUnstatedContainers(PageEditor, [AppContainer, P
 const PageEditorWrapper = (props) => {
   const { data: isEditable } = useIsEditable();
   const { data: editorMode } = useEditorMode();
+  const { data: isMobile } = useIsMobile();
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: slackChannels } = useSlackChannels();
   const { data: grant, mutate: mutateGrant } = useSelectedGrant();
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName } = useSelectedGrantGroupName();
+  const { data: isTextlintEnabled } = useIsTextlintEnabled();
+  const { data: isIndentSizeForced } = useIsIndentSizeForced();
+  const { data: indentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
 
   if (isEditable == null || editorMode == null) {
     return null;
@@ -394,31 +421,20 @@ const PageEditorWrapper = (props) => {
       {...props}
       isEditable={isEditable}
       editorMode={editorMode}
+      isMobile={isMobile}
       isSlackEnabled={isSlackEnabled}
       slackChannels={slackChannels}
       grant={grant}
       grantGroupId={grantGroupId}
       grantGroupName={grantGroupName}
       mutateGrant={mutateGrant}
+      isTextlintEnabled={isTextlintEnabled}
+      isIndentSizeForced={isIndentSizeForced}
+      indentSize={indentSize}
+      mutateCurrentIndentSize={mutateCurrentIndentSize}
+
     />
   );
 };
 
-PageEditor.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-
-  isEditable: PropTypes.bool.isRequired,
-
-  // TODO: remove this when omitting unstated is completed
-  editorMode: PropTypes.string.isRequired,
-  isSlackEnabled: PropTypes.bool.isRequired,
-  slackChannels: PropTypes.string.isRequired,
-  grant: PropTypes.number.isRequired,
-  grantGroupId: PropTypes.string,
-  grantGroupName: PropTypes.string,
-  mutateGrant: PropTypes.func,
-};
-
 export default PageEditorWrapper;

+ 42 - 34
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -100,7 +100,7 @@ require('codemirror/mode/yaml/yaml');
 const MARKDOWN_TABLE_ACTIVATED_CLASS = 'markdown-table-activated';
 const MARKDOWN_LINK_ACTIVATED_CLASS = 'markdown-link-activated';
 
-export default class CodeMirrorEditor extends AbstractEditor {
+class CodeMirrorEditor extends AbstractEditor {
 
   constructor(props) {
     super(props);
@@ -172,22 +172,10 @@ export default class CodeMirrorEditor extends AbstractEditor {
     this.loadedKeymapSet = new Set();
   }
 
-  componentWillMount() {
-    this.initializeTextlint();
-  }
-
   componentDidMount() {
     // ensure to be able to resolve 'this' to use 'codemirror.commands.save'
     this.getCodeMirror().codeMirrorEditor = this;
 
-    // load theme
-    const theme = this.props.editorOptions.theme;
-    this.loadTheme(theme);
-
-    // set keymap
-    const keymapMode = this.props.editorOptions.keymapMode;
-    this.setKeymapMode(keymapMode);
-
     // fold drawio section
     this.foldDrawioSection();
 
@@ -200,28 +188,45 @@ export default class CodeMirrorEditor extends AbstractEditor {
   }
 
   componentWillReceiveProps(nextProps) {
-    // load theme
-    const theme = nextProps.editorOptions.theme;
-    this.loadTheme(theme);
+    this.initializeEditorSettings(nextProps.editorSettings);
 
-    // set keymap
-    const keymapMode = nextProps.editorOptions.keymapMode;
-    this.setKeymapMode(keymapMode);
+    this.initializeTextlint(nextProps.isTextlintEnabled, nextProps.editorSettings);
 
     // fold drawio section
     this.foldDrawioSection();
   }
 
-  async initializeTextlint() {
-    if (this.props.onInitializeTextlint != null) {
-      await this.props.onInitializeTextlint();
-      // If database has empty array, pass null instead to enable all default rules
-      const rulesForValidator = this.props.textlintRules?.length !== 0 ? this.props.textlintRules : null;
-      this.textlintValidator = createValidator(rulesForValidator);
-      this.codemirrorLintConfig = { getAnnotations: this.textlintValidator, async: true };
+  initializeEditorSettings(editorSettings) {
+    if (editorSettings == null) {
+      return;
+    }
+
+    // load theme
+    const theme = editorSettings.theme;
+    if (theme != null) {
+      this.loadTheme(theme);
+    }
+
+    // set keymap
+    const keymapMode = editorSettings.keymapMode;
+    if (keymapMode != null) {
+      this.setKeymapMode(keymapMode);
     }
   }
 
+  async initializeTextlint(isTextlintEnabled, editorSettings) {
+    if (!isTextlintEnabled || editorSettings == null) {
+      return;
+    }
+
+    const textlintRules = editorSettings.textlintSettings?.textlintRules;
+
+    // If database has empty array, pass null instead to enable all default rules
+    const rulesForValidator = (textlintRules == null || textlintRules.length === 0) ? null : textlintRules;
+    this.textlintValidator = createValidator(rulesForValidator);
+    this.codemirrorLintConfig = { getAnnotations: this.textlintValidator, async: true };
+  }
+
   getCodeMirror() {
     return this.cm.editor;
   }
@@ -509,7 +514,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
     const context = {
       handlers: [], // list of handlers which process enter key
       editor: this,
-      editorOptions: this.props.editorOptions,
+      editorSettings: this.props.editorSettings,
     };
 
     const interceptorManager = this.interceptorManager;
@@ -969,7 +974,9 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
 
   render() {
-    const lint = this.props.isTextlintEnabled ? this.codemirrorLintConfig : false;
+    const { isTextlintEnabled } = this.props;
+
+    const lint = isTextlintEnabled ? this.codemirrorLintConfig : false;
     const additionalClasses = Array.from(this.state.additionalClassSet).join(' ');
     const placeholder = this.state.isGfmMode ? 'Input with Markdown..' : 'Input with Plain Text..';
 
@@ -977,7 +984,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
     if (this.props.lineNumbers != null) {
       gutters.push('CodeMirror-linenumbers', 'CodeMirror-foldgutter');
     }
-    if (this.props.isTextlintEnabled === true) {
+    if (isTextlintEnabled) {
       gutters.push('CodeMirror-lint-markers');
     }
 
@@ -996,6 +1003,8 @@ export default class CodeMirrorEditor extends AbstractEditor {
           value={this.state.value}
           options={{
             indentUnit: this.props.indentSize,
+            theme: this.props.editorSettings.theme ?? 'elegant',
+            styleActiveLine: this.props.editorSettings.styleActiveLine,
             lineWrapping: true,
             scrollPastEnd: true,
             autoRefresh: { force: true }, // force option is enabled by autorefresh.ext.js -- Yuki Takei
@@ -1054,7 +1063,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         <HandsontableModal
           ref={this.handsontableModal}
           onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}
-          ignoreAutoFormatting={this.props.editorOptions.ignoreMarkdownTableAutoFormatting}
+          autoFormatMarkdownTable={this.props.editorSettings.autoFormatMarkdownTable}
         />
         <DrawioModal
           ref={this.drawioModal}
@@ -1068,16 +1077,15 @@ export default class CodeMirrorEditor extends AbstractEditor {
 }
 
 CodeMirrorEditor.propTypes = Object.assign({
-  editorOptions: PropTypes.object.isRequired,
   isTextlintEnabled: PropTypes.bool,
-  textlintRules: PropTypes.array,
   lineNumbers: PropTypes.bool,
+  editorSettings: PropTypes.object.isRequired,
   onMarkdownHelpButtonClicked: PropTypes.func,
   onAddAttachmentButtonClicked: PropTypes.func,
-  onInitializeTextlint: PropTypes.func,
 }, AbstractEditor.propTypes);
 
 CodeMirrorEditor.defaultProps = {
   lineNumbers: true,
-  isTextlintEnabled: false,
 };
+
+export default CodeMirrorEditor;

+ 8 - 14
packages/app/src/components/PageEditor/DownloadDictModal.tsx

@@ -1,19 +1,19 @@
-import React, { useState, FC } from 'react';
-import PropTypes from 'prop-types';
+import React, { useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
 
 type DownloadDictModalProps = {
   isModalOpen: boolean
-  onConfirmEnableTextlint?: (isSkipAskingAgainChecked: boolean) => void;
+  onEnableTextlint?: (isSkipAskingAgainChecked: boolean) => void;
   onCancel?: () => void;
 };
 
-export const DownloadDictModal: FC<DownloadDictModalProps> = (props) => {
+export const DownloadDictModal = (props: DownloadDictModalProps): JSX.Element => {
   const { t } = useTranslation('');
-  const [isSkipAskingAgainChecked, setIsSkipAskingAgainChecked] = useState(true);
+  const [isSkipAskingAgainChecked, setIsSkipAskingAgainChecked] = useState(false);
 
   const onCancel = () => {
     if (props.onCancel != null) {
@@ -22,8 +22,8 @@ export const DownloadDictModal: FC<DownloadDictModalProps> = (props) => {
   };
 
   const onConfirmEnableTextlint = () => {
-    if (props.onConfirmEnableTextlint != null) {
-      props.onConfirmEnableTextlint(isSkipAskingAgainChecked);
+    if (props.onEnableTextlint != null) {
+      props.onEnableTextlint(isSkipAskingAgainChecked);
     }
   };
 
@@ -67,9 +67,3 @@ export const DownloadDictModal: FC<DownloadDictModalProps> = (props) => {
     </Modal>
   );
 };
-
-DownloadDictModal.propTypes = {
-  isModalOpen: PropTypes.bool.isRequired,
-  onConfirmEnableTextlint: PropTypes.func,
-  onCancel: PropTypes.func,
-};

+ 43 - 32
packages/app/src/components/PageEditor/Editor.jsx

@@ -1,24 +1,20 @@
 import React from 'react';
-import PropTypes from 'prop-types';
-
-import { Subscribe } from 'unstated';
 
+import PropTypes from 'prop-types';
+import Dropzone from 'react-dropzone';
 import {
   Modal, ModalHeader, ModalBody,
 } from 'reactstrap';
 
-import Dropzone from 'react-dropzone';
+import { useDefaultIndentSize } from '~/stores/context';
+import { useEditorSettings } from '~/stores/editor';
 
-import EditorContainer from '~/client/services/EditorContainer';
-import AppContainer from '~/client/services/AppContainer';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-import Cheatsheet from './Cheatsheet';
 import AbstractEditor from './AbstractEditor';
+import Cheatsheet from './Cheatsheet';
 import CodeMirrorEditor from './CodeMirrorEditor';
+import pasteHelper from './PasteHelper';
 import TextAreaEditor from './TextAreaEditor';
 
-import pasteHelper from './PasteHelper';
 
 class Editor extends AbstractEditor {
 
@@ -285,7 +281,10 @@ class Editor extends AbstractEditor {
       flexDirection: 'column',
     };
 
-    const isMobile = this.props.isMobile;
+    const {
+      isMobile,
+      indentSize,
+    } = this.props;
 
     return (
       <>
@@ -313,24 +312,16 @@ class Editor extends AbstractEditor {
 
                   {/* for PC */}
                   { !isMobile && (
-                    <Subscribe to={[EditorContainer]}>
-                      { editorContainer => (
-                        // eslint-disable-next-line arrow-body-style
-                        <CodeMirrorEditor
-                          ref={(c) => { this.cmEditor = c }}
-                          indentSize={editorContainer.state.indentSize}
-                          editorOptions={editorContainer.state.editorOptions}
-                          isTextlintEnabled={editorContainer.state.isTextlintEnabled}
-                          textlintRules={editorContainer.state.textlintRules}
-                          onInitializeTextlint={editorContainer.retrieveEditorSettings}
-                          onPasteFiles={this.pasteFilesHandler}
-                          onDragEnter={this.dragEnterHandler}
-                          onMarkdownHelpButtonClicked={this.showMarkdownHelp}
-                          onAddAttachmentButtonClicked={this.addAttachmentHandler}
-                          {...this.props}
-                        />
-                      )}
-                    </Subscribe>
+                    // eslint-disable-next-line arrow-body-style
+                    <CodeMirrorEditor
+                      ref={(c) => { this.cmEditor = c }}
+                      indentSize={indentSize}
+                      onPasteFiles={this.pasteFilesHandler}
+                      onDragEnter={this.dragEnterHandler}
+                      onMarkdownHelpButtonClicked={this.showMarkdownHelp}
+                      onAddAttachmentButtonClicked={this.addAttachmentHandler}
+                      {...this.props}
+                    />
                   )}
 
                   {/* for mobile */}
@@ -386,8 +377,28 @@ Editor.propTypes = Object.assign({
   isUploadableFile: PropTypes.bool,
   onChange: PropTypes.func,
   onUpload: PropTypes.func,
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  editorSettings: PropTypes.object.isRequired,
+  indentSize: PropTypes.number,
 }, AbstractEditor.propTypes);
 
-export default withUnstatedContainers(Editor, [EditorContainer, AppContainer]);
+
+const EditorWrapper = React.forwardRef((props, ref) => {
+  const { data: editorSettings } = useEditorSettings();
+  const { data: defaultIndentSize } = useDefaultIndentSize();
+
+  if (editorSettings == null) {
+    return <></>;
+  }
+
+  return (
+    <Editor
+      ref={ref}
+      {...props}
+      editorSettings={editorSettings}
+      // eslint-disable-next-line react/prop-types
+      indentSize={props.indentSize ?? defaultIndentSize}
+    />
+  );
+});
+
+export default EditorWrapper;

+ 5 - 8
packages/app/src/components/PageEditor/EmojiPicker.tsx

@@ -23,14 +23,11 @@ const EmojiPicker: FC<Props> = (props: Props) => {
   // Set search emoji input and trigger search
   const searchEmoji = () => {
     const input = window.document.querySelector('[id^="emoji-mart-search"]') as HTMLInputElement;
-    if (emojiSearchText !== null) {
-
-      const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
-      valueSetter?.call(input, emojiSearchText);
-      const event = new Event('input', { bubbles: true });
-      input.dispatchEvent(event);
-      input.focus();
-    }
+    const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
+    valueSetter?.call(input, emojiSearchText);
+    const event = new Event('input', { bubbles: true });
+    input.dispatchEvent(event);
+    input.focus();
   };
 
   const selectEmoji = (emoji) => {

+ 8 - 7
packages/app/src/components/PageEditor/HandsontableModal.jsx

@@ -1,20 +1,21 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
+import { HotTable } from '@handsontable/react';
+import Handsontable from 'handsontable';
+import PropTypes from 'prop-types';
 import {
   Collapse,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
-
-import Handsontable from 'handsontable';
-import { HotTable } from '@handsontable/react';
 import { debounce } from 'throttle-debounce';
 
 
-import MarkdownTableDataImportForm from './MarkdownTableDataImportForm';
 import MarkdownTable from '~/client/models/MarkdownTable';
+
 import ExpandOrContractButton from '../ExpandOrContractButton';
 
+import MarkdownTableDataImportForm from './MarkdownTableDataImportForm';
+
 const DEFAULT_HOT_HEIGHT = 300;
 const MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING = {
   r: 'htRight',
@@ -401,7 +402,7 @@ export default class HandsontableModal extends React.PureComponent {
   get markdownTableOption() {
     return {
       align: [].concat(this.state.markdownTable.options.align),
-      pad: this.props.ignoreAutoFormatting !== true,
+      pad: this.props.autoFormatMarkdownTable !== false,
     };
   }
 
@@ -518,5 +519,5 @@ export default class HandsontableModal extends React.PureComponent {
 
 HandsontableModal.propTypes = {
   onSave: PropTypes.func,
-  ignoreAutoFormatting: PropTypes.bool,
+  autoFormatMarkdownTable: PropTypes.bool,
 };

+ 10 - 11
packages/app/src/components/PageEditor/LinkEditModal.jsx

@@ -1,6 +1,9 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
+import path from 'path';
+
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
 import {
   Modal,
   ModalHeader,
@@ -9,21 +12,18 @@ import {
   Popover,
   PopoverBody,
 } from 'reactstrap';
-
-import path from 'path';
 import validator from 'validator';
-import { withTranslation } from 'react-i18next';
 
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
 import Linker from '~/client/models/Linker';
+import PageContainer from '~/client/services/PageContainer';
+import { apiv3Get } from '~/client/util/apiv3-client';
 
-import PreviewWithSuspense from './PreviewWithSuspense';
 import PagePreviewIcon from '../Icons/PagePreviewIcon';
 import SearchTypeahead from '../SearchTypeahead';
-
 import { withUnstatedContainers } from '../UnstatedUtils';
 
+import PreviewWithSuspense from './PreviewWithSuspense';
+
 
 class LinkEditModal extends React.PureComponent {
 
@@ -164,7 +164,7 @@ class LinkEditModal extends React.PureComponent {
       const pageId = isPermanentLink ? pathWithoutFragment.slice(1) : null;
 
       try {
-        const { data } = await this.props.appContainer.apiv3Get('/page', { path: pathWithoutFragment, page_id: pageId });
+        const { data } = await apiv3Get('/page', { path: pathWithoutFragment, page_id: pageId });
         const { page } = data;
         markdown = page.revision.body;
         pagePath = page.path;
@@ -460,7 +460,6 @@ class LinkEditModal extends React.PureComponent {
 
 LinkEditModal.propTypes = {
   t: PropTypes.func.isRequired,
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   onSave: PropTypes.func,
 };
@@ -468,6 +467,6 @@ LinkEditModal.propTypes = {
 /**
  * Wrapper component for using unstated
  */
-const LinkEditModalWrapper = withUnstatedContainers(LinkEditModal, [AppContainer, PageContainer]);
+const LinkEditModalWrapper = withUnstatedContainers(LinkEditModal, [PageContainer]);
 
 export default withTranslation('translation', { withRef: true })(LinkEditModalWrapper);

+ 4 - 3
packages/app/src/components/PageEditor/MarkdownTableInterceptor.js

@@ -1,8 +1,9 @@
 import { BasicInterceptor } from '@growi/core';
 
-import mtu from './MarkdownTableUtil';
 import MarkdownTable from '~/client/models/MarkdownTable';
 
+import mtu from './MarkdownTableUtil';
+
 /**
  * Interceptor for markdown table
  */
@@ -56,8 +57,8 @@ export default class MarkdownTableInterceptor extends BasicInterceptor {
   async process(contextName, ...args) {
     const context = Object.assign(args[0]); // clone
     const editor = context.editor; // AbstractEditor instance
-    // "ignoreMarkdownTableAutoFormatting" may be undefined, so it is compared to true and converted to bool.
-    const noIntercept = (context.editorOptions.ignoreMarkdownTableAutoFormatting === true);
+    // "autoFormatMarkdownTable" may be undefined, so it is compared to true and converted to bool.
+    const noIntercept = (context.editorSettings?.autoFormatMarkdownTable === false);
 
     // do nothing if editor is not a CodeMirrorEditor or no intercept
     if (editor == null || editor.getCodeMirror() == null || noIntercept) {

+ 0 - 453
packages/app/src/components/PageEditor/OptionsSelector.jsx

@@ -1,453 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import {
-  Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
-} from 'reactstrap';
-
-import AppContainer from '~/client/services/AppContainer';
-import EditorContainer from '~/client/services/EditorContainer';
-import { toastError } from '~/client/util/apiNotification';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-import { DownloadDictModal } from './DownloadDictModal';
-
-
-export const defaultEditorOptions = {
-  theme: 'elegant',
-  keymapMode: 'default',
-  styleActiveLine: false,
-};
-
-export const defaultPreviewOptions = {
-  renderMathJaxInRealtime: false,
-  renderDrawioInRealtime: true,
-};
-
-class OptionsSelector extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    const config = this.props.appContainer.getConfig();
-    const isMathJaxEnabled = !!config.env.MATHJAX;
-
-    this.state = {
-      isCddMenuOpened: false,
-      isMathJaxEnabled,
-      isDownloadDictModalShown: false,
-      isSkipAskingAgainChecked: false,
-    };
-
-    this.availableThemes = [
-      'eclipse', 'elegant', 'neo', 'mdn-like', 'material', 'dracula', 'monokai', 'twilight',
-    ];
-    this.keymapModes = {
-      default: 'Default',
-      vim: 'Vim',
-      emacs: 'Emacs',
-      sublime: 'Sublime Text',
-    };
-    this.typicalIndentSizes = [2, 4];
-
-    this.onChangeTheme = this.onChangeTheme.bind(this);
-    this.onChangeKeymapMode = this.onChangeKeymapMode.bind(this);
-    this.onClickStyleActiveLine = this.onClickStyleActiveLine.bind(this);
-    this.onClickRenderMathJaxInRealtime = this.onClickRenderMathJaxInRealtime.bind(this);
-    this.onClickRenderDrawioInRealtime = this.onClickRenderDrawioInRealtime.bind(this);
-    this.onClickMarkdownTableAutoFormatting = this.onClickMarkdownTableAutoFormatting.bind(this);
-    this.switchTextlintEnabledHandler = this.switchTextlintEnabledHandler.bind(this);
-    this.confirmEnableTextlintHandler = this.confirmEnableTextlintHandler.bind(this);
-    this.toggleTextlint = this.toggleTextlint.bind(this);
-    this.updateIsTextlintEnabledToDB = this.updateIsTextlintEnabledToDB.bind(this);
-    this.onToggleConfigurationDropdown = this.onToggleConfigurationDropdown.bind(this);
-    this.onChangeIndentSize = this.onChangeIndentSize.bind(this);
-  }
-
-  onChangeTheme(newValue) {
-    const { editorContainer } = this.props;
-
-    const newOpts = Object.assign(editorContainer.state.editorOptions, { theme: newValue });
-    editorContainer.setState({ editorOptions: newOpts });
-
-    // save to localStorage
-    editorContainer.saveOptsToLocalStorage();
-  }
-
-  onChangeKeymapMode(newValue) {
-    const { editorContainer } = this.props;
-
-    const newOpts = Object.assign(editorContainer.state.editorOptions, { keymapMode: newValue });
-    editorContainer.setState({ editorOptions: newOpts });
-
-    // save to localStorage
-    editorContainer.saveOptsToLocalStorage();
-  }
-
-  onClickStyleActiveLine(event) {
-    const { editorContainer } = this.props;
-
-    // keep dropdown opened
-    this._cddForceOpen = true;
-
-    const newValue = !editorContainer.state.editorOptions.styleActiveLine;
-    const newOpts = Object.assign(editorContainer.state.editorOptions, { styleActiveLine: newValue });
-    editorContainer.setState({ editorOptions: newOpts });
-
-    // save to localStorage
-    editorContainer.saveOptsToLocalStorage();
-  }
-
-  onClickRenderMathJaxInRealtime(event) {
-    const { editorContainer } = this.props;
-
-    const newValue = !editorContainer.state.previewOptions.renderMathJaxInRealtime;
-    const newOpts = Object.assign(editorContainer.state.previewOptions, { renderMathJaxInRealtime: newValue });
-    editorContainer.setState({ previewOptions: newOpts });
-
-    // save to localStorage
-    editorContainer.saveOptsToLocalStorage();
-  }
-
-  onClickRenderDrawioInRealtime(event) {
-    const { editorContainer } = this.props;
-
-    const newValue = !editorContainer.state.previewOptions.renderDrawioInRealtime;
-    const newOpts = Object.assign(editorContainer.state.previewOptions, { renderDrawioInRealtime: newValue });
-    editorContainer.setState({ previewOptions: newOpts });
-
-    // save to localStorage
-    editorContainer.saveOptsToLocalStorage();
-  }
-
-  onClickMarkdownTableAutoFormatting(event) {
-    const { editorContainer } = this.props;
-
-    const newValue = !editorContainer.state.editorOptions.ignoreMarkdownTableAutoFormatting;
-    const newOpts = Object.assign(editorContainer.state.editorOptions, { ignoreMarkdownTableAutoFormatting: newValue });
-    editorContainer.setState({ editorOptions: newOpts });
-
-    // save to localStorage
-    editorContainer.saveOptsToLocalStorage();
-  }
-
-  async updateIsTextlintEnabledToDB(newVal) {
-    const { appContainer } = this.props;
-    try {
-      await appContainer.apiv3Put('/personal-setting/editor-settings', { textlintSettings: { isTextlintEnabled: newVal } });
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  toggleTextlint() {
-    const { editorContainer } = this.props;
-    const newVal = !editorContainer.state.isTextlintEnabled;
-    editorContainer.setState({ isTextlintEnabled: newVal });
-    this.updateIsTextlintEnabledToDB(newVal);
-  }
-
-  switchTextlintEnabledHandler() {
-    const { editorContainer } = this.props;
-    if (editorContainer.state.isTextlintEnabled === null) {
-      this.setState({ isDownloadDictModalShown: true });
-      return;
-    }
-    this.toggleTextlint();
-  }
-
-  confirmEnableTextlintHandler(isSkipAskingAgainChecked) {
-    this.setState(
-      { isSkipAskingAgainChecked, isDownloadDictModalShown: false },
-      () => this.toggleTextlint(),
-    );
-  }
-
-  onToggleConfigurationDropdown(newValue) {
-    this.setState({ isCddMenuOpened: !this.state.isCddMenuOpened });
-  }
-
-  onChangeIndentSize(newValue) {
-    const { editorContainer } = this.props;
-    editorContainer.setState({ indentSize: newValue });
-  }
-
-  renderThemeSelector() {
-    const { editorContainer } = this.props;
-
-    const selectedTheme = editorContainer.state.editorOptions.theme;
-    const menuItems = this.availableThemes.map((theme) => {
-      return <button key={theme} className="dropdown-item" type="button" onClick={() => this.onChangeTheme(theme)}>{theme}</button>;
-    });
-
-    return (
-      <div className="input-group flex-nowrap">
-        <div className="input-group-prepend">
-          <span className="input-group-text" id="igt-theme">Theme</span>
-        </div>
-        <div className="input-group-append dropup">
-          <button
-            type="button"
-            className="btn btn-outline-secondary dropdown-toggle"
-            data-toggle="dropdown"
-            aria-haspopup="true"
-            aria-expanded="false"
-            aria-describedby="igt-theme"
-          >
-            {selectedTheme}
-          </button>
-          <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
-            {menuItems}
-          </div>
-        </div>
-      </div>
-    );
-  }
-
-  renderKeymapModeSelector() {
-    const { editorContainer } = this.props;
-
-    const selectedKeymapMode = editorContainer.state.editorOptions.keymapMode;
-    const menuItems = Object.keys(this.keymapModes).map((mode) => {
-      const label = this.keymapModes[mode];
-      const icon = (mode !== 'default')
-        ? <img src={`/images/icons/${mode}.png`} width="16px" className="mr-2"></img>
-        : null;
-      return <button key={mode} className="dropdown-item" type="button" onClick={() => this.onChangeKeymapMode(mode)}>{icon}{label}</button>;
-    });
-
-    return (
-      <div className="input-group flex-nowrap">
-        <div className="input-group-prepend">
-          <span className="input-group-text" id="igt-keymap">Keymap</span>
-        </div>
-        <div className="input-group-append dropup">
-          <button
-            type="button"
-            className="btn btn-outline-secondary dropdown-toggle"
-            data-toggle="dropdown"
-            aria-haspopup="true"
-            aria-expanded="false"
-            aria-describedby="igt-keymap"
-          >
-            {selectedKeymapMode}
-          </button>
-          <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
-            {menuItems}
-          </div>
-        </div>
-      </div>
-    );
-  }
-
-  renderConfigurationDropdown() {
-    return (
-      <div className="my-0 form-group">
-
-        <Dropdown
-          direction="up"
-          className="grw-editor-configuration-dropdown"
-          isOpen={this.state.isCddMenuOpened}
-          toggle={this.onToggleConfigurationDropdown}
-        >
-
-          <DropdownToggle color="outline-secondary" caret>
-            <i className="icon-settings"></i>
-          </DropdownToggle>
-
-          <DropdownMenu>
-            {this.renderActiveLineMenuItem()}
-            {this.renderRealtimeMathJaxMenuItem()}
-            {this.renderRealtimeDrawioMenuItem()}
-            {this.renderMarkdownTableAutoFormattingMenuItem()}
-            {this.renderIsTextlintEnabledMenuItem()}
-            {/* <DropdownItem divider /> */}
-          </DropdownMenu>
-
-        </Dropdown>
-
-      </div>
-    );
-  }
-
-  renderActiveLineMenuItem() {
-    const { t, editorContainer } = this.props;
-    const isActive = editorContainer.state.editorOptions.styleActiveLine;
-
-    const iconClasses = ['text-info'];
-    if (isActive) {
-      iconClasses.push('ti-check');
-    }
-    const iconClassName = iconClasses.join(' ');
-
-    return (
-      <DropdownItem toggle={false} onClick={this.onClickStyleActiveLine}>
-        <div className="d-flex justify-content-between">
-          <span className="icon-container"></span>
-          <span className="menuitem-label">{ t('page_edit.Show active line') }</span>
-          <span className="icon-container"><i className={iconClassName}></i></span>
-        </div>
-      </DropdownItem>
-    );
-  }
-
-  renderRealtimeMathJaxMenuItem() {
-    if (!this.state.isMathJaxEnabled) {
-      return;
-    }
-
-    const { editorContainer } = this.props;
-
-    const isEnabled = this.state.isMathJaxEnabled;
-    const isActive = isEnabled && editorContainer.state.previewOptions.renderMathJaxInRealtime;
-
-    const iconClasses = ['text-info'];
-    if (isActive) {
-      iconClasses.push('ti-check');
-    }
-    const iconClassName = iconClasses.join(' ');
-
-    return (
-      <DropdownItem toggle={false} onClick={this.onClickRenderMathJaxInRealtime}>
-        <div className="d-flex justify-content-between">
-          <span className="icon-container"><img src="/images/icons/fx.svg" width="14px" alt="fx"></img></span>
-          <span className="menuitem-label">MathJax Rendering</span>
-          <span className="icon-container"><i className={iconClassName}></i></span>
-        </div>
-      </DropdownItem>
-    );
-  }
-
-  renderRealtimeDrawioMenuItem() {
-    const { editorContainer } = this.props;
-
-    const isActive = editorContainer.state.previewOptions.renderDrawioInRealtime;
-
-    const iconClasses = ['text-info'];
-    if (isActive) {
-      iconClasses.push('ti-check');
-    }
-    const iconClassName = iconClasses.join(' ');
-
-    return (
-      <DropdownItem toggle={false} onClick={this.onClickRenderDrawioInRealtime}>
-        <div className="d-flex justify-content-between">
-          <span className="icon-container"><img src="/images/icons/fx.svg" width="14px" alt="fx"></img></span>
-          <span className="menuitem-label">draw.io Rendering</span>
-          <span className="icon-container"><i className={iconClassName}></i></span>
-        </div>
-      </DropdownItem>
-    );
-  }
-
-  renderMarkdownTableAutoFormattingMenuItem() {
-    const { t, editorContainer } = this.props;
-    // Auto-formatting was enabled before optionalizing, so we made it a disabled option(ignoreMarkdownTableAutoFormatting).
-    const isActive = !editorContainer.state.editorOptions.ignoreMarkdownTableAutoFormatting;
-
-    const iconClasses = ['text-info'];
-    if (isActive) {
-      iconClasses.push('ti-check');
-    }
-    const iconClassName = iconClasses.join(' ');
-
-    return (
-      <DropdownItem toggle={false} onClick={this.onClickMarkdownTableAutoFormatting}>
-        <div className="d-flex justify-content-between">
-          <span className="icon-container"></span>
-          <span className="menuitem-label">{ t('page_edit.auto_format_table') }</span>
-          <span className="icon-container"><i className={iconClassName}></i></span>
-        </div>
-      </DropdownItem>
-    );
-  }
-
-  renderIsTextlintEnabledMenuItem() {
-    const isActive = this.props.editorContainer.state.isTextlintEnabled;
-
-    const iconClasses = ['text-info'];
-    if (isActive) {
-      iconClasses.push('ti-check');
-    }
-    const iconClassName = iconClasses.join(' ');
-
-    return (
-      <DropdownItem toggle={false} onClick={this.switchTextlintEnabledHandler}>
-        <div className="d-flex justify-content-between">
-          <span className="icon-container"></span>
-          <span className="menuitem-label">Textlint</span>
-          <span className="icon-container"><i className={iconClassName}></i></span>
-        </div>
-      </DropdownItem>
-    );
-  }
-
-  renderIndentSizeSelector() {
-    const { appContainer, editorContainer } = this.props;
-    const menuItems = this.typicalIndentSizes.map((indent) => {
-      return <button key={indent} className="dropdown-item" type="button" onClick={() => this.onChangeIndentSize(indent)}>{indent}</button>;
-    });
-    return (
-      <div className="input-group flex-nowrap">
-        <div className="input-group-prepend">
-          <span className="input-group-text" id="igt-indent">Indent</span>
-        </div>
-        <div className="input-group-append dropup">
-          <button
-            type="button"
-            className="btn btn-outline-secondary dropdown-toggle"
-            data-toggle="dropdown"
-            aria-haspopup="true"
-            aria-expanded="false"
-            aria-describedby="igt-indent"
-            disabled={appContainer.config.isIndentSizeForced}
-          >
-            {editorContainer.state.indentSize}
-          </button>
-          <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
-            {menuItems}
-          </div>
-        </div>
-      </div>
-    );
-  }
-
-  render() {
-    return (
-      <>
-        <div className="d-flex flex-row">
-          <span>{this.renderThemeSelector()}</span>
-          <span className="d-none d-sm-block ml-2 ml-sm-4">{this.renderKeymapModeSelector()}</span>
-          <span className="ml-2 ml-sm-4">{this.renderIndentSizeSelector()}</span>
-          <span className="ml-2 ml-sm-4">{this.renderConfigurationDropdown()}</span>
-        </div>
-
-        {!this.state.isSkipAskingAgainChecked && (
-          <DownloadDictModal
-            isModalOpen={this.state.isDownloadDictModalShown}
-            onConfirmEnableTextlint={this.confirmEnableTextlintHandler}
-            onCancel={() => this.setState({ isDownloadDictModalShown: false })}
-          />
-        )}
-      </>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const OptionsSelectorWrapper = withUnstatedContainers(OptionsSelector, [AppContainer, EditorContainer]);
-
-OptionsSelector.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-};
-
-export default withTranslation()(OptionsSelectorWrapper);

+ 410 - 0
packages/app/src/components/PageEditor/OptionsSelector.tsx

@@ -0,0 +1,410 @@
+import React, {
+  memo, useCallback, useMemo, useState,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
+} from 'reactstrap';
+
+import AppContainer from '~/client/services/AppContainer';
+import { useIsIndentSizeForced } from '~/stores/context';
+import { useEditorSettings, useIsTextlintEnabled, useCurrentIndentSize } from '~/stores/editor';
+
+import { DEFAULT_THEME, KeyMapMode } from '../../interfaces/editor-settings';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import { DownloadDictModal } from './DownloadDictModal';
+
+
+const AVAILABLE_THEMES = [
+  'eclipse', 'elegant', 'neo', 'mdn-like', 'material', 'dracula', 'monokai', 'twilight',
+];
+
+const TYPICAL_INDENT_SIZE = [2, 4];
+
+
+const ThemeSelector = (): JSX.Element => {
+
+  const { data: editorSettings, update } = useEditorSettings();
+
+  const menuItems = useMemo(() => (
+    <>
+      { AVAILABLE_THEMES.map((theme) => {
+        return <button key={theme} className="dropdown-item" type="button" onClick={() => update({ theme })}>{theme}</button>;
+      }) }
+    </>
+  ), [update]);
+
+  const selectedTheme = editorSettings?.theme ?? DEFAULT_THEME;
+
+  return (
+    <div className="input-group flex-nowrap">
+      <div className="input-group-prepend">
+        <span className="input-group-text" id="igt-theme">Theme</span>
+      </div>
+      <div className="input-group-append dropup">
+        <button
+          type="button"
+          className="btn btn-outline-secondary dropdown-toggle"
+          data-toggle="dropdown"
+          aria-haspopup="true"
+          aria-expanded="false"
+          aria-describedby="igt-theme"
+        >
+          {selectedTheme}
+        </button>
+        <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
+          {menuItems}
+        </div>
+      </div>
+    </div>
+  );
+};
+
+
+type KeyMapModeToLabel = {
+  [key in KeyMapMode]: string;
+}
+
+const KEYMAP_LABEL_MAP: KeyMapModeToLabel = {
+  default: 'Default',
+  vim: 'Vim',
+  emacs: 'Emacs',
+  sublime: 'Sublime Text',
+};
+
+const KeymapSelector = memo((): JSX.Element => {
+
+  const { data: editorSettings, update } = useEditorSettings();
+
+  Object.keys(KEYMAP_LABEL_MAP);
+  const menuItems = useMemo(() => (
+    <>
+      { (Object.keys(KEYMAP_LABEL_MAP) as KeyMapMode[]).map((keymapMode) => {
+        const keymapLabel = KEYMAP_LABEL_MAP[keymapMode];
+        const icon = (keymapMode !== 'default')
+          ? <img src={`/images/icons/${keymapMode}.png`} width="16px" className="mr-2"></img>
+          : null;
+        return <button key={keymapMode} className="dropdown-item" type="button" onClick={() => update({ keymapMode })}>{icon}{keymapLabel}</button>;
+      }) }
+    </>
+  ), [update]);
+
+  const selectedKeymapMode = editorSettings?.keymapMode ?? 'default';
+
+  return (
+    <div className="input-group flex-nowrap">
+      <div className="input-group-prepend">
+        <span className="input-group-text" id="igt-keymap">Keymap</span>
+      </div>
+      <div className="input-group-append dropup">
+        <button
+          type="button"
+          className="btn btn-outline-secondary dropdown-toggle"
+          data-toggle="dropdown"
+          aria-haspopup="true"
+          aria-expanded="false"
+          aria-describedby="igt-keymap"
+        >
+          { editorSettings != null && selectedKeymapMode}
+        </button>
+        <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
+          {menuItems}
+        </div>
+      </div>
+    </div>
+  );
+
+});
+
+
+type IndentSizeSelectorProps = {
+  isIndentSizeForced: boolean,
+  selectedIndentSize: number,
+  onChange: (indentSize: number) => void,
+}
+
+const IndentSizeSelector = memo(({ isIndentSizeForced, selectedIndentSize, onChange }: IndentSizeSelectorProps): JSX.Element => {
+  const menuItems = useMemo(() => (
+    <>
+      { TYPICAL_INDENT_SIZE.map((indent) => {
+        return <button key={indent} className="dropdown-item" type="button" onClick={() => onChange(indent)}>{indent}</button>;
+      }) }
+    </>
+  ), [onChange]);
+
+  return (
+    <div className="input-group flex-nowrap">
+      <div className="input-group-prepend">
+        <span className="input-group-text" id="igt-indent">Indent</span>
+      </div>
+      <div className="input-group-append dropup">
+        <button
+          type="button"
+          className="btn btn-outline-secondary dropdown-toggle"
+          data-toggle="dropdown"
+          aria-haspopup="true"
+          aria-expanded="false"
+          aria-describedby="igt-indent"
+          disabled={isIndentSizeForced}
+        >
+          {selectedIndentSize}
+        </button>
+        <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
+          {menuItems}
+        </div>
+      </div>
+    </div>
+  );
+
+});
+
+
+type ConfigurationDropdownProps = {
+  isMathJaxEnabled: boolean,
+  onConfirmEnableTextlint?: () => void,
+}
+
+const ConfigurationDropdown = memo(({ isMathJaxEnabled, onConfirmEnableTextlint }: ConfigurationDropdownProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const [isCddMenuOpened, setCddMenuOpened] = useState(false);
+
+  const { data: editorSettings, update } = useEditorSettings();
+
+  const { data: isTextlintEnabled, mutate: mutateTextlintEnabled } = useIsTextlintEnabled();
+
+  const renderActiveLineMenuItem = useCallback(() => {
+    if (editorSettings == null) {
+      return <></>;
+    }
+
+    const isActive = editorSettings.styleActiveLine;
+
+    const iconClasses = ['text-info'];
+    if (isActive) {
+      iconClasses.push('ti-check');
+    }
+    const iconClassName = iconClasses.join(' ');
+
+    return (
+      <DropdownItem toggle={false} onClick={() => update({ styleActiveLine: !isActive })}>
+        <div className="d-flex justify-content-between">
+          <span className="icon-container"></span>
+          <span className="menuitem-label">{ t('page_edit.Show active line') }</span>
+          <span className="icon-container"><i className={iconClassName}></i></span>
+        </div>
+      </DropdownItem>
+    );
+  }, [editorSettings, update, t]);
+
+  const renderRealtimeMathJaxMenuItem = useCallback(() => {
+    if (editorSettings == null) {
+      return <></>;
+    }
+
+    if (!isMathJaxEnabled) {
+      return <></>;
+    }
+
+    const isActive = editorSettings.renderMathJaxInRealtime;
+
+    const iconClasses = ['text-info'];
+    if (isActive) {
+      iconClasses.push('ti-check');
+    }
+    const iconClassName = iconClasses.join(' ');
+
+    return (
+      <DropdownItem toggle={false} onClick={() => update({ renderMathJaxInRealtime: !isActive })}>
+        <div className="d-flex justify-content-between">
+          <span className="icon-container"><img src="/images/icons/fx.svg" width="14px" alt="fx"></img></span>
+          <span className="menuitem-label">MathJax Rendering</span>
+          <span className="icon-container"><i className={iconClassName}></i></span>
+        </div>
+      </DropdownItem>
+    );
+  }, [editorSettings, isMathJaxEnabled, update]);
+
+  const renderRealtimeDrawioMenuItem = useCallback(() => {
+    if (editorSettings == null) {
+      return <></>;
+    }
+
+    const isActive = editorSettings.renderDrawioInRealtime;
+
+    const iconClasses = ['text-info'];
+    if (isActive) {
+      iconClasses.push('ti-check');
+    }
+    const iconClassName = iconClasses.join(' ');
+
+    return (
+      <DropdownItem toggle={false} onClick={() => update({ renderDrawioInRealtime: !isActive })}>
+        <div className="d-flex justify-content-between">
+          <span className="icon-container"><img src="/images/icons/fx.svg" width="14px" alt="fx"></img></span>
+          <span className="menuitem-label">draw.io Rendering</span>
+          <span className="icon-container"><i className={iconClassName}></i></span>
+        </div>
+      </DropdownItem>
+    );
+  }, [editorSettings, update]);
+
+  const renderMarkdownTableAutoFormattingMenuItem = useCallback(() => {
+    if (editorSettings == null) {
+      return <></>;
+    }
+
+    const isActive = editorSettings.autoFormatMarkdownTable;
+
+    const iconClasses = ['text-info'];
+    if (isActive) {
+      iconClasses.push('ti-check');
+    }
+    const iconClassName = iconClasses.join(' ');
+
+    return (
+      <DropdownItem toggle={false} onClick={() => update({ autoFormatMarkdownTable: !isActive })}>
+        <div className="d-flex justify-content-between">
+          <span className="icon-container"></span>
+          <span className="menuitem-label">{ t('page_edit.auto_format_table') }</span>
+          <span className="icon-container"><i className={iconClassName}></i></span>
+        </div>
+      </DropdownItem>
+    );
+  }, [editorSettings, t, update]);
+
+  const renderIsTextlintEnabledMenuItem = useCallback(() => {
+    if (editorSettings == null) {
+      return <></>;
+    }
+
+    const clickHandler = () => {
+      if (isTextlintEnabled) {
+        mutateTextlintEnabled(false);
+        return;
+      }
+
+      if (editorSettings.textlintSettings?.neverAskBeforeDownloadLargeFiles) {
+        mutateTextlintEnabled(true);
+        return;
+      }
+
+      if (onConfirmEnableTextlint != null) {
+        onConfirmEnableTextlint();
+      }
+    };
+
+    const iconClasses = ['text-info'];
+    if (isTextlintEnabled) {
+      iconClasses.push('ti-check');
+    }
+    const iconClassName = iconClasses.join(' ');
+
+    return (
+      <DropdownItem toggle={false} onClick={clickHandler}>
+        <div className="d-flex justify-content-between">
+          <span className="icon-container"></span>
+          <span className="menuitem-label">Textlint</span>
+          <span className="icon-container"><i className={iconClassName}></i></span>
+        </div>
+      </DropdownItem>
+    );
+  }, [editorSettings, isTextlintEnabled, mutateTextlintEnabled, onConfirmEnableTextlint]);
+
+  return (
+    <div className="my-0 form-group">
+      <Dropdown
+        direction="up"
+        className="grw-editor-configuration-dropdown"
+        isOpen={isCddMenuOpened}
+        toggle={() => setCddMenuOpened(!isCddMenuOpened)}
+      >
+
+        <DropdownToggle color="outline-secondary" caret>
+          <i className="icon-settings"></i>
+        </DropdownToggle>
+
+        <DropdownMenu>
+          {renderActiveLineMenuItem()}
+          {renderRealtimeMathJaxMenuItem()}
+          {renderRealtimeDrawioMenuItem()}
+          {renderMarkdownTableAutoFormattingMenuItem()}
+          {renderIsTextlintEnabledMenuItem()}
+          {/* <DropdownItem divider /> */}
+        </DropdownMenu>
+
+      </Dropdown>
+    </div>
+  );
+
+});
+
+
+type Props = {
+  appContainer: AppContainer
+};
+
+const OptionsSelector = (props: Props): JSX.Element => {
+  const { appContainer } = props;
+  const config = appContainer.config;
+
+  const [isDownloadDictModalShown, setDownloadDictModalShown] = useState(false);
+
+  const { data: editorSettings, turnOffAskingBeforeDownloadLargeFiles } = useEditorSettings();
+  const { mutate: mutateTextlintEnabled } = useIsTextlintEnabled();
+  const { data: isIndentSizeForced } = useIsIndentSizeForced();
+  const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
+
+  if (editorSettings == null || isIndentSizeForced == null || currentIndentSize == null) {
+    return <></>;
+  }
+
+  return (
+    <>
+      <div className="d-flex flex-row">
+        <span>
+          <ThemeSelector />
+        </span>
+        <span className="d-none d-sm-block ml-2 ml-sm-4">
+          <KeymapSelector />
+        </span>
+        <span className="ml-2 ml-sm-4">
+          <IndentSizeSelector
+            isIndentSizeForced={isIndentSizeForced}
+            selectedIndentSize={currentIndentSize}
+            onChange={newValue => mutateCurrentIndentSize(newValue)}
+          />
+        </span>
+        <span className="ml-2 ml-sm-4">
+          <ConfigurationDropdown
+            isMathJaxEnabled={!!config.env.MATHJAX}
+            onConfirmEnableTextlint={() => setDownloadDictModalShown(true)}
+          />
+        </span>
+      </div>
+
+      { editorSettings != null && !editorSettings.textlintSettings?.neverAskBeforeDownloadLargeFiles && (
+        <DownloadDictModal
+          isModalOpen={isDownloadDictModalShown}
+          onEnableTextlint={(isSkipAskingAgainChecked) => {
+            mutateTextlintEnabled(true);
+
+            if (isSkipAskingAgainChecked) {
+              turnOffAskingBeforeDownloadLargeFiles();
+            }
+
+            setDownloadDictModalShown(false);
+          }}
+          onCancel={() => setDownloadDictModalShown(false)}
+        />
+      )}
+    </>
+  );
+
+};
+
+
+const OptionsSelectorWrapper = withUnstatedContainers(OptionsSelector, [AppContainer]);
+export default OptionsSelectorWrapper;

+ 19 - 22
packages/app/src/components/PageEditor/Preview.tsx

@@ -2,18 +2,16 @@ import React, {
   UIEventHandler, useCallback, useEffect, useMemo, useState,
 } from 'react';
 
-import { Subscribe } from 'unstated';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-import RevisionBody from '../Page/RevisionBody';
 
 import AppContainer from '~/client/services/AppContainer';
-import EditorContainer from '~/client/services/EditorContainer';
+import { useEditorSettings } from '~/stores/editor';
+
+import RevisionBody from '../Page/RevisionBody';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 type Props = {
   appContainer: AppContainer,
-  editorContainer: EditorContainer,
 
   markdown?: string,
   pagePath?: string,
@@ -35,6 +33,8 @@ const Preview = (props: Props): JSX.Element => {
 
   const [html, setHtml] = useState('');
 
+  const { data: editorSettings } = useEditorSettings();
+
   const { interceptorManager } = appContainer;
   const growiRenderer = props.appContainer.getRenderer('editor');
 
@@ -42,10 +42,11 @@ const Preview = (props: Props): JSX.Element => {
     return {
       markdown,
       pagePath,
+      editorSettings,
       currentPathname: decodeURIComponent(window.location.pathname),
       parsedHTML: null,
     };
-  }, [markdown, pagePath]);
+  }, [markdown, pagePath, editorSettings]);
 
   const renderPreview = useCallback(async() => {
     if (interceptorManager != null) {
@@ -85,21 +86,17 @@ const Preview = (props: Props): JSX.Element => {
   }, [context, html, interceptorManager]);
 
   return (
-    <Subscribe to={[EditorContainer]}>
-      { editorContainer => (
-        <div
-          className="page-editor-preview-body"
-          ref={inputRef}
-          onScroll={onScroll}
-        >
-          <RevisionBody
-            {...props}
-            html={html}
-            renderMathJaxInRealtime={editorContainer.state.previewOptions.renderMathJaxInRealtime}
-          />
-        </div>
-      ) }
-    </Subscribe>
+    <div
+      className="page-editor-preview-body"
+      ref={inputRef}
+      onScroll={onScroll}
+    >
+      <RevisionBody
+        {...props}
+        html={html}
+        renderMathJaxInRealtime={editorSettings?.renderMathJaxInRealtime}
+      />
+    </div>
   );
 
 };

+ 12 - 13
packages/app/src/components/PageEditorByHackmd.jsx

@@ -1,24 +1,23 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-import loggerFactory from '~/utils/logger';
 
 
 import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
 import EditorContainer from '~/client/services/EditorContainer';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
-
+import PageContainer from '~/client/services/PageContainer';
+import { apiPost } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
-
-// TODO: remove this when omitting unstated is completed
+import { useSlackChannels } from '~/stores/context';
+import { useIsSlackEnabled } from '~/stores/editor';
 import {
   useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
-import { useSlackChannels } from '~/stores/context';
-import { useIsSlackEnabled } from '~/stores/editor';
+import loggerFactory from '~/utils/logger';
+
+import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
+import { withUnstatedContainers } from './UnstatedUtils';
 
 const logger = loggerFactory('growi:PageEditorByHackmd');
 
@@ -105,7 +104,7 @@ class PageEditorByHackmd extends React.Component {
     };
 
     try {
-      const res = await this.props.appContainer.apiPost('/hackmd.integrate', params);
+      const res = await apiPost('/hackmd.integrate', params);
 
       if (!res.ok) {
         throw new Error(res.error);
@@ -147,7 +146,7 @@ class PageEditorByHackmd extends React.Component {
     const { pageId } = pageContainer.state;
 
     try {
-      const res = await this.props.appContainer.apiPost('/hackmd.discard', { pageId });
+      const res = await apiPost('/hackmd.discard', { pageId });
 
       if (!res.ok) {
         throw new Error(res.error);
@@ -220,7 +219,7 @@ class PageEditorByHackmd extends React.Component {
       pageId: pageContainer.state.pageId,
     };
     try {
-      await this.props.appContainer.apiPost('/hackmd.saveOnHackmd', params);
+      await apiPost('/hackmd.saveOnHackmd', params);
     }
     catch (err) {
       logger.error(err);

+ 12 - 17
packages/app/src/components/PageList/BookmarkList.jsx

@@ -1,21 +1,24 @@
 import React, { useState, useCallback, useEffect } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import loggerFactory from '~/utils/logger';
-import { withUnstatedContainers } from '../UnstatedUtils';
 
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
 
-import AppContainer from '~/client/services/AppContainer';
 import { toastError } from '~/client/util/apiNotification';
+import { apiv3Get } from '~/client/util/apiv3-client';
+import loggerFactory from '~/utils/logger';
 
 import PaginationWrapper from '../PaginationWrapper';
 
+
 import PageListItemS from './PageListItemS';
 
+
 const logger = loggerFactory('growi:BookmarkList');
 
 const BookmarkList = (props) => {
-  const { t, appContainer, userId } = props;
+  const { t } = useTranslation();
+
+  const { userId } = props;
 
   const [pages, setPages] = useState([]);
 
@@ -31,7 +34,7 @@ const BookmarkList = (props) => {
     const page = activePage;
 
     try {
-      const res = await appContainer.apiv3Get(`/bookmarks/${userId}`, { page });
+      const res = await apiv3Get(`/bookmarks/${userId}`, { page });
       const { paginationResult } = res.data;
 
       setPages(paginationResult.docs);
@@ -42,7 +45,7 @@ const BookmarkList = (props) => {
       logger.error('failed to fetch data', error);
       toastError(error, 'Error occurred in bookmark page list');
     }
-  }, [appContainer, activePage, userId]);
+  }, [activePage, userId]);
 
   useEffect(() => {
     getMyBookmarkList();
@@ -82,16 +85,8 @@ const BookmarkList = (props) => {
 
 };
 
-/**
- * Wrapper component for using unstated
- */
-const BookmarkListWrapper = withUnstatedContainers(BookmarkList, [AppContainer]);
-
 BookmarkList.propTypes = {
-  t: PropTypes.func.isRequired,
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
   userId: PropTypes.string.isRequired,
 };
 
-export default withTranslation()(BookmarkListWrapper);
+export default BookmarkList;

+ 5 - 4
packages/app/src/components/PageTimeline.jsx

@@ -1,14 +1,15 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
+import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
-import PaginationWrapper from './PaginationWrapper';
-import { withUnstatedContainers } from './UnstatedUtils';
+import { apiv3Get } from '~/client/util/apiv3-client';
 
 import RevisionLoader from './Page/RevisionLoader';
+import PaginationWrapper from './PaginationWrapper';
+import { withUnstatedContainers } from './UnstatedUtils';
 
 
 class PageTimeline extends React.Component {
@@ -34,7 +35,7 @@ class PageTimeline extends React.Component {
     const { path } = pageContainer.state;
     const page = selectedPage;
 
-    const res = await appContainer.apiv3Get('/pages/list', { path, page });
+    const res = await apiv3Get('/pages/list', { path, page });
     const totalPageItems = res.data.totalCount;
     const pages = res.data.pages;
     const pagingLimit = res.data.limit;

+ 7 - 3
packages/app/src/components/PasswordResetExecutionForm.jsx

@@ -1,10 +1,14 @@
 import React, { useState } from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-import loggerFactory from '~/utils/logger';
-import { withUnstatedContainers } from './UnstatedUtils';
+
 import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Put } from '~/client/util/apiv3-client';
+import loggerFactory from '~/utils/logger';
+
+import { withUnstatedContainers } from './UnstatedUtils';
 
 const logger = loggerFactory('growi:passwordReset');
 
@@ -34,7 +38,7 @@ const PasswordResetExecutionForm = (props) => {
     }
 
     try {
-      await appContainer.apiv3Put('/forgot-password', {
+      await apiv3Put('/forgot-password', {
         token, newPassword, newPasswordConfirm,
       });
 

+ 5 - 2
packages/app/src/components/PasswordResetRequestForm.jsx

@@ -1,9 +1,12 @@
 import React, { useState } from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
 
 import AppContainer from '~/client/services/AppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Post } from '~/client/util/apiv3-client';
+
 import { withUnstatedContainers } from './UnstatedUtils';
 
 
@@ -23,7 +26,7 @@ const PasswordResetRequestForm = (props) => {
     }
 
     try {
-      await appContainer.apiv3Post('/forgot-password', { email });
+      await apiv3Post('/forgot-password', { email });
       toastSuccess(t('forgot_password.success_to_send_email'));
     }
     catch (err) {

+ 44 - 8
packages/app/src/components/PrivateLegacyPages.tsx

@@ -15,6 +15,7 @@ import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import { V5MigrationStatus } from '~/interfaces/page-listing-results';
 import { IFormattedSearchResult } from '~/interfaces/search';
 import { PageMigrationErrorData, SocketEventName } from '~/interfaces/websocket';
+import { useCurrentUser } from '~/stores/context';
 import {
   ILegacyPrivatePage, usePrivateLegacyPagesMigrationModal,
 } from '~/stores/modal';
@@ -139,6 +140,11 @@ const ConvertByPathModal = React.memo((props: ConvertByPathModalProps): JSX.Elem
   const { t } = useTranslation();
 
   const [currentInput, setInput] = useState<string>('');
+  const [checked, setChecked] = useState<boolean>(false);
+
+  useEffect(() => {
+    setChecked(false);
+  }, [props.isOpen]);
 
   return (
     <Modal size="lg" isOpen={props.isOpen} toggle={props.close} className="grw-create-page">
@@ -148,9 +154,26 @@ const ConvertByPathModal = React.memo((props: ConvertByPathModalProps): JSX.Elem
       <ModalBody>
         <p>{t('private_legacy_pages.by_path_modal.description')}</p>
         <input type="text" className="form-control" placeholder="/" value={currentInput} onChange={e => setInput(e.target.value)} />
+        <div className="alert alert-danger mt-3" role="alert">
+          { t('private_legacy_pages.by_path_modal.alert') }
+        </div>
       </ModalBody>
       <ModalFooter>
-        <button type="button" className="btn btn-primary" onClick={() => props.onSubmit?.(currentInput)}>
+        <div className="form-check">
+          <input
+            className="form-check-input"
+            type="checkbox"
+            id="understoodCheckbox"
+            onChange={e => setChecked(e.target.checked)}
+          />
+          <label className="form-check-label" htmlFor="understoodCheckbox">{ t('private_legacy_pages.by_path_modal.checkbox_label') }</label>
+        </div>
+        <button
+          type="button"
+          className="btn btn-primary"
+          disabled={!checked}
+          onClick={() => props.onSubmit?.(currentInput)}
+        >
           <i className="icon-fw icon-refresh" aria-hidden="true"></i>
           { t('private_legacy_pages.by_path_modal.button_label') }
         </button>
@@ -159,7 +182,6 @@ const ConvertByPathModal = React.memo((props: ConvertByPathModalProps): JSX.Elem
   );
 });
 
-
 /**
  * LegacyPage
  */
@@ -170,6 +192,9 @@ type Props = {
 
 const PrivateLegacyPages = (props: Props): JSX.Element => {
   const { t } = useTranslation();
+  const { data: currentUser } = useCurrentUser();
+
+  const isAdmin = currentUser?.admin;
 
   const {
     appContainer,
@@ -310,8 +335,23 @@ const PrivateLegacyPages = (props: Props): JSX.Element => {
     mutate();
   }, [limit, mutate]);
 
+  const openConvertModalHandler = useCallback(() => {
+    if (!isAdmin) { return }
+    setOpenConvertModal(true);
+  }, [isAdmin]);
+
   const hitsCount = data?.meta.hitsCount;
 
+  const renderOpenModalButton = useCallback(() => {
+    return (
+      <div className="d-flex pl-md-2">
+        <button type="button" className="btn btn-light" onClick={() => openConvertModalHandler()}>
+          {t('private_legacy_pages.input_path_to_convert')}
+        </button>
+      </div>
+    );
+  }, [t, openConvertModalHandler]);
+
   const searchControlAllAction = useMemo(() => {
     const isCheckboxDisabled = hitsCount === 0;
 
@@ -342,11 +382,7 @@ const PrivateLegacyPages = (props: Props): JSX.Element => {
             </UncontrolledButtonDropdown>
           </OperateAllControl>
         </div>
-        <div className="d-flex pl-md-2">
-          <button type="button" className="btn btn-light" onClick={() => setOpenConvertModal(true)}>
-            {t('private_legacy_pages.input_path_to_convert')}
-          </button>
-        </div>
+        {isAdmin && renderOpenModalButton()}
       </div>
     );
   }, [convertMenuItemClickedHandler, deleteAllButtonClickedHandler, hitsCount, isControlEnabled, selectAllCheckboxChangedHandler, t]);
@@ -418,7 +454,7 @@ const PrivateLegacyPages = (props: Props): JSX.Element => {
         close={() => setOpenConvertModal(false)}
         onSubmit={async(convertPath: string) => {
           try {
-            await apiv3Post<void>('/pages/legacy-pages-migration', {
+            await apiv3Post<void>('/pages/convert-pages-by-path', {
               convertPath,
             });
             toastSuccess(t('private_legacy_pages.by_path_modal.success'));

+ 5 - 12
packages/app/src/components/RecentCreated/RecentCreated.jsx

@@ -1,8 +1,8 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
+import { apiv3Get } from '~/client/util/apiv3-client';
 
 import PageListItemS from '../PageList/PageListItemS';
 import PaginationWrapper from '../PaginationWrapper';
@@ -32,11 +32,11 @@ class RecentCreated extends React.Component {
   }
 
   async getRecentCreatedList(selectedPage) {
-    const { appContainer, userId } = this.props;
+    const { userId } = this.props;
     const page = selectedPage;
 
     // pagesList get and pagination calculate
-    const res = await appContainer.apiv3Get(`/users/${userId}/recent`, { page });
+    const res = await apiv3Get(`/users/${userId}/recent`, { page });
     const { totalCount, pages, limit } = res.data;
 
     this.setState({
@@ -84,15 +84,8 @@ class RecentCreated extends React.Component {
 
 }
 
-/**
- * Wrapper component for using unstated
- */
-const RecentCreatedWrapper = withUnstatedContainers(RecentCreated, [AppContainer]);
-
 RecentCreated.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
   userId: PropTypes.string.isRequired,
 };
 
-export default RecentCreatedWrapper;
+export default RecentCreated;

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

@@ -1,18 +1,19 @@
 import React, { useState } from 'react';
+
+import { pagePathUtils } from '@growi/core';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
+import { withTranslation } from 'react-i18next';
 import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 
-import { pagePathUtils } from '@growi/core';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
 
 import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
 
 import RevisionDiff from '../PageHistory/RevisionDiff';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
 
 const { encodeSpaces } = pagePathUtils;
 
@@ -32,16 +33,17 @@ const RevisionComparer = (props) => {
 
   const { t, revisionComparerContainer } = props;
 
+  const { path, pageId } = revisionComparerContainer.pageContainer.state;
+
   function toggleDropdown() {
     setDropdownOpen(!dropdownOpen);
   }
 
-  const pagePathUrl = () => {
+  const generateURL = (pathName) => {
     const { origin } = window.location;
-    const { path } = revisionComparerContainer.pageContainer.state;
     const { sourceRevision, targetRevision } = revisionComparerContainer.state;
 
-    const url = new URL(path, origin);
+    const url = new URL(pathName, origin);
 
     if (sourceRevision != null && targetRevision != null) {
       const urlParams = `${sourceRevision._id}...${targetRevision._id}`;
@@ -49,6 +51,7 @@ const RevisionComparer = (props) => {
     }
 
     return encodeSpaces(decodeURI(url));
+
   };
 
   const { sourceRevision, targetRevision } = revisionComparerContainer.state;
@@ -76,9 +79,15 @@ const RevisionComparer = (props) => {
           </DropdownToggle>
           <DropdownMenu positionFixed right modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
             {/* Page path URL */}
-            <CopyToClipboard text={pagePathUrl()}>
+            <CopyToClipboard text={generateURL(path)}>
+              <DropdownItem className="px-3">
+                <DropdownItemContents title={t('copy_to_clipboard.Page URL')} contents={generateURL(path)} />
+              </DropdownItem>
+            </CopyToClipboard>
+            {/* Permanent Link URL */}
+            <CopyToClipboard text={generateURL(pageId)}>
               <DropdownItem className="px-3">
-                <DropdownItemContents title={t('copy_to_clipboard.Page URL')} contents={pagePathUrl()} />
+                <DropdownItemContents title={t('copy_to_clipboard.Permanent link')} contents={generateURL(pageId)} />
               </DropdownItem>
             </CopyToClipboard>
             <DropdownItem divider className="my-0"></DropdownItem>

+ 0 - 249
packages/app/src/components/SavePageControls/GrantSelector.jsx

@@ -1,249 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-
-import {
-  UncontrolledDropdown,
-  DropdownToggle, DropdownMenu, DropdownItem,
-
-  Modal, ModalHeader, ModalBody,
-} from 'reactstrap';
-
-
-import AppContainer from '~/client/services/AppContainer';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-/**
- * Page grant select component
- *
- * @export
- * @class GrantSelector
- * @extends {React.Component}
- */
-class GrantSelector extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.availableGrants = [
-      {
-        grant: 1, iconClass: 'icon-people', btnStyleClass: 'outline-info', label: 'Public',
-      },
-      {
-        grant: 2, iconClass: 'icon-link', btnStyleClass: 'outline-teal', label: 'Anyone with the link',
-      },
-      // { grant: 3, iconClass: '', label: 'Specified users only' },
-      {
-        grant: 4, iconClass: 'icon-lock', btnStyleClass: 'outline-danger', label: 'Only me',
-      },
-      {
-        grant: 5, iconClass: 'icon-options', btnStyleClass: 'outline-purple', label: 'Only inside the group', reselectLabel: 'Reselect the group',
-      },
-    ];
-
-    this.state = {
-      userRelatedGroups: [],
-      isSelectGroupModalShown: false,
-    };
-
-    this.showSelectGroupModal = this.showSelectGroupModal.bind(this);
-    this.hideSelectGroupModal = this.hideSelectGroupModal.bind(this);
-
-    this.changeGrantHandler = this.changeGrantHandler.bind(this);
-    this.groupListItemClickHandler = this.groupListItemClickHandler.bind(this);
-  }
-
-  showSelectGroupModal() {
-    this.retrieveUserGroupRelations();
-    this.setState({ isSelectGroupModalShown: true });
-  }
-
-  hideSelectGroupModal() {
-    this.setState({ isSelectGroupModalShown: false });
-  }
-
-  /**
-   * Retrieve user-group-relations data from backend
-   */
-  retrieveUserGroupRelations() {
-    this.props.appContainer.apiGet('/me/user-group-relations')
-      .then((res) => {
-        return res.userGroupRelations;
-      })
-      .then((userGroupRelations) => {
-        const userRelatedGroups = userGroupRelations.map((relation) => {
-          return relation.relatedGroup;
-        });
-        this.setState({ userRelatedGroups });
-      });
-  }
-
-  /**
-   * change event handler for grant selector
-   */
-  changeGrantHandler(grant) {
-    // select group
-    if (grant === 5) {
-      this.showSelectGroupModal();
-      return;
-    }
-
-    if (this.props.onUpdateGrant != null) {
-      this.props.onUpdateGrant({ grant, grantGroupId: null, grantGroupName: null });
-    }
-  }
-
-  groupListItemClickHandler(grantGroup) {
-    if (this.props.onUpdateGrant != null) {
-      this.props.onUpdateGrant({ grant: 5, grantGroupId: grantGroup._id, grantGroupName: grantGroup.name });
-    }
-
-    // hide modal
-    this.hideSelectGroupModal();
-  }
-
-  /**
-   * Render grant selector DOM.
-   * @returns
-   * @memberof GrantSelector
-   */
-  renderGrantSelector() {
-    const { t } = this.props;
-    const { grant: currentGrant, grantGroupId } = this.props;
-
-    let dropdownToggleBtnColor = null;
-    let dropdownToggleLabelElm = null;
-
-    const dropdownMenuElems = this.availableGrants.map((opt) => {
-      const label = (opt.grant === 5 && grantGroupId != null)
-        ? opt.reselectLabel // when grantGroup is selected
-        : opt.label;
-
-      const labelElm = (
-        <span>
-          <i className={`icon icon-fw ${opt.iconClass}`}></i>
-          <span className="label">{t(label)}</span>
-        </span>
-      );
-
-      // set dropdownToggleBtnColor, dropdownToggleLabelElm
-      if (opt.grant === 1 || opt.grant === currentGrant) {
-        dropdownToggleBtnColor = opt.btnStyleClass;
-        dropdownToggleLabelElm = labelElm;
-      }
-
-      return <DropdownItem key={opt.grant} onClick={() => this.changeGrantHandler(opt.grant)}>{labelElm}</DropdownItem>;
-    });
-
-    // add specified group option
-    if (grantGroupId != null) {
-      const labelElm = (
-        <span>
-          <i className="icon icon-fw icon-organization"></i>
-          <span className="label">{this.props.grantGroupName}</span>
-        </span>
-      );
-
-      // set dropdownToggleLabelElm
-      dropdownToggleLabelElm = labelElm;
-
-      dropdownMenuElems.push(<DropdownItem key="groupSelected">{labelElm}</DropdownItem>);
-    }
-
-    return (
-      <div className="form-group grw-grant-selector mb-0">
-        <UncontrolledDropdown direction="up">
-          <DropdownToggle color={dropdownToggleBtnColor} caret className="d-flex justify-content-between align-items-center" disabled={this.props.disabled}>
-            {dropdownToggleLabelElm}
-          </DropdownToggle>
-          <DropdownMenu>
-            {dropdownMenuElems}
-          </DropdownMenu>
-        </UncontrolledDropdown>
-      </div>
-    );
-  }
-
-  /**
-   * Render select grantgroup modal.
-   *
-   * @returns
-   * @memberof GrantSelector
-   */
-  renderSelectGroupModal() {
-    const { t } = this.props;
-
-    const generateGroupListItems = () => {
-      return this.state.userRelatedGroups.map((group) => {
-        return (
-          <button key={group._id} type="button" className="list-group-item list-group-item-action" onClick={() => { this.groupListItemClickHandler(group) }}>
-            <h5>{group.name}</h5>
-            {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
-          </button>
-        );
-      });
-    };
-
-    const content = this.state.userRelatedGroups.length === 0
-      ? (
-        <div>
-          <h4>{t('user_group.belonging_to_no_group')}</h4>
-          { this.props.appContainer.isAdmin
-            && <p><a href="/admin/user-groups"><i className="icon icon-fw icon-login"></i>{t('user_group.manage_user_groups')}</a></p>
-          }
-        </div>
-      )
-      : (
-        <div className="list-group">
-          {generateGroupListItems()}
-        </div>
-      );
-
-    return (
-      <Modal
-        className="select-grant-group"
-        isOpen={this.state.isSelectGroupModalShown}
-        toggle={this.hideSelectGroupModal}
-      >
-        <ModalHeader tag="h4" toggle={this.hideSelectGroupModal} className="bg-purple text-light">
-          {t('user_group.select_group')}
-        </ModalHeader>
-        <ModalBody>
-          {content}
-        </ModalBody>
-      </Modal>
-    );
-  }
-
-  render() {
-    return (
-      <React.Fragment>
-        { this.renderGrantSelector() }
-        { !this.props.disabled && this.renderSelectGroupModal() }
-      </React.Fragment>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const GrantSelectorWrapper = withUnstatedContainers(GrantSelector, [AppContainer]);
-
-GrantSelector.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  disabled: PropTypes.bool,
-  grant: PropTypes.number.isRequired,
-  grantGroupId: PropTypes.string,
-  grantGroupName: PropTypes.string,
-
-  onUpdateGrant: PropTypes.func,
-};
-
-export default withTranslation()(GrantSelectorWrapper);

+ 228 - 0
packages/app/src/components/SavePageControls/GrantSelector.tsx

@@ -0,0 +1,228 @@
+import React, { useCallback, useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  UncontrolledDropdown,
+  DropdownToggle, DropdownMenu, DropdownItem,
+
+  Modal, ModalHeader, ModalBody,
+} from 'reactstrap';
+
+
+import { isNotRef } from '~/interfaces/common';
+import { IUserGroupHasId } from '~/interfaces/user';
+import { useCurrentUser } from '~/stores/context';
+import { useSWRxMyUserGroupRelations } from '~/stores/user-group';
+
+
+const AVAILABLE_GRANTS = [
+  {
+    grant: 1, iconClass: 'icon-people', btnStyleClass: 'outline-info', label: 'Public',
+  },
+  {
+    grant: 2, iconClass: 'icon-link', btnStyleClass: 'outline-teal', label: 'Anyone with the link',
+  },
+  // { grant: 3, iconClass: '', label: 'Specified users only' },
+  {
+    grant: 4, iconClass: 'icon-lock', btnStyleClass: 'outline-danger', label: 'Only me',
+  },
+  {
+    grant: 5, iconClass: 'icon-options', btnStyleClass: 'outline-purple', label: 'Only inside the group', reselectLabel: 'Reselect the group',
+  },
+];
+
+
+type Props = {
+  disabled?: boolean,
+  grant: number,
+  grantGroupId?: string,
+  grantGroupName?: string,
+
+  onUpdateGrant?: (args: { grant: number, grantGroupId?: string | null, grantGroupName?: string | null }) => void,
+}
+
+/**
+ * Page grant select component
+ */
+const GrantSelector = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    disabled,
+    grantGroupName,
+    onUpdateGrant,
+  } = props;
+
+
+  const [isSelectGroupModalShown, setIsSelectGroupModalShown] = useState(false);
+
+  const { data: currentUser } = useCurrentUser();
+
+  const shouldFetch = isSelectGroupModalShown;
+  const { data: myUserGroupRelations, mutate: mutateMyUserGroupRelations } = useSWRxMyUserGroupRelations(shouldFetch);
+
+  const showSelectGroupModal = useCallback(() => {
+    mutateMyUserGroupRelations();
+    setIsSelectGroupModalShown(true);
+  }, [mutateMyUserGroupRelations]);
+
+  /**
+   * change event handler for grant selector
+   */
+  const changeGrantHandler = useCallback((grant: number) => {
+    // select group
+    if (grant === 5) {
+      showSelectGroupModal();
+      return;
+    }
+
+    if (onUpdateGrant != null) {
+      onUpdateGrant({ grant, grantGroupId: null, grantGroupName: null });
+    }
+  }, [onUpdateGrant, showSelectGroupModal]);
+
+  const groupListItemClickHandler = useCallback((grantGroup: IUserGroupHasId) => {
+    if (onUpdateGrant != null) {
+      onUpdateGrant({ grant: 5, grantGroupId: grantGroup._id, grantGroupName: grantGroup.name });
+    }
+
+    // hide modal
+    setIsSelectGroupModalShown(false);
+  }, [onUpdateGrant]);
+
+  /**
+   * Render grant selector DOM.
+   */
+  const renderGrantSelector = useCallback(() => {
+    const { grant: currentGrant, grantGroupId } = props;
+
+    let dropdownToggleBtnColor;
+    let dropdownToggleLabelElm;
+
+    const dropdownMenuElems = AVAILABLE_GRANTS.map((opt) => {
+      const label = ((opt.grant === 5 && opt.reselectLabel != null) && grantGroupId != null)
+        ? opt.reselectLabel // when grantGroup is selected
+        : opt.label;
+
+      const labelElm = (
+        <span>
+          <i className={`icon icon-fw ${opt.iconClass}`}></i>
+          <span className="label">{t(label)}</span>
+        </span>
+      );
+
+      // set dropdownToggleBtnColor, dropdownToggleLabelElm
+      if (opt.grant === 1 || opt.grant === currentGrant) {
+        dropdownToggleBtnColor = opt.btnStyleClass;
+        dropdownToggleLabelElm = labelElm;
+      }
+
+      return <DropdownItem key={opt.grant} onClick={() => changeGrantHandler(opt.grant)}>{labelElm}</DropdownItem>;
+    });
+
+    // add specified group option
+    if (grantGroupId != null) {
+      const labelElm = (
+        <span>
+          <i className="icon icon-fw icon-organization"></i>
+          <span className="label">{grantGroupName}</span>
+        </span>
+      );
+
+      // set dropdownToggleLabelElm
+      dropdownToggleLabelElm = labelElm;
+
+      dropdownMenuElems.push(<DropdownItem key="groupSelected">{labelElm}</DropdownItem>);
+    }
+
+    return (
+      <div className="form-group grw-grant-selector mb-0">
+        <UncontrolledDropdown direction="up">
+          <DropdownToggle color={dropdownToggleBtnColor} caret className="d-flex justify-content-between align-items-center" disabled={disabled}>
+            {dropdownToggleLabelElm}
+          </DropdownToggle>
+          <DropdownMenu>
+            {dropdownMenuElems}
+          </DropdownMenu>
+        </UncontrolledDropdown>
+      </div>
+    );
+  }, [changeGrantHandler, disabled, grantGroupName, props, t]);
+
+  /**
+   * Render select grantgroup modal.
+   */
+  const renderSelectGroupModalContent = useCallback(() => {
+    if (!shouldFetch) {
+      return <></>;
+    }
+
+    // show spinner
+    if (myUserGroupRelations == null) {
+      return (
+        <div className="my-3 text-center">
+          <i className="fa fa-lg fa-spinner fa-pulse mx-auto text-muted"></i>
+        </div>
+      );
+    }
+
+    // extract IUserGroupHasId
+    const userRelatedGroups: IUserGroupHasId[] = myUserGroupRelations
+      .map((relation) => {
+        // relation.relatedGroup should be populated by server
+        return isNotRef(relation.relatedGroup) ? relation.relatedGroup : undefined;
+      })
+      // exclude undefined elements
+      .filter((elem): elem is IUserGroupHasId => elem != null);
+
+    if (userRelatedGroups.length === 0) {
+      return (
+        <div>
+          <h4>{t('user_group.belonging_to_no_group')}</h4>
+          { currentUser?.admin && (
+            <p><a href="/admin/user-groups"><i className="icon icon-fw icon-login"></i>{t('user_group.manage_user_groups')}</a></p>
+          ) }
+        </div>
+      );
+    }
+
+    return (
+      <div className="list-group">
+        { userRelatedGroups.map((group) => {
+          return (
+            <button key={group._id} type="button" className="list-group-item list-group-item-action" onClick={() => groupListItemClickHandler(group)}>
+              <h5>{group.name}</h5>
+              {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
+            </button>
+          );
+        }) }
+      </div>
+    );
+
+  }, [currentUser?.admin, groupListItemClickHandler, myUserGroupRelations, shouldFetch, t]);
+
+  return (
+    <>
+      { renderGrantSelector() }
+
+      {/* render modal */}
+      { !disabled && currentUser != null && (
+        <Modal
+          className="select-grant-group"
+          isOpen={isSelectGroupModalShown}
+          toggle={() => setIsSelectGroupModalShown(false)}
+        >
+          <ModalHeader tag="h4" toggle={() => setIsSelectGroupModalShown(false)} className="bg-purple text-light">
+            {t('user_group.select_group')}
+          </ModalHeader>
+          <ModalBody>
+            {renderSelectGroupModalContent()}
+          </ModalBody>
+        </Modal>
+      ) }
+    </>
+  );
+
+};
+
+export default GrantSelector;

+ 14 - 14
packages/app/src/components/ShareLink/ShareLink.jsx

@@ -1,16 +1,17 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
+import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-import { withUnstatedContainers } from '../UnstatedUtils';
 
-import AppContainer from '~/client/services/AppContainer';
+
 import PageContainer from '~/client/services/PageContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Delete, apiv3Get } from '~/client/util/apiv3-client';
 
-import ShareLinkList from './ShareLinkList';
-import ShareLinkForm from './ShareLinkForm';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import ShareLinkForm from './ShareLinkForm';
+import ShareLinkList from './ShareLinkList';
 
 class ShareLink extends React.Component {
 
@@ -31,11 +32,11 @@ class ShareLink extends React.Component {
   }
 
   async retrieveShareLinks() {
-    const { appContainer, pageContainer } = this.props;
+    const { pageContainer } = this.props;
     const { pageId } = pageContainer.state;
 
     try {
-      const res = await appContainer.apiv3.get('/share-links/', { relatedPage: pageId });
+      const res = await apiv3Get('/share-links/', { relatedPage: pageId });
       const { shareLinksResult } = res.data;
       this.setState({ shareLinks: shareLinksResult });
     }
@@ -51,11 +52,11 @@ class ShareLink extends React.Component {
   }
 
   async deleteAllLinksButtonHandler() {
-    const { t, appContainer, pageContainer } = this.props;
+    const { t, pageContainer } = this.props;
     const { pageId } = pageContainer.state;
 
     try {
-      const res = await appContainer.apiv3.delete('/share-links/', { relatedPage: pageId });
+      const res = await apiv3Delete('/share-links/', { relatedPage: pageId });
       const count = res.data.n;
       toastSuccess(t('toaster.remove_share_link', { count }));
     }
@@ -67,10 +68,10 @@ class ShareLink extends React.Component {
   }
 
   async deleteLinkById(shareLinkId) {
-    const { t, appContainer } = this.props;
+    const { t } = this.props;
 
     try {
-      const res = await appContainer.apiv3Delete(`/share-links/${shareLinkId}`);
+      const res = await apiv3Delete(`/share-links/${shareLinkId}`);
       const { deletedShareLink } = res.data;
       toastSuccess(t('toaster.remove_share_link_success', { shareLinkId: deletedShareLink._id }));
     }
@@ -114,11 +115,10 @@ class ShareLink extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const ShareLinkWrapper = withUnstatedContainers(ShareLink, [AppContainer, PageContainer]);
+const ShareLinkWrapper = withUnstatedContainers(ShareLink, [PageContainer]);
 
 ShareLink.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
 

+ 10 - 11
packages/app/src/components/ShareLink/ShareLinkForm.jsx

@@ -1,16 +1,16 @@
 import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-import { format, parse } from 'date-fns';
 
 import { isInteger } from 'core-js/fn/number';
-import { withUnstatedContainers } from '../UnstatedUtils';
+import { format, parse } from 'date-fns';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
 
+import PageContainer from '~/client/services/PageContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Post } from '~/client/util/apiv3-client';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
 
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
 
 class ShareLinkForm extends React.Component {
 
@@ -110,7 +110,7 @@ class ShareLinkForm extends React.Component {
 
   async handleIssueShareLink() {
     const {
-      t, appContainer, pageContainer,
+      t, pageContainer,
     } = this.props;
     const { pageId } = pageContainer.state;
     const { description } = this.state;
@@ -125,7 +125,7 @@ class ShareLinkForm extends React.Component {
     }
 
     try {
-      await appContainer.apiv3Post('/share-links/', { relatedPage: pageId, expiredAt, description });
+      await apiv3Post('/share-links/', { relatedPage: pageId, expiredAt, description });
       this.closeForm();
       toastSuccess(t('toaster.issue_share_link'));
     }
@@ -261,11 +261,10 @@ class ShareLinkForm extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const ShareLinkFormWrapper = withUnstatedContainers(ShareLinkForm, [AppContainer, PageContainer]);
+const ShareLinkFormWrapper = withUnstatedContainers(ShareLinkForm, [PageContainer]);
 
 ShareLinkForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   onCloseForm: PropTypes.func,
 };

+ 6 - 2
packages/app/src/components/Sidebar/CustomSidebar.tsx

@@ -57,7 +57,7 @@ const CustomSidebar: FC<Props> = (props: Props) => {
       }
 
       {
-        !isLoading && markdown != null ? (
+        (!isLoading && markdown != null) && (
           <div className="p-3">
             <RevisionRenderer
               growiRenderer={renderer}
@@ -66,7 +66,11 @@ const CustomSidebar: FC<Props> = (props: Props) => {
               additionalClassName="grw-custom-sidebar-content"
             />
           </div>
-        ) : (
+        )
+      }
+
+      {
+        (!isLoading && markdown === undefined) && (
           <SidebarNotFound />
         )
       }

+ 6 - 7
packages/app/src/components/StaffCredit/StaffCredit.jsx

@@ -1,11 +1,13 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import {
   Modal, ModalBody,
 } from 'reactstrap';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
 import loggerFactory from '~/utils/logger';
-import AppContainer from '~/client/services/AppContainer';
-import { withUnstatedContainers } from '../UnstatedUtils';
+
 
 /**
  * Page staff credit component
@@ -86,7 +88,7 @@ class StaffCredit extends React.Component {
   }
 
   async componentDidMount() {
-    const res = await this.props.appContainer.apiv3Get('/staffs');
+    const res = await apiv3Get('/staffs');
     const contributors = res.data.contributors;
     this.setState({ contributors });
 
@@ -134,11 +136,8 @@ class StaffCredit extends React.Component {
 
 }
 
-const StaffCreditWrapper = withUnstatedContainers(StaffCredit, [AppContainer]);
-
 StaffCredit.propTypes = {
   onClosed: PropTypes.func,
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 };
 
-export default StaffCreditWrapper;
+export default StaffCredit;

+ 12 - 13
packages/app/src/components/UncontrolledCodeMirror.tsx

@@ -1,7 +1,7 @@
 import React, { forwardRef, ReactNode, Ref } from 'react';
+
 import { ICodeMirror, UnControlled as CodeMirror } from 'react-codemirror2';
-import { Container, Subscribe } from 'unstated';
-import EditorContainer from '~/client/services/EditorContainer';
+
 import AbstractEditor, { AbstractEditorProps } from '~/components/PageEditor/AbstractEditor';
 
 window.CodeMirror = require('codemirror');
@@ -16,7 +16,6 @@ export interface UncontrolledCodeMirrorProps extends AbstractEditorProps {
 }
 
 interface UncontrolledCodeMirrorCoreProps extends UncontrolledCodeMirrorProps {
-  editorContainer: Container<EditorContainer>;
   forwardedRef: Ref<UncontrolledCodeMirrorCore>;
 }
 
@@ -25,11 +24,10 @@ class UncontrolledCodeMirrorCore extends AbstractEditor<UncontrolledCodeMirrorCo
   render(): ReactNode {
 
     const {
-      value, isGfmMode, lineNumbers, editorContainer, options, forwardedRef, ...rest
+      value, isGfmMode, lineNumbers, options, forwardedRef,
+      ...rest
     } = this.props;
 
-    const { editorOptions } = editorContainer.state;
-
     return (
       <CodeMirror
         ref={forwardedRef}
@@ -37,8 +35,6 @@ class UncontrolledCodeMirrorCore extends AbstractEditor<UncontrolledCodeMirrorCo
         options={{
           lineNumbers: lineNumbers ?? true,
           mode: isGfmMode ? 'gfm-growi' : undefined,
-          theme: editorOptions.theme,
-          styleActiveLine: editorOptions.styleActiveLine,
           tabSize: 4,
           ...options,
         }}
@@ -49,8 +45,11 @@ class UncontrolledCodeMirrorCore extends AbstractEditor<UncontrolledCodeMirrorCo
 
 }
 
-export const UncontrolledCodeMirror = forwardRef<UncontrolledCodeMirrorCore, UncontrolledCodeMirrorProps>((props, ref) => (
-  <Subscribe to={[EditorContainer]}>
-    {(EditorContainer: Container<EditorContainer>) => <UncontrolledCodeMirrorCore {...props} forwardedRef={ref} editorContainer={EditorContainer} />}
-  </Subscribe>
-));
+export const UncontrolledCodeMirror = forwardRef<UncontrolledCodeMirrorCore, UncontrolledCodeMirrorProps>((props, ref) => {
+  return (
+    <UncontrolledCodeMirrorCore
+      {...props}
+      forwardedRef={ref}
+    />
+  );
+});

+ 4 - 0
packages/app/src/interfaces/common.ts

@@ -9,3 +9,7 @@ import { HasObjectId } from './has-object-id';
 export type Ref<T> = string | T & HasObjectId;
 
 export type Nullable<T> = T | null | undefined;
+
+export const isNotRef = <T>(ref: string | T & HasObjectId): ref is T & HasObjectId => {
+  return !(typeof ref === 'string');
+};

+ 31 - 0
packages/app/src/interfaces/editor-settings.ts

@@ -0,0 +1,31 @@
+export interface ILintRule {
+  name: string;
+  options?: unknown;
+  isEnabled?: boolean;
+}
+
+export interface ITextlintSettings {
+  neverAskBeforeDownloadLargeFiles: boolean;
+  textlintRules: ILintRule[];
+}
+
+export const DEFAULT_THEME = 'elegant';
+
+const KeyMapMode = {
+  default: 'default',
+  vim: 'vim',
+  emacs: 'emacs',
+  sublime: 'sublime',
+} as const;
+
+export type KeyMapMode = typeof KeyMapMode[keyof typeof KeyMapMode];
+
+export interface IEditorSettings {
+  theme: undefined | string,
+  keymapMode: undefined | KeyMapMode,
+  styleActiveLine: boolean,
+  renderMathJaxInRealtime: boolean,
+  renderDrawioInRealtime: boolean,
+  autoFormatMarkdownTable: boolean,
+  textlintSettings: undefined | ITextlintSettings;
+}

+ 1 - 0
packages/app/src/interfaces/errors/v5-conversion-error.ts

@@ -2,6 +2,7 @@ export const V5ConversionErrCode = {
   GRANT_INVALID: 'GrantInvalid',
   PAGE_NOT_FOUND: 'PageNotFound',
   DUPLICATE_PAGES_FOUND: 'DuplicatePagesFound',
+  FORBIDDEN: 'Forbidden',
 } as const;
 
 export type V5ConversionErrCode = typeof V5ConversionErrCode[keyof typeof V5ConversionErrCode];

Некоторые файлы не были показаны из-за большого количества измененных файлов