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

Merge branch 'imprv/reactify-admin' into imprv/reactify-admin-user-groups-detail

mizozobu 6 лет назад
Родитель
Сommit
77ad9c0f34
100 измененных файлов с 5717 добавлено и 4101 удалено
  1. 0 16
      .babelrc
  2. 3 5
      .eslintrc.js
  3. 56 1
      CHANGES.md
  4. 1 0
      README.md
  5. 1 1
      THIRD-PARTY-NOTICES.md
  6. 23 0
      babel.config.js
  7. 6 1
      bin/generate-plugin-definitions-source.js
  8. 1 1
      bin/templates/plugin-definitions.js.swig
  9. 54 0
      config/jest.config.js
  10. 5 0
      config/logger/config.dev.js
  11. 3 0
      config/migrate.js
  12. 3 2
      config/webpack.common.js
  13. 4 2
      config/webpack.dev.dll.js
  14. 33 29
      package.json
  15. 0 21
      public/images/admin/security/passport-logo.svg
  16. 8 0
      resource/cdn-manifests.js
  17. 75 77
      resource/locales/en-US/translation.json
  18. 50 67
      resource/locales/ja/translation.json
  19. 87 577
      src/client/js/app.js
  20. 15 3
      src/client/js/components/Admin/AdminRebuildSearch.jsx
  21. 120 0
      src/client/js/components/Admin/UserGroup/UserGroupCreateForm.jsx
  22. 206 0
      src/client/js/components/Admin/UserGroup/UserGroupDeleteModal.jsx
  23. 163 0
      src/client/js/components/Admin/UserGroup/UserGroupPage.jsx
  24. 122 0
      src/client/js/components/Admin/UserGroup/UserGroupTable.jsx
  25. 0 260
      src/client/js/components/GroupDeleteModal/GroupDeleteModal.jsx
  26. 19 5
      src/client/js/components/LikeButton.jsx
  27. 196 0
      src/client/js/components/MyDraftList/Draft.jsx
  28. 55 24
      src/client/js/components/MyDraftList/MyDraftList.jsx
  29. 55 22
      src/client/js/components/Page.jsx
  30. 19 6
      src/client/js/components/Page/RevisionLoader.jsx
  31. 3 3
      src/client/js/components/Page/RevisionPath.jsx
  32. 34 19
      src/client/js/components/Page/RevisionRenderer.jsx
  33. 10 49
      src/client/js/components/Page/TagEditor.jsx
  34. 94 36
      src/client/js/components/Page/TagLabels.jsx
  35. 16 4
      src/client/js/components/Page/TagsInput.jsx
  36. 23 9
      src/client/js/components/PageAttachment.jsx
  37. 147 30
      src/client/js/components/PageComment/Comment.jsx
  38. 348 0
      src/client/js/components/PageComment/CommentEditor.jsx
  39. 100 0
      src/client/js/components/PageComment/CommentEditorLazyRenderer.jsx
  40. 0 381
      src/client/js/components/PageComment/CommentForm.jsx
  41. 1 2
      src/client/js/components/PageComment/DeleteCommentModal.jsx
  42. 0 256
      src/client/js/components/PageComments.js
  43. 248 0
      src/client/js/components/PageComments.jsx
  44. 80 91
      src/client/js/components/PageEditor.jsx
  45. 0 1
      src/client/js/components/PageEditor/AbstractEditor.jsx
  46. 0 0
      src/client/js/components/PageEditor/Cheatsheet.jsx
  47. 56 35
      src/client/js/components/PageEditor/CodeMirrorEditor.jsx
  48. 17 10
      src/client/js/components/PageEditor/Editor.jsx
  49. 0 0
      src/client/js/components/PageEditor/MarkdownTableUtil.jsx
  50. 56 54
      src/client/js/components/PageEditor/OptionsSelector.jsx
  51. 0 47
      src/client/js/components/PageEditor/Preview.js
  52. 50 0
      src/client/js/components/PageEditor/Preview.jsx
  53. 0 0
      src/client/js/components/PageEditor/SimpleCheatsheet.jsx
  54. 0 0
      src/client/js/components/PageEditor/TextAreaEditor.jsx
  55. 83 90
      src/client/js/components/PageEditorByHackmd.jsx
  56. 0 167
      src/client/js/components/PageList/Draft.jsx
  57. 1 3
      src/client/js/components/PageList/PagePath.js
  58. 31 44
      src/client/js/components/PageStatusAlert.jsx
  59. 0 28
      src/client/js/components/ReactUtils.js
  60. 22 11
      src/client/js/components/RecentCreated/RecentCreated.jsx
  61. 62 53
      src/client/js/components/SavePageControls.jsx
  62. 35 20
      src/client/js/components/SavePageControls/GrantSelector.jsx
  63. 15 3
      src/client/js/components/SearchForm.js
  64. 15 8
      src/client/js/components/SearchPage.js
  65. 15 3
      src/client/js/components/SearchPage/SearchPageForm.js
  66. 26 22
      src/client/js/components/SearchPage/SearchResult.js
  67. 16 8
      src/client/js/components/SearchPage/SearchResultList.js
  68. 15 4
      src/client/js/components/SearchTypeahead.js
  69. 22 38
      src/client/js/components/SlackNotification.jsx
  70. 114 0
      src/client/js/components/StaffCredit/Contributor.js
  71. 124 0
      src/client/js/components/StaffCredit/StaffCredit.jsx
  72. 61 0
      src/client/js/components/UnstatedUtils.jsx
  73. 16 3
      src/client/js/components/User/UserPictureList.jsx
  74. 1 2
      src/client/js/ie11-polyfill.js
  75. 1 1
      src/client/js/installer.jsx
  76. 60 37
      src/client/js/legacy/crowi.js
  77. 15 8
      src/client/js/plugin.js
  78. 359 0
      src/client/js/services/AppContainer.js
  79. 124 0
      src/client/js/services/CommentContainer.js
  80. 191 0
      src/client/js/services/EditorContainer.js
  81. 361 0
      src/client/js/services/PageContainer.js
  82. 61 0
      src/client/js/services/TagContainer.js
  83. 40 0
      src/client/js/services/WebsocketContainer.js
  84. 0 297
      src/client/js/util/Crowi.js
  85. 46 54
      src/client/js/util/GrowiRenderer.js
  86. 0 0
      src/client/js/util/PostProcessor/.keep
  87. 0 82
      src/client/js/util/PostProcessor/CrowiTemplate.js
  88. 37 0
      src/client/js/util/apiNotification.js
  89. 20 0
      src/client/js/util/apiv3ErrorHandler.js
  90. 0 0
      src/client/js/util/i18n.js
  91. 8 12
      src/client/js/util/reveal/plugins/growi-renderer.js
  92. 28 16
      src/client/styles/agile-admin/inverse/colors/antarctic.scss
  93. 51 51
      src/client/styles/bootstrap4/_alert.scss
  94. 47 47
      src/client/styles/bootstrap4/_badge.scss
  95. 41 38
      src/client/styles/bootstrap4/_breadcrumb.scss
  96. 172 166
      src/client/styles/bootstrap4/_button-group.scss
  97. 143 143
      src/client/styles/bootstrap4/_buttons.scss
  98. 301 270
      src/client/styles/bootstrap4/_card.scss
  99. 236 191
      src/client/styles/bootstrap4/_carousel.scss
  100. 35 34
      src/client/styles/bootstrap4/_close.scss

+ 0 - 16
.babelrc

@@ -1,16 +0,0 @@
-{
-  "plugins": [
-    "lodash",
-    ["transform-runtime", {
-      "regenerator": true
-    }]
-  ],
-  "presets": [
-    ["env", {
-      "targets": {
-        "browsers": ["last 2 versions"]
-      }
-    }],
-    "react"
-  ]
-}

+ 3 - 5
.eslintrc.js

@@ -2,10 +2,11 @@ module.exports = {
   extends: [
     'weseek',
     'weseek/react',
+    "plugin:jest/recommended",
   ],
   env: {
-    mocha: true,
     jquery: true,
+    "jest/globals": true,
   },
   globals: {
     $: true,
@@ -15,7 +16,7 @@ module.exports = {
     window: true,
   },
   plugins: [
-    'chai-friendly',
+    "jest",
   ],
   rules: {
     'indent': [
@@ -35,8 +36,5 @@ module.exports = {
     ],
     // eslint-plugin-import rules
     'import/no-unresolved': [2, { ignore: ['^@'] }], // ignore @alias/..., @commons/..., ...
-    // eslint-plugin-chai-friendly rules
-    'no-unused-expressions': 0,
-    'chai-friendly/no-unused-expressions': 2,
   },
 };

+ 56 - 1
CHANGES.md

@@ -1,10 +1,65 @@
 # CHANGES
 
-## 3.4.7-RC
+## 3.5.1-RC
+
+### BREAKING CHANGES
+
+* GROWI no longer supports plugins with schema version 2
+* The restriction mode of the root page (`/`) will be set 'Public'
+* The restriction mode of the root page (`/`) can not be changed after v 3.5.1
+
+### Updates
+
+* Support: Use Babel 7
+* Support: Support plugins with schema version 3
+
+
+## 3.5.0
+
+### BREAKING CHANGES
+
+* GROWI no longer supports
+    * Protection system with Basic Authentication
+    * Crowi Classic Authentication Mechanism
+    * [Crowi Template syntax](https://medium.com/crowi-book/crowi-v1-5-0-5a62e7c6be90)
+
+Upgrading Guide: https://docs.growi.org/guide/upgrading/35x.html
+
+### Updates
+
+* Feature: Comment Thread
+* Feature: OpenID Connect authentication
+* Feature: HTTP Basic authentication
+* Feature: Staff Credits with [Konami Code](https://en.wikipedia.org/wiki/Konami_Code)
+* Feature: Restricte Complete Deletion of Pages
+* Improvement Draft list
+* Fix: Deleting page completely
+* Fix: Search with `prefix:` param with CJK pathname
+* I18n: User Management Details
+* I18n: Group Management Details
+* Support: Apply unstated
+* Support: Abolish Old Config API
+* Support: Apply Jest for Tests
+* Support: Upgrade libs
+    * async
+    * axios
+    * connect-mongo
+    * file-loader
+    * googleapis
+    * i18next
+    * migrate-mongo
+    * mini-css-extract-plugin
+    * mongoose
+    * mongoose-gridfs
+    * mongoose-unique-validator
+    * null-loader
+
+## 3.4.7
 
 * Improvement: Handle private pages on group deletion
 * Fix: Searching with `tag:xxx` syntax doesn't work
 * Fix: Check CSRF when updating user data
+* Fix: `createdAt` field initialization
 * I18n: Import data page
 * I18n: Group Management page
 

+ 1 - 0
README.md

@@ -201,6 +201,7 @@ Documentation
 ==============
 
 - [GROWI Docs](https://docs.growi.org/)
+- [GROWI Developers Wiki (ja)](https://dev.growi.org/)
 
 
 Contribution

+ 1 - 1
THIRD-PARTY-NOTICES.md

@@ -96,4 +96,4 @@ https://creativecommons.org/licenses/by-sa/3.0/
 
 ```
 Copyright (c) 2018 Stephen Hutchings
-```
+```

+ 23 - 0
babel.config.js

@@ -0,0 +1,23 @@
+module.exports = function(api) {
+  api.cache(true);
+
+  const presets = [
+    [
+      '@babel/preset-env',
+      {
+        targets: {
+          node: 'current',
+        },
+      },
+    ],
+    '@babel/preset-react',
+  ];
+  const plugins = [
+    'lodash',
+  ];
+
+  return {
+    presets,
+    plugins,
+  };
+};

+ 6 - 1
bin/generate-plugin-definitions-source.js

@@ -4,13 +4,14 @@
  * @author Yuki Takei <yuki@weseek.co.jp>
  */
 require('module-alias/register');
+const logger = require('@alias/logger')('growi:bin:generate-plugin-definitions-source');
 
 const fs = require('graceful-fs');
 const normalize = require('normalize-path');
 const swig = require('swig-templates');
 
 const helpers = require('@commons/util/helpers');
-const PluginUtils = require('../src/server/plugins/plugin-utils');
+const PluginUtils = require('@server/plugins/plugin-utils');
 
 const pluginUtils = new PluginUtils();
 
@@ -20,12 +21,16 @@ const OUT = helpers.root('tmp/plugins/plugin-definitions.js');
 
 // list plugin names
 let pluginNames = pluginUtils.listPluginNames(helpers.root());
+logger.info('Detected plugins: ', pluginNames);
+
 // add from PLUGIN_NAMES_TOBE_LOADED when development
 if (process.env.NODE_ENV === 'development'
     && process.env.PLUGIN_NAMES_TOBE_LOADED !== undefined
     && process.env.PLUGIN_NAMES_TOBE_LOADED.length > 0) {
   const pluginNamesDev = process.env.PLUGIN_NAMES_TOBE_LOADED.split(',');
 
+  logger.info('Detected plugins from PLUGIN_NAMES_TOBE_LOADED: ', pluginNamesDev);
+
   // merge and remove duplicates
   if (pluginNamesDev.length > 0) {
     pluginNames = pluginNames.concat(pluginNamesDev);

+ 1 - 1
bin/templates/plugin-definitions.js.swig

@@ -8,7 +8,7 @@ module.exports = [
     meta: require('{{ definition.name }}'),
     entries: [
       {% for entryPath in definition.entries %}
-      require('{{ entryPath }}'),
+      require('{{ entryPath }}').default,
       {% endfor %}
     ]
   },

+ 54 - 0
config/jest.config.js

@@ -0,0 +1,54 @@
+// For a detailed explanation regarding each configuration property, visit:
+// https://jestjs.io/docs/en/configuration.html
+
+module.exports = {
+  // Indicates whether each individual test should be reported during the run
+  verbose: true,
+
+  rootDir: '../',
+
+  // Automatically clear mock calls and instances between every test
+  clearMocks: true,
+  // Automatically reset mock state between every test
+  resetMocks: true,
+
+  projects: [
+    {
+      displayName: 'server',
+      testEnvironment: 'node',
+      rootDir: '.',
+      setupFilesAfterEnv: ['<rootDir>/src/test/setup.js'],
+      testMatch: ['<rootDir>/src/test/**/*.test.js'],
+      // A map from regular expressions to module names that allow to stub out resources with a single module
+      moduleNameMapper: {
+        '@root/(.+)': '<rootDir>/$1',
+        '@commons/(.+)': '<rootDir>/src/lib/$1',
+        '@server/(.+)': '<rootDir>/src/server/$1',
+        '@alias/logger': '<rootDir>/src/lib/service/logger',
+        // -- doesn't work with unknown error -- 2019.06.19 Yuki Takei
+        // debug: '<rootDir>/src/lib/service/logger/alias-for-debug',
+      },
+    },
+    // {
+    //   displayName: 'client',
+    //   rootDir: '.',
+    //   testMatch: ['<rootDir>/src/test/client/**/*.test.js'],
+    // },
+  ],
+
+
+  // Indicates whether the coverage information should be collected while executing the test
+  // collectCoverage: false,
+
+  // An array of glob patterns indicating a set of files for which coverage information should be collected
+  collectCoverageFrom: [
+    'src/client/**/*.js',
+    'src/lib/**/*.js',
+    'src/migrations/**/*.js',
+    'src/server/**/*.js',
+  ],
+
+  // The directory where Jest should output its coverage files
+  coverageDirectory: 'coverage',
+
+};

+ 5 - 0
config/logger/config.dev.js

@@ -1,6 +1,8 @@
 module.exports = {
   default: 'info',
 
+  // 'express-session': 'debug',
+
   /*
    * configure level for server
    */
@@ -13,6 +15,7 @@ module.exports = {
   // 'growi:routes:login': 'debug',
   'growi:routes:login-passport': 'debug',
   'growi:service:PassportService': 'debug',
+  // 'growi:service:ConfigManager': 'debug',
   'growi:lib:search': 'debug',
   // 'growi:service:GlobalNotification': 'debug',
   // 'growi:lib:importer': 'debug',
@@ -27,4 +30,6 @@ module.exports = {
    * configure level for client
    */
   'growi:app': 'debug',
+  'growi:services:*': 'debug',
+  // 'growi:StaffCredit': 'debug',
 };

+ 3 - 0
config/migrate.js

@@ -5,6 +5,8 @@
  * @author Yuki Takei <yuki@weseek.co.jp>
  */
 
+require('module-alias/register');
+
 function getMongoUri(env) {
   return env.MONGOLAB_URI // for B.C.
     || env.MONGODB_URI // MONGOLAB changes their env name
@@ -15,6 +17,7 @@ function getMongoUri(env) {
 
 const mongoUri = getMongoUri(process.env);
 const match = mongoUri.match(/^(.+)\/([^/]+)$/);
+
 module.exports = {
   mongoUri,
   mongodb: {

+ 3 - 2
config/webpack.common.js

@@ -125,6 +125,7 @@ module.exports = (options) => {
 
       // ignore
       new webpack.IgnorePlugin(/^\.\/lib\/deflate\.js/, /markdown-it-plantuml/),
+      new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
 
       new LodashModuleReplacementPlugin({
         flattening: true,
@@ -155,7 +156,7 @@ module.exports = (options) => {
             enforce: true,
           },
           commons: {
-            test: /src[\\/].*\.jsx?$/,
+            test: /(src|resource)[\\/].*\.(js|jsx|json)$/,
             chunks: 'initial',
             name: 'js/commons',
             minChunks: 2,
@@ -163,7 +164,7 @@ module.exports = (options) => {
             priority: 20,
           },
           vendors: {
-            test: /node_modules[\\/].*\.jsx?$/,
+            test: /node_modules[\\/].*\.(js|jsx|json)$/,
             chunks: (chunk) => {
               // ignore patterns
               return chunk.name != null && !chunk.name.match(/legacy-presentation|ie11-polyfill|hackmd-/);

+ 4 - 2
config/webpack.dev.dll.js

@@ -11,21 +11,23 @@ module.exports = {
     dlls: [
       // Libraries
       'axios',
-      'babel-polyfill',
       'browser-bunyan', 'bunyan-format',
       'codemirror', 'react-codemirror2',
       'date-fns',
       'diff2html',
       'debug',
       'entities',
+      'growi-commons',
       'i18next', 'i18next-browser-languagedetector',
       'jquery-slimscroll',
       'lodash', 'pako',
       'markdown-it', 'csv-to-markdown-table',
       'react', 'react-dom',
-      'react-bootstrap', 'react-bootstrap-typeahead', 'react-i18next', 'react-dropzone', 'react-copy-to-clipboard',
+      'react-bootstrap', 'react-bootstrap-typeahead',
+      'react-i18next', 'react-dropzone', 'react-hotkeys', 'react-copy-to-clipboard', 'react-waypoint',
       'socket.io-client',
       'toastr',
+      'unstated',
       'xss',
     ],
   },

+ 33 - 29
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.4.7-RC",
+  "version": "3.5.1-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -55,21 +55,25 @@
     "server:prod": "env-cmd -f config/env.prod.js node src/server/app.js",
     "server": "npm run server:dev",
     "start": "npm run server:prod",
-    "test": "mocha --timeout 10000 --exit -r src/test/bootstrap.js src/test/**/*.js",
+    "test": "jest --config=config/jest.config.js --passWithNoTests -- ",
     "version": "node -p \"require('./package.json').version\"",
     "webpack": "webpack"
   },
   "dependencies": {
-    "async": "^2.3.0",
+    "//": [
+      "check-node-version: see https://github.com/parshap/check-node-version/issues/35",
+      "mongoose: somehow GlobalNotificationSetting CRUD does not work with mongoose v5.6.0"
+    ],
+    "async": "^3.0.1",
     "aws-sdk": "^2.88.0",
-    "axios": "^0.18.0",
+    "axios": "^0.19.0",
     "basic-auth-connect": "~1.0.0",
     "body-parser": "^1.18.2",
     "bunyan": "^1.8.12",
     "bunyan-format": "^0.2.1",
-    "check-node-version": "^3.1.1",
+    "check-node-version": "=3.3.0",
     "connect-flash": "~0.1.1",
-    "connect-mongo": "^2.0.1",
+    "connect-mongo": "^3.0.0",
     "connect-redis": "^3.3.0",
     "cookie-parser": "^1.4.3",
     "cross-env": "^5.0.5",
@@ -85,37 +89,38 @@
     "express-form": "~0.12.0",
     "express-sanitizer": "^1.0.4",
     "express-session": "^1.16.1",
+    "express-validator": "^5.3.1",
     "express-webpack-assets": "^0.1.0",
-    "googleapis": "^39.1.0",
     "graceful-fs": "^4.1.11",
     "growi-commons": "^4.0.1",
     "helmet": "^3.13.0",
-    "i18next": "^15.0.9",
+    "i18next": "^17.0.3",
     "i18next-express-middleware": "^1.4.1",
     "i18next-node-fs-backend": "^2.1.0",
     "i18next-sprintf-postprocessor": "^0.2.2",
     "md5": "^2.2.1",
     "method-override": "^3.0.0",
-    "migrate-mongo": "^5.0.1",
+    "migrate-mongo": "^6.0.0",
     "mkdirp": "~0.5.1",
     "module-alias": "^2.0.6",
-    "mongoose": "^5.4.4",
-    "mongoose-gridfs": "^1.0.1",
+    "mongoose": "5.4.4",
+    "mongoose-gridfs": "^1.2.2",
     "mongoose-paginate": "^5.0.3",
-    "mongoose-unique-validator": "^2.0.2",
+    "mongoose-unique-validator": "^2.0.3",
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "nodemailer": "^6.0.0",
     "nodemailer-ses-transport": "~1.5.0",
     "npm-run-all": "^4.1.2",
+    "openid-client": "^2.5.0",
     "passport": "^0.4.0",
     "passport-github": "^1.1.0",
     "passport-google-auth": "^1.0.2",
+    "passport-http": "^0.3.0",
     "passport-ldapauth": "^2.0.0",
     "passport-local": "^1.0.0",
     "passport-saml": "^1.0.0",
     "passport-twitter": "^1.0.4",
-    "react-dropzone": "^10.1.3",
     "rimraf": "^2.6.1",
     "slack-node": "^0.1.8",
     "socket.io": "^2.0.3",
@@ -128,27 +133,26 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.0.16",
+    "@babel/core": "^7.4.5",
+    "@babel/polyfill": "^7.4.4",
+    "@babel/preset-env": "^7.4.5",
+    "@babel/preset-react": "^7.0.0",
     "@handsontable/react": "^2.0.0",
     "autoprefixer": "^9.0.0",
-    "babel-core": "^6.25.0",
     "babel-eslint": "^10.0.1",
-    "babel-loader": "^7.1.1",
-    "babel-plugin-lodash": "^3.3.2",
-    "babel-plugin-transform-runtime": "^6.23.0",
-    "babel-polyfill": "^6.26.0",
-    "babel-preset-env": "^1.6.0",
-    "babel-preset-react": "^6.24.1",
+    "babel-loader": "^8.0.6",
+    "babel-plugin-lodash": "^3.3.4",
     "bootstrap-sass": "^3.4.1",
     "bootstrap-select": "^1.12.4",
     "browser-bunyan": "^1.3.0",
     "browser-sync": "^2.26.3",
     "bunyan-debug": "^2.0.0",
-    "chai": "^4.1.0",
     "cli": "~1.0.1",
     "codemirror": "^5.42.0",
     "colors": "^1.2.5",
     "commander": "^2.11.0",
     "connect-browser-sync": "^2.1.0",
+    "core-js": "^2.6.9",
     "css-loader": "^1.0.0",
     "csv-to-markdown-table": "^0.5.0",
     "date-fns": "^1.29.0",
@@ -156,13 +160,14 @@
     "eazy-logger": "^3.0.2",
     "eslint": "^5.15.1",
     "eslint-config-weseek": "^1.0.1",
-    "eslint-plugin-chai-friendly": "^0.4.1",
     "eslint-plugin-import": "^2.16.0",
+    "eslint-plugin-jest": "^22.6.4",
     "eslint-plugin-react": "^7.12.4",
-    "file-loader": "^3.0.1",
+    "file-loader": "^4.0.0",
     "handsontable": "^6.0.1",
     "i18next-browser-languagedetector": "^3.0.1",
     "imports-loader": "^0.8.0",
+    "jest": "^24.8.0",
     "jquery-slimscroll": "^1.3.8",
     "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
@@ -179,14 +184,12 @@
     "markdown-it-toc-and-anchor-with-slugid": "^1.1.4",
     "markdown-table": "^1.1.1",
     "metismenu": "^3.0.3",
-    "mini-css-extract-plugin": "^0.6.0",
-    "mocha": "^6.0.1",
+    "mini-css-extract-plugin": "^0.7.0",
     "morgan": "^1.9.0",
     "node-dev": "^4.0.0",
     "node-sass": "^4.11.0",
-    "nodelist-foreach-polyfill": "^1.2.0",
     "normalize-path": "^3.0.0",
-    "null-loader": "^1.0.0",
+    "null-loader": "^3.0.0",
     "on-headers": "^1.0.1",
     "optimize-css-assets-webpack-plugin": "^5.0.0",
     "penpal": "^4.0.0",
@@ -199,15 +202,15 @@
     "react-codemirror2": "^6.0.0",
     "react-copy-to-clipboard": "^5.0.1",
     "react-dom": "^16.8.3",
+    "react-dropzone": "^10.1.3",
     "react-frame-component": "^4.0.0",
+    "react-hotkeys": "^1.1.4",
     "react-i18next": "^10.6.1",
     "react-waypoint": "^9.0.0",
     "replacestream": "^4.0.3",
     "reveal.js": "^3.5.0",
     "sass-loader": "^7.1.0",
     "simple-load-script": "^1.0.2",
-    "sinon": "^7.2.2",
-    "sinon-chai": "^3.3.0",
     "socket.io-client": "^2.0.3",
     "style-loader": "^0.23.0",
     "stylelint-config-recess-order": "^2.0.1",
@@ -215,6 +218,7 @@
     "terser-webpack-plugin": "^1.2.2",
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",
+    "unstated": "^2.1.1",
     "webpack": "^4.29.3",
     "webpack-assets-manifest": "^3.1.1",
     "webpack-bundle-analyzer": "^3.0.2",

+ 0 - 21
public/images/admin/security/passport-logo.svg

@@ -1,21 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<svg width="400px" height="500px" viewBox="0 0 400 500" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
-    <!-- Generator: Sketch 3.3.1 (12002) - http://www.bohemiancoding.com/sketch -->
-    <title>Group</title>
-    <desc>Created with Sketch.</desc>
-    <defs/>
-    <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
-        <g id="passport_logo_final" sketch:type="MSLayerGroup" transform="translate(-400.000000, -350.000000)">
-            <g id="Group" sketch:type="MSShapeGroup">
-                <g id="Shape">
-                    <g transform="translate(400.000000, 350.000000)">
-                        <path d="M200,0 C89.5,0 0,89.5 0,200 L100,200 C100,144.8 144.8,100 200,100 L200,0 L200,0 Z" fill="#D6FF00"/>
-                        <path d="M400,200 C400,89.5 310.5,0 200,0 L200,100 C255.2,100 300,144.8 300,200 L400,200 L400,200 Z" fill="#34E27A"/>
-                        <path d="M200,400 C310.5,400 400,310.5 400,200 L300,200 C300,255.2 255.2,300 200,300 L200,400 L200,400 Z" fill="#00B9F1"/>
-                        <path d="M100,400 L100,200 L0,200 L0,500 L200,500 L200,400 L100,400 Z" fill="#FFFFFF"/>
-                    </g>
-                </g>
-            </g>
-        </g>
-    </g>
-</svg>

+ 8 - 0
resource/cdn-manifests.js

@@ -87,6 +87,14 @@ module.exports = {
         integrity: '',
       },
     },
+    {
+      name: 'Press Start 2P',
+      url: 'https://fonts.googleapis.com/css?family=Press+Start+2P',
+      groups: ['basis'],
+      args: {
+        integrity: '',
+      },
+    },
     {
       name: 'font-awesome',
       url: 'https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css',

+ 75 - 77
resource/locales/en-US/translation.json

@@ -2,6 +2,7 @@
   "Help": "Help",
   "Edit": "Edit",
   "Delete": "Delete",
+  "Delete All": "Delete All",
   "Duplicate": "Duplicate",
   "Copy": "Copy",
   "Click to copy": "Click to copy",
@@ -13,6 +14,7 @@
   "Cancel": "Cancel",
   "Create": "Create",
   "Admin": "Admin",
+  "administrator": "Admin",
   "Tag": "Tag",
   "Tags": "Tags",
   "New": "New",
@@ -24,6 +26,7 @@
   "Page Path": "Page Path",
   "Category": "Category",
   "User": "User",
+  "status":"Status",
 
   "Update": "Update",
   "Update Page": "Update Page",
@@ -46,6 +49,7 @@
 
   "Created": "Created",
   "Last updated": "Updated",
+  "Last_Login": "Last Login",
 
   "Share": "Share",
   "Share Link": "Share Link",
@@ -77,9 +81,7 @@
   "Delete this image?": "Delete this image?",
   "Updated": "Updated",
   "Upload new image": "Upload new image",
-  "Google Setting": "Google Setting",
   "Connected": "Connected",
-  "Disconnect": "Disconnect",
   "Show": "Show",
   "Hide": "Hide",
   "Disclose E-mail": "Disclose E-mail",
@@ -102,12 +104,12 @@
   "Markdown Settings": "Markdown Settings",
   "Customize": "Customize",
   "Notification Settings": "Notification Settings",
-  "User Management": "User Management",
+  "User_Management": "User Management",
   "External Account management": "External Account management",
   "UserGroup Management": "UserGroup Management",
   "Full Text Search Management": "Full Text Search Management",
   "Import Data": "Import Data",
-  "Basic settings": "Basic settings",
+  "Basic Settings": "Basic Settings",
   "Basic authentication": "Basic authentication",
   "Guest users access": "Guest users access",
   "Register limitation": "Register limitation",
@@ -120,7 +122,6 @@
   "Reselect the group": "Reselect the group",
   "Shareable link": "Shareable link",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
-  "Selecting authentication mechanism": "Selecting authentication mechanism",
   "Add tags for this page": "Add tags for this page",
   "Edit tags for this page": "Edit tags for this page",
   "You have no tag, You can set tags on pages": "You have no tag, You can set tags on pages",
@@ -145,15 +146,13 @@
   },
 
   "breaking_changes": {
-    "v346_passport_is_not_enabled": "Crowi Classic Authentication mechanism currently in use will <strong>no longer be supported</strong> in the near future. Switch to Passport from %s",
     "v346_using_basic_auth": "Basic Authentication currently in use will <strong>no longer be available</strong> in the near future. Remove settings from %s"
   },
 
   "page_register": {
     "notice": {
       "restricted": "Admin approval required.",
-      "restricted_defail": "Once the admin approves your sign up, you'll be able to access this wiki.",
-      "google_account_continue": "Enter your user ID, name and password to continue."
+      "restricted_defail": "Once the admin approves your sign up, you'll be able to access this wiki."
     },
     "form_help": {
       "email": "You must have email address which listed below to sign up to this wiki.",
@@ -165,11 +164,7 @@
   "page_me": {
     "form_help": {
       "profile_image1": "Image upload settings not completed.",
-      "profile_image2": "Set up AWS or enable local uploads.",
-      "google_connect1": "With Google Connect, you can sign in with your Google Account.",
-      "google_connect2": "Only Google Apps accounts with the following email addresses are connectable Google accounts:",
-      "google_disconnect1": "If you disconnect your Google account, you will be unable to sign in using Google Authentication",
-      "google_disconnect2": "After disconnecting your Google account, you can sign in normally using your email and password"
+      "profile_image2": "Set up AWS or enable local uploads."
     }
   },
   "page_me_apitoken": {
@@ -191,7 +186,7 @@
   "Re-enter new password": "Re-enter new password",
   "Password is not set": "Password is not set",
 
-  "Security Settings": "Security Settings",
+  "security_settings": "Security Settings",
 
   "API Settings": "API Settings",
   "API Token Settings": "API Token Settings",
@@ -266,7 +261,8 @@
   "page_api_error": {
     "notfound_or_forbidden": "Original page is not found or forbidden.",
     "already_exists": "New page is already exists.",
-    "outdated": "Page is updated someone and now outdated. "
+    "outdated": "Page is updated someone and now outdated.",
+    "user_not_admin": "Only admin user can delete completely"
   },
 
   "modal_rename": {
@@ -287,15 +283,13 @@
   "Delete Completely": "Delete Completely",
 
   "modal_delete": {
-    "label": {
-      "Delete Page": "Delete Page",
-      "Delete recursively": "Delete recursively",
-      "Delete completely": "Delete completely"
-    },
-    "help": {
-      "recursively": "Delete children of under <code>%s</code> recursively",
-      "completely": "Delete completely instead of putting it into trash"
-    }
+    "delete_page": "Delete Page",
+    "deleting_page": "Deleting Page",
+    "delete_recursively": "Delete child pages under %s recursively.",
+    "delete_completely": "Delete Completely",
+    "delete_completely_restriction": "You have no admin to delete completely.",
+    "recursively": "Delete children of under <code>%s</code> recursively.",
+    "completely": "Delete completely instead of putting it into trash."
   },
 
   "modal_duplicate": {
@@ -321,7 +315,10 @@
       "title": "Global shortcuts",
       "Open/Close shortcut help": "Open/Close shortcut help",
       "Edit Page": "Edit Page",
-      "Create Page": "Create Page"
+      "Create Page": "Create Page",
+      "Show Contributors": "Show Contributors",
+      "Konami Code": "Konami Code",
+      "konami_code_url": "https://en.wikipedia.org/wiki/Konami_Code"
     },
     "editor": {
       "title": "Editor shortcuts",
@@ -436,12 +433,11 @@
   },
 
   "security_setting": {
-		"Basic authentication": "Basic authentication",
+		"Basic authentication": "Basic Authentication",
 		"Security settings": "Security settings",
 		"Guest users access": "Guest users access",
 		"Register limitation": "Register limitation",
 		"The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
-		"Selecting authentication mechanism": "Selecting authentication mechanism",
 		"common_authentication": "If you set the basic authentication, common authentication is applied on the whole page.",
 		"without_encryption": "Please be noted that your ID and Password will be sent wihtout encryption.",
 		"basic_acl_disable": "Because of Public Wiki  setting, basic authentication can not be used.",
@@ -455,45 +451,30 @@
     "page_listing_1_desc": "Show pages that are restricted by 'Just Me' option when listing/searching",
     "page_listing_2": "Page listing/searching<br>restricted by User Group",
     "page_listing_2_desc": "Show pages that are restricted by User Group when listing/searching",
+    "complete_deletion": "Restrict Complete Deletion of Pages",
+    "complete_deletion_explain": "Restricts users who can completely delete pages.",
+    "admin_only": "Admin Only",
+    "admin_and_author": "Admin and Author",
+    "anyone": "Anyone",
 
-		"Authentication mechanism settings": "Authentication mechanism settings",
-    "note": "Note",
-    "require_server_restart_change_auth": "Restarting the server is required if you switch the auth mechanism.",
-    "auth_mechanism": "authentication mechanism",
-    "recommended": "Recommended",
-    "username_email_password": "Username, Email and Password authentication",
+		"Authentication mechanism settings": "Authentication Mechanism Settings",
     "alert_siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the %s",
-    "ldap_auth": "LDAP authentication",
-    "saml_auth": "SAML authentication",
-    "google_auth2": "Google OAuth authentication",
-    "google_auth2_by_crowi_desc": "However, this feature does not create new users, butit only makes it possible to login to the existing user who set up the association.",
-    "facebook_auth2": "Facebook OAuth authentication",
-    "twitter_auth2": "Twitter OAuth authentication",
-    "github_auth2": "GitHub OAuth authentication",
-    "crowi_auth": "Crowi classic authentication mechanism",
-		"require_server_restart": "Restarting the server is required.",
-		"server_on_passport_auth": "The server is running with Passport authentication mechanism.",
-		"server_on_crowi_auth": "The server is running with official Crowi authentication mechanism.",
-		"google_setting": "Google Setting",
-    "connect_api_manager": "You can use your Google account to sign up and login after creating OAuth2 ClientId at <a href=\"https://console.cloud.google.com/apis/credentials\" target=\"_blank\">API Manager on Google Cloud Platform</a>",
-		"access_api_manager": "Access <a href=\"%s\" target=\"_blank\">%s</a>",
-		"create_project": "Create Project if no projects have been created.",
-		"create_auth_to_oauth": "\"Create credentials\" -> \"OAuth clientID\"",
-		"select_webapp": "Select \"Web Application\"",
-    "change_redirect_url": "Enter <code>https://${crowi.host}/google/callback</code> <br>(where <code>${crowi.host}</code> is your host name) for \"Authorized redirect URIs\".",
-    "clientID": "Client ID",
-    "client_secret": "Client Secret",
     "xss_prevent_setting":"Prevent XSS(Cross Site Scripting)",
     "xss_prevent_setting_link":"Go to Markdown settings",
     "callback_URL": "Callback URL",
+    "providerName": "Provider Name",
+    "issuerHost": "Issuer Host",
+    "scope": "Scope",
     "desc_of_callback_URL": "Use it in the setting of the %s provider",
+    "clientID": "Client ID",
+    "client_secret": "Client Secret",
     "guest_mode": {
       "deny": "Deny Unregistered Users",
       "readonly": "View Only"
     },
     "registration_mode": {
       "open": "Anyone",
-      "restricted": "Reuqire Admin permission",
+      "restricted": "Require Admin permission",
       "closed": "Invitation Only"
     },
     "configuration": " Configuration",
@@ -529,8 +510,9 @@
       "group_search_base_DN_detail": "The base DN from which to search for groups. If defined, also <code>Group Search Filter</code> must be defined for the search to work.",
       "group_search_filter": "Group Search Filter",
       "group_search_filter_detail1": "The query used to filter for groups.",
-      "group_search_filter_detail2": "Use <code>&#123;&#123;dn&#125;&#125;</code> to have it replaced of the found user object.",
-      "group_search_filter_detail3": "<code>(&(cn=group1)(memberUid=&#123;&#123;dn&#125;&#125;))</code> hits the groups which has <code>cn=group1</code> and <code>memberUid</code> includes the user's <code>uid</code>(when <code>Group DN Property</code> is not changed from the default value.)",
+      "group_search_filter_detail2": "Login via LDAP is accepted only when this query hits one or more groups.",
+      "group_search_filter_detail3": "Use <code>&#123;&#123;dn&#125;&#125;</code> to have it replaced of the found user object.",
+      "group_search_filter_detail4": "<code>(&(cn=group1)(memberUid=&#123;&#123;dn&#125;&#125;))</code> hits the groups which has <code>cn=group1</code> and <code>memberUid</code> includes the user's <code>uid</code>(when <code>Group DN Property</code> is not changed from the default value.)",
       "group_search_user_DN_property": "User DN Property",
       "group_search_user_DN_property_detail": "The property of user object to use in <code>&#123;&#123;dn&#125;&#125;</code> interpolation of <code>Group Search Filter</code>.",
       "test_config": "Test Saved Configuration"
@@ -572,10 +554,21 @@
         "register_2": "Register your OAuth App with \"Authorization callback URL\" as <code>%s</code> (where <code>%s</code> is your hostname)",
         "register_3": "Copy and paste your ClientID and Client Secret above"
       },
+      "OIDC": {
+        "name": "OpenID Connect",
+        "id_detail": "Specification of the name of attribute which can identify the user in OIDC claims",
+        "username_detail": "Specification of mappings for <code>username</code> when creating new users",
+        "name_detail": "Specification of mappings for <code>name</code> when creating new users",
+        "mapping_detail": "Specification of mappings for %s when creating new users",
+        "register_1": "Contant to OIDC IdP Administrator",
+        "register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>%s</code> (where <code>%s</code> is your hostname)",
+        "register_3": "Copy and paste your ClientID and Client Secret above"
+      },
       "how_to": {
         "google": "How to configure Google OAuth?",
         "github": "How to configure GitHub OAuth?",
-        "twitter": "How to configure Twitter OAuth?"
+        "twitter": "How to configure Twitter OAuth?",
+        "oidc": "How to configure OIDC?"
       }
     },
     "form_item_name": {
@@ -672,46 +665,47 @@
   },
 
   "user_management": {
-    "User management": "User management",
-    "invite_users": "Invite new users",
+    "target_user": "Target User",
+    "invite_users": "Invite New Users",
     "emails": "Emails",
     "invite_thru_email": "Send Invitation Email",
     "invite": "Invite",
-    "give_admin_access": "Give admin access",
-    "remove_admin_access": "Remove admin access",
-    "external_account": "External account management",
-    "user_list": "List of users",
+    "invited": "User was invited",
+    "give_admin_access": "Give Admin Access",
+    "remove_admin_access": "Remove Admin Access",
+    "external_account": "External Account Management",
     "external_account_list": "External Account List",
     "back_to_user_management": "Back to User Management",
     "authentication_provider": "Authentication Provider",
-    "Date created": "Date created",
-    "Last login": "Last login",
-    "Manage": "Manage",
-    "Edit menu": "Edit menu",
+    "manage": "Manage",
+    "edit_menu": "Edit Menu",
     "password_setting": "Password Setting",
+    "password_setting_help": "Is password set?",
     "set": "Yes",
     "unset": "No",
-    "password_setting_help": "Show whether the related user has a password set",
-    "Reissue password": "Reissue password",
+    "temporary_password": "The created user has a temporary password",
+    "send_temporary_password": "Be sure to copy the temporary password ON THIS SCREEN and send it to the user.",
+    "send_new_password": "Please send the new password to the user.",
+    "password_never_seen": "The temporary password can never be retrieved after this screen is closed.",
+    "reset_password": "Reset Password",
     "related_username": "Related user's <code>%s</code>",
-    "Status":"Status",
     "accept": "Accept",
-    "Deactivate account":"Deactivate account",
+    "deactivate_account":"Deactivate Account",
     "your_own":"You cannot deactivate your own account",
-    "Administrator menu":"Administrator menu",
+    "administrator_menu":"Administrator Menu",
     "cannot_remove":"You cannot remove yourself from administrator",
     "cannot_invite_maximum_users": "Can not invite more than the maximum number of users.",
-    "current users": "Current users:"
+    "current_users": "Current users:"
   },
 
   "user_group_management": {
-    "group_list": "List of Group",
+    "group_list": "Group List",
+    "back_to_list": "Go Back to Group List",
     "create_group": "Create New Group",
     "group_example": "e.g. : Group1",
     "created_group": "Group was created",
-    "add_user": "Add a user to the created group",
+    "add_user": "Add a User to the Created Group",
     "deny_create_group": "You can't create a new group with the current settings",
-    "is_loading_data": "Loading data...",
     "choose_action": "Choose an action for private pages",
     "delete_group": "Delete Group",
     "group_name": "Group Name",
@@ -720,7 +714,11 @@
     "delete_pages": "Delete All",
     "transfer_pages": "Transfer to another group",
     "select_group": "Select a group",
-    "no_groups": "No groups to select"
+    "no_groups": "No groups to select",
+    "no_pages": "There are no pages the group has view permission",
+    "how_to_add1": "Enter a username to add",
+    "how_to_add2": "Select a user from user list",
+    "remove_from_group": "Remove this group"
   },
 
   "importer_management": {

+ 50 - 67
resource/locales/ja/translation.json

@@ -2,6 +2,7 @@
   "Help": "ヘルプ",
   "Edit": "編集",
   "Delete": "削除",
+  "Delete All": "全て削除",
   "Duplicate": "複製",
   "Copy": "コピー",
   "Click to copy": "クリックでコピー",
@@ -13,6 +14,7 @@
   "Cancel": "キャンセル",
   "Create": "作成",
   "Admin": "管理",
+  "administrator": "管理者",
   "Tag": "タグ",
   "Tags": "タグ",
   "New": "作成",
@@ -24,6 +26,7 @@
   "Page Path": "ページパス",
   "Category": "カテゴリー",
   "User": "ユーザー",
+  "status": "ステータス",
 
   "Update": "更新",
   "Update Page": "ページを更新",
@@ -46,6 +49,7 @@
 
   "Created": "作成日",
   "Last updated": "最終更新",
+  "Last_Login": "最終ログイン",
 
   "Share": "共有",
   "Share Link": "共有用リンク",
@@ -77,9 +81,7 @@
   "Delete this image?": "削除してよろしいですか?",
   "Updated": "更新しました",
   "Upload new image": "新しい画像をアップロード",
-  "Google Setting": "Google設定",
   "Connected": "接続されています",
-  "Disconnect": "接続を解除",
   "Show": "公開",
   "Hide": "非公開",
   "Disclose E-mail": "メールアドレスの公開",
@@ -102,12 +104,12 @@
   "Markdown Settings": "マークダウン設定",
   "Customize": "カスタマイズ",
   "Notification Settings": "通知設定",
-  "User Management": "ユーザー管理",
+  "User_Management": "ユーザー管理",
   "External Account management": "外部アカウント管理",
   "UserGroup Management": "グループ管理",
   "Full Text Search Management": "全文検索管理",
   "Import Data": "データインポート",
-  "Basic settings": "基本設定",
+  "Basic Settings": "基本設定",
   "Basic authentication": "Basic認証",
   "Guest users access": "ゲストユーザーのアクセス",
   "Register limitation": "登録の制限",
@@ -120,7 +122,6 @@
   "Reselect the group": "グループの再選択",
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
-  "Selecting authentication mechanism": "認証機構選択",
   "Add tags for this page": "タグを付ける",
   "Edit tags for this page": "タグを編集する",
   "You have no tag, You can set tags on pages": "使用中のタグがありません",
@@ -145,15 +146,13 @@
   },
 
   "breaking_changes": {
-    "v346_passport_is_not_enabled": "現在利用中の Crowi Classic Authentication mechanism は、近い将来<strong>サポートされなくなります</strong>。%s から Passport に切り替えてください。",
     "v346_using_basic_auth": "現在利用中の Basic 認証機能は、近い将来<strong>廃止されます</strong>。%s から設定を削除してください。"
   },
 
   "page_register": {
     "notice": {
        "restricted": "この Wiki への新規登録は制限されています。",
-       "restricted_defail": "利用を開始するには、新規登録後、管理者による承認が必要です。",
-       "google_account_continue": "ユーザーID、名前、パスワードを決めて登録を継続してください。"
+       "restricted_defail": "利用を開始するには、新規登録後、管理者による承認が必要です。"
     },
     "form_help": {
       "email": "この Wiki では以下のメールアドレスのみ登録可能です。",
@@ -165,11 +164,7 @@
   "page_me": {
     "form_help": {
       "profile_image1": "画像をアップロードをするための設定がされていません。",
-      "profile_image2": "アップロードできるようにするには、AWS またはローカルアップロードの設定をしてください。",
-      "google_connect1": "Googleコネクトをすると、Googleアカウントでログイン可能になります。",
-      "google_connect2": "コネクト可能なGoogleアカウントは、以下のメールアドレスの発行できるGoogle Appsアカウントに限られます。",
-      "google_disconnect1": "接続を解除すると、Googleでログインができなくなります。",
-      "google_disconnect2": "解除後はメールアドレスとパスワードでログインすることができます。"
+      "profile_image2": "アップロードできるようにするには、AWS またはローカルアップロードの設定をしてください。"
     }
   },
   "page_me_apitoken": {
@@ -191,7 +186,7 @@
   "Re-enter new password": "(確認用)",
   "Password is not set": "パスワードが設定されていません",
 
-  "Security Settings": "セキュリティ設定",
+  "security_settings": "セキュリティ設定",
 
   "API Settings": "API設定",
   "API Token Settings": "API Token設定",
@@ -266,7 +261,8 @@
   "page_api_error": {
     "notfound_or_forbidden": "元のページが見つからないか、アクセス権がありません。",
     "already_exists": "新しいページが既に存在しています。",
-    "outdated": "ページが他のユーザーによって更新されました。"
+    "outdated": "ページが他のユーザーによって更新されました。",
+    "user_not_admin": "権限のあるユーザーのみが完全削除できます"
   },
 
   "modal_rename": {
@@ -287,15 +283,13 @@
   "Delete Completely": "完全削除",
 
   "modal_delete": {
-    "label": {
-      "Delete Page": "ページを削除する",
-      "Delete recursively": "全ての子ページも削除",
-      "Delete completely": "完全削除"
-    },
-    "help": {
-      "recursively": "<code>%s</code> 配下のページも削除します",
-      "completely": "ゴミ箱を経由せず、完全に削除します"
-    }
+    "delete_page": "ページを削除する",
+    "deleting_page": "ページパス",
+    "delete_recursively": "全ての子ページも削除",
+    "delete_completely": "完全削除",
+    "delete_completely_restriction": "完全削除をするための権限がありません。",
+    "recursively": "<code>%s</code> 配下のページも削除します",
+    "completely": "ゴミ箱を経由せず、完全に削除します"
   },
 
   "modal_duplicate": {
@@ -321,7 +315,10 @@
       "title": "グローバルショートカット",
       "Open/Close shortcut help": "ショートカットヘルプの表示/非表示",
       "Edit Page": "ページ編集",
-      "Create Page": "ページ作成"
+      "Create Page": "ページ作成",
+      "Show Contributors": "コントリビューターを表示",
+      "Konami Code": "コナミコマンド",
+      "konami_code_url": "https://ja.wikipedia.org/wiki/コナミコマンド"
     },
     "editor": {
       "titile": "エディターショートカット",
@@ -437,11 +434,9 @@
 
   "security_setting": {
     "Basic authentication": "Basic認証",
-    "Security settings": "セキュリティ設定",
     "Guest users access": "ゲストユーザーのアクセス",
     "Register limitation": "登録の制限",
     "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
-    "Selecting authentication mechanism": "認証機構選択",
     "common_authentication": "Basic認証を設定すると、ページ全体に共通の認証がかかります。",
     "without_encryption": "IDとパスワードは暗号化されずに送信されるのでご注意下さい。",
     "basic_acl_disable": "Public Wiki の設定のため、Basic認証は利用できません。",
@@ -455,38 +450,20 @@
     "page_listing_1_desc": "ページのリスト表示や検索結果において、'自分のみ'に閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
     "page_listing_2": "ページのリスト表示と検索<br>特定グループに閲覧制限しているページ",
     "page_listing_2_desc": "ページのリスト表示や検索結果において、特定グループにのみ閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
+    "complete_deletion": "ページの完全削除",
+    "complete_deletion_explain": "ページを完全に削除できるユーザーを制限します。",
+    "admin_only": "管理者のみ可能",
+    "admin_and_author": "管理者とページ作者が可能",
+    "anyone": "誰でも可能",
 
     "Authentication mechanism settings":"認証機構設定",
-    "note": "メモ",
-    "require_server_restart_change_auth": "認証機構の変更後はサーバーを再起動してください。",
-    "auth_mechanism": "認証機構",
-    "recommended": "推奨",
-    "username_email_password": "ユーザー名、Eメール、パスワードでの認証",
     "alert_siteUrl_is_not_set": "'サイトURL' が設定されていません。%s から設定してください。",
-    "ldap_auth": "LDAP 認証",
-    "saml_auth": "SAML 認証",
-    "google_auth2": "Google OAuth 認証",
-    "google_auth2_by_crowi_desc": "ただし、この機能では新たなユーザーは作成されず、関連付け設定を行った既存ユーザーをログインできるようにするだけです。",
-    "facebook_auth2": "Facebook OAuth 認証",
-    "twitter_auth2": "Twitter OAuth 認証",
-    "github_auth2": "GitHub OAuth 認証",
-    "crowi_auth": "Crowi Classic OAuth 認証",
-    "require_server_restart": "サーバーを再起動してください。",
-    "server_on_passport_auth": "Passport 認証機構でサーバーが稼働しています。",
-    "server_on_crowi_auth": "Crowi Classic 認証機構でサーバーが稼働しています。",
-    "google_setting": "Google 設定",
-    "connect_api_manager": "Google Cloud Platform の <a href=\"https://console.cloud.google.com/apis/credentials\" target=\"_blank\">API Manager</a>から OAuth2 Client ID を作成すると、Google アカウントにコネクトして登録やログインが可能になります。",
-    "access_api_manager": "<a href=\"%s\" target=\"_blank\">%s</a> へアクセス",
-    "create_project": "プロジェクトを作成していない場合は作成してください",
-    "create_auth_to_oauth": "「認証情報を作成」-> OAuthクライアントID",
-    "select_webapp": "「ウェブアプリケーション」を選択",
-    "change_redirect_url": "承認済みのリダイレクトURLに、 <code>https://${crowi.host}/google/callback</code> を入力<br>(<code>${crowi.host}</code>は環境に合わせて変更してください)",
-    "clientID": "クライアントID",
-    "client_secret": "クライアントシークレット",
     "xss_prevent_setting":"XSS(Cross Site Scripting)対策設定",
     "xss_prevent_setting_link":"マークダウン設定ページに移動",
     "callback_URL": "コールバックURL",
     "desc_of_callback_URL": "%s プロバイダ側の設定で利用してください。",
+    "clientID": "クライアントID",
+    "client_secret": "クライアントシークレット",
     "guest_mode": {
       "deny": "アカウントを持たないユーザーはアクセス不可",
       "readonly": "閲覧のみ許可"
@@ -529,8 +506,9 @@
       "group_search_base_DN_detail": "グループ検索を実行するベース DN。利用する場合は <code>グループ検索フィルター</code> も入力する必要があります。",
       "group_search_filter": "グループ検索フィルター",
       "group_search_filter_detail1": "グループフィルターに用いるクエリ",
-      "group_search_filter_detail2": "ログイン対象ユーザーオブジェクトのプロパティーで置換する場合は <code>&#123;&#123;dn&#125;&#125;</code> を用いてください。",
-      "group_search_filter_detail3": "<code>(&(cn=group1)(memberUid=&#123;&#123;dn&#125;&#125;))</code> は <code>cn=group1</code> と、ユーザーの <code>uid</code> を含む <code>memberUid</code> を持つグループにヒットします(<code>ユーザーの DN プロパティー</code> がデフォルトから変更されていない場合)",
+      "group_search_filter_detail2": "このクエリにヒットするグループがあったときのみ、LDAPでのログインが成功します。",
+      "group_search_filter_detail3": "ログイン対象ユーザーオブジェクトのプロパティーで置換する場合は <code>&#123;&#123;dn&#125;&#125;</code> を用いてください。",
+      "group_search_filter_detail4": "<code>(&(cn=group1)(memberUid=&#123;&#123;dn&#125;&#125;))</code> は <code>cn=group1</code> と、ユーザーの <code>uid</code> を含む <code>memberUid</code> を持つグループにヒットします(<code>ユーザーの DN プロパティー</code> がデフォルトから変更されていない場合)",
       "group_search_user_DN_property": "ユーザーの DN プロパティー",
       "group_search_user_DN_property_detail": "<code>グループ検索フィルター</code> 内の <code>&#123;&#123;dn&#125;&#125;</code> で置換される、ユーザーオブジェクトのプロパティー",
       "test_config": "ログインテスト"
@@ -672,46 +650,47 @@
   },
 
   "user_management": {
-    "User Management": "ユーザー管理",
+    "target_user": "対象ユーザー",
     "invite_users": "新規ユーザーの招待",
     "emails": "メールアドレス (複数行入力で複数人招待可能)",
     "invite_thru_email": "招待をメールで送信",
     "invite": "招待する",
+    "invited": "ユーザーを招待しました",
     "give_admin_access": "管理者にする",
     "remove_admin_access": "管理者から外す",
     "external_account": "外部アカウントの管理",
-    "user_list": "ユーザー一覧",
     "external_account_list": "外部アカウント一覧",
     "back_to_user_management": "ユーザー管理に戻る",
     "authentication_provider": "認証情報プロバイダ",
-    "Date created": "作成日",
-    "Last login": "最終ログイン",
-    "Manage": "操作",
-    "Edit menu": "編集メニュー",
+    "manage": "操作",
+    "edit_menu": "編集メニュー",
     "password_setting": "パスワード設定",
     "password_setting_help": "関連付けられているユーザーがパスワードを設定しているかどうかを表示します",
     "set": "設定済み",
     "unset": "未設定",
-    "Reissue password": "パスワードの再発行",
+    "temporary_password": "作成したユーザーは仮パスワードが設定されています。",
+    "send_temporary_password": "招待メールを送っていない場合、この画面で必ず仮パスワードをコピーし、招待者へ連絡してください。",
+    "send_new_password": "新規発行したパスワードを、対象ユーザーへ連絡してください。",
+    "password_never_seen": "表示されたパスワードはこの画面を閉じると二度と表示できませんのでご注意ください。",
+    "reset_password": "パスワードの再発行",
     "related_username": "関連付けられているユーザーの <code>%s</code>",
-    "Status": "ステータス",
     "accept": "承認する",
-    "Deactivate account": "アカウント停止",
+    "deactivate_account": "アカウント停止",
     "your_own": "自分自身のアカウントを停止することはできません",
-    "Administrator menu": "管理者メニュー",
+    "administrator_menu": "管理者メニュー",
     "cannot_remove": "自分自身を管理者から外すことはできません",
     "cannot_invite_maximum_users": "ユーザーが上限に達したため招待できません。",
-    "current users": "現在のユーザー数:"
+    "current_users": "現在のユーザー数:"
   },
 
   "user_group_management": {
     "group_list": "グループ一覧",
+    "back_to_list": "グループ一覧に戻る",
     "create_group": "新規グループの作成",
     "group_example": "例: Group1",
     "created_group": "グループを作成しました",
     "add_user": "グループへのユーザー追加",
     "deny_create_group": "現在の設定では新規グループの作成はできません。",
-    "is_loading_data": "データを取得中です...",
     "choose_action": "削除するグループの限定公開ページの処理を選択してください",
     "delete_group": "グループの削除",
     "group_name": "グループ名",
@@ -720,7 +699,11 @@
     "delete_pages": "全て削除する",
     "transfer_pages": "全て他のグループに移譲する",
     "select_group": "グループを選択してください",
-    "no_groups": "グループがありません"
+    "no_groups": "グループがありません",
+    "no_pages": "グループが閲覧権限を保有するページはありません",
+    "how_to_add1": "ユーザー名を入力して追加",
+    "how_to_add2": "ユーザーを下のリストから選択",
+    "remove_from_group": "グループから外す"
   },
 
   "importer_management": {

+ 87 - 577
src/client/js/app.js

@@ -2,18 +2,11 @@
 
 import React from 'react';
 import ReactDOM from 'react-dom';
+import { Provider } from 'unstated';
 import { I18nextProvider } from 'react-i18next';
-import * as toastr from 'toastr';
 
 import loggerFactory from '@alias/logger';
 import Xss from '@commons/service/xss';
-import * as entities from 'entities';
-import i18nFactory from './i18n';
-
-
-import Crowi from './util/Crowi';
-// import CrowiRenderer from './util/CrowiRenderer';
-import GrowiRenderer from './util/GrowiRenderer';
 
 import HeaderSearchBox from './components/HeaderSearchBox';
 import SearchPage from './components/SearchPage';
@@ -22,13 +15,13 @@ import PageEditor from './components/PageEditor';
 // eslint-disable-next-line import/no-duplicates
 import OptionsSelector from './components/PageEditor/OptionsSelector';
 // eslint-disable-next-line import/no-duplicates
-import { EditorOptions, PreviewOptions } from './components/PageEditor/OptionsSelector';
+import { defaultEditorOptions, defaultPreviewOptions } from './components/PageEditor/OptionsSelector';
 import SavePageControls from './components/SavePageControls';
 import PageEditorByHackmd from './components/PageEditorByHackmd';
 import Page from './components/Page';
 import PageHistory from './components/PageHistory';
 import PageComments from './components/PageComments';
-import CommentForm from './components/PageComment/CommentForm';
+import CommentEditorLazyRenderer from './components/PageComment/CommentEditorLazyRenderer';
 import PageAttachment from './components/PageAttachment';
 import PageStatusAlert from './components/PageStatusAlert';
 import RevisionPath from './components/Page/RevisionPath';
@@ -37,6 +30,7 @@ import BookmarkButton from './components/BookmarkButton';
 import LikeButton from './components/LikeButton';
 import PagePathAutoComplete from './components/PagePathAutoComplete';
 import RecentCreated from './components/RecentCreated/RecentCreated';
+import StaffCredit from './components/StaffCredit/StaffCredit';
 import MyDraftList from './components/MyDraftList/MyDraftList';
 import UserPictureList from './components/User/UserPictureList';
 
@@ -45,8 +39,14 @@ import CustomCssEditor from './components/Admin/CustomCssEditor';
 import CustomScriptEditor from './components/Admin/CustomScriptEditor';
 import CustomHeaderEditor from './components/Admin/CustomHeaderEditor';
 import AdminRebuildSearch from './components/Admin/AdminRebuildSearch';
-import GroupDeleteModal from './components/GroupDeleteModal/GroupDeleteModal';
+import UserGroupPage from './components/Admin/UserGroup/UserGroupPage';
 
+import AppContainer from './services/AppContainer';
+import PageContainer from './services/PageContainer';
+import CommentContainer from './services/CommentContainer';
+import EditorContainer from './services/EditorContainer';
+import TagContainer from './services/TagContainer';
+import WebsocketContainer from './services/WebsocketContainer';
 
 const logger = loggerFactory('growi:app');
 
@@ -54,506 +54,95 @@ if (!window) {
   window = {};
 }
 
-const userlang = $('body').data('userlang');
-const i18n = i18nFactory(userlang);
-
 // setup xss library
 const xss = new Xss();
 window.xss = xss;
 
-const mainContent = document.querySelector('#content-main');
-let pageId = null;
-let pageRevisionId = null;
-let pageRevisionCreatedAt = null;
-let pageRevisionIdHackmdSynced = null;
-let hasDraftOnHackmd = false;
-let pageIdOnHackmd = null;
-let pagePath;
-let pageContent = '';
-let markdown = '';
-let slackChannels;
-let pageTags = [];
-let templateTagData = '';
-if (mainContent !== null) {
-  pageId = mainContent.getAttribute('data-page-id') || null;
-  pageRevisionId = mainContent.getAttribute('data-page-revision-id');
-  pageRevisionCreatedAt = +mainContent.getAttribute('data-page-revision-created');
-  pageRevisionIdHackmdSynced = mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null;
-  pageIdOnHackmd = mainContent.getAttribute('data-page-id-on-hackmd') || null;
-  hasDraftOnHackmd = !!mainContent.getAttribute('data-page-has-draft-on-hackmd');
-  pagePath = mainContent.attributes['data-path'].value;
-  slackChannels = mainContent.getAttribute('data-slack-channels') || '';
-  templateTagData = mainContent.getAttribute('data-template-tags') || '';
-  const rawText = document.getElementById('raw-text-original');
-  if (rawText) {
-    pageContent = rawText.innerHTML;
-  }
-  markdown = entities.decodeHTML(pageContent);
-}
-const isLoggedin = document.querySelector('.main-container.nologin') == null;
-
-// FIXME
-const crowi = new Crowi({
-  me: $('body').data('current-username'),
-  isAdmin: $('body').data('is-admin'),
-  csrfToken: $('body').data('csrftoken'),
-}, window);
-window.crowi = crowi;
-crowi.setConfig(JSON.parse(document.getElementById('crowi-context-hydrate').textContent || '{}'));
-if (isLoggedin) {
-  crowi.fetchUsers();
-}
-const socket = crowi.getWebSocket();
-const socketClientId = crowi.getSocketClientId();
-
-const crowiRenderer = new GrowiRenderer(crowi, null, {
-  mode: 'page',
-  isAutoSetup: false, // manually setup because plugins may configure it
-  renderToc: crowi.getCrowiForJquery().renderTocContent, // function for rendering Table Of Contents
-});
-window.crowiRenderer = crowiRenderer;
-
-// FIXME
-const isEnabledPlugins = $('body').data('plugin-enabled');
-if (isEnabledPlugins) {
-  const crowiPlugin = window.crowiPlugin;
-  crowiPlugin.installAll(crowi, crowiRenderer);
-}
-
-/**
- * receive tags from PageTagForm
- * @param {Array} tagData new tags
- */
-const setTagData = function(tagData) {
-  pageTags = tagData;
-};
-
-/**
- * component store
- */
-const componentInstances = {};
-
-/**
- * save success handler when reloading is not needed
- * @param {object} page Page instance
- */
-const saveWithShortcutSuccessHandler = function(page) {
-  const editorMode = crowi.getCrowiForJquery().getCurrentEditorMode();
-
-  // show toastr
-  toastr.success(undefined, 'Saved successfully', {
-    closeButton: true,
-    progressBar: true,
-    newestOnTop: false,
-    showDuration: '100',
-    hideDuration: '100',
-    timeOut: '1200',
-    extendedTimeOut: '150',
-  });
-
-  pageId = page._id;
-  pageRevisionId = page.revision._id;
-  pageRevisionIdHackmdSynced = page.revisionHackmdSynced;
-
-  // set page id to SavePageControls
-  componentInstances.savePageControls.setPageId(pageId);
-
-  // Page component
-  if (componentInstances.page != null) {
-    componentInstances.page.setMarkdown(page.revision.body);
-  }
-  // PageEditor component
-  if (componentInstances.pageEditor != null) {
-    const updateEditorValue = (editorMode !== 'builtin');
-    componentInstances.pageEditor.setMarkdown(page.revision.body, updateEditorValue);
-  }
-  // PageEditorByHackmd component
-  if (componentInstances.pageEditorByHackmd != null) {
-    // clear state of PageEditorByHackmd
-    componentInstances.pageEditorByHackmd.clearRevisionStatus(pageRevisionId, pageRevisionIdHackmdSynced);
-    // reset
-    if (editorMode !== 'hackmd') {
-      componentInstances.pageEditorByHackmd.setMarkdown(page.revision.body, false);
-      componentInstances.pageEditorByHackmd.reset();
-    }
-  }
-  // PageStatusAlert component
-  const pageStatusAlert = componentInstances.pageStatusAlert;
-  // clear state of PageStatusAlert
-  if (componentInstances.pageStatusAlert != null) {
-    pageStatusAlert.clearRevisionStatus(pageRevisionId, pageRevisionIdHackmdSynced);
-  }
-
-  // hidden input
-  $('input[name="revision_id"]').val(pageRevisionId);
-};
-
-const errorHandler = function(error) {
-  toastr.error(error.message, 'Error occured', {
-    closeButton: true,
-    progressBar: true,
-    newestOnTop: false,
-    showDuration: '100',
-    hideDuration: '100',
-    timeOut: '3000',
-  });
-};
-
-const saveWithShortcut = function(markdown) {
-  const editorMode = crowi.getCrowiForJquery().getCurrentEditorMode();
-
-  let revisionId = pageRevisionId;
-  // get options
-  const options = componentInstances.savePageControls.getCurrentOptionsToSave();
-  options.socketClientId = socketClientId;
-  options.pageTags = pageTags;
-
-  if (editorMode === 'hackmd') {
-    // set option to sync
-    options.isSyncRevisionToHackmd = true;
-    // use revisionId of PageEditorByHackmd
-    revisionId = componentInstances.pageEditorByHackmd.getRevisionIdHackmdSynced();
-  }
-
-  let promise;
-  if (pageId == null) {
-    promise = crowi.createPage(pagePath, markdown, options);
-  }
-  else {
-    promise = crowi.updatePage(pageId, revisionId, markdown, options);
-  }
-
-  promise
-    .then(saveWithShortcutSuccessHandler)
-    .catch(errorHandler);
-};
-
-const saveWithSubmitButtonSuccessHandler = function() {
-  crowi.clearDraft(pagePath);
-  window.location.href = pagePath;
-};
+// create unstated container instance
+const appContainer = new AppContainer();
+const websocketContainer = new WebsocketContainer(appContainer);
+const pageContainer = new PageContainer(appContainer);
+const commentContainer = new CommentContainer(appContainer);
+const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
+const tagContainer = new TagContainer(appContainer);
+const injectableContainers = [
+  appContainer, websocketContainer, pageContainer, commentContainer, editorContainer, tagContainer,
+];
 
-const saveWithSubmitButton = function(submitOpts) {
-  const editorMode = crowi.getCrowiForJquery().getCurrentEditorMode();
-  if (editorMode == null) {
-    // do nothing
-    return;
-  }
-
-  let revisionId = pageRevisionId;
-  // get options
-  const options = componentInstances.savePageControls.getCurrentOptionsToSave();
-  options.socketClientId = socketClientId;
-  options.pageTags = pageTags;
+logger.info('unstated containers have been initialized');
 
-  // set 'submitOpts.overwriteScopesOfDescendants' to options
-  options.overwriteScopesOfDescendants = submitOpts ? !!submitOpts.overwriteScopesOfDescendants : false;
+appContainer.initPlugins();
+appContainer.injectToWindow();
 
-  let promise;
-  if (editorMode === 'hackmd') {
-    // get markdown
-    promise = componentInstances.pageEditorByHackmd.getMarkdown();
-    // use revisionId of PageEditorByHackmd
-    revisionId = componentInstances.pageEditorByHackmd.getRevisionIdHackmdSynced();
-    // set option to sync
-    options.isSyncRevisionToHackmd = true;
-  }
-  else {
-    // get markdown
-    promise = Promise.resolve(componentInstances.pageEditor.getMarkdown());
-  }
-  // create or update
-  if (pageId == null) {
-    promise = promise.then((markdown) => {
-      return crowi.createPage(pagePath, markdown, options);
-    });
-  }
-  else {
-    promise = promise.then((markdown) => {
-      return crowi.updatePage(pageId, revisionId, markdown, options);
-    });
-  }
-
-  promise
-    .then(saveWithSubmitButtonSuccessHandler)
-    .catch(errorHandler);
-};
-
-// setup renderer after plugins are installed
-crowiRenderer.setup();
-
-// restore draft when the first time to edit
-const draft = crowi.findDraft(pagePath);
-if (!pageRevisionId && draft != null) {
-  markdown = draft;
-}
+const i18n = appContainer.i18n;
 
 /**
  * define components
  *  key: id of element
  *  value: React Element
  */
-const componentMappings = {
-  'search-top': <I18nextProvider i18n={i18n}><HeaderSearchBox crowi={crowi} /></I18nextProvider>,
-  'search-sidebar': <I18nextProvider i18n={i18n}><HeaderSearchBox crowi={crowi} /></I18nextProvider>,
-  'search-page': <I18nextProvider i18n={i18n}><SearchPage crowi={crowi} crowiRenderer={crowiRenderer} /></I18nextProvider>,
+let componentMappings = {
+  'search-top': <HeaderSearchBox crowi={appContainer} />,
+  'search-sidebar': <HeaderSearchBox crowi={appContainer} />,
+  'search-page': <SearchPage crowi={appContainer} />,
 
   // 'revision-history': <PageHistory pageId={pageId} />,
-  'bookmark-button': <BookmarkButton pageId={pageId} crowi={crowi} />,
-  'bookmark-button-lg': <BookmarkButton pageId={pageId} crowi={crowi} size="lg" />,
+  'tags-page': <TagsList crowi={appContainer} />,
 
-  'tags-page': <I18nextProvider i18n={i18n}><TagsList crowi={crowi} /></I18nextProvider>,
+  'create-page-name-input': <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} addTrailingSlash />,
 
-  'create-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} addTrailingSlash />,
-  'rename-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} />,
-  'duplicate-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} />,
+  'page-editor': <PageEditor />,
+  'page-editor-options-selector': <OptionsSelector crowi={appContainer} />,
+  'page-status-alert': <PageStatusAlert />,
+  'save-page-controls': <SavePageControls />,
 
+  'user-created-list': <RecentCreated />,
+  'user-draft-list': <MyDraftList />,
+
+  'staff-credit': <StaffCredit />,
 };
+
 // additional definitions if data exists
-if (pageId) {
-  componentMappings['page-comments-list'] = <PageComments pageId={pageId} revisionId={pageRevisionId} revisionCreatedAt={pageRevisionCreatedAt} crowi={crowi} crowiOriginRenderer={crowiRenderer} />;
-  componentMappings['page-attachment'] = <PageAttachment pageId={pageId} markdown={markdown} crowi={crowi} />;
-}
-if (pagePath) {
-  componentMappings.page = <Page crowi={crowi} crowiRenderer={crowiRenderer} markdown={markdown} pagePath={pagePath} onSaveWithShortcut={saveWithShortcut} />;
-  componentMappings['revision-path'] = <I18nextProvider i18n={i18n}><RevisionPath pageId={pageId} pagePath={pagePath} crowi={crowi} /></I18nextProvider>;
-  componentMappings['tag-label'] = <I18nextProvider i18n={i18n}><TagLabels crowi={crowi} pageId={pageId} sendTagData={setTagData} templateTagData={templateTagData} /></I18nextProvider>;
+if (pageContainer.state.pageId != null) {
+  componentMappings = Object.assign({
+    'page-editor-with-hackmd': <PageEditorByHackmd />,
+    'page-comments-list': <PageComments />,
+    'page-attachment':  <PageAttachment />,
+    'page-comment-write':  <CommentEditorLazyRenderer />,
+    'like-button': <LikeButton pageId={pageContainer.state.pageId} isLiked={pageContainer.state.isLiked} />,
+    'seen-user-list': <UserPictureList userIds={pageContainer.state.seenUserIds} />,
+    'liker-list': <UserPictureList userIds={pageContainer.state.likerUserIds} />,
+    'bookmark-button':  <BookmarkButton pageId={pageContainer.state.pageId} crowi={appContainer} />,
+    'bookmark-button-lg':  <BookmarkButton pageId={pageContainer.state.pageId} crowi={appContainer} size="lg" />,
+    'rename-page-name-input':  <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
+    'duplicate-page-name-input':  <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
+
+    'admin-rebuild-search': <AdminRebuildSearch crowi={appContainer} />,
+  }, componentMappings);
+}
+if (pageContainer.state.path != null) {
+  componentMappings = Object.assign({
+    // eslint-disable-next-line quote-props
+    'page': <Page />,
+    'revision-path': <RevisionPath behaviorType={appContainer.config.behaviorType} pageId={pageContainer.state.pageId} pagePath={pageContainer.state.path} />,
+    'tag-label':  <TagLabels />,
+  }, componentMappings);
 }
 
 Object.keys(componentMappings).forEach((key) => {
   const elem = document.getElementById(key);
   if (elem) {
-    componentInstances[key] = ReactDOM.render(componentMappings[key], elem);
+    ReactDOM.render(
+      <I18nextProvider i18n={i18n}>
+        <Provider inject={injectableContainers}>
+          {componentMappings[key]}
+        </Provider>
+      </I18nextProvider>,
+      elem,
+    );
   }
 });
 
-// set page if exists
-if (componentInstances.page != null) {
-  crowi.setPage(componentInstances.page);
-}
-
-// render LikeButton
-const likeButtonElem = document.getElementById('like-button');
-if (likeButtonElem) {
-  const isLiked = likeButtonElem.dataset.liked === 'true';
-  ReactDOM.render(
-    <LikeButton crowi={crowi} pageId={pageId} isLiked={isLiked} />,
-    likeButtonElem,
-  );
-}
-
-// render UserPictureList for seen-user-list
-const seenUserListElem = document.getElementById('seen-user-list');
-if (seenUserListElem) {
-  const userIdsStr = seenUserListElem.dataset.userIds;
-  const userIds = userIdsStr.split(',');
-  ReactDOM.render(
-    <UserPictureList crowi={crowi} userIds={userIds} />,
-    seenUserListElem,
-  );
-}
-// render UserPictureList for liker-list
-const likerListElem = document.getElementById('liker-list');
-if (likerListElem) {
-  const userIdsStr = likerListElem.dataset.userIds;
-  const userIds = userIdsStr.split(',');
-  ReactDOM.render(
-    <UserPictureList crowi={crowi} userIds={userIds} />,
-    likerListElem,
-  );
-}
-
-// render SavePageControls
-let savePageControls = null;
-const savePageControlsElem = document.getElementById('save-page-controls');
-if (savePageControlsElem) {
-  const grant = +savePageControlsElem.dataset.grant;
-  const grantGroupId = savePageControlsElem.dataset.grantGroup;
-  const grantGroupName = savePageControlsElem.dataset.grantGroupName;
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <SavePageControls
-        crowi={crowi}
-        onSubmit={saveWithSubmitButton}
-        ref={(elem) => {
-            if (savePageControls == null) {
-              savePageControls = elem;
-            }
-          }}
-        pageId={pageId}
-        slackChannels={slackChannels}
-        grant={grant}
-        grantGroupId={grantGroupId}
-        grantGroupName={grantGroupName}
-      />
-    </I18nextProvider>,
-    savePageControlsElem,
-  );
-  componentInstances.savePageControls = savePageControls;
-}
-
-const recentCreatedControlsElem = document.getElementById('user-created-list');
-if (recentCreatedControlsElem) {
-  let limit = crowi.getConfig().recentCreatedLimit;
-  if (limit == null) {
-    limit = 10;
-  }
-  ReactDOM.render(
-    <RecentCreated crowi={crowi} pageId={pageId} limit={limit}>
-
-    </RecentCreated>, document.getElementById('user-created-list'),
-  );
-}
-
-const myDraftControlsElem = document.getElementById('user-draft-list');
-if (myDraftControlsElem) {
-  let limit = crowi.getConfig().recentCreatedLimit;
-  if (limit == null) {
-    limit = 10;
-  }
-
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <MyDraftList
-        limit={limit}
-        crowi={crowi}
-        crowiOriginRenderer={crowiRenderer}
-      />
-    </I18nextProvider>,
-    myDraftControlsElem,
-  );
-}
-
-/*
- * HackMD Editor
- */
-// render PageEditorWithHackmd
-let pageEditorByHackmd = null;
-const pageEditorWithHackmdElem = document.getElementById('page-editor-with-hackmd');
-if (pageEditorWithHackmdElem) {
-  pageEditorByHackmd = ReactDOM.render(
-    <PageEditorByHackmd
-      crowi={crowi}
-      pageId={pageId}
-      revisionId={pageRevisionId}
-      pageIdOnHackmd={pageIdOnHackmd}
-      revisionIdHackmdSynced={pageRevisionIdHackmdSynced}
-      hasDraftOnHackmd={hasDraftOnHackmd}
-      markdown={markdown}
-      onSaveWithShortcut={saveWithShortcut}
-    />,
-    pageEditorWithHackmdElem,
-  );
-  componentInstances.pageEditorByHackmd = pageEditorByHackmd;
-}
-
-
-/*
- * PageEditor
- */
-let pageEditor = null;
-const editorOptions = new EditorOptions(crowi.editorOptions);
-const previewOptions = new PreviewOptions(crowi.previewOptions);
-// render PageEditor
-const pageEditorElem = document.getElementById('page-editor');
-if (pageEditorElem) {
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <PageEditor
-        ref={(elem) => {
-          if (pageEditor == null) {
-            pageEditor = elem;
-          }
-        }}
-        crowi={crowi}
-        crowiRenderer={crowiRenderer}
-        pageId={pageId}
-        revisionId={pageRevisionId}
-        pagePath={pagePath}
-        markdown={markdown}
-        editorOptions={editorOptions}
-        previewOptions={previewOptions}
-        onSaveWithShortcut={saveWithShortcut}
-      />
-    </I18nextProvider>,
-    pageEditorElem,
-  );
-  componentInstances.pageEditor = pageEditor;
-  // set refs for setCaretLine/forceToFocus when tab is changed
-  crowi.setPageEditor(pageEditor);
-}
-
-// render comment form
-const writeCommentElem = document.getElementById('page-comment-write');
-if (writeCommentElem) {
-  const pageCommentsElem = componentInstances['page-comments-list'];
-  const postCompleteHandler = (comment) => {
-    if (pageCommentsElem != null) {
-      pageCommentsElem.retrieveData();
-    }
-  };
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <CommentForm
-        crowi={crowi}
-        crowiOriginRenderer={crowiRenderer}
-        pageId={pageId}
-        pagePath={pagePath}
-        revisionId={pageRevisionId}
-        onPostComplete={postCompleteHandler}
-        editorOptions={editorOptions}
-        slackChannels={slackChannels}
-      />
-    </I18nextProvider>,
-    writeCommentElem,
-  );
-}
-
-// render OptionsSelector
-const pageEditorOptionsSelectorElem = document.getElementById('page-editor-options-selector');
-if (pageEditorOptionsSelectorElem) {
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <OptionsSelector
-        crowi={crowi}
-        editorOptions={editorOptions}
-        previewOptions={previewOptions}
-        onChange={(newEditorOptions, newPreviewOptions) => { // set onChange event handler
-          // set options
-          pageEditor.setEditorOptions(newEditorOptions);
-          pageEditor.setPreviewOptions(newPreviewOptions);
-          // save
-          crowi.saveEditorOptions(newEditorOptions);
-          crowi.savePreviewOptions(newPreviewOptions);
-        }}
-      />
-    </I18nextProvider>,
-    pageEditorOptionsSelectorElem,
-  );
-}
-
-// render PageStatusAlert
-let pageStatusAlert = null;
-const pageStatusAlertElem = document.getElementById('page-status-alert');
-if (pageStatusAlertElem) {
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <PageStatusAlert
-        ref={(elem) => {
-            if (pageStatusAlert == null) {
-              pageStatusAlert = elem;
-            }
-          }}
-        revisionId={pageRevisionId}
-        revisionIdHackmdSynced={pageRevisionIdHackmdSynced}
-        hasDraftOnHackmd={hasDraftOnHackmd}
-      />
-    </I18nextProvider>,
-    pageStatusAlertElem,
-  );
-  componentInstances.pageStatusAlert = pageStatusAlert;
-}
-
 // render for admin
 const adminUserGroupDetailElem = document.getElementById('admin-user-group-detail');
 if (adminUserGroupDetailElem != null) {
@@ -598,108 +187,29 @@ if (customHeaderEditorElem != null) {
     customHeaderEditorElem,
   );
 }
-const adminRebuildSearchElem = document.getElementById('admin-rebuild-search');
-if (adminRebuildSearchElem != null) {
-  ReactDOM.render(
-    <AdminRebuildSearch crowi={crowi} />,
-    adminRebuildSearchElem,
-  );
-}
-const adminGrantSelectorElem = document.getElementById('admin-delete-user-group-modal');
-if (adminGrantSelectorElem != null) {
+
+const adminUserGroupPageElem = document.getElementById('admin-user-group-page');
+if (adminUserGroupPageElem != null) {
+  const isAclEnabled = adminUserGroupPageElem.getAttribute('data-isAclEnabled') === 'true';
+
   ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <GroupDeleteModal
-        crowi={crowi}
-      />
-    </I18nextProvider>,
-    adminGrantSelectorElem,
+    <Provider inject={[]}>
+      <I18nextProvider i18n={i18n}>
+        <UserGroupPage
+          crowi={appContainer}
+          isAclEnabled={isAclEnabled}
+        />
+      </I18nextProvider>
+    </Provider>,
+    adminUserGroupPageElem,
   );
 }
 
-// notification from websocket
-function updatePageStatusAlert(page, user) {
-  const pageStatusAlert = componentInstances.pageStatusAlert;
-  if (pageStatusAlert != null) {
-    const revisionId = page.revision._id;
-    const revisionIdHackmdSynced = page.revisionHackmdSynced;
-    pageStatusAlert.setRevisionId(revisionId, revisionIdHackmdSynced);
-    pageStatusAlert.setLastUpdateUsername(user.name);
-  }
-}
-socket.on('page:create', (data) => {
-  // skip if triggered myself
-  if (data.socketClientId != null && data.socketClientId === socketClientId) {
-    return;
-  }
-
-  logger.debug({ obj: data }, `websocket on 'page:create'`); // eslint-disable-line quotes
-
-  // update PageStatusAlert
-  if (data.page.path === pagePath) {
-    updatePageStatusAlert(data.page, data.user);
-  }
-});
-socket.on('page:update', (data) => {
-  // skip if triggered myself
-  if (data.socketClientId != null && data.socketClientId === socketClientId) {
-    return;
-  }
-
-  logger.debug({ obj: data }, `websocket on 'page:update'`); // eslint-disable-line quotes
-
-  if (data.page.path === pagePath) {
-    // update PageStatusAlert
-    updatePageStatusAlert(data.page, data.user);
-    // update PageEditorByHackmd
-    const pageEditorByHackmd = componentInstances.pageEditorByHackmd;
-    if (pageEditorByHackmd != null) {
-      const page = data.page;
-      pageEditorByHackmd.setRevisionId(page.revision._id, page.revisionHackmdSynced);
-      pageEditorByHackmd.setHasDraftOnHackmd(data.page.hasDraftOnHackmd);
-    }
-  }
-});
-socket.on('page:delete', (data) => {
-  // skip if triggered myself
-  if (data.socketClientId != null && data.socketClientId === socketClientId) {
-    return;
-  }
-
-  logger.debug({ obj: data }, `websocket on 'page:delete'`); // eslint-disable-line quotes
-
-  // update PageStatusAlert
-  if (data.page.path === pagePath) {
-    updatePageStatusAlert(data.page, data.user);
-  }
-});
-socket.on('page:editingWithHackmd', (data) => {
-  // skip if triggered myself
-  if (data.socketClientId != null && data.socketClientId === socketClientId) {
-    return;
-  }
-
-  logger.debug({ obj: data }, `websocket on 'page:editingWithHackmd'`); // eslint-disable-line quotes
-
-  if (data.page.path === pagePath) {
-    // update PageStatusAlert
-    const pageStatusAlert = componentInstances.pageStatusAlert;
-    if (pageStatusAlert != null) {
-      pageStatusAlert.setHasDraftOnHackmd(data.page.hasDraftOnHackmd);
-    }
-    // update PageEditorByHackmd
-    const pageEditorByHackmd = componentInstances.pageEditorByHackmd;
-    if (pageEditorByHackmd != null) {
-      pageEditorByHackmd.setHasDraftOnHackmd(data.page.hasDraftOnHackmd);
-    }
-  }
-});
-
 // うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
   ReactDOM.render(
     <I18nextProvider i18n={i18n}>
-      <PageHistory pageId={pageId} crowi={crowi} />
+      <PageHistory pageId={pageContainer.state.pageId} crowi={appContainer} />
     </I18nextProvider>, document.getElementById('revision-history'),
   );
 });

+ 15 - 3
src/client/js/components/Admin/AdminRebuildSearch.jsx

@@ -1,7 +1,10 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-export default class AdminRebuildSearch extends React.Component {
+import { createSubscribedElement } from '../UnstatedUtils';
+import WebsocketContainer from '../../services/AppContainer';
+
+class AdminRebuildSearch extends React.Component {
 
   constructor(props) {
     super(props);
@@ -15,7 +18,7 @@ export default class AdminRebuildSearch extends React.Component {
   }
 
   componentDidMount() {
-    const socket = this.props.crowi.getWebSocket();
+    const socket = this.props.webspcketContainer.getWebSocket();
 
     socket.on('admin:addPageProgress', (data) => {
       const newStates = Object.assign(data, { isCompleted: false });
@@ -65,6 +68,15 @@ export default class AdminRebuildSearch extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const AdminRebuildSearchWrapper = (props) => {
+  return createSubscribedElement(AdminRebuildSearch, props, [WebsocketContainer]);
+};
+
 AdminRebuildSearch.propTypes = {
-  crowi: PropTypes.object.isRequired,
+  webspcketContainer: PropTypes.instanceOf(WebsocketContainer).isRequired,
 };
+
+export default AdminRebuildSearchWrapper;

+ 120 - 0
src/client/js/components/Admin/UserGroup/UserGroupCreateForm.jsx

@@ -0,0 +1,120 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class UserGroupCreateForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      name: '',
+    };
+
+    this.xss = window.xss;
+
+    this.handleChange = this.handleChange.bind(this);
+    this.handleSubmit = this.handleSubmit.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  handleChange(event) {
+    const target = event.target;
+    const value = target.type === 'checkbox' ? target.checked : target.value;
+    const name = target.name;
+
+    this.setState({
+      [name]: value,
+    });
+  }
+
+  async handleSubmit(e) {
+    e.preventDefault();
+
+    try {
+      const res = await this.props.appContainer.apiv3.post('/user-groups', {
+        name: this.state.name,
+      });
+
+      const userGroup = res.data.userGroup;
+      const userGroupId = userGroup._id;
+
+      const res2 = await this.props.appContainer.apiv3.get(`/user-groups/${userGroupId}/users`);
+
+      const { users } = res2.data;
+
+      this.props.onCreate(userGroup, users);
+
+      this.setState({ name: '' });
+
+      toastSuccess(`Created a user group "${this.xss.process(userGroup.name)}"`);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  validateForm() {
+    return this.state.name !== '';
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <div>
+        <p>
+          {this.props.isAclEnabled
+            ? (
+              <button type="button" data-toggle="collapse" className="btn btn-default" href="#createGroupForm">
+                { t('user_group_management.create_group') }
+              </button>
+            )
+            : (
+              t('user_group_management.deny_create_group')
+            )
+          }
+        </p>
+        <form onSubmit={this.handleSubmit}>
+          <div id="createGroupForm" className="collapse">
+            <div className="form-group">
+              <label htmlFor="name">{ t('user_group_management.group_name') }</label>
+              <textarea
+                id="name"
+                name="name"
+                className="form-control"
+                placeholder={t('user_group_management.group_example')}
+                value={this.state.name}
+                onChange={this.handleChange}
+              >
+              </textarea>
+            </div>
+            <button type="submit" className="btn btn-primary" disabled={!this.validateForm()}>{ t('Create') }</button>
+          </div>
+        </form>
+      </div>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupCreateFormWrapper = (props) => {
+  return createSubscribedElement(UserGroupCreateForm, props, [AppContainer]);
+};
+
+UserGroupCreateForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  isAclEnabled: PropTypes.bool,
+  onCreate: PropTypes.func.isRequired,
+};
+
+export default withTranslation()(UserGroupCreateFormWrapper);

+ 206 - 0
src/client/js/components/Admin/UserGroup/UserGroupDeleteModal.jsx

@@ -0,0 +1,206 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import Modal from 'react-bootstrap/es/Modal';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+
+/**
+ * Delete User Group Select component
+ *
+ * @export
+ * @class GrantSelector
+ * @extends {React.Component}
+ */
+class UserGroupDeleteModal extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    const { t } = this.props;
+
+    // actionName master constants
+    this.actionForPages = {
+      public: 'public',
+      delete: 'delete',
+      transfer: 'transfer',
+    };
+
+    this.availableOptions = [
+      {
+        id: 1, actionForPages: this.actionForPages.public, iconClass: 'icon-people', styleClass: '', label: t('user_group_management.publish_pages'),
+      },
+      {
+        id: 2, actionForPages: this.actionForPages.delete, iconClass: 'icon-trash', styleClass: 'text-danger', label: t('user_group_management.delete_pages'),
+      },
+      {
+        id: 3, actionForPages: this.actionForPages.transfer, iconClass: 'icon-options', styleClass: '', label: t('user_group_management.transfer_pages'),
+      },
+    ];
+
+    this.initialState = {
+      actionName: '',
+      transferToUserGroupId: '',
+    };
+
+    this.state = this.initialState;
+
+    this.xss = window.xss;
+
+    this.onHide = this.onHide.bind(this);
+    this.handleActionChange = this.handleActionChange.bind(this);
+    this.handleGroupChange = this.handleGroupChange.bind(this);
+    this.handleSubmit = this.handleSubmit.bind(this);
+    this.renderPageActionSelector = this.renderPageActionSelector.bind(this);
+    this.renderGroupSelector = this.renderGroupSelector.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  onHide() {
+    this.setState(this.initialState);
+    this.props.onHide();
+  }
+
+  handleActionChange(e) {
+    const actionName = e.target.value;
+    this.setState({ actionName });
+  }
+
+  handleGroupChange(e) {
+    const transferToUserGroupId = e.target.value;
+    this.setState({ transferToUserGroupId });
+  }
+
+  handleSubmit(e) {
+    e.preventDefault();
+
+    this.props.onDelete({
+      deleteGroupId: this.props.deleteUserGroup._id,
+      actionName: this.state.actionName,
+      transferToUserGroupId: this.state.transferToUserGroupId,
+    });
+  }
+
+  renderPageActionSelector() {
+    const { t } = this.props;
+
+    const optoins = this.availableOptions.map((opt) => {
+      const dataContent = `<i class="icon icon-fw ${opt.iconClass} ${opt.styleClass}"></i> <span class="action-name ${opt.styleClass}">${t(opt.label)}</span>`;
+      return <option key={opt.id} value={opt.actionForPages} data-content={dataContent}>{t(opt.label)}</option>;
+    });
+
+    return (
+      <select
+        name="actionName"
+        className="form-control"
+        placeholder="select"
+        value={this.state.actionName}
+        onChange={this.handleActionChange}
+      >
+        <option value="" disabled>{t('user_group_management.choose_action')}</option>
+        {optoins}
+      </select>
+    );
+  }
+
+  renderGroupSelector() {
+    const { t } = this.props;
+
+    const groups = this.props.userGroups.filter((group) => {
+      return group._id !== this.props.deleteUserGroup._id;
+    });
+
+    const options = groups.map((group) => {
+      const dataContent = `<i class="icon icon-fw icon-organization"></i> ${this.xss.process(group.name)}`;
+      return <option key={group._id} value={group._id} data-content={dataContent}>{this.xss.process(group.name)}</option>;
+    });
+
+    const defaultOptionText = groups.length === 0 ? t('user_group_management.no_groups') : t('user_group_management.select_group');
+
+    return (
+      <select
+        name="transferToUserGroupId"
+        className={`form-control ${this.state.actionName === this.actionForPages.transfer ? '' : 'd-none'}`}
+        value={this.state.transferToUserGroupId}
+        onChange={this.handleGroupChange}
+      >
+        <option value="" disabled>{defaultOptionText}</option>
+        {options}
+      </select>
+    );
+  }
+
+  validateForm() {
+    let isValid = true;
+
+    if (this.state.actionName === '') {
+      isValid = false;
+    }
+    else if (this.state.actionName === this.actionForPages.transfer) {
+      isValid = this.state.transferToUserGroupId !== '';
+    }
+
+    return isValid;
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Modal show={this.props.isShow} onHide={this.onHide}>
+        <Modal.Header className="modal-header bg-danger" closeButton>
+          <Modal.Title>
+            <i className="icon icon-fire"></i> {t('user_group_management.delete_group')}
+          </Modal.Title>
+        </Modal.Header>
+        <Modal.Body>
+          <div>
+            <span className="font-weight-bold">{t('user_group_management.group_name')}</span> : &quot;{this.props.deleteUserGroup.name}&quot;
+          </div>
+          <div className="text-danger mt-5">
+            {t('user_group_management.group_and_pages_not_retrievable')}
+          </div>
+        </Modal.Body>
+        <Modal.Footer>
+          <form className="d-flex justify-content-between" onSubmit={this.handleSubmit}>
+            <div className="d-flex">
+              {this.renderPageActionSelector()}
+              {this.renderGroupSelector()}
+            </div>
+            <button type="submit" value="" className="btn btn-sm btn-danger" disabled={!this.validateForm()}>
+              <i className="icon icon-fire"></i> {t('Delete')}
+            </button>
+          </form>
+        </Modal.Footer>
+      </Modal>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupDeleteModalWrapper = (props) => {
+  return createSubscribedElement(UserGroupDeleteModal, props, [AppContainer]);
+};
+
+UserGroupDeleteModal.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  userGroups: PropTypes.arrayOf(PropTypes.object).isRequired,
+  deleteUserGroup: PropTypes.object,
+  onDelete: PropTypes.func.isRequired,
+  isShow: PropTypes.bool.isRequired,
+  onShow: PropTypes.func.isRequired,
+  onHide: PropTypes.func.isRequired,
+};
+
+UserGroupDeleteModal.defaultProps = {
+  deleteUserGroup: {},
+};
+
+export default withTranslation()(UserGroupDeleteModalWrapper);

+ 163 - 0
src/client/js/components/Admin/UserGroup/UserGroupPage.jsx

@@ -0,0 +1,163 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+
+import UserGroupTable from './UserGroupTable';
+import UserGroupCreateForm from './UserGroupCreateForm';
+import UserGroupDeleteModal from './UserGroupDeleteModal';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class UserGroupPage extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      userGroups: [],
+      userGroupRelations: {},
+      selectedUserGroup: undefined, // not null but undefined (to use defaultProps in UserGroupDeleteModal)
+      isDeleteModalShow: false,
+    };
+
+    this.xss = window.xss;
+
+    this.showDeleteModal = this.showDeleteModal.bind(this);
+    this.hideDeleteModal = this.hideDeleteModal.bind(this);
+    this.addUserGroup = this.addUserGroup.bind(this);
+    this.deleteUserGroupById = this.deleteUserGroupById.bind(this);
+  }
+
+  async componentDidMount() {
+    await this.syncUserGroupAndRelations();
+  }
+
+  async showDeleteModal(group) {
+    try {
+      await this.syncUserGroupAndRelations();
+
+      this.setState({
+        selectedUserGroup: group,
+        isDeleteModalShow: true,
+      });
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  hideDeleteModal() {
+    this.setState({
+      selectedUserGroup: undefined,
+      isDeleteModalShow: false,
+    });
+  }
+
+  addUserGroup(userGroup, users) {
+    this.setState((prevState) => {
+      const userGroupRelations = Object.assign(prevState.userGroupRelations, {
+        [userGroup._id]: users,
+      });
+
+      return {
+        userGroups: [...prevState.userGroups, userGroup],
+        userGroupRelations,
+      };
+    });
+  }
+
+  async deleteUserGroupById({ deleteGroupId, actionName, transferToUserGroupId }) {
+    try {
+      const res = await this.props.appContainer.apiv3.delete(`/user-groups/${deleteGroupId}`, {
+        actionName,
+        transferToUserGroupId,
+      });
+
+      this.setState((prevState) => {
+        const userGroups = prevState.userGroups.filter((userGroup) => {
+          return userGroup._id !== deleteGroupId;
+        });
+
+        delete prevState.userGroupRelations[deleteGroupId];
+
+        return {
+          userGroups,
+          userGroupRelations: prevState.userGroupRelations,
+          selectedUserGroup: undefined,
+          isDeleteModalShow: false,
+        };
+      });
+
+      toastSuccess(`Deleted a group "${this.xss.process(res.data.userGroup.name)}"`);
+    }
+    catch (err) {
+      toastError(new Error('Unable to delete the group'));
+    }
+  }
+
+  async syncUserGroupAndRelations() {
+    let userGroups = [];
+    let userGroupRelations = {};
+
+    try {
+      const responses = await Promise.all([
+        this.props.appContainer.apiv3.get('/user-groups'),
+        this.props.appContainer.apiv3.get('/user-group-relations'),
+      ]);
+
+      const [userGroupsRes, userGroupRelationsRes] = responses;
+      userGroups = userGroupsRes.data.userGroups;
+      userGroupRelations = userGroupRelationsRes.data.userGroupRelations;
+
+      this.setState({
+        userGroups,
+        userGroupRelations,
+      });
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  render() {
+    return (
+      <Fragment>
+        <UserGroupCreateForm
+          isAclEnabled={this.props.isAclEnabled}
+          onCreate={this.addUserGroup}
+        />
+        <UserGroupTable
+          userGroups={this.state.userGroups}
+          userGroupRelations={this.state.userGroupRelations}
+          isAclEnabled={this.props.isAclEnabled}
+          onDelete={this.showDeleteModal}
+        />
+        <UserGroupDeleteModal
+          userGroups={this.state.userGroups}
+          deleteUserGroup={this.state.selectedUserGroup}
+          onDelete={this.deleteUserGroupById}
+          isShow={this.state.isDeleteModalShow}
+          onShow={this.showDeleteModal}
+          onHide={this.hideDeleteModal}
+        />
+      </Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupPageWrapper = (props) => {
+  return createSubscribedElement(UserGroupPage, props, [AppContainer]);
+};
+
+UserGroupPage.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  isAclEnabled: PropTypes.bool,
+};
+
+export default UserGroupPageWrapper;

+ 122 - 0
src/client/js/components/Admin/UserGroup/UserGroupTable.jsx

@@ -0,0 +1,122 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+
+class UserGroupTable extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.xss = window.xss;
+
+    this.onDelete = this.onDelete.bind(this);
+  }
+
+  onDelete(e) {
+    const { target } = e;
+    const groupId = target.getAttribute('data-user-group-id');
+    const group = this.props.userGroups.find((group) => {
+      return group._id === groupId;
+    });
+
+    this.props.onDelete(group);
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Fragment>
+        <h2>{t('user_group_management.group_list')}</h2>
+
+        <table className="table table-bordered table-user-list">
+          <thead>
+            <tr>
+              <th>{ t('Name') }</th>
+              <th>{ t('User') }</th>
+              <th width="100px">{ t('Created') }</th>
+              <th width="70px"></th>
+            </tr>
+          </thead>
+          <tbody>
+            {this.props.userGroups.map((group) => {
+              return (
+                <tr key={group._id}>
+                  {this.props.isAclEnabled
+                    ? (
+                      <td><a href={`/admin/user-group-detail/${group._id}`}>{this.xss.process(group.name)}</a></td>
+                    )
+                    : (
+                      <td>{this.xss.process(group.name)}</td>
+                    )
+                  }
+                  <td>
+                    <ul className="list-inline">
+                      {this.props.userGroupRelations[group._id].map((user) => {
+                        return <li key={user._id} className="list-inline-item badge badge-primary">{this.xss.process(user.username)}</li>;
+                      })}
+                    </ul>
+                  </td>
+                  <td>{dateFnsFormat(new Date(group.createdAt), 'YYYY-MM-DD')}</td>
+                  {this.props.isAclEnabled
+                    ? (
+                      <td>
+                        <div className="btn-group admin-group-menu">
+                          <button type="button" className="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
+                            <i className="icon-settings"></i> <span className="caret"></span>
+                          </button>
+                          <ul className="dropdown-menu" role="menu">
+                            <li>
+                              <a href={`/admin/user-group-detail/${group._id}`}>
+                                <i className="icon-fw icon-note"></i> { t('Edit') }
+                              </a>
+                            </li>
+
+                            <li>
+                              <a href="#" onClick={this.onDelete} data-user-group-id={group._id}>
+                                <i className="icon-fw icon-fire text-danger"></i> { t('Delete') }
+                              </a>
+                            </li>
+
+                          </ul>
+                        </div>
+                      </td>
+                    )
+                    : (
+                      <td></td>
+                    )
+                  }
+                </tr>
+              );
+            })}
+          </tbody>
+        </table>
+      </Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupTableWrapper = (props) => {
+  return createSubscribedElement(UserGroupTable, props, [AppContainer]);
+};
+
+
+UserGroupTable.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  userGroups: PropTypes.arrayOf(PropTypes.object).isRequired,
+  userGroupRelations: PropTypes.object.isRequired,
+  isAclEnabled: PropTypes.bool,
+  onDelete: PropTypes.func.isRequired,
+};
+
+export default withTranslation()(UserGroupTableWrapper);

+ 0 - 260
src/client/js/components/GroupDeleteModal/GroupDeleteModal.jsx

@@ -1,260 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import * as toastr from 'toastr';
-
-/**
- * Delete User Group Select component
- *
- * @export
- * @class GrantSelector
- * @extends {React.Component}
- */
-class GroupDeleteModal extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    const { t } = this.props;
-
-    // actionName master constants
-    this.actionForPages = {
-      public: 'public',
-      delete: 'delete',
-      transfer: 'transfer',
-    };
-
-    this.availableOptions = [
-      {
-        id: 1, actionForPages: this.actionForPages.public, iconClass: 'icon-people', styleClass: '', label: t('user_group_management.publish_pages'),
-      },
-      {
-        id: 2, actionForPages: this.actionForPages.delete, iconClass: 'icon-trash', styleClass: 'text-danger', label: t('user_group_management.delete_pages'),
-      },
-      {
-        id: 3, actionForPages: this.actionForPages.transfer, iconClass: 'icon-options', styleClass: '', label: t('user_group_management.transfer_pages'),
-      },
-    ];
-
-    this.initialState = {
-      deleteGroupId: '',
-      deleteGroupName: '',
-      groups: [],
-      actionName: '',
-      selectedGroupId: '',
-      isFetching: false,
-    };
-
-    this.state = this.initialState;
-
-    // logger
-    this.logger = require('@alias/logger')('growi:GroupDeleteModal:GroupDeleteModal');
-
-    // retrieve xss library from window
-    this.xss = window.xss;
-
-    this.getGroupName = this.getGroupName.bind(this);
-    this.changeActionHandler = this.changeActionHandler.bind(this);
-    this.changeGroupHandler = this.changeGroupHandler.bind(this);
-    this.renderPageActionSelector = this.renderPageActionSelector.bind(this);
-    this.renderGroupSelector = this.renderGroupSelector.bind(this);
-    this.validateForm = this.validateForm.bind(this);
-  }
-
-  componentDidMount() {
-    // bootstrap and this jQuery opens/hides the modal.
-    // let React handle it in the future.
-    $('#admin-delete-user-group-modal').on('show.bs.modal', async(button) => {
-      this.setState({ isFetching: true });
-
-      const groups = await this.fetchAllGroups();
-
-      const data = $(button.relatedTarget);
-      const deleteGroupId = data.data('user-group-id');
-      const deleteGroupName = data.data('user-group-name');
-
-      this.setState({
-        groups,
-        deleteGroupId,
-        deleteGroupName,
-        isFetching: false,
-      });
-    });
-
-    $('#admin-delete-user-group-modal').on('hide.bs.modal', (button) => {
-      this.setState(this.initialState);
-    });
-  }
-
-  getGroupName(group) {
-    return this.xss.process(group.name);
-  }
-
-  async fetchAllGroups() {
-    let groups = [];
-
-    try {
-      const res = await this.props.crowi.apiGet('/admin/user-groups');
-      if (res.ok) {
-        groups = res.userGroups;
-      }
-      else {
-        throw new Error('Unable to fetch groups from server');
-      }
-    }
-    catch (err) {
-      this.handleError(err);
-    }
-
-    return groups;
-  }
-
-  handleError(err) {
-    this.logger.error(err);
-    toastr.error(err, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
-  }
-
-  changeActionHandler(e) {
-    const actionName = e.target.value;
-    this.setState({ actionName });
-  }
-
-  changeGroupHandler(e) {
-    const selectedGroupId = e.target.value;
-    this.setState({ selectedGroupId });
-  }
-
-  renderPageActionSelector() {
-    const { t } = this.props;
-
-    const optoins = this.availableOptions.map((opt) => {
-      const dataContent = `<i class="icon icon-fw ${opt.iconClass} ${opt.styleClass}"></i> <span class="action-name ${opt.styleClass}">${t(opt.label)}</span>`;
-      return <option key={opt.id} value={opt.actionForPages} data-content={dataContent}>{t(opt.label)}</option>;
-    });
-
-    return (
-      <select
-        name="actionName"
-        className="form-control"
-        placeholder="select"
-        value={this.state.actionName}
-        onChange={this.changeActionHandler}
-      >
-        <option value="" disabled>{t('user_group_management.choose_action')}</option>
-        {optoins}
-      </select>
-    );
-  }
-
-  renderGroupSelector() {
-    const { t } = this.props;
-
-    const groups = this.state.groups.filter((group) => {
-      return group._id !== this.state.deleteGroupId;
-    });
-
-    const options = groups.map((group) => {
-      const dataContent = `<i class="icon icon-fw icon-organization"></i> ${this.getGroupName(group)}`;
-      return <option key={group._id} value={group._id} data-content={dataContent}>{this.getGroupName(group)}</option>;
-    });
-
-    const defaultOptionText = groups.length === 0 ? t('user_group_management.no_groups') : t('user_group_management.select_group');
-
-    return (
-      <select
-        name="selectedGroupId"
-        className={`form-control ${this.state.actionName === this.actionForPages.transfer ? '' : 'd-none'}`}
-        value={this.state.selectedGroupId}
-        onChange={this.changeGroupHandler}
-      >
-        <option value="" disabled>{defaultOptionText}</option>
-        {options}
-      </select>
-    );
-  }
-
-  validateForm() {
-    let isValid = true;
-
-    if (this.state.actionName === '') {
-      isValid = false;
-    }
-    else if (this.state.actionName === this.actionForPages.transfer) {
-      isValid = this.state.selectedGroupId !== '';
-    }
-
-    return isValid;
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <div className="modal-dialog">
-        <div className="modal-content">
-          <div className="modal-header bg-danger">
-            <button type="button" className="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-            <div className="modal-title">
-              <i className="icon icon-fire"></i> {t('user_group_management.delete_group')}
-            </div>
-          </div>
-
-          <div className="modal-body">
-            <div>
-              <span className="font-weight-bold">{t('user_group_management.group_name')}</span> : &quot;{this.state.deleteGroupName}&quot;
-            </div>
-            {this.state.isFetching
-              ? (
-                <div className="mt-5">
-                  {t('user_group_management.is_loading_data')}
-                </div>
-              )
-              : (
-                <div className="text-danger mt-5">
-                  {t('user_group_management.group_and_pages_not_retrievable')}
-                </div>
-              )
-            }
-          </div>
-
-          {this.state.isFetching
-            ? (
-              null
-            )
-            : (
-              <div className="modal-footer">
-                <form action="/admin/user-group.remove" method="post" id="admin-user-groups-delete" className="d-flex justify-content-between">
-                  <div className="d-flex">
-                    {this.renderPageActionSelector()}
-                    {this.renderGroupSelector()}
-                  </div>
-                  <input type="hidden" id="deleteGroupId" name="deleteGroupId" value={this.state.deleteGroupId} onChange={() => {}} />
-                  <input type="hidden" name="_csrf" defaultValue={this.props.crowi.csrfToken} />
-                  <button type="submit" value="" className="btn btn-sm btn-danger" disabled={!this.validateForm()}>
-                    <i className="icon icon-fire"></i> {t('Delete')}
-                  </button>
-                </form>
-              </div>
-            )
-          }
-        </div>
-      </div>
-    );
-  }
-
-}
-
-GroupDeleteModal.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  crowi: PropTypes.object.isRequired,
-};
-
-export default withTranslation()(GroupDeleteModal);

+ 19 - 5
src/client/js/components/LikeButton.jsx

@@ -1,7 +1,10 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-export default class LikeButton extends React.Component {
+import { createSubscribedElement } from './UnstatedUtils';
+import AppContainer from '../services/AppContainer';
+
+class LikeButton extends React.Component {
 
   constructor(props) {
     super(props);
@@ -16,16 +19,17 @@ export default class LikeButton extends React.Component {
   handleClick(event) {
     event.preventDefault();
 
+    const { appContainer } = this.props;
     const pageId = this.props.pageId;
 
     if (!this.state.isLiked) {
-      this.props.crowi.apiPost('/likes.add', { page_id: pageId })
+      appContainer.apiPost('/likes.add', { page_id: pageId })
         .then((res) => {
           this.setState({ isLiked: true });
         });
     }
     else {
-      this.props.crowi.apiPost('/likes.remove', { page_id: pageId })
+      appContainer.apiPost('/likes.remove', { page_id: pageId })
         .then((res) => {
           this.setState({ isLiked: false });
         });
@@ -33,7 +37,7 @@ export default class LikeButton extends React.Component {
   }
 
   isUserLoggedIn() {
-    return this.props.crowi.me !== '';
+    return this.props.appContainer.me !== '';
   }
 
   render() {
@@ -64,9 +68,19 @@ export default class LikeButton extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const LikeButtonWrapper = (props) => {
+  return createSubscribedElement(LikeButton, props, [AppContainer]);
+};
+
 LikeButton.propTypes = {
-  crowi: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   pageId: PropTypes.string,
   isLiked: PropTypes.bool,
   size: PropTypes.string,
 };
+
+export default LikeButtonWrapper;

+ 196 - 0
src/client/js/components/MyDraftList/Draft.jsx

@@ -0,0 +1,196 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+import { CopyToClipboard } from 'react-copy-to-clipboard';
+
+import Panel from 'react-bootstrap/es/Panel';
+import Tooltip from 'react-bootstrap/es/Tooltip';
+import OverlayTrigger from 'react-bootstrap/es/OverlayTrigger';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+
+import RevisionBody from '../Page/RevisionBody';
+
+class Draft extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      html: '',
+      isRendered: false,
+      isPanelExpanded: false,
+      showCopiedMessage: false,
+    };
+
+    this.growiRenderer = this.props.appContainer.getRenderer('draft');
+
+    this.changeToolTipLabel = this.changeToolTipLabel.bind(this);
+    this.expandPanelHandler = this.expandPanelHandler.bind(this);
+    this.collapsePanelHandler = this.collapsePanelHandler.bind(this);
+    this.renderHtml = this.renderHtml.bind(this);
+    this.renderAccordionTitle = this.renderAccordionTitle.bind(this);
+  }
+
+  changeToolTipLabel() {
+    this.setState({ showCopiedMessage: true });
+    setTimeout(() => {
+      this.setState({ showCopiedMessage: false });
+    }, 1000);
+  }
+
+  expandPanelHandler() {
+    this.setState({ isPanelExpanded: true });
+
+    if (!this.state.isRendered) {
+      this.renderHtml();
+    }
+  }
+
+  collapsePanelHandler() {
+    this.setState({ isPanelExpanded: false });
+  }
+
+  async renderHtml() {
+    const context = {
+      markdown: this.props.markdown,
+    };
+
+    const growiRenderer = this.growiRenderer;
+    const interceptorManager = this.props.appContainer.interceptorManager;
+    await interceptorManager.process('prePreProcess', context)
+      .then(() => {
+        context.markdown = growiRenderer.preProcess(context.markdown);
+      })
+      .then(() => { return interceptorManager.process('postPreProcess', context) })
+      .then(() => {
+        const parsedHTML = growiRenderer.process(context.markdown);
+        context.parsedHTML = parsedHTML;
+      })
+      .then(() => { return interceptorManager.process('prePostProcess', context) })
+      .then(() => {
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
+      })
+      .then(() => { return interceptorManager.process('postPostProcess', context) })
+      .then(() => {
+        this.setState({ html: context.parsedHTML, isRendered: true });
+      });
+  }
+
+  renderAccordionTitle(isExist) {
+    const iconClass = this.state.isPanelExpanded ? 'caret-opened' : '';
+
+    return (
+      <Fragment>
+        <i className={`caret ${iconClass}`}></i>
+        <span className="mx-2">{this.props.path}</span>
+        { isExist && (
+          <span>({this.props.t('page exists')})</span>
+        ) }
+        { !isExist && (
+          <span className="label-draft label label-default">draft</span>
+        ) }
+      </Fragment>
+    );
+  }
+
+  render() {
+    const { t } = this.props;
+
+    const copyButtonTooltip = (
+      <Tooltip id="draft-copied-tooltip">
+        { this.state.showCopiedMessage && (
+          <strong>copied!</strong>
+        ) }
+        { !this.state.showCopiedMessage && (
+          <span>{this.props.t('Copy')}</span>
+        ) }
+      </Tooltip>
+    );
+
+    return (
+      <div className="draft-list-item">
+        <Panel>
+          <Panel.Heading className="d-flex">
+            <Panel.Toggle>
+              {this.renderAccordionTitle(this.props.isExist)}
+            </Panel.Toggle>
+            <a href={this.props.path}><i className="icon icon-login"></i></a>
+            <div className="flex-grow-1"></div>
+            <div className="icon-container">
+              {this.props.isExist
+                ? null
+                : (
+                  <a
+                    href={`${this.props.path}#edit`}
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    data-toggle="tooltip"
+                    title={this.props.t('Edit')}
+                  >
+                    <i className="mx-2 icon-note" />
+                  </a>
+                )
+              }
+              <OverlayTrigger overlay={copyButtonTooltip} placement="top">
+                <CopyToClipboard text={this.props.markdown} onCopy={this.changeToolTipLabel}>
+                  <a
+                    className="text-center draft-copy"
+                  >
+                    <i className="mx-2 ti-clipboard" />
+                  </a>
+                </CopyToClipboard>
+              </OverlayTrigger>
+              <a
+                className="text-danger text-center"
+                data-toggle="tooltip"
+                data-placement="top"
+                title={t('Delete')}
+                onClick={() => { return this.props.clearDraft(this.props.path) }}
+              >
+                <i className="mx-2 icon-trash" />
+              </a>
+            </div>
+          </Panel.Heading>
+          <Panel.Collapse onEnter={this.expandPanelHandler} onExit={this.collapsePanelHandler}>
+            <Panel.Body>
+              {/* loading spinner */}
+              { this.state.isPanelExpanded && !this.state.isRendered && (
+                <div className="text-center">
+                  <i className="fa fa-lg fa-spinner fa-pulse mx-auto text-muted"></i>
+                </div>
+              ) }
+              {/* contents */}
+              { this.state.isPanelExpanded && this.state.isRendered && (
+                <RevisionBody html={this.state.html} />
+              ) }
+            </Panel.Body>
+          </Panel.Collapse>
+        </Panel>
+      </div>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const DraftWrapper = (props) => {
+  return createSubscribedElement(Draft, props, [AppContainer]);
+};
+
+
+Draft.propTypes = {
+  t: PropTypes.func.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  path: PropTypes.string.isRequired,
+  markdown: PropTypes.string.isRequired,
+  isExist: PropTypes.bool.isRequired,
+  clearDraft: PropTypes.func.isRequired,
+};
+
+export default withTranslation()(DraftWrapper);

+ 55 - 24
src/client/js/components/MyDraftList/MyDraftList.jsx

@@ -1,10 +1,18 @@
 import React from 'react';
-
 import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+
 import Pagination from 'react-bootstrap/lib/Pagination';
-import Draft from '../PageList/Draft';
 
-export default class MyDraftList extends React.Component {
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+import EditorContainer from '../../services/EditorContainer';
+
+import Draft from './Draft';
+
+class MyDraftList extends React.Component {
 
   constructor(props) {
     super(props);
@@ -29,9 +37,9 @@ export default class MyDraftList extends React.Component {
   }
 
   async getDraftsFromLocalStorage() {
-    const draftsAsObj = JSON.parse(this.props.crowi.localStorage.getItem('draft') || '{}');
+    const draftsAsObj = this.props.editorContainer.drafts;
 
-    const res = await this.props.crowi.apiGet('/pages.exist', {
+    const res = await this.props.appContainer.apiGet('/pages.exist', {
       pages: draftsAsObj,
     });
 
@@ -49,7 +57,10 @@ export default class MyDraftList extends React.Component {
   }
 
   getCurrentDrafts(selectPageNumber) {
-    const limit = this.props.limit;
+    const { appContainer } = this.props;
+
+    const limit = appContainer.getConfig().recentCreatedLimit;
+
     const totalCount = this.state.drafts.length;
     const activePage = selectPageNumber;
     const paginationNumbers = this.calculatePagination(limit, totalCount, activePage);
@@ -74,8 +85,6 @@ export default class MyDraftList extends React.Component {
       return (
         <Draft
           key={draft.path}
-          crowi={this.props.crowi}
-          crowiOriginRenderer={this.props.crowiOriginRenderer}
           path={draft.path}
           markdown={draft.markdown}
           isExist={draft.isExist}
@@ -86,7 +95,7 @@ export default class MyDraftList extends React.Component {
   }
 
   clearDraft(path) {
-    this.props.crowi.clearDraft(path);
+    this.props.editorContainer.clearDraft(path);
 
     this.setState((prevState) => {
       return {
@@ -97,7 +106,7 @@ export default class MyDraftList extends React.Component {
   }
 
   clearAllDrafts() {
-    this.props.crowi.clearAllDrafts();
+    this.props.editorContainer.clearAllDrafts();
 
     this.setState({
       drafts: [],
@@ -204,10 +213,14 @@ export default class MyDraftList extends React.Component {
   }
 
   render() {
+    const { t } = this.props;
+
     const draftList = this.generateDraftList(this.state.currentDrafts);
 
     const paginationItems = [];
 
+    const totalCount = this.state.drafts.length;
+
     const activePage = this.state.activePage;
     const totalPage = this.state.paginationNumbers.totalPage;
     const paginationStart = this.state.paginationNumbers.paginationStart;
@@ -222,21 +235,28 @@ export default class MyDraftList extends React.Component {
     return (
       <div className="page-list-container-create">
 
-        { draftList.length === 0
+        { totalCount === 0
           && <span>No drafts yet.</span>
         }
 
-        { draftList.length > 0
-          && (
-            <React.Fragment>
-              <button type="button" className="btn-danger mb-3" onClick={this.clearAllDrafts}>Delete All</button>
-              <div className="tab-pane m-t-30 accordion" id="draft-list">
-                {draftList}
+        { totalCount > 0 && (
+          <React.Fragment>
+            <div className="d-flex justify-content-between">
+              <h4>Total: {totalCount} drafts</h4>
+              <div className="align-self-center">
+                <button type="button" className="btn btn-sm btn-default" onClick={this.clearAllDrafts}>
+                  <i className="icon-fw icon-fire text-danger"></i>
+                  {t('Delete All')}
+                </button>
               </div>
-              <Pagination bsSize="small">{paginationItems}</Pagination>
-            </React.Fragment>
-          )
-        }
+            </div>
+
+            <div className="tab-pane m-t-30 accordion" id="draft-list">
+              {draftList}
+            </div>
+            <Pagination bsSize="small">{paginationItems}</Pagination>
+          </React.Fragment>
+        ) }
 
       </div>
     );
@@ -244,9 +264,20 @@ export default class MyDraftList extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const MyDraftListWrapper = (props) => {
+  return createSubscribedElement(MyDraftList, props, [AppContainer, PageContainer, EditorContainer]);
+};
+
 
 MyDraftList.propTypes = {
-  limit: PropTypes.number,
-  crowi: PropTypes.object.isRequired,
-  crowiOriginRenderer: PropTypes.object.isRequired,
+  t: PropTypes.func.isRequired, // react-i18next
+
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
+
+export default withTranslation()(MyDraftListWrapper);

+ 55 - 22
src/client/js/components/Page.jsx

@@ -1,26 +1,36 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from './UnstatedUtils';
+import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
+import EditorContainer from '../services/EditorContainer';
+
+import MarkdownTable from '../models/MarkdownTable';
 
 import RevisionRenderer from './Page/RevisionRenderer';
 import HandsontableModal from './PageEditor/HandsontableModal';
-import MarkdownTable from '../models/MarkdownTable';
 import mtu from './PageEditor/MarkdownTableUtil';
 
-export default class Page extends React.Component {
+const logger = loggerFactory('growi:Page');
+
+class Page extends React.Component {
 
   constructor(props) {
     super(props);
 
     this.state = {
-      markdown: this.props.markdown,
       currentTargetTableArea: null,
     };
 
+    this.growiRenderer = this.props.appContainer.getRenderer('page');
+
     this.saveHandlerForHandsontableModal = this.saveHandlerForHandsontableModal.bind(this);
   }
 
-  setMarkdown(markdown) {
-    this.setState({ markdown });
+  componentWillMount() {
+    this.props.appContainer.registerComponentInstance('Page', this);
   }
 
   /**
@@ -29,33 +39,48 @@ export default class Page extends React.Component {
    * @param endLineNumber
    */
   launchHandsontableModal(beginLineNumber, endLineNumber) {
-    const tableLines = this.state.markdown.split(/\r\n|\r|\n/).slice(beginLineNumber - 1, endLineNumber).join('\n');
+    const markdown = this.props.pageContainer.state.markdown;
+    const tableLines = markdown.split(/\r\n|\r|\n/).slice(beginLineNumber - 1, endLineNumber).join('\n');
     this.setState({ currentTargetTableArea: { beginLineNumber, endLineNumber } });
     this.handsontableModal.show(MarkdownTable.fromMarkdownString(tableLines));
   }
 
-  saveHandlerForHandsontableModal(markdownTable) {
+  async saveHandlerForHandsontableModal(markdownTable) {
+    const { pageContainer, editorContainer } = this.props;
+
     const newMarkdown = mtu.replaceMarkdownTableInMarkdown(
       markdownTable,
-      this.state.markdown,
+      this.props.pageContainer.state.markdown,
       this.state.currentTargetTableArea.beginLineNumber,
       this.state.currentTargetTableArea.endLineNumber,
     );
-    this.props.onSaveWithShortcut(newMarkdown);
-    this.setState({ currentTargetTableArea: null });
+
+    try {
+      // disable unsaved warning
+      editorContainer.disableUnsavedWarning();
+
+      // eslint-disable-next-line no-unused-vars
+      const { page, tags } = await pageContainer.save(newMarkdown);
+      logger.debug('success to save');
+
+      pageContainer.showSuccessToastr();
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      pageContainer.showErrorToastr(error);
+    }
+    finally {
+      this.setState({ currentTargetTableArea: null });
+    }
   }
 
   render() {
-    const isMobile = this.props.crowi.isMobile;
+    const isMobile = this.props.appContainer.isMobile;
+    const { markdown } = this.props.pageContainer.state;
 
     return (
       <div className={isMobile ? 'page-mobile' : ''}>
-        <RevisionRenderer
-          crowi={this.props.crowi}
-          crowiRenderer={this.props.crowiRenderer}
-          markdown={this.state.markdown}
-          pagePath={this.props.pagePath}
-        />
+        <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} />
         <HandsontableModal ref={(c) => { this.handsontableModal = c }} onSave={this.saveHandlerForHandsontableModal} />
       </div>
     );
@@ -63,10 +88,18 @@ export default class Page extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const PageWrapper = (props) => {
+  return createSubscribedElement(Page, props, [AppContainer, PageContainer, EditorContainer]);
+};
+
+
 Page.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object.isRequired,
-  onSaveWithShortcut: PropTypes.func.isRequired,
-  markdown: PropTypes.string.isRequired,
-  pagePath: PropTypes.string.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
+
+export default PageWrapper;

+ 19 - 6
src/client/js/components/Page/RevisionLoader.jsx

@@ -3,12 +3,16 @@ import PropTypes from 'prop-types';
 
 import { Waypoint } from 'react-waypoint';
 
+import { createSubscribedElement } from '../UnstatedUtils';
+import GrowiRenderer from '../../util/GrowiRenderer';
+import AppContainer from '../../services/AppContainer';
+
 import RevisionRenderer from './RevisionRenderer';
 
 /**
  * Load data from server and render RevisionBody component
  */
-export default class RevisionLoader extends React.Component {
+class RevisionLoader extends React.Component {
 
   constructor(props) {
     super(props);
@@ -42,7 +46,7 @@ export default class RevisionLoader extends React.Component {
     };
 
     // load data with REST API
-    this.props.crowi.apiGet('/revisions.get', requestData)
+    this.props.appContainer.apiGet('/revisions.get', requestData)
       .then((res) => {
         if (!res.ok) {
           throw new Error(res.error);
@@ -96,8 +100,7 @@ export default class RevisionLoader extends React.Component {
 
     return (
       <RevisionRenderer
-        crowi={this.props.crowi}
-        crowiRenderer={this.props.crowiRenderer}
+        growiRenderer={this.props.growiRenderer}
         pagePath={this.props.pagePath}
         markdown={markdown}
         highlightKeywords={this.props.highlightKeywords}
@@ -107,12 +110,22 @@ export default class RevisionLoader extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const RevisionLoaderWrapper = (props) => {
+  return createSubscribedElement(RevisionLoader, props, [AppContainer]);
+};
+
 RevisionLoader.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   pageId: PropTypes.string.isRequired,
   pagePath: PropTypes.string.isRequired,
   revisionId: PropTypes.string.isRequired,
   lazy: PropTypes.bool,
   highlightKeywords: PropTypes.string,
 };
+
+export default RevisionLoaderWrapper;

+ 3 - 3
src/client/js/components/Page/RevisionPath.jsx

@@ -26,8 +26,8 @@ class RevisionPath extends React.Component {
     this.setState({ isListPage });
 
     // whether set link to '/'
-    const behaviorType = this.props.crowi.getConfig().behaviorType;
-    const isLinkToListPage = (!behaviorType || behaviorType === 'crowi');
+    const { behaviorType } = this.props;
+    const isLinkToListPage = (behaviorType === 'crowi');
     this.setState({ isLinkToListPage });
 
     // generate pages obj
@@ -127,7 +127,7 @@ class RevisionPath extends React.Component {
 
 RevisionPath.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  crowi: PropTypes.object.isRequired,
+  behaviorType: PropTypes.string.isRequired,
   pagePath: PropTypes.string.isRequired,
   pageId: PropTypes.string,
 };

+ 34 - 19
src/client/js/components/Page/RevisionRenderer.jsx

@@ -1,9 +1,14 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+import GrowiRenderer from '../../util/GrowiRenderer';
+
 import RevisionBody from './RevisionBody';
 
-export default class RevisionRenderer extends React.Component {
+class RevisionRenderer extends React.Component {
 
   constructor(props) {
     super(props);
@@ -14,18 +19,16 @@ export default class RevisionRenderer extends React.Component {
 
     this.renderHtml = this.renderHtml.bind(this);
     this.getHighlightedBody = this.getHighlightedBody.bind(this);
+  }
 
-    this.setMarkdown(this.props.markdown);
+  componentWillMount() {
+    this.renderHtml(this.props.markdown, this.props.highlightKeywords);
   }
 
   componentWillReceiveProps(nextProps) {
     this.renderHtml(nextProps.markdown, this.props.highlightKeywords);
   }
 
-  setMarkdown(markdown) {
-    this.renderHtml(markdown, this.props.highlightKeywords);
-  }
-
   /**
    * transplanted from legacy code -- Yuki Takei
    * @param {string} body html strings
@@ -48,30 +51,32 @@ export default class RevisionRenderer extends React.Component {
     return returnBody;
   }
 
-  renderHtml(markdown, highlightKeywords) {
+  renderHtml(markdown) {
+    const { pageContainer } = this.props;
+
     const context = {
       markdown,
-      currentPagePath: this.props.pagePath,
+      currentPagePath: pageContainer.state.path,
     };
 
-    const crowiRenderer = this.props.crowiRenderer;
-    const interceptorManager = this.props.crowi.interceptorManager;
+    const growiRenderer = this.props.growiRenderer;
+    const interceptorManager = this.props.appContainer.interceptorManager;
     interceptorManager.process('preRender', context)
       .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => {
-        context.markdown = crowiRenderer.preProcess(context.markdown);
+        context.markdown = growiRenderer.preProcess(context.markdown);
       })
       .then(() => { return interceptorManager.process('postPreProcess', context) })
       .then(() => {
-        context.parsedHTML = crowiRenderer.process(context.markdown);
+        context.parsedHTML = growiRenderer.process(context.markdown);
       })
       .then(() => { return interceptorManager.process('prePostProcess', context) })
       .then(() => {
-        context.parsedHTML = crowiRenderer.postProcess(context.parsedHTML);
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
 
         // highlight
-        if (highlightKeywords != null) {
-          context.parsedHTML = this.getHighlightedBody(context.parsedHTML, highlightKeywords);
+        if (this.props.highlightKeywords != null) {
+          context.parsedHTML = this.getHighlightedBody(context.parsedHTML, this.props.highlightKeywords);
         }
       })
       .then(() => { return interceptorManager.process('postPostProcess', context) })
@@ -85,7 +90,7 @@ export default class RevisionRenderer extends React.Component {
   }
 
   render() {
-    const config = this.props.crowi.getConfig();
+    const config = this.props.appContainer.getConfig();
     const isMathJaxEnabled = !!config.env.MATHJAX;
 
     return (
@@ -99,10 +104,20 @@ export default class RevisionRenderer extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const RevisionRendererWrapper = (props) => {
+  return createSubscribedElement(RevisionRenderer, props, [AppContainer, PageContainer]);
+};
+
 RevisionRenderer.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
+  growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
-  pagePath: PropTypes.string.isRequired,
   highlightKeywords: PropTypes.string,
 };
+
+export default RevisionRendererWrapper;

+ 10 - 49
src/client/js/components/Page/TagEditor.jsx

@@ -1,11 +1,13 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import * as toastr from 'toastr';
 import Button from 'react-bootstrap/es/Button';
 import Modal from 'react-bootstrap/es/Modal';
+
+import AppContainer from '../../services/AppContainer';
+
 import TagsInput from './TagsInput';
 
-class TagEditor extends React.Component {
+export default class TagEditor extends React.Component {
 
   constructor(props) {
     super(props);
@@ -13,23 +15,19 @@ class TagEditor extends React.Component {
     this.state = {
       tags: [],
       isOpenModal: false,
-      isEditorMode: null,
     };
 
     this.show = this.show.bind(this);
-    this.onTagsUpdatedByFormHandler = this.onTagsUpdatedByFormHandler.bind(this);
+    this.onTagsUpdatedByTagsInput = this.onTagsUpdatedByTagsInput.bind(this);
     this.closeModalHandler = this.closeModalHandler.bind(this);
     this.handleSubmit = this.handleSubmit.bind(this);
-    this.apiSuccessHandler = this.apiSuccessHandler.bind(this);
-    this.apiErrorHandler = this.apiErrorHandler.bind(this);
   }
 
   show(tags) {
-    const isEditorMode = this.props.crowi.getCrowiForJquery().getCurrentEditorMode();
-    this.setState({ isOpenModal: true, isEditorMode, tags });
+    this.setState({ tags, isOpenModal: true });
   }
 
-  onTagsUpdatedByFormHandler(tags) {
+  onTagsUpdatedByTagsInput(tags) {
     this.setState({ tags });
   }
 
@@ -38,47 +36,12 @@ class TagEditor extends React.Component {
   }
 
   async handleSubmit() {
-
-    if (!this.state.isEditorMode) {
-      try {
-        await this.props.crowi.apiPost('/tags.update', { pageId: this.props.pageId, tags: this.state.tags });
-        this.apiSuccessHandler();
-      }
-      catch (err) {
-        this.apiErrorHandler(err);
-        return;
-      }
-    }
-
     this.props.onTagsUpdated(this.state.tags);
 
     // close modal
     this.setState({ isOpenModal: false });
   }
 
-  apiSuccessHandler() {
-    toastr.success(undefined, 'updated tags successfully', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '1200',
-      extendedTimeOut: '150',
-    });
-  }
-
-  apiErrorHandler(err) {
-    toastr.error(err.message, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
-  }
-
   render() {
     return (
       <Modal show={this.state.isOpenModal} onHide={this.closeModalHandler} id="editTagModal">
@@ -86,7 +49,7 @@ class TagEditor extends React.Component {
           <Modal.Title className="text-white">Edit Tags</Modal.Title>
         </Modal.Header>
         <Modal.Body>
-          <TagsInput crowi={this.props.crowi} tags={this.state.tags} onTagsUpdated={this.onTagsUpdatedByFormHandler} />
+          <TagsInput tags={this.state.tags} onTagsUpdated={this.onTagsUpdatedByTagsInput} />
         </Modal.Body>
         <Modal.Footer>
           <Button variant="primary" onClick={this.handleSubmit}>
@@ -100,9 +63,7 @@ class TagEditor extends React.Component {
 }
 
 TagEditor.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  pageId: PropTypes.string,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   onTagsUpdated: PropTypes.func.isRequired,
 };
-
-export default TagEditor;

+ 94 - 36
src/client/js/components/Page/TagLabels.jsx

@@ -2,6 +2,13 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
+import * as toastr from 'toastr';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+import EditorContainer from '../../services/EditorContainer';
+
 import TagEditor from './TagEditor';
 
 class TagLabels extends React.Component {
@@ -10,61 +17,105 @@ class TagLabels extends React.Component {
     super(props);
 
     this.state = {
-      tags: [],
+      showTagEditor: false,
     };
 
     this.showEditor = this.showEditor.bind(this);
     this.tagsUpdatedHandler = this.tagsUpdatedHandler.bind(this);
   }
 
-  async componentWillMount() {
-    // set pageTag on button
-    const pageId = this.props.pageId;
+  /**
+   * @return tags data
+   *   1. pageContainer.state.tags if editorMode is null
+   *   2. editorContainer.state.tags if editorMode is not null
+   */
+  getEditTargetData() {
+    const { editorMode } = this.props.appContainer.state;
+    return (editorMode == null)
+      ? this.props.pageContainer.state.tags
+      : this.props.editorContainer.state.tags;
+  }
+
+  showEditor() {
+    this.tagEditor.show(this.getEditTargetData());
+  }
+
+  async tagsUpdatedHandler(tags) {
+    const { appContainer, editorContainer } = this.props;
+    const { editorMode } = appContainer.state;
+
+    // post api request and update tags
+    if (editorMode == null) {
+      const { pageContainer } = this.props;
+
+      try {
+        const { pageId } = pageContainer.state;
+        await appContainer.apiPost('/tags.update', { pageId, tags });
 
-    if (pageId) {
-      const res = await this.props.crowi.apiGet('/pages.getPageTag', { pageId });
-      this.setState({ tags: res.tags });
-      this.props.sendTagData(res.tags);
+        // update pageContainer.state
+        pageContainer.setState({ tags });
+        editorContainer.setState({ tags });
+
+        this.apiSuccessHandler();
+      }
+      catch (err) {
+        this.apiErrorHandler(err);
+        return;
+      }
     }
-    else if (this.props.templateTagData) {
-      const templateTags = this.props.templateTagData.split(',');
-      this.setState({ tags: templateTags });
-      this.props.sendTagData(templateTags);
+    // only update tags in editorContainer
+    else {
+      editorContainer.setState({ tags });
     }
   }
 
-  showEditor() {
-    this.tagEditor.show(this.state.tags);
+  apiSuccessHandler() {
+    toastr.success(undefined, 'updated tags successfully', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '1200',
+      extendedTimeOut: '150',
+    });
   }
 
-  tagsUpdatedHandler(tags) {
-    this.setState({ tags });
-    this.props.sendTagData(tags);
+  apiErrorHandler(err) {
+    toastr.error(err.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
   }
 
   render() {
-    const tagElements = [];
-    const { t, pageId } = this.props;
+    const { t } = this.props;
+    const { pageId } = this.props.pageContainer.state;
 
-    for (let i = 0; i < this.state.tags.length; i++) {
-      tagElements.push(
-        <span key={`${pageId}_${i}`} className="text-muted">
+    const tags = this.getEditTargetData();
+
+    const tagElements = tags.map((tag) => {
+      return (
+        <span key={`${pageId}_${tag}`} className="text-muted">
           <i className="tag-icon icon-tag mr-1"></i>
-          <a className="tag-name mr-2" href={`/_search?q=tag:${this.state.tags[i]}`} key={i.toString()}>{this.state.tags[i]}</a>
-        </span>,
+          <a className="tag-name mr-2" href={`/_search?q=tag:${tag}`} key={`${pageId}_${tag}_link`}>{tag}</a>
+        </span>
       );
-
-    }
+    });
 
     return (
-      <div className={`tag-viewer ${this.props.pageId ? 'existed-page' : 'new-page'}`}>
-        {this.state.tags.length === 0 && (
+      <div className={`tag-viewer ${pageId ? 'existed-page' : 'new-page'}`}>
+        {tags.length === 0 && (
           <a className="btn btn-link btn-edit-tags no-tags p-0" onClick={this.showEditor}>
             { t('Add tags for this page') } <i className="manage-tags ml-2 icon-plus"></i>
           </a>
         )}
         {tagElements}
-        {this.state.tags.length > 0 && (
+        {tags.length > 0 && (
           <a className="btn btn-link btn-edit-tags p-0" onClick={this.showEditor}>
             <i className="manage-tags ml-2 icon-plus"></i> { t('Edit tags for this page') }
           </a>
@@ -72,8 +123,8 @@ class TagLabels extends React.Component {
 
         <TagEditor
           ref={(c) => { this.tagEditor = c }}
-          crowi={this.props.crowi}
-          pageId={this.props.pageId}
+          appContainer={this.props.appContainer}
+          show={this.state.showTagEditor}
           onTagsUpdated={this.tagsUpdatedHandler}
         >
         </TagEditor>
@@ -83,12 +134,19 @@ class TagLabels extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const TagLabelsWrapper = (props) => {
+  return createSubscribedElement(TagLabels, props, [AppContainer, PageContainer, EditorContainer]);
+};
+
+
 TagLabels.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  crowi: PropTypes.object.isRequired,
-  pageId: PropTypes.string,
-  sendTagData: PropTypes.func.isRequired,
-  templateTagData: PropTypes.string,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 
-export default withTranslation()(TagLabels);
+export default withTranslation()(TagLabelsWrapper);

+ 16 - 4
src/client/js/components/Page/TagsInput.jsx

@@ -2,6 +2,9 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+
 /**
  *
  * @author Yuki Takei <yuki@weseek.co.jp>
@@ -11,7 +14,7 @@ import { AsyncTypeahead } from 'react-bootstrap-typeahead';
  * @extends {React.Component}
  */
 
-export default class TagsInput extends React.Component {
+class TagsInput extends React.Component {
 
   constructor(props) {
     super(props);
@@ -22,7 +25,6 @@ export default class TagsInput extends React.Component {
       selected: this.props.tags,
       defaultPageTags: this.props.tags,
     };
-    this.crowi = this.props.crowi;
 
     this.handleChange = this.handleChange.bind(this);
     this.handleSearch = this.handleSearch.bind(this);
@@ -42,7 +44,7 @@ export default class TagsInput extends React.Component {
 
   async handleSearch(query) {
     this.setState({ isLoading: true });
-    const res = await this.crowi.apiGet('/tags.search', { q: query });
+    const res = await this.props.appContainer.apiGet('/tags.search', { q: query });
     res.tags.unshift(query); // selectable new tag whose name equals query
     this.setState({
       resultTags: Array.from(new Set(res.tags)), // use Set for de-duplication
@@ -87,11 +89,21 @@ export default class TagsInput extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const TagsInputWrapper = (props) => {
+  return createSubscribedElement(TagsInput, props, [AppContainer]);
+};
+
 TagsInput.propTypes = {
-  crowi: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   tags: PropTypes.array.isRequired,
   onTagsUpdated: PropTypes.func.isRequired,
 };
 
 TagsInput.defaultProps = {
 };
+
+export default TagsInputWrapper;

+ 23 - 9
src/client/js/components/PageAttachment.js → src/client/js/components/PageAttachment.jsx

@@ -4,8 +4,11 @@ import PropTypes from 'prop-types';
 
 import PageAttachmentList from './PageAttachment/PageAttachmentList';
 import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
+import { createSubscribedElement } from './UnstatedUtils';
+import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
 
-export default class PageAttachment extends React.Component {
+class PageAttachment extends React.Component {
 
   constructor(props) {
     super(props);
@@ -23,13 +26,13 @@ export default class PageAttachment extends React.Component {
   }
 
   componentDidMount() {
-    const pageId = this.props.pageId;
+    const { pageId } = this.props.pageContainer.state;
 
     if (!pageId) {
       return;
     }
 
-    this.props.crowi.apiGet('/attachments.list', { page_id: pageId })
+    this.props.appContainer.apiGet('/attachments.list', { page_id: pageId })
       .then((res) => {
         const attachments = res.attachments;
         const inUse = {};
@@ -46,7 +49,9 @@ export default class PageAttachment extends React.Component {
   }
 
   checkIfFileInUse(attachment) {
-    if (this.props.markdown.match(attachment.filePathProxied)) {
+    const { markdown } = this.props.pageContainer.state;
+
+    if (markdown.match(attachment.filePathProxied)) {
       return true;
     }
     return false;
@@ -64,7 +69,7 @@ export default class PageAttachment extends React.Component {
       deleting: true,
     });
 
-    this.props.crowi.apiPost('/attachments.remove', { attachment_id: attachmentId })
+    this.props.appContainer.apiPost('/attachments.remove', { attachment_id: attachmentId })
       .then((res) => {
         this.setState({
           attachments: this.state.attachments.filter((at) => {
@@ -84,7 +89,7 @@ export default class PageAttachment extends React.Component {
   }
 
   isUserLoggedIn() {
-    return this.props.crowi.me !== '';
+    return this.props.appContainer.me !== '';
   }
 
   render() {
@@ -133,8 +138,17 @@ export default class PageAttachment extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const PageAttachmentWrapper = (props) => {
+  return createSubscribedElement(PageAttachment, props, [AppContainer, PageContainer]);
+};
+
+
 PageAttachment.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  markdown: PropTypes.string.isRequired,
-  pageId: PropTypes.string.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
+
+export default PageAttachmentWrapper;

+ 147 - 30
src/client/js/components/PageComment/Comment.jsx

@@ -3,9 +3,11 @@ import PropTypes from 'prop-types';
 
 import dateFnsFormat from 'date-fns/format';
 
-import RevisionBody from '../Page/RevisionBody';
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
 
-import ReactUtils from '../ReactUtils';
+import { createSubscribedElement } from '../UnstatedUtils';
+import RevisionBody from '../Page/RevisionBody';
 import UserPicture from '../User/UserPicture';
 import Username from '../User/Username';
 
@@ -17,13 +19,14 @@ import Username from '../User/Username';
  * @class Comment
  * @extends {React.Component}
  */
-export default class Comment extends React.Component {
+class Comment extends React.Component {
 
   constructor(props) {
     super(props);
 
     this.state = {
       html: '',
+      isLayoutTypeGrowi: false,
     };
 
     this.isCurrentUserIsAuthor = this.isCurrentUserEqualsToAuthor.bind(this);
@@ -31,11 +34,18 @@ export default class Comment extends React.Component {
     this.getRootClassName = this.getRootClassName.bind(this);
     this.getRevisionLabelClassName = this.getRevisionLabelClassName.bind(this);
     this.deleteBtnClickedHandler = this.deleteBtnClickedHandler.bind(this);
+    this.renderText = this.renderText.bind(this);
     this.renderHtml = this.renderHtml.bind(this);
   }
 
   componentWillMount() {
     this.renderHtml(this.props.comment.comment);
+    this.init();
+  }
+
+  init() {
+    const layoutType = this.props.appContainer.getConfig().layoutType;
+    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
   }
 
   componentWillReceiveProps(nextProps) {
@@ -48,11 +58,11 @@ export default class Comment extends React.Component {
   }
 
   isCurrentUserEqualsToAuthor() {
-    return this.props.comment.creator.username === this.props.currentUserId;
+    return this.props.comment.creator.username === this.props.appContainer.me;
   }
 
   isCurrentRevision() {
-    return this.props.comment.revision === this.props.currentRevisionId;
+    return this.props.comment.revision === this.props.pageContainer.state.revisionId;
   }
 
   getRootClassName() {
@@ -69,8 +79,12 @@ export default class Comment extends React.Component {
     this.props.deleteBtnClicked(this.props.comment);
   }
 
+  renderText(comment) {
+    return <span style={{ whiteSpace: 'pre-wrap' }}>{comment}</span>;
+  }
+
   renderRevisionBody() {
-    const config = this.props.crowi.getConfig();
+    const config = this.props.appContainer.getConfig();
     const isMathJaxEnabled = !!config.env.MATHJAX;
     return (
       <RevisionBody
@@ -82,26 +96,34 @@ export default class Comment extends React.Component {
     );
   }
 
+  toggleOlderReplies() {
+    this.setState((prevState) => {
+      return {
+        showOlderReplies: !prevState.showOlderReplies,
+      };
+    });
+  }
+
   renderHtml(markdown) {
     const context = {
       markdown,
     };
 
-    const crowiRenderer = this.props.crowiRenderer;
-    const interceptorManager = this.props.crowi.interceptorManager;
+    const growiRenderer = this.props.growiRenderer;
+    const interceptorManager = this.props.appContainer.interceptorManager;
     interceptorManager.process('preRenderComment', context)
       .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => {
-        context.markdown = crowiRenderer.preProcess(context.markdown);
+        context.markdown = growiRenderer.preProcess(context.markdown);
       })
       .then(() => { return interceptorManager.process('postPreProcess', context) })
       .then(() => {
-        const parsedHTML = crowiRenderer.process(context.markdown);
+        const parsedHTML = growiRenderer.process(context.markdown);
         context.parsedHTML = parsedHTML;
       })
       .then(() => { return interceptorManager.process('prePostProcess', context) })
       .then(() => {
-        context.parsedHTML = crowiRenderer.postProcess(context.parsedHTML);
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
       })
       .then(() => { return interceptorManager.process('postPostProcess', context) })
       .then(() => { return interceptorManager.process('preRenderCommentHtml', context) })
@@ -113,6 +135,68 @@ export default class Comment extends React.Component {
 
   }
 
+  renderReplies() {
+    const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
+    let replyList = this.props.replyList;
+    if (!isLayoutTypeGrowi) {
+      replyList = replyList.slice().reverse();
+    }
+
+    const areThereHiddenReplies = replyList.length > 2;
+
+    const iconForOlder = <i className="icon-options-vertical"></i>;
+    const toggleOlder = areThereHiddenReplies
+      ? (
+        <a className="page-comments-list-toggle-older text-center" data-toggle="collapse" href="#page-comments-list-older">
+          {iconForOlder} Read More
+        </a>
+      )
+      : <div></div>;
+
+    const shownReplies = replyList.slice(replyList.length - 2, replyList.length);
+    const hiddenReplies = replyList.slice(0, replyList.length - 2);
+
+    const toggleElements = hiddenReplies.map((reply) => {
+      return (
+        <div key={reply._id} className="col-xs-offset-1 col-xs-11 col-sm-offset-1 col-sm-11 col-md-offset-1 col-md-11 col-lg-offset-1 col-lg-11">
+          <CommentWrapper
+            comment={reply}
+            deleteBtnClicked={this.props.deleteBtnClicked}
+            growiRenderer={this.props.growiRenderer}
+            replyList={[]}
+          />
+        </div>
+      );
+    });
+
+    const toggleBlock = (
+      <div className="page-comments-list-older collapse out" id="page-comments-list-older">
+        {toggleElements}
+      </div>
+    );
+
+    const shownBlock = shownReplies.map((reply) => {
+      return (
+        <div key={reply._id} className="col-xs-offset-1 col-xs-11 col-sm-offset-1 col-sm-11 col-md-offset-1 col-md-11 col-lg-offset-1 col-lg-11">
+          <CommentWrapper
+            comment={reply}
+            deleteBtnClicked={this.props.deleteBtnClicked}
+            growiRenderer={this.props.growiRenderer}
+            replyList={[]}
+          />
+        </div>
+      );
+    });
+
+    return (
+      <div>
+        {toggleBlock}
+        {toggleOlder}
+        {shownBlock}
+      </div>
+    );
+  }
+
   render() {
     const comment = this.props.comment;
     const creator = comment.creator;
@@ -120,27 +204,50 @@ export default class Comment extends React.Component {
 
     const rootClassName = this.getRootClassName();
     const commentDate = dateFnsFormat(comment.createdAt, 'YYYY/MM/DD HH:mm');
-    const commentBody = isMarkdown ? this.renderRevisionBody() : ReactUtils.nl2br(comment.comment);
+    const commentBody = isMarkdown ? this.renderRevisionBody() : this.renderText(comment.comment);
     const revHref = `?revision=${comment.revision}`;
     const revFirst8Letters = comment.revision.substr(-8);
     const revisionLavelClassName = this.getRevisionLabelClassName();
 
+    const { revisionId, revisionCreatedAt } = this.props.pageContainer.state;
+
+    let isNewer;
+    if (comment.revision === revisionId) {
+      isNewer = 'page-comments-list-current';
+    }
+    else if (Date.parse(comment.createdAt) / 1000 > revisionCreatedAt) {
+      isNewer = 'page-comments-list-newer';
+    }
+    else {
+      isNewer = 'page-comments-list-older';
+    }
+
+
     return (
-      <div className={rootClassName}>
-        <UserPicture user={creator} />
-        <div className="page-comment-main">
-          <div className="page-comment-creator">
-            <Username user={creator} />
+      <div>
+        <div className={isNewer}>
+          <div className={rootClassName}>
+            <UserPicture user={creator} />
+            <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">
+                {commentDate}&nbsp;
+                <a className={revisionLavelClassName} href={revHref}>{revFirst8Letters}</a>
+              </div>
+              <div className="page-comment-control">
+                <button type="button" className="btn btn-link" onClick={this.deleteBtnClickedHandler}>
+                  <i className="ti-close"></i>
+                </button>
+              </div>
+            </div>
           </div>
-          <div className="page-comment-body">{commentBody}</div>
-          <div className="page-comment-meta">
-            {commentDate}&nbsp;
-            <a className={revisionLavelClassName} href={revHref}>{revFirst8Letters}</a>
-          </div>
-          <div className="page-comment-control">
-            <button type="button" className="btn btn-link" onClick={this.deleteBtnClickedHandler}>
-              <i className="ti-close"></i>
-            </button>
+        </div>
+        <div className="container-fluid">
+          <div className="row">
+            {this.renderReplies()}
           </div>
         </div>
       </div>
@@ -149,11 +256,21 @@ export default class Comment extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const CommentWrapper = (props) => {
+  return createSubscribedElement(Comment, props, [AppContainer, PageContainer]);
+};
+
 Comment.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
   comment: PropTypes.object.isRequired,
-  currentRevisionId: PropTypes.string.isRequired,
-  currentUserId: PropTypes.string.isRequired,
+  growiRenderer: PropTypes.object.isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
-  crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object.isRequired,
+  replyList: PropTypes.array,
 };
+
+export default CommentWrapper;

+ 348 - 0
src/client/js/components/PageComment/CommentEditor.jsx

@@ -0,0 +1,348 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Button from 'react-bootstrap/es/Button';
+import Tab from 'react-bootstrap/es/Tab';
+import Tabs from 'react-bootstrap/es/Tabs';
+import * as toastr from 'toastr';
+
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+import CommentContainer from '../../services/CommentContainer';
+import EditorContainer from '../../services/EditorContainer';
+import GrowiRenderer from '../../util/GrowiRenderer';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+import UserPicture from '../User/UserPicture';
+import Editor from '../PageEditor/Editor';
+import SlackNotification from '../SlackNotification';
+
+import CommentPreview from './CommentPreview';
+
+/**
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ * @extends {React.Component}
+ */
+
+class CommentEditor extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    const config = this.props.appContainer.getConfig();
+    const isUploadable = config.upload.image || config.upload.file;
+    const isUploadableFile = config.upload.file;
+
+    this.state = {
+      isLayoutTypeGrowi: false,
+      comment: '',
+      isMarkdown: true,
+      html: '',
+      key: 1,
+      isUploadable,
+      isUploadableFile,
+      errorMessage: undefined,
+      hasSlackConfig: config.hasSlackConfig,
+    };
+
+    this.updateState = this.updateState.bind(this);
+    this.updateStateCheckbox = this.updateStateCheckbox.bind(this);
+
+    this.postHandler = this.postHandler.bind(this);
+    this.uploadHandler = this.uploadHandler.bind(this);
+
+    this.renderHtml = this.renderHtml.bind(this);
+    this.handleSelect = this.handleSelect.bind(this);
+    this.onSlackEnabledFlagChange = this.onSlackEnabledFlagChange.bind(this);
+    this.onSlackChannelsChange = this.onSlackChannelsChange.bind(this);
+    this.toggleEditor = this.toggleEditor.bind(this);
+  }
+
+  componentWillMount() {
+    this.init();
+  }
+
+  init() {
+    const layoutType = this.props.appContainer.getConfig().layoutType;
+    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
+  }
+
+  updateState(value) {
+    this.setState({ comment: value });
+  }
+
+  updateStateCheckbox(event) {
+    const value = event.target.checked;
+    this.setState({ isMarkdown: value });
+    // changeMode
+    this.editor.setGfmMode(value);
+  }
+
+  handleSelect(key) {
+    this.setState({ key });
+    this.renderHtml(this.state.comment);
+  }
+
+  onSlackEnabledFlagChange(isSlackEnabled) {
+    this.props.commentContainer.setState({ isSlackEnabled });
+  }
+
+  onSlackChannelsChange(slackChannels) {
+    this.props.commentContainer.setState({ slackChannels });
+  }
+
+  toggleEditor() {
+    this.props.commentButtonClickedHandler(this.props.replyTo);
+  }
+
+  /**
+   * Post comment with CommentContainer and update state
+   */
+  postHandler(event) {
+    if (event != null) {
+      event.preventDefault();
+    }
+
+    const { commentContainer } = this.props;
+
+    this.props.commentContainer.postComment(
+      this.state.comment,
+      this.state.isMarkdown,
+      this.props.replyTo,
+      commentContainer.state.isSlackEnabled,
+      commentContainer.state.slackChannels,
+    )
+      .then((res) => {
+        this.setState({
+          comment: '',
+          isMarkdown: true,
+          html: '',
+          key: 1,
+          errorMessage: undefined,
+        });
+        // reset value
+        this.editor.setValue('');
+        this.toggleEditor();
+      })
+      .catch((err) => {
+        const errorMessage = err.message || 'An unknown error occured when posting comment';
+        this.setState({ errorMessage });
+      });
+  }
+
+  uploadHandler(file) {
+    this.props.commentContainer.uploadAttachment(file)
+      .then((res) => {
+        const attachment = res.attachment;
+        const fileName = attachment.originalName;
+
+        let insertText = `[${fileName}](${attachment.filePathProxied})`;
+        // when image
+        if (attachment.fileFormat.startsWith('image/')) {
+          // modify to "![fileName](url)" syntax
+          insertText = `!${insertText}`;
+        }
+        this.editor.insertText(insertText);
+      })
+      .catch(this.apiErrorHandler)
+      // finally
+      .then(() => {
+        this.editor.terminateUploadingState();
+      });
+  }
+
+  apiErrorHandler(error) {
+    toastr.error(error.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
+  }
+
+  getCommentHtml() {
+    return (
+      <CommentPreview
+        inputRef={(el) => { this.previewElement = el }}
+        html={this.state.html}
+      />
+    );
+  }
+
+  renderHtml(markdown) {
+    const context = {
+      markdown,
+    };
+
+    const { growiRenderer } = this.props;
+    const interceptorManager = this.props.appContainer.interceptorManager;
+    interceptorManager.process('preRenderCommnetPreview', context)
+      .then(() => { return interceptorManager.process('prePreProcess', context) })
+      .then(() => {
+        context.markdown = growiRenderer.preProcess(context.markdown);
+      })
+      .then(() => { return interceptorManager.process('postPreProcess', context) })
+      .then(() => {
+        const parsedHTML = growiRenderer.process(context.markdown);
+        context.parsedHTML = parsedHTML;
+      })
+      .then(() => { return interceptorManager.process('prePostProcess', context) })
+      .then(() => {
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
+      })
+      .then(() => { return interceptorManager.process('postPostProcess', context) })
+      .then(() => { return interceptorManager.process('preRenderCommentPreviewHtml', context) })
+      .then(() => {
+        this.setState({ html: context.parsedHTML });
+      })
+      // process interceptors for post rendering
+      .then(() => { return interceptorManager.process('postRenderCommentPreviewHtml', context) });
+  }
+
+  generateInnerHtml(html) {
+    return { __html: html };
+  }
+
+  render() {
+    const { appContainer, commentContainer } = this.props;
+    const username = appContainer.me;
+    const user = appContainer.findUser(username);
+    const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : null;
+    const emojiStrategy = appContainer.getEmojiStrategy();
+
+    const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
+
+    const errorMessage = <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>;
+    const submitButton = (
+      <Button
+        bsStyle="primary"
+        className="fcbtn btn btn-primary btn-outline btn-rounded btn-1b"
+        onClick={this.postHandler}
+      >
+        Comment
+      </Button>
+    );
+
+    return (
+      <div className="form page-comment-form">
+
+        { username
+          && (
+          <div className="comment-form">
+            { isLayoutTypeGrowi
+              && (
+              <div className="comment-form-user">
+                <UserPicture user={user} />
+              </div>
+              )
+            }
+            <div className="comment-form-main">
+              <div className="comment-write">
+                <Tabs activeKey={this.state.key} id="comment-form-tabs" onSelect={this.handleSelect} animation={false}>
+                  <Tab eventKey={1} title="Write">
+                    <Editor
+                      ref={(c) => { this.editor = c }}
+                      value={this.state.comment}
+                      isGfmMode={this.state.isMarkdown}
+                      lineNumbers={false}
+                      isMobile={appContainer.isMobile}
+                      isUploadable={this.state.isUploadable && this.state.isLayoutTypeGrowi} // enable only when GROWI layout
+                      isUploadableFile={this.state.isUploadableFile}
+                      emojiStrategy={emojiStrategy}
+                      onChange={this.updateState}
+                      onUpload={this.uploadHandler}
+                      onCtrlEnter={this.postHandler}
+                    />
+                  </Tab>
+                  { this.state.isMarkdown
+                    && (
+                    <Tab eventKey={2} title="Preview">
+                      <div className="comment-form-preview">
+                        {commentPreview}
+                      </div>
+                    </Tab>
+                    )
+                  }
+                </Tabs>
+              </div>
+              <div className="comment-submit">
+                <div className="d-flex">
+                  <label style={{ flex: 1 }}>
+                    { isLayoutTypeGrowi && this.state.key === 1
+                      && (
+                      <span>
+                        <input
+                          type="checkbox"
+                          id="comment-form-is-markdown"
+                          name="isMarkdown"
+                          checked={this.state.isMarkdown}
+                          value="1"
+                          onChange={this.updateStateCheckbox}
+                        />
+                        <span className="ml-2">Markdown</span>
+                      </span>
+                      )
+                  }
+                  </label>
+                  <span className="hidden-xs">{ this.state.errorMessage && errorMessage }</span>
+                  { this.state.hasSlackConfig
+                    && (
+                    <div className="form-inline align-self-center mr-md-2">
+                      <SlackNotification
+                        isSlackEnabled={commentContainer.state.isSlackEnabled}
+                        slackChannels={commentContainer.state.slackChannels}
+                        onEnabledFlagChange={this.onSlackEnabledFlagChange}
+                        onChannelChange={this.onSlackChannelsChange}
+                      />
+                    </div>
+                    )
+                  }
+                  <div>
+                    <Button bsStyle="danger" className="fcbtn btn btn-xs btn-danger btn-outline btn-rounded" onClick={this.toggleEditor}>
+                      Cancel
+                    </Button>
+                  </div>
+                  &nbsp;&nbsp;&nbsp;&nbsp;
+                  <div className="hidden-xs">{submitButton}</div>
+                </div>
+                <div className="visible-xs mt-2">
+                  <div className="d-flex justify-content-end">
+                    { this.state.errorMessage && errorMessage }
+                    <div>{submitButton}</div>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+          )
+        }
+
+      </div>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const CommentEditorWrapper = (props) => {
+  return createSubscribedElement(CommentEditor, props, [AppContainer, PageContainer, EditorContainer, CommentContainer]);
+};
+
+CommentEditor.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+  commentContainer: PropTypes.instanceOf(CommentContainer).isRequired,
+
+  growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
+  replyTo: PropTypes.string,
+  commentButtonClickedHandler: PropTypes.func.isRequired,
+};
+
+export default CommentEditorWrapper;

+ 100 - 0
src/client/js/components/PageComment/CommentEditorLazyRenderer.jsx

@@ -0,0 +1,100 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+import UserPicture from '../User/UserPicture';
+
+import CommentEditor from './CommentEditor';
+
+class CommentEditorLazyRenderer extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isEditorShown: false,
+      isLayoutTypeGrowi: false,
+    };
+
+    this.growiRenderer = this.props.appContainer.getRenderer('comment');
+
+    this.showCommentFormBtnClickHandler = this.showCommentFormBtnClickHandler.bind(this);
+  }
+
+  componentWillMount() {
+    this.init();
+  }
+
+  init() {
+    const layoutType = this.props.appContainer.getConfig().layoutType;
+    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
+  }
+
+  showCommentFormBtnClickHandler() {
+    this.setState({ isEditorShown: !this.state.isEditorShown });
+  }
+
+  render() {
+    const { appContainer } = this.props;
+    const username = appContainer.me;
+    const user = appContainer.findUser(username);
+    const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
+    return (
+      <React.Fragment>
+        { !this.state.isEditorShown
+          && (
+          <div className="form page-comment-form">
+            { username
+              && (
+                <div className="comment-form">
+                  { isLayoutTypeGrowi
+                  && (
+                    <div className="comment-form-user">
+                      <UserPicture user={user} />
+                    </div>
+                  )
+                  }
+                  <div className="comment-form-main">
+                    <button
+                      type="button"
+                      className={`btn btn-lg ${this.state.isLayoutTypeGrowi ? 'btn-link' : 'btn-primary'} center-block`}
+                      onClick={this.showCommentFormBtnClickHandler}
+                    >
+                      <i className="icon-bubble"></i> Add Comment
+                    </button>
+                  </div>
+                </div>
+              )
+            }
+          </div>
+          )
+        }
+        { this.state.isEditorShown
+          && (
+          <CommentEditor
+            growiRenderer={this.growiRenderer}
+            replyTo={undefined}
+            commentButtonClickedHandler={this.showCommentFormBtnClickHandler}
+          >
+          </CommentEditor>
+)
+        }
+      </React.Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const CommentEditorLazyRendererWrapper = (props) => {
+  return createSubscribedElement(CommentEditorLazyRenderer, props, [AppContainer]);
+};
+
+CommentEditorLazyRenderer.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+export default CommentEditorLazyRendererWrapper;

+ 0 - 381
src/client/js/components/PageComment/CommentForm.jsx

@@ -1,381 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import Button from 'react-bootstrap/es/Button';
-import Tab from 'react-bootstrap/es/Tab';
-import Tabs from 'react-bootstrap/es/Tabs';
-import * as toastr from 'toastr';
-import UserPicture from '../User/UserPicture';
-import ReactUtils from '../ReactUtils';
-
-import GrowiRenderer from '../../util/GrowiRenderer';
-
-import Editor from '../PageEditor/Editor';
-import CommentPreview from './CommentPreview';
-import SlackNotification from '../SlackNotification';
-
-/**
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @export
- * @class Comment
- * @extends {React.Component}
- */
-
-export default class CommentForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    const config = this.props.crowi.getConfig();
-    const isUploadable = config.upload.image || config.upload.file;
-    const isUploadableFile = config.upload.file;
-
-    this.state = {
-      isLayoutTypeGrowi: false,
-      isFormShown: false,
-      comment: '',
-      isMarkdown: true,
-      html: '',
-      key: 1,
-      isUploadable,
-      isUploadableFile,
-      errorMessage: undefined,
-      hasSlackConfig: config.hasSlackConfig,
-      isSlackEnabled: false,
-      slackChannels: this.props.slackChannels,
-    };
-
-    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, { mode: 'comment' });
-
-    this.updateState = this.updateState.bind(this);
-    this.updateStateCheckbox = this.updateStateCheckbox.bind(this);
-    this.postComment = this.postComment.bind(this);
-    this.renderHtml = this.renderHtml.bind(this);
-    this.handleSelect = this.handleSelect.bind(this);
-    this.apiErrorHandler = this.apiErrorHandler.bind(this);
-    this.onUpload = this.onUpload.bind(this);
-    this.onSlackEnabledFlagChange = this.onSlackEnabledFlagChange.bind(this);
-    this.onSlackChannelsChange = this.onSlackChannelsChange.bind(this);
-    this.showCommentFormBtnClickHandler = this.showCommentFormBtnClickHandler.bind(this);
-  }
-
-  componentWillMount() {
-    this.init();
-  }
-
-  init() {
-    if (!this.props.pageId) {
-      return;
-    }
-
-    const layoutType = this.props.crowi.getConfig().layoutType;
-    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
-  }
-
-  updateState(value) {
-    this.setState({ comment: value });
-  }
-
-  updateStateCheckbox(event) {
-    const value = event.target.checked;
-    this.setState({ isMarkdown: value });
-    // changeMode
-    this.editor.setGfmMode(value);
-  }
-
-  handleSelect(key) {
-    this.setState({ key });
-    this.renderHtml(this.state.comment);
-  }
-
-  onSlackEnabledFlagChange(value) {
-    this.setState({ isSlackEnabled: value });
-  }
-
-  onSlackChannelsChange(value) {
-    this.setState({ slackChannels: value });
-  }
-
-  /**
-   * Load data of comments and rerender <PageComments />
-   */
-  postComment(event) {
-    if (event != null) {
-      event.preventDefault();
-    }
-
-    this.props.crowi.apiPost('/comments.add', {
-      commentForm: {
-        comment: this.state.comment,
-        _csrf: this.props.crowi.csrfToken,
-        page_id: this.props.pageId,
-        revision_id: this.props.revisionId,
-        is_markdown: this.state.isMarkdown,
-      },
-      slackNotificationForm: {
-        isSlackEnabled: this.state.isSlackEnabled,
-        slackChannels: this.state.slackChannels,
-      },
-    })
-      .then((res) => {
-        if (this.props.onPostComplete != null) {
-          this.props.onPostComplete(res.comment);
-        }
-        this.setState({
-          comment: '',
-          isMarkdown: true,
-          html: '',
-          key: 1,
-          errorMessage: undefined,
-          isSlackEnabled: false,
-        });
-        // reset value
-        this.editor.setValue('');
-      })
-      .catch((err) => {
-        const errorMessage = err.message || 'An unknown error occured when posting comment';
-        this.setState({ errorMessage });
-      });
-  }
-
-  getCommentHtml() {
-    return (
-      <CommentPreview
-        inputRef={(el) => { this.previewElement = el }}
-        html={this.state.html}
-      />
-    );
-  }
-
-  renderHtml(markdown) {
-    const context = {
-      markdown,
-    };
-
-    const growiRenderer = this.growiRenderer;
-    const interceptorManager = this.props.crowi.interceptorManager;
-    interceptorManager.process('preRenderCommnetPreview', context)
-      .then(() => { return interceptorManager.process('prePreProcess', context) })
-      .then(() => {
-        context.markdown = growiRenderer.preProcess(context.markdown);
-      })
-      .then(() => { return interceptorManager.process('postPreProcess', context) })
-      .then(() => {
-        const parsedHTML = growiRenderer.process(context.markdown);
-        context.parsedHTML = parsedHTML;
-      })
-      .then(() => { return interceptorManager.process('prePostProcess', context) })
-      .then(() => {
-        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
-      })
-      .then(() => { return interceptorManager.process('postPostProcess', context) })
-      .then(() => { return interceptorManager.process('preRenderCommentPreviewHtml', context) })
-      .then(() => {
-        this.setState({ html: context.parsedHTML });
-      })
-      // process interceptors for post rendering
-      .then(() => { return interceptorManager.process('postRenderCommentPreviewHtml', context) });
-  }
-
-  generateInnerHtml(html) {
-    return { __html: html };
-  }
-
-  onUpload(file) {
-    const endpoint = '/attachments.add';
-
-    // create a FromData instance
-    const formData = new FormData();
-    formData.append('_csrf', this.props.crowi.csrfToken);
-    formData.append('file', file);
-    formData.append('path', this.props.pagePath);
-    formData.append('page_id', this.props.pageId || 0);
-
-    // post
-    this.props.crowi.apiPost(endpoint, formData)
-      .then((res) => {
-        const attachment = res.attachment;
-        const fileName = attachment.originalName;
-
-        let insertText = `[${fileName}](${attachment.filePathProxied})`;
-        // when image
-        if (attachment.fileFormat.startsWith('image/')) {
-          // modify to "![fileName](url)" syntax
-          insertText = `!${insertText}`;
-        }
-        this.editor.insertText(insertText);
-      })
-      .catch(this.apiErrorHandler)
-      // finally
-      .then(() => {
-        this.editor.terminateUploadingState();
-      });
-  }
-
-  apiErrorHandler(error) {
-    toastr.error(error.message, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
-  }
-
-  showCommentFormBtnClickHandler() {
-    this.setState({ isFormShown: true });
-  }
-
-  renderControls() {
-
-  }
-
-  render() {
-    const crowi = this.props.crowi;
-    const username = crowi.me;
-    const user = crowi.findUser(username);
-    const comment = this.state.comment;
-    const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : ReactUtils.nl2br(comment);
-    const emojiStrategy = this.props.crowi.getEmojiStrategy();
-
-    const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
-
-    const errorMessage = <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>;
-    const submitButton = (
-      <Button type="submit" bsStyle="primary" className="fcbtn btn btn-sm btn-primary btn-outline btn-rounded btn-1b">
-        Comment
-      </Button>
-    );
-
-    return (
-      <div>
-
-        <form className="form page-comment-form" id="page-comment-form" onSubmit={this.postComment}>
-          { username
-            && (
-            <div className="comment-form">
-              { isLayoutTypeGrowi
-                && (
-                <div className="comment-form-user">
-                  <UserPicture user={user} />
-                </div>
-                )
-              }
-              <div className="comment-form-main">
-                {/* Add Comment Button */}
-                { !this.state.isFormShown
-                  && (
-                  <button
-                    type="button"
-                    className={`btn btn-lg ${isLayoutTypeGrowi ? 'btn-link' : 'btn-primary'} center-block`}
-                    onClick={this.showCommentFormBtnClickHandler}
-                  >
-                    <i className="icon-bubble"></i> Add Comment
-                  </button>
-                  )
-                }
-                {/* Editor */}
-                { this.state.isFormShown
-                  && (
-                  <React.Fragment>
-                    <div className="comment-write">
-                      <Tabs activeKey={this.state.key} id="comment-form-tabs" onSelect={this.handleSelect} animation={false}>
-                        <Tab eventKey={1} title="Write">
-                          <Editor
-                            ref={(c) => { this.editor = c }}
-                            value={this.state.comment}
-                            isGfmMode={this.state.isMarkdown}
-                            editorOptions={this.props.editorOptions}
-                            lineNumbers={false}
-                            isMobile={this.props.crowi.isMobile}
-                            isUploadable={this.state.isUploadable && this.state.isLayoutTypeGrowi} // enable only when GROWI layout
-                            isUploadableFile={this.state.isUploadableFile}
-                            emojiStrategy={emojiStrategy}
-                            onChange={this.updateState}
-                            onUpload={this.onUpload}
-                            onCtrlEnter={this.postComment}
-                          />
-                        </Tab>
-                        { this.state.isMarkdown
-                          && (
-                          <Tab eventKey={2} title="Preview">
-                            <div className="comment-form-preview">
-                              {commentPreview}
-                            </div>
-                          </Tab>
-                          )
-                        }
-                      </Tabs>
-                    </div>
-                    <div className="comment-submit">
-                      <div className="d-flex">
-                        <label style={{ flex: 1 }}>
-                          { isLayoutTypeGrowi && this.state.key === 1
-                            && (
-                            <span>
-                              <input
-                                type="checkbox"
-                                id="comment-form-is-markdown"
-                                name="isMarkdown"
-                                checked={this.state.isMarkdown}
-                                value="1"
-                                onChange={this.updateStateCheckbox}
-                              />
-                              <span className="ml-2">Markdown</span>
-                            </span>
-                            )
-                        }
-                        </label>
-                        <span className="hidden-xs">{ this.state.errorMessage && errorMessage }</span>
-                        { this.state.hasSlackConfig
-                          && (
-                          <div className="form-inline align-self-center mr-md-2">
-                            <SlackNotification
-                              isSlackEnabled={this.state.isSlackEnabled}
-                              slackChannels={this.state.slackChannels}
-                              onEnabledFlagChange={this.onSlackEnabledFlagChange}
-                              onChannelChange={this.onSlackChannelsChange}
-                            />
-                          </div>
-                          )
-                        }
-                        <div className="hidden-xs">{submitButton}</div>
-                      </div>
-                      <div className="visible-xs mt-2">
-                        <div className="d-flex justify-content-end">
-                          { this.state.errorMessage && errorMessage }
-                          <div>{submitButton}</div>
-                        </div>
-                      </div>
-                    </div>
-                  </React.Fragment>
-                  )
-                }
-              </div>
-            </div>
-            )
-          }
-        </form>
-
-      </div>
-    );
-  }
-
-}
-
-CommentForm.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiOriginRenderer: PropTypes.object.isRequired,
-  onPostComplete: PropTypes.func,
-  pageId: PropTypes.string,
-  revisionId: PropTypes.string,
-  pagePath: PropTypes.string,
-  editorOptions: PropTypes.object,
-  slackChannels: PropTypes.string,
-};
-CommentForm.defaultProps = {
-  editorOptions: {},
-};

+ 1 - 2
src/client/js/components/PageComment/DeleteCommentModal.jsx

@@ -6,7 +6,6 @@ import Modal from 'react-bootstrap/es/Modal';
 
 import dateFnsFormat from 'date-fns/format';
 
-import ReactUtils from '../ReactUtils';
 import UserPicture from '../User/UserPicture';
 import Username from '../User/Username';
 
@@ -33,7 +32,7 @@ export default class DeleteCommentModal extends React.Component {
     if (commentBody.length > DeleteCommentModal.OMIT_BODY_THRES) { // omit
       commentBody = `${commentBody.substr(0, DeleteCommentModal.OMIT_BODY_THRES)}...`;
     }
-    commentBody = ReactUtils.nl2br(commentBody);
+    commentBody = <span style={{ whiteSpace: 'pre-wrap' }}>{commentBody}</span>;
 
     return (
       <Modal show={this.props.isShown} onHide={this.props.cancel} className="page-comment-delete-modal">

+ 0 - 256
src/client/js/components/PageComments.js

@@ -1,256 +0,0 @@
-/* eslint-disable react/no-access-state-in-setstate */
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import GrowiRenderer from '../util/GrowiRenderer';
-
-import Comment from './PageComment/Comment';
-import DeleteCommentModal from './PageComment/DeleteCommentModal';
-
-/**
- * Load data of comments and render the list of <Comment />
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @export
- * @class PageComments
- * @extends {React.Component}
- */
-export default class PageComments extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      // desc order array
-      comments: [],
-
-      isLayoutTypeGrowi: false,
-
-      // for deleting comment
-      commentToDelete: undefined,
-      isDeleteConfirmModalShown: false,
-      errorMessageForDeleting: undefined,
-    };
-
-    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, { mode: 'comment' });
-
-    this.init = this.init.bind(this);
-    this.confirmToDeleteComment = this.confirmToDeleteComment.bind(this);
-    this.deleteComment = this.deleteComment.bind(this);
-    this.showDeleteConfirmModal = this.showDeleteConfirmModal.bind(this);
-    this.closeDeleteConfirmModal = this.closeDeleteConfirmModal.bind(this);
-  }
-
-  componentWillMount() {
-    this.init();
-    this.retrieveData = this.retrieveData.bind(this);
-  }
-
-  init() {
-    if (!this.props.pageId) {
-      return;
-    }
-
-    const layoutType = this.props.crowi.getConfig().layoutType;
-    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
-
-    this.retrieveData();
-  }
-
-  /**
-   * Load data of comments and store them in state
-   */
-  retrieveData() {
-    // get data (desc order array)
-    this.props.crowi.apiGet('/comments.get', { page_id: this.props.pageId })
-      .then((res) => {
-        if (res.ok) {
-          this.setState({ comments: res.comments });
-        }
-      });
-  }
-
-  confirmToDeleteComment(comment) {
-    this.setState({ commentToDelete: comment });
-    this.showDeleteConfirmModal();
-  }
-
-  deleteComment() {
-    const comment = this.state.commentToDelete;
-
-    this.props.crowi.apiPost('/comments.remove', { comment_id: comment._id })
-      .then((res) => {
-        if (res.ok) {
-          this.findAndSplice(comment);
-        }
-        this.closeDeleteConfirmModal();
-      })
-      .catch((err) => {
-        this.setState({ errorMessageForDeleting: err.message });
-      });
-  }
-
-  findAndSplice(comment) {
-    const comments = this.state.comments;
-
-    const index = comments.indexOf(comment);
-    if (index < 0) {
-      return;
-    }
-    comments.splice(index, 1);
-
-    this.setState({ comments });
-  }
-
-  showDeleteConfirmModal() {
-    this.setState({ isDeleteConfirmModalShown: true });
-  }
-
-  closeDeleteConfirmModal() {
-    this.setState({
-      commentToDelete: undefined,
-      isDeleteConfirmModalShown: false,
-      errorMessageForDeleting: undefined,
-    });
-  }
-
-  /**
-   * generate Elements of Comment
-   *
-   * @param {any} comments Array of Comment Model Obj
-   *
-   * @memberOf PageComments
-   */
-  generateCommentElements(comments) {
-    return comments.map((comment) => {
-      return (
-        <Comment
-          key={comment._id}
-          comment={comment}
-          currentUserId={this.props.crowi.me}
-          currentRevisionId={this.props.revisionId}
-          deleteBtnClicked={this.confirmToDeleteComment}
-          crowi={this.props.crowi}
-          crowiRenderer={this.growiRenderer}
-        />
-      );
-    });
-  }
-
-  render() {
-    const currentComments = [];
-    const newerComments = [];
-    const olderComments = [];
-
-    let comments = this.state.comments;
-    if (this.state.isLayoutTypeGrowi) {
-      // replace with asc order array
-      comments = comments.slice().reverse(); // non-destructive reverse
-    }
-
-    // divide by revisionId and createdAt
-    const revisionId = this.props.revisionId;
-    const revisionCreatedAt = this.props.revisionCreatedAt;
-    comments.forEach((comment) => {
-      // comparing ObjectId
-      // eslint-disable-next-line eqeqeq
-      if (comment.revision == revisionId) {
-        currentComments.push(comment);
-      }
-      else if (Date.parse(comment.createdAt) / 1000 > revisionCreatedAt) {
-        newerComments.push(comment);
-      }
-      else {
-        olderComments.push(comment);
-      }
-    });
-
-    // generate elements
-    const currentElements = this.generateCommentElements(currentComments);
-    const newerElements = this.generateCommentElements(newerComments);
-    const olderElements = this.generateCommentElements(olderComments);
-    // generate blocks
-    const currentBlock = (
-      <div className="page-comments-list-current" id="page-comments-list-current">
-        {currentElements}
-      </div>
-    );
-    const newerBlock = (
-      <div className="page-comments-list-newer collapse in" id="page-comments-list-newer">
-        {newerElements}
-      </div>
-    );
-    const olderBlock = (
-      <div className="page-comments-list-older collapse in" id="page-comments-list-older">
-        {olderElements}
-      </div>
-    );
-
-    // generate toggle elements
-    const iconForNewer = (this.state.isLayoutTypeGrowi)
-      ? <i className="fa fa-angle-double-down"></i>
-      : <i className="fa fa-angle-double-up"></i>;
-    const toggleNewer = (newerElements.length === 0)
-      ? <div></div>
-      : (
-        <a className="page-comments-list-toggle-newer text-center" data-toggle="collapse" href="#page-comments-list-newer">
-          {iconForNewer} Comments for Newer Revision {iconForNewer}
-        </a>
-      );
-    const iconForOlder = (this.state.isLayoutTypeGrowi)
-      ? <i className="fa fa-angle-double-up"></i>
-      : <i className="fa fa-angle-double-down"></i>;
-    const toggleOlder = (olderElements.length === 0)
-      ? <div></div>
-      : (
-        <a className="page-comments-list-toggle-older text-center" data-toggle="collapse" href="#page-comments-list-older">
-          {iconForOlder} Comments for Older Revision {iconForOlder}
-        </a>
-      );
-
-    // layout blocks
-    const commentsElements = (this.state.isLayoutTypeGrowi)
-      ? (
-        <div>
-          {olderBlock}
-          {toggleOlder}
-          {currentBlock}
-          {toggleNewer}
-          {newerBlock}
-        </div>
-      )
-      : (
-        <div>
-          {newerBlock}
-          {toggleNewer}
-          {currentBlock}
-          {toggleOlder}
-          {olderBlock}
-        </div>
-      );
-
-    return (
-      <div>
-        {commentsElements}
-
-        <DeleteCommentModal
-          isShown={this.state.isDeleteConfirmModalShown}
-          comment={this.state.commentToDelete}
-          errorMessage={this.state.errorMessageForDeleting}
-          cancel={this.closeDeleteConfirmModal}
-          confirmedToDelete={this.deleteComment}
-        />
-      </div>
-    );
-  }
-
-}
-
-PageComments.propTypes = {
-  pageId: PropTypes.string,
-  revisionId: PropTypes.string,
-  revisionCreatedAt: PropTypes.number,
-  crowi: PropTypes.object.isRequired,
-  crowiOriginRenderer: PropTypes.object.isRequired,
-};

+ 248 - 0
src/client/js/components/PageComments.jsx

@@ -0,0 +1,248 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Button from 'react-bootstrap/es/Button';
+
+import { withTranslation } from 'react-i18next';
+
+import AppContainer from '../services/AppContainer';
+import CommentContainer from '../services/CommentContainer';
+
+import { createSubscribedElement } from './UnstatedUtils';
+import CommentEditor from './PageComment/CommentEditor';
+
+import Comment from './PageComment/Comment';
+import DeleteCommentModal from './PageComment/DeleteCommentModal';
+import PageContainer from '../services/PageContainer';
+
+
+/**
+ * Load data of comments and render the list of <Comment />
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ * @export
+ * @class PageComments
+ * @extends {React.Component}
+ */
+class PageComments extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isLayoutTypeGrowi: false,
+
+      // for deleting comment
+      commentToDelete: undefined,
+      isDeleteConfirmModalShown: false,
+      errorMessageForDeleting: undefined,
+
+      showEditorIds: new Set(),
+    };
+
+    this.growiRenderer = this.props.appContainer.getRenderer('comment');
+
+    this.init = this.init.bind(this);
+    this.confirmToDeleteComment = this.confirmToDeleteComment.bind(this);
+    this.deleteComment = this.deleteComment.bind(this);
+    this.showDeleteConfirmModal = this.showDeleteConfirmModal.bind(this);
+    this.closeDeleteConfirmModal = this.closeDeleteConfirmModal.bind(this);
+    this.replyButtonClickedHandler = this.replyButtonClickedHandler.bind(this);
+    this.commentButtonClickedHandler = this.commentButtonClickedHandler.bind(this);
+  }
+
+  componentWillMount() {
+    this.init();
+  }
+
+  init() {
+    if (!this.props.pageContainer.state.pageId) {
+      return;
+    }
+
+    const layoutType = this.props.appContainer.getConfig().layoutType;
+    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
+
+    this.props.commentContainer.retrieveComments();
+  }
+
+  confirmToDeleteComment(comment) {
+    this.setState({ commentToDelete: comment });
+    this.showDeleteConfirmModal();
+  }
+
+  deleteComment() {
+    const comment = this.state.commentToDelete;
+
+    this.props.commentContainer.deleteComment(comment)
+      .then(() => {
+        this.closeDeleteConfirmModal();
+      })
+      .catch((err) => {
+        this.setState({ errorMessageForDeleting: err.message });
+      });
+  }
+
+  showDeleteConfirmModal() {
+    this.setState({ isDeleteConfirmModalShown: true });
+  }
+
+  closeDeleteConfirmModal() {
+    this.setState({
+      commentToDelete: undefined,
+      isDeleteConfirmModalShown: false,
+      errorMessageForDeleting: undefined,
+    });
+  }
+
+  replyButtonClickedHandler(commentId) {
+    const ids = this.state.showEditorIds.add(commentId);
+    this.setState({ showEditorIds: ids });
+  }
+
+  commentButtonClickedHandler(commentId) {
+    this.setState((prevState) => {
+      prevState.showEditorIds.delete(commentId);
+      return {
+        showEditorIds: prevState.showEditorIds,
+      };
+    });
+  }
+
+  // adds replies to specific comment object
+  addRepliesToComments(comment, replies) {
+    const replyList = [];
+    replies.forEach((reply) => {
+      if (reply.replyTo === comment._id) {
+        replyList.push(reply);
+      }
+    });
+    return replyList;
+  }
+
+  /**
+   * generate Elements of Comment
+   *
+   * @param {any} comments Array of Comment Model Obj
+   *
+   * @memberOf PageComments
+   */
+  generateCommentElements(comments, replies) {
+    return comments.map((comment) => {
+
+      const commentId = comment._id;
+      const showEditor = this.state.showEditorIds.has(commentId);
+      const username = this.props.appContainer.me;
+
+      const replyList = this.addRepliesToComments(comment, replies);
+
+      return (
+        <div key={commentId}>
+          <Comment
+            comment={comment}
+            deleteBtnClicked={this.confirmToDeleteComment}
+            growiRenderer={this.growiRenderer}
+            replyList={replyList}
+          />
+          <div className="container-fluid">
+            <div className="row">
+              <div className="col-xs-offset-1 col-xs-11 col-sm-offset-1 col-sm-11 col-md-offset-1 col-md-11 col-lg-offset-1 col-lg-11">
+                { !showEditor && (
+                  <div>
+                    { username
+                    && (
+                      <div className="col-xs-offset-6 col-sm-offset-6 col-md-offset-6 col-lg-offset-6">
+                        <Button
+                          bsStyle="primary"
+                          className="fcbtn btn btn-outline btn-rounded btn-xxs"
+                          onClick={() => { return this.replyButtonClickedHandler(commentId) }}
+                        >
+                          Reply <i className="fa fa-mail-reply"></i>
+                        </Button>
+                      </div>
+                    )
+                  }
+                  </div>
+                )}
+                { showEditor && (
+                  <CommentEditor
+                    growiRenderer={this.growiRenderer}
+                    replyTo={commentId}
+                    commentButtonClickedHandler={this.commentButtonClickedHandler}
+                  />
+                )}
+              </div>
+            </div>
+          </div>
+          <br />
+        </div>
+      );
+    });
+  }
+
+  render() {
+    const currentComments = [];
+    const currentReplies = [];
+
+    let comments = this.props.commentContainer.state.comments;
+    if (this.state.isLayoutTypeGrowi) {
+      // replace with asc order array
+      comments = comments.slice().reverse(); // non-destructive reverse
+    }
+
+    comments.forEach((comment) => {
+      if (comment.replyTo === undefined) {
+      // comment is not a reply
+        currentComments.push(comment);
+      }
+      else {
+      // comment is a reply
+        currentReplies.push(comment);
+      }
+    });
+
+    // generate elements
+    const currentElements = this.generateCommentElements(currentComments, currentReplies);
+
+    // generate blocks
+    const currentBlock = (
+      <div className="page-comments-list-current" id="page-comments-list-current">
+        {currentElements}
+      </div>
+    );
+
+    // layout blocks
+    const commentsElements = (<div>{currentBlock}</div>);
+
+    return (
+      <div>
+        {commentsElements}
+
+        <DeleteCommentModal
+          isShown={this.state.isDeleteConfirmModalShown}
+          comment={this.state.commentToDelete}
+          errorMessage={this.state.errorMessageForDeleting}
+          cancel={this.closeDeleteConfirmModal}
+          confirmedToDelete={this.deleteComment}
+        />
+      </div>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const PageCommentsWrapper = (props) => {
+  return createSubscribedElement(PageComments, props, [AppContainer, PageContainer, CommentContainer]);
+};
+
+PageComments.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  commentContainer: PropTypes.instanceOf(CommentContainer).isRequired,
+};
+
+export default withTranslation()(PageCommentsWrapper);

+ 80 - 91
src/client/js/components/PageEditor.js → src/client/js/components/PageEditor.jsx

@@ -1,52 +1,50 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
 
 import { throttle, debounce } from 'throttle-debounce';
 
-import * as toastr from 'toastr';
-import GrowiRenderer from '../util/GrowiRenderer';
+import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
 
-import { EditorOptions, PreviewOptions } from './PageEditor/OptionsSelector';
+import { createSubscribedElement } from './UnstatedUtils';
 import Editor from './PageEditor/Editor';
 import Preview from './PageEditor/Preview';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
+import EditorContainer from '../services/EditorContainer';
 
+const logger = loggerFactory('growi:PageEditor');
 
-export default class PageEditor extends React.Component {
+class PageEditor extends React.Component {
 
   constructor(props) {
     super(props);
 
-    const config = this.props.crowi.getConfig();
+    const config = this.props.appContainer.getConfig();
     const isUploadable = config.upload.image || config.upload.file;
     const isUploadableFile = config.upload.file;
     const isMathJaxEnabled = !!config.env.MATHJAX;
 
     this.state = {
-      pageId: this.props.pageId,
-      revisionId: this.props.revisionId,
-      markdown: this.props.markdown,
+      markdown: this.props.pageContainer.state.markdown,
       isUploadable,
       isUploadableFile,
       isMathJaxEnabled,
-      editorOptions: this.props.editorOptions,
-      previewOptions: this.props.previewOptions,
     };
 
-    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiRenderer, { mode: 'editor' });
-
     this.setCaretLine = this.setCaretLine.bind(this);
     this.focusToEditor = this.focusToEditor.bind(this);
     this.onMarkdownChanged = this.onMarkdownChanged.bind(this);
-    this.onSave = this.onSave.bind(this);
+    this.onSaveWithShortcut = this.onSaveWithShortcut.bind(this);
     this.onUpload = this.onUpload.bind(this);
     this.onEditorScroll = this.onEditorScroll.bind(this);
     this.onEditorScrollCursorIntoView = this.onEditorScrollCursorIntoView.bind(this);
     this.onPreviewScroll = this.onPreviewScroll.bind(this);
     this.saveDraft = this.saveDraft.bind(this);
     this.clearDraft = this.clearDraft.bind(this);
-    this.apiErrorHandler = this.apiErrorHandler.bind(this);
-    this.showUnsavedWarning = this.showUnsavedWarning.bind(this);
+
+    // get renderer
+    this.growiRenderer = this.props.appContainer.getRenderer('editor');
 
     // for scrolling
     this.lastScrolledDateWithCursor = null;
@@ -62,33 +60,18 @@ export default class PageEditor extends React.Component {
   }
 
   componentWillMount() {
+    this.props.appContainer.registerComponentInstance('PageEditor', this);
+
     // initial rendering
     this.renderPreview(this.state.markdown);
-
-    this.props.crowi.window.addEventListener('beforeunload', this.showUnsavedWarning);
-  }
-
-  componentWillUnmount() {
-    this.props.crowi.window.removeEventListener('beforeunload', this.showUnsavedWarning);
-  }
-
-  showUnsavedWarning(e) {
-    if (!this.props.crowi.getIsDocSaved()) {
-      // display browser default message
-      e.returnValue = '';
-      return '';
-    }
   }
 
   getMarkdown() {
     return this.state.markdown;
   }
 
-  setMarkdown(markdown, updateEditorValue = true) {
-    this.setState({ markdown });
-    if (updateEditorValue) {
-      this.editor.setValue(markdown);
-    }
+  updateEditorValue(markdown) {
+    this.editor.setValue(markdown);
   }
 
   focusToEditor() {
@@ -104,22 +87,6 @@ export default class PageEditor extends React.Component {
     scrollSyncHelper.scrollPreview(this.previewElement, line);
   }
 
-  /**
-   * set options (used from the outside)
-   * @param {object} editorOptions
-   */
-  setEditorOptions(editorOptions) {
-    this.setState({ editorOptions });
-  }
-
-  /**
-   * set options (used from the outside)
-   * @param {object} previewOptions
-   */
-  setPreviewOptions(previewOptions) {
-    this.setState({ previewOptions });
-  }
-
   /**
    * the change event handler for `markdown` state
    * @param {string} value
@@ -127,12 +94,32 @@ export default class PageEditor extends React.Component {
   onMarkdownChanged(value) {
     this.renderPreviewWithDebounce(value);
     this.saveDraftWithDebounce();
-    this.props.crowi.setIsDocSaved(false);
   }
 
-  onSave() {
-    this.props.onSaveWithShortcut(this.state.markdown);
-    this.props.crowi.setIsDocSaved(true);
+  /**
+   * save and update state of containers
+   */
+  async onSaveWithShortcut() {
+    const { pageContainer, editorContainer } = this.props;
+    const optionsToSave = editorContainer.getCurrentOptionsToSave();
+
+    try {
+      // disable unsaved warning
+      editorContainer.disableUnsavedWarning();
+
+      // eslint-disable-next-line no-unused-vars
+      const { page, tags } = await pageContainer.save(this.state.markdown, optionsToSave);
+      logger.debug('success to save');
+
+      pageContainer.showSuccessToastr();
+
+      // update state of EditorContainer
+      editorContainer.setState({ tags });
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      pageContainer.showErrorToastr(error);
+    }
   }
 
   /**
@@ -140,19 +127,27 @@ export default class PageEditor extends React.Component {
    * @param {any} file
    */
   async onUpload(file) {
+    const { appContainer, pageContainer } = this.props;
+
     try {
-      let res = await this.props.crowi.apiGet('/attachments.limit', { _csrf: this.props.crowi.csrfToken, fileSize: file.size });
+      let res = await appContainer.apiGet('/attachments.limit', {
+        fileSize: file.size,
+      });
+
       if (!res.isUploadable) {
         throw new Error(res.errorMessage);
       }
 
       const formData = new FormData();
-      formData.append('_csrf', this.props.crowi.csrfToken);
+      const { pageId, path } = pageContainer.state;
+      formData.append('_csrf', appContainer.csrfToken);
       formData.append('file', file);
-      formData.append('path', this.props.pagePath);
-      formData.append('page_id', this.state.pageId || 0);
+      formData.append('path', path);
+      if (pageId != null) {
+        formData.append('page_id', pageContainer.state.pageId);
+      }
 
-      res = await this.props.crowi.apiPost('/attachments.add', formData);
+      res = await appContainer.apiPost('/attachments.add', formData);
       const attachment = res.attachment;
       const fileName = attachment.originalName;
 
@@ -166,11 +161,13 @@ export default class PageEditor extends React.Component {
 
       // when if created newly
       if (res.pageCreated) {
-        // do nothing
+        logger.info('Page is created', res.pageCreated._id);
+        pageContainer.updateStateAfterSave(res.page);
       }
     }
     catch (e) {
-      this.apiErrorHandler(e);
+      logger.error('failed to upload', e);
+      pageContainer.showErrorToastr(e);
     }
     finally {
       this.editor.terminateUploadingState();
@@ -275,14 +272,16 @@ export default class PageEditor extends React.Component {
   }
 
   saveDraft() {
+    const { pageContainer, editorContainer } = this.props;
     // only when the first time to edit
-    if (!this.state.revisionId) {
-      this.props.crowi.saveDraft(this.props.pagePath, this.state.markdown);
+    if (!pageContainer.state.revisionId) {
+      editorContainer.saveDraft(pageContainer.state.path, this.state.markdown);
     }
+    editorContainer.enableUnsavedWarning();
   }
 
   clearDraft() {
-    this.props.crowi.clearDraft(this.props.pagePath);
+    this.props.editorContainer.clearDraft(this.props.pageContainer.state.path);
   }
 
   renderPreview(value) {
@@ -295,7 +294,7 @@ export default class PageEditor extends React.Component {
     };
 
     const growiRenderer = this.growiRenderer;
-    const interceptorManager = this.props.crowi.interceptorManager;
+    const interceptorManager = this.props.appContainer.interceptorManager;
     interceptorManager.process('preRenderPreview', context)
       .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => {
@@ -320,21 +319,10 @@ export default class PageEditor extends React.Component {
 
   }
 
-  apiErrorHandler(error) {
-    toastr.error(error.message, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
-  }
-
   render() {
-    const config = this.props.crowi.getConfig();
+    const config = this.props.appContainer.getConfig();
     const noCdn = !!config.env.NO_CDN;
-    const emojiStrategy = this.props.crowi.getEmojiStrategy();
+    const emojiStrategy = this.props.appContainer.getEmojiStrategy();
 
     return (
       <div className="row">
@@ -342,9 +330,8 @@ export default class PageEditor extends React.Component {
           <Editor
             ref={(c) => { this.editor = c }}
             value={this.state.markdown}
-            editorOptions={this.state.editorOptions}
             noCdn={noCdn}
-            isMobile={this.props.crowi.isMobile}
+            isMobile={this.props.appContainer.isMobile}
             isUploadable={this.state.isUploadable}
             isUploadableFile={this.state.isUploadableFile}
             emojiStrategy={emojiStrategy}
@@ -352,7 +339,7 @@ export default class PageEditor extends React.Component {
             onScrollCursorIntoView={this.onEditorScrollCursorIntoView}
             onChange={this.onMarkdownChanged}
             onUpload={this.onUpload}
-            onSave={this.onSave}
+            onSave={this.onSaveWithShortcut}
           />
         </div>
         <div className="col-md-6 hidden-sm hidden-xs page-editor-preview-container">
@@ -362,7 +349,6 @@ export default class PageEditor extends React.Component {
             inputRef={(el) => { return this.previewElement = el }}
             isMathJaxEnabled={this.state.isMathJaxEnabled}
             renderMathJaxOnInit={false}
-            previewOptions={this.state.previewOptions}
             onScroll={this.onPreviewScroll}
           />
         </div>
@@ -372,14 +358,17 @@ export default class PageEditor extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const PageEditorWrapper = (props) => {
+  return createSubscribedElement(PageEditor, props, [AppContainer, PageContainer, EditorContainer]);
+};
+
 PageEditor.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object.isRequired,
-  onSaveWithShortcut: PropTypes.func.isRequired,
-  markdown: PropTypes.string.isRequired,
-  pageId: PropTypes.string,
-  revisionId: PropTypes.string,
-  pagePath: PropTypes.string,
-  editorOptions: PropTypes.instanceOf(EditorOptions),
-  previewOptions: PropTypes.instanceOf(PreviewOptions),
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
+
+export default PageEditorWrapper;

+ 0 - 1
src/client/js/components/PageEditor/AbstractEditor.js → src/client/js/components/PageEditor/AbstractEditor.jsx

@@ -124,7 +124,6 @@ export default class AbstractEditor extends React.Component {
 AbstractEditor.propTypes = {
   value: PropTypes.string,
   isGfmMode: PropTypes.bool,
-  editorOptions: PropTypes.object,
   onChange: PropTypes.func,
   onScroll: PropTypes.func,
   onScrollCursorIntoView: PropTypes.func,

+ 0 - 0
src/client/js/components/PageEditor/Cheatsheet.js → src/client/js/components/PageEditor/Cheatsheet.jsx


+ 56 - 35
src/client/js/components/PageEditor/CodeMirrorEditor.js → src/client/js/components/PageEditor/CodeMirrorEditor.jsx

@@ -119,6 +119,14 @@ export default class CodeMirrorEditor extends AbstractEditor {
   componentDidMount() {
     // ensure to be able to resolve 'this' to use 'codemirror.commands.save'
     this.getCodeMirror().codeMirrorEditor = this;
+
+    // load theme
+    const theme = this.props.editorOptions.theme;
+    this.loadTheme(theme);
+
+    // set keymap
+    const keymapMode = this.props.editorOptions.keymapMode;
+    this.setKeymapMode(keymapMode);
   }
 
   componentWillReceiveProps(nextProps) {
@@ -375,7 +383,24 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
     this.loadKeymapMode(keymapMode)
       .then(() => {
-        this.getCodeMirror().setOption('keyMap', keymapMode);
+        let errorCount = 0;
+        const timer = setInterval(() => {
+          if (errorCount > 10) { // cancel over 3000ms
+            this.logger.error(`Timeout to load keyMap '${keymapMode}'`);
+            clearInterval(timer);
+          }
+
+          try {
+            this.getCodeMirror().setOption('keyMap', keymapMode);
+            clearInterval(timer);
+          }
+          catch (e) {
+            this.logger.info(`keyMap '${keymapMode}' has not been initialized. retry..`);
+
+            // continue if error occured
+            errorCount++;
+          }
+        }, 300);
       });
   }
 
@@ -717,12 +742,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
   render() {
     const mode = this.state.isGfmMode ? 'gfm' : undefined;
-    const defaultEditorOptions = {
-      theme: 'elegant',
-      lineNumbers: true,
-    };
     const additionalClasses = Array.from(this.state.additionalClassSet).join(' ');
-    const editorOptions = Object.assign(defaultEditorOptions, this.props.editorOptions || {});
 
     const placeholder = this.state.isGfmMode ? 'Input with Markdown..' : 'Input with Plane Text..';
 
@@ -740,35 +760,35 @@ export default class CodeMirrorEditor extends AbstractEditor {
         }}
           value={this.state.value}
           options={{
-          mode,
-          theme: editorOptions.theme,
-          styleActiveLine: editorOptions.styleActiveLine,
-          lineNumbers: this.props.lineNumbers,
-          tabSize: 4,
-          indentUnit: 4,
-          lineWrapping: true,
-          autoRefresh: { force: true }, // force option is enabled by autorefresh.ext.js -- Yuki Takei
-          autoCloseTags: true,
-          placeholder,
-          matchBrackets: true,
-          matchTags: { bothTags: true },
-          // folding
-          foldGutter: this.props.lineNumbers,
-          gutters: this.props.lineNumbers ? ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'] : [],
-          // match-highlighter, matchesonscrollbar, annotatescrollbar options
-          highlightSelectionMatches: { annotateScrollbar: true },
-          // markdown mode options
-          highlightFormatting: true,
-          // continuelist, indentlist
-          extraKeys: {
-            Enter: this.handleEnterKey,
-            'Ctrl-Enter': this.handleCtrlEnterKey,
-            'Cmd-Enter': this.handleCtrlEnterKey,
-            Tab: 'indentMore',
-            'Shift-Tab': 'indentLess',
-            'Ctrl-Q': (cm) => { cm.foldCode(cm.getCursor()) },
-          },
-        }}
+            mode,
+            theme: this.props.editorOptions.theme,
+            styleActiveLine: this.props.editorOptions.styleActiveLine,
+            lineNumbers: this.props.lineNumbers,
+            tabSize: 4,
+            indentUnit: 4,
+            lineWrapping: true,
+            autoRefresh: { force: true }, // force option is enabled by autorefresh.ext.js -- Yuki Takei
+            autoCloseTags: true,
+            placeholder,
+            matchBrackets: true,
+            matchTags: { bothTags: true },
+            // folding
+            foldGutter: this.props.lineNumbers,
+            gutters: this.props.lineNumbers ? ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'] : [],
+            // match-highlighter, matchesonscrollbar, annotatescrollbar options
+            highlightSelectionMatches: { annotateScrollbar: true },
+            // markdown mode options
+            highlightFormatting: true,
+            // continuelist, indentlist
+            extraKeys: {
+              Enter: this.handleEnterKey,
+              'Ctrl-Enter': this.handleCtrlEnterKey,
+              'Cmd-Enter': this.handleCtrlEnterKey,
+              Tab: 'indentMore',
+              'Shift-Tab': 'indentLess',
+              'Ctrl-Q': (cm) => { cm.foldCode(cm.getCursor()) },
+            },
+          }}
           onCursor={this.cursorHandler}
           onScroll={(editor, data) => {
           if (this.props.onScroll != null) {
@@ -804,6 +824,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 }
 
 CodeMirrorEditor.propTypes = Object.assign({
+  editorOptions: PropTypes.object.isRequired,
   emojiStrategy: PropTypes.object,
   lineNumbers: PropTypes.bool,
 }, AbstractEditor.propTypes);

+ 17 - 10
src/client/js/components/PageEditor/Editor.jsx

@@ -1,6 +1,8 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import { Subscribe } from 'unstated';
+
 import Dropzone from 'react-dropzone';
 import AbstractEditor from './AbstractEditor';
 import CodeMirrorEditor from './CodeMirrorEditor';
@@ -8,6 +10,7 @@ import TextAreaEditor from './TextAreaEditor';
 
 
 import pasteHelper from './PasteHelper';
+import EditorContainer from '../../services/EditorContainer';
 
 export default class Editor extends AbstractEditor {
 
@@ -271,14 +274,19 @@ export default class Editor extends AbstractEditor {
 
                 {/* for PC */}
                 { !isMobile && (
-                  <CodeMirrorEditor
-                    ref={(c) => { this.cmEditor = c }}
-                    onPasteFiles={this.pasteFilesHandler}
-                    onDragEnter={this.dragEnterHandler}
-                    {...this.props}
-                  />
-                  )
-                }
+                  <Subscribe to={[EditorContainer]}>
+                    { editorContainer => (
+                      // eslint-disable-next-line arrow-body-style
+                      <CodeMirrorEditor
+                        ref={(c) => { this.cmEditor = c }}
+                        editorOptions={editorContainer.state.editorOptions}
+                        onPasteFiles={this.pasteFilesHandler}
+                        onDragEnter={this.dragEnterHandler}
+                        {...this.props}
+                      />
+                    )}
+                  </Subscribe>
+                )}
 
                 {/* for mobile */}
                 { isMobile && (
@@ -288,8 +296,7 @@ export default class Editor extends AbstractEditor {
                     onDragEnter={this.dragEnterHandler}
                     {...this.props}
                   />
-                  )
-                }
+                )}
 
                 <input {...getInputProps()} />
               </div>

+ 0 - 0
src/client/js/components/PageEditor/MarkdownTableUtil.js → src/client/js/components/PageEditor/MarkdownTableUtil.jsx


+ 56 - 54
src/client/js/components/PageEditor/OptionsSelector.js → src/client/js/components/PageEditor/OptionsSelector.jsx

@@ -1,5 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+
 import { withTranslation } from 'react-i18next';
 
 import FormGroup from 'react-bootstrap/es/FormGroup';
@@ -9,27 +10,19 @@ import ControlLabel from 'react-bootstrap/es/ControlLabel';
 import Dropdown from 'react-bootstrap/es/Dropdown';
 import MenuItem from 'react-bootstrap/es/MenuItem';
 
-export class EditorOptions {
-
-  constructor(props) {
-    this.theme = 'elegant';
-    this.keymapMode = 'default';
-    this.styleActiveLine = false;
-
-    Object.assign(this, props);
-  }
-
-}
+import { createSubscribedElement } from '../UnstatedUtils';
+import EditorContainer from '../../services/EditorContainer';
 
-export class PreviewOptions {
 
-  constructor(props) {
-    this.renderMathJaxInRealtime = false;
-
-    Object.assign(this, props);
-  }
+export const defaultEditorOptions = {
+  theme: 'elegant',
+  keymapMode: 'default',
+  styleActiveLine: false,
+};
 
-}
+export const defaultPreviewOptions = {
+  renderMathJaxInRealtime: false,
+};
 
 class OptionsSelector extends React.Component {
 
@@ -40,8 +33,6 @@ class OptionsSelector extends React.Component {
     const isMathJaxEnabled = !!config.env.MATHJAX;
 
     this.state = {
-      editorOptions: this.props.editorOptions || new EditorOptions(),
-      previewOptions: this.props.previewOptions || new PreviewOptions(),
       isCddMenuOpened: false,
       isMathJaxEnabled,
     };
@@ -68,50 +59,60 @@ class OptionsSelector extends React.Component {
   }
 
   init() {
-    this.themeSelectorInputEl.value = this.state.editorOptions.theme;
-    this.keymapModeSelectorInputEl.value = this.state.editorOptions.keymapMode;
+    const { editorContainer } = this.props;
+
+    this.themeSelectorInputEl.value = editorContainer.state.editorOptions.theme;
+    this.keymapModeSelectorInputEl.value = editorContainer.state.editorOptions.keymapMode;
   }
 
   onChangeTheme() {
+    const { editorContainer } = this.props;
+
     const newValue = this.themeSelectorInputEl.value;
-    const newOpts = Object.assign(this.state.editorOptions, { theme: newValue });
-    this.setState({ editorOptions: newOpts });
+    const newOpts = Object.assign(editorContainer.state.editorOptions, { theme: newValue });
+    editorContainer.setState({ editorOptions: newOpts });
 
-    // dispatch event
-    this.dispatchOnChange();
+    // save to localStorage
+    editorContainer.saveOptsToLocalStorage();
   }
 
   onChangeKeymapMode() {
+    const { editorContainer } = this.props;
+
     const newValue = this.keymapModeSelectorInputEl.value;
-    const newOpts = Object.assign(this.state.editorOptions, { keymapMode: newValue });
-    this.setState({ editorOptions: newOpts });
+    const newOpts = Object.assign(editorContainer.state.editorOptions, { keymapMode: newValue });
+    editorContainer.setState({ editorOptions: newOpts });
 
-    // dispatch event
-    this.dispatchOnChange();
+    // save to localStorage
+    editorContainer.saveOptsToLocalStorage();
   }
 
   onClickStyleActiveLine(event) {
+    const { editorContainer } = this.props;
+
     // keep dropdown opened
     this._cddForceOpen = true;
 
-    const newValue = !this.state.editorOptions.styleActiveLine;
-    const newOpts = Object.assign(this.state.editorOptions, { styleActiveLine: newValue });
-    this.setState({ editorOptions: newOpts });
+    const newValue = !editorContainer.state.editorOptions.styleActiveLine;
+    const newOpts = Object.assign(editorContainer.state.editorOptions, { styleActiveLine: newValue });
+    editorContainer.setState({ editorOptions: newOpts });
 
-    // dispatch event
-    this.dispatchOnChange();
+    // save to localStorage
+    editorContainer.saveOptsToLocalStorage();
   }
 
   onClickRenderMathJaxInRealtime(event) {
+    const { editorContainer } = this.props;
+
     // keep dropdown opened
     this._cddForceOpen = true;
 
-    const newValue = !this.state.previewOptions.renderMathJaxInRealtime;
-    const newOpts = Object.assign(this.state.previewOptions, { renderMathJaxInRealtime: newValue });
-    this.setState({ previewOptions: newOpts });
+    const newValue = !editorContainer.state.previewOptions.renderMathJaxInRealtime;
+    const newOpts = Object.assign(editorContainer.state.previewOptions, { renderMathJaxInRealtime: newValue });
+    editorContainer.setState({ previewOptions: newOpts });
 
-    // dispatch event
-    this.dispatchOnChange();
+    // save to localStorage
+    editorContainer.saveOptsToLocalStorage();
   }
 
   /*
@@ -127,13 +128,6 @@ class OptionsSelector extends React.Component {
     }
   }
 
-  /**
-   * dispatch onChange event
-   */
-  dispatchOnChange() {
-    this.props.onChange(this.state.editorOptions, this.state.previewOptions);
-  }
-
   renderThemeSelector() {
     const optionElems = this.availableThemes.map((theme) => {
       return <option key={theme} value={theme}>{theme}</option>;
@@ -225,8 +219,8 @@ class OptionsSelector extends React.Component {
   }
 
   renderActiveLineMenuItem() {
-    const { t } = this.props;
-    const isActive = this.state.editorOptions.styleActiveLine;
+    const { t, editorContainer } = this.props;
+    const isActive = editorContainer.state.editorOptions.styleActiveLine;
 
     const iconClasses = ['text-info'];
     if (isActive) {
@@ -248,8 +242,10 @@ class OptionsSelector extends React.Component {
       return;
     }
 
+    const { editorContainer } = this.props;
+
     const isEnabled = this.state.isMathJaxEnabled;
-    const isActive = isEnabled && this.state.previewOptions.renderMathJaxInRealtime;
+    const isActive = isEnabled && editorContainer.state.previewOptions.renderMathJaxInRealtime;
 
     const iconClasses = ['text-info'];
     if (isActive) {
@@ -278,13 +274,19 @@ class OptionsSelector extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const OptionsSelectorWrapper = (props) => {
+  return createSubscribedElement(OptionsSelector, props, [EditorContainer]);
+};
 
 OptionsSelector.propTypes = {
   t: PropTypes.func.isRequired, // i18next
+
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
   crowi: PropTypes.object.isRequired,
-  editorOptions: PropTypes.instanceOf(EditorOptions).isRequired,
-  previewOptions: PropTypes.instanceOf(PreviewOptions).isRequired,
-  onChange: PropTypes.func.isRequired,
 };
 
-export default withTranslation()(OptionsSelector);
+export default withTranslation()(OptionsSelectorWrapper);

+ 0 - 47
src/client/js/components/PageEditor/Preview.js

@@ -1,47 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import RevisionBody from '../Page/RevisionBody';
-
-import { PreviewOptions } from './OptionsSelector';
-
-/**
- * Wrapper component for Page/RevisionBody
- */
-export default class Preview extends React.Component {
-
-  render() {
-    const renderMathJaxInRealtime = this.props.previewOptions.renderMathJaxInRealtime;
-
-    return (
-      <div
-        className="page-editor-preview-body"
-        ref={(elm) => {
-            this.previewElement = elm;
-            this.props.inputRef(elm);
-          }}
-        onScroll={(event) => {
-            if (this.props.onScroll != null) {
-              this.props.onScroll(event.target.scrollTop);
-            }
-          }}
-      >
-
-        <RevisionBody
-          {...this.props}
-          renderMathJaxInRealtime={renderMathJaxInRealtime}
-        />
-      </div>
-    );
-  }
-
-}
-
-Preview.propTypes = {
-  html: PropTypes.string,
-  inputRef: PropTypes.func.isRequired, // for getting div element
-  isMathJaxEnabled: PropTypes.bool,
-  renderMathJaxOnInit: PropTypes.bool,
-  previewOptions: PropTypes.instanceOf(PreviewOptions),
-  onScroll: PropTypes.func,
-};

+ 50 - 0
src/client/js/components/PageEditor/Preview.jsx

@@ -0,0 +1,50 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { Subscribe } from 'unstated';
+
+import RevisionBody from '../Page/RevisionBody';
+
+import EditorContainer from '../../services/EditorContainer';
+
+/**
+ * Wrapper component for Page/RevisionBody
+ */
+export default class Preview extends React.PureComponent {
+
+  render() {
+    return (
+      <Subscribe to={[EditorContainer]}>
+        { editorContainer => (
+          // eslint-disable-next-line arrow-body-style
+          <div
+            className="page-editor-preview-body"
+            ref={(elm) => {
+                this.previewElement = elm;
+                this.props.inputRef(elm);
+              }}
+            onScroll={(event) => {
+                if (this.props.onScroll != null) {
+                  this.props.onScroll(event.target.scrollTop);
+                }
+              }}
+          >
+            <RevisionBody
+              {...this.props}
+              renderMathJaxInRealtime={editorContainer.state.previewOptions.renderMathJaxInRealtime}
+            />
+          </div>
+        )}
+      </Subscribe>
+    );
+  }
+
+}
+
+Preview.propTypes = {
+  html: PropTypes.string,
+  inputRef: PropTypes.func.isRequired, // for getting div element
+  isMathJaxEnabled: PropTypes.bool,
+  renderMathJaxOnInit: PropTypes.bool,
+  onScroll: PropTypes.func,
+};

+ 0 - 0
src/client/js/components/PageEditor/SimpleCheatsheet.js → src/client/js/components/PageEditor/SimpleCheatsheet.jsx


+ 0 - 0
src/client/js/components/PageEditor/TextAreaEditor.js → src/client/js/components/PageEditor/TextAreaEditor.jsx


+ 83 - 90
src/client/js/components/PageEditorByHackmd.jsx

@@ -1,38 +1,39 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
 
 import SplitButton from 'react-bootstrap/es/SplitButton';
 import MenuItem from 'react-bootstrap/es/MenuItem';
 
-import * as toastr from 'toastr';
+import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
+import EditorContainer from '../services/EditorContainer';
 
+import { createSubscribedElement } from './UnstatedUtils';
 import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
 
-export default class PageEditorByHackmd extends React.PureComponent {
+const logger = loggerFactory('growi:PageEditorByHackmd');
+
+class PageEditorByHackmd extends React.Component {
 
   constructor(props) {
     super(props);
 
     this.state = {
-      markdown: this.props.markdown,
+      markdown: this.props.pageContainer.state.markdown,
       isInitialized: false,
       isInitializing: false,
-      initialRevisionId: this.props.revisionId,
-      revisionId: this.props.revisionId,
-      revisionIdHackmdSynced: this.props.revisionIdHackmdSynced,
-      pageIdOnHackmd: this.props.pageIdOnHackmd,
-      hasDraftOnHackmd: this.props.hasDraftOnHackmd,
     };
 
     this.getHackmdUri = this.getHackmdUri.bind(this);
     this.startToEdit = this.startToEdit.bind(this);
     this.resumeToEdit = this.resumeToEdit.bind(this);
+    this.onSaveWithShortcut = this.onSaveWithShortcut.bind(this);
     this.hackmdEditorChangeHandler = this.hackmdEditorChangeHandler.bind(this);
-
-    this.apiErrorHandler = this.apiErrorHandler.bind(this);
   }
 
   componentWillMount() {
+    this.props.appContainer.registerComponentInstance('PageEditorByHackmd', this);
   }
 
   /**
@@ -51,13 +52,6 @@ export default class PageEditorByHackmd extends React.PureComponent {
       });
   }
 
-  setMarkdown(markdown, updateEditorValue = true) {
-    this.setState({ markdown });
-    if (this.state.isInitialized && updateEditorValue) {
-      this.hackmdEditor.setValue(markdown);
-    }
-  }
-
   /**
    * reset initialized status
    */
@@ -65,40 +59,8 @@ export default class PageEditorByHackmd extends React.PureComponent {
     this.setState({ isInitialized: false });
   }
 
-  /**
-   * clear revision status (invoked when page is updated by myself)
-   */
-  clearRevisionStatus(updatedRevisionId, updatedRevisionIdHackmdSynced) {
-    this.setState({
-      initialRevisionId: updatedRevisionId,
-      revisionId: updatedRevisionId,
-      revisionIdHackmdSynced: updatedRevisionIdHackmdSynced,
-    });
-  }
-
-  /**
-   * update revisionId of state
-   * @param {string} revisionId
-   * @param {string} revisionIdHackmdSynced
-   */
-  setRevisionId(revisionId, revisionIdHackmdSynced) {
-    this.setState({ revisionId, revisionIdHackmdSynced });
-  }
-
-  getRevisionIdHackmdSynced() {
-    return this.state.revisionIdHackmdSynced;
-  }
-
-  /**
-   * update hasDraftOnHackmd of state
-   * @param {bool} hasDraftOnHackmd
-   */
-  setHasDraftOnHackmd(hasDraftOnHackmd) {
-    this.setState({ hasDraftOnHackmd });
-  }
-
   getHackmdUri() {
-    const envVars = this.props.crowi.config.env;
+    const envVars = this.props.appContainer.getConfig().env;
     return envVars.HACKMD_URI;
   }
 
@@ -106,6 +68,7 @@ export default class PageEditorByHackmd extends React.PureComponent {
    * Start integration with HackMD
    */
   startToEdit() {
+    const { pageContainer } = this.props;
     const hackmdUri = this.getHackmdUri();
 
     if (hackmdUri == null) {
@@ -119,9 +82,9 @@ export default class PageEditorByHackmd extends React.PureComponent {
     });
 
     const params = {
-      pageId: this.props.pageId,
+      pageId: pageContainer.state.pageId,
     };
-    this.props.crowi.apiPost('/hackmd.integrate', params)
+    this.props.appContainer.apiPost('/hackmd.integrate', params)
       .then((res) => {
         if (!res.ok) {
           throw new Error(res.error);
@@ -129,11 +92,15 @@ export default class PageEditorByHackmd extends React.PureComponent {
 
         this.setState({
           isInitialized: true,
+        });
+        pageContainer.setState({
           pageIdOnHackmd: res.pageIdOnHackmd,
           revisionIdHackmdSynced: res.revisionIdHackmdSynced,
         });
       })
-      .catch(this.apiErrorHandler)
+      .catch((err) => {
+        pageContainer.showErrorToastr(err);
+      })
       .then(() => {
         this.setState({ isInitializing: false });
       });
@@ -150,14 +117,42 @@ export default class PageEditorByHackmd extends React.PureComponent {
    * Reset draft
    */
   discardChanges() {
-    this.setState({ hasDraftOnHackmd: false });
+    this.props.pageContainer.setState({ hasDraftOnHackmd: false });
+  }
+
+  /**
+   * save and update state of containers
+   * @param {string} markdown
+   */
+  async onSaveWithShortcut(markdown) {
+    const { pageContainer, editorContainer } = this.props;
+    const optionsToSave = editorContainer.getCurrentOptionsToSave();
+
+    try {
+      // disable unsaved warning
+      editorContainer.disableUnsavedWarning();
+
+      // eslint-disable-next-line no-unused-vars
+      const { page, tags } = await pageContainer.save(markdown, optionsToSave);
+      logger.debug('success to save');
+
+      pageContainer.showSuccessToastr();
+
+      // update state of EditorContainer
+      editorContainer.setState({ tags });
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      pageContainer.showErrorToastr(error);
+    }
   }
 
   /**
    * onChange event of HackmdEditor handler
    */
-  hackmdEditorChangeHandler(body) {
+  async hackmdEditorChangeHandler(body) {
     const hackmdUri = this.getHackmdUri();
+    const { pageContainer, editorContainer } = this.props;
 
     if (hackmdUri == null) {
       // do nothing
@@ -165,57 +160,52 @@ export default class PageEditorByHackmd extends React.PureComponent {
     }
 
     // do nothing if contents are same
-    if (this.props.markdown === body) {
+    if (this.state.markdown === body) {
       return;
     }
 
+    // enable unsaved warning
+    editorContainer.enableUnsavedWarning();
+
     const params = {
-      pageId: this.props.pageId,
+      pageId: pageContainer.state.pageId,
     };
-    this.props.crowi.apiPost('/hackmd.saveOnHackmd', params)
-      .then((res) => {
-        // do nothing
-      })
-      .catch((err) => {
-        // do nothing
-      });
-  }
-
-  apiErrorHandler(error) {
-    toastr.error(error.message, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
+    try {
+      await this.props.appContainer.apiPost('/hackmd.saveOnHackmd', params);
+    }
+    catch (err) {
+      logger.error(err);
+    }
   }
 
   render() {
     const hackmdUri = this.getHackmdUri();
+    const { pageContainer } = this.props;
+    const {
+      pageIdOnHackmd, revisionId, revisionIdHackmdSynced, remoteRevisionId, hasDraftOnHackmd,
+    } = pageContainer.state;
 
-    const isPageExistsOnHackmd = (this.state.pageIdOnHackmd != null);
-    const isResume = isPageExistsOnHackmd && this.state.hasDraftOnHackmd;
+    const isPageExistsOnHackmd = (pageIdOnHackmd != null);
+    const isResume = isPageExistsOnHackmd && hasDraftOnHackmd;
 
     if (this.state.isInitialized) {
       return (
         <HackmdEditor
           ref={(c) => { this.hackmdEditor = c }}
           hackmdUri={hackmdUri}
-          pageIdOnHackmd={this.state.pageIdOnHackmd}
+          pageIdOnHackmd={pageIdOnHackmd}
           initializationMarkdown={isResume ? null : this.state.markdown}
           onChange={this.hackmdEditorChangeHandler}
           onSaveWithShortcut={(document) => {
-            this.props.onSaveWithShortcut(document);
+            this.onSaveWithShortcut(document);
           }}
         >
         </HackmdEditor>
       );
     }
 
-    const isRevisionOutdated = this.state.initialRevisionId !== this.state.revisionId;
-    const isHackmdDocumentOutdated = this.state.revisionId !== this.state.revisionIdHackmdSynced;
+    const isRevisionOutdated = revisionId !== remoteRevisionId;
+    const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
 
     let content;
     /*
@@ -232,7 +222,6 @@ export default class PageEditorByHackmd extends React.PureComponent {
      * Resume to edit or discard changes
      */
     else if (isResume) {
-      const revisionIdHackmdSynced = this.state.revisionIdHackmdSynced;
       const title = (
         <React.Fragment>
           <span className="btn-label"><i className="icon-control-end"></i></span>
@@ -320,13 +309,17 @@ export default class PageEditorByHackmd extends React.PureComponent {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const PageEditorByHackmdWrapper = (props) => {
+  return createSubscribedElement(PageEditorByHackmd, props, [AppContainer, PageContainer, EditorContainer]);
+};
+
 PageEditorByHackmd.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  markdown: PropTypes.string.isRequired,
-  onSaveWithShortcut: PropTypes.func.isRequired,
-  pageId: PropTypes.string,
-  revisionId: PropTypes.string,
-  pageIdOnHackmd: PropTypes.string,
-  revisionIdHackmdSynced: PropTypes.string,
-  hasDraftOnHackmd: PropTypes.bool,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
+
+export default PageEditorByHackmdWrapper;

+ 0 - 167
src/client/js/components/PageList/Draft.jsx

@@ -1,167 +0,0 @@
-import React, { Fragment } from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import GrowiRenderer from '../../util/GrowiRenderer';
-
-import RevisionBody from '../Page/RevisionBody';
-
-class Draft extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      html: '',
-      isOpen: false,
-    };
-
-    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, { mode: 'draft' });
-
-    this.renderHtml = this.renderHtml.bind(this);
-    this.toggleContent = this.toggleContent.bind(this);
-    this.copyMarkdownToClipboard = this.copyMarkdownToClipboard.bind(this);
-    this.renderAccordionTitle = this.renderAccordionTitle.bind(this);
-  }
-
-  copyMarkdownToClipboard() {
-    navigator.clipboard.writeText(this.props.markdown);
-  }
-
-  async toggleContent(e) {
-    const target = e.currentTarget.getAttribute('data-target');
-
-    if (!this.state.html) {
-      await this.renderHtml();
-    }
-
-    if (this.state.isOpen) {
-      $(target).collapse('hide');
-      this.setState({ isOpen: false });
-    }
-    else {
-      $(target).collapse('show');
-      this.setState({ isOpen: true });
-    }
-  }
-
-  async renderHtml() {
-    const context = {
-      markdown: this.props.markdown,
-    };
-
-    const growiRenderer = this.growiRenderer;
-    const interceptorManager = this.props.crowi.interceptorManager;
-    await interceptorManager.process('prePreProcess', context)
-      .then(() => {
-        context.markdown = growiRenderer.preProcess(context.markdown);
-      })
-      .then(() => { return interceptorManager.process('postPreProcess', context) })
-      .then(() => {
-        const parsedHTML = growiRenderer.process(context.markdown);
-        context.parsedHTML = parsedHTML;
-      })
-      .then(() => { return interceptorManager.process('prePostProcess', context) })
-      .then(() => {
-        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
-      })
-      .then(() => { return interceptorManager.process('postPostProcess', context) })
-      .then(() => {
-        this.setState({ html: context.parsedHTML });
-      });
-  }
-
-  renderAccordionTitle(isExist) {
-    const iconClass = this.state.isOpen ? 'caret-opened' : '';
-
-    if (isExist) {
-      return (
-        <Fragment>
-          <i className={`caret ${iconClass}`}></i>
-          <span className="mx-2">{this.props.path}</span>
-          <span>({this.props.t('page exists')})</span>
-        </Fragment>
-      );
-    }
-
-    return (
-      <Fragment>
-        <i className={`caret ${iconClass}`}></i>
-        <a className="mx-2" href={`${this.props.path}#edit`} target="_blank" rel="noopener noreferrer">{this.props.path}</a>
-        <span className="label-draft label label-default">draft</span>
-      </Fragment>
-    );
-  }
-
-  render() {
-    const { t } = this.props;
-    const id = this.props.path.replace('/', '-');
-
-    return (
-      <div className="draft-list-item">
-        <div className="panel">
-          <div className="panel-heading d-flex justify-content-between">
-            <div className="panel-title" onClick={this.toggleContent} data-target={`#${id}`}>
-              {this.renderAccordionTitle(this.props.isExist)}
-            </div>
-            <div className="icon-container">
-              {this.props.isExist
-                ? null
-                : (
-                  <a
-                    href={`${this.props.path}#edit`}
-                    target="_blank"
-                    rel="noopener noreferrer"
-                    className="draft-edit"
-                    data-toggle="tooltip"
-                    data-placement="bottom"
-                    title={this.props.t('Edit')}
-                  >
-                    <i className="icon-note" />
-                  </a>
-                )
-              }
-              <a
-                className="draft-copy"
-                data-toggle="tooltip"
-                data-placement="bottom"
-                title={this.props.t('Copy')}
-                onClick={this.copyMarkdownToClipboard}
-              >
-                <i className="icon-doc" />
-              </a>
-              <a
-                className="text-danger draft-delete"
-                data-toggle="tooltip"
-                data-placement="top"
-                title={t('Delete')}
-                onClick={() => { return this.props.clearDraft(this.props.path) }}
-              >
-                <i className="icon-trash" />
-              </a>
-            </div>
-          </div>
-          <div className="panel-body collapse" id={id} aria-labelledby={id} data-parent="#draft-list">
-            <div className="revision-body wiki">
-              <RevisionBody html={this.state.html} />
-            </div>
-          </div>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-Draft.propTypes = {
-  t: PropTypes.func.isRequired,
-  crowi: PropTypes.object.isRequired,
-  crowiOriginRenderer: PropTypes.object.isRequired,
-  path: PropTypes.string.isRequired,
-  markdown: PropTypes.string.isRequired,
-  isExist: PropTypes.bool.isRequired,
-  clearDraft: PropTypes.func.isRequired,
-};
-
-export default withTranslation()(Draft);

+ 1 - 3
src/client/js/components/PageList/PagePath.js

@@ -30,7 +30,7 @@ export default class PagePath extends React.Component {
   render() {
     const page = this.props.page;
     const isShortPathOnly = this.props.isShortPathOnly;
-    const pagePath = decodeURIComponent(page.path.replace(this.props.excludePathString.replace(/^\//, ''), ''));
+    const pagePath = decodeURIComponent(page.path);
     const shortPath = this.getShortPath(pagePath);
 
     const shortPathEscaped = escapeStringRegexp(shortPath);
@@ -51,11 +51,9 @@ export default class PagePath extends React.Component {
 PagePath.propTypes = {
   page: PropTypes.object.isRequired,
   isShortPathOnly: PropTypes.bool,
-  excludePathString: PropTypes.string,
   additionalClassNames: PropTypes.array,
 };
 
 PagePath.defaultProps = {
   additionalClassNames: [],
-  excludePathString: '',
 };

+ 31 - 44
src/client/js/components/PageStatusAlert.jsx

@@ -1,7 +1,13 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+
 import { withTranslation } from 'react-i18next';
 
+import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
+
+import { createSubscribedElement } from './UnstatedUtils';
+
 /**
  *
  * @author Yuki Takei <yuki@weseek.co.jp>
@@ -17,12 +23,6 @@ class PageStatusAlert extends React.Component {
     super(props);
 
     this.state = {
-      initialRevisionId: this.props.revisionId,
-      revisionId: this.props.revisionId,
-      revisionIdHackmdSynced: this.props.revisionIdHackmdSynced,
-      lastUpdateUsername: undefined,
-      hasDraftOnHackmd: this.props.hasDraftOnHackmd,
-      isDraftUpdatingInRealtime: false,
     };
 
     this.renderSomeoneEditingAlert = this.renderSomeoneEditingAlert.bind(this);
@@ -30,32 +30,8 @@ class PageStatusAlert extends React.Component {
     this.renderUpdatedAlert = this.renderUpdatedAlert.bind(this);
   }
 
-  /**
-   * clear status (invoked when page is updated by myself)
-   */
-  clearRevisionStatus(updatedRevisionId, updatedRevisionIdHackmdSynced) {
-    this.setState({
-      initialRevisionId: updatedRevisionId,
-      revisionId: updatedRevisionId,
-      revisionIdHackmdSynced: updatedRevisionIdHackmdSynced,
-      hasDraftOnHackmd: false,
-      isDraftUpdatingInRealtime: false,
-    });
-  }
-
-  setRevisionId(revisionId, revisionIdHackmdSynced) {
-    this.setState({ revisionId, revisionIdHackmdSynced });
-  }
-
-  setLastUpdateUsername(lastUpdateUsername) {
-    this.setState({ lastUpdateUsername });
-  }
-
-  setHasDraftOnHackmd(hasDraftOnHackmd) {
-    this.setState({
-      hasDraftOnHackmd,
-      isDraftUpdatingInRealtime: true,
-    });
+  componentWillMount() {
+    this.props.appContainer.registerComponentInstance('PageStatusAlert', this);
   }
 
   refreshPage() {
@@ -100,11 +76,11 @@ class PageStatusAlert extends React.Component {
     return (
       <div className="alert-revision-outdated myadmin-alert alert-warning myadmin-alert-bottom alertbottom2">
         <i className="icon-fw icon-bulb"></i>
-        {this.state.lastUpdateUsername} {label1}
+        {this.props.pageContainer.state.lastUpdateUsername} {label1}
         &nbsp;
         <i className="fa fa-angle-double-right"></i>
         &nbsp;
-        <a onClick={this.refreshPage}>
+        <a href="#" onClick={this.refreshPage}>
           {label2}
         </a>
       </div>
@@ -114,16 +90,23 @@ class PageStatusAlert extends React.Component {
   render() {
     let content = <React.Fragment></React.Fragment>;
 
-    const isRevisionOutdated = this.state.initialRevisionId !== this.state.revisionId;
-    const isHackmdDocumentOutdated = this.state.revisionId !== this.state.revisionIdHackmdSynced;
+    const {
+      revisionId, revisionIdHackmdSynced, remoteRevisionId, hasDraftOnHackmd, isHackmdDraftUpdatingInRealtime,
+    } = this.props.pageContainer.state;
+
+    const isRevisionOutdated = revisionId !== remoteRevisionId;
+    const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
 
+    // when remote revision is newer than both
     if (isHackmdDocumentOutdated && isRevisionOutdated) {
       content = this.renderUpdatedAlert();
     }
-    else if (this.state.isDraftUpdatingInRealtime) {
+    // when someone editing with HackMD
+    else if (isHackmdDraftUpdatingInRealtime) {
       content = this.renderSomeoneEditingAlert();
     }
-    else if (this.state.hasDraftOnHackmd) {
+    // when the draft of HackMD is newest
+    else if (hasDraftOnHackmd) {
       content = this.renderDraftExistsAlert();
     }
 
@@ -132,14 +115,18 @@ class PageStatusAlert extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const PageStatusAlertWrapper = (props) => {
+  return createSubscribedElement(PageStatusAlert, props, [AppContainer, PageContainer]);
+};
+
 PageStatusAlert.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  hasDraftOnHackmd: PropTypes.bool.isRequired,
-  revisionId: PropTypes.string,
-  revisionIdHackmdSynced: PropTypes.string,
-};
 
-PageStatusAlert.defaultProps = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
 
-export default withTranslation(null, { withRef: true })(PageStatusAlert);
+export default withTranslation()(PageStatusAlertWrapper);

+ 0 - 28
src/client/js/components/ReactUtils.js

@@ -1,28 +0,0 @@
-import React from 'react';
-
-export default class ReactUtils {
-
-  /**
-   * show '\n' as '<br>'
-   *
-   * @see http://qiita.com/kouheiszk/items/e7c74ab5eab901f89a7f
-   *
-   * @static
-   * @param {any} text
-   * @returns
-   *
-   * @memberOf ReactUtils
-   */
-  static nl2br(text) {
-    const regex = /(\n)/g;
-    return text.split(regex).map((line) => {
-      if (line.match(regex)) {
-        return React.createElement('br', { key: Math.random().toString(10).substr(2, 10) });
-      }
-
-      return line;
-
-    });
-  }
-
-}

+ 22 - 11
src/client/js/components/RecentCreated/RecentCreated.jsx

@@ -1,10 +1,15 @@
 import React from 'react';
-
 import PropTypes from 'prop-types';
+
 import Pagination from 'react-bootstrap/lib/Pagination';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+
 import Page from '../PageList/Page';
 
-export default class RecentCreated extends React.Component {
+class RecentCreated extends React.Component {
 
   constructor(props) {
     super(props);
@@ -23,13 +28,15 @@ export default class RecentCreated extends React.Component {
   }
 
   getRecentCreatedList(selectPageNumber) {
-    const pageId = this.props.pageId;
-    const userId = this.props.crowi.me;
-    const limit = this.props.limit;
+    const { appContainer, pageContainer } = this.props;
+    const { pageId } = pageContainer.state;
+
+    const userId = appContainer.me;
+    const limit = appContainer.getConfig().recentCreatedLimit;
     const offset = (selectPageNumber - 1) * limit;
 
     // pagesList get and pagination calculate
-    this.props.crowi.apiGet('/pages.recentCreated', {
+    this.props.appContainer.apiGet('/pages.recentCreated', {
       page_id: pageId, user: userId, limit, offset,
     })
       .then((res) => {
@@ -183,12 +190,16 @@ export default class RecentCreated extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const RecentCreatedWrapper = (props) => {
+  return createSubscribedElement(RecentCreated, props, [AppContainer, PageContainer]);
+};
 
 RecentCreated.propTypes = {
-  pageId: PropTypes.string.isRequired,
-  crowi: PropTypes.object.isRequired,
-  limit: PropTypes.number,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
 
-RecentCreated.defaultProps = {
-};
+export default RecentCreatedWrapper;

+ 62 - 53
src/client/js/components/SavePageControls.jsx

@@ -1,63 +1,73 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+
 import { withTranslation } from 'react-i18next';
 
 import ButtonToolbar from 'react-bootstrap/es/ButtonToolbar';
 import SplitButton from 'react-bootstrap/es/SplitButton';
 import MenuItem from 'react-bootstrap/es/MenuItem';
 
+import PageContainer from '../services/PageContainer';
+import AppContainer from '../services/AppContainer';
+import EditorContainer from '../services/EditorContainer';
+
+import { createSubscribedElement } from './UnstatedUtils';
 import SlackNotification from './SlackNotification';
 import GrantSelector from './SavePageControls/GrantSelector';
 
-class SavePageControls extends React.PureComponent {
+
+class SavePageControls extends React.Component {
 
   constructor(props) {
     super(props);
 
-    this.state = {
-      pageId: this.props.pageId,
-    };
-
-    const config = this.props.crowi.getConfig();
+    const config = this.props.appContainer.getConfig();
     this.hasSlackConfig = config.hasSlackConfig;
     this.isAclEnabled = config.isAclEnabled;
 
-    this.getCurrentOptionsToSave = this.getCurrentOptionsToSave.bind(this);
-    this.submit = this.submit.bind(this);
-    this.submitAndOverwriteScopesOfDescendants = this.submitAndOverwriteScopesOfDescendants.bind(this);
+    this.slackEnabledFlagChangedHandler = this.slackEnabledFlagChangedHandler.bind(this);
+    this.slackChannelsChangedHandler = this.slackChannelsChangedHandler.bind(this);
+    this.updateGrantHandler = this.updateGrantHandler.bind(this);
+
+    this.save = this.save.bind(this);
+    this.saveAndOverwriteScopesOfDescendants = this.saveAndOverwriteScopesOfDescendants.bind(this);
   }
 
-  componentWillMount() {
+  slackEnabledFlagChangedHandler(isSlackEnabled) {
+    this.props.editorContainer.setState({ isSlackEnabled });
   }
 
-  getCurrentOptionsToSave() {
-    let currentOptions = this.grantSelector.getCurrentOptionsToSave();
-    if (this.hasSlackConfig) {
-      currentOptions = Object.assign(currentOptions, this.slackNotification.getCurrentOptionsToSave());
-    }
-    return currentOptions;
+  slackChannelsChangedHandler(slackChannels) {
+    this.props.editorContainer.setState({ slackChannels });
   }
 
-  /**
-   * update pageId of state
-   * @param {string} pageId
-   */
-  setPageId(pageId) {
-    this.setState({ pageId });
+  updateGrantHandler(data) {
+    this.props.editorContainer.setState(data);
   }
 
-  submit() {
-    this.props.crowi.setIsDocSaved(true);
-    this.props.onSubmit();
+  save() {
+    const { pageContainer, editorContainer } = this.props;
+    // disable unsaved warning
+    editorContainer.disableUnsavedWarning();
+    // save
+    pageContainer.saveAndReload(editorContainer.getCurrentOptionsToSave());
   }
 
-  submitAndOverwriteScopesOfDescendants() {
-    this.props.onSubmit({ overwriteScopesOfDescendants: true });
+  saveAndOverwriteScopesOfDescendants() {
+    const { pageContainer, editorContainer } = this.props;
+    // disable unsaved warning
+    editorContainer.disableUnsavedWarning();
+    // save
+    const optionsToSave = Object.assign(editorContainer.getCurrentOptionsToSave(), {
+      overwriteScopesOfDescendants: true,
+    });
+    pageContainer.saveAndReload(optionsToSave);
   }
 
   render() {
-    const { t } = this.props;
-    const labelSubmitButton = this.state.pageId == null ? t('Create') : t('Update');
+    const { t, pageContainer, editorContainer } = this.props;
+    const isRootPage = pageContainer.state.path === '/';
+    const labelSubmitButton = pageContainer.state.pageId == null ? t('Create') : t('Update');
     const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
 
     return (
@@ -66,9 +76,10 @@ class SavePageControls extends React.PureComponent {
           && (
           <div className="mr-2">
             <SlackNotification
-              ref={(c) => { this.slackNotification = c }}
-              isSlackEnabled={false}
-              slackChannels={this.props.slackChannels}
+              isSlackEnabled={editorContainer.state.isSlackEnabled}
+              slackChannels={editorContainer.state.slackChannels}
+              onEnabledFlagChange={this.slackEnabledFlagChangedHandler}
+              onChannelChange={this.slackChannelsChangedHandler}
             />
           </div>
           )
@@ -78,15 +89,11 @@ class SavePageControls extends React.PureComponent {
           && (
           <div className="mr-2">
             <GrantSelector
-              crowi={this.props.crowi}
-              ref={(elem) => {
-                  if (this.grantSelector == null) {
-                    this.grantSelector = elem;
-                  }
-                }}
-              grant={this.props.grant}
-              grantGroupId={this.props.grantGroupId}
-              grantGroupName={this.props.grantGroupName}
+              disabled={isRootPage}
+              grant={editorContainer.state.grant}
+              grantGroupId={editorContainer.state.grantGroupId}
+              grantGroupName={editorContainer.state.grantGroupName}
+              onUpdateGrant={this.updateGrantHandler}
             />
           </div>
           )
@@ -99,10 +106,10 @@ class SavePageControls extends React.PureComponent {
             className="btn-submit"
             dropup
             pullRight
-            onClick={this.submit}
+            onClick={this.save}
             title={labelSubmitButton}
           >
-            <MenuItem eventKey="1" onClick={this.submitAndOverwriteScopesOfDescendants}>{labelOverwriteScopes}</MenuItem>
+            <MenuItem eventKey="1" onClick={this.saveAndOverwriteScopesOfDescendants}>{labelOverwriteScopes}</MenuItem>
             {/* <MenuItem divider /> */}
           </SplitButton>
         </ButtonToolbar>
@@ -112,17 +119,19 @@ class SavePageControls extends React.PureComponent {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const SavePageControlsWrapper = (props) => {
+  return createSubscribedElement(SavePageControls, props, [AppContainer, PageContainer, EditorContainer]);
+};
+
 SavePageControls.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  crowi: PropTypes.object.isRequired,
-  onSubmit: PropTypes.func.isRequired,
-  pageId: PropTypes.string,
-  // for SlackNotification
-  slackChannels: PropTypes.string,
-  // for GrantSelector
-  grant: PropTypes.number,
-  grantGroupId: PropTypes.string,
-  grantGroupName: PropTypes.string,
+
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 
-export default withTranslation(null, { withRef: true })(SavePageControls);
+export default withTranslation()(SavePageControlsWrapper);

+ 35 - 20
src/client/js/components/SavePageControls/GrantSelector.jsx

@@ -1,5 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+
 import { withTranslation } from 'react-i18next';
 
 import FormGroup from 'react-bootstrap/es/FormGroup';
@@ -8,6 +9,10 @@ import ListGroup from 'react-bootstrap/es/ListGroup';
 import ListGroupItem from 'react-bootstrap/es/ListGroupItem';
 import Modal from 'react-bootstrap/es/Modal';
 
+import AppContainer from '../../services/AppContainer';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+
 const SPECIFIED_GROUP_VALUE = 'specifiedGroup';
 
 /**
@@ -42,11 +47,12 @@ class GrantSelector extends React.Component {
     ];
 
     this.state = {
-      grant: this.props.grant || 1, // default: 1
       userRelatedGroups: [],
       isSelectGroupModalShown: false,
+      grant: this.props.grant,
+      grantGroup: null,
     };
-    if (this.props.grantGroupId !== '') {
+    if (this.props.grantGroupId != null) {
       this.state.grantGroup = {
         _id: this.props.grantGroupId,
         name: this.props.grantGroupName,
@@ -56,7 +62,6 @@ class GrantSelector extends React.Component {
     // retrieve xss library from window
     this.xss = window.xss;
 
-    this.getCurrentOptionsToSave = this.getCurrentOptionsToSave.bind(this);
     this.showSelectGroupModal = this.showSelectGroupModal.bind(this);
     this.hideSelectGroupModal = this.hideSelectGroupModal.bind(this);
 
@@ -85,16 +90,6 @@ class GrantSelector extends React.Component {
 
   }
 
-  getCurrentOptionsToSave() {
-    const options = {
-      grant: this.state.grant,
-    };
-    if (this.state.grantGroup != null) {
-      options.grantUserGroupId = this.state.grantGroup._id;
-    }
-    return options;
-  }
-
   showSelectGroupModal() {
     this.retrieveUserGroupRelations();
     this.setState({ isSelectGroupModalShown: true });
@@ -113,7 +108,7 @@ class GrantSelector extends React.Component {
    * Retrieve user-group-relations data from backend
    */
   retrieveUserGroupRelations() {
-    this.props.crowi.apiGet('/me/user-group-relations')
+    this.props.appContainer.apiGet('/me/user-group-relations')
       .then((res) => {
         return res.userGroupRelations;
       })
@@ -142,11 +137,19 @@ class GrantSelector extends React.Component {
     }
 
     this.setState({ grant, grantGroup: null });
+
+    if (this.props.onUpdateGrant != null) {
+      this.props.onUpdateGrant({ grant, grantGroupId: null, grantGroupName: null });
+    }
   }
 
   groupListItemClickHandler(grantGroup) {
     this.setState({ grant: 5, grantGroup });
 
+    if (this.props.onUpdateGrant != null) {
+      this.props.onUpdateGrant({ grant: 5, grantGroupId: grantGroup._id, grantGroupName: grantGroup.name });
+    }
+
     // hide modal
     this.hideSelectGroupModal();
   }
@@ -202,6 +205,7 @@ class GrantSelector extends React.Component {
     return (
       <FormGroup className="grant-selector m-b-0">
         <FormControl
+          disabled={this.props.disabled}
           componentClass="select"
           placeholder="select"
           defaultValue={selectedValue}
@@ -239,7 +243,7 @@ class GrantSelector extends React.Component {
       ? (
         <div>
           <h4>There is no group to which you belong.</h4>
-          { this.props.crowi.isAdmin
+          { this.props.appContainer.isAdmin
             && <p><a href="/admin/user-groups"><i className="icon icon-fw icon-login"></i> Manage Groups</a></p>
           }
         </div>
@@ -272,20 +276,31 @@ class GrantSelector extends React.Component {
   render() {
     return (
       <React.Fragment>
-        {this.renderGrantSelector()}
-        {this.renderSelectGroupModal()}
+        { this.renderGrantSelector() }
+        { this.props.disabled && this.renderSelectGroupModal() }
       </React.Fragment>
     );
   }
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const GrantSelectorWrapper = (props) => {
+  return createSubscribedElement(GrantSelector, props, [AppContainer]);
+};
+
 GrantSelector.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  crowi: PropTypes.object.isRequired,
-  grant: PropTypes.number,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  disabled: PropTypes.bool,
+  grant: PropTypes.number.isRequired,
   grantGroupId: PropTypes.string,
   grantGroupName: PropTypes.string,
+
+  onUpdateGrant: PropTypes.func,
 };
 
-export default withTranslation(null, { withRef: true })(GrantSelector);
+export default withTranslation()(GrantSelectorWrapper);

+ 15 - 3
src/client/js/components/SearchForm.js

@@ -1,9 +1,12 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import { createSubscribedElement } from './UnstatedUtils';
+import AppContainer from '../services/AppContainer';
+
 import SearchTypeahead from './SearchTypeahead';
 
 // SearchTypeahead wrapper
-export default class SearchForm extends React.Component {
+class SearchForm extends React.Component {
 
   constructor(props) {
     super(props);
@@ -93,7 +96,6 @@ export default class SearchForm extends React.Component {
 
     return (
       <SearchTypeahead
-        crowi={this.props.crowi}
         onChange={this.onChange}
         onSubmit={this.props.onSubmit}
         onInputChange={this.props.onInputChange}
@@ -108,9 +110,17 @@ export default class SearchForm extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const SearchFormWrapper = (props) => {
+  return createSubscribedElement(SearchForm, props, [AppContainer]);
+};
+
 SearchForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  crowi: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   keyword: PropTypes.string,
   onSubmit: PropTypes.func.isRequired,
   onInputChange: PropTypes.func,
@@ -119,3 +129,5 @@ SearchForm.propTypes = {
 SearchForm.defaultProps = {
   onInputChange: () => {},
 };
+
+export default SearchFormWrapper;

+ 15 - 8
src/client/js/components/SearchPage.js

@@ -4,6 +4,9 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
+import { createSubscribedElement } from './UnstatedUtils';
+import AppContainer from '../services/AppContainer';
+
 import SearchPageForm from './SearchPage/SearchPageForm';
 import SearchResult from './SearchPage/SearchResult';
 
@@ -13,7 +16,7 @@ class SearchPage extends React.Component {
     super(props);
 
     this.state = {
-      searchingKeyword: this.props.query.q || '',
+      searchingKeyword: decodeURI(this.props.query.q) || '',
       searchedKeyword: '',
       searchedPages: [],
       searchResultMeta: {},
@@ -69,7 +72,7 @@ class SearchPage extends React.Component {
       searchingKeyword: keyword,
     });
 
-    this.props.crowi.apiGet('/search', { q: keyword })
+    this.props.appContainer.apiGet('/search', { q: keyword })
       .then((res) => {
         this.changeURL(keyword);
 
@@ -92,14 +95,11 @@ class SearchPage extends React.Component {
         <div className="search-page-input">
           <SearchPageForm
             t={this.props.t}
-            crowi={this.props.crowi}
             onSearchFormChanged={this.search}
             keyword={this.state.searchingKeyword}
           />
         </div>
         <SearchResult
-          crowi={this.props.crowi}
-          crowiRenderer={this.props.crowiRenderer}
           pages={this.state.searchedPages}
           searchingKeyword={this.state.searchingKeyword}
           searchResultMeta={this.state.searchResultMeta}
@@ -110,10 +110,17 @@ class SearchPage extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const SearchPageWrapper = (props) => {
+  return createSubscribedElement(SearchPage, props, [AppContainer]);
+};
+
 SearchPage.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   query: PropTypes.object,
 };
 SearchPage.defaultProps = {
@@ -121,4 +128,4 @@ SearchPage.defaultProps = {
   query: SearchPage.getQueryByLocation(window.location || {}),
 };
 
-export default withTranslation()(SearchPage);
+export default withTranslation()(SearchPageWrapper);

+ 15 - 3
src/client/js/components/SearchPage/SearchPageForm.js

@@ -5,10 +5,13 @@ import FormGroup from 'react-bootstrap/es/FormGroup';
 import Button from 'react-bootstrap/es/Button';
 import InputGroup from 'react-bootstrap/es/InputGroup';
 
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+
 import SearchForm from '../SearchForm';
 
 // Search.SearchForm
-export default class SearchPageForm extends React.Component {
+class SearchPageForm extends React.Component {
 
   constructor(props) {
     super(props);
@@ -38,7 +41,6 @@ export default class SearchPageForm extends React.Component {
         <InputGroup>
           <SearchForm
             t={this.props.t}
-            crowi={this.props.crowi}
             onSubmit={this.search}
             keyword={this.state.searchedKeyword}
             onInputChange={this.onInputChange}
@@ -55,11 +57,21 @@ export default class SearchPageForm extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const SearchPageFormWrapper = (props) => {
+  return createSubscribedElement(SearchPageForm, props, [AppContainer]);
+};
+
 SearchPageForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  crowi: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   keyword: PropTypes.string,
   onSearchFormChanged: PropTypes.func.isRequired,
 };
 SearchPageForm.defaultProps = {
 };
+
+export default SearchPageFormWrapper;

+ 26 - 22
src/client/js/components/SearchPage/SearchResult.js

@@ -7,9 +7,10 @@ import * as toastr from 'toastr';
 import Page from '../PageList/Page';
 import SearchResultList from './SearchResultList';
 import DeletePageListModal from './DeletePageListModal';
+import AppContainer from '../../services/AppContainer';
+import { createSubscribedElement } from '../UnstatedUtils';
 
-// Search.SearchResult
-export default class SearchResult extends React.Component {
+class SearchResult extends React.Component {
 
   constructor(props) {
     super(props);
@@ -117,7 +118,8 @@ export default class SearchResult extends React.Component {
       return new Promise((resolve, reject) => {
         const pageId = page._id;
         const revisionId = page.revision._id;
-        this.props.crowi.apiPost('/pages.remove', { page_id: pageId, revision_id: revisionId, completely: deleteCompletely })
+
+        this.props.appContainer.apiPost('/pages.remove', { page_id: pageId, revision_id: revisionId, completely: deleteCompletely })
           .then((res) => {
             if (res.ok) {
               this.state.selectedPages.delete(page);
@@ -171,10 +173,6 @@ export default class SearchResult extends React.Component {
   }
 
   render() {
-    const excludePathString = this.props.tree;
-
-    // console.log(this.props.searchError);
-    // console.log(this.isError());
     if (this.isError()) {
       return (
         <div className="content-main">
@@ -189,7 +187,7 @@ export default class SearchResult extends React.Component {
 
     if (this.isNotFound()) {
       let under = '';
-      if (this.props.tree !== '') {
+      if (this.props.tree != null) {
         under = ` under "${this.props.tree}"`;
       }
       return (
@@ -249,18 +247,17 @@ export default class SearchResult extends React.Component {
           page={page}
           linkTo={pageId}
           key={page._id}
-          excludePathString={excludePathString}
         >
           { this.state.deletionMode
             && (
-            <input
-              type="checkbox"
-              className="search-result-list-delete-checkbox"
-              value={pageId}
-              checked={this.state.selectedPages.has(page)}
-              onClick={() => { return this.toggleCheckbox(page) }}
-            />
-)
+              <input
+                type="checkbox"
+                className="search-result-list-delete-checkbox"
+                value={pageId}
+                checked={this.state.selectedPages.has(page)}
+                onClick={() => { return this.toggleCheckbox(page) }}
+              />
+            )
             }
           <div className="page-list-option">
             <a href={page.path}><i className="icon-login" /></a>
@@ -300,8 +297,6 @@ export default class SearchResult extends React.Component {
           </div>
           <div className="col-md-8 search-result-content" id="search-result-content">
             <SearchResultList
-              crowi={this.props.crowi}
-              crowiRenderer={this.props.crowiRenderer}
               pages={this.props.pages}
               searchingKeyword={this.props.searchingKeyword}
             />
@@ -322,15 +317,24 @@ export default class SearchResult extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const SearchResultWrapper = (props) => {
+  return createSubscribedElement(SearchResult, props, [AppContainer]);
+};
+
 SearchResult.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object,
-  tree: PropTypes.string.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   pages: PropTypes.array.isRequired,
   searchingKeyword: PropTypes.string.isRequired,
   searchResultMeta: PropTypes.object.isRequired,
   searchError: PropTypes.object,
+  tree: PropTypes.string,
 };
 SearchResult.defaultProps = {
   searchError: null,
 };
+
+export default SearchResultWrapper;

+ 16 - 8
src/client/js/components/SearchPage/SearchResultList.js

@@ -1,16 +1,16 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import GrowiRenderer from '../../util/GrowiRenderer';
-
 import RevisionLoader from '../Page/RevisionLoader';
+import AppContainer from '../../services/AppContainer';
+import { createSubscribedElement } from '../UnstatedUtils';
 
-export default class SearchResultList extends React.Component {
+class SearchResultList extends React.Component {
 
   constructor(props) {
     super(props);
 
-    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiRenderer, { mode: 'searchresult' });
+    this.growiRenderer = this.props.appContainer.getRenderer('searchresult');
   }
 
   render() {
@@ -22,8 +22,7 @@ export default class SearchResultList extends React.Component {
             <span><i className="tag-icon icon-tag"></i> {page.tags.join(', ')}</span>
           )}
           <RevisionLoader
-            crowi={this.props.crowi}
-            crowiRenderer={this.growiRenderer}
+            growiRenderer={this.growiRenderer}
             pageId={page._id}
             pagePath={page.path}
             revisionId={page.revision}
@@ -42,12 +41,21 @@ export default class SearchResultList extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const SearchResultListWrapper = (props) => {
+  return createSubscribedElement(SearchResultList, props, [AppContainer]);
+};
+
 SearchResultList.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   pages: PropTypes.array.isRequired,
   searchingKeyword: PropTypes.string.isRequired,
 };
 
 SearchResultList.defaultProps = {
 };
+
+export default SearchResultListWrapper;

+ 15 - 4
src/client/js/components/SearchTypeahead.js

@@ -7,8 +7,10 @@ import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 import UserPicture from './User/UserPicture';
 import PageListMeta from './PageList/PageListMeta';
 import PagePath from './PageList/PagePath';
+import AppContainer from '../services/AppContainer';
+import { createSubscribedElement } from './UnstatedUtils';
 
-export default class SearchTypeahead extends React.Component {
+class SearchTypeahead extends React.Component {
 
   constructor(props) {
 
@@ -20,7 +22,6 @@ export default class SearchTypeahead extends React.Component {
       isLoading: false,
       searchError: null,
     };
-    this.crowi = this.props.crowi;
 
     this.restoreInitialData = this.restoreInitialData.bind(this);
     this.search = this.search.bind(this);
@@ -68,7 +69,7 @@ export default class SearchTypeahead extends React.Component {
 
     this.setState({ isLoading: true });
 
-    this.crowi.apiGet('/search', { q: keyword })
+    this.props.appContainer.apiGet('/search', { q: keyword })
       .then((res) => { this.onSearchSuccess(res) })
       .catch((err) => { this.onSearchError(err) });
   }
@@ -205,11 +206,19 @@ export default class SearchTypeahead extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const SearchTypeaheadWrapper = (props) => {
+  return createSubscribedElement(SearchTypeahead, props, [AppContainer]);
+};
+
 /**
  * Properties
  */
 SearchTypeahead.propTypes = {
-  crowi:           PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   onSearchSuccess: PropTypes.func,
   onSearchError:   PropTypes.func,
   onChange:        PropTypes.func,
@@ -234,3 +243,5 @@ SearchTypeahead.defaultProps = {
   keywordOnInit:   '',
   onInputChange: () => {},
 };
+
+export default SearchTypeaheadWrapper;

+ 22 - 38
src/client/js/components/SlackNotification.jsx

@@ -15,62 +15,50 @@ export default class SlackNotification extends React.Component {
   constructor(props) {
     super(props);
 
-    this.state = {
-      isSlackEnabled: this.props.isSlackEnabled,
-      slackChannels: this.props.slackChannels,
-    };
-
-    this.updateState = this.updateState.bind(this);
-    this.updateStateCheckbox = this.updateStateCheckbox.bind(this);
-  }
-
-  componentWillReceiveProps(nextProps) {
-    this.setState({
-      isSlackEnabled: nextProps.isSlackEnabled,
-      slackChannels: nextProps.slackChannels,
-    });
+    this.updateCheckboxHandler = this.updateCheckboxHandler.bind(this);
+    this.updateSlackChannelsHandler = this.updateSlackChannelsHandler.bind(this);
   }
 
-  getCurrentOptionsToSave() {
-    return Object.assign({}, this.state);
+  updateCheckboxHandler(event) {
+    const value = event.target.checked;
+    if (this.props.onEnabledFlagChange != null) {
+      this.props.onEnabledFlagChange(value);
+    }
   }
 
-  updateState(value) {
-    this.setState({ slackChannels: value });
-    // dispatch event
+  updateSlackChannelsHandler(event) {
+    const value = event.target.value;
     if (this.props.onChannelChange != null) {
       this.props.onChannelChange(value);
     }
   }
 
-  updateStateCheckbox(event) {
-    const value = event.target.checked;
-    this.setState({ isSlackEnabled: value });
-    // dispatch event
-    if (this.props.onEnabledFlagChange != null) {
-      this.props.onEnabledFlagChange(value);
-    }
-  }
-
   render() {
     return (
       <div className="input-group input-group-sm input-group-slack extended-setting">
         <label className="input-group-addon">
           <img id="slack-mark-white" alt="slack-mark" src="/images/icons/slack/mark-monochrome_white.svg" width="18" height="18" />
           <img id="slack-mark-black" alt="slack-mark" src="/images/icons/slack/mark-monochrome_black.svg" width="18" height="18" />
-          <input type="checkbox" value="1" checked={this.state.isSlackEnabled} onChange={this.updateStateCheckbox} />
+
+          <input
+            type="checkbox"
+            value="1"
+            checked={this.props.isSlackEnabled}
+            onChange={this.updateCheckboxHandler}
+          />
+
         </label>
         <input
           className="form-control"
           type="text"
-          value={this.state.slackChannels}
+          value={this.props.slackChannels}
           placeholder="slack channel name"
           data-toggle="popover"
           title="Slack通知"
           data-content="通知するにはチェックを入れてください。カンマ区切りで複数チャンネルに通知することができます。"
           data-trigger="focus"
           data-placement="top"
-          onChange={(e) => { return this.updateState(e.target.value) }}
+          onChange={this.updateSlackChannelsHandler}
         />
       </div>
     );
@@ -79,12 +67,8 @@ export default class SlackNotification extends React.Component {
 }
 
 SlackNotification.propTypes = {
-  isSlackEnabled: PropTypes.bool,
-  slackChannels: PropTypes.string,
-  onChannelChange: PropTypes.func,
+  isSlackEnabled: PropTypes.bool.isRequired,
+  slackChannels: PropTypes.string.isRequired,
   onEnabledFlagChange: PropTypes.func,
-};
-
-SlackNotification.defaultProps = {
-  slackChannels: '',
+  onChannelChange: PropTypes.func,
 };

+ 114 - 0
src/client/js/components/StaffCredit/Contributor.js

@@ -0,0 +1,114 @@
+const contributors = [
+  {
+    sectionName: 'GROWI VILLAGE',
+    additionalClass: '',
+    memberGroups: [
+      {
+        additionalClass: 'col-md-12 my-4',
+        members: [
+          { position: 'Founder', name: 'yuki-takei' },
+          { position: 'Soncho 1st', name: 'mizozobu' },
+          { position: 'Soncho 2nd', name: 'yusuketk' },
+        ],
+      },
+      {
+        additionalClass: 'col-md-6 my-4',
+        members: [
+          { name: 'utsushiiro' },
+          { name: 'mayumorita' },
+          { name: 'TatsuyaIse' },
+          { name: 'shinoka7' },
+          { name: 'SeiyaTashiro' },
+          { name: 'itizawa' },
+          { name: 'TsuyoshiSuzukief' },
+          { name: 'Yuchan4342' },
+          { name: 'ryu-sato' },
+          { name: 'haruhikonyan' },
+          { name: 'KazuyaNagase' },
+          { name: 'kaishuu0123' },
+          { name: 'kouki-o' },
+          { name: 'Angola' },
+        ],
+      },
+    ],
+  },
+  {
+    sectionName: 'CONTRIBUTER',
+    additionalClass: '',
+    memberGroups: [
+      {
+        additionalClass: 'col-md-4 my-4',
+        members: [
+          { name: 'inductor' },
+          { name: 'shield-9' },
+          { name: 'yaodingyd' },
+          { name: 'hitochan777' },
+          { name: 'ttaka66' },
+          { name: 'watagashi' },
+          { name: 'nt-7' },
+          { name: 'hideo54' },
+          { name: 'wadahiro' },
+        ],
+      },
+      {
+        additionalClass: 'col-md-6 my-4',
+        members: [
+          { name: 'shaminmeerankutty' },
+          { name: 'rabitarochan' },
+        ],
+      },
+      {
+        additionalClass: 'col-md-4 my-4',
+        members: [
+          { name: 'fumitti' },
+          { name: 'fmy' },
+          { name: 'yaamai' },
+          { name: 'ta2yak' },
+          { name: 'ryo33' },
+          { name: 'r-tateshina' },
+          { name: 'nekoruri' },
+          { name: 'kmyk' },
+          { name: 'aximov' },
+          { name: 'tats-u' },
+        ],
+      },
+    ],
+  },
+  {
+    sectionName: 'VULNERABILITY HUNTER',
+    additionalClass: '',
+    memberGroups: [
+      {
+        additionalClass: 'col-md-6 my-4',
+        members: [
+          { name: 'Yoshinori Hayashi' },
+          { name: 'Kanta Nishitani' },
+          { position: 'The University of Tokyo', name: 'Takashi Yoneuchi' },
+          { position: 'DeCurret', name: 'Yusuke Tanomogi' },
+        ],
+      },
+    ],
+  },
+  {
+    sectionName: 'SPECIAL THANKS',
+    additionalClass: '',
+    memberGroups: [
+      {
+        additionalClass: 'col-md-4 my-4',
+        members: [
+          { name: 'Crowi Team' },
+          { position: 'Ambassador', name: 'Tsuyoshi Suzuki' },
+          { name: 'JPCERT/CC' },
+        ],
+      },
+      {
+        additionalClass: 'col-md-12 staff-credit-mt-10',
+        members: [
+          { name: 'AND YOU' },
+        ],
+      },
+    ],
+  },
+];
+
+module.exports = contributors;

+ 124 - 0
src/client/js/components/StaffCredit/StaffCredit.jsx

@@ -0,0 +1,124 @@
+import React from 'react';
+import { HotKeys } from 'react-hotkeys';
+
+import loggerFactory from '@alias/logger';
+
+import contributors from './Contributor';
+
+/**
+ * Page staff credit component
+ *
+ * @export
+ * @class StaffCredit
+ * @extends {React.Component}
+ */
+
+export default class StaffCredit extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.logger = loggerFactory('growi:StaffCredit');
+
+    this.state = {
+      isShown: false,
+      userCommand: [],
+    };
+    this.konamiCommand = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a'];
+    this.deleteCredit = this.deleteCredit.bind(this);
+  }
+
+  check(event) {
+    this.logger.debug(`'${event.key}' pressed`);
+
+    // compare keydown and next konamiCommand
+    if (this.konamiCommand[this.state.userCommand.length] === event.key) {
+      const nextValue = this.state.userCommand.concat(event.key);
+      if (nextValue.length === this.konamiCommand.length) {
+        this.setState({
+          isShown: true,
+          userCommand: [],
+        });
+      }
+      else {
+        // add UserCommand
+        this.setState({ userCommand: nextValue });
+
+        this.logger.debug('userCommand', this.state.userCommand);
+      }
+    }
+    else {
+      this.setState({ userCommand: [] });
+    }
+  }
+
+  deleteCredit() {
+    if (this.state.isShown) {
+      this.setState({ isShown: false });
+    }
+  }
+
+  renderMembers(memberGroup, keyPrefix) {
+    // construct members elements
+    const members = memberGroup.members.map((member) => {
+      return (
+        <div className={memberGroup.additionalClass} key={`${keyPrefix}-${member.name}-container`}>
+          <span className="dev-position" key={`${keyPrefix}-${member.name}-position`}>
+            {/* position or '&nbsp;' */}
+            { member.position || '\u00A0' }
+          </span>
+          <p className="dev-name" key={`${keyPrefix}-${member.name}`}>{member.name}</p>
+        </div>
+      );
+    });
+    return (
+      <React.Fragment key={`${keyPrefix}-fragment`}>
+        {members}
+      </React.Fragment>
+    );
+  }
+
+  renderContributors() {
+    if (this.state.isShown) {
+      const credit = contributors.map((contributor) => {
+        // construct members elements
+        const memberGroups = contributor.memberGroups.map((memberGroup, idx) => {
+          return this.renderMembers(memberGroup, `${contributor.sectionName}-group${idx}`);
+        });
+        return (
+          <React.Fragment key={`${contributor.sectionName}-fragment`}>
+            <div className={`row staff-credit-my-10 ${contributor.additionalClass}`} key={`${contributor.sectionName}-row`}>
+              <h2 className="col-md-12 dev-team mt-5 staff-credit-mb-10" key={contributor.sectionName}>{contributor.sectionName}</h2>
+              {memberGroups}
+            </div>
+            <div className="clearfix"></div>
+          </React.Fragment>
+        );
+      });
+      return (
+        <div className="text-center credit-curtain" onClick={this.deleteCredit}>
+          <div className="credit-body">
+            <h1 className="staff-credit-mb-10">GROWI Contributors</h1>
+            <div className="clearfix"></div>
+            {credit}
+          </div>
+        </div>
+      );
+    }
+    return null;
+  }
+
+  render() {
+    const keyMap = { check: ['up', 'down', 'right', 'left', 'b', 'a'] };
+    const handlers = { check: (event) => { return this.check(event) } };
+    return (
+      <HotKeys focused attach={window} keyMap={keyMap} handlers={handlers}>
+        {this.renderContributors()}
+      </HotKeys>
+    );
+  }
+
+}
+
+StaffCredit.propTypes = {
+};

+ 61 - 0
src/client/js/components/UnstatedUtils.jsx

@@ -0,0 +1,61 @@
+/* eslint-disable import/prefer-default-export */
+
+import React from 'react';
+import { Subscribe } from 'unstated';
+
+/**
+ * generate K/V object by specified instances
+ *
+ * @param {Array<object>} instances
+ * @returns automatically named key and value
+ *   e.g.
+ *   {
+ *     appContainer: <AppContainer />,
+ *     exampleContainer: <ExampleContainer />,
+ *   }
+ */
+function generateAutoNamedProps(instances) {
+  const props = {};
+
+  instances.forEach((instance) => {
+    // get class name
+    const className = instance.constructor.getClassName();
+    // convert initial charactor to lower case
+    const propName = `${className.charAt(0).toLowerCase()}${className.slice(1)}`;
+
+    props[propName] = instance;
+  });
+
+  return props;
+}
+
+/**
+ * create React component instance that is injected specified containers
+ *
+ * @param {object} componentClass wrapped React.Component class
+ * @param {*} props
+ * @param {*} containerClasses unstated container classes to subscribe
+ * @returns returns such like a following element:
+ *  e.g.
+ *  <Subscribe to={containerClasses}>  // containerClasses = [AppContainer, PageContainer]
+ *    { (appContainer, pageContainer) => (
+ *      <Component appContainer={appContainer} pageContainer={pageContainer} {...this.props} />
+ *    )}
+ *  </Subscribe>
+ */
+export function createSubscribedElement(componentClass, props, containerClasses) {
+  return (
+    // wrap with <Subscribe></Subscribe>
+    <Subscribe to={containerClasses}>
+      { (...containers) => {
+        const propsForContainers = generateAutoNamedProps(containers);
+
+        return React.createElement(
+          componentClass,
+          Object.assign(propsForContainers, props),
+        );
+      }}
+    </Subscribe>
+  );
+
+}

+ 16 - 3
src/client/js/components/User/UserPictureList.jsx

@@ -4,9 +4,12 @@ import PropTypes from 'prop-types';
 import OverlayTrigger from 'react-bootstrap/es/OverlayTrigger';
 import Tooltip from 'react-bootstrap/es/Tooltip';
 
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+
 import UserPicture from './UserPicture';
 
-export default class UserPictureList extends React.Component {
+class UserPictureList extends React.Component {
 
   constructor(props) {
     super(props);
@@ -15,7 +18,7 @@ export default class UserPictureList extends React.Component {
 
     const users = this.props.users.concat(
       // FIXME: user data cache
-      this.props.crowi.findUserByIds(userIds),
+      this.props.appContainer.findUserByIds(userIds),
     );
 
     this.state = {
@@ -47,8 +50,16 @@ export default class UserPictureList extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const UserPictureListWrapper = (props) => {
+  return createSubscribedElement(UserPictureList, props, [AppContainer]);
+};
+
 UserPictureList.propTypes = {
-  crowi: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   userIds: PropTypes.arrayOf(PropTypes.string),
   users: PropTypes.arrayOf(PropTypes.object),
 };
@@ -57,3 +68,5 @@ UserPictureList.defaultProps = {
   userIds: [],
   users: [],
 };
+
+export default UserPictureListWrapper;

+ 1 - 2
src/client/js/ie11-polyfill.js

@@ -1,2 +1 @@
-import 'nodelist-foreach-polyfill';
-import 'babel-polyfill';
+import '@babel/polyfill';

+ 1 - 1
src/client/js/installer.js → src/client/js/installer.jsx

@@ -2,7 +2,7 @@ import React from 'react';
 import ReactDOM from 'react-dom';
 import { I18nextProvider } from 'react-i18next';
 
-import i18nFactory from './i18n';
+import i18nFactory from './util/i18n';
 
 import InstallerForm from './components/InstallerForm';
 

+ 60 - 37
src/client/js/legacy/crowi.js

@@ -4,6 +4,8 @@
 import React from 'react';
 import ReactDOM from 'react-dom';
 
+import { Provider } from 'unstated';
+
 import { debounce } from 'throttle-debounce';
 
 import { pathUtils } from 'growi-commons';
@@ -55,14 +57,15 @@ Crowi.setCaretLineAndFocusToEditor = function() {
     return;
   }
 
-  const crowi = window.crowi;
+  const { appContainer } = window;
+  const editorContainer = appContainer.getContainer('EditorContainer');
   const line = pageEditorDom.getAttribute('data-caret-line') || 0;
-  crowi.setCaretLine(+line);
+  editorContainer.setCaretLine(+line);
   // reset data-caret-line attribute
   pageEditorDom.removeAttribute('data-caret-line');
 
   // focus
-  crowi.focusToEditor();
+  editorContainer.focusToEditor();
 };
 
 // original: middleware.swigFilter
@@ -209,6 +212,30 @@ Crowi.initSlimScrollForRevisionToc = () => {
   });
 };
 
+Crowi.initClassesByOS = function() {
+  // add classes to cmd-key by OS
+  const platform = navigator.platform.toLowerCase();
+  const isMac = (platform.indexOf('mac') > -1);
+
+  document.querySelectorAll('.system-version .cmd-key').forEach((element) => {
+    if (isMac) {
+      element.classList.add('mac');
+    }
+    else {
+      element.classList.add('win');
+    }
+  });
+
+  document.querySelectorAll('#shortcuts-modal .cmd-key').forEach((element) => {
+    if (isMac) {
+      element.classList.add('mac');
+    }
+    else {
+      element.classList.add('win', 'key-longer');
+    }
+  });
+};
+
 Crowi.findHashFromUrl = function(url) {
   let match;
   /* eslint-disable no-cond-assign */
@@ -251,26 +278,10 @@ Crowi.highlightSelectedSection = function(hash) {
   }
 };
 
-/**
- * Return editor mode string
- * @return 'builtin' or 'hackmd' or null (not editing)
- */
-Crowi.getCurrentEditorMode = function() {
-  const isEditing = $('body').hasClass('on-edit');
-  if (!isEditing) {
-    return null;
-  }
-
-  if ($('body').hasClass('builtin-editor')) {
-    return 'builtin';
-  }
-
-  return 'hackmd';
-};
-
 $(() => {
-  const crowi = window.crowi;
-  const config = JSON.parse(document.getElementById('crowi-context-hydrate').textContent || '{}');
+  const appContainer = window.appContainer;
+  const websocketContainer = appContainer.getContainer('WebsocketContainer');
+  const config = appContainer.getConfig();
 
   const pageId = $('#content-main').data('page-id');
   // const revisionId = $('#content-main').data('page-revision-id');
@@ -357,7 +368,7 @@ $(() => {
     $(this).serializeArray().forEach((obj) => {
       nameValueMap[obj.name] = obj.value; // nameValueMap.new_path is renamed page path
     });
-    nameValueMap.socketClientId = crowi.getSocketClientId();
+    nameValueMap.socketClientId = websocketContainer.getSocketClientId();
 
     $.ajax({
       type: 'POST',
@@ -395,7 +406,7 @@ $(() => {
     $(this).serializeArray().forEach((obj) => {
       nameValueMap[obj.name] = obj.value; // nameValueMap.new_path is duplicated page path
     });
-    nameValueMap.socketClientId = crowi.getSocketClientId();
+    nameValueMap.socketClientId = websocketContainer.getSocketClientId();
 
     $.ajax({
       type: 'POST',
@@ -431,7 +442,7 @@ $(() => {
     $('#delete-page-form').serializeArray().forEach((obj) => {
       nameValueMap[obj.name] = obj.value;
     });
-    nameValueMap.socketClientId = crowi.getSocketClientId();
+    nameValueMap.socketClientId = websocketContainer.getSocketClientId();
 
     $.ajax({
       type: 'POST',
@@ -547,9 +558,7 @@ $(() => {
     const isShown = $('#view-timeline').data('shown');
 
     if (growiRendererForTimeline == null) {
-      const crowi = window.crowi;
-      const crowiRenderer = window.crowiRenderer;
-      growiRendererForTimeline = new GrowiRenderer(crowi, crowiRenderer, { mode: 'timeline' });
+      growiRendererForTimeline = GrowiRenderer.generate('timeline');
     }
 
     if (isShown === 0) {
@@ -564,14 +573,15 @@ $(() => {
         const revisionId = timelineElm.getAttribute('data-revision');
 
         ReactDOM.render(
-          <RevisionLoader
-            lazy
-            crowi={crowi}
-            crowiRenderer={growiRendererForTimeline}
-            pageId={pageId}
-            pagePath={pagePath}
-            revisionId={revisionId}
-          />,
+          <Provider inject={[appContainer]}>
+            <RevisionLoader
+              lazy
+              growiRenderer={growiRendererForTimeline}
+              pageId={pageId}
+              pagePath={pagePath}
+              revisionId={revisionId}
+            />
+          </Provider>,
           revisionBodyElem,
         );
       });
@@ -587,7 +597,8 @@ $(() => {
       const templateId = $(this).data('template');
       const template = $(`#${templateId}`).html();
 
-      crowi.saveDraft(path, template);
+      const editorContainer = appContainer.getContainer('EditorContainer');
+      editorContainer.saveDraft(path, template);
       top.location.href = `${path}#edit`;
     });
 
@@ -625,7 +636,11 @@ $(() => {
   } // end if pageId
 
   // tab changing handling
+  $('a[href="#revision-body"]').on('show.bs.tab', () => {
+    appContainer.setState({ editorMode: null });
+  });
   $('a[href="#edit"]').on('show.bs.tab', () => {
+    appContainer.setState({ editorMode: 'builtin' });
     $('body').addClass('on-edit');
     $('body').addClass('builtin-editor');
   });
@@ -634,6 +649,7 @@ $(() => {
     $('body').removeClass('builtin-editor');
   });
   $('a[href="#hackmd"]').on('show.bs.tab', () => {
+    appContainer.setState({ editorMode: 'hackmd' });
     $('body').addClass('on-edit');
     $('body').addClass('hackmd');
   });
@@ -689,9 +705,13 @@ $(() => {
 });
 
 window.addEventListener('load', (e) => {
+  const { appContainer } = window;
+
   // hash on page
   if (location.hash) {
     if ((location.hash === '#edit' || location.hash === '#edit-form') && $('.tab-pane#edit').length > 0) {
+      appContainer.setState({ editorMode: 'builtin' });
+
       $('a[data-toggle="tab"][href="#edit"]').tab('show');
       $('body').addClass('on-edit');
       $('body').addClass('builtin-editor');
@@ -700,6 +720,8 @@ window.addEventListener('load', (e) => {
       Crowi.setCaretLineAndFocusToEditor();
     }
     else if (location.hash === '#hackmd' && $('.tab-pane#hackmd').length > 0) {
+      appContainer.setState({ editorMode: 'hackmd' });
+
       $('a[data-toggle="tab"][href="#hackmd"]').tab('show');
       $('body').addClass('on-edit');
       $('body').addClass('hackmd');
@@ -750,6 +772,7 @@ window.addEventListener('load', (e) => {
   Crowi.modifyScrollTop();
   Crowi.initSlimScrollForRevisionToc();
   Crowi.initAffix();
+  Crowi.initClassesByOS();
 });
 
 window.addEventListener('hashchange', (e) => {

+ 15 - 8
src/client/js/plugin.js

@@ -2,17 +2,17 @@ import loggerFactory from '@alias/logger';
 
 const logger = loggerFactory('growi:plugin');
 
-export default class CrowiPlugin {
+export default class GrowiPlugin {
 
   /**
    * process plugin entry
    *
-   * @param {Crowi} crowi Crowi context class
-   * @param {CrowiRenderer} crowiRenderer CrowiRenderer
+   * @param {AppContainer} appContainer
+   * @param {GrowiRenderer} originRenderer The origin instance of GrowiRenderer
    *
    * @memberof CrowiPlugin
    */
-  installAll(crowi, crowiRenderer) {
+  installAll(appContainer, originRenderer) {
     // import plugin definitions
     let definitions = [];
     try {
@@ -30,12 +30,19 @@ export default class CrowiPlugin {
       switch (meta.pluginSchemaVersion) {
         // v1 is deprecated
         case 1:
+          logger.warn('pluginSchemaVersion 1 is deprecated', definition);
           break;
-        // v2 or above
-        default:
+        // v2 is deprecated
+        case 2:
+          logger.warn('pluginSchemaVersion 2 is deprecated', definition);
+          break;
+        case 3:
           definition.entries.forEach((entry) => {
-            entry(crowi, crowiRenderer);
+            entry(appContainer);
           });
+          break;
+        default:
+          logger.warn('Unsupported schema version', meta.pluginSchemaVersion);
       }
     });
 
@@ -43,4 +50,4 @@ export default class CrowiPlugin {
 
 }
 
-window.crowiPlugin = new CrowiPlugin(); // FIXME
+window.growiPlugin = new GrowiPlugin();

+ 359 - 0
src/client/js/services/AppContainer.js

@@ -0,0 +1,359 @@
+import { Container } from 'unstated';
+
+import axios from 'axios';
+import urljoin from 'url-join';
+
+import InterceptorManager from '@commons/service/interceptor-manager';
+
+import emojiStrategy from '../util/emojione/emoji_strategy_shrinked.json';
+import GrowiRenderer from '../util/GrowiRenderer';
+
+import {
+  DetachCodeBlockInterceptor,
+  RestoreCodeBlockInterceptor,
+} from '../util/interceptor/detach-code-blocks';
+
+import i18nFactory from '../util/i18n';
+import apiv3ErrorHandler from '../util/apiv3ErrorHandler';
+
+/**
+ * Service container related to options for Application
+ * @extends {Container} unstated Container
+ */
+export default class AppContainer extends Container {
+
+  constructor() {
+    super();
+
+    this.state = {
+      editorMode: null,
+    };
+
+    const body = document.querySelector('body');
+
+    this.me = body.dataset.currentUsername;
+    this.isAdmin = body.dataset.isAdmin === 'true';
+    this.csrfToken = body.dataset.csrftoken;
+    this.isPluginEnabled = body.dataset.pluginEnabled === 'true';
+    this.isLoggedin = document.querySelector('.main-container.nologin') == null;
+
+    this.config = JSON.parse(document.getElementById('crowi-context-hydrate').textContent || '{}');
+
+    const userAgent = window.navigator.userAgent.toLowerCase();
+    this.isMobile = /iphone|ipad|android/.test(userAgent);
+
+    this.isDocSaved = true;
+
+    this.originRenderer = new GrowiRenderer(this);
+
+    this.interceptorManager = new InterceptorManager();
+    this.interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(this), 10); // process as soon as possible
+    this.interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(this), 900); // process as late as possible
+
+    const userlang = body.dataset.userlang;
+    this.i18n = i18nFactory(userlang);
+
+    this.users = [];
+    this.userByName = {};
+    this.userById = {};
+    this.recoverData();
+
+    if (this.isLoggedin) {
+      this.fetchUsers();
+    }
+
+    this.containerInstances = {};
+    this.componentInstances = {};
+    this.rendererInstances = {};
+
+    this.fetchUsers = this.fetchUsers.bind(this);
+    this.apiGet = this.apiGet.bind(this);
+    this.apiPost = this.apiPost.bind(this);
+    this.apiRequest = this.apiRequest.bind(this);
+
+    this.apiv3Root = '/_api/v3';
+    this.apiv3 = {
+      get: this.apiv3Get.bind(this),
+      post: this.apiv3Post.bind(this),
+      put: this.apiv3Put.bind(this),
+      delete: this.apiv3Delete.bind(this),
+    };
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AppContainer';
+  }
+
+  initPlugins() {
+    if (this.isPluginEnabled) {
+      const growiPlugin = window.growiPlugin;
+      growiPlugin.installAll(this, this.originRenderer);
+    }
+  }
+
+  injectToWindow() {
+    window.appContainer = this;
+
+    const originRenderer = this.getOriginRenderer();
+    window.growiRenderer = originRenderer;
+
+    // backward compatibility
+    window.crowi = this;
+    window.crowiRenderer = originRenderer;
+    window.crowiPlugin = window.growiPlugin;
+  }
+
+  /**
+   * @return {Object} window.Crowi (js/legacy/crowi.js)
+   */
+  getCrowiForJquery() {
+    return window.Crowi;
+  }
+
+  getConfig() {
+    return this.config;
+  }
+
+  /**
+   * Register unstated container instance
+   * @param {object} instance unstated container instance
+   */
+  registerContainer(instance) {
+    if (instance == null) {
+      throw new Error('The specified instance must not be null');
+    }
+
+    const className = instance.constructor.getClassName();
+
+    if (this.containerInstances[className] != null) {
+      throw new Error('The specified instance couldn\'t register because the same type object has already been registered');
+    }
+
+    this.containerInstances[className] = instance;
+  }
+
+  /**
+   * Get registered unstated container instance
+   * !! THIS METHOD SHOULD ONLY BE USED FROM unstated CONTAINERS !!
+   * !! From component instances, inject containers with `import { Subscribe } from 'unstated'` !!
+   *
+   * @param {string} className
+   */
+  getContainer(className) {
+    return this.containerInstances[className];
+  }
+
+  /**
+   * Register React component instance
+   * @param {string} id
+   * @param {object} instance React component instance
+   */
+  registerComponentInstance(id, instance) {
+    if (instance == null) {
+      throw new Error('The specified instance must not be null');
+    }
+
+    if (this.componentInstances[id] != null) {
+      throw new Error('The specified instance couldn\'t register because the same id has already been registered');
+    }
+
+    this.componentInstances[id] = instance;
+  }
+
+  /**
+   * Get registered React component instance
+   * @param {string} id
+   */
+  getComponentInstance(id) {
+    return this.componentInstances[id];
+  }
+
+  getOriginRenderer() {
+    return this.originRenderer;
+  }
+
+  /**
+   * factory method
+   */
+  getRenderer(mode) {
+    if (this.rendererInstances[mode] != null) {
+      return this.rendererInstances[mode];
+    }
+
+    const renderer = new GrowiRenderer(this, this.originRenderer);
+    // setup
+    renderer.initMarkdownItConfigurers(mode);
+    renderer.setup(mode);
+    // register
+    this.rendererInstances[mode] = renderer;
+
+    return renderer;
+  }
+
+  getEmojiStrategy() {
+    return emojiStrategy;
+  }
+
+  recoverData() {
+    const keys = [
+      'userByName',
+      'userById',
+      'users',
+    ];
+
+    keys.forEach((key) => {
+      const keyContent = window.localStorage[key];
+      if (keyContent) {
+        try {
+          this[key] = JSON.parse(keyContent);
+        }
+        catch (e) {
+          window.localStorage.removeItem(key);
+        }
+      }
+    });
+  }
+
+  fetchUsers() {
+    const interval = 1000 * 60 * 15; // 15min
+    const currentTime = new Date();
+    if (window.localStorage.lastFetched && interval > currentTime - new Date(window.localStorage.lastFetched)) {
+      return;
+    }
+
+    this.apiGet('/users.list', {})
+      .then((data) => {
+        this.users = data.users;
+        window.localStorage.users = JSON.stringify(data.users);
+
+        const userByName = {};
+        const userById = {};
+        for (let i = 0; i < data.users.length; i++) {
+          const user = data.users[i];
+          userByName[user.username] = user;
+          userById[user._id] = user;
+        }
+        this.userByName = userByName;
+        window.localStorage.userByName = JSON.stringify(userByName);
+
+        this.userById = userById;
+        window.localStorage.userById = JSON.stringify(userById);
+
+        window.localStorage.lastFetched = new Date();
+      })
+      .catch((err) => {
+        window.localStorage.removeItem('lastFetched');
+      // ignore errors
+      });
+  }
+
+  findUserById(userId) {
+    if (this.userById && this.userById[userId]) {
+      return this.userById[userId];
+    }
+
+    return null;
+  }
+
+  findUserByIds(userIds) {
+    const users = [];
+    for (const userId of userIds) {
+      const user = this.findUserById(userId);
+      if (user) {
+        users.push(user);
+      }
+    }
+
+    return users;
+  }
+
+  findUser(username) {
+    if (this.userByName && this.userByName[username]) {
+      return this.userByName[username];
+    }
+
+    return null;
+  }
+
+  launchHandsontableModal(componentKind, beginLineNumber, endLineNumber) {
+    let targetComponent;
+    switch (componentKind) {
+      case 'page':
+        targetComponent = this.getComponentInstance('Page');
+        break;
+    }
+    targetComponent.launchHandsontableModal(beginLineNumber, endLineNumber);
+  }
+
+  apiGet(path, params) {
+    return this.apiRequest('get', path, { params });
+  }
+
+  apiPost(path, params) {
+    if (!params._csrf) {
+      params._csrf = this.csrfToken;
+    }
+
+    return this.apiRequest('post', path, params);
+  }
+
+  apiRequest(method, path, params) {
+    return new Promise((resolve, reject) => {
+      axios[method](`/_api${path}`, params)
+        .then((res) => {
+          if (res.data.ok) {
+            resolve(res.data);
+          }
+          else {
+            reject(new Error(res.data.error));
+          }
+        })
+        .catch((res) => {
+          reject(res);
+        });
+    });
+  }
+
+  async apiv3Request(method, path, params) {
+    try {
+      const res = await axios[method](urljoin(this.apiv3Root, path), params);
+      return res.data;
+    }
+    catch (err) {
+      const errors = apiv3ErrorHandler(err);
+      throw errors;
+    }
+  }
+
+  async apiv3Get(path, params) {
+    return this.apiv3Request('get', path, { params });
+  }
+
+  async apiv3Post(path, params) {
+    if (!params._csrf) {
+      params._csrf = this.csrfToken;
+    }
+
+    return this.apiv3Request('post', path, params);
+  }
+
+  async apiv3Put(path, params) {
+    if (!params._csrf) {
+      params._csrf = this.csrfToken;
+    }
+
+    return this.apiv3Request('put', path, params);
+  }
+
+  async apiv3Delete(path, params) {
+    if (!params._csrf) {
+      params._csrf = this.csrfToken;
+    }
+
+    return this.apiv3Request('delete', path, { params });
+  }
+
+}

+ 124 - 0
src/client/js/services/CommentContainer.js

@@ -0,0 +1,124 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+const logger = loggerFactory('growi:services:CommentContainer');
+
+/**
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ * @extends {Container} unstated Container
+ */
+export default class CommentContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+    this.appContainer.registerContainer(this);
+
+    const mainContent = document.querySelector('#content-main');
+
+    if (mainContent == null) {
+      logger.debug('#content-main element is not exists');
+      return;
+    }
+
+    this.state = {
+      comments: [],
+
+      // settings shared among all of CommentEditor
+      isSlackEnabled: false,
+      slackChannels: mainContent.getAttribute('data-slack-channels') || '',
+    };
+
+    this.retrieveComments = this.retrieveComments.bind(this);
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'CommentContainer';
+  }
+
+  getPageContainer() {
+    return this.appContainer.getContainer('PageContainer');
+  }
+
+  findAndSplice(comment) {
+    const comments = this.state.comments;
+
+    const index = comments.indexOf(comment);
+    if (index < 0) {
+      return;
+    }
+    comments.splice(index, 1);
+
+    this.setState({ comments });
+  }
+
+  /**
+   * Load data of comments and store them in state
+   */
+  retrieveComments() {
+    const { pageId } = this.getPageContainer().state;
+
+    // get data (desc order array)
+    return this.appContainer.apiGet('/comments.get', { page_id: pageId })
+      .then((res) => {
+        if (res.ok) {
+          this.setState({ comments: res.comments });
+        }
+      });
+  }
+
+  /**
+   * Load data of comments and rerender <PageComments />
+   */
+  postComment(comment, isMarkdown, replyTo, isSlackEnabled, slackChannels) {
+    const { pageId, revisionId } = this.getPageContainer().state;
+
+    return this.appContainer.apiPost('/comments.add', {
+      commentForm: {
+        comment,
+        page_id: pageId,
+        revision_id: revisionId,
+        is_markdown: isMarkdown,
+        replyTo,
+      },
+      slackNotificationForm: {
+        isSlackEnabled,
+        slackChannels,
+      },
+    })
+      .then((res) => {
+        if (res.ok) {
+          return this.retrieveComments();
+        }
+      });
+  }
+
+  deleteComment(comment) {
+    return this.appContainer.apiPost('/comments.remove', { comment_id: comment._id })
+      .then((res) => {
+        if (res.ok) {
+          this.findAndSplice(comment);
+        }
+      });
+  }
+
+  uploadAttachment(file) {
+    const { pageId, pagePath } = this.getPageContainer().state;
+
+    const endpoint = '/attachments.add';
+    const formData = new FormData();
+    formData.append('file', file);
+    formData.append('path', pagePath);
+    formData.append('page_id', pageId);
+
+    return this.appContainer.apiPost(endpoint, formData);
+  }
+
+}

+ 191 - 0
src/client/js/services/EditorContainer.js

@@ -0,0 +1,191 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+const logger = loggerFactory('growi:services:EditorContainer');
+
+/**
+ * Service container related to options for Editor/Preview
+ * @extends {Container} unstated Container
+ */
+export default class EditorContainer extends Container {
+
+  constructor(appContainer, defaultEditorOptions, defaultPreviewOptions) {
+    super();
+
+    this.appContainer = appContainer;
+    this.appContainer.registerContainer(this);
+
+    const mainContent = document.querySelector('#content-main');
+
+    if (mainContent == null) {
+      logger.debug('#content-main element is not exists');
+      return;
+    }
+
+    this.state = {
+      tags: [],
+
+      isSlackEnabled: false,
+      slackChannels: mainContent.getAttribute('data-slack-channels') || '',
+
+      grant: 1, // default: public
+      grantGroupId: null,
+      grantGroupName: null,
+
+      editorOptions: {},
+      previewOptions: {},
+    };
+
+    this.isSetBeforeunloadEventHandler = false;
+
+    this.initStateGrant();
+    this.initDrafts();
+
+    this.initEditorOptions('editorOptions', 'editorOptions', defaultEditorOptions);
+    this.initEditorOptions('previewOptions', 'previewOptions', defaultPreviewOptions);
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'EditorContainer';
+  }
+
+  /**
+   * initialize state for page permission
+   */
+  initStateGrant() {
+    const elem = document.getElementById('save-page-controls');
+
+    if (elem) {
+      this.state.grant = +elem.dataset.grant;
+
+      const grantGroupId = elem.dataset.grantGroup;
+      if (grantGroupId != null && grantGroupId.length > 0) {
+        this.state.grantGroupId = grantGroupId;
+        this.state.grantGroupName = elem.dataset.grantGroupName;
+      }
+    }
+  }
+
+  /**
+   * initialize state for drafts
+   */
+  initDrafts() {
+    this.drafts = {};
+
+    // restore data from localStorage
+    const contents = window.localStorage.drafts;
+    if (contents != null) {
+      try {
+        this.drafts = JSON.parse(contents);
+      }
+      catch (e) {
+        window.localStorage.removeItem('drafts');
+      }
+    }
+
+    if (this.state.pageId == null) {
+      const draft = this.findDraft(this.state.path);
+      if (draft != null) {
+        this.state.markdown = draft;
+      }
+    }
+  }
+
+  initEditorOptions(stateKey, localStorageKey, defaultOptions) {
+    // load from localStorage
+    const optsStr = window.localStorage[localStorageKey];
+
+    let loadedOpts = {};
+    // JSON.parseparse
+    if (optsStr != null) {
+      try {
+        loadedOpts = JSON.parse(optsStr);
+      }
+      catch (e) {
+        this.localStorage.removeItem(localStorageKey);
+      }
+    }
+
+    // set to state obj
+    this.state[stateKey] = Object.assign(defaultOptions, loadedOpts);
+  }
+
+  saveOptsToLocalStorage() {
+    window.localStorage.setItem('editorOptions', JSON.stringify(this.state.editorOptions));
+    window.localStorage.setItem('previewOptions', JSON.stringify(this.state.previewOptions));
+  }
+
+  setCaretLine(line) {
+    const pageEditor = this.appContainer.getComponentInstance('PageEditor');
+    if (pageEditor != null) {
+      pageEditor.setCaretLine(line);
+    }
+  }
+
+  focusToEditor() {
+    const pageEditor = this.appContainer.getComponentInstance('PageEditor');
+    if (pageEditor != null) {
+      pageEditor.focusToEditor();
+    }
+  }
+
+  getCurrentOptionsToSave() {
+    const opt = {
+      isSlackEnabled: this.state.isSlackEnabled,
+      slackChannels: this.state.slackChannels,
+      grant: this.state.grant,
+      pageTags: this.state.tags,
+    };
+
+    if (this.state.grantGroupId != null) {
+      opt.grantUserGroupId = this.state.grantGroupId;
+    }
+
+    return opt;
+  }
+
+  showUnsavedWarning(e) {
+    // display browser default message
+    e.returnValue = '';
+    return '';
+  }
+
+  disableUnsavedWarning() {
+    window.removeEventListener('beforeunload', this.showUnsavedWarning);
+    this.isSetBeforeunloadEventHandler = false;
+  }
+
+  enableUnsavedWarning() {
+    if (!this.isSetBeforeunloadEventHandler) {
+      window.addEventListener('beforeunload', this.showUnsavedWarning);
+      this.isSetBeforeunloadEventHandler = true;
+    }
+  }
+
+  clearDraft(path) {
+    delete this.drafts[path];
+    window.localStorage.setItem('drafts', JSON.stringify(this.drafts));
+  }
+
+  clearAllDrafts() {
+    window.localStorage.removeItem('drafts');
+  }
+
+  saveDraft(path, body) {
+    this.drafts[path] = body;
+    window.localStorage.setItem('drafts', JSON.stringify(this.drafts));
+  }
+
+  findDraft(path) {
+    if (this.drafts != null && this.drafts[path]) {
+      return this.drafts[path];
+    }
+
+    return null;
+  }
+
+}

+ 361 - 0
src/client/js/services/PageContainer.js

@@ -0,0 +1,361 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+import * as entities from 'entities';
+import * as toastr from 'toastr';
+
+const logger = loggerFactory('growi:services:PageContainer');
+
+/**
+ * Service container related to Page
+ * @extends {Container} unstated Container
+ */
+export default class PageContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+    this.appContainer.registerContainer(this);
+
+    this.state = {};
+
+    const mainContent = document.querySelector('#content-main');
+    if (mainContent == null) {
+      logger.debug('#content-main element is not exists');
+      return;
+    }
+
+    const revisionId = mainContent.getAttribute('data-page-revision-id');
+
+    this.state = {
+      // local page data
+      markdown: null, // will be initialized after initStateMarkdown()
+      pageId: mainContent.getAttribute('data-page-id'),
+      revisionId,
+      revisionCreatedAt: +mainContent.getAttribute('data-page-revision-created'),
+      path: mainContent.getAttribute('data-path'),
+      isLiked: false,
+      seenUserIds: [],
+      likerUserIds: [],
+
+      tags: [],
+      templateTagData: mainContent.getAttribute('data-template-tags'),
+
+      // latest(on remote) information
+      remoteRevisionId: revisionId,
+      revisionIdHackmdSynced: mainContent.getAttribute('data-page-revision-id-hackmd-synced'),
+      lastUpdateUsername: undefined,
+      pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd'),
+      hasDraftOnHackmd: !!mainContent.getAttribute('data-page-has-draft-on-hackmd'),
+      isHackmdDraftUpdatingInRealtime: false,
+    };
+
+    this.initStateMarkdown();
+    this.initStateOthers();
+
+    this.save = this.save.bind(this);
+    this.addWebSocketEventHandlers = this.addWebSocketEventHandlers.bind(this);
+    this.addWebSocketEventHandlers();
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'PageContainer';
+  }
+
+  /**
+   * initialize state for markdown data
+   */
+  initStateMarkdown() {
+    let pageContent = '';
+
+    const rawText = document.getElementById('raw-text-original');
+    if (rawText) {
+      pageContent = rawText.innerHTML;
+    }
+    const markdown = entities.decodeHTML(pageContent);
+
+    this.state.markdown = markdown;
+  }
+
+  initStateOthers() {
+    const likeButtonElem = document.getElementById('like-button');
+    if (likeButtonElem != null) {
+      this.state.isLiked = likeButtonElem.dataset.liked === 'true';
+    }
+
+    const seenUserListElem = document.getElementById('seen-user-list');
+    if (seenUserListElem != null) {
+      const userIdsStr = seenUserListElem.dataset.userIds;
+      this.state.seenUserIds = userIdsStr.split(',');
+    }
+
+
+    const likerListElem = document.getElementById('liker-list');
+    if (likerListElem != null) {
+      const userIdsStr = likerListElem.dataset.userIds;
+      this.state.likerUserIds = userIdsStr.split(',');
+    }
+  }
+
+  setLatestRemotePageData(page, user) {
+    this.setState({
+      remoteRevisionId: page.revision._id,
+      revisionIdHackmdSynced: page.revisionHackmdSynced,
+      lastUpdateUsername: user.name,
+    });
+  }
+
+
+  /**
+   * save success handler
+   * @param {object} page Page instance
+   * @param {Array[Tag]} tags Array of Tag
+   */
+  updateStateAfterSave(page, tags) {
+    const { editorMode } = this.appContainer.state;
+
+    // update state of PageContainer
+    const newState = {
+      pageId: page._id,
+      revisionId: page.revision._id,
+      revisionCreatedAt: new Date(page.revision.createdAt).getTime() / 1000,
+      remoteRevisionId: page.revision._id,
+      revisionIdHackmdSynced: page.revisionHackmdSynced,
+      hasDraftOnHackmd: page.hasDraftOnHackmd,
+      markdown: page.revision.body,
+    };
+    if (tags != null) {
+      newState.tags = tags;
+    }
+    this.setState(newState);
+
+    // PageEditor component
+    const pageEditor = this.appContainer.getComponentInstance('PageEditor');
+    if (pageEditor != null) {
+      if (editorMode !== 'builtin') {
+        pageEditor.updateEditorValue(newState.markdown);
+      }
+    }
+    // PageEditorByHackmd component
+    const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
+    if (pageEditorByHackmd != null) {
+      // reset
+      if (editorMode !== 'hackmd') {
+        pageEditorByHackmd.reset();
+      }
+    }
+
+    // hidden input
+    $('input[name="revision_id"]').val(newState.revisionId);
+  }
+
+  /**
+   * Save page
+   * @param {string} markdown
+   * @param {object} optionsToSave
+   * @return {object} { page: Page, tags: Tag[] }
+   */
+  async save(markdown, optionsToSave = {}) {
+    const { editorMode } = this.appContainer.state;
+
+    const { pageId, path } = this.state;
+    let { revisionId } = this.state;
+
+    const options = Object.assign({}, optionsToSave);
+
+    if (editorMode === 'hackmd') {
+      // set option to sync
+      options.isSyncRevisionToHackmd = true;
+      revisionId = this.state.revisionIdHackmdSynced;
+    }
+
+    let res;
+    if (pageId == null) {
+      res = await this.createPage(path, markdown, options);
+    }
+    else {
+      res = await this.updatePage(pageId, revisionId, markdown, options);
+    }
+
+    this.updateStateAfterSave(res.page, res.tags);
+    return res;
+  }
+
+  async saveAndReload(optionsToSave) {
+    if (optionsToSave == null) {
+      const msg = '\'saveAndReload\' requires the \'optionsToSave\' param';
+      throw new Error(msg);
+    }
+
+    const { editorMode } = this.appContainer.state;
+    if (editorMode == null) {
+      logger.warn('\'saveAndReload\' requires the \'errorMode\' param');
+      return;
+    }
+
+    const { pageId, path } = this.state;
+    let { revisionId } = this.state;
+
+    const options = Object.assign({}, optionsToSave);
+
+    let markdown;
+    if (editorMode === 'hackmd') {
+      const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
+      markdown = await pageEditorByHackmd.getMarkdown();
+      // set option to sync
+      options.isSyncRevisionToHackmd = true;
+      revisionId = this.state.revisionIdHackmdSynced;
+    }
+    else {
+      const pageEditor = this.appContainer.getComponentInstance('PageEditor');
+      markdown = pageEditor.getMarkdown();
+    }
+
+    let res;
+    if (pageId == null) {
+      res = await this.createPage(path, markdown, options);
+    }
+    else {
+      res = await this.updatePage(pageId, revisionId, markdown, options);
+    }
+
+    const editorContainer = this.appContainer.getContainer('EditorContainer');
+    editorContainer.clearDraft(path);
+    window.location.href = path;
+
+    return res;
+  }
+
+  async createPage(pagePath, markdown, tmpParams) {
+    const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
+
+    // clone
+    const params = Object.assign(tmpParams, {
+      socketClientId: websocketContainer.getSocketClientId(),
+      path: pagePath,
+      body: markdown,
+    });
+
+    const res = await this.appContainer.apiPost('/pages.create', params);
+    if (!res.ok) {
+      throw new Error(res.error);
+    }
+    return { page: res.page, tags: res.tags };
+  }
+
+  async updatePage(pageId, revisionId, markdown, tmpParams) {
+    const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
+
+    // clone
+    const params = Object.assign(tmpParams, {
+      socketClientId: websocketContainer.getSocketClientId(),
+      page_id: pageId,
+      revision_id: revisionId,
+      body: markdown,
+    });
+
+    const res = await this.appContainer.apiPost('/pages.update', params);
+    if (!res.ok) {
+      throw new Error(res.error);
+    }
+    return { page: res.page, tags: res.tags };
+  }
+
+  showSuccessToastr() {
+    toastr.success(undefined, 'Saved successfully', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '1200',
+      extendedTimeOut: '150',
+    });
+  }
+
+  showErrorToastr(error) {
+    toastr.error(error.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
+  }
+
+  addWebSocketEventHandlers() {
+    const pageContainer = this;
+    const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
+    const socket = websocketContainer.getWebSocket();
+
+    socket.on('page:create', (data) => {
+      // skip if triggered myself
+      if (data.socketClientId != null && data.socketClientId === websocketContainer.getSocketClientId()) {
+        return;
+      }
+
+      logger.debug({ obj: data }, `websocket on 'page:create'`); // eslint-disable-line quotes
+
+      // update PageStatusAlert
+      if (data.page.path === pageContainer.state.path) {
+        this.setLatestRemotePageData(data.page, data.user);
+      }
+    });
+
+    socket.on('page:update', (data) => {
+      // skip if triggered myself
+      if (data.socketClientId != null && data.socketClientId === websocketContainer.getSocketClientId()) {
+        return;
+      }
+
+      logger.debug({ obj: data }, `websocket on 'page:update'`); // eslint-disable-line quotes
+
+      if (data.page.path === pageContainer.state.path) {
+        // update PageStatusAlert
+        pageContainer.setLatestRemotePageData(data.page, data.user);
+        // update remote data
+        const page = data.page;
+        pageContainer.setState({
+          remoteRevisionId: page.revision._id,
+          revisionIdHackmdSynced: page.revisionHackmdSynced,
+          hasDraftOnHackmd: page.hasDraftOnHackmd,
+        });
+      }
+    });
+
+    socket.on('page:delete', (data) => {
+      // skip if triggered myself
+      if (data.socketClientId != null && data.socketClientId === websocketContainer.getSocketClientId()) {
+        return;
+      }
+
+      logger.debug({ obj: data }, `websocket on 'page:delete'`); // eslint-disable-line quotes
+
+      // update PageStatusAlert
+      if (data.page.path === pageContainer.state.path) {
+        pageContainer.setLatestRemotePageData(data.page, data.user);
+      }
+    });
+
+    socket.on('page:editingWithHackmd', (data) => {
+      // skip if triggered myself
+      if (data.socketClientId != null && data.socketClientId === websocketContainer.getSocketClientId()) {
+        return;
+      }
+
+      logger.debug({ obj: data }, `websocket on 'page:editingWithHackmd'`); // eslint-disable-line quotes
+
+      if (data.page.path === pageContainer.state.path) {
+        pageContainer.setState({ isHackmdDraftUpdatingInRealtime: true });
+      }
+    });
+
+  }
+
+}

+ 61 - 0
src/client/js/services/TagContainer.js

@@ -0,0 +1,61 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+const logger = loggerFactory('growi:services:TagContainer');
+
+/**
+ * Service container related to Tag
+ * @extends {Container} unstated Container
+ */
+export default class TagContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+    this.appContainer.registerContainer(this);
+
+    this.init();
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'TagContainer';
+  }
+
+  /**
+   * retrieve tags data
+   * !! This method should be invoked after PageContainer and EditorContainer has been initialized !!
+   */
+  async init() {
+    const pageContainer = this.appContainer.getContainer('PageContainer');
+    const editorContainer = this.appContainer.getContainer('EditorContainer');
+
+    if (Object.keys(pageContainer.state).length === 0) {
+      logger.debug('There is no need to initialize TagContainer because this is not a Page');
+      return;
+    }
+
+    const { pageId, templateTagData } = pageContainer.state;
+
+    let tags = [];
+    // when the page exists
+    if (pageId != null) {
+      const res = await this.appContainer.apiGet('/pages.getPageTag', { pageId });
+      tags = res.tags;
+    }
+    // when the page not exist
+    else if (templateTagData != null) {
+      tags = templateTagData.split(',');
+    }
+
+    logger.debug('tags data has been initialized');
+
+    pageContainer.setState({ tags });
+    editorContainer.setState({ tags });
+  }
+
+}

+ 40 - 0
src/client/js/services/WebsocketContainer.js

@@ -0,0 +1,40 @@
+import { Container } from 'unstated';
+
+import io from 'socket.io-client';
+
+/**
+ * Service container related to options for WebSocket
+ * @extends {Container} unstated Container
+ */
+export default class WebsocketContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+    this.appContainer.registerContainer(this);
+
+    this.socket = io();
+    this.socketClientId = Math.floor(Math.random() * 100000);
+
+    this.state = {
+    };
+
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'WebsocketContainer';
+  }
+
+  getWebSocket() {
+    return this.socket;
+  }
+
+  getSocketClientId() {
+    return this.socketClientId;
+  }
+
+}

+ 0 - 297
src/client/js/util/Crowi.js

@@ -1,297 +0,0 @@
-/**
- * Crowi context class for client
- */
-
-import axios from 'axios';
-import io from 'socket.io-client';
-
-import InterceptorManager from '@commons/service/interceptor-manager';
-
-import emojiStrategy from './emojione/emoji_strategy_shrinked.json';
-
-import {
-  DetachCodeBlockInterceptor,
-  RestoreCodeBlockInterceptor,
-} from './interceptor/detach-code-blocks';
-
-export default class Crowi {
-
-  constructor(context, window) {
-    this.context = context;
-    this.config = {};
-
-    const userAgent = window.navigator.userAgent.toLowerCase();
-    this.isMobile = /iphone|ipad|android/.test(userAgent);
-
-    this.window = window;
-    this.location = window.location || {};
-    this.document = window.document || {};
-    this.localStorage = window.localStorage || {};
-    this.socketClientId = Math.floor(Math.random() * 100000);
-    this.page = undefined;
-    this.pageEditor = undefined;
-    this.isDocSaved = true;
-
-    this.fetchUsers = this.fetchUsers.bind(this);
-    this.apiGet = this.apiGet.bind(this);
-    this.apiPost = this.apiPost.bind(this);
-    this.apiRequest = this.apiRequest.bind(this);
-
-    this.interceptorManager = new InterceptorManager();
-    this.interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(this), 10); // process as soon as possible
-    this.interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(this), 900); // process as late as possible
-
-    // FIXME
-    this.me = context.me;
-    this.isAdmin = context.isAdmin;
-    this.csrfToken = context.csrfToken;
-
-    this.users = [];
-    this.userByName = {};
-    this.userById = {};
-    this.draft = {};
-    this.editorOptions = {};
-
-    this.recoverData();
-
-    this.socket = io();
-  }
-
-  /**
-   * @return {Object} window.Crowi (js/legacy/crowi.js)
-   */
-  getCrowiForJquery() {
-    return window.Crowi;
-  }
-
-  setConfig(config) {
-    this.config = config;
-  }
-
-  getConfig() {
-    return this.config;
-  }
-
-  setPage(page) {
-    this.page = page;
-  }
-
-  setPageEditor(pageEditor) {
-    this.pageEditor = pageEditor;
-  }
-
-  setIsDocSaved(isSaved) {
-    this.isDocSaved = isSaved;
-  }
-
-  getIsDocSaved() {
-    return this.isDocSaved;
-  }
-
-  getWebSocket() {
-    return this.socket;
-  }
-
-  getSocketClientId() {
-    return this.socketClientId;
-  }
-
-  getEmojiStrategy() {
-    return emojiStrategy;
-  }
-
-  recoverData() {
-    const keys = [
-      'userByName',
-      'userById',
-      'users',
-      'draft',
-      'editorOptions',
-      'previewOptions',
-    ];
-
-    keys.forEach((key) => {
-      const keyContent = this.localStorage[key];
-      if (keyContent) {
-        try {
-          this[key] = JSON.parse(keyContent);
-        }
-        catch (e) {
-          this.localStorage.removeItem(key);
-        }
-      }
-    });
-  }
-
-  fetchUsers() {
-    const interval = 1000 * 60 * 15; // 15min
-    const currentTime = new Date();
-    if (this.localStorage.lastFetched && interval > currentTime - new Date(this.localStorage.lastFetched)) {
-      return;
-    }
-
-    this.apiGet('/users.list', {})
-      .then((data) => {
-        this.users = data.users;
-        this.localStorage.users = JSON.stringify(data.users);
-
-        const userByName = {};
-        const userById = {};
-        for (let i = 0; i < data.users.length; i++) {
-          const user = data.users[i];
-          userByName[user.username] = user;
-          userById[user._id] = user;
-        }
-        this.userByName = userByName;
-        this.localStorage.userByName = JSON.stringify(userByName);
-
-        this.userById = userById;
-        this.localStorage.userById = JSON.stringify(userById);
-
-        this.localStorage.lastFetched = new Date();
-      })
-      .catch((err) => {
-        this.localStorage.removeItem('lastFetched');
-      // ignore errors
-      });
-  }
-
-  setCaretLine(line) {
-    if (this.pageEditor != null) {
-      this.pageEditor.setCaretLine(line);
-    }
-  }
-
-  focusToEditor() {
-    if (this.pageEditor != null) {
-      this.pageEditor.focusToEditor();
-    }
-  }
-
-  clearDraft(path) {
-    delete this.draft[path];
-    this.localStorage.setItem('draft', JSON.stringify(this.draft));
-  }
-
-  clearAllDrafts() {
-    this.localStorage.removeItem('draft');
-  }
-
-  saveDraft(path, body) {
-    this.draft[path] = body;
-    this.localStorage.setItem('draft', JSON.stringify(this.draft));
-  }
-
-  findDraft(path) {
-    if (this.draft && this.draft[path]) {
-      return this.draft[path];
-    }
-
-    return null;
-  }
-
-  saveEditorOptions(options) {
-    this.localStorage.setItem('editorOptions', JSON.stringify(options));
-  }
-
-  savePreviewOptions(options) {
-    this.localStorage.setItem('previewOptions', JSON.stringify(options));
-  }
-
-  findUserById(userId) {
-    if (this.userById && this.userById[userId]) {
-      return this.userById[userId];
-    }
-
-    return null;
-  }
-
-  findUserByIds(userIds) {
-    const users = [];
-    for (const userId of userIds) {
-      const user = this.findUserById(userId);
-      if (user) {
-        users.push(user);
-      }
-    }
-
-    return users;
-  }
-
-  findUser(username) {
-    if (this.userByName && this.userByName[username]) {
-      return this.userByName[username];
-    }
-
-    return null;
-  }
-
-  createPage(pagePath, markdown, additionalParams = {}) {
-    const params = Object.assign(additionalParams, {
-      path: pagePath,
-      body: markdown,
-    });
-    return this.apiPost('/pages.create', params)
-      .then((res) => {
-        if (!res.ok) {
-          throw new Error(res.error);
-        }
-        return res.page;
-      });
-  }
-
-  updatePage(pageId, revisionId, markdown, additionalParams = {}) {
-    const params = Object.assign(additionalParams, {
-      page_id: pageId,
-      revision_id: revisionId,
-      body: markdown,
-    });
-    return this.apiPost('/pages.update', params)
-      .then((res) => {
-        if (!res.ok) {
-          throw new Error(res.error);
-        }
-        return res.page;
-      });
-  }
-
-  launchHandsontableModal(componentKind, beginLineNumber, endLineNumber) {
-    let targetComponent;
-    switch (componentKind) {
-      case 'page':
-        targetComponent = this.page;
-        break;
-    }
-    targetComponent.launchHandsontableModal(beginLineNumber, endLineNumber);
-  }
-
-  apiGet(path, params) {
-    return this.apiRequest('get', path, { params });
-  }
-
-  apiPost(path, params) {
-    if (!params._csrf) {
-      params._csrf = this.csrfToken;
-    }
-
-    return this.apiRequest('post', path, params);
-  }
-
-  apiRequest(method, path, params) {
-    return new Promise((resolve, reject) => {
-      axios[method](`/_api${path}`, params)
-        .then((res) => {
-          if (res.data.ok) {
-            resolve(res.data);
-          }
-          else {
-            reject(new Error(res.data.error));
-          }
-        })
-        .catch((res) => {
-          reject(res);
-        });
-    });
-  }
-
-}

+ 46 - 54
src/client/js/util/GrowiRenderer.js

@@ -3,7 +3,6 @@ import MarkdownIt from 'markdown-it';
 import Linker from './PreProcessor/Linker';
 import CsvToTable from './PreProcessor/CsvToTable';
 import XssFilter from './PreProcessor/XssFilter';
-import CrowiTemplate from './PostProcessor/CrowiTemplate';
 
 import EmojiConfigurer from './markdown-it/emoji';
 import FooternoteConfigurer from './markdown-it/footernote';
@@ -20,38 +19,39 @@ import HeaderWithEditLinkConfigurer from './markdown-it/header-with-edit-link';
 
 const logger = require('@alias/logger')('growi:util:GrowiRenderer');
 
-
 export default class GrowiRenderer {
 
   /**
    *
-   * @param {Crowi} crowi
-   * @param {GrowiRenderer} originRenderer may be customized by plugins
-   * @param {object} options
+   * @param {AppContainer} appContainer
+   * @param {GrowiRenderer} originRenderer
+   * @param {string} mode
    */
-  constructor(crowi, originRenderer, options) {
-    this.crowi = crowi;
-    this.originRenderer = originRenderer || {};
-    this.options = Object.assign( //  merge options
-      { isAutoSetup: true }, //       default options
-      options || {}, //               specified options
-    );
-
-    // initialize processors
-    //  that will be retrieved if originRenderer exists
-    this.preProcessors = this.originRenderer.preProcessors || [
-      new Linker(crowi),
-      new CsvToTable(crowi),
-      new XssFilter(crowi),
-    ];
-    this.postProcessors = this.originRenderer.postProcessors || [
-      new CrowiTemplate(crowi),
-    ];
+  constructor(appContainer, originRenderer) {
+    this.appContainer = appContainer;
+
+    if (originRenderer != null) {
+      this.preProcessors = originRenderer.preProcessors;
+      this.postProcessors = originRenderer.postProcessors;
+    }
+    else {
+      this.preProcessors = [
+        new Linker(appContainer),
+        new CsvToTable(appContainer),
+        new XssFilter(appContainer),
+      ];
+      this.postProcessors = [
+      ];
+    }
 
     this.initMarkdownItConfigurers = this.initMarkdownItConfigurers.bind(this);
     this.setup = this.setup.bind(this);
     this.process = this.process.bind(this);
     this.codeRenderer = this.codeRenderer.bind(this);
+  }
+
+  initMarkdownItConfigurers(mode) {
+    const appContainer = this.appContainer;
 
     // init markdown-it
     this.md = new MarkdownIt({
@@ -59,52 +59,44 @@ export default class GrowiRenderer {
       linkify: true,
       highlight: this.codeRenderer,
     });
-    this.initMarkdownItConfigurers(options);
-
-    // auto setup
-    if (this.options.isAutoSetup) {
-      this.setup(crowi.getConfig());
-    }
-  }
-
-  initMarkdownItConfigurers(options) {
-    const crowi = this.crowi;
 
     this.isMarkdownItConfigured = false;
 
     this.markdownItConfigurers = [
-      new TaskListsConfigurer(crowi),
-      new HeaderConfigurer(crowi),
-      new EmojiConfigurer(crowi),
-      new MathJaxConfigurer(crowi),
-      new PlantUMLConfigurer(crowi),
-      new BlockdiagConfigurer(crowi),
+      new TaskListsConfigurer(appContainer),
+      new HeaderConfigurer(appContainer),
+      new EmojiConfigurer(appContainer),
+      new MathJaxConfigurer(appContainer),
+      new PlantUMLConfigurer(appContainer),
+      new BlockdiagConfigurer(appContainer),
     ];
 
     // add configurers according to mode
-    const mode = options.mode;
     switch (mode) {
-      case 'page':
+      case 'page': {
+        const renderToc = appContainer.getCrowiForJquery().renderTocContent;
+
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
-          new FooternoteConfigurer(crowi),
-          new TocAndAnchorConfigurer(crowi, options.renderToc),
-          new HeaderLineNumberConfigurer(crowi),
-          new HeaderWithEditLinkConfigurer(crowi),
-          new TableWithHandsontableButtonConfigurer(crowi),
+          new FooternoteConfigurer(appContainer),
+          new TocAndAnchorConfigurer(appContainer, renderToc),
+          new HeaderLineNumberConfigurer(appContainer),
+          new HeaderWithEditLinkConfigurer(appContainer),
+          new TableWithHandsontableButtonConfigurer(appContainer),
         ]);
         break;
+      }
       case 'editor':
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
-          new FooternoteConfigurer(crowi),
-          new HeaderLineNumberConfigurer(crowi),
-          new TableConfigurer(crowi),
+          new FooternoteConfigurer(appContainer),
+          new HeaderLineNumberConfigurer(appContainer),
+          new TableConfigurer(appContainer),
         ]);
         break;
       // case 'comment':
       //   break;
       default:
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
-          new TableConfigurer(crowi),
+          new TableConfigurer(appContainer),
         ]);
         break;
     }
@@ -113,11 +105,11 @@ export default class GrowiRenderer {
   /**
    * setup with crowi config
    */
-  setup() {
-    const crowiConfig = this.crowi.config;
+  setup(mode) {
+    const crowiConfig = this.appContainer.config;
 
     let isEnabledLinebreaks;
-    switch (this.options.mode) {
+    switch (mode) {
       case 'comment':
         isEnabledLinebreaks = crowiConfig.isEnabledLinebreaksInComments;
         break;
@@ -166,7 +158,7 @@ export default class GrowiRenderer {
   }
 
   codeRenderer(code, langExt) {
-    const config = this.crowi.getConfig();
+    const config = this.appContainer.getConfig();
     const noborder = (!config.highlightJsStyleBorder) ? 'hljs-no-border' : '';
 
     let citeTag = '';

+ 0 - 0
src/client/js/util/PostProcessor/.keep


+ 0 - 82
src/client/js/util/PostProcessor/CrowiTemplate.js

@@ -1,82 +0,0 @@
-import dateFnsFormat from 'date-fns/format';
-
-export default class CrowiTemplate {
-
-  constructor(crowi) {
-    this.templatePattern = {
-      year: this.getYear,
-      month: this.getMonth,
-      date: this.getDate,
-      user: this.getUser,
-    };
-  }
-
-  process(markdown) {
-    // see: https://regex101.com/r/WR6IvX/3
-    return markdown.replace(/:::\s*(\S+)[\r\n]((.|[\r\n])*?)[\r\n]:::/gm, (all, group1, group2) => {
-      const lang = group1;
-      let code = group2;
-
-      if (!lang.match(/^template/)) {
-        return all;
-      }
-
-      const templateId = new Date().getTime().toString(16) + Math.floor(1000 * Math.random()).toString(16);
-      let pageName = lang;
-      if (lang.match(':')) {
-        pageName = this.parseTemplateString(lang.split(':')[1]);
-      }
-      code = this.parseTemplateString(code);
-
-      return (
-        /* eslint-disable quotes */
-        `<div class="page-template-builder">`
-          + `<button class="template-create-button btn btn-default" data-template="${templateId}" data-path="${pageName}">`
-            + `<i class="fa fa-pencil"></i> ${pageName}`
-          + `</button>`
-          + `<pre><code id="${templateId}" class="lang-${lang}">${code}\n</code></pre>`
-        + `</div>`
-        /* eslint-enable quotes */
-      );
-    });
-  }
-
-  getYear() {
-    return dateFnsFormat(new Date(), 'YYYY');
-  }
-
-  getMonth() {
-    return dateFnsFormat(new Date(), 'YYYY/MM');
-  }
-
-  getDate() {
-    return dateFnsFormat(new Date(), 'YYYY/MM/DD');
-  }
-
-  getUser() {
-    // FIXME
-    const username = window.crowi.me || null;
-
-    if (!username) {
-      return '';
-    }
-
-    return `/user/${username}`;
-  }
-
-  parseTemplateString(templateString) {
-    let parsed = templateString;
-
-    Object.keys(this.templatePattern).forEach((key) => {
-      const k = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-      const matcher = new RegExp(`{${k}}`, 'g');
-      if (parsed.match(matcher)) {
-        const replacer = this.templatePattern[key]();
-        parsed = parsed.replace(matcher, replacer);
-      }
-    });
-
-    return parsed;
-  }
-
-}

+ 37 - 0
src/client/js/util/apiNotification.js

@@ -0,0 +1,37 @@
+// show API error/sucess toastr
+
+import * as toastr from 'toastr';
+import toArrayIfNot from '../../../lib/util/toArrayIfNot';
+
+const toastrOption = {
+  error: {
+    closeButton: true,
+    progressBar: true,
+    newestOnTop: false,
+    showDuration: '100',
+    hideDuration: '100',
+    timeOut: '3000',
+  },
+  success: {
+    closeButton: true,
+    progressBar: true,
+    newestOnTop: false,
+    showDuration: '100',
+    hideDuration: '100',
+    timeOut: '3000',
+  },
+};
+
+// accepts both a single error and an array of errors
+export const toastError = (err, header = 'Error', option = toastrOption.error) => {
+  const errs = toArrayIfNot(err);
+
+  for (const err of errs) {
+    toastr.error(err.message, header, option);
+  }
+};
+
+// only accepts a single item
+export const toastSuccess = (body, header = 'Success', option = toastrOption.success) => {
+  toastr.success(body, header, option);
+};

+ 20 - 0
src/client/js/util/apiv3ErrorHandler.js

@@ -0,0 +1,20 @@
+// API v3 sends an array of errors in res.data.errors.
+// API v3 errors need to extracted from an error object in order to properly handle them.
+
+import toArrayIfNot from '../../../lib/util/toArrayIfNot';
+
+const logger = require('@alias/logger')('growi:apiv3');
+
+const apiv3ErrorHandler = (_err, header = 'Error') => {
+  // extract api errors from general 400 err
+  const err = _err.response ? _err.response.data.errors : _err;
+  const errs = toArrayIfNot(err);
+
+  for (const err of errs) {
+    logger.error(err.message);
+  }
+
+  return errs;
+};
+
+export default apiv3ErrorHandler;

+ 0 - 0
src/client/js/i18n.js → src/client/js/util/i18n.js


+ 8 - 12
src/client/js/util/reveal/plugins/growi-renderer.js

@@ -1,24 +1,21 @@
-import GrowiRenderer from '../../GrowiRenderer';
-
 /**
  * reveal.js growi-renderer plugin.
  */
 (function(root, factory) {
-  // parent window DOM (crowi.js) of presentation window.
-  const parentWindow = window.parent;
-
-  // create GrowiRenderer instance and setup.
-  const growiRenderer = new GrowiRenderer(parentWindow.crowi, parentWindow.crowiRenderer, { mode: 'editor' });
+  // get AppContainer instance from parent window
+  const appContainer = window.parent.appContainer;
 
-  const growiRendererPlugin = factory(growiRenderer);
+  const growiRendererPlugin = factory(appContainer);
   growiRendererPlugin.initialize();
-}(this, (growiRenderer) => {
+}(this, (appContainer) => {
   /* eslint-disable no-useless-escape */
   const DEFAULT_SLIDE_SEPARATOR = '^\r?\n---\r?\n$';
   const DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR = '\\\.element\\\s*?(.+?)$';
   const DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR = '\\\.slide:\\\s*?(\\\S.+?)$';
   /* eslint-enable no-useless-escape */
 
+  const growiRenderer = appContainer.getRenderer('editor');
+
   let marked;
 
   /**
@@ -31,7 +28,7 @@ import GrowiRenderer from '../../GrowiRenderer';
       const section = sections[i];
       const markdown = marked.getMarkdownFromSlide(section);
       const context = { markdown };
-      const interceptorManager = growiRenderer.crowi.interceptorManager;
+      const interceptorManager = appContainer.interceptorManager;
       let dataSeparator = section.getAttribute('data-separator') || DEFAULT_SLIDE_SEPARATOR;
       // replace string '\n' to LF code.
       dataSeparator = dataSeparator.replace(/\\n/g, '\n');
@@ -54,7 +51,7 @@ import GrowiRenderer from '../../GrowiRenderer';
   function convertSlides() {
     const sections = document.querySelectorAll('[data-markdown]');
     let markdown;
-    const interceptorManager = growiRenderer.crowi.interceptorManager;
+    const interceptorManager = appContainer.interceptorManager;
 
     for (let i = 0, len = sections.length; i < len; i++) {
       const section = sections[i];
@@ -104,7 +101,6 @@ import GrowiRenderer from '../../GrowiRenderer';
   // API
   return {
     async initialize() {
-      growiRenderer.setup();
       marked = require('./markdown').default(growiRenderer.process);
       divideSlides();
       marked.processSlides();

+ 28 - 16
src/client/styles/agile-admin/inverse/colors/antarctic.scss

@@ -16,20 +16,23 @@ $active-nav-tabs-bgcolor: $themecolor;
 $logo-mark-fill: $themelight;
 $wikilinktext: lighten($themecolor, 5%);
 $wikilinktext-hover: lighten($wikilinktext, 15%);
-$inline-code-color: darken($subthemecolor, 5%);
-$inline-code-bg: lighten($subthemecolor, 70%);
+$inline-code-color: #c7254e;
+$inline-code-bg: #f9f2f4;
 $border: $subthemecolor;
 $border-original: $subthemecolor;
 $navbar-border: $themecolor;
-$background-color: rgba($color: $themelight, $alpha: 0.8);
-$info:$subthemecolor;
+$background-color: rgba(
+  $color: $themelight,
+  $alpha: 0.8,
+);
+$info: $subthemecolor;
 
 @import 'apply-colors';
 @import 'apply-colors-light';
 
 // change color of highlighted header in wiki (default: orange)
 .code-line,
-ul>.text-muted {
+ul > .text-muted {
   color: $subthemecolor;
 }
 
@@ -50,7 +53,7 @@ ul>.text-muted {
 }
 
 // add background-image
-.main-container>#wrapper>#page-wrapper,
+.main-container > #wrapper > #page-wrapper,
 .page-editor-preview-container {
   background-image: url('/images/themes/antarctic/bg.svg');
   background-attachment: fixed;
@@ -99,7 +102,7 @@ header.affix {
   }
 }
 
-#wrapper>.navbar>.navbar-header {
+#wrapper > .navbar > .navbar-header {
   border-bottom: 4px solid $accentcolor;
 }
 
@@ -111,7 +114,7 @@ header.affix {
   .page-comment-main {
     box-shadow: 0px 3px 15px rgba(0, 0, 0, 0.2);
 
-    .page-comment-creator>a {
+    .page-comment-creator > a {
       border-bottom: 1px double $subthemecolor;
     }
   }
@@ -131,7 +134,7 @@ header.affix {
     }
 
     .nav.nav-tabs {
-      >li.active>a {
+      > li.active > a {
         background: $themecolor;
         border-bottom: solid 1px $themecolor;
         border-bottom-color: $themecolor;
@@ -144,21 +147,23 @@ header.affix {
  * Tabs
  */
 
-.nav.nav-tabs>li.active>a {
+.nav.nav-tabs > li.active > a {
   color: $themelight;
 }
 
 .text-info,
 body:not(.on-edit) .nav.nav-tabs {
-  >li>a {
+  > li > a {
     color: $subthemecolor;
   }
 
-  >li.active>a {
+  > li.active > a {
     color: $themelight;
-    background: linear-gradient(rgba($active-nav-tabs-bgcolor, 0) 50%,
+    background: linear-gradient(
+      rgba($active-nav-tabs-bgcolor, 0) 50%,
       rgba($active-nav-tabs-bgcolor, 0) 90%,
-      $active-nav-tabs-bgcolor 100%); // overwrite only the bottom pixel
+      $active-nav-tabs-bgcolor 100%
+    ); // overwrite only the bottom pixel
     background-color: $themecolor;
   }
 }
@@ -199,7 +204,6 @@ body:not(.on-edit) .nav.nav-tabs {
       ul {
         padding-left: 5px;
       }
-
     }
   }
 }
@@ -208,7 +212,7 @@ body:not(.on-edit) .nav.nav-tabs {
  *  Login page
  */
 
-.login-page>#wrapper>#page-wrapper {
+.login-page > #wrapper > #page-wrapper {
   background-image: url('/images/themes/antarctic/topimage.svg');
   background-attachment: fixed;
   background-position: center center;
@@ -222,3 +226,11 @@ body:not(.on-edit) .nav.nav-tabs {
     }
   }
 }
+
+/*
+ *  for Hightlight-js
+ */
+
+.hljs-ln {
+  background-color: transparent;
+}

+ 51 - 51
src/client/styles/bootstrap4/_alert.scss

@@ -1,51 +1,51 @@
-//
-// Base styles
-//
-
-.alert {
-  position: relative;
-  padding: $alert-padding-y $alert-padding-x;
-  margin-bottom: $alert-margin-bottom;
-  border: $alert-border-width solid transparent;
-  @include border-radius($alert-border-radius);
-}
-
-// Headings for larger alerts
-.alert-heading {
-  // Specified to prevent conflicts of changing $headings-color
-  color: inherit;
-}
-
-// Provide class for links that match alerts
-.alert-link {
-  font-weight: $alert-link-font-weight;
-}
-
-
-// Dismissible alerts
-//
-// Expand the right padding and account for the close button's positioning.
-
-.alert-dismissible {
-  padding-right: ($close-font-size + $alert-padding-x * 2);
-
-  // Adjust close link position
-  .close {
-    position: absolute;
-    top: 0;
-    right: 0;
-    padding: $alert-padding-y $alert-padding-x;
-    color: inherit;
-  }
-}
-
-
-// Alternate styles
-//
-// Generate contextual modifier classes for colorizing the alert.
-
-@each $color, $value in $theme-colors {
-  .alert-#{$color} {
-    @include alert-variant(theme-color-level($color, $alert-bg-level), theme-color-level($color, $alert-border-level), theme-color-level($color, $alert-color-level));
-  }
-}
+//
+// Base styles
+//
+
+.alert {
+  position: relative;
+  padding: $alert-padding-y $alert-padding-x;
+  margin-bottom: $alert-margin-bottom;
+  border: $alert-border-width solid transparent;
+  @include border-radius($alert-border-radius);
+}
+
+// Headings for larger alerts
+.alert-heading {
+  // Specified to prevent conflicts of changing $headings-color
+  color: inherit;
+}
+
+// Provide class for links that match alerts
+.alert-link {
+  font-weight: $alert-link-font-weight;
+}
+
+
+// Dismissible alerts
+//
+// Expand the right padding and account for the close button's positioning.
+
+.alert-dismissible {
+  padding-right: ($close-font-size + $alert-padding-x * 2);
+
+  // Adjust close link position
+  .close {
+    position: absolute;
+    top: 0;
+    right: 0;
+    padding: $alert-padding-y $alert-padding-x;
+    color: inherit;
+  }
+}
+
+
+// Alternate styles
+//
+// Generate contextual modifier classes for colorizing the alert.
+
+@each $color, $value in $theme-colors {
+  .alert-#{$color} {
+    @include alert-variant(theme-color-level($color, $alert-bg-level), theme-color-level($color, $alert-border-level), theme-color-level($color, $alert-color-level));
+  }
+}

+ 47 - 47
src/client/styles/bootstrap4/_badge.scss

@@ -1,47 +1,47 @@
-// Base class
-//
-// Requires one of the contextual, color modifier classes for `color` and
-// `background-color`.
-
-.badge {
-  display: inline-block;
-  padding: $badge-padding-y $badge-padding-x;
-  font-size: $badge-font-size;
-  font-weight: $badge-font-weight;
-  line-height: 1;
-  text-align: center;
-  white-space: nowrap;
-  vertical-align: baseline;
-  @include border-radius($badge-border-radius);
-
-  // Empty badges collapse automatically
-  &:empty {
-    display: none;
-  }
-}
-
-// Quick fix for badges in buttons
-.btn .badge {
-  position: relative;
-  top: -1px;
-}
-
-// Pill badges
-//
-// Make them extra rounded with a modifier to replace v3's badges.
-
-.badge-pill {
-  padding-right: $badge-pill-padding-x;
-  padding-left: $badge-pill-padding-x;
-  @include border-radius($badge-pill-border-radius);
-}
-
-// Colors
-//
-// Contextual variations (linked badges get darker on :hover).
-
-@each $color, $value in $theme-colors {
-  .badge-#{$color} {
-    @include badge-variant($value);
-  }
-}
+// Base class
+//
+// Requires one of the contextual, color modifier classes for `color` and
+// `background-color`.
+
+.badge {
+  display: inline-block;
+  padding: $badge-padding-y $badge-padding-x;
+  font-size: $badge-font-size;
+  font-weight: $badge-font-weight;
+  line-height: 1;
+  text-align: center;
+  white-space: nowrap;
+  vertical-align: baseline;
+  @include border-radius($badge-border-radius);
+
+  // Empty badges collapse automatically
+  &:empty {
+    display: none;
+  }
+}
+
+// Quick fix for badges in buttons
+.btn .badge {
+  position: relative;
+  top: -1px;
+}
+
+// Pill badges
+//
+// Make them extra rounded with a modifier to replace v3's badges.
+
+.badge-pill {
+  padding-right: $badge-pill-padding-x;
+  padding-left: $badge-pill-padding-x;
+  @include border-radius($badge-pill-border-radius);
+}
+
+// Colors
+//
+// Contextual variations (linked badges get darker on :hover).
+
+@each $color, $value in $theme-colors {
+  .badge-#{$color} {
+    @include badge-variant($value);
+  }
+}

+ 41 - 38
src/client/styles/bootstrap4/_breadcrumb.scss

@@ -1,38 +1,41 @@
-.breadcrumb {
-  display: flex;
-  flex-wrap: wrap;
-  padding: $breadcrumb-padding-y $breadcrumb-padding-x;
-  margin-bottom: $breadcrumb-margin-bottom;
-  list-style: none;
-  background-color: $breadcrumb-bg;
-  @include border-radius($border-radius);
-}
-
-.breadcrumb-item {
-  // The separator between breadcrumbs (by default, a forward-slash: "/")
-  + .breadcrumb-item::before {
-    display: inline-block; // Suppress underlining of the separator in modern browsers
-    padding-right: $breadcrumb-item-padding;
-    padding-left: $breadcrumb-item-padding;
-    color: $breadcrumb-divider-color;
-    content: "#{$breadcrumb-divider}";
-  }
-
-  // IE9-11 hack to properly handle hyperlink underlines for breadcrumbs built
-  // without `<ul>`s. The `::before` pseudo-element generates an element
-  // *within* the .breadcrumb-item and thereby inherits the `text-decoration`.
-  //
-  // To trick IE into suppressing the underline, we give the pseudo-element an
-  // underline and then immediately remove it.
-  + .breadcrumb-item:hover::before {
-    text-decoration: underline;
-  }
-  // stylelint-disable-next-line no-duplicate-selectors
-  + .breadcrumb-item:hover::before {
-    text-decoration: none;
-  }
-
-  &.active {
-    color: $breadcrumb-active-color;
-  }
-}
+.breadcrumb {
+  display: flex;
+  flex-wrap: wrap;
+  padding: $breadcrumb-padding-y $breadcrumb-padding-x;
+  margin-bottom: $breadcrumb-margin-bottom;
+  list-style: none;
+  background-color: $breadcrumb-bg;
+  @include border-radius($breadcrumb-border-radius);
+}
+
+.breadcrumb-item {
+  // The separator between breadcrumbs (by default, a forward-slash: "/")
+  + .breadcrumb-item {
+    padding-left: $breadcrumb-item-padding;
+
+    &::before {
+      display: inline-block; // Suppress underlining of the separator in modern browsers
+      padding-right: $breadcrumb-item-padding;
+      color: $breadcrumb-divider-color;
+      content: $breadcrumb-divider;
+    }
+  }
+
+  // IE9-11 hack to properly handle hyperlink underlines for breadcrumbs built
+  // without `<ul>`s. The `::before` pseudo-element generates an element
+  // *within* the .breadcrumb-item and thereby inherits the `text-decoration`.
+  //
+  // To trick IE into suppressing the underline, we give the pseudo-element an
+  // underline and then immediately remove it.
+  + .breadcrumb-item:hover::before {
+    text-decoration: underline;
+  }
+  // stylelint-disable-next-line no-duplicate-selectors
+  + .breadcrumb-item:hover::before {
+    text-decoration: none;
+  }
+
+  &.active {
+    color: $breadcrumb-active-color;
+  }
+}

+ 172 - 166
src/client/styles/bootstrap4/_button-group.scss

@@ -1,166 +1,172 @@
-// stylelint-disable selector-no-qualifying-type
-
-// Make the div behave like a button
-.btn-group,
-.btn-group-vertical {
-  position: relative;
-  display: inline-flex;
-  vertical-align: middle; // match .btn alignment given font-size hack above
-
-  > .btn {
-    position: relative;
-    flex: 0 1 auto;
-
-    // Bring the hover, focused, and "active" buttons to the front to overlay
-    // the borders properly
-    @include hover {
-      z-index: 1;
-    }
-    &:focus,
-    &:active,
-    &.active {
-      z-index: 1;
-    }
-  }
-
-  // Prevent double borders when buttons are next to each other
-  .btn + .btn,
-  .btn + .btn-group,
-  .btn-group + .btn,
-  .btn-group + .btn-group {
-    margin-left: -$btn-border-width;
-  }
-}
-
-// Optional: Group multiple button groups together for a toolbar
-.btn-toolbar {
-  display: flex;
-  flex-wrap: wrap;
-  justify-content: flex-start;
-
-  .input-group {
-    width: auto;
-  }
-}
-
-.btn-group {
-  > .btn:first-child {
-    margin-left: 0;
-  }
-
-  // Reset rounded corners
-  > .btn:not(:last-child):not(.dropdown-toggle),
-  > .btn-group:not(:last-child) > .btn {
-    @include border-right-radius(0);
-  }
-
-  > .btn:not(:first-child),
-  > .btn-group:not(:first-child) > .btn {
-    @include border-left-radius(0);
-  }
-}
-
-// Sizing
-//
-// Remix the default button sizing classes into new ones for easier manipulation.
-
-.btn-group-sm > .btn { @extend .btn-sm; }
-.btn-group-lg > .btn { @extend .btn-lg; }
-
-
-//
-// Split button dropdowns
-//
-
-.dropdown-toggle-split {
-  padding-right: $btn-padding-x * .75;
-  padding-left: $btn-padding-x * .75;
-
-  &::after {
-    margin-left: 0;
-  }
-}
-
-.btn-sm + .dropdown-toggle-split {
-  padding-right: $btn-padding-x-sm * .75;
-  padding-left: $btn-padding-x-sm * .75;
-}
-
-.btn-lg + .dropdown-toggle-split {
-  padding-right: $btn-padding-x-lg * .75;
-  padding-left: $btn-padding-x-lg * .75;
-}
-
-
-// The clickable button for toggling the menu
-// Set the same inset shadow as the :active state
-.btn-group.show .dropdown-toggle {
-  @include box-shadow($btn-active-box-shadow);
-
-  // Show no shadow for `.btn-link` since it has no other button styles.
-  &.btn-link {
-    @include box-shadow(none);
-  }
-}
-
-
-//
-// Vertical button groups
-//
-
-.btn-group-vertical {
-  flex-direction: column;
-  align-items: flex-start;
-  justify-content: center;
-
-  .btn,
-  .btn-group {
-    width: 100%;
-  }
-
-  > .btn + .btn,
-  > .btn + .btn-group,
-  > .btn-group + .btn,
-  > .btn-group + .btn-group {
-    margin-top: -$btn-border-width;
-    margin-left: 0;
-  }
-
-  // Reset rounded corners
-  > .btn:not(:last-child):not(.dropdown-toggle),
-  > .btn-group:not(:last-child) > .btn {
-    @include border-bottom-radius(0);
-  }
-
-  > .btn:not(:first-child),
-  > .btn-group:not(:first-child) > .btn {
-    @include border-top-radius(0);
-  }
-}
-
-
-// Checkbox and radio options
-//
-// In order to support the browser's form validation feedback, powered by the
-// `required` attribute, we have to "hide" the inputs via `clip`. We cannot use
-// `display: none;` or `visibility: hidden;` as that also hides the popover.
-// Simply visually hiding the inputs via `opacity` would leave them clickable in
-// certain cases which is prevented by using `clip` and `pointer-events`.
-// This way, we ensure a DOM element is visible to position the popover from.
-//
-// See https://github.com/twbs/bootstrap/pull/12794 and
-// https://github.com/twbs/bootstrap/pull/14559 for more information.
-
-.btn-group-toggle {
-  > .btn,
-  > .btn-group > .btn {
-    margin-bottom: 0; // Override default `<label>` value
-
-    input[type="radio"],
-    input[type="checkbox"] {
-      position: absolute;
-      clip: rect(0, 0, 0, 0);
-      pointer-events: none;
-    }
-  }
-}
+// stylelint-disable selector-no-qualifying-type
+
+// Make the div behave like a button
+.btn-group,
+.btn-group-vertical {
+  position: relative;
+  display: inline-flex;
+  vertical-align: middle; // match .btn alignment given font-size hack above
+
+  > .btn {
+    position: relative;
+    flex: 0 1 auto;
+
+    // Bring the hover, focused, and "active" buttons to the front to overlay
+    // the borders properly
+    @include hover {
+      z-index: 1;
+    }
+    &:focus,
+    &:active,
+    &.active {
+      z-index: 1;
+    }
+  }
+
+  // Prevent double borders when buttons are next to each other
+  .btn + .btn,
+  .btn + .btn-group,
+  .btn-group + .btn,
+  .btn-group + .btn-group {
+    margin-left: -$btn-border-width;
+  }
+}
+
+// Optional: Group multiple button groups together for a toolbar
+.btn-toolbar {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: flex-start;
+
+  .input-group {
+    width: auto;
+  }
+}
+
+.btn-group {
+  > .btn:first-child {
+    margin-left: 0;
+  }
+
+  // Reset rounded corners
+  > .btn:not(:last-child):not(.dropdown-toggle),
+  > .btn-group:not(:last-child) > .btn {
+    @include border-right-radius(0);
+  }
+
+  > .btn:not(:first-child),
+  > .btn-group:not(:first-child) > .btn {
+    @include border-left-radius(0);
+  }
+}
+
+// Sizing
+//
+// Remix the default button sizing classes into new ones for easier manipulation.
+
+.btn-group-sm > .btn { @extend .btn-sm; }
+.btn-group-lg > .btn { @extend .btn-lg; }
+
+
+//
+// Split button dropdowns
+//
+
+.dropdown-toggle-split {
+  padding-right: $btn-padding-x * .75;
+  padding-left: $btn-padding-x * .75;
+
+  &::after,
+  .dropup &::after,
+  .dropright &::after {
+    margin-left: 0;
+  }
+
+  .dropleft &::before {
+    margin-right: 0;
+  }
+}
+
+.btn-sm + .dropdown-toggle-split {
+  padding-right: $btn-padding-x-sm * .75;
+  padding-left: $btn-padding-x-sm * .75;
+}
+
+.btn-lg + .dropdown-toggle-split {
+  padding-right: $btn-padding-x-lg * .75;
+  padding-left: $btn-padding-x-lg * .75;
+}
+
+
+// The clickable button for toggling the menu
+// Set the same inset shadow as the :active state
+.btn-group.show .dropdown-toggle {
+  @include box-shadow($btn-active-box-shadow);
+
+  // Show no shadow for `.btn-link` since it has no other button styles.
+  &.btn-link {
+    @include box-shadow(none);
+  }
+}
+
+
+//
+// Vertical button groups
+//
+
+.btn-group-vertical {
+  flex-direction: column;
+  align-items: flex-start;
+  justify-content: center;
+
+  .btn,
+  .btn-group {
+    width: 100%;
+  }
+
+  > .btn + .btn,
+  > .btn + .btn-group,
+  > .btn-group + .btn,
+  > .btn-group + .btn-group {
+    margin-top: -$btn-border-width;
+    margin-left: 0;
+  }
+
+  // Reset rounded corners
+  > .btn:not(:last-child):not(.dropdown-toggle),
+  > .btn-group:not(:last-child) > .btn {
+    @include border-bottom-radius(0);
+  }
+
+  > .btn:not(:first-child),
+  > .btn-group:not(:first-child) > .btn {
+    @include border-top-radius(0);
+  }
+}
+
+
+// Checkbox and radio options
+//
+// In order to support the browser's form validation feedback, powered by the
+// `required` attribute, we have to "hide" the inputs via `clip`. We cannot use
+// `display: none;` or `visibility: hidden;` as that also hides the popover.
+// Simply visually hiding the inputs via `opacity` would leave them clickable in
+// certain cases which is prevented by using `clip` and `pointer-events`.
+// This way, we ensure a DOM element is visible to position the popover from.
+//
+// See https://github.com/twbs/bootstrap/pull/12794 and
+// https://github.com/twbs/bootstrap/pull/14559 for more information.
+
+.btn-group-toggle {
+  > .btn,
+  > .btn-group > .btn {
+    margin-bottom: 0; // Override default `<label>` value
+
+    input[type="radio"],
+    input[type="checkbox"] {
+      position: absolute;
+      clip: rect(0, 0, 0, 0);
+      pointer-events: none;
+    }
+  }
+}

+ 143 - 143
src/client/styles/bootstrap4/_buttons.scss

@@ -1,143 +1,143 @@
-// stylelint-disable selector-no-qualifying-type
-
-//
-// Base styles
-//
-
-.btn {
-  display: inline-block;
-  font-weight: $btn-font-weight;
-  text-align: center;
-  white-space: nowrap;
-  vertical-align: middle;
-  user-select: none;
-  border: $btn-border-width solid transparent;
-  @include button-size($btn-padding-y, $btn-padding-x, $font-size-base, $btn-line-height, $btn-border-radius);
-  @include transition($btn-transition);
-
-  // Share hover and focus styles
-  @include hover-focus {
-    text-decoration: none;
-  }
-
-  &:focus,
-  &.focus {
-    outline: 0;
-    box-shadow: $btn-focus-box-shadow;
-  }
-
-  // Disabled comes first so active can properly restyle
-  &.disabled,
-  &:disabled {
-    opacity: $btn-disabled-opacity;
-    @include box-shadow(none);
-  }
-
-  // Opinionated: add "hand" cursor to non-disabled .btn elements
-  &:not(:disabled):not(.disabled) {
-    cursor: pointer;
-  }
-
-  &:not(:disabled):not(.disabled):active,
-  &:not(:disabled):not(.disabled).active {
-    background-image: none;
-    @include box-shadow($btn-active-box-shadow);
-
-    &:focus {
-      @include box-shadow($btn-focus-box-shadow, $btn-active-box-shadow);
-    }
-  }
-}
-
-// Future-proof disabling of clicks on `<a>` elements
-a.btn.disabled,
-fieldset:disabled a.btn {
-  pointer-events: none;
-}
-
-
-//
-// Alternate buttons
-//
-
-@each $color, $value in $theme-colors {
-  .btn-#{$color} {
-    @include button-variant($value, $value);
-  }
-}
-
-@each $color, $value in $theme-colors {
-  .btn-outline-#{$color} {
-    @include button-outline-variant($value);
-  }
-}
-
-
-//
-// Link buttons
-//
-
-// Make a button look and behave like a link
-.btn-link {
-  font-weight: $font-weight-normal;
-  color: $link-color;
-  background-color: transparent;
-
-  @include hover {
-    color: $link-hover-color;
-    text-decoration: $link-hover-decoration;
-    background-color: transparent;
-    border-color: transparent;
-  }
-
-  &:focus,
-  &.focus {
-    text-decoration: $link-hover-decoration;
-    border-color: transparent;
-    box-shadow: none;
-  }
-
-  &:disabled,
-  &.disabled {
-    color: $btn-link-disabled-color;
-  }
-
-  // No need for an active state here
-}
-
-
-//
-// Button Sizes
-//
-
-.btn-lg {
-  @include button-size($btn-padding-y-lg, $btn-padding-x-lg, $font-size-lg, $btn-line-height-lg, $btn-border-radius-lg);
-}
-
-.btn-sm {
-  @include button-size($btn-padding-y-sm, $btn-padding-x-sm, $font-size-sm, $btn-line-height-sm, $btn-border-radius-sm);
-}
-
-
-//
-// Block button
-//
-
-.btn-block {
-  display: block;
-  width: 100%;
-
-  // Vertically space out multiple block buttons
-  + .btn-block {
-    margin-top: $btn-block-spacing-y;
-  }
-}
-
-// Specificity overrides
-input[type="submit"],
-input[type="reset"],
-input[type="button"] {
-  &.btn-block {
-    width: 100%;
-  }
-}
+// stylelint-disable selector-no-qualifying-type
+
+//
+// Base styles
+//
+
+.btn {
+  display: inline-block;
+  font-weight: $btn-font-weight;
+  text-align: center;
+  white-space: nowrap;
+  vertical-align: middle;
+  user-select: none;
+  border: $btn-border-width solid transparent;
+  @include button-size($btn-padding-y, $btn-padding-x, $font-size-base, $btn-line-height, $btn-border-radius);
+  @include transition($btn-transition);
+
+  // Share hover and focus styles
+  @include hover-focus {
+    text-decoration: none;
+  }
+
+  &:focus,
+  &.focus {
+    outline: 0;
+    box-shadow: $btn-focus-box-shadow;
+  }
+
+  // Disabled comes first so active can properly restyle
+  &.disabled,
+  &:disabled {
+    opacity: $btn-disabled-opacity;
+    @include box-shadow(none);
+  }
+
+  // Opinionated: add "hand" cursor to non-disabled .btn elements
+  &:not(:disabled):not(.disabled) {
+    cursor: pointer;
+  }
+
+  &:not(:disabled):not(.disabled):active,
+  &:not(:disabled):not(.disabled).active {
+    @include box-shadow($btn-active-box-shadow);
+
+    &:focus {
+      @include box-shadow($btn-focus-box-shadow, $btn-active-box-shadow);
+    }
+  }
+}
+
+// Future-proof disabling of clicks on `<a>` elements
+a.btn.disabled,
+fieldset:disabled a.btn {
+  pointer-events: none;
+}
+
+
+//
+// Alternate buttons
+//
+
+@each $color, $value in $theme-colors {
+  .btn-#{$color} {
+    @include button-variant($value, $value);
+  }
+}
+
+@each $color, $value in $theme-colors {
+  .btn-outline-#{$color} {
+    @include button-outline-variant($value);
+  }
+}
+
+
+//
+// Link buttons
+//
+
+// Make a button look and behave like a link
+.btn-link {
+  font-weight: $font-weight-normal;
+  color: $link-color;
+  background-color: transparent;
+
+  @include hover {
+    color: $link-hover-color;
+    text-decoration: $link-hover-decoration;
+    background-color: transparent;
+    border-color: transparent;
+  }
+
+  &:focus,
+  &.focus {
+    text-decoration: $link-hover-decoration;
+    border-color: transparent;
+    box-shadow: none;
+  }
+
+  &:disabled,
+  &.disabled {
+    color: $btn-link-disabled-color;
+    pointer-events: none;
+  }
+
+  // No need for an active state here
+}
+
+
+//
+// Button Sizes
+//
+
+.btn-lg {
+  @include button-size($btn-padding-y-lg, $btn-padding-x-lg, $font-size-lg, $btn-line-height-lg, $btn-border-radius-lg);
+}
+
+.btn-sm {
+  @include button-size($btn-padding-y-sm, $btn-padding-x-sm, $font-size-sm, $btn-line-height-sm, $btn-border-radius-sm);
+}
+
+
+//
+// Block button
+//
+
+.btn-block {
+  display: block;
+  width: 100%;
+
+  // Vertically space out multiple block buttons
+  + .btn-block {
+    margin-top: $btn-block-spacing-y;
+  }
+}
+
+// Specificity overrides
+input[type="submit"],
+input[type="reset"],
+input[type="button"] {
+  &.btn-block {
+    width: 100%;
+  }
+}

+ 301 - 270
src/client/styles/bootstrap4/_card.scss

@@ -1,270 +1,301 @@
-//
-// Base styles
-//
-
-.card {
-  position: relative;
-  display: flex;
-  flex-direction: column;
-  min-width: 0;
-  word-wrap: break-word;
-  background-color: $card-bg;
-  background-clip: border-box;
-  border: $card-border-width solid $card-border-color;
-  @include border-radius($card-border-radius);
-
-  > hr {
-    margin-right: 0;
-    margin-left: 0;
-  }
-
-  > .list-group:first-child {
-    .list-group-item:first-child {
-      @include border-top-radius($card-border-radius);
-    }
-  }
-
-  > .list-group:last-child {
-    .list-group-item:last-child {
-      @include border-bottom-radius($card-border-radius);
-    }
-  }
-}
-
-.card-body {
-  // Enable `flex-grow: 1` for decks and groups so that card blocks take up
-  // as much space as possible, ensuring footers are aligned to the bottom.
-  flex: 1 1 auto;
-  padding: $card-spacer-x;
-}
-
-.card-title {
-  margin-bottom: $card-spacer-y;
-}
-
-.card-subtitle {
-  margin-top: -($card-spacer-y / 2);
-  margin-bottom: 0;
-}
-
-.card-text:last-child {
-  margin-bottom: 0;
-}
-
-.card-link {
-  @include hover {
-    text-decoration: none;
-  }
-
-  + .card-link {
-    margin-left: $card-spacer-x;
-  }
-}
-
-//
-// Optional textual caps
-//
-
-.card-header {
-  padding: $card-spacer-y $card-spacer-x;
-  margin-bottom: 0; // Removes the default margin-bottom of <hN>
-  background-color: $card-cap-bg;
-  border-bottom: $card-border-width solid $card-border-color;
-
-  &:first-child {
-    @include border-radius($card-inner-border-radius $card-inner-border-radius 0 0);
-  }
-
-  + .list-group {
-    .list-group-item:first-child {
-      border-top: 0;
-    }
-  }
-}
-
-.card-footer {
-  padding: $card-spacer-y $card-spacer-x;
-  background-color: $card-cap-bg;
-  border-top: $card-border-width solid $card-border-color;
-
-  &:last-child {
-    @include border-radius(0 0 $card-inner-border-radius $card-inner-border-radius);
-  }
-}
-
-
-//
-// Header navs
-//
-
-.card-header-tabs {
-  margin-right: -($card-spacer-x / 2);
-  margin-bottom: -$card-spacer-y;
-  margin-left: -($card-spacer-x / 2);
-  border-bottom: 0;
-}
-
-.card-header-pills {
-  margin-right: -($card-spacer-x / 2);
-  margin-left: -($card-spacer-x / 2);
-}
-
-// Card image
-.card-img-overlay {
-  position: absolute;
-  top: 0;
-  right: 0;
-  bottom: 0;
-  left: 0;
-  padding: $card-img-overlay-padding;
-}
-
-.card-img {
-  width: 100%; // Required because we use flexbox and this inherently applies align-self: stretch
-  @include border-radius($card-inner-border-radius);
-}
-
-// Card image caps
-.card-img-top {
-  width: 100%; // Required because we use flexbox and this inherently applies align-self: stretch
-  @include border-top-radius($card-inner-border-radius);
-}
-
-.card-img-bottom {
-  width: 100%; // Required because we use flexbox and this inherently applies align-self: stretch
-  @include border-bottom-radius($card-inner-border-radius);
-}
-
-
-// Card deck
-
-.card-deck {
-  display: flex;
-  flex-direction: column;
-
-  .card {
-    margin-bottom: $card-deck-margin;
-  }
-
-  @include media-breakpoint-up(sm) {
-    flex-flow: row wrap;
-    margin-right: -$card-deck-margin;
-    margin-left: -$card-deck-margin;
-
-    .card {
-      display: flex;
-      // Flexbugs #4: https://github.com/philipwalton/flexbugs#4-flex-shorthand-declarations-with-unitless-flex-basis-values-are-ignored
-      flex: 1 0 0%;
-      flex-direction: column;
-      margin-right: $card-deck-margin;
-      margin-bottom: 0; // Override the default
-      margin-left: $card-deck-margin;
-    }
-  }
-}
-
-
-//
-// Card groups
-//
-
-.card-group {
-  display: flex;
-  flex-direction: column;
-
-  // The child selector allows nested `.card` within `.card-group`
-  // to display properly.
-  > .card {
-    margin-bottom: $card-group-margin;
-  }
-
-  @include media-breakpoint-up(sm) {
-    flex-flow: row wrap;
-    // The child selector allows nested `.card` within `.card-group`
-    // to display properly.
-    > .card {
-      // Flexbugs #4: https://github.com/philipwalton/flexbugs#4-flex-shorthand-declarations-with-unitless-flex-basis-values-are-ignored
-      flex: 1 0 0%;
-      margin-bottom: 0;
-
-      + .card {
-        margin-left: 0;
-        border-left: 0;
-      }
-
-      // Handle rounded corners
-      @if $enable-rounded {
-        &:first-child {
-          @include border-right-radius(0);
-
-          .card-img-top,
-          .card-header {
-            border-top-right-radius: 0;
-          }
-          .card-img-bottom,
-          .card-footer {
-            border-bottom-right-radius: 0;
-          }
-        }
-
-        &:last-child {
-          @include border-left-radius(0);
-
-          .card-img-top,
-          .card-header {
-            border-top-left-radius: 0;
-          }
-          .card-img-bottom,
-          .card-footer {
-            border-bottom-left-radius: 0;
-          }
-        }
-
-        &:only-child {
-          @include border-radius($card-border-radius);
-
-          .card-img-top,
-          .card-header {
-            @include border-top-radius($card-border-radius);
-          }
-          .card-img-bottom,
-          .card-footer {
-            @include border-bottom-radius($card-border-radius);
-          }
-        }
-
-        &:not(:first-child):not(:last-child):not(:only-child) {
-          @include border-radius(0);
-
-          .card-img-top,
-          .card-img-bottom,
-          .card-header,
-          .card-footer {
-            @include border-radius(0);
-          }
-        }
-      }
-    }
-  }
-}
-
-
-//
-// Columns
-//
-
-.card-columns {
-  .card {
-    margin-bottom: $card-columns-margin;
-  }
-
-  @include media-breakpoint-up(sm) {
-    column-count: $card-columns-count;
-    column-gap: $card-columns-gap;
-
-    .card {
-      display: inline-block; // Don't let them vertically span multiple columns
-      width: 100%; // Don't let their width change
-    }
-  }
-}
+//
+// Base styles
+//
+
+.card {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+  word-wrap: break-word;
+  background-color: $card-bg;
+  background-clip: border-box;
+  border: $card-border-width solid $card-border-color;
+  @include border-radius($card-border-radius);
+
+  > hr {
+    margin-right: 0;
+    margin-left: 0;
+  }
+
+  > .list-group:first-child {
+    .list-group-item:first-child {
+      @include border-top-radius($card-border-radius);
+    }
+  }
+
+  > .list-group:last-child {
+    .list-group-item:last-child {
+      @include border-bottom-radius($card-border-radius);
+    }
+  }
+}
+
+.card-body {
+  // Enable `flex-grow: 1` for decks and groups so that card blocks take up
+  // as much space as possible, ensuring footers are aligned to the bottom.
+  flex: 1 1 auto;
+  padding: $card-spacer-x;
+}
+
+.card-title {
+  margin-bottom: $card-spacer-y;
+}
+
+.card-subtitle {
+  margin-top: -($card-spacer-y / 2);
+  margin-bottom: 0;
+}
+
+.card-text:last-child {
+  margin-bottom: 0;
+}
+
+.card-link {
+  @include hover {
+    text-decoration: none;
+  }
+
+  + .card-link {
+    margin-left: $card-spacer-x;
+  }
+}
+
+//
+// Optional textual caps
+//
+
+.card-header {
+  padding: $card-spacer-y $card-spacer-x;
+  margin-bottom: 0; // Removes the default margin-bottom of <hN>
+  background-color: $card-cap-bg;
+  border-bottom: $card-border-width solid $card-border-color;
+
+  &:first-child {
+    @include border-radius($card-inner-border-radius $card-inner-border-radius 0 0);
+  }
+
+  + .list-group {
+    .list-group-item:first-child {
+      border-top: 0;
+    }
+  }
+}
+
+.card-footer {
+  padding: $card-spacer-y $card-spacer-x;
+  background-color: $card-cap-bg;
+  border-top: $card-border-width solid $card-border-color;
+
+  &:last-child {
+    @include border-radius(0 0 $card-inner-border-radius $card-inner-border-radius);
+  }
+}
+
+
+//
+// Header navs
+//
+
+.card-header-tabs {
+  margin-right: -($card-spacer-x / 2);
+  margin-bottom: -$card-spacer-y;
+  margin-left: -($card-spacer-x / 2);
+  border-bottom: 0;
+}
+
+.card-header-pills {
+  margin-right: -($card-spacer-x / 2);
+  margin-left: -($card-spacer-x / 2);
+}
+
+// Card image
+.card-img-overlay {
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  padding: $card-img-overlay-padding;
+}
+
+.card-img {
+  width: 100%; // Required because we use flexbox and this inherently applies align-self: stretch
+  @include border-radius($card-inner-border-radius);
+}
+
+// Card image caps
+.card-img-top {
+  width: 100%; // Required because we use flexbox and this inherently applies align-self: stretch
+  @include border-top-radius($card-inner-border-radius);
+}
+
+.card-img-bottom {
+  width: 100%; // Required because we use flexbox and this inherently applies align-self: stretch
+  @include border-bottom-radius($card-inner-border-radius);
+}
+
+
+// Card deck
+
+.card-deck {
+  display: flex;
+  flex-direction: column;
+
+  .card {
+    margin-bottom: $card-deck-margin;
+  }
+
+  @include media-breakpoint-up(sm) {
+    flex-flow: row wrap;
+    margin-right: -$card-deck-margin;
+    margin-left: -$card-deck-margin;
+
+    .card {
+      display: flex;
+      // Flexbugs #4: https://github.com/philipwalton/flexbugs#flexbug-4
+      flex: 1 0 0%;
+      flex-direction: column;
+      margin-right: $card-deck-margin;
+      margin-bottom: 0; // Override the default
+      margin-left: $card-deck-margin;
+    }
+  }
+}
+
+
+//
+// Card groups
+//
+
+.card-group {
+  display: flex;
+  flex-direction: column;
+
+  // The child selector allows nested `.card` within `.card-group`
+  // to display properly.
+  > .card {
+    margin-bottom: $card-group-margin;
+  }
+
+  @include media-breakpoint-up(sm) {
+    flex-flow: row wrap;
+    // The child selector allows nested `.card` within `.card-group`
+    // to display properly.
+    > .card {
+      // Flexbugs #4: https://github.com/philipwalton/flexbugs#flexbug-4
+      flex: 1 0 0%;
+      margin-bottom: 0;
+
+      + .card {
+        margin-left: 0;
+        border-left: 0;
+      }
+
+      // Handle rounded corners
+      @if $enable-rounded {
+        &:first-child {
+          @include border-right-radius(0);
+
+          .card-img-top,
+          .card-header {
+            border-top-right-radius: 0;
+          }
+          .card-img-bottom,
+          .card-footer {
+            border-bottom-right-radius: 0;
+          }
+        }
+
+        &:last-child {
+          @include border-left-radius(0);
+
+          .card-img-top,
+          .card-header {
+            border-top-left-radius: 0;
+          }
+          .card-img-bottom,
+          .card-footer {
+            border-bottom-left-radius: 0;
+          }
+        }
+
+        &:only-child {
+          @include border-radius($card-border-radius);
+
+          .card-img-top,
+          .card-header {
+            @include border-top-radius($card-border-radius);
+          }
+          .card-img-bottom,
+          .card-footer {
+            @include border-bottom-radius($card-border-radius);
+          }
+        }
+
+        &:not(:first-child):not(:last-child):not(:only-child) {
+          @include border-radius(0);
+
+          .card-img-top,
+          .card-img-bottom,
+          .card-header,
+          .card-footer {
+            @include border-radius(0);
+          }
+        }
+      }
+    }
+  }
+}
+
+
+//
+// Columns
+//
+
+.card-columns {
+  .card {
+    margin-bottom: $card-columns-margin;
+  }
+
+  @include media-breakpoint-up(sm) {
+    column-count: $card-columns-count;
+    column-gap: $card-columns-gap;
+    orphans: 1;
+    widows: 1;
+
+    .card {
+      display: inline-block; // Don't let them vertically span multiple columns
+      width: 100%; // Don't let their width change
+    }
+  }
+}
+
+
+//
+// Accordion
+//
+
+.accordion {
+  .card:not(:first-of-type):not(:last-of-type) {
+    border-bottom: 0;
+    border-radius: 0;
+  }
+
+  .card:not(:first-of-type) {
+    .card-header:first-child {
+      border-radius: 0;
+    }
+  }
+
+  .card:first-of-type {
+    border-bottom: 0;
+    border-bottom-right-radius: 0;
+    border-bottom-left-radius: 0;
+  }
+
+  .card:last-of-type {
+    border-top-left-radius: 0;
+    border-top-right-radius: 0;
+  }
+}

+ 236 - 191
src/client/styles/bootstrap4/_carousel.scss

@@ -1,191 +1,236 @@
-// Wrapper for the slide container and indicators
-.carousel {
-  position: relative;
-}
-
-.carousel-inner {
-  position: relative;
-  width: 100%;
-  overflow: hidden;
-}
-
-.carousel-item {
-  position: relative;
-  display: none;
-  align-items: center;
-  width: 100%;
-  @include transition($carousel-transition);
-  backface-visibility: hidden;
-  perspective: 1000px;
-}
-
-.carousel-item.active,
-.carousel-item-next,
-.carousel-item-prev {
-  display: block;
-}
-
-.carousel-item-next,
-.carousel-item-prev {
-  position: absolute;
-  top: 0;
-}
-
-// CSS3 transforms when supported by the browser
-.carousel-item-next.carousel-item-left,
-.carousel-item-prev.carousel-item-right {
-  transform: translateX(0);
-
-  @supports (transform-style: preserve-3d) {
-    transform: translate3d(0, 0, 0);
-  }
-}
-
-.carousel-item-next,
-.active.carousel-item-right {
-  transform: translateX(100%);
-
-  @supports (transform-style: preserve-3d) {
-    transform: translate3d(100%, 0, 0);
-  }
-}
-
-.carousel-item-prev,
-.active.carousel-item-left {
-  transform: translateX(-100%);
-
-  @supports (transform-style: preserve-3d) {
-    transform: translate3d(-100%, 0, 0);
-  }
-}
-
-
-//
-// Left/right controls for nav
-//
-
-.carousel-control-prev,
-.carousel-control-next {
-  position: absolute;
-  top: 0;
-  bottom: 0;
-  // Use flex for alignment (1-3)
-  display: flex; // 1. allow flex styles
-  align-items: center; // 2. vertically center contents
-  justify-content: center; // 3. horizontally center contents
-  width: $carousel-control-width;
-  color: $carousel-control-color;
-  text-align: center;
-  opacity: $carousel-control-opacity;
-  // We can't have a transition here because WebKit cancels the carousel
-  // animation if you trip this while in the middle of another animation.
-
-  // Hover/focus state
-  @include hover-focus {
-    color: $carousel-control-color;
-    text-decoration: none;
-    outline: 0;
-    opacity: .9;
-  }
-}
-.carousel-control-prev {
-  left: 0;
-  @if $enable-gradients {
-    background: linear-gradient(90deg, rgba(0, 0, 0, .25), rgba(0, 0, 0, .001));
-  }
-}
-.carousel-control-next {
-  right: 0;
-  @if $enable-gradients {
-    background: linear-gradient(270deg, rgba(0, 0, 0, .25), rgba(0, 0, 0, .001));
-  }
-}
-
-// Icons for within
-.carousel-control-prev-icon,
-.carousel-control-next-icon {
-  display: inline-block;
-  width: $carousel-control-icon-width;
-  height: $carousel-control-icon-width;
-  background: transparent no-repeat center center;
-  background-size: 100% 100%;
-}
-.carousel-control-prev-icon {
-  background-image: $carousel-control-prev-icon-bg;
-}
-.carousel-control-next-icon {
-  background-image: $carousel-control-next-icon-bg;
-}
-
-
-// Optional indicator pips
-//
-// Add an ordered list with the following class and add a list item for each
-// slide your carousel holds.
-
-.carousel-indicators {
-  position: absolute;
-  right: 0;
-  bottom: 10px;
-  left: 0;
-  z-index: 15;
-  display: flex;
-  justify-content: center;
-  padding-left: 0; // override <ol> default
-  // Use the .carousel-control's width as margin so we don't overlay those
-  margin-right: $carousel-control-width;
-  margin-left: $carousel-control-width;
-  list-style: none;
-
-  li {
-    position: relative;
-    flex: 0 1 auto;
-    width: $carousel-indicator-width;
-    height: $carousel-indicator-height;
-    margin-right: $carousel-indicator-spacer;
-    margin-left: $carousel-indicator-spacer;
-    text-indent: -999px;
-    background-color: rgba($carousel-indicator-active-bg, .5);
-
-    // Use pseudo classes to increase the hit area by 10px on top and bottom.
-    &::before {
-      position: absolute;
-      top: -10px;
-      left: 0;
-      display: inline-block;
-      width: 100%;
-      height: 10px;
-      content: "";
-    }
-    &::after {
-      position: absolute;
-      bottom: -10px;
-      left: 0;
-      display: inline-block;
-      width: 100%;
-      height: 10px;
-      content: "";
-    }
-  }
-
-  .active {
-    background-color: $carousel-indicator-active-bg;
-  }
-}
-
-
-// Optional captions
-//
-//
-
-.carousel-caption {
-  position: absolute;
-  right: ((100% - $carousel-caption-width) / 2);
-  bottom: 20px;
-  left: ((100% - $carousel-caption-width) / 2);
-  z-index: 10;
-  padding-top: 20px;
-  padding-bottom: 20px;
-  color: $carousel-caption-color;
-  text-align: center;
-}
+// Notes on the classes:
+//
+// 1. The .carousel-item-left and .carousel-item-right is used to indicate where
+//    the active slide is heading.
+// 2. .active.carousel-item is the current slide.
+// 3. .active.carousel-item-left and .active.carousel-item-right is the current
+//    slide in its in-transition state. Only one of these occurs at a time.
+// 4. .carousel-item-next.carousel-item-left and .carousel-item-prev.carousel-item-right
+//    is the upcoming slide in transition.
+
+.carousel {
+  position: relative;
+}
+
+.carousel-inner {
+  position: relative;
+  width: 100%;
+  overflow: hidden;
+}
+
+.carousel-item {
+  position: relative;
+  display: none;
+  align-items: center;
+  width: 100%;
+  backface-visibility: hidden;
+  perspective: 1000px;
+}
+
+.carousel-item.active,
+.carousel-item-next,
+.carousel-item-prev {
+  display: block;
+  @include transition($carousel-transition);
+}
+
+.carousel-item-next,
+.carousel-item-prev {
+  position: absolute;
+  top: 0;
+}
+
+.carousel-item-next.carousel-item-left,
+.carousel-item-prev.carousel-item-right {
+  transform: translateX(0);
+
+  @supports (transform-style: preserve-3d) {
+    transform: translate3d(0, 0, 0);
+  }
+}
+
+.carousel-item-next,
+.active.carousel-item-right {
+  transform: translateX(100%);
+
+  @supports (transform-style: preserve-3d) {
+    transform: translate3d(100%, 0, 0);
+  }
+}
+
+.carousel-item-prev,
+.active.carousel-item-left {
+  transform: translateX(-100%);
+
+  @supports (transform-style: preserve-3d) {
+    transform: translate3d(-100%, 0, 0);
+  }
+}
+
+
+//
+// Alternate transitions
+//
+
+.carousel-fade {
+  .carousel-item {
+    opacity: 0;
+    transition-duration: .6s;
+    transition-property: opacity;
+  }
+
+  .carousel-item.active,
+  .carousel-item-next.carousel-item-left,
+  .carousel-item-prev.carousel-item-right {
+    opacity: 1;
+  }
+
+  .active.carousel-item-left,
+  .active.carousel-item-right {
+    opacity: 0;
+  }
+
+  .carousel-item-next,
+  .carousel-item-prev,
+  .carousel-item.active,
+  .active.carousel-item-left,
+  .active.carousel-item-prev {
+    transform: translateX(0);
+
+    @supports (transform-style: preserve-3d) {
+      transform: translate3d(0, 0, 0);
+    }
+  }
+}
+
+
+//
+// Left/right controls for nav
+//
+
+.carousel-control-prev,
+.carousel-control-next {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  // Use flex for alignment (1-3)
+  display: flex; // 1. allow flex styles
+  align-items: center; // 2. vertically center contents
+  justify-content: center; // 3. horizontally center contents
+  width: $carousel-control-width;
+  color: $carousel-control-color;
+  text-align: center;
+  opacity: $carousel-control-opacity;
+  // We can't have a transition here because WebKit cancels the carousel
+  // animation if you trip this while in the middle of another animation.
+
+  // Hover/focus state
+  @include hover-focus {
+    color: $carousel-control-color;
+    text-decoration: none;
+    outline: 0;
+    opacity: .9;
+  }
+}
+.carousel-control-prev {
+  left: 0;
+  @if $enable-gradients {
+    background: linear-gradient(90deg, rgba($black, .25), rgba($black, .001));
+  }
+}
+.carousel-control-next {
+  right: 0;
+  @if $enable-gradients {
+    background: linear-gradient(270deg, rgba($black, .25), rgba($black, .001));
+  }
+}
+
+// Icons for within
+.carousel-control-prev-icon,
+.carousel-control-next-icon {
+  display: inline-block;
+  width: $carousel-control-icon-width;
+  height: $carousel-control-icon-width;
+  background: transparent no-repeat center center;
+  background-size: 100% 100%;
+}
+.carousel-control-prev-icon {
+  background-image: $carousel-control-prev-icon-bg;
+}
+.carousel-control-next-icon {
+  background-image: $carousel-control-next-icon-bg;
+}
+
+
+// Optional indicator pips
+//
+// Add an ordered list with the following class and add a list item for each
+// slide your carousel holds.
+
+.carousel-indicators {
+  position: absolute;
+  right: 0;
+  bottom: 10px;
+  left: 0;
+  z-index: 15;
+  display: flex;
+  justify-content: center;
+  padding-left: 0; // override <ol> default
+  // Use the .carousel-control's width as margin so we don't overlay those
+  margin-right: $carousel-control-width;
+  margin-left: $carousel-control-width;
+  list-style: none;
+
+  li {
+    position: relative;
+    flex: 0 1 auto;
+    width: $carousel-indicator-width;
+    height: $carousel-indicator-height;
+    margin-right: $carousel-indicator-spacer;
+    margin-left: $carousel-indicator-spacer;
+    text-indent: -999px;
+    cursor: pointer;
+    background-color: rgba($carousel-indicator-active-bg, .5);
+
+    // Use pseudo classes to increase the hit area by 10px on top and bottom.
+    &::before {
+      position: absolute;
+      top: -10px;
+      left: 0;
+      display: inline-block;
+      width: 100%;
+      height: 10px;
+      content: "";
+    }
+    &::after {
+      position: absolute;
+      bottom: -10px;
+      left: 0;
+      display: inline-block;
+      width: 100%;
+      height: 10px;
+      content: "";
+    }
+  }
+
+  .active {
+    background-color: $carousel-indicator-active-bg;
+  }
+}
+
+
+// Optional captions
+//
+//
+
+.carousel-caption {
+  position: absolute;
+  right: ((100% - $carousel-caption-width) / 2);
+  bottom: 20px;
+  left: ((100% - $carousel-caption-width) / 2);
+  z-index: 10;
+  padding-top: 20px;
+  padding-bottom: 20px;
+  color: $carousel-caption-color;
+  text-align: center;
+}

+ 35 - 34
src/client/styles/bootstrap4/_close.scss

@@ -1,34 +1,35 @@
-.close {
-  float: right;
-  font-size: $close-font-size;
-  font-weight: $close-font-weight;
-  line-height: 1;
-  color: $close-color;
-  text-shadow: $close-text-shadow;
-  opacity: .5;
-
-  @include hover-focus {
-    color: $close-color;
-    text-decoration: none;
-    opacity: .75;
-  }
-
-  // Opinionated: add "hand" cursor to non-disabled .close elements
-  &:not(:disabled):not(.disabled) {
-    cursor: pointer;
-  }
-}
-
-// Additional properties for button version
-// iOS requires the button element instead of an anchor tag.
-// If you want the anchor version, it requires `href="#"`.
-// See https://developer.mozilla.org/en-US/docs/Web/Events/click#Safari_Mobile
-
-// stylelint-disable property-no-vendor-prefix, selector-no-qualifying-type
-button.close {
-  padding: 0;
-  background-color: transparent;
-  border: 0;
-  -webkit-appearance: none;
-}
-// stylelint-enable
+.close {
+  float: right;
+  font-size: $close-font-size;
+  font-weight: $close-font-weight;
+  line-height: 1;
+  color: $close-color;
+  text-shadow: $close-text-shadow;
+  opacity: .5;
+
+  &:not(:disabled):not(.disabled) {
+
+    @include hover-focus {
+      color: $close-color;
+      text-decoration: none;
+      opacity: .75;
+    }
+
+    // Opinionated: add "hand" cursor to non-disabled .close elements
+    cursor: pointer;
+  }
+}
+
+// Additional properties for button version
+// iOS requires the button element instead of an anchor tag.
+// If you want the anchor version, it requires `href="#"`.
+// See https://developer.mozilla.org/en-US/docs/Web/Events/click#Safari_Mobile
+
+// stylelint-disable property-no-vendor-prefix, selector-no-qualifying-type
+button.close {
+  padding: 0;
+  background-color: transparent;
+  border: 0;
+  -webkit-appearance: none;
+}
+// stylelint-enable

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