Browse Source

Merge branch 'support/apply-nextjs-2' into feat/render-page-list-and-timeline

yohei0125 3 years ago
parent
commit
5827b8352d
100 changed files with 988 additions and 1016 deletions
  1. 10 0
      .eslintrc.js
  2. 38 33
      .github/workflows/ci-app-prod.yml
  3. 5 4
      .github/workflows/ci-app.yml
  4. 8 4
      .github/workflows/reusable-app-prod.yml
  5. 2 2
      package.json
  6. 4 0
      packages/app/.eslintignore
  7. 11 1
      packages/app/.eslintrc.js
  8. 4 1
      packages/app/.gitignore
  9. 0 0
      packages/app/_obsolete/config/webpack.common.js
  10. 0 0
      packages/app/_obsolete/config/webpack.dev.dll.js
  11. 0 0
      packages/app/_obsolete/config/webpack.dev.js
  12. 0 0
      packages/app/_obsolete/config/webpack.prod.js
  13. 0 0
      packages/app/_obsolete/src/client/admin.jsx
  14. 2 2
      packages/app/_obsolete/src/client/app.jsx
  15. 0 0
      packages/app/_obsolete/src/client/base.jsx
  16. 0 0
      packages/app/_obsolete/src/client/installer.jsx
  17. 0 0
      packages/app/_obsolete/src/client/nologin.jsx
  18. 6 1
      packages/app/config/migrate-mongo-config.js
  19. 0 0
      packages/app/config/next-i18next.config.ts
  20. 1 1
      packages/app/docker/Dockerfile
  21. 92 70
      packages/app/next.config.js
  22. 18 15
      packages/app/package.json
  23. 3 0
      packages/app/public/static/locales/en_US/admin.json
  24. 1 0
      packages/app/public/static/locales/en_US/translation.json
  25. 3 0
      packages/app/public/static/locales/ja_JP/admin.json
  26. 1 0
      packages/app/public/static/locales/ja_JP/translation.json
  27. 3 0
      packages/app/public/static/locales/zh_CN/admin.json
  28. 1 0
      packages/app/public/static/locales/zh_CN/translation.json
  29. 0 7
      packages/app/resource/cdn-manifests.js
  30. 3 0
      packages/app/src/client/interfaces/clearable.ts
  31. 1 7
      packages/app/src/client/services/AdminBasicSecurityContainer.js
  32. 0 51
      packages/app/src/client/services/AdminCustomizeContainer.js
  33. 1 6
      packages/app/src/client/services/AdminGeneralSecurityContainer.js
  34. 0 1
      packages/app/src/client/services/AdminGitHubSecurityContainer.js
  35. 0 1
      packages/app/src/client/services/AdminGoogleSecurityContainer.js
  36. 1 4
      packages/app/src/client/services/AdminLdapSecurityContainer.js
  37. 1 5
      packages/app/src/client/services/AdminOidcSecurityContainer.js
  38. 1 5
      packages/app/src/client/services/AdminSamlSecurityContainer.js
  39. 1 5
      packages/app/src/client/services/AdminTwitterSecurityContainer.js
  40. 1 1
      packages/app/src/client/util/apiv3-client.ts
  41. 1 1
      packages/app/src/client/util/i18n.js
  42. 2 1
      packages/app/src/components/Admin/App/AppSetting.jsx
  43. 11 2
      packages/app/src/components/Admin/AuditLog/ActivityTable.tsx
  44. 18 3
      packages/app/src/components/Admin/AuditLog/SearchUsernameTypeahead.tsx
  45. 32 8
      packages/app/src/components/Admin/AuditLogManagement.tsx
  46. 8 10
      packages/app/src/components/Admin/Customize/Customize.jsx
  47. 1 1
      packages/app/src/components/Admin/Customize/CustomizeLayoutSetting.tsx
  48. 1 1
      packages/app/src/components/Admin/Customize/CustomizeLogoSetting.tsx
  49. 2 2
      packages/app/src/components/Admin/Customize/CustomizeSidebarSetting.tsx
  50. 8 6
      packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx
  51. 21 10
      packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx
  52. 6 4
      packages/app/src/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx
  53. 18 27
      packages/app/src/components/Admin/Security/BasicSecuritySetting.jsx
  54. 2 5
      packages/app/src/components/Admin/Security/FacebookSecuritySetting.jsx
  55. 17 22
      packages/app/src/components/Admin/Security/GitHubSecuritySetting.jsx
  56. 13 6
      packages/app/src/components/Admin/Security/GitHubSecuritySettingContents.jsx
  57. 18 22
      packages/app/src/components/Admin/Security/GoogleSecuritySetting.jsx
  58. 14 8
      packages/app/src/components/Admin/Security/GoogleSecuritySettingContents.jsx
  59. 17 21
      packages/app/src/components/Admin/Security/LdapSecuritySetting.jsx
  60. 18 22
      packages/app/src/components/Admin/Security/LocalSecuritySetting.jsx
  61. 9 12
      packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  62. 17 22
      packages/app/src/components/Admin/Security/OidcSecuritySetting.jsx
  63. 19 13
      packages/app/src/components/Admin/Security/OidcSecuritySettingContents.jsx
  64. 17 22
      packages/app/src/components/Admin/Security/SamlSecuritySetting.jsx
  65. 15 5
      packages/app/src/components/Admin/Security/SamlSecuritySettingContents.jsx
  66. 14 21
      packages/app/src/components/Admin/Security/SecurityManagement.jsx
  67. 18 18
      packages/app/src/components/Admin/Security/SecurityManagementContents.jsx
  68. 17 25
      packages/app/src/components/Admin/Security/TwitterSecuritySetting.jsx
  69. 13 5
      packages/app/src/components/Admin/Security/TwitterSecuritySettingContents.jsx
  70. 3 1
      packages/app/src/components/CustomNavigation/CustomNav.jsx
  71. 8 6
      packages/app/src/components/CustomNavigation/CustomNav.module.scss
  72. 12 18
      packages/app/src/components/CustomNavigation/CustomNavAndContents.tsx
  73. 1 1
      packages/app/src/components/DescendantsPageListModal.tsx
  74. 5 5
      packages/app/src/components/Layout/AdminLayout.tsx
  75. 10 9
      packages/app/src/components/LoginForm.jsx
  76. 20 6
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  77. 23 5
      packages/app/src/components/Navbar/GrowiSubNavigation.module.scss
  78. 2 3
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  79. 7 17
      packages/app/src/components/Navbar/SubNavButtons.tsx
  80. 3 5
      packages/app/src/components/NotFoundPage.tsx
  81. 3 5
      packages/app/src/components/Page/DisplaySwitcher.tsx
  82. 5 1
      packages/app/src/components/PageAlert/FixPageGrantAlert.tsx
  83. 26 23
      packages/app/src/components/PageComment.tsx
  84. 0 259
      packages/app/src/components/PageComment/Comment.jsx
  85. 178 0
      packages/app/src/components/PageComment/Comment.tsx
  86. 16 29
      packages/app/src/components/PageComment/CommentEditor.tsx
  87. 2 7
      packages/app/src/components/PageComment/CommentEditorLazyRenderer.tsx
  88. 13 17
      packages/app/src/components/PageComment/ReplayComments.jsx
  89. 3 9
      packages/app/src/components/PageEditor/DrawioModal.jsx
  90. 5 15
      packages/app/src/components/PageEditor/Preview.tsx
  91. 7 11
      packages/app/src/components/PageTimeline.tsx
  92. 2 2
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  93. 1 1
      packages/app/src/components/ShareLink/ShareLink.jsx
  94. 6 2
      packages/app/src/components/Skelton.tsx
  95. 2 1
      packages/app/src/components/UnstatedUtils.tsx
  96. 2 0
      packages/app/src/interfaces/activity.ts
  97. 3 4
      packages/app/src/interfaces/in-app-notification.ts
  98. 28 0
      packages/app/src/interfaces/unstated-container.ts
  99. 1 1
      packages/app/src/migrations/20180926134048-make-email-unique.js
  100. 1 1
      packages/app/src/migrations/20180927102719-init-serverurl.js

+ 10 - 0
.eslintrc.js

@@ -56,6 +56,7 @@ module.exports = {
       },
     ],
     '@typescript-eslint/no-explicit-any': 'off',
+    '@typescript-eslint/explicit-module-boundary-types': 'off',
     indent: [
       'error',
       2,
@@ -80,4 +81,13 @@ module.exports = {
       },
     ]],
   },
+  overrides: [
+    {
+      // enable the rule specifically for TypeScript files
+      files: ['*.ts', '*.tsx'],
+      rules: {
+        '@typescript-eslint/explicit-module-boundary-types': ['error'],
+      },
+    },
+  ],
 };

+ 38 - 33
.github/workflows/ci-app-prod.yml

@@ -3,7 +3,25 @@ name: Node CI for app production
 on:
   push:
     branches:
-      - master
+      # - master
+      - support/apply-nextjs-2
+    paths:
+      - .github/workflows/ci-app-prod.yml
+      - .github/workflows/reusable-app-prod.yml
+      - .github/workflows/reusable-app-reg-suit.yml
+      - tsconfig.base.json
+      - yarn.lock
+      - packages/app/**
+      - '!packages/app/docker/**'
+      - packages/core/**
+      - packages/slack/**
+      - packages/ui/**
+      - packages/plugin-**
+  pull_request:
+    branches:
+      # - master
+      - support/apply-nextjs-2
+    types: [opened, reopened, synchronize]
     paths:
       - .github/workflows/ci-app-prod.yml
       - .github/workflows/reusable-app-prod.yml
@@ -16,27 +34,12 @@ on:
       - packages/slack/**
       - packages/ui/**
       - packages/plugin-**
-  # pull_request:
-  #   branches:
-  #       - master
-  #   types: [opened, reopened, synchronize]
-  #   paths:
-  #     - .github/workflows/ci-app-prod.yml
-  #     - .github/workflows/reusable-app-prod.yml
-  #     - .github/workflows/reusable-app-reg-suit.yml
-  #     - tsconfig.base.json
-  #     - yarn.lock
-  #     - packages/app/**
-  #     - '!packages/app/docker/**'
-  #     - packages/core/**
-  #     - packages/slack/**
-  #     - packages/ui/**
-  #     - packages/plugin-**
 
 jobs:
 
   test-prod-node14:
-    uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
+    # uses: weseek/growi/.github/workflows/reusable-app-prod.yml@support/master
+    uses: weseek/growi/.github/workflows/reusable-app-prod.yml@support/apply-nextjs-2
     with:
       node-version: 14.x
       skip-cypress: true
@@ -45,28 +48,30 @@ jobs:
 
 
   test-prod-node16:
-    uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
+    # uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
+    uses: weseek/growi/.github/workflows/reusable-app-prod.yml@support/apply-nextjs-2
     with:
       node-version: 16.x
-      skip-cypress: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) && contains( github.event.pull_request.labels.*.name, 'github_actions' ) }}
+      # skip-cypress: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) && contains( github.event.pull_request.labels.*.name, 'github_actions' ) }}
+      skip-cypress: true
       cypress-report-artifact-name: Cypress report
     secrets:
       SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
-  run-reg-suit-node16:
-    needs: [test-prod-node16]
+  # run-reg-suit-node16:
+  #   needs: [test-prod-node16]
 
-    uses: weseek/growi/.github/workflows/reusable-app-reg-suit.yml@master
+  #   uses: weseek/growi/.github/workflows/reusable-app-reg-suit.yml@master
 
-    if: always()
+  #   if: always()
 
-    with:
-      node-version: 16.x
-      skip-reg-suit: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) && contains( github.event.pull_request.labels.*.name, 'github_actions' ) }}
-      cypress-report-artifact-name: Cypress report
-    secrets:
-      REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: ${{ secrets.REG_NOTIFY_GITHUB_PLUGIN_CLIENTID }}
-      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
-      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
-      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+  #   with:
+  #     node-version: 16.x
+  #     skip-reg-suit: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) && contains( github.event.pull_request.labels.*.name, 'github_actions' ) }}
+  #     cypress-report-artifact-name: Cypress report
+  #   secrets:
+  #     REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: ${{ secrets.REG_NOTIFY_GITHUB_PLUGIN_CLIENTID }}
+  #     AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
+  #     AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+  #     SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 5 - 4
.github/workflows/ci-app.yml

@@ -150,16 +150,17 @@ jobs:
           cache: 'yarn'
           cache-dependency-path: '**/yarn.lock'
 
-      - name: Cache/Restore node_modules
+      - name: Cache/Restore node_modules and next cache files
         id: cache-dependencies
         uses: actions/cache@v3
         with:
           path: |
             **/node_modules
-          key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/app/package.json') }}
+            ${{ github.workspace }}/packages/app/.next/cache
+          key: dev-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/app/package.json') }}
           restore-keys: |
-            node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-
-            node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
+            dev-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-
+            dev-${{ runner.OS }}-node${{ matrix.node-version }}-
 
       - name: lerna bootstrap
         run: |

+ 8 - 4
.github/workflows/reusable-app-prod.yml

@@ -49,6 +49,7 @@ jobs:
     - name: Remove unnecessary packages
       run: |
         rm -rf packages/slackbot-proxy
+        rm -f "node_modules/@growi/slackbot-proxy"
 
     - name: Build
       run: |
@@ -59,17 +60,17 @@ jobs:
     - name: Archive production files
       id: archive-prod-files
       run: |
-        tar -cf production.tar \
+        tar -zcf production.tar.gz \
           package.json \
+          packages/app/.next \
           packages/app/config \
           packages/app/public \
           packages/app/resource \
           packages/app/tmp \
-          packages/app/migrate-mongo-config.js \
           packages/app/.env.production* \
           packages/*/package.json \
           packages/*/dist
-        echo ::set-output name=file::production.tar
+        echo ::set-output name=file::production.tar.gz
 
     - name: Upload production files as artifact
       uses: actions/upload-artifact@v3
@@ -81,7 +82,9 @@ jobs:
       uses: actions/upload-artifact@v3
       with:
         name: Bundle Analyzing Report
-        path: packages/app/report/bundle-analyzer.html
+        path: |
+          packages/app/.next/analyze/client.html
+          packages/app/.next/analyze/server.html
 
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
@@ -143,6 +146,7 @@ jobs:
     - name: Remove unnecessary packages
       run: |
         rm -rf packages/slackbot-proxy
+        rm -f "node_modules/@growi/slackbot-proxy"
 
     - name: lerna bootstrap --production
       run: |

+ 2 - 2
package.json

@@ -82,9 +82,9 @@
     "stylelint": "^14.2.0",
     "stylelint-config-recess-order": "^3.0.0",
     "ts-jest": "^27.0.4",
-    "ts-node": "^9.1.1",
+    "ts-node": "^10.9.1",
     "tsconfig-paths": "^3.9.0",
-    "typescript": "^4.7.3",
+    "typescript": "~4.7",
     "yargs": "^17.3.1"
   },
   "engines": {

+ 4 - 0
packages/app/.eslintignore

@@ -1,7 +1,11 @@
+/_obsolete/**
 /dist/**
+/transpiled/**
 /public/**
+/config/next-i18next.config.js
 /src/client/legacy/thirdparty-js/**
 /src/client/util/reveal/plugins/markdown.js
 /src/linter-checker/**
+/src/utils/next.config.utils.js
 /tmp/**
 /next-env.d.ts

+ 11 - 1
packages/app/.eslintrc.js

@@ -38,9 +38,19 @@ module.exports = {
     '@typescript-eslint/no-var-requires': 'off',
 
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei
-    '@typescript-eslint/explicit-module-boundary-types': ['warn'],
     '@typescript-eslint/no-use-before-define': ['warn'],
     '@typescript-eslint/no-this-alias': ['warn'],
     'jest/no-done-callback': ['warn'],
   },
+  overrides: [
+    {
+      // enable the rule specifically for TypeScript files
+      files: ['*.ts', '*.tsx'],
+      rules: {
+        // '@typescript-eslint/explicit-module-boundary-types': ['error'],
+        // set 'warn' temporarily -- 2022.07.25 Yuki Takei
+        '@typescript-eslint/explicit-module-boundary-types': ['warn'],
+      },
+    },
+  ],
 };

+ 4 - 1
packages/app/.gitignore

@@ -11,12 +11,15 @@ test/cypress/videos
 /build/
 /dist/
 /transpiled/
-/report/
 /public/static/js
 /public/static/styles
 /public/uploads
 /tmp/
 
+# transpiled configuration files for production build
+/config/next-i18next.config.js
+/src/utils/next.config.utils.js
+
 # dist (for GROWI v4.x and below)
 /public/*.chunk.js
 /public/*.chunk.js.LICENSE.txt

+ 0 - 0
packages/app/config/webpack.common.js → packages/app/_obsolete/config/webpack.common.js


+ 0 - 0
packages/app/config/webpack.dev.dll.js → packages/app/_obsolete/config/webpack.dev.dll.js


+ 0 - 0
packages/app/config/webpack.dev.js → packages/app/_obsolete/config/webpack.dev.js


+ 0 - 0
packages/app/config/webpack.prod.js → packages/app/_obsolete/config/webpack.prod.js


+ 0 - 0
packages/app/src/client/admin.jsx → packages/app/_obsolete/src/client/admin.jsx


+ 2 - 2
packages/app/src/client/app.jsx → packages/app/_obsolete/src/client/app.jsx

@@ -30,12 +30,12 @@ import { Page } from '../components/Page';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
-import PageComment from '../components/PageComment';
+import { PageComment } from '../components/PageComment';
 import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
 import PageContentFooter from '../components/PageContentFooter';
 import BookmarkList from '../components/PageList/BookmarkList';
 import PageStatusAlert from '../components/PageStatusAlert';
-import PageTimeline from '../components/PageTimeline';
+import { PageTimeline } from '../components/PageTimeline';
 import RecentCreated from '../components/RecentCreated/RecentCreated';
 import { SearchPage } from '../components/SearchPage';
 import Sidebar from '../components/Sidebar';

+ 0 - 0
packages/app/src/client/base.jsx → packages/app/_obsolete/src/client/base.jsx


+ 0 - 0
packages/app/src/client/installer.jsx → packages/app/_obsolete/src/client/installer.jsx


+ 0 - 0
packages/app/src/client/nologin.jsx → packages/app/_obsolete/src/client/nologin.jsx


+ 6 - 1
packages/app/migrate-mongo-config.js → packages/app/config/migrate-mongo-config.js

@@ -4,16 +4,21 @@
  *
  * @author Yuki Takei <yuki@weseek.co.jp>
  */
+const isProduction = process.env.NODE_ENV === 'production';
 
 const { URL } = require('url');
 
+const { initMongooseGlobalSettings, getMongoUri, mongoOptions } = isProduction
+  // eslint-disable-next-line import/extensions, import/no-unresolved
+  ? require('../dist/server/util/mongoose-utils')
+  : require('../src/server/util/mongoose-utils');
+
 // get migrationsDir from env var
 const migrationsDir = process.env.MIGRATIONS_DIR;
 if (migrationsDir == null) {
   throw new Error('An env var MIGRATIONS_DIR must be set.');
 }
 
-const { initMongooseGlobalSettings, getMongoUri, mongoOptions } = require('@growi/core');
 
 initMongooseGlobalSettings();
 

+ 0 - 0
packages/app/src/next-i18next.config.ts → packages/app/config/next-i18next.config.ts


+ 1 - 1
packages/app/docker/Dockerfile

@@ -111,11 +111,11 @@ RUN yarn lerna run build
 # make artifacts
 RUN tar -cf packages.tar \
   package.json \
+  packages/app/.next \
   packages/app/config \
   packages/app/public \
   packages/app/resource \
   packages/app/tmp \
-  packages/app/migrate-mongo-config.js \
   packages/app/.env.production* \
   packages/*/package.json \
   packages/*/dist

+ 92 - 70
packages/app/next.config.js

@@ -1,23 +1,33 @@
-import eazyLogger from 'eazy-logger';
-import { I18NextHMRPlugin } from 'i18next-hmr/plugin';
-import { withSuperjson } from 'next-superjson';
-import { WebpackManifestPlugin } from 'webpack-manifest-plugin';
+/**
+ * == Notes for production build==
+ * The modules required from this file must be transpiled before running `next build`.
+ *
+ * See: https://github.com/vercel/next.js/discussions/35969#discussioncomment-2522954
+ */
 
-import { i18n, localePath } from './src/next-i18next.config';
-import { listScopedPackages, listPrefixedPackages } from './src/utils/next.config.utils';
+const { withSuperjson } = require('next-superjson');
+const { PHASE_PRODUCTION_BUILD, PHASE_PRODUCTION_SERVER } = require('next/constants');
 
 
-// setup logger
-const logger = eazyLogger.Logger({
-  prefix: '[{green:next.config.js}] ',
-  useLevelPrefixes: false,
-});
+// define additional entries
+const additionalWebpackEntries = {
+  boot: './src/client/boot',
+};
 
 
-const setupWithTM = () => {
+const setupTranspileModules = () => {
+  const eazyLogger = require('eazy-logger');
+  const { listScopedPackages, listPrefixedPackages } = require('./src/utils/next.config.utils');
+
+  // setup logger
+  const logger = eazyLogger.Logger({
+    prefix: '[{green:next.config.js}] ',
+    useLevelPrefixes: false,
+  });
+
   // define transpiled packages for '@growi/*'
   const packages = [
-    ...listScopedPackages(['@growi'], { ignorePackageNames: '@growi/app' }),
+    ...listScopedPackages(['@growi'], { ignorePackageNames: ['@growi/app'] }),
     // listing ESM packages until experimental.esmExternals works correctly to avoid ERR_REQUIRE_ESM
     'react-markdown',
     'unified',
@@ -39,63 +49,75 @@ const setupWithTM = () => {
 
   return require('next-transpile-modules')(packages);
 };
-const withTM = setupWithTM();
-
-
-// define additional entries
-const additionalWebpackEntries = {
-  boot: './src/client/boot',
-};
-
 
-/** @type {import('next').NextConfig} */
-const nextConfig = {
-  // == DOES NOT WORK
-  // see: https://github.com/vercel/next.js/discussions/27876
-  // experimental: { esmExternals: true }, // Prefer loading of ES Modules over CommonJS
-
-  reactStrictMode: true,
-  typescript: {
-    tsconfigPath: 'tsconfig.build.client.json',
-  },
-  pageExtensions: ['page.tsx', 'page.ts', 'page.jsx', 'page.js'],
-
-  i18n,
-
-  /** @param config {import('next').NextConfig} */
-  webpack(config, options) {
-    // Avoid "Module not found: Can't resolve 'fs'"
-    // See: https://stackoverflow.com/a/68511591
-    if (!options.isServer) {
-      config.resolve.fallback.fs = false;
-    }
-
-    // See: https://webpack.js.org/configuration/externals/
-    // This provides a way of excluding dependencies from the output bundles
-    config.externals.push('dtrace-provider');
-
-    // configure additional entries
-    const orgEntry = config.entry;
-    config.entry = () => {
-      return orgEntry().then((entry) => {
-        return { ...entry, ...additionalWebpackEntries };
-      });
-    };
-
-    config.plugins.push(
-      new WebpackManifestPlugin({
-        fileName: 'custom-manifest.json',
-      }),
-    );
-
-    // setup i18next-hmr
-    if (!options.isServer && options.dev) {
-      config.plugins.push(new I18NextHMRPlugin({ localesDir: localePath }));
-    }
-
-    return config;
-  },
 
+module.exports = async(phase, { defaultConfig }) => {
+
+  const { i18n, localePath } = require('./config/next-i18next.config');
+
+  /** @type {import('next').NextConfig} */
+  const nextConfig = {
+    // == DOES NOT WORK
+    // see: https://github.com/vercel/next.js/discussions/27876
+    // experimental: { esmExternals: true }, // Prefer loading of ES Modules over CommonJS
+
+    reactStrictMode: true,
+    swcMinify: true,
+    typescript: {
+      tsconfigPath: 'tsconfig.build.client.json',
+    },
+    pageExtensions: ['page.tsx', 'page.ts', 'page.jsx', 'page.js'],
+
+    i18n,
+
+    /** @param config {import('next').NextConfig} */
+    webpack(config, options) {
+      // Avoid "Module not found: Can't resolve 'fs'"
+      // See: https://stackoverflow.com/a/68511591
+      if (!options.isServer) {
+        config.resolve.fallback.fs = false;
+      }
+
+      // See: https://webpack.js.org/configuration/externals/
+      // This provides a way of excluding dependencies from the output bundles
+      config.externals.push('dtrace-provider');
+      config.externals.push('mongoose');
+
+      // configure additional entries
+      const orgEntry = config.entry;
+      config.entry = () => {
+        return orgEntry().then((entry) => {
+          return { ...entry, ...additionalWebpackEntries };
+        });
+      };
+
+      const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
+      config.plugins.push(
+        new WebpackManifestPlugin({
+          fileName: 'custom-manifest.json',
+        }),
+      );
+
+      // setup i18next-hmr
+      if (!options.isServer && options.dev) {
+        const { I18NextHMRPlugin } = require('i18next-hmr/plugin');
+        config.plugins.push(new I18NextHMRPlugin({ localesDir: localePath }));
+      }
+
+      return config;
+    },
+
+  };
+
+  // production server
+  if (phase === PHASE_PRODUCTION_SERVER) {
+    return withSuperjson()(nextConfig);
+  }
+
+  const withTM = setupTranspileModules();
+  const withBundleAnalyzer = require('@next/bundle-analyzer')({
+    enabled: phase === PHASE_PRODUCTION_BUILD || process.env.ANALYZE === 'true',
+  });
+
+  return withBundleAnalyzer(withTM(withSuperjson()(nextConfig)));
 };
-
-module.exports = withSuperjson()(withTM(nextConfig));

+ 18 - 15
packages/app/package.json

@@ -4,29 +4,28 @@
   "license": "MIT",
   "scripts": {
     "//// for production": "",
-    "build": "yarn next build",
+    "build": "run-p build:*",
     "start": "yarn next start",
-    "//// for production (obsolete)": "",
-    "start:obsolete": "yarn build && yarn server",
-    "build:obsolete": "run-p build:*",
-    "build:client": "yarn cross-env NODE_ENV=production webpack --config config/webpack.prod.js",
+    "build:client": "yarn next build",
+    "prebuild:client": "tsc -p tsconfig.build.next.config.json",
     "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
+    "postbuild:server": "npx -y shx mv transpiled/src dist && npx -y shx cp -r transpiled/config/* config && npx -y shx cp -r src/server/views dist/server/ && npx -y shx rm -rf transpiled",
     "clean": "npx -y shx rm -rf dist transpiled",
     "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
-    "postbuild": "npx -y shx mv transpiled/src dist && npx -y shx cp -r transpiled/config/* config && npx -y shx cp -r src/server/views dist/server/ && npx -y shx rm -rf transpiled",
     "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config dist/server/app.js",
     "server:ci": "yarn server --ci",
     "preserver": "yarn cross-env NODE_ENV=production yarn migrate",
-    "migrate": "node -r dotenv-flow/config node_modules/.bin/migrate-mongo up",
+    "migrate": "node -r dotenv-flow/config node_modules/.bin/migrate-mongo up -f config/migrate-mongo-config.js",
     "//// for development": "",
     "dev": "yarn cross-env NODE_ENV=development ts-node-dev -r tsconfig-paths/register -r dotenv-flow/config --inspect --transpile-only src/server/app.ts",
     "predev": "yarn cross-env NODE_ENV=development run-p resources:* dev:migrate:up",
+    "dev:analyze": "yarn cross-env ANALYZE=true yarn dev",
     "dev:migrate-mongo": "yarn cross-env NODE_ENV=development yarn ts-node node_modules/.bin/migrate-mongo",
     "dev:migrate": "yarn dev:migrate:up",
-    "dev:migrate:create": "yarn dev:migrate-mongo create",
-    "dev:migrate:status": "yarn dev:migrate-mongo status",
-    "dev:migrate:up": "yarn dev:migrate-mongo up",
-    "dev:migrate:down": "yarn dev:migrate-mongo down",
+    "dev:migrate:create": "yarn dev:migrate-mongo create -f config/migrate-mongo-config.js",
+    "dev:migrate:status": "yarn dev:migrate-mongo status -f config/migrate-mongo-config.js",
+    "dev:migrate:up": "yarn dev:migrate-mongo up -f config/migrate-mongo-config.js",
+    "dev:migrate:down": "yarn dev:migrate-mongo down -f config/migrate-mongo-config.js",
     "cy:run": "cypress run --browser chrome",
     "//// for CI": "",
     "dev:ci": "yarn dev --ci",
@@ -64,6 +63,7 @@
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@growi/codemirror-textlint": "^5.1.1-RC.0",
+    "@growi/core": "^5.1.1-RC.0",
     "@growi/plugin-attachment-refs": "^5.1.1-RC.0",
     "@growi/plugin-lsx": "^5.1.1-RC.0",
     "@growi/plugin-pukiwiki-like-linker": "^5.1.1-RC.0",
@@ -82,6 +82,7 @@
     "axios-retry": "^3.2.4",
     "body-parser": "^1.18.2",
     "browser-bunyan": "^1.6.3",
+    "bson-objectid": "^2.0.3",
     "bunyan": "^1.8.15",
     "check-node-version": "^4.1.0",
     "compression": "^1.7.4",
@@ -126,6 +127,7 @@
     "multer-autoreap": "^1.0.3",
     "next": "^12.1.6",
     "next-i18next": "^11.0.0",
+    "next-superjson": "^0.0.4",
     "next-themes": "^0.2.0",
     "nocache": "^3.0.1",
     "nodemailer": "^6.6.2",
@@ -177,12 +179,15 @@
   },
   "// comments for defDependencies": {
     "@handsontable/react": "v3 requires handsontable >= 7.0.0.",
-    "handsontable": "v7.0.0 or above is no loger MIT lisence."
+    "handsontable": "v7.0.0 or above is no loger MIT lisence.",
+    "ts-node": "v10 occurs 'SyntaxError: Cannot use import statement outside a module' when using migrate-mongo"
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
     "@growi/ui": "^5.1.1-RC.0",
     "@handsontable/react": "=2.1.0",
+    "@icon/themify-icons": "1.0.1-alpha.3",
+    "@next/bundle-analyzer": "^12.2.3",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
     "@types/jquery": "^3.5.8",
@@ -207,14 +212,12 @@
     "handsontable": "=6.2.2",
     "i18next-hmr": "^1.7.7",
     "jquery-slimscroll": "^1.3.8",
-    "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
     "jshint": "^2.13.0",
     "load-css-file": "^1.0.0",
     "markdown-table": "^1.1.1",
     "material-icons": "^1.11.3",
     "morgan": "^1.10.0",
-    "next-superjson": "^0.0.4",
     "next-transpile-modules": "^9.0.0",
     "normalize-path": "^3.0.0",
     "penpal": "^4.0.0",
@@ -239,9 +242,9 @@
     "sticky-events": "^3.4.11",
     "swagger2openapi": "^5.3.1",
     "swr": "^1.3.0",
-    "@icon/themify-icons": "1.0.1-alpha.3",
     "throttle-debounce": "^3.0.1",
     "toastr": "^2.1.2",
+    "ts-node": "^9.1.1",
     "ts-node-dev": "^2.0.0",
     "tsc-alias": "^1.2.9",
     "unstated": "^2.1.1",

+ 3 - 0
packages/app/public/static/locales/en_US/admin.json

@@ -529,6 +529,7 @@
     }
   },
   "audit_log_management": {
+    "user": "User",
     "username": "Username",
     "date": "Date",
     "action": "Action",
@@ -536,6 +537,8 @@
     "url": "URL",
     "settings": "Settings",
     "return": "Return",
+    "clear": "Clear search criteria",
+    "reload": "Reload",
     "activity_expiration_date": "Audit Log expiration date",
     "activity_expiration_date_explain": "Created Audit Log are automatically deleted after the number of seconds set in the environment variable from the creation time",
     "fixed_by_env_var": "This is fixed by the env var <code>{{key}}={{value}}</code>.",

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

@@ -1101,6 +1101,7 @@
       "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": {
+        "public": "Public",
         "isForbidden": "Authority not allowed to view",
         "currentPageGrantLabel": "Authorization for this page: ",
         "parentPageGrantLabel": "Authority of parent page: ",

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

@@ -528,6 +528,7 @@
     }
   },
   "audit_log_management": {
+    "user": "ユーザー",
     "username": "ユーザー名",
     "date": "日付",
     "action": "アクション",
@@ -535,6 +536,8 @@
     "url": "URL",
     "settings": "設定",
     "return": "戻る",
+    "clear": "検索条件のクリア",
+    "reload": "再読み込み",
     "activity_expiration_date": "監査ログの有効期限",
     "activity_expiration_date_explain": "作成された監査ログは、作成時間から環境変数に設定した秒数後に自動的に削除されます",
     "fixed_by_env_var": "環境変数により固定されています <code>{{key}}={{value}}</code>.",

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

@@ -1094,6 +1094,7 @@
       "no_grant_available": "選択可能な権限のリストが見つかりませんでした。まず親ページの権限を修正したのちに再試行してください。",
       "need_to_fix_grant": "正しく機能を使用するためにはこのページに紐づく権限を修正する必要があります。 <br> 下記の選択肢から選んで変更してください。",
       "grant_label": {
+        "public": "公開",
         "isForbidden": "権限の閲覧が許可されていません",
         "currentPageGrantLabel": "このページの権限: ",
         "parentPageGrantLabel": "親のページの権限: ",

+ 3 - 0
packages/app/public/static/locales/zh_CN/admin.json

@@ -538,6 +538,7 @@
     }
   },
   "audit_log_management": {
+    "user": "用户",
     "username": "帐号",
     "date": "日期",
     "action": "行动",
@@ -545,6 +546,8 @@
     "url": "URL",
     "settings": "设置",
     "return": "返回",
+    "clear": "清除搜索标准",
+    "reload": "重新加载",
     "activity_expiration_date": "审计日志的到期日",
     "activity_expiration_date_explain": "创建的审计日志会在环境变量中设置的从创建时间算起的秒数后自动删除",
     "fixed_by_env_var": "这是由env var 修复的 <code>{{key}}={{value}}</code>.",

+ 1 - 0
packages/app/public/static/locales/zh_CN/translation.json

@@ -1104,6 +1104,7 @@
       "no_grant_available": "无法找到可选择的权限列表。 请先修改父页的权限,然后再试一次。",
       "need_to_fix_grant": "为了正确使用该功能,需要修改与该页面相关的权限。 <br> 请从以下选项中选择进行更改。",
       "grant_label": {
+        "public": "向公众提供",
         "isForbidden": "无权查看的机构",
         "currentPageGrantLabel": "本页的权限: ",
         "parentPageGrantLabel": "父页的权限: ",

+ 0 - 7
packages/app/resource/cdn-manifests.js

@@ -147,13 +147,6 @@ module.exports = {
         integrity: '',
       },
     },
-    {
-      name: 'jquery-ui',
-      url: 'https://cdn.jsdelivr.net/jquery.ui/1.11.4/jquery-ui.min.css',
-      args: {
-        integrity: '',
-      },
-    },
     {
       name: 'highlight-theme-github',
       url: 'https://cdn.jsdelivr.net/npm/highlight.js@9.13.0/styles/github.css',

+ 3 - 0
packages/app/src/client/interfaces/clearable.ts

@@ -0,0 +1,3 @@
+export interface IClearable {
+  clear: () => void,
+}

+ 1 - 7
packages/app/src/client/services/AdminBasicSecurityContainer.js

@@ -16,15 +16,9 @@ export default class AdminBasicSecurityContainer extends Container {
   constructor() {
     super();
 
-    this.dummyIsSameUsernameTreatedAsIdenticalUser = 0;
-    this.dummyIsSameUsernameTreatedAsIdenticalUserForError = 1;
-
     this.state = {
-      retrieveError: null,
-      // set dummy value tile for using suspense
-      isSameUsernameTreatedAsIdenticalUser: this.dummyIsSameUsernameTreatedAsIdenticalUser,
+      isSameUsernameTreatedAsIdenticalUser: false,
     };
-
   }
 
   /**

+ 0 - 51
packages/app/src/client/services/AdminCustomizeContainer.js

@@ -19,8 +19,6 @@ export default class AdminCustomizeContainer extends Container {
 
     this.state = {
       retrieveError: null,
-      // set dummy value tile for using suspense
-      currentTheme: 'default',
       isEnabledTimeline: false,
       isSavedStatesOfTabChanges: false,
       isEnabledAttachTitleHeader: false,
@@ -77,7 +75,6 @@ export default class AdminCustomizeContainer extends Container {
       const { customizeParams } = response.data;
 
       this.setState({
-        currentTheme: customizeParams.themeType,
         isEnabledTimeline: customizeParams.isEnabledTimeline,
         isSavedStatesOfTabChanges: customizeParams.isSavedStatesOfTabChanges,
         isEnabledAttachTitleHeader: customizeParams.isEnabledAttachTitleHeader,
@@ -106,17 +103,6 @@ export default class AdminCustomizeContainer extends Container {
     }
   }
 
-  /**
-   * Switch themeType
-   */
-  switchThemeType(themeName) {
-    this.setState({ currentTheme: themeName });
-
-    // preview if production
-    if (process.env.NODE_ENV !== 'development') {
-      this.previewTheme(themeName);
-    }
-  }
 
   /**
    * Switch enabledTimeLine
@@ -236,24 +222,6 @@ export default class AdminCustomizeContainer extends Container {
     this.setState({ currentCustomizeScript: inpuValue });
   }
 
-  /**
-   * Preview theme
-   * @param {string} themeName
-   */
-  async previewTheme(themeName) {
-    try {
-      // get theme asset path
-      const response = await apiv3Get('/customize-setting/theme/asset-path', { themeName });
-      const { assetPath } = response.data;
-
-      const themeLink = document.getElementById('grw-theme-link');
-      themeLink.setAttribute('href', assetPath);
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
   /**
    * Preview hljs style
    * @param {string} styleId
@@ -265,25 +233,6 @@ export default class AdminCustomizeContainer extends Container {
     styleLInk.href = styleLInk.href.replace(/[^/]+\.css$/, `${styleId}.css`);
   }
 
-  /**
-   * Update theme
-   * @memberOf AdminCustomizeContainer
-   */
-  async updateCustomizeTheme() {
-    try {
-      const response = await apiv3Put('/customize-setting/theme', {
-        themeType: this.state.currentTheme,
-      });
-      const { customizedParams } = response.data;
-      this.setState({
-        themeType: customizedParams.themeType,
-      });
-    }
-    catch (err) {
-      logger.error(err);
-      throw new Error('Failed to update data');
-    }
-  }
 
   /**
    * Update function

+ 1 - 6
packages/app/src/client/services/AdminGeneralSecurityContainer.js

@@ -18,15 +18,11 @@ export default class AdminGeneralSecurityContainer extends Container {
   constructor(appContainer) {
     super();
 
-    this.dummyCurrentRestrictGuestMode = 0;
-    this.dummyCurrentRestrictGuestModeForError = 1;
-
     this.state = {
       retrieveError: null,
       sessionMaxAge: null,
       wikiMode: '',
-      // set dummy value tile for using suspense
-      currentRestrictGuestMode: this.dummyCurrentRestrictGuestMode,
+      currentRestrictGuestMode: '',
       currentPageDeletionAuthority: PageSingleDeleteConfigValue.AdminOnly,
       currentPageRecursiveDeletionAuthority: PageRecursiveDeleteConfigValue.Inherit,
       currentPageCompleteDeletionAuthority: PageSingleDeleteCompConfigValue.AdminOnly,
@@ -37,7 +33,6 @@ export default class AdminGeneralSecurityContainer extends Container {
       expandOtherOptionsForCompleteDeletion: false,
       isShowRestrictedByOwner: false,
       isShowRestrictedByGroup: false,
-      appSiteUrl: appContainer.config.crowi.url || '',
       isLocalEnabled: false,
       isLdapEnabled: false,
       isSamlEnabled: false,

+ 0 - 1
packages/app/src/client/services/AdminGitHubSecurityContainer.js

@@ -23,7 +23,6 @@ export default class AdminGitHubSecurityContainer extends Container {
 
     this.state = {
       retrieveError: null,
-      callbackUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/github/callback'),
       // set dummy value tile for using suspense
       githubClientId: this.dummyGithubClientId,
       githubClientSecret: '',

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

@@ -23,7 +23,6 @@ export default class AdminGoogleSecurityContainer extends Container {
 
     this.state = {
       retrieveError: null,
-      callbackUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/google/callback'),
       // set dummy value tile for using suspense
       googleClientId: this.dummyGoogleClientId,
       googleClientSecret: '',

+ 1 - 4
packages/app/src/client/services/AdminLdapSecurityContainer.js

@@ -17,13 +17,10 @@ export default class AdminLdapSecurityContainer extends Container {
     super();
 
     this.appContainer = appContainer;
-    this.dummyServerUrl = 0;
-    this.dummyServerUrlForError = 1;
 
     this.state = {
       retrieveError: null,
-      // set dummy value tile for using suspense
-      serverUrl: this.dummyServerUrl,
+      serverUrl: '',
       isUserBind: false,
       ldapBindDN: '',
       ldapBindDNPassword: '',

+ 1 - 5
packages/app/src/client/services/AdminOidcSecurityContainer.js

@@ -19,14 +19,10 @@ export default class AdminOidcSecurityContainer extends Container {
     super();
 
     this.appContainer = appContainer;
-    this.dummyOidcProviderName = 0;
-    this.dummyOidcProviderNameForError = 1;
 
     this.state = {
       retrieveError: null,
-      callbackUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/oidc/callback'),
-      // set dummy value tile for using suspense
-      oidcProviderName: this.dummyOidcProviderName,
+      oidcProviderName: '',
       oidcIssuerHost: '',
       oidcAuthorizationEndpoint: '',
       oidcTokenEndpoint: '',

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

@@ -19,17 +19,13 @@ export default class AdminSamlSecurityContainer extends Container {
     super();
 
     this.appContainer = appContainer;
-    this.dummySamlEntryPoint = 0;
-    this.dummySamlEntryPointForError = 1;
 
     this.state = {
       retrieveError: null,
       // TODO GW-1324 ABLCRure DB value takes precedence
       useOnlyEnvVars: false,
-      callbackUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/saml/callback'),
       missingMandatoryConfigKeys: [],
-      // set dummy value tile for using suspense
-      samlEntryPoint: this.dummySamlEntryPoint,
+      samlEntryPoint: '',
       samlIssuer: '',
       samlCert: '',
       samlAttrMapId: '',

+ 1 - 5
packages/app/src/client/services/AdminTwitterSecurityContainer.js

@@ -19,13 +19,9 @@ export default class AdminTwitterSecurityContainer extends Container {
     super();
 
     this.appContainer = appContainer;
-    this.dummyTwitterConsumerKey = 0;
-    this.dummyTwitterConsumerKeyForError = 1;
 
     this.state = {
-      callbackUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/twitter/callback'),
-      // set dummy value tile for using suspense
-      twitterConsumerKey: this.dummyTwitterConsumerKey,
+      twitterConsumerKey: '',
       twitterConsumerSecret: '',
       isSameUsernameTreatedAsIdenticalUser: false,
     };

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

@@ -13,7 +13,7 @@ const apiv3Root = '/_api/v3';
 const logger = loggerFactory('growi:apiv3');
 
 
-const apiv3ErrorHandler = (_err) => {
+const apiv3ErrorHandler = (_err: any): any[] => {
   // extract api errors from general 400 err
   const err = _err.response ? _err.response.data.errors : _err;
   const errs = toArrayIfNot(err);

+ 1 - 1
packages/app/src/client/util/i18n.js

@@ -17,7 +17,7 @@ Object.values(locales).forEach((locale) => {
 });
 
 /*
-* Note: This file will be deleted. use "~/next-i18next.config" instead
+* Note: This file will be deleted. use "^/config/next-i18next.config" instead
 */
 // extract metadata list from 'public/static/locales/${locale}/meta.json'
 export const localeMetadatas = Object.values(locales).map(locale => locale.meta);

+ 2 - 1
packages/app/src/components/Admin/App/AppSetting.jsx

@@ -3,9 +3,10 @@ import React, { useCallback } from 'react';
 import { useTranslation, i18n } from 'next-i18next';
 import PropTypes from 'prop-types';
 
+import { i18n as i18nConfig } from '^/config/next-i18next.config';
+
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { i18n as i18nConfig } from '~/next-i18next.config';
 import loggerFactory from '~/utils/logger';
 
 

+ 11 - 2
packages/app/src/components/Admin/AuditLog/ActivityTable.tsx

@@ -1,5 +1,7 @@
 import React, { FC } from 'react';
 
+import { pagePathUtils } from '@growi/core';
+import { UserPicture } from '@growi/ui';
 import { format } from 'date-fns';
 import { useTranslation } from 'react-i18next';
 
@@ -21,7 +23,7 @@ export const ActivityTable : FC<Props> = (props: Props) => {
       <table className="table table-default table-bordered table-user-list">
         <thead>
           <tr>
-            <th scope="col">{t('admin:audit_log_management.username')}</th>
+            <th scope="col">{t('admin:audit_log_management.user')}</th>
             <th scope="col">{t('admin:audit_log_management.date')}</th>
             <th scope="col">{t('admin:audit_log_management.action')}</th>
             <th scope="col">{t('admin:audit_log_management.ip')}</th>
@@ -32,7 +34,14 @@ export const ActivityTable : FC<Props> = (props: Props) => {
           {props.activityList.map((activity) => {
             return (
               <tr data-testid="activity-table" key={activity._id}>
-                <td>{activity.snapshot?.username}</td>
+                <td>
+                  { activity.user != null && (
+                    <>
+                      <UserPicture user={activity.user} className="picture rounded-circle" />
+                      <a className="ml-2" href={pagePathUtils.userPageRoot(activity.user)}>{activity.snapshot?.username}</a>
+                    </>
+                  )}
+                </td>
                 <td>{formatDate(activity.createdAt)}</td>
                 <td>{t(`admin:audit_log_action.${activity.action}`)}</td>
                 <td>{activity.ip}</td>

+ 18 - 3
packages/app/src/components/Admin/AuditLog/SearchUsernameTypeahead.tsx

@@ -1,10 +1,11 @@
 import React, {
-  FC, Fragment, useState, useCallback,
+  Fragment, useState, useCallback, useRef, ForwardRefRenderFunction, forwardRef, useImperativeHandle,
 } from 'react';
 
 import { AsyncTypeahead, Menu, MenuItem } from 'react-bootstrap-typeahead';
 import { useTranslation } from 'react-i18next';
 
+import { IClearable } from '~/client/interfaces/clearable';
 import { useSWRxUsernames } from '~/stores/user';
 
 
@@ -25,10 +26,12 @@ type Props = {
   onChange: (text: string[]) => void
 }
 
-export const SearchUsernameTypeahead: FC<Props> = (props: Props) => {
+const SearchUsernameTypeaheadSubstance: ForwardRefRenderFunction<IClearable, Props> = ((props: Props, ref) => {
   const { onChange } = props;
   const { t } = useTranslation();
 
+  const typeaheadRef = useRef<IClearable>(null);
+
   /*
    * State
    */
@@ -96,6 +99,15 @@ export const SearchUsernameTypeahead: FC<Props> = (props: Props) => {
     );
   }, []);
 
+  useImperativeHandle(ref, () => ({
+    clear() {
+      const instance = typeaheadRef?.current;
+      if (instance != null) {
+        instance.clear();
+      }
+    },
+  }));
+
   return (
     <div className="input-group mr-2">
       <div className="input-group-prepend">
@@ -104,6 +116,7 @@ export const SearchUsernameTypeahead: FC<Props> = (props: Props) => {
         </span>
       </div>
       <AsyncTypeahead
+        ref={typeaheadRef}
         id="search-username-typeahead-asynctypeahead"
         multiple
         delay={400}
@@ -119,4 +132,6 @@ export const SearchUsernameTypeahead: FC<Props> = (props: Props) => {
       />
     </div>
   );
-};
+});
+
+export const SearchUsernameTypeahead = forwardRef(SearchUsernameTypeaheadSubstance);

+ 32 - 8
packages/app/src/components/Admin/AuditLogManagement.tsx

@@ -1,8 +1,11 @@
-import React, { FC, useState, useCallback } from 'react';
+import React, {
+  FC, useState, useCallback, useRef,
+} from 'react';
 
 import { format } from 'date-fns';
 import { useTranslation } from 'react-i18next';
 
+import { IClearable } from '~/client/interfaces/clearable';
 import { toastError } from '~/client/util/apiNotification';
 import { SupportedActionType } from '~/interfaces/activity';
 import { useSWRxActivity } from '~/stores/activity';
@@ -17,7 +20,6 @@ import { DateRangePicker } from './AuditLog/DateRangePicker';
 import { SearchUsernameTypeahead } from './AuditLog/SearchUsernameTypeahead';
 import { SelectActionDropdown } from './AuditLog/SelectActionDropdown';
 
-
 const formatDate = (date: Date | null) => {
   if (date == null) {
     return '';
@@ -30,8 +32,9 @@ const PAGING_LIMIT = 10;
 export const AuditLogManagement: FC = () => {
   const { t } = useTranslation();
 
+  const typeaheadRef = useRef<IClearable>(null);
+
   const { data: auditLogAvailableActionsData } = useAuditLogAvailableActions();
-  const auditLogAvailableActions = auditLogAvailableActionsData != null ? auditLogAvailableActionsData : [];
 
   /*
    * State
@@ -43,7 +46,7 @@ export const AuditLogManagement: FC = () => {
   const [endDate, setEndDate] = useState<Date | null>(null);
   const [selectedUsernames, setSelectedUsernames] = useState<string[]>([]);
   const [actionMap, setActionMap] = useState(
-    new Map<SupportedActionType, boolean>(auditLogAvailableActions.map(action => [action, true])),
+    new Map<SupportedActionType, boolean>(auditLogAvailableActionsData != null ? auditLogAvailableActionsData.map(action => [action, true]) : []),
   );
 
   /*
@@ -94,6 +97,18 @@ export const AuditLogManagement: FC = () => {
     setSelectedUsernames(usernames);
   }, []);
 
+  const clearButtonPushedHandler = useCallback(() => {
+    setActivePage(1);
+    setStartDate(null);
+    setEndDate(null);
+    setSelectedUsernames([]);
+    typeaheadRef.current?.clear();
+
+    if (auditLogAvailableActionsData != null) {
+      setActionMap(new Map<SupportedActionType, boolean>(auditLogAvailableActionsData.map(action => [action, true])));
+    }
+  }, [setActivePage, setStartDate, setEndDate, setSelectedUsernames, setActionMap, auditLogAvailableActionsData]);
+
   const reloadButtonPushedHandler = useCallback(() => {
     setActivePage(1);
     mutateActivity();
@@ -128,6 +143,7 @@ export const AuditLogManagement: FC = () => {
         <>
           <div className="form-inline mb-3">
             <SearchUsernameTypeahead
+              ref={typeaheadRef}
               onChange={setUsernamesHandler}
             />
 
@@ -139,14 +155,22 @@ export const AuditLogManagement: FC = () => {
 
             <SelectActionDropdown
               actionMap={actionMap}
-              availableActions={auditLogAvailableActions}
+              availableActions={auditLogAvailableActionsData || []}
               onChangeAction={actionCheckboxChangedHandler}
               onChangeMultipleAction={multipleActionCheckboxChangedHandler}
             />
 
-            <button type="button" className="btn ml-auto grw-btn-reload" onClick={reloadButtonPushedHandler}>
-              <i className="icon icon-reload" />
-            </button>
+            <div className="ml-auto">
+              <button type="button" className="btn btn-outline-secondary btn-sm mr-2" onClick={clearButtonPushedHandler}>
+                <span className="icon-refresh mr-1" />
+                {t('admin:audit_log_management.clear')}
+              </button>
+
+              <button type="button" className="btn btn-outline-secondary btn-sm" onClick={reloadButtonPushedHandler}>
+                <i className="icon icon-reload mr-1" />
+                {t('admin:audit_log_management.reload')}
+              </button>
+            </div>
           </div>
 
           <p

+ 8 - 10
packages/app/src/components/Admin/Customize/Customize.jsx

@@ -1,5 +1,5 @@
 
-import React, { useEffect } from 'react';
+import React, { useEffect, useCallback } from 'react';
 
 import PropTypes from 'prop-types';
 
@@ -26,13 +26,9 @@ const logger = loggerFactory('growi:services:AdminCustomizePage');
 function Customize(props) {
   const { adminCustomizeContainer } = props;
 
-  useEffect(() => {
-    async function fetchCustomizeSettingsData() {
-      await adminCustomizeContainer.retrieveCustomizeData();
-    }
-
+  const fetchCustomizeSettingsData = useCallback(async() => {
     try {
-      fetchCustomizeSettingsData();
+      await adminCustomizeContainer.retrieveCustomizeData();
     }
     catch (err) {
       const errs = toArrayIfNot(err);
@@ -41,6 +37,10 @@ function Customize(props) {
     }
   }, [adminCustomizeContainer]);
 
+  useEffect(() => {
+    fetchCustomizeSettingsData();
+  }, [fetchCustomizeSettingsData]);
+
 
   return (
     <div data-testid="admin-customize">
@@ -51,9 +51,7 @@ function Customize(props) {
         <CustomizeThemeSetting />
       </div>
       <div className="mb-5">
-        {/* TODO: [resolve browser err] A component is changing an uncontrolled input to be controlled. by https://redmine.weseek.co.jp/issues/101155
-          <CustomizeSidebarSetting />
-        */}
+        <CustomizeSidebarSetting />
       </div>
       <div className="mb-5">
         <CustomizeFunctionSetting />

+ 1 - 1
packages/app/src/components/Admin/Customize/CustomizeLayoutSetting.tsx

@@ -12,7 +12,7 @@ const CustomizeLayoutSetting = (): JSX.Element => {
   const { resolvedTheme } = useNextThemes();
 
   const [isContainerFluid, setIsContainerFluid] = useState(false);
-  const [retrieveError, setRetrieveError] = useState();
+  const [retrieveError, setRetrieveError] = useState<any>();
 
   const retrieveData = useCallback(async() => {
     try {

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

@@ -19,7 +19,7 @@ const CustomizeLogoSetting = (): JSX.Element => {
   const [uploadLogoSrc, setUploadLogoSrc] = useState<ArrayBuffer | string | null>(null);
   const [isImageCropModalShow, setIsImageCropModalShow] = useState<boolean>(false);
   const [isDefaultLogo, setIsDefaultLogo] = useState<boolean>(true);
-  const [retrieveError, setRetrieveError] = useState<string | null>(null);
+  const [retrieveError, setRetrieveError] = useState<any>();
   const [customizedLogoSrc, setCustomizedLogoSrc] = useState< string | null >(null);
 
   const retrieveData = useCallback(async() => {

+ 2 - 2
packages/app/src/components/Admin/Customize/CustomizeSidebarSetting.tsx

@@ -78,7 +78,7 @@ const CustomizeSidebarsetting = (): JSX.Element => {
                 id="is-open"
                 className="custom-control-input"
                 name="mailVisibility"
-                checked={!isSidebarDrawerMode && !isSidebarClosedAtDockMode}
+                checked={isSidebarDrawerMode === false && isSidebarClosedAtDockMode === false}
                 disabled={isSidebarDrawerMode}
                 onChange={() => setIsSidebarClosedAtDockMode(false)}
               />
@@ -92,7 +92,7 @@ const CustomizeSidebarsetting = (): JSX.Element => {
                 id="is-closed"
                 className="custom-control-input"
                 name="mailVisibility"
-                checked={!isSidebarDrawerMode && isSidebarClosedAtDockMode}
+                checked={isSidebarDrawerMode === false && isSidebarClosedAtDockMode === true}
                 disabled={isSidebarDrawerMode}
                 onChange={() => setIsSidebarClosedAtDockMode(true)}
               />

+ 8 - 6
packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx

@@ -6,7 +6,6 @@ import PropTypes from 'prop-types';
 
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import { GrowiThemes } from '~/interfaces/theme';
-import { useGrowiTheme } from '~/stores/context';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -50,10 +49,11 @@ const uniqueTheme = [{
 
 const CustomizeThemeOptions = (props) => {
 
-  const { adminCustomizeContainer } = props;
+  const { adminCustomizeContainer, currentTheme } = props;
+  const { currentLayout } = adminCustomizeContainer.state;
+
   const { t } = useTranslation();
-  const { mutate: mutateGrowiTheme } = useGrowiTheme();
-  const { currentLayout, currentTheme } = adminCustomizeContainer.state;
+
 
   return (
     <div id="themeOptions" className={`${currentLayout === 'kibela' && 'disabled'}`}>
@@ -66,7 +66,7 @@ const CustomizeThemeOptions = (props) => {
               <ThemeColorBox
                 key={theme.name}
                 isSelected={currentTheme === theme.name}
-                onSelected={() => mutateGrowiTheme(theme.name)}
+                onSelected={() => props.onSelected(theme.name)}
                 {...theme}
               />
             );
@@ -82,7 +82,7 @@ const CustomizeThemeOptions = (props) => {
               <ThemeColorBox
                 key={theme.name}
                 isSelected={currentTheme === theme.name}
-                onSelected={() => mutateGrowiTheme(theme.name)}
+                onSelected={() => props.onSelected(theme.name)}
                 {...theme}
               />
             );
@@ -98,6 +98,8 @@ const CustomizeThemeOptionsWrapper = withUnstatedContainers(CustomizeThemeOption
 
 CustomizeThemeOptions.propTypes = {
   adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+  onSelected: PropTypes.func,
+  currentTheme: PropTypes.string,
 };
 
 export default CustomizeThemeOptionsWrapper;

+ 21 - 10
packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx

@@ -4,6 +4,9 @@ import { useTranslation } from 'next-i18next';
 
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { useGrowiTheme } from '~/stores/context';
+
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
@@ -17,28 +20,36 @@ type Props = {
 const CustomizeThemeSetting = (props: Props): JSX.Element => {
 
   const { adminCustomizeContainer } = props;
+  const { data: currentTheme, mutate: mutateGrowiTheme } = useGrowiTheme();
   const { t } = useTranslation();
 
+  const selectedHandler = useCallback((themeName) => {
+    mutateGrowiTheme(themeName);
+  }, [mutateGrowiTheme]);
+
   const submitHandler = useCallback(async() => {
     try {
-      await adminCustomizeContainer.updateCustomizeTheme();
+      if (currentTheme != null) {
+        await apiv3Put('/customize-setting/theme', {
+          themeType: currentTheme,
+        });
+      }
+
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.theme') }));
     }
     catch (err) {
       toastError(err);
     }
-  }, [t, adminCustomizeContainer]);
+  }, [currentTheme, t]);
 
   return (
-    <React.Fragment>
-      <div className="row">
-        <div className="col-12">
-          <h2 className="admin-setting-header">{t('admin:customize_setting.theme')}</h2>
-          <CustomizeThemeOptions />
-          <AdminUpdateButtonRow onClick={submitHandler} disabled={adminCustomizeContainer.state.retrieveError != null} />
-        </div>
+    <div className="row">
+      <div className="col-12">
+        <h2 className="admin-setting-header">{t('admin:customize_setting.theme')}</h2>
+        <CustomizeThemeOptions onSelected={selectedHandler} currentTheme={currentTheme} />
+        <AdminUpdateButtonRow onClick={submitHandler} disabled={adminCustomizeContainer.state.retrieveError != null} />
       </div>
-    </React.Fragment>
+    </div>
   );
 };
 

+ 6 - 4
packages/app/src/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx

@@ -44,13 +44,15 @@ const ElasticsearchManagement = () => {
       setAliasesData(info.aliases);
       setIsNormalized(info.isNormalized);
     }
-    catch (errors) {
+    catch (errors: unknown) {
       setIsConnected(false);
 
       // evaluate whether configured or not
-      for (const error of errors) {
-        if (error.code === 'search-service-unconfigured') {
-          setIsConfigured(false);
+      if (Array.isArray(errors)) {
+        for (const error of errors) {
+          if (error.code === 'search-service-unconfigured') {
+            setIsConfigured(false);
+          }
         }
       }
 

+ 18 - 27
packages/app/src/components/Admin/Security/BasicSecuritySetting.jsx

@@ -1,5 +1,4 @@
-/* eslint-disable react/no-danger */
-import React from 'react';
+import React, { useEffect, useCallback } from 'react';
 
 import PropTypes from 'prop-types';
 
@@ -11,34 +10,26 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import BasicSecurityManagementContents from './BasicSecuritySettingContents';
 
-let retrieveErrors = null;
-function BasicSecurityManagement(props) {
+const BasicSecurityManagement = (props) => {
   const { adminBasicSecurityContainer } = props;
-  if (adminBasicSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser === adminBasicSecurityContainer.dummyIsSameUsernameTreatedAsIdenticalUser) {
-    throw (async() => {
-      try {
-        await adminBasicSecurityContainer.retrieveSecurityData();
-      }
-      catch (err) {
-        const errs = toArrayIfNot(err);
-        toastError(errs);
-        retrieveErrors = errs;
-        adminBasicSecurityContainer.setState({
-          isSameUsernameTreatedAsIdenticalUser: adminBasicSecurityContainer.dummyIsSameUsernameTreatedAsIdenticalUser,
-        });
-
-      }
-    })();
-  }
-
-  if (
-    adminBasicSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser === adminBasicSecurityContainer.dummyIsSameUsernameTreatedAsIdenticalUserForError
-  ) {
-    throw new Error(`${retrieveErrors.length} errors occured`);
-  }
+
+  const fetchBasicSecuritySettingsData = useCallback(async() => {
+    try {
+      await adminBasicSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      const errs = toArrayIfNot(err);
+      toastError(errs);
+    }
+  }, [adminBasicSecurityContainer]);
+
+  useEffect(() => {
+    fetchBasicSecuritySettingsData();
+  }, [adminBasicSecurityContainer, fetchBasicSecuritySettingsData]);
+
 
   return <BasicSecurityManagementContents />;
-}
+};
 
 BasicSecurityManagement.propTypes = {
   adminBasicSecurityContainer: PropTypes.instanceOf(AdminBasicSecurityContainer).isRequired,

+ 2 - 5
packages/app/src/components/Admin/Security/FacebookSecuritySetting.jsx

@@ -1,4 +1,3 @@
-/* eslint-disable react/no-danger */
 import React from 'react';
 
 import { withTranslation } from 'next-i18next';
@@ -14,15 +13,13 @@ class FacebookSecurityManagement extends React.Component {
   render() {
     const { t } = this.props;
     return (
-      <React.Fragment>
-
+      <>
         <h2 className="alert-anchor border-bottom">
           Facebook OAuth { t('security_setting.configuration') }
         </h2>
 
         <p className="well">(TBD)</p>
-
-      </React.Fragment>
+      </>
     );
   }
 

+ 17 - 22
packages/app/src/components/Admin/Security/GitHubSecuritySetting.jsx

@@ -1,5 +1,4 @@
-/* eslint-disable react/no-danger */
-import React from 'react';
+import React, { useEffect, useCallback } from 'react';
 
 import PropTypes from 'prop-types';
 
@@ -12,29 +11,25 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import GitHubSecuritySettingContents from './GitHubSecuritySettingContents';
 
-let retrieveErrors = null;
-function GitHubSecurityManagement(props) {
+const GitHubSecurityManagement = (props) => {
   const { adminGitHubSecurityContainer } = props;
-  if (adminGitHubSecurityContainer.state.githubClientId === adminGitHubSecurityContainer.dummyGithubClientId) {
-    throw (async() => {
-      try {
-        await adminGitHubSecurityContainer.retrieveSecurityData();
-      }
-      catch (err) {
-        const errs = toArrayIfNot(err);
-        toastError(errs);
-        retrieveErrors = errs;
-        adminGitHubSecurityContainer.setState({ githubClientId: adminGitHubSecurityContainer.dummyGithubClientIdForError });
-      }
-    })();
-  }
-
-  if (adminGitHubSecurityContainer.state.githubClientId === adminGitHubSecurityContainer.dummyGithubClientIdForError) {
-    throw new Error(`${retrieveErrors.length} errors occured`);
-  }
+
+  const fetchGitHubSecuritySettingsData = useCallback(async() => {
+    try {
+      await adminGitHubSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      const errs = toArrayIfNot(err);
+      toastError(errs);
+    }
+  }, [adminGitHubSecurityContainer]);
+
+  useEffect(() => {
+    fetchGitHubSecuritySettingsData();
+  }, [adminGitHubSecurityContainer, fetchGitHubSecuritySettingsData]);
 
   return <GitHubSecuritySettingContents />;
-}
+};
 
 
 GitHubSecurityManagement.propTypes = {

+ 13 - 6
packages/app/src/components/Admin/Security/GitHubSecuritySettingContents.jsx

@@ -1,13 +1,16 @@
 /* eslint-disable react/no-danger */
 import React from 'react';
 
-import PropTypes from 'prop-types';
+import { pathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
+import urljoin from 'url-join';
 
 
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { useSiteUrl } from '~/stores/context';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -33,8 +36,11 @@ class GitHubSecurityManagementContents extends React.Component {
   }
 
   render() {
-    const { t, adminGeneralSecurityContainer, adminGitHubSecurityContainer } = this.props;
+    const {
+      t, adminGeneralSecurityContainer, adminGitHubSecurityContainer, siteUrl,
+    } = this.props;
     const { isGitHubEnabled } = adminGeneralSecurityContainer.state;
+    const gitHubCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/github/callback');
 
     return (
 
@@ -75,11 +81,11 @@ class GitHubSecurityManagementContents extends React.Component {
             <input
               className="form-control"
               type="text"
-              value={adminGitHubSecurityContainer.state.appSiteUrl}
+              value={gitHubCallbackUrl}
               readOnly
             />
             <p className="form-text text-muted small">{t('security_setting.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
-            {!adminGeneralSecurityContainer.state.appSiteUrl && (
+            {(siteUrl == null || siteUrl === '') && (
               <div className="alert alert-danger">
                 <i
                   className="icon-exclamation"
@@ -172,7 +178,7 @@ class GitHubSecurityManagementContents extends React.Component {
           <ol id="collapseHelpForGitHubOauth" className="collapse">
             {/* eslint-disable-next-line max-len */}
             <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.GitHub.register_1', { link: '<a href="https://github.com/settings/developers" target=_blank>GitHub Developer Settings</a>' }) }} />
-            <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.GitHub.register_2', { url: adminGitHubSecurityContainer.state.callbackUrl }) }} />
+            <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.GitHub.register_2', { url: gitHubCallbackUrl }) }} />
             <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.GitHub.register_3') }} />
           </ol>
         </div>
@@ -187,7 +193,8 @@ class GitHubSecurityManagementContents extends React.Component {
 
 const GitHubSecurityManagementContentsFC = (props) => {
   const { t } = useTranslation();
-  return <GitHubSecurityManagementContents t={t} {...props} />;
+  const { data: siteUrl } = useSiteUrl();
+  return <GitHubSecurityManagementContents t={t} siteUrl={siteUrl} {...props} />;
 };
 
 /**

+ 18 - 22
packages/app/src/components/Admin/Security/GoogleSecuritySetting.jsx

@@ -1,5 +1,4 @@
-/* eslint-disable react/no-danger */
-import React from 'react';
+import React, { useEffect, useCallback } from 'react';
 
 import PropTypes from 'prop-types';
 
@@ -11,29 +10,26 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import GoogleSecurityManagementContents from './GoogleSecuritySettingContents';
 
-let retrieveErrors = null;
-function GoogleSecurityManagement(props) {
+const GoogleSecurityManagement = (props) => {
   const { adminGoogleSecurityContainer } = props;
-  if (adminGoogleSecurityContainer.state.googleClientId === adminGoogleSecurityContainer.dummyGoogleClientId) {
-    throw (async() => {
-      try {
-        await adminGoogleSecurityContainer.retrieveSecurityData();
-      }
-      catch (err) {
-        const errs = toArrayIfNot(err);
-        toastError(errs);
-        retrieveErrors = errs;
-        adminGoogleSecurityContainer.setState({ googleClientId: adminGoogleSecurityContainer.dummyGoogleClientIdForError });
-      }
-    })();
-  }
-
-  if (adminGoogleSecurityContainer.state.googleClientId === adminGoogleSecurityContainer.dummyGoogleClientIdForError) {
-    throw new Error(`${retrieveErrors.length} errors occured`);
-  }
+
+  const fetchGoogleSecuritySettingsData = useCallback(async() => {
+    try {
+      await adminGoogleSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      const errs = toArrayIfNot(err);
+      toastError(errs);
+    }
+  }, [adminGoogleSecurityContainer]);
+
+
+  useEffect(() => {
+    fetchGoogleSecuritySettingsData();
+  }, [adminGoogleSecurityContainer, fetchGoogleSecuritySettingsData]);
 
   return <GoogleSecurityManagementContents />;
-}
+};
 
 
 GoogleSecurityManagement.propTypes = {

+ 14 - 8
packages/app/src/components/Admin/Security/GoogleSecuritySettingContents.jsx

@@ -1,13 +1,14 @@
-/* eslint-disable react/no-danger */
 import React from 'react';
 
-import PropTypes from 'prop-types';
+import { pathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
-
+import PropTypes from 'prop-types';
+import urljoin from 'url-join';
 
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { useSiteUrl } from '~/stores/context';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -33,8 +34,11 @@ class GoogleSecurityManagementContents extends React.Component {
   }
 
   render() {
-    const { t, adminGeneralSecurityContainer, adminGoogleSecurityContainer } = this.props;
+    const {
+      t, adminGeneralSecurityContainer, adminGoogleSecurityContainer, siteUrl,
+    } = this.props;
     const { isGoogleEnabled } = adminGeneralSecurityContainer.state;
+    const googleCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/google/callback');
 
     return (
 
@@ -75,11 +79,11 @@ class GoogleSecurityManagementContents extends React.Component {
             <input
               className="form-control"
               type="text"
-              value={adminGoogleSecurityContainer.state.callbackUrl}
+              value={googleCallbackUrl}
               readOnly
             />
             <p className="form-text text-muted small">{t('security_setting.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
-            {!adminGeneralSecurityContainer.state.appSiteUrl && (
+            {(siteUrl == null || siteUrl === '') && (
               <div className="alert alert-danger">
                 <i
                   className="icon-exclamation"
@@ -179,7 +183,7 @@ class GoogleSecurityManagementContents extends React.Component {
             <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.Google.register_1', { link: '<a href="https://console.cloud.google.com/apis/credentials" target=_blank>Google Cloud Platform API Manager</a>' }) }} />
             <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.Google.register_2') }} />
             <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.Google.register_3') }} />
-            <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.Google.register_4', { url: adminGoogleSecurityContainer.state.callbackUrl }) }} />
+            <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.Google.register_4', { url: googleCallbackUrl }) }} />
             <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.Google.register_5') }} />
           </ol>
         </div>
@@ -194,7 +198,8 @@ class GoogleSecurityManagementContents extends React.Component {
 
 const GoogleSecurityManagementContentsFc = (props) => {
   const { t } = useTranslation();
-  return <GoogleSecurityManagementContents t={t} {...props} />;
+  const { data: siteUrl } = useSiteUrl();
+  return <GoogleSecurityManagementContents t={t} siteUrl={siteUrl} {...props} />;
 };
 
 
@@ -202,6 +207,7 @@ GoogleSecurityManagementContents.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
   adminGoogleSecurityContainer: PropTypes.instanceOf(AdminGoogleSecurityContainer).isRequired,
+  siteUrl: PropTypes.string,
 };
 
 const GoogleSecurityManagementContentsWrapper = withUnstatedContainers(GoogleSecurityManagementContentsFc, [

+ 17 - 21
packages/app/src/components/Admin/Security/LdapSecuritySetting.jsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useEffect, useCallback } from 'react';
 
 import PropTypes from 'prop-types';
 
@@ -10,29 +10,25 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import LdapSecuritySettingContents from './LdapSecuritySettingContents';
 
-let retrieveErrors = null;
-function LdapSecuritySetting(props) {
+const LdapSecuritySetting = (props) => {
   const { adminLdapSecurityContainer } = props;
-  if (adminLdapSecurityContainer.state.serverUrl === adminLdapSecurityContainer.dummyServerUrl) {
-    throw (async() => {
-      try {
-        await adminLdapSecurityContainer.retrieveSecurityData();
-      }
-      catch (err) {
-        const errs = toArrayIfNot(err);
-        toastError(errs);
-        retrieveErrors = errs;
-        adminLdapSecurityContainer.setState({ serverUrl: adminLdapSecurityContainer.dummyServerUrlForError });
-      }
-    })();
-  }
-
-  if (adminLdapSecurityContainer.state.serverUrl === adminLdapSecurityContainer.dummyServerUrlForError) {
-    throw new Error(`${retrieveErrors.length} errors occured`);
-  }
+
+  const fetchLdapSecuritySettingsData = useCallback(async() => {
+    try {
+      await adminLdapSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      const errs = toArrayIfNot(err);
+      toastError(errs);
+    }
+  }, [adminLdapSecurityContainer]);
+
+  useEffect(() => {
+    fetchLdapSecuritySettingsData();
+  }, [adminLdapSecurityContainer, fetchLdapSecuritySettingsData]);
 
   return <LdapSecuritySettingContents />;
-}
+};
 
 LdapSecuritySetting.propTypes = {
   adminLdapSecurityContainer: PropTypes.instanceOf(AdminLdapSecurityContainer).isRequired,

+ 18 - 22
packages/app/src/components/Admin/Security/LocalSecuritySetting.jsx

@@ -1,5 +1,4 @@
-/* eslint-disable react/no-danger */
-import React from 'react';
+import React, { useEffect, useCallback } from 'react';
 
 import PropTypes from 'prop-types';
 
@@ -11,29 +10,26 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import LocalSecuritySettingContents from './LocalSecuritySettingContents';
 
-let retrieveErrors = null;
-function LocalSecuritySetting(props) {
+const LocalSecuritySetting = (props) => {
   const { adminLocalSecurityContainer } = props;
-  if (adminLocalSecurityContainer.state.registrationMode === adminLocalSecurityContainer.dummyRegistrationMode) {
-    throw (async() => {
-      try {
-        await adminLocalSecurityContainer.retrieveSecurityData();
-      }
-      catch (err) {
-        const errs = toArrayIfNot(err);
-        toastError(errs);
-        retrieveErrors = errs;
-        adminLocalSecurityContainer.setState({ registrationMode: adminLocalSecurityContainer.dummyRegistrationModeForError });
-      }
-    })();
-  }
-
-  if (adminLocalSecurityContainer.state.registrationMode === adminLocalSecurityContainer.dummyRegistrationModeForError) {
-    throw new Error(`${retrieveErrors.length} errors occured`);
-  }
+
+  const fetchLocalSecuritySettingsData = useCallback(async() => {
+    try {
+      await adminLocalSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      const errs = toArrayIfNot(err);
+      toastError(errs);
+    }
+  }, [adminLocalSecurityContainer]);
+
+
+  useEffect(() => {
+    fetchLocalSecuritySettingsData();
+  }, [adminLocalSecurityContainer, fetchLocalSecuritySettingsData]);
 
   return <LocalSecuritySettingContents />;
-}
+};
 
 LocalSecuritySetting.propTypes = {
   adminLocalSecurityContainer: PropTypes.instanceOf(AdminLocalSecurityContainer).isRequired,

+ 9 - 12
packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx

@@ -1,14 +1,13 @@
-/* eslint-disable react/no-danger */
 import React from 'react';
 
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 
 
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer';
-import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { useIsMailerSetup } from '~/stores/context';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -37,14 +36,13 @@ class LocalSecuritySettingContents extends React.Component {
       t,
       adminGeneralSecurityContainer,
       adminLocalSecurityContainer,
-      appContainer,
+      isMailerSetup,
     } = this.props;
     const { registrationMode, isPasswordResetEnabled, isEmailAuthenticationEnabled } = adminLocalSecurityContainer.state;
     const { isLocalEnabled } = adminGeneralSecurityContainer.state;
-    const { isMailerSetup } = appContainer.config;
 
     return (
-      <React.Fragment>
+      <>
         {adminLocalSecurityContainer.state.retrieveError != null && (
           <div className="alert alert-danger">
             <p>
@@ -97,7 +95,7 @@ class LocalSecuritySettingContents extends React.Component {
         </div>
 
         {isLocalEnabled && (
-          <React.Fragment>
+          <>
             <h3 className="border-bottom">{t('security_setting.configuration')}</h3>
 
             <div className="row">
@@ -236,9 +234,9 @@ class LocalSecuritySettingContents extends React.Component {
                 </button>
               </div>
             </div>
-          </React.Fragment>
+          </>
         )}
-      </React.Fragment>
+      </>
     );
   }
 
@@ -246,18 +244,17 @@ class LocalSecuritySettingContents extends React.Component {
 
 LocalSecuritySettingContents.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
   adminLocalSecurityContainer: PropTypes.instanceOf(AdminLocalSecurityContainer).isRequired,
 };
 
 const LocalSecuritySettingContentsWrapperFC = (props) => {
   const { t } = useTranslation();
-  return <LocalSecuritySettingContents t={t} {...props} />;
+  const { data: isMailerSetup } = useIsMailerSetup();
+  return <LocalSecuritySettingContents t={t} {...props} isMailerSetup={isMailerSetup ?? false} />;
 };
 
 const LocalSecuritySettingContentsWrapper = withUnstatedContainers(LocalSecuritySettingContentsWrapperFC, [
-  AppContainer,
   AdminGeneralSecurityContainer,
   AdminLocalSecurityContainer,
 ]);

+ 17 - 22
packages/app/src/components/Admin/Security/OidcSecuritySetting.jsx

@@ -1,5 +1,4 @@
-/* eslint-disable react/no-danger */
-import React from 'react';
+import React, { useEffect, useCallback } from 'react';
 
 import PropTypes from 'prop-types';
 
@@ -11,29 +10,25 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import OidcSecurityManagementContents from './OidcSecuritySettingContents';
 
-let retrieveErrors = null;
-function OidcSecurityManagement(props) {
+const OidcSecurityManagement = (props) => {
   const { adminOidcSecurityContainer } = props;
-  if (adminOidcSecurityContainer.state.oidcProviderName === adminOidcSecurityContainer.dummyOidcProviderName) {
-    throw (async() => {
-      try {
-        await adminOidcSecurityContainer.retrieveSecurityData();
-      }
-      catch (err) {
-        const errs = toArrayIfNot(err);
-        toastError(errs);
-        retrieveErrors = errs;
-        adminOidcSecurityContainer.setState({ oidcProviderName: adminOidcSecurityContainer.dummyOidcProviderNameForError });
-      }
-    })();
-  }
-
-  if (adminOidcSecurityContainer.state.oidcProviderName === adminOidcSecurityContainer.dummyOidcProviderNameForError) {
-    throw new Error(`${retrieveErrors.length} errors occured`);
-  }
+
+  const fetchOidcSecuritySettingsData = useCallback(async() => {
+    try {
+      await adminOidcSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      const errs = toArrayIfNot(err);
+      toastError(errs);
+    }
+  }, [adminOidcSecurityContainer]);
+
+  useEffect(() => {
+    fetchOidcSecuritySettingsData();
+  }, [adminOidcSecurityContainer, fetchOidcSecuritySettingsData]);
 
   return <OidcSecurityManagementContents />;
-}
+};
 
 OidcSecurityManagement.propTypes = {
   adminOidcSecurityContainer: PropTypes.instanceOf(AdminOidcSecurityContainer).isRequired,

+ 19 - 13
packages/app/src/components/Admin/Security/OidcSecuritySettingContents.jsx

@@ -1,13 +1,15 @@
-/* eslint-disable react/no-danger */
 import React from 'react';
 
-import PropTypes from 'prop-types';
+import { pathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
+import urljoin from 'url-join';
 
 
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { useSiteUrl } from '~/stores/context';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -33,13 +35,15 @@ class OidcSecurityManagementContents extends React.Component {
   }
 
   render() {
-    const { t, adminGeneralSecurityContainer, adminOidcSecurityContainer } = this.props;
+    const {
+      t, adminGeneralSecurityContainer, adminOidcSecurityContainer, siteUrl,
+    } = this.props;
     const { isOidcEnabled } = adminGeneralSecurityContainer.state;
+    const oidcCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/oidc/callback');
 
     return (
 
-      <React.Fragment>
-
+      <>
         <h2 className="alert-anchor border-bottom">
           {t('security_setting.OAuth.OIDC.name')}
         </h2>
@@ -69,11 +73,11 @@ class OidcSecurityManagementContents extends React.Component {
             <input
               className="form-control"
               type="text"
-              value={adminOidcSecurityContainer.state.callbackUrl}
+              value={oidcCallbackUrl}
               readOnly
             />
             <p className="form-text text-muted small">{t('security_setting.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
-            {!adminGeneralSecurityContainer.state.appSiteUrl && (
+            {(siteUrl == null || siteUrl === '') && (
               <div className="alert alert-danger">
                 <i
                   className="icon-exclamation"
@@ -86,7 +90,7 @@ class OidcSecurityManagementContents extends React.Component {
         </div>
 
         {isOidcEnabled && (
-          <React.Fragment>
+          <>
 
             <h3 className="border-bottom">{t('security_setting.configuration')}</h3>
 
@@ -365,11 +369,11 @@ class OidcSecurityManagementContents extends React.Component {
                 <input
                   className="form-control"
                   type="text"
-                  defaultValue={adminOidcSecurityContainer.state.callbackUrl || ''}
+                  defaultValue={oidcCallbackUrl}
                   readOnly
                 />
                 <p className="form-text text-muted small">{t('security_setting.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
-                {!adminGeneralSecurityContainer.state.appSiteUrl && (
+                {(siteUrl == null || siteUrl === '') && (
                   <div className="alert alert-danger">
                     <i
                       className="icon-exclamation"
@@ -437,7 +441,7 @@ class OidcSecurityManagementContents extends React.Component {
                 </button>
               </div>
             </div>
-          </React.Fragment>
+          </>
         )}
 
 
@@ -455,7 +459,7 @@ class OidcSecurityManagementContents extends React.Component {
           </ol>
         </div>
 
-      </React.Fragment>
+      </>
     );
   }
 
@@ -465,11 +469,13 @@ OidcSecurityManagementContents.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
   adminOidcSecurityContainer: PropTypes.instanceOf(AdminOidcSecurityContainer).isRequired,
+  siteUrl: PropTypes.string,
 };
 
 const OidcSecurityManagementContentsWrapperFC = (props) => {
   const { t } = useTranslation();
-  return <OidcSecurityManagementContents t={t} {...props} />;
+  const { data: siteUrl } = useSiteUrl();
+  return <OidcSecurityManagementContents t={t} {...props} siteUrl={siteUrl} />;
 };
 
 const OidcSecurityManagementContentsWrapper = withUnstatedContainers(OidcSecurityManagementContentsWrapperFC, [

+ 17 - 22
packages/app/src/components/Admin/Security/SamlSecuritySetting.jsx

@@ -1,5 +1,4 @@
-/* eslint-disable react/no-danger */
-import React from 'react';
+import React, { useEffect, useCallback } from 'react';
 
 import PropTypes from 'prop-types';
 
@@ -11,29 +10,25 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import SamlSecuritySettingContents from './SamlSecuritySettingContents';
 
-let retrieveErrors = null;
-function SamlSecurityManagement(props) {
+const SamlSecurityManagement = (props) => {
   const { adminSamlSecurityContainer } = props;
-  if (adminSamlSecurityContainer.state.samlEntryPoint === adminSamlSecurityContainer.dummySamlEntryPoint) {
-    throw (async() => {
-      try {
-        await adminSamlSecurityContainer.retrieveSecurityData();
-      }
-      catch (err) {
-        const errs = toArrayIfNot(err);
-        toastError(errs);
-        retrieveErrors = errs;
-        adminSamlSecurityContainer.setState({ samlEntryPoint: adminSamlSecurityContainer.dummySamlEntryPointForError });
-      }
-    })();
-  }
-
-  if (adminSamlSecurityContainer.state.samlEntryPoint === adminSamlSecurityContainer.dummySamlEntryPointForError) {
-    throw new Error(`${retrieveErrors.length} errors occured`);
-  }
+
+  const fetchSamlSecuritySettingsData = useCallback(async() => {
+    try {
+      await adminSamlSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      const errs = toArrayIfNot(err);
+      toastError(errs);
+    }
+  }, [adminSamlSecurityContainer]);
+
+  useEffect(() => {
+    fetchSamlSecuritySettingsData();
+  }, [adminSamlSecurityContainer, fetchSamlSecuritySettingsData]);
 
   return <SamlSecuritySettingContents />;
-}
+};
 
 SamlSecurityManagement.propTypes = {
   adminSamlSecurityContainer: PropTypes.instanceOf(AdminSamlSecurityContainer).isRequired,

+ 15 - 5
packages/app/src/components/Admin/Security/SamlSecuritySettingContents.jsx

@@ -1,17 +1,21 @@
 /* eslint-disable react/no-danger */
 import React from 'react';
 
-import PropTypes from 'prop-types';
+import { pathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 import { Collapse } from 'reactstrap';
+import urljoin from 'url-join';
 
 
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { useSiteUrl } from '~/stores/context';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
+
 class SamlSecurityManagementContents extends React.Component {
 
   constructor(props) {
@@ -38,10 +42,14 @@ class SamlSecurityManagementContents extends React.Component {
   }
 
   render() {
-    const { t, adminGeneralSecurityContainer, adminSamlSecurityContainer } = this.props;
+    const {
+      t, adminGeneralSecurityContainer, adminSamlSecurityContainer, siteUrl,
+    } = this.props;
     const { useOnlyEnvVars } = adminSamlSecurityContainer.state;
     const { isSamlEnabled } = adminGeneralSecurityContainer.state;
 
+    const samlCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/saml/callback');
+
     return (
       <React.Fragment>
 
@@ -82,11 +90,11 @@ class SamlSecurityManagementContents extends React.Component {
             <input
               className="form-control"
               type="text"
-              defaultValue={adminSamlSecurityContainer.state.callbackUrl}
+              defaultValue={samlCallbackUrl}
               readOnly
             />
             <p className="form-text text-muted small">{t('security_setting.desc_of_callback_URL', { AuthName: 'SAML Identity' })}</p>
-            {!adminGeneralSecurityContainer.state.appSiteUrl && (
+            {(siteUrl == null || siteUrl === '') && (
               <div className="alert alert-danger">
                 <i
                   className="icon-exclamation"
@@ -534,11 +542,13 @@ SamlSecurityManagementContents.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
   adminSamlSecurityContainer: PropTypes.instanceOf(AdminSamlSecurityContainer).isRequired,
+  siteUrl: PropTypes.string,
 };
 
 const SamlSecurityManagementContentsWrapperFC = (props) => {
   const { t } = useTranslation();
-  return <SamlSecurityManagementContents t={t} {...props} />;
+  const { data: siteUrl } = useSiteUrl();
+  return <SamlSecurityManagementContents t={t} siteUrl={siteUrl} {...props} />;
 };
 
 const SamlSecurityManagementContentsWrapper = withUnstatedContainers(SamlSecurityManagementContentsWrapperFC, [

+ 14 - 21
packages/app/src/components/Admin/Security/SecurityManagement.jsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useEffect, useCallback } from 'react';
 
 import PropTypes from 'prop-types';
 
@@ -10,29 +10,22 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import SecurityManagementContents from './SecurityManagementContents';
 
-let retrieveErrors = null;
 function SecurityManagement(props) {
   const { adminGeneralSecurityContainer } = props;
 
-  if (adminGeneralSecurityContainer.state.currentRestrictGuestMode === adminGeneralSecurityContainer.dummyCurrentRestrictGuestMode) {
-    throw (async() => {
-      try {
-        await adminGeneralSecurityContainer.retrieveSecurityData();
-      }
-      catch (err) {
-        const errs = toArrayIfNot(err);
-        toastError(errs);
-        retrieveErrors = errs;
-        adminGeneralSecurityContainer.setState({
-          currentRestrictGuestMode: adminGeneralSecurityContainer.dummyCurrentRestrictGuestModeForError,
-        });
-      }
-    })();
-  }
-
-  if (adminGeneralSecurityContainer.state.currentRestrictGuestMode === adminGeneralSecurityContainer.dummyCurrentRestrictGuestModeForError) {
-    throw new Error(`${retrieveErrors.length} errors occured`);
-  }
+  const fetchGeneralSecuritySettingsData = useCallback(async() => {
+    try {
+      await adminGeneralSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      const errs = toArrayIfNot(err);
+      toastError(errs);
+    }
+  }, [adminGeneralSecurityContainer]);
+
+  useEffect(() => {
+    fetchGeneralSecuritySettingsData();
+  }, [adminGeneralSecurityContainer, fetchGeneralSecuritySettingsData]);
 
   return <SecurityManagementContents />;
 }

+ 18 - 18
packages/app/src/components/Admin/Security/SecurityManagementContents.jsx

@@ -5,17 +5,17 @@ import { TabContent, TabPane } from 'reactstrap';
 
 import CustomNav from '../../CustomNavigation/CustomNav';
 
-// import BasicSecuritySetting from './BasicSecuritySetting';
-// import FacebookSecuritySetting from './FacebookSecuritySetting';
-// import GitHubSecuritySetting from './GitHubSecuritySetting';
-// import GoogleSecuritySetting from './GoogleSecuritySetting';
-// import LdapSecuritySetting from './LdapSecuritySetting';
-// import LocalSecuritySetting from './LocalSecuritySetting';
-// import OidcSecuritySetting from './OidcSecuritySetting';
-// import SamlSecuritySetting from './SamlSecuritySetting';
+import BasicSecuritySetting from './BasicSecuritySetting';
+import FacebookSecuritySetting from './FacebookSecuritySetting';
+import GitHubSecuritySetting from './GitHubSecuritySetting';
+import GoogleSecuritySetting from './GoogleSecuritySetting';
+import LdapSecuritySetting from './LdapSecuritySetting';
+import LocalSecuritySetting from './LocalSecuritySetting';
+import OidcSecuritySetting from './OidcSecuritySetting';
+import SamlSecuritySetting from './SamlSecuritySetting';
 import SecuritySetting from './SecuritySetting';
 import ShareLinkSetting from './ShareLinkSetting';
-// import TwitterSecuritySetting from './TwitterSecuritySetting';
+import TwitterSecuritySetting from './TwitterSecuritySetting';
 
 const SecurityManagementContents = () => {
   const { t } = useTranslation();
@@ -112,31 +112,31 @@ const SecurityManagementContents = () => {
         />
         <TabContent activeTab={activeTab} className="p-5">
           <TabPane tabId="passport_local">
-            {/* {activeComponents.has('passport_local') && <LocalSecuritySetting />} */}
+            {activeComponents.has('passport_local') && <LocalSecuritySetting />}
           </TabPane>
           <TabPane tabId="passport_ldap">
-            {/* {activeComponents.has('passport_ldap') && <LdapSecuritySetting />} */}
+            {activeComponents.has('passport_ldap') && <LdapSecuritySetting />}
           </TabPane>
           <TabPane tabId="passport_saml">
-            {/* {activeComponents.has('passport_saml') && <SamlSecuritySetting />} */}
+            {activeComponents.has('passport_saml') && <SamlSecuritySetting />}
           </TabPane>
           <TabPane tabId="passport_oidc">
-            {/* {activeComponents.has('passport_oidc') && <OidcSecuritySetting />} */}
+            {activeComponents.has('passport_oidc') && <OidcSecuritySetting />}
           </TabPane>
           <TabPane tabId="passport_basic">
-            {/* {activeComponents.has('passport_basic') && <BasicSecuritySetting />} */}
+            {activeComponents.has('passport_basic') && <BasicSecuritySetting />}
           </TabPane>
           <TabPane tabId="passport_google">
-            {/* {activeComponents.has('passport_google') && <GoogleSecuritySetting />} */}
+            {activeComponents.has('passport_google') && <GoogleSecuritySetting />}
           </TabPane>
           <TabPane tabId="passport_github">
-            {/* {activeComponents.has('passport_github') && <GitHubSecuritySetting />} */}
+            {activeComponents.has('passport_github') && <GitHubSecuritySetting />}
           </TabPane>
           <TabPane tabId="passport_twitter">
-            {/* {activeComponents.has('passport_twitter') && <TwitterSecuritySetting />} */}
+            {activeComponents.has('passport_twitter') && <TwitterSecuritySetting />}
           </TabPane>
           <TabPane tabId="passport_facebook">
-            {/* {activeComponents.has('passport_facebook') && <FacebookSecuritySetting />} */}
+            {activeComponents.has('passport_facebook') && <FacebookSecuritySetting />}
           </TabPane>
         </TabContent>
       </div>

+ 17 - 25
packages/app/src/components/Admin/Security/TwitterSecuritySetting.jsx

@@ -1,5 +1,4 @@
-/* eslint-disable react/no-danger */
-import React from 'react';
+import React, { useEffect, useCallback } from 'react';
 
 import PropTypes from 'prop-types';
 
@@ -9,34 +8,27 @@ import { toArrayIfNot } from '~/utils/array-utils';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-
 import TwitterSecuritySettingContents from './TwitterSecuritySettingContents';
 
-let retrieveErrors = null;
-function TwitterSecurityManagement(props) {
+const TwitterSecurityManagement = (props) => {
   const { adminTwitterSecurityContainer } = props;
-  if (adminTwitterSecurityContainer.state.twitterConsumerKey === adminTwitterSecurityContainer.dummyTwitterConsumerKey) {
-    throw (async() => {
-      try {
-        await adminTwitterSecurityContainer.retrieveSecurityData();
-      }
-      catch (err) {
-        const errs = toArrayIfNot(err);
-        toastError(errs);
-        retrieveErrors = errs;
-        adminTwitterSecurityContainer.setState({
-          twitterConsumerKey: adminTwitterSecurityContainer.dummyTwitterConsumerKeyForError,
-        });
-      }
-    })();
-  }
-
-  if (adminTwitterSecurityContainer.state.twitterConsumerKey === adminTwitterSecurityContainer.dummyTwitterConsumerKeyForError) {
-    throw new Error(`${retrieveErrors.length} errors occured`);
-  }
+
+  const fetchTwitterSecuritySettingsData = useCallback(async() => {
+    try {
+      await adminTwitterSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      const errs = toArrayIfNot(err);
+      toastError(errs);
+    }
+  }, [adminTwitterSecurityContainer]);
+
+  useEffect(() => {
+    fetchTwitterSecuritySettingsData();
+  }, [adminTwitterSecurityContainer, fetchTwitterSecuritySettingsData]);
 
   return <TwitterSecuritySettingContents />;
-}
+};
 
 TwitterSecurityManagement.propTypes = {
   adminTwitterSecurityContainer: PropTypes.instanceOf(AdminTwitterSecurityContainer).isRequired,

+ 13 - 5
packages/app/src/components/Admin/Security/TwitterSecuritySettingContents.jsx

@@ -1,13 +1,16 @@
 /* eslint-disable react/no-danger */
 import React from 'react';
 
-import PropTypes from 'prop-types';
+import { pathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
+import urljoin from 'url-join';
 
 
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { useSiteUrl } from '~/stores/context';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -33,8 +36,11 @@ class TwitterSecuritySettingContents extends React.Component {
   }
 
   render() {
-    const { t, adminGeneralSecurityContainer, adminTwitterSecurityContainer } = this.props;
+    const {
+      t, adminGeneralSecurityContainer, adminTwitterSecurityContainer, siteUrl,
+    } = this.props;
     const { isTwitterEnabled } = adminGeneralSecurityContainer.state;
+    const twitterCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/twitter/callback');
 
     return (
 
@@ -75,11 +81,11 @@ class TwitterSecuritySettingContents extends React.Component {
             <input
               className="form-control"
               type="text"
-              value={adminTwitterSecurityContainer.state.callbackUrl}
+              value={twitterCallbackUrl}
               readOnly
             />
             <p className="form-text text-muted small">{t('security_setting.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
-            {!adminGeneralSecurityContainer.state.appSiteUrl && (
+            {(siteUrl == null || siteUrl === '') && (
               <div className="alert alert-danger">
                 <i
                   className="icon-exclamation"
@@ -197,11 +203,13 @@ TwitterSecuritySettingContents.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
   adminTwitterSecurityContainer: PropTypes.instanceOf(AdminTwitterSecurityContainer).isRequired,
+  siteUrl: PropTypes.string,
 };
 
 const TwitterSecuritySettingContentsWrapperFC = (props) => {
   const { t } = useTranslation();
-  return <TwitterSecuritySettingContents t={t} {...props} />;
+  const { data: siteUrl } = useSiteUrl();
+  return <TwitterSecuritySettingContents t={t} siteUrl={siteUrl} {...props} />;
 };
 
 /**

+ 3 - 1
packages/app/src/components/CustomNavigation/CustomNav.jsx

@@ -7,6 +7,8 @@ import {
   Nav, NavItem, NavLink,
 } from 'reactstrap';
 
+import styles from './CustomNav.module.scss';
+
 
 function getBreakpointOneLevelLarger(breakpoint) {
   switch (breakpoint) {
@@ -149,7 +151,7 @@ export const CustomNavTab = (props) => {
   }
 
   return (
-    <div className="grw-custom-nav-tab">
+    <div className={`grw-custom-nav-tab ${styles['grw-custom-nav-tab']}`}>
       <div ref={navContainer} className="d-flex justify-content-between">
         <Nav className="nav-title">
           {Object.entries(navTabMapping).map(([key, value]) => {

+ 8 - 6
packages/app/src/styles/_navbar.scss → packages/app/src/components/CustomNavigation/CustomNav.module.scss

@@ -1,14 +1,16 @@
 .grw-custom-nav-tab,
 .grw-custom-nav-dropdown {
-  svg {
-    width: 17px;
-    height: 17px;
-    margin-right: 5px;
-    vertical-align: text-bottom;
+  :global {
+    svg {
+      width: 17px;
+      height: 17px;
+      margin-right: 5px;
+      vertical-align: text-bottom;
+    }
   }
 }
 
-.grw-custom-nav-tab {
+.grw-custom-nav-tab :global {
   .nav-title {
     flex-wrap: nowrap;
   }

+ 12 - 18
packages/app/src/components/CustomNavigation/CustomNavAndContents.jsx → packages/app/src/components/CustomNavigation/CustomNavAndContents.tsx

@@ -1,14 +1,21 @@
-import React, { useState } from 'react';
-
-import PropTypes from 'prop-types';
+import React, { ReactNode, useState } from 'react';
 
 import CustomNav, { CustomNavTab, CustomNavDropdown } from './CustomNav';
 import CustomTabContent from './CustomTabContent';
 
+type CustomNavAndContentsProps = {
+  navTabMapping: any,
+  defaultTabIndex?: number,
+  navigationMode?: 'both' | 'tab' | 'dropdown',
+  tabContentClasses?: string[],
+  breakpointToHideInactiveTabsDown?: 'xs' | 'sm' | 'md' | 'lg' | 'xl',
+  navRightElement?: ReactNode
+}
+
 
-const CustomNavAndContents = (props) => {
+const CustomNavAndContents = (props: CustomNavAndContentsProps): JSX.Element => {
   const {
-    navTabMapping, defaultTabIndex, navigationMode, tabContentClasses, breakpointToHideInactiveTabsDown, navRightElement,
+    navTabMapping, defaultTabIndex, navigationMode = 'tab', tabContentClasses = ['p-4'], breakpointToHideInactiveTabsDown, navRightElement,
   } = props;
   const [activeTab, setActiveTab] = useState(Object.keys(props.navTabMapping)[defaultTabIndex || 0]);
 
@@ -39,17 +46,4 @@ const CustomNavAndContents = (props) => {
   );
 };
 
-CustomNavAndContents.propTypes = {
-  navTabMapping: PropTypes.object.isRequired,
-  defaultTabIndex: PropTypes.number,
-  navigationMode: PropTypes.oneOf(['both', 'tab', 'dropdown']),
-  tabContentClasses: PropTypes.arrayOf(PropTypes.string),
-  breakpointToHideInactiveTabsDown: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
-  navRightElement: PropTypes.node,
-};
-CustomNavAndContents.defaultProps = {
-  navigationMode: 'tab',
-  tabContentClasses: ['p-4'],
-};
-
 export default CustomNavAndContents;

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

@@ -15,7 +15,7 @@ import { DescendantsPageList } from './DescendantsPageList';
 import ExpandOrContractButton from './ExpandOrContractButton';
 import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
-import PageTimeline from './PageTimeline';
+import { PageTimeline } from './PageTimeline';
 
 
 export const DescendantsPageListModal = (): JSX.Element => {

+ 5 - 5
packages/app/src/components/Layout/AdminLayout.tsx

@@ -3,6 +3,8 @@ import React, { ReactNode } from 'react';
 import dynamic from 'next/dynamic';
 import { Provider } from 'unstated';
 
+import { AdminInjectableContainers } from '~/interfaces/unstated-container';
+
 import { GrowiNavbar } from '../Navbar/GrowiNavbar';
 
 import { RawLayout } from './RawLayout';
@@ -10,8 +12,6 @@ import { RawLayout } from './RawLayout';
 import styles from './Admin.module.scss';
 
 
-// import { injectableContainers } from '~/client/admin';
-
 type Props = {
   title: string
   /**
@@ -21,11 +21,12 @@ type Props = {
    */
   selectedNavOpt: string
   children?: ReactNode
+  injectableContainers: AdminInjectableContainers
 }
 
 
 const AdminLayout = ({
-  children, title, selectedNavOpt,
+  children, title, selectedNavOpt, injectableContainers,
 }: Props): JSX.Element => {
 
   const AdminNavigation = dynamic(() => import('~/components/Admin/Common/AdminNavigation'), { ssr: false });
@@ -45,8 +46,7 @@ const AdminLayout = ({
               <AdminNavigation selected={selectedNavOpt} />
             </div>
             <div className="col-lg-9">
-              {/* TODO: inject Admincontainer (injectableContainers & adminSecurityContainers) by https://redmine.weseek.co.jp/issues/100072 */}
-              <Provider>
+              <Provider inject={injectableContainers}>
                 {children}
               </Provider>
             </div>

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

@@ -4,11 +4,8 @@ import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import ReactCardFlip from 'react-card-flip';
 
-import AppContainer from '~/client/services/AppContainer';
 import { useCsrfToken } from '~/stores/context';
 
-import { withUnstatedContainers } from './UnstatedUtils';
-
 class LoginForm extends React.Component {
 
   constructor(props) {
@@ -148,7 +145,7 @@ class LoginForm extends React.Component {
   renderRegisterForm() {
     const {
       t,
-      appContainer,
+      // appContainer,
       csrfToken,
       isEmailAuthenticationEnabled,
       username,
@@ -156,9 +153,9 @@ class LoginForm extends React.Component {
       email,
       registrationMode,
       registrationWhiteList,
+      isMailerSetup,
     } = this.props;
 
-    const { isMailerSetup } = appContainer.config;
     let registerAction = '/register';
 
     let submitText = t('Sign up');
@@ -288,8 +285,10 @@ class LoginForm extends React.Component {
       objOfIsExternalAuthEnableds,
     } = this.props;
 
+
     const isLocalOrLdapStrategiesEnabled = isLocalStrategySetup || isLdapStrategySetup;
-    const isSomeExternalAuthEnabled = Object.values(objOfIsExternalAuthEnableds).some(elem => elem);
+    // const isSomeExternalAuthEnabled = Object.values(objOfIsExternalAuthEnableds).some(elem => elem);
+    const isSomeExternalAuthEnabled = true;
 
     return (
       <div className="login-dialog mx-auto" id="login-dialog">
@@ -332,7 +331,7 @@ class LoginForm extends React.Component {
 LoginForm.propTypes = {
   // i18next
   t: PropTypes.func.isRequired,
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  // appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
   csrfToken: PropTypes.string,
   isRegistering: PropTypes.bool,
@@ -347,6 +346,7 @@ LoginForm.propTypes = {
   isLocalStrategySetup: PropTypes.bool,
   isLdapStrategySetup: PropTypes.bool,
   objOfIsExternalAuthEnableds: PropTypes.object,
+  isMailerSetup: PropTypes.bool,
 };
 
 const LoginFormWrapperFC = (props) => {
@@ -359,6 +359,7 @@ const LoginFormWrapperFC = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const LoginFormWrapper = withUnstatedContainers(LoginFormWrapperFC, [AppContainer]);
+// const LoginFormWrapper = withUnstatedContainers(LoginFormWrapperFC, [AppContainer]);
 
-export default LoginFormWrapper;
+// export default LoginForm;
+export default LoginFormWrapperFC;

+ 20 - 6
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -10,11 +10,12 @@ import { exportAsMarkdown } from '~/client/services/page-operation';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
 import {
-  IPageToRenameWithMeta, IPageWithMeta, IPageInfoForEntity,
+  IPageToRenameWithMeta, IPageWithMeta, IPageInfoForEntity, IPageHasId,
 } from '~/interfaces/page';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import {
   useCurrentPageId,
+  useCurrentPathname,
   useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useTemplateTagData,
 } from '~/stores/context';
 import { usePageTagsForEditors } from '~/stores/editor';
@@ -36,7 +37,7 @@ import ShareLinkIcon from '../Icons/ShareLinkIcon';
 import { Skelton } from '../Skelton';
 
 import { GrowiSubNavigation } from './GrowiSubNavigation';
-import { SubNavButtons } from './SubNavButtons';
+import { SubNavButtonsProps } from './SubNavButtons';
 
 
 type AdditionalMenuItemsProps = {
@@ -121,6 +122,7 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
       <DropdownItem
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
         disabled={isGuestUser || isSharedUser || isLinkSharingDisabled}
+        data-testid="open-page-accessories-modal-btn-with-share-link-management-data-tab"
         className="grw-page-control-dropdown-item"
       >
         <span className="grw-page-control-dropdown-icon">
@@ -151,7 +153,14 @@ type GrowiContextualSubNavigationProps = {
 
 const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps): JSX.Element => {
 
-  const PageEditorModeManager = dynamic(() => import('./PageEditorModeManager'), { ssr: false, loading: () => <Skelton width={208} height={32.49} /> });
+  const PageEditorModeManager = dynamic(
+    () => import('./PageEditorModeManager'),
+    { ssr: false, loading: () => <Skelton width={213} height={33.99} /> },
+  );
+  const SubNavButtons = dynamic<SubNavButtonsProps>(
+    () => import('./SubNavButtons').then(mod => mod.SubNavButtons),
+    { ssr: false, loading: () => <Skelton additionalClass='btn-skelton py-2' /> },
+  );
 
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
   const path = currentPage?.path;
@@ -162,6 +171,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { data: isDrawerMode } = useDrawerMode();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: pageId } = useCurrentPageId();
+  const { data: currentPathname } = useCurrentPathname();
   const { data: currentUser } = useCurrentUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isSharedUser } = useIsSharedUser();
@@ -303,7 +313,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
         <div className="d-flex flex-column align-items-end justify-content-center py-md-2" style={{ gap: `${isCompactMode ? '5px' : '7px'}` }}>
 
           { isViewMode && (
-            <div className="h-50">
+            <div className="h-50 w-100">
               <SubNavButtons
                 isCompactMode={isCompactMode}
                 pageId={pageId}
@@ -344,13 +354,17 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     templateMenuItemClickHandler, isPageTemplateModalShown,
   ]);
 
-  if (currentPage == null) {
+  if (currentPathname == null) {
     return <></>;
   }
 
+  const notFoundPage: Partial<IPageHasId> = {
+    path: currentPathname,
+  };
+
   return (
     <GrowiSubNavigation
-      page={currentPage}
+      page={currentPage ?? notFoundPage}
       showDrawerToggler={isDrawerMode}
       showTagLabel={isAbleToShowTagLabel}
       showPageAuthors={isAbleToShowPageAuthors}

+ 23 - 5
packages/app/src/components/Navbar/GrowiSubNavigation.module.scss

@@ -2,6 +2,14 @@
 @use '~/styles/bootstrap/init' as bs;
 @use '~/styles/mixins';
 
+%subnav-buttons-height {
+  height: 40px;
+}
+
+%compact-subnav-buttons-height {
+  height: 32px;
+}
+
 // https://github.com/css-modules/css-modules/issues/295#issuecomment-404873976
 // workaround to use '&' in global scope
 .grw-subnav {
@@ -36,15 +44,20 @@
       }
     }
 
+    .btn-skelton {
+      @extend %subnav-buttons-height;
+      width: 100%;
+    }
+
     .btn-subscribe {
-      height: 40px;
+      @extend %subnav-buttons-height;
       font-size: 20px;
     }
 
     .btn-like,
     .btn-bookmark,
     .btn-seen-user {
-      height: 40px;
+      @extend %subnav-buttons-height;
       padding-right: 6px;
       padding-left: 8px;
       font-size: 20px;
@@ -135,17 +148,22 @@
         min-height: 90px;
       }
 
+      .btn-skelton {
+        @extend %compact-subnav-buttons-height;
+        width: 100%;
+      }
+
       .btn-like,
       .btn-bookmark,
       .btn-subscribe {
         width: 32px;
-        height: 32px;
+        @extend %compact-subnav-buttons-height;
         padding: 4px;
         font-size: 16px;
       }
       .btn-seen-user {
         width: 48px;
-        height: 32px;
+        @extend %compact-subnav-buttons-height;
         padding: 4px;
         font-size: 16px;
 
@@ -156,7 +174,7 @@
       }
       .btn-page-item-control {
         width: 32px;
-        height: 32px;
+        @extend %compact-subnav-buttons-height;
         font-size: 12px;
       }
     }

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

@@ -11,7 +11,6 @@ import {
 import PagePathNav from '../PagePathNav';
 import { Skelton } from '../Skelton';
 
-import AuthorInfo from './AuthorInfo';
 import DrawerToggler from './DrawerToggler';
 
 
@@ -38,8 +37,8 @@ type Props = {
 
 export const GrowiSubNavigation = (props: Props): JSX.Element => {
 
-  const TagLabels = dynamic(() => import('../Page/TagLabels'), { ssr: false, loading: () => <Skelton width={124.5} height={21.99} /> });
-  const AuthorInfo = dynamic(() => import('./AuthorInfo'), { ssr: false, loading: () => <Skelton width={148.32} height={32.84} /> });
+  const TagLabels = dynamic(() => import('../Page/TagLabels'), { ssr: false, loading: () => <Skelton width={137} height={21.99} additionalClass='py-1' /> });
+  const AuthorInfo = dynamic(() => import('./AuthorInfo'), { ssr: false, loading: () => <Skelton width={139} height={32.84} additionalClass='py-1' /> });
 
   const { data: editorMode } = useEditorMode();
 

+ 7 - 17
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -12,10 +12,14 @@ import { IPageForPageDuplicateModal } from '~/stores/modal';
 import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { useSWRxPageInfo } from '../../stores/page';
 import { useSWRxUsersList } from '../../stores/user';
+import BookmarkButtons from '../BookmarkButtons';
 import {
-  AdditionalMenuItemsRendererProps, ForceHideMenuItems, MenuItemType, PageItemControlProps,
+  AdditionalMenuItemsRendererProps, ForceHideMenuItems, MenuItemType,
+  PageItemControl,
 } from '../Common/Dropdown/PageItemControl';
-import { Skelton } from '../Skelton';
+import LikeButtons from '../LikeButtons';
+import SubscribeButton from '../SubscribeButton';
+import SeenUserInfo from '../User/SeenUserInfo';
 
 
 type CommonProps = {
@@ -51,20 +55,6 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
 
   const { data: bookmarkInfo, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageId);
 
-  // dynamic import for show skelton
-  const SubscribeButton = dynamic(() => import('../SubscribeButton'), { ssr: false, loading: () => <Skelton width={37} additionalClass='btn-subscribe'/> });
-  const LikeButtons = dynamic(() => import('../LikeButtons'), { ssr: false, loading: () => <Skelton width={57.48} additionalClass='btn-like'/> });
-  const BookmarkButtons = dynamic(() => import('../BookmarkButtons'), {
-    ssr: false,
-    loading: () => <Skelton width={53.48} additionalClass='total-bookmarks'/>,
-  });
-  const SeenUserInfo = dynamic(() => import('../User/SeenUserInfo'), { ssr: false, loading: () => <Skelton width={58.98} additionalClass='btn-seen-user'/> });
-  const PageItemControl = dynamic<PageItemControlProps>(() => import('../Common/Dropdown/PageItemControl').then(mod => mod.PageItemControl), {
-    ssr: false,
-    loading: () => <Skelton width={37} additionalClass='btn-page-item-control'/>,
-  });
-
-
   const likerIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.likerIds ?? []).slice(0, 15) : [];
   const seenUserIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.seenUserIds ?? []).slice(0, 15) : [];
 
@@ -215,7 +205,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
   );
 };
 
-type SubNavButtonsProps= CommonProps & {
+export type SubNavButtonsProps = CommonProps & {
   pageId: string,
   shareLinkId?: string | null,
   revisionId?: string | null,

+ 3 - 5
packages/app/src/components/NotFoundPage.tsx

@@ -6,13 +6,12 @@ import dynamic from 'next/dynamic';
 import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
 import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
-// import PageTimeline from './PageTimeline';
+import { PageTimeline } from './PageTimeline';
+import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 
 const NotFoundPage = (): JSX.Element => {
   const { t } = useTranslation();
 
-  const CustomNavAndContents = dynamic(() => import('./CustomNavigation/CustomNavAndContents'), { ssr: false });
-
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {
@@ -23,8 +22,7 @@ const NotFoundPage = (): JSX.Element => {
       },
       timeLine: {
         Icon: TimeLineIcon,
-        // Content: PageTimeline,
-        Content: () => <></>,
+        Content: PageTimeline,
         i18n: t('Timeline View'),
         index: 1,
       },

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

@@ -7,7 +7,7 @@ import { TabContent, TabPane } from 'reactstrap';
 
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import {
-  useCurrentPagePath, useIsSharedUser, useIsEditable, useIsUserPage, usePageUser, useShareLinkId, useIsNotFound, useIsNotCreatable,
+  useCurrentPagePath, useIsSharedUser, useIsEditable, useIsUserPage, usePageUser, useShareLinkId, useIsNotFound,
 } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
@@ -15,7 +15,6 @@ import { EditorMode, useEditorMode } from '~/stores/ui';
 
 import CountBadge from '../Common/CountBadge';
 import PageListIcon from '../Icons/PageListIcon';
-import { NotCreatablePage } from '../NotCreatablePage';
 import NotFoundPage from '../NotFoundPage';
 import { Page } from '../Page';
 // import PageEditor from '../PageEditor';
@@ -37,6 +36,7 @@ const DisplaySwitcher = (): JSX.Element => {
   const EditorNavbarBottom = dynamic(() => import('../PageEditor/EditorNavbarBottom'), { ssr: false });
   const HashChanged = dynamic(() => import('../EventListeneres/HashChanged'), { ssr: false });
   const ContentLinkButtons = dynamic(() => import('../ContentLinkButtons'), { ssr: false });
+  const NotFoundPage = dynamic(() => import('../NotFoundPage'), { ssr: false });
 
   // get element for smoothScroll
   // const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
@@ -48,7 +48,6 @@ const DisplaySwitcher = (): JSX.Element => {
   const { data: isEditable } = useIsEditable();
   const { data: pageUser } = usePageUser();
   const { data: isNotFound } = useIsNotFound();
-  const { data: isNotCreatable } = useIsNotCreatable();
   const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
 
   const { data: editorMode } = useEditorMode();
@@ -69,8 +68,7 @@ const DisplaySwitcher = (): JSX.Element => {
             <div className="flex-grow-1 flex-basis-0 mw-0">
               { isUserPage && <UserInfo pageUser={pageUser} />}
               { !isNotFound && <Page /> }
-              { isNotFound && !isNotCreatable && <NotFoundPage /> }
-              { isNotFound && isNotCreatable && <NotCreatablePage /> }
+              { isNotFound && <NotFoundPage /> }
             </div>
 
             { !isNotFound && !currentPage?.isEmpty && (

+ 5 - 1
packages/app/src/components/PageAlert/FixPageGrantAlert.tsx

@@ -76,6 +76,10 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
       return t('fix_page_grant.modal.grant_label.isForbidden');
     }
 
+    if (grantData.grant === 1) {
+      return t('fix_page_grant.modal.grant_label.public');
+    }
+
     if (grantData.grant === 4) {
       return t('fix_page_grant.modal.radio_btn.only_me');
     }
@@ -87,7 +91,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
       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
+    throw Error('cannot get grant label'); // this error can't be throwed
   }, [t]);
 
   const renderGrantDataLabel = useCallback(() => {

+ 26 - 23
packages/app/src/components/PageComment.tsx

@@ -2,41 +2,41 @@ import React, {
   FC, useEffect, useState, useMemo, memo, useCallback,
 } from 'react';
 
+import { Nullable } from '@growi/core';
 import { Button } from 'reactstrap';
 
-
-import AppContainer from '~/client/services/AppContainer';
 import { toastError } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
+import { useCurrentPagePath } from '~/stores/context';
+import { useSWRxCurrentPage } from '~/stores/page';
 import { useCommentPreviewOptions } from '~/stores/renderer';
 
 import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
 import { useSWRxPageComment } from '../stores/comment';
 
-
-import Comment from './PageComment/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,
-  pageId: string,
+  pageId?: Nullable<string>, // TODO: check pageId type
   isReadOnly : boolean,
   titleAlign?: 'center' | 'left' | 'right',
   highlightKeywords?:string[],
   hideIfEmpty?: boolean,
 }
 
-
-const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
+export const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
 
   const {
-    appContainer, pageId, highlightKeywords, isReadOnly, titleAlign, hideIfEmpty,
+    pageId, highlightKeywords, isReadOnly, titleAlign, hideIfEmpty,
   } = props;
 
   const { data: comments, mutate } = useSWRxPageComment(pageId);
   const { data: rendererOptions } = useCommentPreviewOptions();
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { data: currentPagePath } = useCurrentPagePath();
 
   const [commentToBeDeleted, setCommentToBeDeleted] = useState<ICommentHasId | null>(null);
   const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState<boolean>(false);
@@ -110,17 +110,8 @@ const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
     }
   }, [commentToBeDeleted, onDeleteCommentAfterOperation]);
 
-  const generateCommentInnerElement = (comment: ICommentHasId) => (
-    <Comment
-      rendererOptions={rendererOptions}
-      deleteBtnClicked={onClickDeleteButton}
-      comment={comment}
-      onComment={mutate}
-      isReadOnly={isReadOnly}
-    />
-  );
-
   const generateAllRepliesElement = (replyComments: ICommentHasIdList) => (
+    // TODO: need page props path
     <ReplayComments
       replyList={replyComments}
       deleteBtnClicked={onClickDeleteButton}
@@ -143,11 +134,25 @@ const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
   if (hideIfEmpty && comments?.length === 0) {
     return <></>;
   }
-
-  if (rendererOptions == null) {
+  if (rendererOptions == null || currentPagePath == null) {
     return <></>;
   }
 
+  const generateCommentInnerElement = (comment: ICommentHasId) => (
+    currentPage != null && (
+      <Comment
+        rendererOptions={rendererOptions}
+        deleteBtnClicked={onClickDeleteButton}
+        comment={comment}
+        onComment={mutate}
+        isReadOnly={isReadOnly}
+        currentPagePath={currentPagePath}
+        currentRevisionId={currentPage.revision._id}
+        currentRevisionCreatedAt={currentPage.revision.createdAt}
+      />
+    )
+  );
+
   let commentTitleClasses = 'border-bottom py-3 mb-3';
   commentTitleClasses = titleAlign != null ? `${commentTitleClasses} text-${titleAlign}` : `${commentTitleClasses} text-center`;
 
@@ -224,5 +229,3 @@ const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
 });
 
 PageComment.displayName = 'PageComment';
-
-export default PageComment;

+ 0 - 259
packages/app/src/components/PageComment/Comment.jsx

@@ -1,259 +0,0 @@
-import React from 'react';
-
-import { UserPicture } from '@growi/ui';
-import { format } from 'date-fns';
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-import { UncontrolledTooltip } from 'reactstrap';
-
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-import { useCurrentUser } from '~/stores/context';
-
-import FormattedDistanceDate from '../FormattedDistanceDate';
-import HistoryIcon from '../Icons/HistoryIcon';
-import RevisionBody from '../Page/RevisionBody';
-import { withUnstatedContainers } from '../UnstatedUtils';
-import Username from '../User/Username';
-
-import CommentControl from './CommentControl';
-import CommentEditor from './CommentEditor';
-
-import { RendererOptions } from '~/services/renderer/renderer';
-
-/**
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @export
- * @class Comment
- * @extends {React.Component}
- */
-class Comment extends React.PureComponent {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      html: '',
-      isReEdit: false,
-    };
-
-    this.isCurrentUserIsAuthor = this.isCurrentUserEqualsToAuthor.bind(this);
-    this.isCurrentRevision = this.isCurrentRevision.bind(this);
-    this.getRootClassName = this.getRootClassName.bind(this);
-    this.deleteBtnClickedHandler = this.deleteBtnClickedHandler.bind(this);
-    this.renderText = this.renderText.bind(this);
-    this.renderHtml = this.renderHtml.bind(this);
-  }
-
-
-  initCurrentRenderingContext() {
-    this.currentRenderingContext = {
-      markdown: this.props.comment.comment,
-    };
-  }
-
-  componentDidMount() {
-    this.initCurrentRenderingContext();
-    this.renderHtml();
-  }
-
-  componentDidUpdate(prevProps) {
-    const { comment: prevComment } = prevProps;
-    const { comment } = this.props;
-
-    // render only when props.markdown is updated
-    if (comment !== prevComment) {
-      this.initCurrentRenderingContext();
-      this.renderHtml();
-      return;
-    }
-
-    const { interceptorManager } = window;
-
-    interceptorManager.process('postRenderCommentHtml', this.currentRenderingContext);
-  }
-
-  isCurrentUserEqualsToAuthor() {
-    const { comment, currentUser } = this.props;
-    const { creator } = comment;
-
-    if (creator == null || currentUser == null) {
-      return false;
-    }
-    return creator.username === currentUser.username;
-  }
-
-  isCurrentRevision() {
-    return this.props.comment.revision === this.props.pageContainer.state.revisionId;
-  }
-
-  getRootClassName(comment) {
-    let className = 'page-comment flex-column';
-
-    const { revisionId, revisionCreatedAt } = this.props.pageContainer.state;
-    if (comment.revision === revisionId) {
-      className += ' page-comment-current';
-    }
-    else if (Date.parse(comment.createdAt) / 1000 > revisionCreatedAt) {
-      className += ' page-comment-newer';
-    }
-    else {
-      className += ' page-comment-older';
-    }
-
-    if (this.isCurrentUserEqualsToAuthor()) {
-      className += ' page-comment-me';
-    }
-
-    return className;
-  }
-
-  deleteBtnClickedHandler() {
-    this.props.deleteBtnClicked(this.props.comment);
-  }
-
-  renderText(comment) {
-    return <span style={{ whiteSpace: 'pre-wrap' }}>{comment}</span>;
-  }
-
-  renderRevisionBody() {
-    const config = this.props.appContainer.getConfig();
-    const isMathJaxEnabled = !!config.env.MATHJAX;
-    return (
-      <RevisionBody
-        html={this.state.html}
-        isMathJaxEnabled={isMathJaxEnabled}
-        renderMathJaxOnInit
-        additionalClassName="comment"
-      />
-    );
-  }
-
-  async renderHtml() {
-
-    const { rendererOptions, appContainer } = this.props;
-    const { interceptorManager } = window;
-    const context = this.currentRenderingContext;
-
-    await interceptorManager.process('preRenderComment', context);
-    await interceptorManager.process('prePreProcess', context);
-    context.markdown = await rendererOptions.preProcess(context.markdown, context);
-    await interceptorManager.process('postPreProcess', context);
-    context.parsedHTML = await rendererOptions.process(context.markdown, context);
-    await interceptorManager.process('prePostProcess', context);
-    context.parsedHTML = await rendererOptions.postProcess(context.parsedHTML, context);
-    await interceptorManager.process('postPostProcess', context);
-    await interceptorManager.process('preRenderCommentHtml', context);
-    this.setState({ html: context.parsedHTML });
-    await interceptorManager.process('postRenderCommentHtml', context);
-  }
-
-  render() {
-    const {
-      t, comment, isReadOnly, onComment,
-    } = this.props;
-    const commentId = comment._id;
-    const creator = comment.creator;
-    const isMarkdown = comment.isMarkdown;
-    const createdAt = new Date(comment.createdAt);
-    const updatedAt = new Date(comment.updatedAt);
-    const isEdited = createdAt < updatedAt;
-
-    const rootClassName = this.getRootClassName(comment);
-    const commentBody = isMarkdown ? this.renderRevisionBody() : this.renderText(comment.comment);
-    const revHref = `?revision=${comment.revision}`;
-
-    const editedDateId = `editedDate-${comment._id}`;
-    const editedDateFormatted = isEdited
-      ? format(updatedAt, 'yyyy/MM/dd HH:mm')
-      : null;
-
-    return (
-      <React.Fragment>
-        {(this.state.isReEdit && !isReadOnly) ? (
-          <CommentEditor
-            rendererOptions={this.props.rendererOptions}
-            currentCommentId={commentId}
-            commentBody={comment.comment}
-            replyTo={undefined}
-            commentCreator={creator?.username}
-            onCancelButtonClicked={() => this.setState({ isReEdit: false })}
-            onCommentButtonClicked={() => {
-              this.setState({ isReEdit: false });
-              if (onComment != null) onComment();
-            }}
-          />
-        ) : (
-          <div id={commentId} className={rootClassName}>
-            <div className="page-comment-writer">
-              <UserPicture user={creator} />
-            </div>
-            <div className="page-comment-main">
-              <div className="page-comment-creator">
-                <Username user={creator} />
-              </div>
-              <div className="page-comment-body">{commentBody}</div>
-              <div className="page-comment-meta">
-                <a href={`#${commentId}`}>
-                  <FormattedDistanceDate id={commentId} date={comment.createdAt} />
-                </a>
-                { isEdited && (
-                  <>
-                    <span id={editedDateId}>&nbsp;(edited)</span>
-                    <UncontrolledTooltip placement="bottom" fade={false} target={editedDateId}>{editedDateFormatted}</UncontrolledTooltip>
-                  </>
-                )}
-                <span className="ml-2">
-                  <a id={`page-comment-revision-${commentId}`} className="page-comment-revision" href={revHref}>
-                    <HistoryIcon />
-                  </a>
-                  <UncontrolledTooltip placement="bottom" fade={false} target={`page-comment-revision-${commentId}`}>
-                    {t('page_comment.display_the_page_when_posting_this_comment')}
-                  </UncontrolledTooltip>
-                </span>
-              </div>
-              {(this.isCurrentUserEqualsToAuthor() && !isReadOnly) && (
-                <CommentControl
-                  onClickDeleteBtn={this.deleteBtnClickedHandler}
-                  onClickEditBtn={() => this.setState({ isReEdit: true })}
-                />
-              ) }
-            </div>
-          </div>
-        )
-        }
-      </React.Fragment>
-    );
-  }
-
-}
-
-Comment.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  comment: PropTypes.object.isRequired,
-  isReadOnly: PropTypes.bool.isRequired,
-  rendererOptions: PropTypes.instanceOf(RendererOptions).isRequired,
-  deleteBtnClicked: PropTypes.func.isRequired,
-  currentUser: PropTypes.object,
-  onComment: PropTypes.func,
-};
-
-const CommentWrapperFC = (props) => {
-  const { t } = useTranslation();
-
-  const { data: currentUser } = useCurrentUser();
-
-  return <Comment t={t} currentUser={currentUser} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const CommentWrapper = withUnstatedContainers(CommentWrapperFC, [AppContainer, PageContainer]);
-
-export default CommentWrapper;

+ 178 - 0
packages/app/src/components/PageComment/Comment.tsx

@@ -0,0 +1,178 @@
+import React, { useEffect, useState } from 'react';
+
+
+import { UserPicture } from '@growi/ui';
+import { ConsoleFormattedStream } from 'browser-bunyan';
+import { format } from 'date-fns';
+import { useTranslation } from 'next-i18next';
+import { UncontrolledTooltip } from 'reactstrap';
+
+import { RendererOptions } from '~/services/renderer/renderer';
+import { useCurrentUser } from '~/stores/context';
+
+import { ICommentHasId } from '../../interfaces/comment';
+import FormattedDistanceDate from '../FormattedDistanceDate';
+import HistoryIcon from '../Icons/HistoryIcon';
+import RevisionRenderer from '../Page/RevisionRenderer';
+import Username from '../User/Username';
+
+import CommentControl from './CommentControl';
+import CommentEditor from './CommentEditor';
+
+type CommentProps = {
+  comment: ICommentHasId,
+  isReadOnly: boolean,
+  deleteBtnClicked: (comment: ICommentHasId) => void,
+  onComment: () => void,
+  rendererOptions: RendererOptions,
+  currentPagePath: string,
+  currentRevisionId: string,
+  currentRevisionCreatedAt: Date,
+}
+
+export const Comment = (props: CommentProps): JSX.Element => {
+  const {
+    comment, isReadOnly, deleteBtnClicked, onComment, rendererOptions, currentPagePath, currentRevisionId, currentRevisionCreatedAt,
+  } = props;
+  const { t } = useTranslation();
+  const { data: currentUser } = useCurrentUser();
+
+  const [markdown, setMarkdown] = useState('');
+  const [isReEdit, setIsReEdit] = useState(false);
+
+  const commentId = comment._id;
+  const creator = comment.creator;
+  const isMarkdown = comment.isMarkdown;
+  const createdAt = new Date(comment.createdAt);
+  const updatedAt = new Date(comment.updatedAt);
+  const isEdited = createdAt < updatedAt;
+
+  useEffect(() => {
+    setMarkdown(comment.comment);
+
+    const isCurrentRevision = () => {
+      return comment.revision === currentRevisionId;
+    };
+    isCurrentRevision();
+
+  }, [comment, currentRevisionId]);
+
+  const isCurrentUserEqualsToAuthor = () => {
+    const { creator }: any = comment;
+
+    if (creator == null || currentUser == null) {
+      return false;
+    }
+    return creator.username === currentUser.username;
+  };
+
+  const getRootClassName = (comment) => {
+    let className = 'page-comment flex-column';
+
+    if (comment.revision === currentRevisionId) {
+      className += ' page-comment-current';
+    }
+    else if (comment.createdAt.getTime() > currentRevisionCreatedAt.getTime()) {
+      className += ' page-comment-newer';
+    }
+    else {
+      className += ' page-comment-older';
+    }
+
+    if (isCurrentUserEqualsToAuthor()) {
+      className += ' page-comment-me';
+    }
+
+    return className;
+  };
+
+  const deleteBtnClickedHandler = (comment) => {
+    deleteBtnClicked(comment);
+  };
+
+  const renderText = (comment) => {
+    return <span style={{ whiteSpace: 'pre-wrap' }}>{comment}</span>;
+  };
+
+  // TODO: Remove when update ReplayComments.jsx
+  if (currentPagePath == null) {
+    return <></>;
+  }
+
+  const renderRevisionBody = () => {
+    return (
+      <RevisionRenderer
+        rendererOptions={rendererOptions}
+        markdown={markdown}
+        additionalClassName="comment"
+        pagePath={currentPagePath}
+      />
+    );
+  };
+
+  const rootClassName = getRootClassName(comment);
+  const commentBody = isMarkdown ? renderRevisionBody() : renderText(comment.comment);
+  const revHref = `?revision=${comment.revision}`;
+
+  const editedDateId = `editedDate-${comment._id}`;
+  const editedDateFormatted = isEdited
+    ? format(updatedAt, 'yyyy/MM/dd HH:mm')
+    : null;
+
+  return (
+    <>
+      {(isReEdit && !isReadOnly) ? (
+        <CommentEditor
+          rendererOptions={rendererOptions}
+          currentCommentId={commentId}
+          commentBody={comment.comment}
+          replyTo={undefined}
+          commentCreator={creator?.username}
+          onCancelButtonClicked={() => setIsReEdit(false)}
+          onCommentButtonClicked={() => {
+            setIsReEdit(false);
+            if (onComment != null) onComment();
+          }}
+        />
+      ) : (
+        <div id={commentId} className={rootClassName}>
+          <div className="page-comment-writer">
+            <UserPicture user={creator} />
+          </div>
+          <div className="page-comment-main">
+            <div className="page-comment-creator">
+              <Username user={creator} />
+            </div>
+            <div className="page-comment-body">{commentBody}</div>
+            <div className="page-comment-meta">
+              <a href={`#${commentId}`}>
+                <FormattedDistanceDate id={commentId} date={comment.createdAt} />
+              </a>
+              { isEdited && (
+                <>
+                  <span id={editedDateId}>&nbsp;(edited)</span>
+                  <UncontrolledTooltip placement="bottom" fade={false} target={editedDateId}>{editedDateFormatted}</UncontrolledTooltip>
+                </>
+              )}
+              <span className="ml-2">
+                <a id={`page-comment-revision-${commentId}`} className="page-comment-revision" href={revHref}>
+                  <HistoryIcon />
+                </a>
+                <UncontrolledTooltip placement="bottom" fade={false} target={`page-comment-revision-${commentId}`}>
+                  {t('page_comment.display_the_page_when_posting_this_comment')}
+                </UncontrolledTooltip>
+              </span>
+            </div>
+            {(isCurrentUserEqualsToAuthor() && !isReadOnly) && (
+              <CommentControl
+                onClickDeleteBtn={deleteBtnClickedHandler}
+                onClickEditBtn={() => setIsReEdit(true)}
+              />
+            ) }
+          </div>
+        </div>
+      )
+      }
+    </>
+  );
+};

+ 16 - 29
packages/app/src/components/PageComment/CommentEditor.tsx

@@ -9,16 +9,13 @@ import {
 } from 'reactstrap';
 import * as toastr from 'toastr';
 
-import AppContainer from '~/client/services/AppContainer';
-import EditorContainer from '~/client/services/EditorContainer';
-import PageContainer from '~/client/services/PageContainer';
 import { apiPostForm } from '~/client/util/apiv1-client';
 import { CustomWindow } from '~/interfaces/global';
 import { IInterceptorManager } from '~/interfaces/interceptor-manager';
 import { RendererOptions } from '~/services/renderer/renderer';
 import { useSWRxPageComment } from '~/stores/comment';
 import {
-  useCurrentPagePath, useCurrentPageId, useCurrentUser, useRevisionId,
+  useCurrentPagePath, useCurrentPageId, useCurrentUser, useRevisionId, useRendererConfig,
 } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import { useIsMobile } from '~/stores/ui';
@@ -26,9 +23,8 @@ import { useIsMobile } from '~/stores/ui';
 
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
 import NotAvailableForGuest from '../NotAvailableForGuest';
-import Editor from '../PageEditor/Editor';
+// import Editor from '../PageEditor/Editor';
 import { SlackNotification } from '../SlackNotification';
-import { withUnstatedContainers } from '../UnstatedUtils';
 
 import CommentPreview from './CommentPreview';
 
@@ -47,8 +43,6 @@ const navTabMapping = {
 };
 
 type PropsType = {
-  appContainer: AppContainer,
-
   rendererOptions: RendererOptions,
   isForNewComment?: boolean,
   replyTo?: string,
@@ -68,7 +62,7 @@ type EditorRef = {
 const CommentEditor = (props: PropsType): JSX.Element => {
 
   const {
-    appContainer, rendererOptions, isForNewComment, replyTo,
+    rendererOptions, isForNewComment, replyTo,
     currentCommentId, commentBody, commentCreator, onCancelButtonClicked, onCommentButtonClicked,
   } = props;
   const { data: currentUser } = useCurrentUser();
@@ -79,11 +73,11 @@ const CommentEditor = (props: PropsType): JSX.Element => {
   const { data: isMobile } = useIsMobile();
   const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
+  const { data: config } = useRendererConfig();
 
-  const config = appContainer.getConfig();
-  const isUploadable = config.upload.image || config.upload.file;
-  const isUploadableFile = config.upload.file;
-  const isSlackConfigured = config.isSlackConfigured;
+  // const isUploadable = config.upload.image || config.upload.file;
+  // const isUploadableFile = config.upload.file;
+  // const isSlackConfigured = config.isSlackConfigured;
 
   const [isReadyToUse, setIsReadyToUse] = useState(!isForNewComment);
   const [comment, setComment] = useState(commentBody ?? '');
@@ -292,8 +286,8 @@ const CommentEditor = (props: PropsType): JSX.Element => {
       </Button>
     );
 
-    // TODO: typescriptize Editor
-    const AnyEditor = Editor as any;
+    // // TODO: typescriptize Editor
+    // const AnyEditor = Editor as any;
 
     return (
       <>
@@ -301,18 +295,18 @@ const CommentEditor = (props: PropsType): JSX.Element => {
           <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={handleSelect} hideBorderBottom />
           <TabContent activeTab={activeTab}>
             <TabPane tabId="comment_editor">
-              <AnyEditor
+              {/* <AnyEditor
                 ref={editorRef}
                 value={comment}
                 lineNumbers={false}
                 isMobile={isMobile}
-                isUploadable={isUploadable}
-                isUploadableFile={isUploadableFile}
+                // isUploadable={isUploadable}
+                // isUploadableFile={isUploadableFile}
                 onChange={setComment}
                 onUpload={uploadHandler}
                 onCtrlEnter={ctrlEnterHandler}
                 isComment
-              />
+              /> */}
               {/*
                 Note: <OptionsSelector /> is not optimized for ComentEditor in terms of responsive design.
                 See a review comment in https://github.com/weseek/growi/pull/3473
@@ -331,7 +325,7 @@ const CommentEditor = (props: PropsType): JSX.Element => {
             <span className="flex-grow-1" />
             <span className="d-none d-sm-inline">{ errorMessage && errorMessage }</span>
 
-            { isSlackConfigured
+            {/* { isSlackConfigured
               && (
                 <div className="form-inline align-self-center mr-md-2">
                   <SlackNotification
@@ -343,7 +337,7 @@ const CommentEditor = (props: PropsType): JSX.Element => {
                   />
                 </div>
               )
-            }
+            } */}
             <div className="d-none d-sm-block">
               <span className="mr-2">{cancelButton}</span><span>{submitButton}</span>
             </div>
@@ -377,11 +371,4 @@ const CommentEditor = (props: PropsType): JSX.Element => {
 
 };
 
-/**
- * Wrapper component for using unstated
- */
-const CommentEditorWrapper = withUnstatedContainers<unknown, Partial<PropsType>>(
-  CommentEditor, [AppContainer, PageContainer, EditorContainer],
-);
-
-export default CommentEditorWrapper;
+export default CommentEditor;

+ 2 - 7
packages/app/src/components/PageComment/CommentEditorLazyRenderer.tsx

@@ -1,14 +1,12 @@
 import React, { FC } from 'react';
 
-import { useSWRxPageComment } from '../../stores/comment';
+import { useCommentPreviewOptions } from '~/stores/renderer';
 
-import AppContainer from '~/client/services/AppContainer';
+import { useSWRxPageComment } from '../../stores/comment';
 
 import CommentEditor from './CommentEditor';
-import { useCommentPreviewOptions } from '~/stores/renderer';
 
 type Props = {
-  appContainer: AppContainer,
   pageId: string,
 }
 
@@ -22,11 +20,8 @@ const CommentEditorLazyRenderer:FC<Props> = (props:Props):JSX.Element => {
     return <></>;
   }
 
-  const { appContainer } = props;
-
   return (
     <CommentEditor
-      appContainer={appContainer}
       rendererOptions={rendererOptions}
       replyTo={undefined}
       onCommentButtonClicked={mutate}

+ 13 - 17
packages/app/src/components/PageComment/ReplayComments.jsx

@@ -1,16 +1,13 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
+import PropTypes from 'prop-types';
 import { Collapse } from 'reactstrap';
 
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-
-import Comment from './Comment';
+import { RendererOptions } from '~/services/renderer/renderer';
+import { useRendererConfig } from '~/stores/context';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
+import { Comment } from './Comment';
 
-import { RendererOptions } from '~/services/renderer/renderer';
 
 class ReplayComments extends React.PureComponent {
 
@@ -42,8 +39,9 @@ class ReplayComments extends React.PureComponent {
   }
 
   render() {
+    const { config } = this.props;
 
-    const isAllReplyShown = this.props.appContainer.getConfig().isAllReplyShown || false;
+    const isAllReplyShown = config.isAllReplyShown || false;
     const replyList = this.props.replyList;
 
     if (isAllReplyShown) {
@@ -100,19 +98,17 @@ class ReplayComments extends React.PureComponent {
 
 }
 
-/**
- * Wrapper component for using unstated
- */
-const ReplayCommentsWrapper = withUnstatedContainers(ReplayComments, [AppContainer, PageContainer]);
-
 ReplayComments.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
   rendererOptions: PropTypes.instanceOf(RendererOptions).isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
   isReadOnly: PropTypes.bool.isRequired,
   replyList: PropTypes.array,
 };
 
-export default ReplayCommentsWrapper;
+const ReplayCommentsWrapperFC = (props) => {
+  const { data: config } = useRendererConfig();
+
+  return <ReplayComments config={config} {...props} />;
+};
+
+export default ReplayCommentsWrapperFC;

+ 3 - 9
packages/app/src/components/PageEditor/DrawioModal.jsx

@@ -1,15 +1,12 @@
 import React from 'react';
-import PropTypes from 'prop-types';
-import i18next from 'i18next';
 
+import i18next from 'i18next';
+import PropTypes from 'prop-types';
 import {
   Modal,
   ModalBody,
 } from 'reactstrap';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import EditorContainer from '~/client/services/EditorContainer';
 import { getDiagramsNetLangCode } from '~/client/util/locale-utils';
 
 class DrawioModal extends React.PureComponent {
@@ -168,11 +165,8 @@ class DrawioModal extends React.PureComponent {
 }
 
 DrawioModal.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-
   onSave: PropTypes.func,
 };
 
 
-export default withUnstatedContainers(DrawioModal, [AppContainer, EditorContainer]);
+export default DrawioModal;

+ 5 - 15
packages/app/src/components/PageEditor/Preview.tsx

@@ -2,14 +2,11 @@ import React, {
   useCallback, useEffect, useMemo, useState, SyntheticEvent, RefObject,
 } from 'react';
 
-
-import AppContainer from '~/client/services/AppContainer';
 import InterceptorManager from '~/services/interceptor-manager';
 import { RendererOptions } from '~/services/renderer/renderer';
 import { useEditorSettings } from '~/stores/editor';
 
 import RevisionBody from '../Page/RevisionBody';
-import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 declare const interceptorManager: InterceptorManager;
@@ -23,9 +20,7 @@ type Props = {
   onScroll?: (scrollTop: number) => void,
 }
 
-type UnstatedProps = Props & { appContainer: AppContainer };
-
-const Preview = React.forwardRef((props: UnstatedProps, ref: RefObject<HTMLDivElement>): JSX.Element => {
+const Preview = React.forwardRef((props: Props, ref: RefObject<HTMLDivElement>): JSX.Element => {
 
   const {
     rendererOptions,
@@ -108,16 +103,11 @@ const Preview = React.forwardRef((props: UnstatedProps, ref: RefObject<HTMLDivEl
 
 Preview.displayName = 'Preview';
 
-/**
- * Wrapper component for using unstated
- */
-const PreviewWrapper = withUnstatedContainers(Preview, [AppContainer]);
-
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-const PreviewWrapper2 = React.forwardRef((props: Props, ref: RefObject<HTMLDivElement>): JSX.Element => {
-  return <PreviewWrapper ref={ref} {...props} />;
+const PreviewWrapper = React.forwardRef((props: Props, ref: RefObject<HTMLDivElement>): JSX.Element => {
+  return <Preview ref={ref} {...props} />;
 });
 
-PreviewWrapper2.displayName = 'PreviewWrapper2';
+PreviewWrapper.displayName = 'PreviewWrapper';
 
-export default PreviewWrapper2;
+export default PreviewWrapper;

+ 7 - 11
packages/app/src/components/PageTimeline.tsx

@@ -10,24 +10,23 @@ import { useTimelineOptions } from '~/stores/renderer';
 import RevisionLoader from './Page/RevisionLoader';
 import PaginationWrapper from './PaginationWrapper';
 
-
-const PageTimeline = (): JSX.Element => {
-  const { t } = useTranslation();
-
+export const PageTimeline = (): JSX.Element => {
   const [activePage, setActivePage] = useState(1);
   const [totalPageItems, setTotalPageItems] = useState(0);
-  const [limit, setLimit] = useState();
+  const [limit, setLimit] = useState(10);
   const [pages, setPages] = useState<IPageHasId[] | null>(null);
 
   const { data: currentPagePath } = useCurrentPagePath();
+  const { t } = useTranslation();
   const { data: rendererOptions } = useTimelineOptions();
 
-  const handlePage = useCallback(async(selectedPageNum: number) => {
-    const res = await apiv3Get('/pages/list', { path: currentPagePath, page: selectedPageNum });
+  const handlePage = useCallback(async(selectedPage: number) => {
+    if (currentPagePath == null) { return }
+    const res = await apiv3Get('/pages/list', { path: currentPagePath, selectedPage });
     setTotalPageItems(res.data.totalCount);
     setPages(res.data.pages);
     setLimit(res.data.limit);
-    setActivePage(selectedPageNum);
+    setActivePage(selectedPage);
   }, [currentPagePath]);
 
   useEffect(() => {
@@ -76,7 +75,4 @@ const PageTimeline = (): JSX.Element => {
       />
     </div>
   );
-
 };
-
-export default PageTimeline;

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

@@ -24,7 +24,7 @@ import { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/
 import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
 import { SubNavButtons } from '../Navbar/SubNavButtons';
 import RevisionLoader from '../Page/RevisionLoader';
-import PageComment from '../PageComment';
+import { PageComment } from '../PageComment';
 import PageContentFooter from '../PageContentFooter';
 
 
@@ -214,7 +214,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
           revisionId={page.revision}
           highlightKeywords={highlightKeywords}
         />
-        <PageComment appContainer={appContainer} pageId={page._id} highlightKeywords={highlightKeywords} isReadOnly hideIfEmpty />
+        <PageComment pageId={page._id} highlightKeywords={highlightKeywords} isReadOnly hideIfEmpty />
         <PageContentFooter
           createdAt={new Date(pageWithMeta.data.createdAt)}
           updatedAt={new Date(pageWithMeta.data.updatedAt)}

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

@@ -86,7 +86,7 @@ class ShareLink extends React.Component {
     const { t } = this.props;
 
     return (
-      <div className="container p-0">
+      <div className="container p-0" data-testid="share-link-management">
         <h3 className="grw-modal-head d-flex pb-2">
           { t('share_links.share_link_list') }
           <button className="btn btn-danger ml-auto " type="button" onClick={this.deleteAllLinksButtonHandler}>{t('delete_all')}</button>

+ 6 - 2
packages/app/src/components/Skelton.tsx

@@ -12,10 +12,14 @@ export const Skelton = (props: SkeltonProps): JSX.Element => {
     width, height, additionalClass, roundedPill,
   } = props;
 
-  const style = {
+  const skeltonStyle = {
     width,
     height,
   };
 
-  return <div style={style} className={`grw-skelton ${additionalClass} ${roundedPill ? 'rounded-pill' : ''}`}></div>;
+  return (
+    <div style={skeltonStyle} className={`${additionalClass}`}>
+      <div className={`grw-skelton h-100 w-100 ${roundedPill ? 'rounded-pill' : ''}`}></div>
+    </div>
+  );
 };

+ 2 - 1
packages/app/src/components/UnstatedUtils.tsx

@@ -2,7 +2,7 @@
 
 import React from 'react';
 
-import { Subscribe } from 'unstated';
+import { Provider, Subscribe } from 'unstated';
 
 /**
  * generate K/V object by specified instances
@@ -15,6 +15,7 @@ import { Subscribe } from 'unstated';
  *     exampleContainer: <ExampleContainer />,
  *   }
  */
+
 function generateAutoNamedProps(instances) {
   const props = {};
 

+ 2 - 0
packages/app/src/interfaces/activity.ts

@@ -376,6 +376,8 @@ export const SmallActionGroup = {
   ACTION_USER_LOGOUT,
   ACTION_PAGE_CREATE,
   ACTION_PAGE_DELETE,
+  ACTION_PAGE_DELETE_COMPLETELY,
+  ACTION_PAGE_EMPTY_TRASH,
 } as const;
 
 // SmallActionGroup + Action by all General Users - PAGE_VIEW

+ 3 - 4
packages/app/src/interfaces/in-app-notification.ts

@@ -1,6 +1,5 @@
-import { Types } from 'mongoose';
-import { IUser } from './user';
 import { IPage } from './page';
+import { IUser } from './user';
 
 export enum InAppNotificationStatuses {
   STATUS_UNREAD = 'UNREAD',
@@ -54,7 +53,7 @@ export interface ISubscribeRule {
   name: subscribeRuleNames;
   isEnabled: boolean;
 }
-export interface IInAppNotificationSettings {
-  userId: Types.ObjectId;
+export interface IInAppNotificationSettings<UserID> {
+  userId: UserID | string;
   subscribeRules: ISubscribeRule[];
 }

+ 28 - 0
packages/app/src/interfaces/unstated-container.ts

@@ -0,0 +1,28 @@
+import AdminAppContainer from '~/client/services/AdminAppContainer';
+import AdminBasicSecurityContainer from '~/client/services/AdminBasicSecurityContainer';
+import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
+import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
+import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
+import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
+import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
+import AdminHomeContainer from '~/client/services/AdminHomeContainer';
+import AdminImportContainer from '~/client/services/AdminImportContainer';
+import AdminLdapSecurityContainer from '~/client/services/AdminLdapSecurityContainer';
+import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer';
+import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
+import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
+import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer';
+import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
+import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
+import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
+import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+
+type AdminUnstatedContainers =
+  AdminAppContainer | AdminBasicSecurityContainer | AdminCustomizeContainer | AdminExternalAccountsContainer |
+  AdminGeneralSecurityContainer | AdminGitHubSecurityContainer | AdminGoogleSecurityContainer | AdminHomeContainer |
+  AdminImportContainer | AdminLdapSecurityContainer | AdminLocalSecurityContainer | AdminMarkDownContainer |
+  AdminNotificationContainer | AdminOidcSecurityContainer | AdminSamlSecurityContainer | AdminSlackIntegrationLegacyContainer |
+  AdminTwitterSecurityContainer | AdminUserGroupDetailContainer | AdminUsersContainer;
+
+export type AdminInjectableContainers = AdminUnstatedContainers[];

+ 1 - 1
packages/app/src/migrations/20180926134048-make-email-unique.js

@@ -1,6 +1,6 @@
 import mongoose from 'mongoose';
 
-import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
+import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:make-email-unique');

+ 1 - 1
packages/app/src/migrations/20180927102719-init-serverurl.js

@@ -1,8 +1,8 @@
-import { getMongoUri, mongoOptions } from '@growi/core';
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
 import Config from '~/server/models/config';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:init-serverurl');

Some files were not shown because too many files changed in this diff