Browse Source

Merge pull request #904 from weseek/master

release v3.4.3
Yuki Takei 7 years ago
parent
commit
c1cf7aef1d
100 changed files with 2235 additions and 1670 deletions
  1. 1 0
      .eslintignore
  2. 34 85
      .eslintrc.js
  3. 1 0
      .prettierignore
  4. 5 0
      .prettierrc
  5. 19 0
      .stylelintrc.json
  6. 2 2
      .vscode/extensions.json
  7. 12 0
      .vscode/settings.json
  8. 21 4
      CHANGES.md
  9. 8 0
      README.md
  10. 1 1
      bin/download-cdn-resources.js
  11. 2 2
      bin/generate-plugin-definitions-source.js
  12. 5 4
      bin/shrink-emojione-strategy.js
  13. 6 2
      config/logger/config.dev.js
  14. 6 6
      config/migrate.js
  15. 22 22
      config/webpack.common.js
  16. 3 5
      config/webpack.dev.dll.js
  17. 8 8
      config/webpack.dev.js
  18. 21 15
      config/webpack.prod.js
  19. 26 22
      package.json
  20. 12 0
      public/images/themes/antarctic/bg.svg
  21. 2 0
      public/images/themes/antarctic/topimage.svg
  22. 36 36
      resource/cdn-manifests.js
  23. 3 0
      resource/search/mappings.json
  24. 129 92
      src/client/js/app.js
  25. 7 3
      src/client/js/components/Admin/AdminRebuildSearch.jsx
  26. 6 9
      src/client/js/components/Admin/CustomCssEditor.js
  27. 6 9
      src/client/js/components/Admin/CustomHeaderEditor.js
  28. 6 9
      src/client/js/components/Admin/CustomScriptEditor.js
  29. 24 18
      src/client/js/components/BookmarkButton.jsx
  30. 1 2
      src/client/js/components/Common/UserDate.js
  31. 6 6
      src/client/js/components/Common/UserPictureList.jsx
  32. 15 7
      src/client/js/components/CopyButton.js
  33. 16 14
      src/client/js/components/HeaderSearchBox.jsx
  34. 63 22
      src/client/js/components/InstallerForm.jsx
  35. 16 10
      src/client/js/components/LikeButton.jsx
  36. 20 12
      src/client/js/components/Page.jsx
  37. 4 3
      src/client/js/components/Page/PagePath.js
  38. 6 4
      src/client/js/components/Page/RevisionBody.js
  39. 13 9
      src/client/js/components/Page/RevisionLoader.jsx
  40. 23 16
      src/client/js/components/Page/RevisionPath.js
  41. 15 14
      src/client/js/components/Page/RevisionRenderer.jsx
  42. 12 6
      src/client/js/components/Page/RevisionUrl.js
  43. 107 0
      src/client/js/components/Page/TagViewer.jsx
  44. 16 11
      src/client/js/components/PageAttachment.js
  45. 10 5
      src/client/js/components/PageAttachment/Attachment.js
  46. 11 5
      src/client/js/components/PageAttachment/DeleteAttachmentModal.js
  47. 7 6
      src/client/js/components/PageAttachment/PageAttachmentList.js
  48. 22 20
      src/client/js/components/PageComment/Comment.js
  49. 131 103
      src/client/js/components/PageComment/CommentForm.jsx
  50. 7 8
      src/client/js/components/PageComment/CommentPreview.js
  51. 2 6
      src/client/js/components/PageComment/DeleteCommentModal.js
  52. 34 26
      src/client/js/components/PageComments.js
  53. 33 37
      src/client/js/components/PageEditor.js
  54. 4 2
      src/client/js/components/PageEditor/AbstractEditor.js
  55. 22 18
      src/client/js/components/PageEditor/Cheatsheet.js
  56. 174 120
      src/client/js/components/PageEditor/CodeMirrorEditor.js
  57. 92 67
      src/client/js/components/PageEditor/Editor.jsx
  58. 17 17
      src/client/js/components/PageEditor/EmojiAutoCompleteHelper.js
  59. 57 48
      src/client/js/components/PageEditor/HandsontableModal.jsx
  60. 1 1
      src/client/js/components/PageEditor/MarkdownListUtil.js
  61. 19 10
      src/client/js/components/PageEditor/MarkdownTableDataImportForm.jsx
  62. 4 7
      src/client/js/components/PageEditor/MarkdownTableInterceptor.js
  63. 13 13
      src/client/js/components/PageEditor/MarkdownTableUtil.js
  64. 70 44
      src/client/js/components/PageEditor/OptionsSelector.js
  65. 3 13
      src/client/js/components/PageEditor/PasteHelper.js
  66. 5 7
      src/client/js/components/PageEditor/PreventMarkdownListInterceptor.js
  67. 8 9
      src/client/js/components/PageEditor/Preview.js
  68. 38 36
      src/client/js/components/PageEditor/ScrollSyncHelper.js
  69. 12 10
      src/client/js/components/PageEditor/SimpleCheatsheet.js
  70. 23 18
      src/client/js/components/PageEditor/TextAreaEditor.js
  71. 47 21
      src/client/js/components/PageEditorByHackmd.jsx
  72. 15 11
      src/client/js/components/PageEditorByHackmd/HackmdEditor.jsx
  73. 64 59
      src/client/js/components/PageHistory.js
  74. 24 18
      src/client/js/components/PageHistory/PageRevisionList.jsx
  75. 16 14
      src/client/js/components/PageHistory/Revision.jsx
  76. 8 5
      src/client/js/components/PageHistory/RevisionDiff.js
  77. 3 3
      src/client/js/components/PageList/ListView.js
  78. 3 4
      src/client/js/components/PageList/Page.js
  79. 2 3
      src/client/js/components/PageList/PageListMeta.js
  80. 4 6
      src/client/js/components/PageList/PagePath.js
  81. 7 5
      src/client/js/components/PagePathAutoComplete.jsx
  82. 14 12
      src/client/js/components/PageStatusAlert.jsx
  83. 37 30
      src/client/js/components/PageTagForm.jsx
  84. 6 6
      src/client/js/components/ReactUtils.js
  85. 30 27
      src/client/js/components/RecentCreated/RecentCreated.jsx
  86. 46 30
      src/client/js/components/SavePageControls.jsx
  87. 78 45
      src/client/js/components/SavePageControls/GrantSelector.jsx
  88. 5 4
      src/client/js/components/SearchForm.js
  89. 33 34
      src/client/js/components/SearchPage.js
  90. 4 8
      src/client/js/components/SearchPage/DeletePageListModal.js
  91. 23 19
      src/client/js/components/SearchPage/SearchPageForm.js
  92. 88 70
      src/client/js/components/SearchPage/SearchResult.js
  93. 3 4
      src/client/js/components/SearchPage/SearchResultList.js
  94. 25 27
      src/client/js/components/SearchTypeahead.js
  95. 14 12
      src/client/js/components/SlackNotification.jsx
  96. 8 9
      src/client/js/components/User/User.js
  97. 9 9
      src/client/js/components/User/UserPicture.js
  98. 25 23
      src/client/js/hackmd-agent.js
  99. 1 1
      src/client/js/hackmd-styles.js
  100. 7 5
      src/client/js/i18n.js

+ 1 - 0
.eslintignore

@@ -3,5 +3,6 @@
 /node_modules/**
 /public/**
 /src/client/js/legacy/thirdparty-js/**
+/src/client/js/util/reveal/plugins/markdown.js
 /test/**
 /tmp/**

+ 34 - 85
.eslintrc.js

@@ -1,93 +1,42 @@
 module.exports = {
-  "env": {
-    "browser": true,
-    "commonjs": true,
-    "es6": true,
-    "node": true
-  },
-  "extends": [
-    "eslint:recommended",
-    "plugin:react/recommended"
+  extends: [
+    'weseek',
+    'weseek/react',
   ],
-  "globals": {
-    "$": true,
-    "jquery": true,
-    "emojione": true,
-    "hljs": true,
-    "window": true
+  env: {
+    mocha: true,
+    jquery: true,
   },
-  "parserOptions": {
-    "ecmaVersion": 8,
-    "ecmaFeatures": {
-      "experimentalObjectRestSpread": true,
-      "jsx": true
-    },
-    "sourceType": "module"
+  globals: {
+    $: true,
+    jquery: true,
+    emojione: true,
+    hljs: true,
+    window: true,
   },
-  "plugins": [
-    "react"
+  plugins: [
+    'chai-friendly',
   ],
-  "rules": {
-    "brace-style": [
-      "error",
-      "stroustrup", { "allowSingleLine": true }
-    ],
-    "comma-spacing": [
-      "error",
-      { "before": false, "after": true }
-    ],
-    "func-call-spacing": [
-      "error",
-      "never"
-    ],
-    "indent": [
-      "error",
+  rules: {
+    'indent': [
+      'error',
       2,
       {
-        "SwitchCase": 1,
-        "ignoredNodes": ['JSXElement *', 'JSXElement', "JSXAttribute", "JSXSpreadAttribute"],
-        "FunctionDeclaration": {"body": 1, "parameters": 2},
-        "FunctionExpression": {"body": 1, "parameters": 2},
-        "MemberExpression": "off"
-      }
-    ],
-    "key-spacing": [
-      "error", {
-        "beforeColon": false,
-        "afterColon": true,
-        "mode": "minimum"
-      }
-    ],
-    "keyword-spacing": [
-      "error", {}
-    ],
-    "linebreak-style": [
-      "error",
-      "unix"
-    ],
-    "no-unused-vars": [
-      "error",
-      { "args": "none" }
-    ],
-    "no-var": [ "error" ],
-    "quotes": [
-      "error",
-      "single"
-    ],
-    "react/jsx-uses-vars": 1,
-    "react/no-string-refs": "off",
-    "semi": [
-      "error",
-      "always",
-      { "omitLastInOneLineBlock": true }
-    ],
-    "space-before-blocks": [
-      "error",
-      "always"
-    ],
-    "space-before-function-paren": [
-      "error",
-      "never"
-    ]
-  }
+        SwitchCase: 1,
+        ignoredNodes: ['JSXElement *', 'JSXElement', 'JSXAttribute', 'JSXSpreadAttribute'],
+        ArrayExpression: 'first',
+        FunctionDeclaration: { body: 1, parameters: 2 },
+        FunctionExpression: { body: 1, parameters: 2 },
+      },
+    ],
+    'react/jsx-filename-extension': [
+      'warn',
+      { extensions: ['.jsx']},
+    ],
+    // 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,
+  },
 };

+ 1 - 0
.prettierignore

@@ -0,0 +1 @@
+src/client/styles/scss/_override-bootstrap-variables.scss

+ 5 - 0
.prettierrc

@@ -0,0 +1,5 @@
+{
+  "printWidth": 160,
+  "singleQuote": true,
+  "trailingComma": "all"
+}

+ 19 - 0
.stylelintrc.json

@@ -0,0 +1,19 @@
+{
+  "extends": [
+    "stylelint-config-recess-order",
+    "./node_modules/prettier-stylelint/config.js"
+  ],
+  "ignoreFiles": [
+    "src/client/styles/scss/_override-bootstrap-variables.scss"
+  ],
+  "rules": {
+    "indentation": 2,
+    "string-quotes": "single",
+    "rule-empty-line-before": [ "always-multi-line", {
+      "except": ["after-single-line-comment", "first-nested"],
+      "ignore": ["after-comment", "inside-block"]
+    } ],
+    "selector-combinator-space-before": "always",
+    "selector-combinator-space-after": "always"
+  }
+}

+ 2 - 2
.vscode/extensions.json

@@ -4,16 +4,16 @@
 
 	// List of extensions which should be recommended for users of this workspace.
 	"recommendations": [
-    "hookyqr.beautify",
     "msjsdiag.debugger-for-chrome",
     "hbenl.vscode-firefox-debug",
     "editorconfig.editorconfig",
     "dbaeumer.vscode-eslint",
     "eg2.vscode-npm-script",
     "christian-kohler.npm-intellisense",
+    "esbenp.prettier-vscode",
 	],
 	// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
 	"unwantedRecommendations": [
-
+    "hookyqr.beautify",
 	]
 }

+ 12 - 0
.vscode/settings.json

@@ -4,5 +4,17 @@
   // 指定した構文に対してプロファイルを定義するか、特定の規則がある独自のプロファイルをご使用ください。
   "emmet.syntaxProfiles": {
     "javascript": "jsx"
+  },
+
+  // for vscode-eslint
+  "eslint.autoFixOnSave": true,
+  "[javascript]": {
+    "editor.formatOnSave": false
+  },
+
+  // for prettier-vecode + prettier-stylelint
+  "prettier.stylelintIntegration": true,
+  "[scss]": {
+    "editor.formatOnSave": true
   }
 }

+ 21 - 4
CHANGES.md

@@ -1,9 +1,26 @@
-CHANGES
-========
+# CHANGES
 
-## 3.4.2-RC
+## 3.4.3-RC
 
-* 
+* Improvement: Add 'antarctic' theme
+* Support Apply eslint-config-airbnb based rules
+* Support Apply prettier and stylelint
+* Support: Upgrade libs
+    * csrf
+    * escape-string-regexp
+    * eslint
+    * express-session
+    * googleapis
+    * growi-commons
+    * i18next
+    * nodemailer
+    * react-i18next
+    * string-width
+
+## 3.4.2
+
+* Fix: Nofitication to Slack doesn't work
+    * Introduced by 3.4.0
 
 ## 3.4.1
 

+ 8 - 0
README.md

@@ -168,7 +168,9 @@ Environment Variables
       * `mongodb` : MongoDB GridFS (Setting-less)
       * `local` : Server's Local file system (Setting-less)
       * `none` : Disable file uploading
+    * MAX_FILE_SIZE: The maximum file size limit for uploads (bytes). default: `Infinity`
     * MONGO_GRIDFS_TOTAL_LIMIT: Total capacity limit of MongoDB GridFS (bytes). default: `Infinity`
+    * SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: If `true`, the system uses only the value of the environment variable as the value of the SAML option that can be set via the environment variable.
 * **Option to integrate with external systems**
     * HACKMD_URI: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server.
         * **This server must load the GROWI agent. [Here's how to prepare it](https://docs.growi.org/guide/admin-cookbook/integrate-with-hackmd.html).**
@@ -183,8 +185,14 @@ Environment Variables
     * OAUTH_GITHUB_CLIENT_SECRET: GitHub API client secret for OAuth login.
     * OAUTH_TWITTER_CONSUMER_KEY: Twitter consumer key(API key) for OAuth login.
     * OAUTH_TWITTER_CONSUMER_SECRET: Twitter consumer secret(API secret) for OAuth login.
+    * SAML_ENABLED: Enable or disable SAML
     * SAML_ENTRY_POINT: IdP entry point
     * SAML_ISSUER: Issuer string to supply to IdP
+    * SAML_ATTR_MAPPING_ID: Attribute map for id
+    * SAML_ATTR_MAPPING_USERNAME: Attribute map for username
+    * SAML_ATTR_MAPPING_MAIL: Attribute map for email
+    * SAML_ATTR_MAPPING_FIRST_NAME: Attribute map for first name
+    * SAML_ATTR_MAPPING_LAST_NAME:  Attribute map for last name
     * SAML_CERT: PEM-encoded X.509 signing certificate string to validate the response from IdP
 
 

+ 1 - 1
bin/download-cdn-resources.js

@@ -27,6 +27,6 @@ service.downloadAndWriteAll(downloader)
   .then(() => {
     logger.info('Download is terminated successfully');
   })
-  .catch(err => {
+  .catch((err) => {
     logger.error(err);
   });

+ 2 - 2
bin/generate-plugin-definitions-source.js

@@ -11,6 +11,7 @@ const swig = require('swig-templates');
 
 const helpers = require('@commons/util/helpers');
 const PluginUtils = require('../src/server/plugins/plugin-utils');
+
 const pluginUtils = new PluginUtils();
 
 const TEMPLATE = helpers.root('bin/templates/plugin-definitions.js.swig');
@@ -23,7 +24,6 @@ let pluginNames = pluginUtils.listPluginNames(helpers.root());
 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(',');
 
   // merge and remove duplicates
@@ -48,7 +48,7 @@ const definitions = pluginNames
   });
 
 const compiledTemplate = swig.compileFile(TEMPLATE);
-const code = compiledTemplate({definitions});
+const code = compiledTemplate({ definitions });
 
 // write
 fs.writeFileSync(OUT, code);

+ 5 - 4
bin/shrink-emojione-strategy.js

@@ -11,21 +11,22 @@ const helpers = require('@commons/util/helpers');
 
 const emojiStrategy = require('emojione/emoji_strategy.json');
 const markdownItEmojiFull = require('markdown-it-emoji/lib/data/full.json');
+
 const OUT = helpers.root('tmp/emoji_strategy_shrinked.json');
 
-let shrinkedMap = {};
-for (let unicode in emojiStrategy) {
+const shrinkedMap = {};
+Object.keys(emojiStrategy).forEach((unicode) => {
   const data = emojiStrategy[unicode];
   const shortname = data.shortname.replace(/:/g, '');
 
   // ignore if it isn't included in markdownItEmojiFull
   if (markdownItEmojiFull[shortname] == null) {
-    continue;
+    return;
   }
 
   // add
   shrinkedMap[unicode] = data;
-}
+});
 
 // write
 fs.writeFileSync(OUT, JSON.stringify(shrinkedMap));

+ 6 - 2
config/logger/config.dev.js

@@ -1,7 +1,9 @@
 module.exports = {
   default: 'info',
 
-  //// configure level for server
+  /*
+   * configure level for server
+   */
   // 'express:*': 'debug',
   // 'growi:*': 'debug',
   'growi:crowi': 'debug',
@@ -21,6 +23,8 @@ module.exports = {
   // email
   // 'growi:lib:mailer': 'debug',
 
-  //// configure level for client
+  /*
+   * configure level for client
+   */
   'growi:app': 'debug',
 };

+ 6 - 6
config/migrate.js

@@ -6,11 +6,11 @@
  */
 
 function getMongoUri(env) {
-  return env.MONGOLAB_URI || // for B.C.
-    env.MONGODB_URI || // MONGOLAB changes their env name
-    env.MONGOHQ_URL ||
-    env.MONGO_URI ||
-    ((env.NODE_ENV === 'test') ? 'mongodb://localhost/growi_test' : 'mongodb://localhost/growi');
+  return env.MONGOLAB_URI // for B.C.
+    || env.MONGODB_URI // MONGOLAB changes their env name
+    || env.MONGOHQ_URL
+    || env.MONGO_URI
+    || ((env.NODE_ENV === 'test') ? 'mongodb://localhost/growi_test' : 'mongodb://localhost/growi');
 }
 
 const mongoUri = getMongoUri(process.env);
@@ -25,5 +25,5 @@ module.exports = {
     },
   },
   migrationsDir: 'src/migrations/',
-  changelogCollectionName: 'migrations'
+  changelogCollectionName: 'migrations',
 };

+ 22 - 22
config/webpack.common.js

@@ -2,13 +2,13 @@
  * @author: Yuki Takei <yuki@weseek.co.jp>
  */
 const webpack = require('webpack');
-const helpers = require('../src/lib/util/helpers');
 
 /*
  * Webpack Plugins
  */
 const WebpackAssetsManifest = require('webpack-assets-manifest');
 const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');
+const helpers = require('../src/lib/util/helpers');
 
 /*
  * Webpack configuration
@@ -43,9 +43,10 @@ module.exports = (options) => {
       'styles/theme-wood':          './src/client/styles/scss/theme/wood.scss',
       'styles/theme-christmas':          './src/client/styles/scss/theme/christmas.scss',
       'styles/theme-island':      './src/client/styles/scss/theme/island.scss',
+      'styles/theme-antarctic':      './src/client/styles/scss/theme/antarctic.scss',
       // styles for external services
       'styles/style-hackmd':          './src/client/styles/hackmd/style.scss',
-    }, options.entry || {}),  // Merge with env dependent settings
+    }, options.entry || {}), // Merge with env dependent settings
     output: Object.assign({
       path: helpers.root('public'),
       publicPath: '/',
@@ -54,9 +55,9 @@ module.exports = (options) => {
     externals: {
       // require("jquery") is external and available
       //  on the global var jQuery
-      'jquery': 'jQuery',
-      'emojione': 'emojione',
-      'hljs': 'hljs',
+      jquery: 'jQuery',
+      emojione: 'emojione',
+      hljs: 'hljs',
     },
     resolve: {
       extensions: ['.js', '.jsx', '.json'],
@@ -69,8 +70,8 @@ module.exports = (options) => {
         '@alias/logger': helpers.root('src/lib/service/logger'),
         '@alias/locales': helpers.root('resource/locales'),
         // replace bunyan
-        'bunyan': 'browser-bunyan',
-      }
+        bunyan: 'browser-bunyan',
+      },
     },
     module: {
       rules: options.module.rules.concat([
@@ -78,27 +79,26 @@ module.exports = (options) => {
           test: /.jsx?$/,
           exclude: {
             test:    helpers.root('node_modules'),
-            exclude: [  // include as a result
+            exclude: [ // include as a result
               { test: helpers.root('node_modules', 'growi-plugin-') },
+              helpers.root('node_modules/growi-commons'),
               helpers.root('node_modules/codemirror/src'),
-              helpers.root('node_modules/string-width'),
-              helpers.root('node_modules/is-fullwidth-code-point'), // depends from string-width
-            ]
+            ],
           },
           use: [{
-            loader: 'babel-loader?cacheDirectory'
-          }]
+            loader: 'babel-loader?cacheDirectory',
+          }],
         },
         {
           test: /locales/,
           loader: '@alienfast/i18next-loader',
           options: {
             basenameAsNamespace: true,
-          }
+          },
         },
         { // see https://github.com/abpetkov/switchery/issues/120
           test: /switchery\.js$/,
-          loader: 'imports-loader?module=>false,exports=>false,define=>false,this=>window'
+          loader: 'imports-loader?module=>false,exports=>false,define=>false,this=>window',
         },
         /*
          * File loader for supporting images, for example, in CSS files.
@@ -112,8 +112,8 @@ module.exports = (options) => {
         {
           test: /\.(eot|woff2?|svg|ttf)([?]?.*)$/,
           use: 'null-loader',
-        }
-      ])
+        },
+      ]),
     },
     plugins: options.plugins.concat([
 
@@ -127,7 +127,7 @@ module.exports = (options) => {
       new webpack.IgnorePlugin(/^\.\/lib\/deflate\.js/, /markdown-it-plantuml/),
 
       new LodashModuleReplacementPlugin({
-        flattening: true
+        flattening: true,
       }),
 
       new webpack.ProvidePlugin({ // refs externals
@@ -152,7 +152,7 @@ module.exports = (options) => {
             name: 'styles/style-commons',
             minSize: 1,
             priority: 30,
-            enforce: true
+            enforce: true,
           },
           commons: {
             test: /src[\\/].*\.jsx?$/,
@@ -160,7 +160,7 @@ module.exports = (options) => {
             name: 'js/commons',
             minChunks: 2,
             minSize: 1,
-            priority: 20
+            priority: 20,
           },
           vendors: {
             test: /node_modules[\\/].*\.jsx?$/,
@@ -171,9 +171,9 @@ module.exports = (options) => {
             name: 'js/vendors',
             minSize: 1,
             priority: 10,
-            enforce: true
+            enforce: true,
           },
-        }
+        },
       },
       minimizer: options.optimization.minimizer || [],
     },

+ 3 - 5
config/webpack.dev.dll.js

@@ -28,9 +28,7 @@ module.exports = {
       'socket.io-client',
       'toastr',
       'xss',
-      // GROWI Libraries
-      'growi-pluginkit',
-    ]
+    ],
   },
   output: {
     path: helpers.root('public/dll'),
@@ -44,7 +42,7 @@ module.exports = {
   plugins: [
     new webpack.DllPlugin({
       path: helpers.root('public/dll/manifest.json'),
-      name: 'growi_dlls'
-    })
+      name: 'growi_dlls',
+    }),
   ],
 };

+ 8 - 8
config/webpack.dev.js

@@ -3,18 +3,18 @@
  */
 
 const webpack = require('webpack');
-const helpers = require('../src/lib/util/helpers');
 
 /*
  * Webpack Plugins
  */
 const MiniCssExtractPlugin = require('mini-css-extract-plugin');
-const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
+const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
+const helpers = require('../src/lib/util/helpers');
 
 /**
  * Webpack Constants
  */
-const ANALYZE = process.env.ANALYZE;
+const { ANALYZE } = process.env;
 
 module.exports = require('./webpack.common')({
   mode: 'development',
@@ -37,19 +37,19 @@ module.exports = require('./webpack.common')({
         exclude: [
           helpers.root('src/client/styles/hackmd'),
           helpers.root('src/client/styles/scss/style-presentation.scss'),
-        ]
+        ],
       },
       { // Dump CSS for HackMD
         test: /\.(css|scss)$/,
         use: [
           MiniCssExtractPlugin.loader,
           'css-loader',
-          'sass-loader'
+          'sass-loader',
         ],
         include: [
           helpers.root('src/client/styles/hackmd'),
           helpers.root('src/client/styles/scss/style-presentation.scss'),
-        ]
+        ],
       },
     ],
   },
@@ -71,7 +71,7 @@ module.exports = require('./webpack.common')({
   ],
   optimization: {},
   performance: {
-    hints: false
-  }
+    hints: false,
+  },
 
 });

+ 21 - 15
config/webpack.prod.js

@@ -1,7 +1,6 @@
 /**
  * @author: Yuki Takei <yuki@weseek.co.jp>
  */
-const helpers = require('../src/lib/util/helpers');
 
 /**
  * Webpack Plugins
@@ -9,19 +8,21 @@ const helpers = require('../src/lib/util/helpers');
 const TerserPlugin = require('terser-webpack-plugin');
 const MiniCssExtractPlugin = require('mini-css-extract-plugin');
 const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
-const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
+const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
+
+const helpers = require('../src/lib/util/helpers');
 
 /**
  * Webpack Constants
  */
-const ANALYZE = process.env.ANALYZE;
+const { ANALYZE } = process.env;
 
 module.exports = require('./webpack.common')({
   mode: 'production',
   devtool: undefined,
   output: {
     filename: '[name].[chunkhash].bundle.js',
-    chunkFilename: '[name].[chunkhash].chunk.js'
+    chunkFilename: '[name].[chunkhash].chunk.js',
   },
   module: {
     rules: [
@@ -30,22 +31,27 @@ module.exports = require('./webpack.common')({
         use: [
           MiniCssExtractPlugin.loader,
           'css-loader',
-          { loader: 'postcss-loader', options: {
-            sourceMap: false,
-            plugins: (loader) => [
-              require('autoprefixer')()
-            ]
-          } },
-          'sass-loader'
+          {
+            loader: 'postcss-loader',
+            options: {
+              sourceMap: false,
+              plugins: (loader) => {
+                return [
+                  require('autoprefixer')(),
+                ];
+              },
+            },
+          },
+          'sass-loader',
         ],
-        exclude: [helpers.root('src/client/js/legacy')]
+        exclude: [helpers.root('src/client/js/legacy')],
       },
       {
         test: /\.(css|scss)$/,
         use: ['style-loader', 'css-loader', 'sass-loader'],
-        include: [helpers.root('src/client/js/legacy')]
+        include: [helpers.root('src/client/js/legacy')],
       },
-    ]
+    ],
   },
   plugins: [
 
@@ -63,7 +69,7 @@ module.exports = require('./webpack.common')({
   optimization: {
     minimizer: [
       new TerserPlugin({}),
-      new OptimizeCSSAssetsPlugin({})
+      new OptimizeCSSAssetsPlugin({}),
     ],
   },
 });

+ 26 - 22
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.4.2-RC",
+  "version": "3.4.3-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -32,8 +32,11 @@
     "clean:report": "rimraf -- report",
     "clean": "npm-run-all -p clean:*",
     "heroku-postbuild": "sh bin/heroku/install-plugins.sh && npm run build:prod",
-    "lint:fix": "eslint . --fix",
-    "lint": "eslint .",
+    "lint:js:fix": "eslint **/*.{js,jsx} --fix",
+    "lint:js": "eslint **/*.{js,jsx}",
+    "lint:styles:fix": "prettier-stylelint --quiet --write src/client/styles/scss/**/*.scss",
+    "lint:styles": "stylelint src/client/styles/scss/**/*.scss",
+    "lint": "npm-run-all -p lint:js lint:styles",
     "migrate": "npm run migrate:up",
     "migrate:create": "migrate-mongo create -f config/migrate.js -- ",
     "migrate:status": "migrate-mongo status -f config/migrate.js",
@@ -70,24 +73,24 @@
     "connect-redis": "^3.3.0",
     "cookie-parser": "^1.4.3",
     "cross-env": "^5.0.5",
-    "csrf": "~3.0.3",
+    "csrf": "^3.1.0",
     "diff": "^4.0.1",
     "elasticsearch": "^15.0.0",
     "entities": "^1.1.1",
     "env-cmd": "^8.0.1",
     "esa-nodejs": "^0.0.7",
-    "escape-string-regexp": "^1.0.5",
+    "escape-string-regexp": "^2.0.0",
     "express": "^4.16.1",
     "express-bunyan-logger": "^1.3.3",
     "express-form": "~0.12.0",
     "express-sanitizer": "^1.0.4",
-    "express-session": "~1.15.0",
+    "express-session": "^1.16.1",
     "express-webpack-assets": "^0.1.0",
-    "googleapis": "^37.0.0",
+    "googleapis": "^39.1.0",
     "graceful-fs": "^4.1.11",
-    "growi-pluginkit": "^1.1.0",
+    "growi-commons": "^3.2.2",
     "helmet": "^3.13.0",
-    "i18next": "=12.1.0",
+    "i18next": "^15.0.9",
     "i18next-express-middleware": "^1.4.1",
     "i18next-node-fs-backend": "^2.1.0",
     "i18next-sprintf-postprocessor": "^0.2.2",
@@ -102,7 +105,7 @@
     "mongoose-unique-validator": "^2.0.2",
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
-    "nodemailer": "^5.1.1",
+    "nodemailer": "^6.0.0",
     "nodemailer-ses-transport": "~1.5.0",
     "npm-run-all": "^4.1.2",
     "passport": "^0.4.0",
@@ -112,11 +115,12 @@
     "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",
     "stream-to-promise": "^2.2.0",
-    "string-width": "^3.0.0",
+    "string-width": "^4.1.0",
     "swig-templates": "^2.0.2",
     "uglifycss": "^0.0.29",
     "url-join": "^4.0.0",
@@ -127,6 +131,7 @@
     "@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",
@@ -149,8 +154,11 @@
     "date-fns": "^1.29.0",
     "diff2html": "^2.3.3",
     "eazy-logger": "^3.0.2",
-    "eslint": "^5.0.0",
-    "eslint-plugin-react": "^7.7.0",
+    "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-react": "^7.12.4",
     "file-loader": "^3.0.1",
     "handsontable": "^6.0.1",
     "i18next-browser-languagedetector": "^3.0.1",
@@ -171,7 +179,7 @@
     "markdown-it-toc-and-anchor-with-slugid": "^1.1.4",
     "markdown-table": "^1.1.1",
     "metismenu": "^3.0.3",
-    "mini-css-extract-plugin": "^0.5.0",
+    "mini-css-extract-plugin": "^0.6.0",
     "mocha": "^6.0.1",
     "morgan": "^1.9.0",
     "node-dev": "^3.1.3",
@@ -181,18 +189,18 @@
     "null-loader": "^0.1.1",
     "on-headers": "^1.0.1",
     "optimize-css-assets-webpack-plugin": "^5.0.0",
-    "penpal": "^3.0.3",
+    "penpal": "^4.0.0",
     "plantuml-encoder": "^1.2.5",
     "postcss-loader": "^3.0.0",
+    "prettier-stylelint": "^0.4.2",
     "react": "^16.8.3",
     "react-bootstrap": "^0.32.1",
     "react-bootstrap-typeahead": "^3.3.4",
     "react-clipboard.js": "^2.0.0",
     "react-codemirror2": "^5.1.0",
     "react-dom": "^16.8.3",
-    "react-dropzone": "=7.0.1",
     "react-frame-component": "^4.0.0",
-    "react-i18next": "=7.13.0",
+    "react-i18next": "^10.6.1",
     "react-waypoint": "^9.0.0",
     "replacestream": "^4.0.3",
     "reveal.js": "^3.5.0",
@@ -202,6 +210,7 @@
     "sinon-chai": "^3.3.0",
     "socket.io-client": "^2.0.3",
     "style-loader": "^0.23.0",
+    "stylelint-config-recess-order": "^2.0.1",
     "terser-webpack-plugin": "^1.2.2",
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",
@@ -222,10 +231,5 @@
     "node": ">=8.11.1 <11",
     "npm": ">=5.6.0 <7",
     "yarn": ">=1.5.1 <2"
-  },
-  "config": {
-    "blanket": {
-      "pattern": "./src/lib/**/*.js"
-    }
   }
 }

File diff suppressed because it is too large
+ 12 - 0
public/images/themes/antarctic/bg.svg


File diff suppressed because it is too large
+ 2 - 0
public/images/themes/antarctic/topimage.svg


+ 36 - 36
resource/cdn-manifests.js

@@ -6,7 +6,7 @@ module.exports = {
       groups: ['basis'],
       args: {
         integrity: '',
-      }
+      },
     },
     {
       name: 'highlight',
@@ -14,25 +14,25 @@ module.exports = {
       groups: ['basis'],
       args: {
         integrity: '',
-      }
+      },
     },
     {
       name: 'highlight-addons',
-      url: 'https://cdn.jsdelivr.net/combine/' +
-'gh/highlightjs/cdn-release@9.13.0/build/languages/dockerfile.min.js,' +
-'gh/highlightjs/cdn-release@9.13.0/build/languages/go.min.js,' +
-'gh/highlightjs/cdn-release@9.13.0/build/languages/gradle.min.js,' +
-'gh/highlightjs/cdn-release@9.13.0/build/languages/json.min.js,' +
-'gh/highlightjs/cdn-release@9.13.0/build/languages/less.min.js,' +
-'gh/highlightjs/cdn-release@9.13.0/build/languages/plaintext.min.js,' +
-'gh/highlightjs/cdn-release@9.13.0/build/languages/scss.min.js,' +
-'gh/highlightjs/cdn-release@9.13.0/build/languages/typescript.min.js,' +
-'gh/highlightjs/cdn-release@9.13.0/build/languages/yaml.min.js,' +
-'npm/highlightjs-line-numbers.js@2.6.0/dist/highlightjs-line-numbers.min.js',
+      url: 'https://cdn.jsdelivr.net/combine/'
+        + 'gh/highlightjs/cdn-release@9.13.0/build/languages/dockerfile.min.js,'
+        + 'gh/highlightjs/cdn-release@9.13.0/build/languages/go.min.js,'
+        + 'gh/highlightjs/cdn-release@9.13.0/build/languages/gradle.min.js,'
+        + 'gh/highlightjs/cdn-release@9.13.0/build/languages/json.min.js,'
+        + 'gh/highlightjs/cdn-release@9.13.0/build/languages/less.min.js,'
+        + 'gh/highlightjs/cdn-release@9.13.0/build/languages/plaintext.min.js,'
+        + 'gh/highlightjs/cdn-release@9.13.0/build/languages/scss.min.js,'
+        + 'gh/highlightjs/cdn-release@9.13.0/build/languages/typescript.min.js,'
+        + 'gh/highlightjs/cdn-release@9.13.0/build/languages/yaml.min.js,'
+        + 'npm/highlightjs-line-numbers.js@2.6.0/dist/highlightjs-line-numbers.min.js',
       args: {
         async: true,
         integrity: '',
-      }
+      },
     },
     {
       name: 'mathjax',
@@ -40,35 +40,35 @@ module.exports = {
       args: {
         async: true,
         integrity: '',
-      }
+      },
     },
     {
       name: 'codemirror-dialog',
       url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/addon/dialog/dialog.min.js',
       args: {
         integrity: '',
-      }
+      },
     },
     {
       name: 'codemirror-keymap-vim',
       url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/keymap/vim.min.js',
       args: {
         integrity: '',
-      }
+      },
     },
     {
       name: 'codemirror-keymap-emacs',
       url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/keymap/emacs.min.js',
       args: {
         integrity: '',
-      }
+      },
     },
     {
       name: 'codemirror-keymap-sublime',
       url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/keymap/sublime.min.js',
       args: {
         integrity: '',
-      }
+      },
     },
   ],
   style: [
@@ -77,7 +77,7 @@ module.exports = {
       url: 'https://fonts.googleapis.com/css?family=Lato:400,700',
       groups: ['basis'],
       args: {
-        integrity: ''
+        integrity: '',
       },
     },
     {
@@ -86,14 +86,14 @@ module.exports = {
       groups: ['basis'],
       args: {
         integrity: '',
-      }
+      },
     },
     {
       name: 'themify-icons',
       url: 'https://cdn.jsdelivr.net/npm/cd-themify-icons@0.0.1/index.min.css',
       groups: ['basis'],
       args: {
-        integrity: ''
+        integrity: '',
       },
     },
     {
@@ -101,7 +101,7 @@ module.exports = {
       url: 'https://cdn.jsdelivr.net/npm/simple-line-icons@2.4.1/css/simple-line-icons.min.css',
       groups: ['basis'],
       args: {
-        integrity: ''
+        integrity: '',
       },
     },
     {
@@ -109,21 +109,21 @@ module.exports = {
       url: 'https://cdn.jsdelivr.net/npm/emojione@3.1.2/extras/css/emojione.min.css',
       groups: ['basis'],
       args: {
-        integrity: ''
+        integrity: '',
       },
     },
     {
       name: 'jquery-ui',
       url: 'https://cdn.jsdelivr.net/jquery.ui/1.11.4/jquery-ui.min.css',
       args: {
-        integrity: ''
+        integrity: '',
       },
     },
     {
       name: 'highlight-theme-github',
       url: 'https://cdn.jsdelivr.net/npm/highlight.js@9.13.0/styles/github.css',
       args: {
-        integrity: ''
+        integrity: '',
       },
     },
     {
@@ -131,63 +131,63 @@ module.exports = {
       url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/addon/dialog/dialog.min.css',
       args: {
         integrity: '',
-      }
+      },
     },
     {
       name: 'codemirror-theme-eclipse',
       url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/theme/eclipse.min.css',
       args: {
-        integrity: ''
+        integrity: '',
       },
     },
     {
       name: 'codemirror-theme-elegant',
       url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/theme/elegant.min.css',
       args: {
-        integrity: ''
+        integrity: '',
       },
     },
     {
       name: 'codemirror-theme-neo',
       url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/theme/neo.min.css',
       args: {
-        integrity: ''
+        integrity: '',
       },
     },
     {
       name: 'codemirror-theme-mdn-like',
       url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/theme/mdn-like.min.css',
       args: {
-        integrity: ''
+        integrity: '',
       },
     },
     {
       name: 'codemirror-theme-material',
       url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/theme/material.min.css',
       args: {
-        integrity: ''
+        integrity: '',
       },
     },
     {
       name: 'codemirror-theme-dracula',
       url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/theme/dracula.min.css',
       args: {
-        integrity: ''
+        integrity: '',
       },
     },
     {
       name: 'codemirror-theme-monokai',
       url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/theme/monokai.min.css',
       args: {
-        integrity: ''
+        integrity: '',
       },
     },
     {
       name: 'codemirror-theme-twilight',
       url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/theme/twilight.min.css',
       args: {
-        integrity: ''
+        integrity: '',
       },
     },
-  ]
+  ],
 };

+ 3 - 0
resource/search/mappings.json

@@ -93,6 +93,9 @@
         "updated_at": {
           "type": "date",
           "format": "dateOptionalTime"
+        },
+        "tag_names": {
+          "type": "text"
         }
       }
     }

+ 129 - 92
src/client/js/app.js

@@ -1,45 +1,49 @@
+/* eslint-disable max-len */
+
 import React from 'react';
 import ReactDOM from 'react-dom';
 import { I18nextProvider } from 'react-i18next';
 import * as toastr from 'toastr';
 
-import i18nFactory from './i18n';
-
 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';
-import PageEditor       from './components/PageEditor';
-import OptionsSelector  from './components/PageEditor/OptionsSelector';
+import HeaderSearchBox from './components/HeaderSearchBox';
+import SearchPage from './components/SearchPage';
+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 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 Page from './components/Page';
+import PageHistory from './components/PageHistory';
+import PageComments from './components/PageComments';
 import CommentForm from './components/PageComment/CommentForm';
-import PageAttachment   from './components/PageAttachment';
-import PageStatusAlert  from './components/PageStatusAlert';
-import RevisionPath     from './components/Page/RevisionPath';
-import PageTagForm      from './components/PageTagForm';
-import RevisionUrl      from './components/Page/RevisionUrl';
-import BookmarkButton   from './components/BookmarkButton';
-import LikeButton       from './components/LikeButton';
+import PageAttachment from './components/PageAttachment';
+import PageStatusAlert from './components/PageStatusAlert';
+import RevisionPath from './components/Page/RevisionPath';
+import TagViewer from './components/Page/TagViewer';
+import RevisionUrl from './components/Page/RevisionUrl';
+import BookmarkButton from './components/BookmarkButton';
+import LikeButton from './components/LikeButton';
 import PagePathAutoComplete from './components/PagePathAutoComplete';
 import RecentCreated from './components/RecentCreated/RecentCreated';
-import UserPictureList  from './components/Common/UserPictureList';
+import UserPictureList from './components/Common/UserPictureList';
 
-import CustomCssEditor  from './components/Admin/CustomCssEditor';
+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 * as entities from 'entities';
 
 const logger = loggerFactory('growi:app');
 
@@ -65,8 +69,7 @@ let pagePath;
 let pageContent = '';
 let markdown = '';
 let slackChannels;
-let currentPageTags = '';
-let newPageTags = '';
+let pageTags = [];
 if (mainContent !== null) {
   pageId = mainContent.getAttribute('data-page-id') || null;
   pageRevisionId = mainContent.getAttribute('data-page-revision-id');
@@ -100,8 +103,8 @@ 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
+  isAutoSetup: false, // manually setup because plugins may configure it
+  renderToc: crowi.getCrowiForJquery().renderTocContent, // function for rendering Table Of Contents
 });
 window.crowiRenderer = crowiRenderer;
 
@@ -113,17 +116,17 @@ if (isEnabledPlugins) {
 }
 
 /**
- * get new tags from page tag form
- * @param {String} tags new tags [TODO] String -> Array
+ * receive tags from PageTagForm
+ * @param {Array} tagData new tags
  */
-const getNewPageTags = function(tags) {
-  newPageTags = tags;
+const setTagData = function(tagData) {
+  pageTags = tagData;
 };
 
 /**
  * component store
  */
-let componentInstances = {};
+const componentInstances = {};
 
 /**
  * save success handler when reloading is not needed
@@ -198,7 +201,7 @@ const saveWithShortcut = function(markdown) {
   // get options
   const options = componentInstances.savePageControls.getCurrentOptionsToSave();
   options.socketClientId = socketClientId;
-  options.pageTags = newPageTags;
+  options.pageTags = pageTags;
 
   if (editorMode === 'hackmd') {
     // set option to sync
@@ -207,7 +210,7 @@ const saveWithShortcut = function(markdown) {
     revisionId = componentInstances.pageEditorByHackmd.getRevisionIdHackmdSynced();
   }
 
-  let promise = undefined;
+  let promise;
   if (pageId == null) {
     promise = crowi.createPage(pagePath, markdown, options);
   }
@@ -222,7 +225,7 @@ const saveWithShortcut = function(markdown) {
 
 const saveWithSubmitButtonSuccessHandler = function() {
   crowi.clearDraft(pagePath);
-  location.href = pagePath;
+  window.location.href = pagePath;
 };
 
 const saveWithSubmitButton = function(submitOpts) {
@@ -236,12 +239,12 @@ const saveWithSubmitButton = function(submitOpts) {
   // get options
   const options = componentInstances.savePageControls.getCurrentOptionsToSave();
   options.socketClientId = socketClientId;
-  options.pageTags = newPageTags;
+  options.pageTags = pageTags;
 
   // set 'submitOpts.overwriteScopesOfDescendants' to options
   options.overwriteScopesOfDescendants = submitOpts ? !!submitOpts.overwriteScopesOfDescendants : false;
 
-  let promise = undefined;
+  let promise;
   if (editorMode === 'hackmd') {
     // get markdown
     promise = componentInstances.pageEditorByHackmd.getMarkdown();
@@ -256,12 +259,12 @@ const saveWithSubmitButton = function(submitOpts) {
   }
   // create or update
   if (pageId == null) {
-    promise = promise.then(markdown => {
+    promise = promise.then((markdown) => {
       return crowi.createPage(pagePath, markdown, options);
     });
   }
   else {
-    promise = promise.then(markdown => {
+    promise = promise.then((markdown) => {
       return crowi.updatePage(pageId, revisionId, markdown, options);
     });
   }
@@ -287,14 +290,14 @@ if (!pageRevisionId && draft != null) {
  */
 const componentMappings = {
   'search-top': <I18nextProvider i18n={i18n}><HeaderSearchBox crowi={crowi} /></I18nextProvider>,
-  'search-sidebar': <HeaderSearchBox crowi={crowi} />,
+  'search-sidebar': <I18nextProvider i18n={i18n}><HeaderSearchBox crowi={crowi} /></I18nextProvider>,
   'search-page': <I18nextProvider i18n={i18n}><SearchPage crowi={crowi} crowiRenderer={crowiRenderer} /></I18nextProvider>,
 
-  //'revision-history': <PageHistory pageId={pageId} />,
+  // 'revision-history': <PageHistory pageId={pageId} />,
   'bookmark-button': <BookmarkButton pageId={pageId} crowi={crowi} />,
   'bookmark-button-lg': <BookmarkButton pageId={pageId} crowi={crowi} size="lg" />,
 
-  'create-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} addTrailingSlash={true} />,
+  '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} />,
 
@@ -305,9 +308,9 @@ if (pageId) {
   componentMappings['page-attachment'] = <PageAttachment pageId={pageId} markdown={markdown} crowi={crowi} />;
 }
 if (pagePath) {
-  componentMappings['page'] = <Page crowi={crowi} crowiRenderer={crowiRenderer} markdown={markdown} pagePath={pagePath} showHeadEditButton={true} onSaveWithShortcut={saveWithShortcut} />;
+  componentMappings.page = <Page crowi={crowi} crowiRenderer={crowiRenderer} markdown={markdown} pagePath={pagePath} onSaveWithShortcut={saveWithShortcut} />;
   componentMappings['revision-path'] = <RevisionPath pagePath={pagePath} crowi={crowi} />;
-  // componentMappings['page-tag'] = <PageTagForm pageTags={currentPageTags} submitTags={getNewPageTags} />; [pagetag]
+  componentMappings['tag-viewer'] = <TagViewer crowi={crowi} pageId={pageId} sendTagData={setTagData} />;
   componentMappings['revision-url'] = <RevisionUrl pageId={pageId} pagePath={pagePath} />;
 }
 
@@ -319,8 +322,8 @@ Object.keys(componentMappings).forEach((key) => {
 });
 
 // set page if exists
-if (componentInstances['page'] != null) {
-  crowi.setPage(componentInstances['page']);
+if (componentInstances.page != null) {
+  crowi.setPage(componentInstances.page);
 }
 
 // render LikeButton
@@ -329,7 +332,7 @@ if (likeButtonElem) {
   const isLiked = likeButtonElem.dataset.liked === 'true';
   ReactDOM.render(
     <LikeButton crowi={crowi} pageId={pageId} isLiked={isLiked} />,
-    likeButtonElem
+    likeButtonElem,
   );
 }
 
@@ -340,7 +343,7 @@ if (seenUserListElem) {
   const userIds = userIdsStr.split(',');
   ReactDOM.render(
     <UserPictureList crowi={crowi} userIds={userIds} />,
-    seenUserListElem
+    seenUserListElem,
   );
 }
 // render UserPictureList for liker-list
@@ -350,7 +353,7 @@ if (likerListElem) {
   const userIds = userIdsStr.split(',');
   ReactDOM.render(
     <UserPictureList crowi={crowi} userIds={userIds} />,
-    likerListElem
+    likerListElem,
   );
 }
 
@@ -363,16 +366,22 @@ if (savePageControlsElem) {
   const grantGroupName = savePageControlsElem.dataset.grantGroupName;
   ReactDOM.render(
     <I18nextProvider i18n={i18n}>
-      <SavePageControls crowi={crowi} onSubmit={saveWithSubmitButton}
-          ref={(elem) => {
+      <SavePageControls
+        crowi={crowi}
+        onSubmit={saveWithSubmitButton}
+        ref={(elem) => {
             if (savePageControls == null) {
-              savePageControls = elem.getWrappedInstance();
+              savePageControls = elem;
             }
           }}
-          pageId={pageId} pagePath={pagePath} slackChannels={slackChannels}
-          grant={grant} grantGroupId={grantGroupId} grantGroupName={grantGroupName} />
+        pageId={pageId}
+        slackChannels={slackChannels}
+        grant={grant}
+        grantGroupId={grantGroupId}
+        grantGroupName={grantGroupName}
+      />
     </I18nextProvider>,
-    savePageControlsElem
+    savePageControlsElem,
   );
   componentInstances.savePageControls = savePageControls;
 }
@@ -380,13 +389,13 @@ if (savePageControlsElem) {
 const recentCreatedControlsElem = document.getElementById('user-created-list');
 if (recentCreatedControlsElem) {
   let limit = crowi.getConfig().recentCreatedLimit;
-  if (null == limit) {
+  if (limit == null) {
     limit = 10;
   }
   ReactDOM.render(
-    <RecentCreated  crowi={crowi} pageId={pageId} limit={limit} >
+    <RecentCreated crowi={crowi} pageId={pageId} limit={limit}>
 
-    </RecentCreated>, document.getElementById('user-created-list')
+    </RecentCreated>, document.getElementById('user-created-list'),
   );
 }
 
@@ -398,12 +407,17 @@ 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
+    <PageEditorByHackmd
+      crowi={crowi}
+      pageId={pageId}
+      revisionId={pageRevisionId}
+      pageIdOnHackmd={pageIdOnHackmd}
+      revisionIdHackmdSynced={pageRevisionIdHackmdSynced}
+      hasDraftOnHackmd={hasDraftOnHackmd}
+      markdown={markdown}
+      onSaveWithShortcut={saveWithShortcut}
+    />,
+    pageEditorWithHackmdElem,
   );
   componentInstances.pageEditorByHackmd = pageEditorByHackmd;
 }
@@ -418,13 +432,26 @@ const previewOptions = new PreviewOptions(crowi.previewOptions);
 // render PageEditor
 const pageEditorElem = document.getElementById('page-editor');
 if (pageEditorElem) {
-  pageEditor = ReactDOM.render(
-    <PageEditor crowi={crowi} crowiRenderer={crowiRenderer}
-        pageId={pageId} revisionId={pageRevisionId} pagePath={pagePath}
+  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} />,
-    pageEditorElem
+        editorOptions={editorOptions}
+        previewOptions={previewOptions}
+        onSaveWithShortcut={saveWithShortcut}
+      />
+    </I18nextProvider>,
+    pageEditorElem,
   );
   componentInstances.pageEditor = pageEditor;
   // set refs for setCaretLine/forceToFocus when tab is changed
@@ -441,15 +468,18 @@ if (writeCommentElem) {
     }
   };
   ReactDOM.render(
-    <CommentForm crowi={crowi}
+    <CommentForm
+      crowi={crowi}
       crowiOriginRenderer={crowiRenderer}
       pageId={pageId}
       pagePath={pagePath}
       revisionId={pageRevisionId}
       onPostComplete={postCompleteHandler}
       editorOptions={editorOptions}
-      slackChannels = {slackChannels}/>,
-    writeCommentElem);
+      slackChannels={slackChannels}
+    />,
+    writeCommentElem,
+  );
 }
 
 // render OptionsSelector
@@ -457,8 +487,10 @@ const pageEditorOptionsSelectorElem = document.getElementById('page-editor-optio
 if (pageEditorOptionsSelectorElem) {
   ReactDOM.render(
     <I18nextProvider i18n={i18n}>
-      <OptionsSelector crowi={crowi}
-        editorOptions={editorOptions} previewOptions={previewOptions}
+      <OptionsSelector
+        crowi={crowi}
+        editorOptions={editorOptions}
+        previewOptions={previewOptions}
         onChange={(newEditorOptions, newPreviewOptions) => { // set onChange event handler
           // set options
           pageEditor.setEditorOptions(newEditorOptions);
@@ -466,9 +498,10 @@ if (pageEditorOptionsSelectorElem) {
           // save
           crowi.saveEditorOptions(newEditorOptions);
           crowi.savePreviewOptions(newPreviewOptions);
-        }} />
+        }}
+      />
     </I18nextProvider>,
-    pageEditorOptionsSelectorElem
+    pageEditorOptionsSelectorElem,
   );
 }
 
@@ -478,15 +511,18 @@ const pageStatusAlertElem = document.getElementById('page-status-alert');
 if (pageStatusAlertElem) {
   ReactDOM.render(
     <I18nextProvider i18n={i18n}>
-      <PageStatusAlert crowi={crowi}
-          ref={(elem) => {
+      <PageStatusAlert
+        ref={(elem) => {
             if (pageStatusAlert == null) {
-              pageStatusAlert = elem.getWrappedInstance();
+              pageStatusAlert = elem;
             }
           }}
-          revisionId={pageRevisionId} revisionIdHackmdSynced={pageRevisionIdHackmdSynced} hasDraftOnHackmd={hasDraftOnHackmd} />
+        revisionId={pageRevisionId}
+        revisionIdHackmdSynced={pageRevisionIdHackmdSynced}
+        hasDraftOnHackmd={hasDraftOnHackmd}
+      />
     </I18nextProvider>,
-    pageStatusAlertElem
+    pageStatusAlertElem,
   );
   componentInstances.pageStatusAlert = pageStatusAlert;
 }
@@ -499,7 +535,7 @@ if (customCssEditorElem != null) {
 
   ReactDOM.render(
     <CustomCssEditor inputElem={customCssInputElem} />,
-    customCssEditorElem
+    customCssEditorElem,
   );
 }
 const customScriptEditorElem = document.getElementById('custom-script-editor');
@@ -509,7 +545,7 @@ if (customScriptEditorElem != null) {
 
   ReactDOM.render(
     <CustomScriptEditor inputElem={customScriptInputElem} />,
-    customScriptEditorElem
+    customScriptEditorElem,
   );
 }
 const customHeaderEditorElem = document.getElementById('custom-header-editor');
@@ -519,14 +555,14 @@ if (customHeaderEditorElem != null) {
 
   ReactDOM.render(
     <CustomHeaderEditor inputElem={customHeaderInputElem} />,
-    customHeaderEditorElem
+    customHeaderEditorElem,
   );
 }
 const adminRebuildSearchElem = document.getElementById('admin-rebuild-search');
 if (adminRebuildSearchElem != null) {
   ReactDOM.render(
     <AdminRebuildSearch crowi={crowi} />,
-    adminRebuildSearchElem
+    adminRebuildSearchElem,
   );
 }
 
@@ -540,7 +576,7 @@ function updatePageStatusAlert(page, user) {
     pageStatusAlert.setLastUpdateUsername(user.name);
   }
 }
-socket.on('page:create', function(data) {
+socket.on('page:create', (data) => {
   // skip if triggered myself
   if (data.socketClientId != null && data.socketClientId === socketClientId) {
     return;
@@ -549,11 +585,11 @@ socket.on('page:create', function(data) {
   logger.debug({ obj: data }, `websocket on 'page:create'`); // eslint-disable-line quotes
 
   // update PageStatusAlert
-  if (data.page.path == pagePath) {
+  if (data.page.path === pagePath) {
     updatePageStatusAlert(data.page, data.user);
   }
 });
-socket.on('page:update', function(data) {
+socket.on('page:update', (data) => {
   // skip if triggered myself
   if (data.socketClientId != null && data.socketClientId === socketClientId) {
     return;
@@ -561,7 +597,7 @@ socket.on('page:update', function(data) {
 
   logger.debug({ obj: data }, `websocket on 'page:update'`); // eslint-disable-line quotes
 
-  if (data.page.path == pagePath) {
+  if (data.page.path === pagePath) {
     // update PageStatusAlert
     updatePageStatusAlert(data.page, data.user);
     // update PageEditorByHackmd
@@ -573,7 +609,7 @@ socket.on('page:update', function(data) {
     }
   }
 });
-socket.on('page:delete', function(data) {
+socket.on('page:delete', (data) => {
   // skip if triggered myself
   if (data.socketClientId != null && data.socketClientId === socketClientId) {
     return;
@@ -582,11 +618,11 @@ socket.on('page:delete', function(data) {
   logger.debug({ obj: data }, `websocket on 'page:delete'`); // eslint-disable-line quotes
 
   // update PageStatusAlert
-  if (data.page.path == pagePath) {
+  if (data.page.path === pagePath) {
     updatePageStatusAlert(data.page, data.user);
   }
 });
-socket.on('page:editingWithHackmd', function(data) {
+socket.on('page:editingWithHackmd', (data) => {
   // skip if triggered myself
   if (data.socketClientId != null && data.socketClientId === socketClientId) {
     return;
@@ -594,7 +630,7 @@ socket.on('page:editingWithHackmd', function(data) {
 
   logger.debug({ obj: data }, `websocket on 'page:editingWithHackmd'`); // eslint-disable-line quotes
 
-  if (data.page.path == pagePath) {
+  if (data.page.path === pagePath) {
     // update PageStatusAlert
     const pageStatusAlert = componentInstances.pageStatusAlert;
     if (pageStatusAlert != null) {
@@ -609,9 +645,10 @@ socket.on('page:editingWithHackmd', function(data) {
 });
 
 // うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
-$('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', function() {
+$('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
   ReactDOM.render(
     <I18nextProvider i18n={i18n}>
       <PageHistory pageId={pageId} crowi={crowi} />
-    </I18nextProvider>, document.getElementById('revision-history'));
+    </I18nextProvider>, document.getElementById('revision-history'),
+  );
 });

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

@@ -2,6 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 export default class AdminRebuildSearch extends React.Component {
+
   constructor(props) {
     super(props);
 
@@ -16,19 +17,21 @@ export default class AdminRebuildSearch extends React.Component {
   componentDidMount() {
     const socket = this.props.crowi.getWebSocket();
 
-    socket.on('admin:addPageProgress', data => {
+    socket.on('admin:addPageProgress', (data) => {
       const newStates = Object.assign(data, { isCompleted: false });
       this.setState(newStates);
     });
 
-    socket.on('admin:finishAddPage', data => {
+    socket.on('admin:finishAddPage', (data) => {
       const newStates = Object.assign(data, { isCompleted: true });
       this.setState(newStates);
     });
   }
 
   render() {
-    const { total, current, skip, isCompleted } = this.state;
+    const {
+      total, current, skip, isCompleted,
+    } = this.state;
     if (total === 0) {
       return null;
     }
@@ -59,6 +62,7 @@ export default class AdminRebuildSearch extends React.Component {
       </div>
     );
   }
+
 }
 
 AdminRebuildSearch.propTypes = {

+ 6 - 9
src/client/js/components/Admin/CustomCssEditor.js

@@ -2,6 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import { UnControlled as CodeMirror } from 'react-codemirror2';
+
 require('codemirror/addon/lint/css-lint');
 require('codemirror/addon/hint/css-hint');
 require('codemirror/addon/hint/show-hint');
@@ -14,10 +15,6 @@ require('jquery-ui/ui/widgets/resizable');
 
 export default class CustomCssEditor extends React.Component {
 
-  constructor(props) {
-    super(props);
-  }
-
   render() {
     // get initial value from inputElem
     const value = this.props.inputElem.value;
@@ -25,24 +22,24 @@ export default class CustomCssEditor extends React.Component {
     return (
       <CodeMirror
         value={value}
-        autoFocus={true}
+        autoFocus
         options={{
           mode: 'css',
           lineNumbers: true,
           tabSize: 2,
           indentUnit: 2,
           theme: 'eclipse',
-          autoRefresh: {force: true},   // force option is enabled by autorefresh.ext.js -- Yuki Takei
+          autoRefresh: { force: true }, // force option is enabled by autorefresh.ext.js -- Yuki Takei
           matchBrackets: true,
           autoCloseBrackets: true,
-          extraKeys: {'Ctrl-Space': 'autocomplete'},
+          extraKeys: { 'Ctrl-Space': 'autocomplete' },
         }}
         editorDidMount={(editor, next) => {
           // resizable with jquery.ui
           $(editor.getWrapperElement()).resizable({
-            resize: function() {
+            resize() {
               editor.setSize($(this).width(), $(this).height());
-            }
+            },
           });
         }}
         onChange={(editor, data, value) => {

+ 6 - 9
src/client/js/components/Admin/CustomHeaderEditor.js

@@ -2,6 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import { UnControlled as CodeMirror } from 'react-codemirror2';
+
 require('codemirror/addon/hint/show-hint');
 require('codemirror/addon/edit/matchbrackets');
 require('codemirror/addon/edit/closebrackets');
@@ -12,10 +13,6 @@ require('jquery-ui/ui/widgets/resizable');
 
 export default class CustomHeaderEditor extends React.Component {
 
-  constructor(props) {
-    super(props);
-  }
-
   render() {
     // get initial value from inputElem
     const value = this.props.inputElem.value;
@@ -23,24 +20,24 @@ export default class CustomHeaderEditor extends React.Component {
     return (
       <CodeMirror
         value={value}
-        autoFocus={true}
+        autoFocus
         options={{
           mode: 'htmlmixed',
           lineNumbers: true,
           tabSize: 2,
           indentUnit: 2,
           theme: 'eclipse',
-          autoRefresh: {force: true},   // force option is enabled by autorefresh.ext.js -- Yuki Takei
+          autoRefresh: { force: true }, // force option is enabled by autorefresh.ext.js -- Yuki Takei
           matchBrackets: true,
           autoCloseBrackets: true,
-          extraKeys: {'Ctrl-Space': 'autocomplete'},
+          extraKeys: { 'Ctrl-Space': 'autocomplete' },
         }}
         editorDidMount={(editor, next) => {
           // resizable with jquery.ui
           $(editor.getWrapperElement()).resizable({
-            resize: function() {
+            resize() {
               editor.setSize($(this).width(), $(this).height());
-            }
+            },
           });
         }}
         onChange={(editor, data, value) => {

+ 6 - 9
src/client/js/components/Admin/CustomScriptEditor.js

@@ -2,6 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import { UnControlled as CodeMirror } from 'react-codemirror2';
+
 require('codemirror/addon/lint/javascript-lint');
 require('codemirror/addon/hint/javascript-hint');
 require('codemirror/addon/hint/show-hint');
@@ -14,10 +15,6 @@ require('jquery-ui/ui/widgets/resizable');
 
 export default class CustomScriptEditor extends React.Component {
 
-  constructor(props) {
-    super(props);
-  }
-
   render() {
     // get initial value from inputElem
     const value = this.props.inputElem.value;
@@ -25,24 +22,24 @@ export default class CustomScriptEditor extends React.Component {
     return (
       <CodeMirror
         value={value}
-        autoFocus={true}
+        autoFocus
         options={{
           mode: 'javascript',
           lineNumbers: true,
           tabSize: 2,
           indentUnit: 2,
           theme: 'eclipse',
-          autoRefresh: {force: true},   // force option is enabled by autorefresh.ext.js -- Yuki Takei
+          autoRefresh: { force: true }, // force option is enabled by autorefresh.ext.js -- Yuki Takei
           matchBrackets: true,
           autoCloseBrackets: true,
-          extraKeys: {'Ctrl-Space': 'autocomplete'},
+          extraKeys: { 'Ctrl-Space': 'autocomplete' },
         }}
         editorDidMount={(editor, next) => {
           // resizable with jquery.ui
           $(editor.getWrapperElement()).resizable({
-            resize: function() {
+            resize() {
               editor.setSize($(this).width(), $(this).height());
-            }
+            },
           });
         }}
         onChange={(editor, data, value) => {

+ 24 - 18
src/client/js/components/BookmarkButton.jsx

@@ -20,12 +20,12 @@ export default class BookmarkButton extends React.Component {
       return;
     }
 
-    this.props.crowi.apiGet('/bookmarks.get', {page_id: this.props.pageId})
-    .then(res => {
-      if (res.bookmark) {
-        this.markBookmarked();
-      }
-    });
+    this.props.crowi.apiGet('/bookmarks.get', { page_id: this.props.pageId })
+      .then((res) => {
+        if (res.bookmark) {
+          this.markBookmarked();
+        }
+      });
   }
 
   handleClick(event) {
@@ -34,25 +34,25 @@ export default class BookmarkButton extends React.Component {
     const pageId = this.props.pageId;
 
     if (!this.state.bookmarked) {
-      this.props.crowi.apiPost('/bookmarks.add', {page_id: pageId})
-      .then(res => {
-        this.markBookmarked();
-      });
+      this.props.crowi.apiPost('/bookmarks.add', { page_id: pageId })
+        .then((res) => {
+          this.markBookmarked();
+        });
     }
     else {
-      this.props.crowi.apiPost('/bookmarks.remove', {page_id: pageId})
-      .then(res => {
-        this.markUnBookmarked();
-      });
+      this.props.crowi.apiPost('/bookmarks.remove', { page_id: pageId })
+        .then((res) => {
+          this.markUnBookmarked();
+        });
     }
   }
 
   markBookmarked() {
-    this.setState({bookmarked: true});
+    this.setState({ bookmarked: true });
   }
 
   markUnBookmarked() {
-    this.setState({bookmarked: false});
+    this.setState({ bookmarked: false });
   }
 
   isUserLoggedIn() {
@@ -73,12 +73,18 @@ export default class BookmarkButton extends React.Component {
     const addedClassName = addedClassNames.join(' ');
 
     return (
-      <button href="#" title="Bookmark" onClick={this.handleClick}
-          className={`btn-bookmark btn btn-default btn-circle btn-outline ${addedClassName}`}>
+      <button
+        type="button"
+        href="#"
+        title="Bookmark"
+        onClick={this.handleClick}
+        className={`btn-bookmark btn btn-default btn-circle btn-outline ${addedClassName}`}
+      >
         <i className="icon-star"></i>
       </button>
     );
   }
+
 }
 
 BookmarkButton.propTypes = {

+ 1 - 2
src/client/js/components/Common/UserDate.js

@@ -19,6 +19,7 @@ export default class UserDate extends React.Component {
       </span>
     );
   }
+
 }
 
 UserDate.propTypes = {
@@ -28,8 +29,6 @@ UserDate.propTypes = {
 };
 
 UserDate.defaultProps = {
-  dateTime: 'now',
   format: 'YYYY/MM/DD HH:mm:ss',
   className: '',
 };
-

+ 6 - 6
src/client/js/components/Common/UserPictureList.jsx

@@ -15,24 +15,23 @@ export default class UserPictureList extends React.Component {
 
     const users = this.props.users.concat(
       // FIXME: user data cache
-      this.props.crowi.findUserByIds(userIds)
+      this.props.crowi.findUserByIds(userIds),
     );
 
     this.state = {
-      users: users,
-      tooltipUsername: '',
+      users,
     };
 
   }
 
   render() {
-    const users = this.state.users.map(user => {
+    const users = this.state.users.map((user) => {
       // create Tooltip
       const tooltip = <Tooltip id={`tooltip-${user._id}`}>{user.username}</Tooltip>;
 
       return (
-        <a key={user._id} data-user-id={user._id} href={'/user/' + user.username}>
-          <OverlayTrigger overlay={tooltip} placement='bottom'>
+        <a key={user._id} data-user-id={user._id} href={`/user/${user.username}`}>
+          <OverlayTrigger overlay={tooltip} placement="bottom">
             <span key={`span-${user._id}`}>{/* workaround from https://github.com/react-bootstrap/react-bootstrap/issues/2208#issuecomment-301737531 */}
               <UserPicture user={user} size="xs" ref={`userPicture-${user._id}`} />
             </span>
@@ -47,6 +46,7 @@ export default class UserPictureList extends React.Component {
       </span>
     );
   }
+
 }
 
 UserPictureList.propTypes = {

+ 15 - 7
src/client/js/components/CopyButton.js

@@ -23,7 +23,7 @@ export default class CopyButton extends React.Component {
 
   render() {
     const containerStyle = {
-      lineHeight: 0
+      lineHeight: 0,
     };
     const style = Object.assign({
       padding: '0 2px',
@@ -34,16 +34,25 @@ export default class CopyButton extends React.Component {
 
     return (
       <span className="btn-copy-container" style={containerStyle}>
-        <ClipboardButton className={this.props.buttonClassName}
-            button-id={this.props.buttonId} button-data-toggle="tooltip" button-data-container="body" button-title="copied!" button-data-placement="bottom" button-data-trigger="manual"
-            button-style={style}
-            data-clipboard-text={text} onSuccess={this.showToolTip}>
+        <ClipboardButton
+          className={this.props.buttonClassName}
+          button-id={this.props.buttonId}
+          button-data-toggle="tooltip"
+          button-data-container="body"
+          button-title="copied!"
+          button-data-placement="bottom"
+          button-data-trigger="manual"
+          button-style={style}
+          data-clipboard-text={text}
+          onSuccess={this.showToolTip}
+        >
 
-          <i className={this.props.iconClassName}></i>
+          <i className={this.props.iconClassName} />
         </ClipboardButton>
       </span>
     );
   }
+
 }
 
 CopyButton.propTypes = {
@@ -54,6 +63,5 @@ CopyButton.propTypes = {
   iconClassName: PropTypes.string.isRequired,
 };
 CopyButton.defaultProps = {
-  buttonId: 'btnCopy',
   buttonStyle: {},
 };

+ 16 - 14
src/client/js/components/HeaderSearchBox.jsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import { translate } from 'react-i18next';
+import { withTranslation } from 'react-i18next';
 
 import FormGroup from 'react-bootstrap/es/FormGroup';
 import Button from 'react-bootstrap/es/Button';
@@ -46,17 +46,17 @@ class HeaderSearchBox extends React.Component {
   }
 
   search() {
-    const url = new URL(location.href);
+    const url = new URL(window.location.href);
     url.pathname = '/_search';
 
     // construct search query
     let q = this.state.text;
     if (this.state.isScopeChildren) {
-      q += ` prefix:${location.pathname}`;
+      q += ` prefix:${window.location.pathname}`;
     }
     url.searchParams.append('q', q);
 
-    location.href = url.href;
+    window.location.href = url.href;
   }
 
   render() {
@@ -68,13 +68,14 @@ class HeaderSearchBox extends React.Component {
     return (
       <FormGroup>
         <InputGroup>
-        <InputGroup.Button className="btn-group-dropdown-scope">
-          <DropdownButton id="dbScope" title={scopeLabel}>
-            <MenuItem onClick={this.onClickAllPages}>All pages</MenuItem>
-            <MenuItem onClick={this.onClickChildren}>{ t('header_search_box.item_label.This tree') }</MenuItem>
-          </DropdownButton>
-        </InputGroup.Button>
-          <SearchForm t={this.props.t}
+          <InputGroup.Button className="btn-group-dropdown-scope">
+            <DropdownButton id="dbScope" title={scopeLabel}>
+              <MenuItem onClick={this.onClickAllPages}>All pages</MenuItem>
+              <MenuItem onClick={this.onClickChildren}>{ t('header_search_box.item_label.This tree') }</MenuItem>
+            </DropdownButton>
+          </InputGroup.Button>
+          <SearchForm
+            t={this.props.t}
             crowi={this.props.crowi}
             onInputChange={this.onInputChange}
             onSubmit={this.search}
@@ -83,17 +84,18 @@ class HeaderSearchBox extends React.Component {
           <InputGroup.Button className="btn-group-submit-search">
             <Button bsStyle="link" onClick={this.search}>
               <i className="icon-magnifier"></i>
-            </Button >
+            </Button>
           </InputGroup.Button>
         </InputGroup>
       </FormGroup>
     );
   }
+
 }
 
 HeaderSearchBox.propTypes = {
-  t: PropTypes.func.isRequired,               // i18next
+  t: PropTypes.func.isRequired, // i18next
   crowi: PropTypes.object.isRequired,
 };
 
-export default translate()(HeaderSearchBox);
+export default withTranslation()(HeaderSearchBox);

+ 63 - 22
src/client/js/components/InstallerForm.jsx

@@ -1,9 +1,10 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import i18next from 'i18next';
-import { translate } from 'react-i18next';
+import { withTranslation } from 'react-i18next';
 
 class InstallerForm extends React.Component {
+
   constructor(props) {
     super(props);
 
@@ -21,12 +22,12 @@ class InstallerForm extends React.Component {
     const axios = require('axios').create({
       headers: {
         'Content-Type': 'application/json',
-        'X-Requested-With': 'XMLHttpRequest'
+        'X-Requested-With': 'XMLHttpRequest',
       },
-      responseType: 'json'
+      responseType: 'json',
     });
-    axios.get('/_api/check_username', {params: {username: event.target.value}})
-      .then((res) => this.setState({ isValidUserName: res.data.valid }));
+    axios.get('/_api/check_username', { params: { username: event.target.value } })
+      .then((res) => { return this.setState({ isValidUserName: res.data.valid }) });
   }
 
   changeLanguage(locale) {
@@ -35,9 +36,12 @@ class InstallerForm extends React.Component {
 
   render() {
     const hasErrorClass = this.state.isValidUserName ? '' : ' has-error';
-    const unavailableUserId = this.state.isValidUserName ? '' : <span><i className="icon-fw icon-ban" />{ this.props.t('installer.unavaliable_user_id') }</span>;
+    const unavailableUserId = this.state.isValidUserName
+      ? ''
+      : <span><i className="icon-fw icon-ban" />{ this.props.t('installer.unavaliable_user_id') }</span>;
+
     return (
-      <div className={'login-dialog p-t-10 p-b-10 col-sm-offset-4 col-sm-4' + hasErrorClass}>
+      <div className={`login-dialog p-t-10 p-b-10 col-sm-offset-4 col-sm-4${hasErrorClass}`}>
         <p className="alert alert-success">
           <strong>{ this.props.t('installer.create_initial_account') }</strong><br />
           <small>{ this.props.t('installer.initial_account_will_be_administrator_automatically') }</small>
@@ -46,43 +50,79 @@ class InstallerForm extends React.Component {
         <form role="form" action="/installer" method="post" id="register-form">
           <div className="input-group m-t-20 m-b-20 mx-auto">
             <div className="radio radio-primary radio-inline">
-              <input type="radio" id="radioLangEn" name="registerForm[app:globalLang]" value="en-US"
-                     defaultChecked={ true } onClick={() => this.changeLanguage('en-US')} />
+              <input
+                type="radio"
+                id="radioLangEn"
+                name="registerForm[app:globalLang]"
+                value="en-US"
+                defaultChecked
+                onClick={() => { return this.changeLanguage('en-US') }}
+              />
               <label htmlFor="radioLangEn">English</label>
             </div>
             <div className="radio radio-primary radio-inline">
-              <input type="radio" id="radioLangJa" name="registerForm[app:globalLang]" value="ja"
-                     defaultChecked={ false } onClick={() => this.changeLanguage('ja')} />
+              <input
+                type="radio"
+                id="radioLangJa"
+                name="registerForm[app:globalLang]"
+                value="ja"
+                defaultChecked={false}
+                onClick={() => { return this.changeLanguage('ja') }}
+              />
               <label htmlFor="radioLangJa">日本語</label>
             </div>
           </div>
 
-          <div className={'input-group' + hasErrorClass}>
+          <div className={`input-group${hasErrorClass}`}>
             <span className="input-group-addon"><i className="icon-user" /></span>
-            <input type="text" className="form-control" placeholder={ this.props.t('User ID') }
-              name="registerForm[username]" defaultValue={this.props.userName} onBlur={this.checkUserName} required />
+            <input
+              type="text"
+              className="form-control"
+              placeholder={this.props.t('User ID')}
+              name="registerForm[username]"
+              defaultValue={this.props.userName}
+              onBlur={this.checkUserName}
+              required
+            />
           </div>
           <p className="help-block">{ unavailableUserId }</p>
 
           <div className="input-group">
             <span className="input-group-addon"><i className="icon-tag" /></span>
-            <input type="text" className="form-control" placeholder={ this.props.t('Name') }
-                   name="registerForm[name]" defaultValue={ this.props.name } required />
+            <input
+              type="text"
+              className="form-control"
+              placeholder={this.props.t('Name')}
+              name="registerForm[name]"
+              defaultValue={this.props.name}
+              required
+            />
           </div>
 
           <div className="input-group">
             <span className="input-group-addon"><i className="icon-envelope" /></span>
-            <input type="email" className="form-control" placeholder={ this.props.t('Email') }
-                   name="registerForm[email]" defaultValue={ this.props.email } required />
+            <input
+              type="email"
+              className="form-control"
+              placeholder={this.props.t('Email')}
+              name="registerForm[email]"
+              defaultValue={this.props.email}
+              required
+            />
           </div>
 
           <div className="input-group">
             <span className="input-group-addon"><i className="icon-lock" /></span>
-            <input type="password" className="form-control" placeholder={ this.props.t('Password') }
-                   name="registerForm[password]" required />
+            <input
+              type="password"
+              className="form-control"
+              placeholder={this.props.t('Password')}
+              name="registerForm[password]"
+              required
+            />
           </div>
 
-          <input type="hidden" name="_csrf" value={ this.props.csrf } />
+          <input type="hidden" name="_csrf" value={this.props.csrf} />
 
           <div className="input-group m-t-30 m-b-20 d-flex justify-content-center">
             <button type="submit" className="fcbtn btn btn-success btn-1b btn-register">
@@ -100,6 +140,7 @@ class InstallerForm extends React.Component {
       </div>
     );
   }
+
 }
 
 InstallerForm.propTypes = {
@@ -112,4 +153,4 @@ InstallerForm.propTypes = {
   csrf: PropTypes.string,
 };
 
-export default translate()(InstallerForm);
+export default withTranslation()(InstallerForm);

+ 16 - 10
src/client/js/components/LikeButton.jsx

@@ -19,16 +19,16 @@ export default class LikeButton extends React.Component {
     const pageId = this.props.pageId;
 
     if (!this.state.isLiked) {
-      this.props.crowi.apiPost('/likes.add', {page_id: pageId})
-      .then(res => {
-        this.setState({isLiked: true});
-      });
+      this.props.crowi.apiPost('/likes.add', { page_id: pageId })
+        .then((res) => {
+          this.setState({ isLiked: true });
+        });
     }
     else {
-      this.props.crowi.apiPost('/likes.remove', {page_id: pageId})
-      .then(res => {
-        this.setState({isLiked: false});
-      });
+      this.props.crowi.apiPost('/likes.remove', { page_id: pageId })
+        .then((res) => {
+          this.setState({ isLiked: false });
+        });
     }
   }
 
@@ -50,12 +50,18 @@ export default class LikeButton extends React.Component {
     const addedClassName = addedClassNames.join(' ');
 
     return (
-      <button href="#" title="Like" onClick={this.handleClick}
-          className={`btn-like btn btn-default btn-circle btn-outline ${addedClassName}`}>
+      <button
+        type="button"
+        href="#"
+        title="Like"
+        onClick={this.handleClick}
+        className={`btn-like btn btn-default btn-circle btn-outline ${addedClassName}`}
+      >
         <i className="icon-like"></i>
       </button>
     );
   }
+
 }
 
 LikeButton.propTypes = {

+ 20 - 12
src/client/js/components/Page.jsx

@@ -13,7 +13,7 @@ export default class Page extends React.Component {
 
     this.state = {
       markdown: this.props.markdown,
-      currentTargetTableArea: null
+      currentTargetTableArea: null,
     };
 
     this.saveHandlerForHandsontableModal = this.saveHandlerForHandsontableModal.bind(this);
@@ -30,28 +30,37 @@ export default class Page extends React.Component {
    */
   launchHandsontableModal(beginLineNumber, endLineNumber) {
     const tableLines = this.state.markdown.split(/\r\n|\r|\n/).slice(beginLineNumber - 1, endLineNumber).join('\n');
-    this.setState({currentTargetTableArea: {beginLineNumber, endLineNumber}});
-    this.refs.handsontableModal.show(MarkdownTable.fromMarkdownString(tableLines));
+    this.setState({ currentTargetTableArea: { beginLineNumber, endLineNumber } });
+    this.handsontableModal.show(MarkdownTable.fromMarkdownString(tableLines));
   }
 
   saveHandlerForHandsontableModal(markdownTable) {
-    const newMarkdown = mtu.replaceMarkdownTableInMarkdown(markdownTable, this.state.markdown, this.state.currentTargetTableArea.beginLineNumber, this.state.currentTargetTableArea.endLineNumber);
+    const newMarkdown = mtu.replaceMarkdownTableInMarkdown(
+      markdownTable,
+      this.state.markdown,
+      this.state.currentTargetTableArea.beginLineNumber,
+      this.state.currentTargetTableArea.endLineNumber,
+    );
     this.props.onSaveWithShortcut(newMarkdown);
-    this.setState({currentTargetTableArea: null});
+    this.setState({ currentTargetTableArea: null });
   }
 
   render() {
     const isMobile = this.props.crowi.isMobile;
 
-    return <div className={isMobile ? 'page-mobile' : ''}>
-      <RevisionRenderer
-          crowi={this.props.crowi} crowiRenderer={this.props.crowiRenderer}
+    return (
+      <div className={isMobile ? 'page-mobile' : ''}>
+        <RevisionRenderer
+          crowi={this.props.crowi}
+          crowiRenderer={this.props.crowiRenderer}
           markdown={this.state.markdown}
           pagePath={this.props.pagePath}
-      />
-      <HandsontableModal ref='handsontableModal' onSave={this.saveHandlerForHandsontableModal} />
-    </div>;
+        />
+        <HandsontableModal ref={(c) => { this.handsontableModal = c }} onSave={this.saveHandlerForHandsontableModal} />
+      </div>
+    );
   }
+
 }
 
 Page.propTypes = {
@@ -60,5 +69,4 @@ Page.propTypes = {
   onSaveWithShortcut: PropTypes.func.isRequired,
   markdown: PropTypes.string.isRequired,
   pagePath: PropTypes.string.isRequired,
-  showHeadEditButton: PropTypes.bool,
 };

+ 4 - 3
src/client/js/components/Page/PagePath.js

@@ -10,14 +10,16 @@ export default class PagePath extends React.Component {
   render() {
     const page = this.props.page;
     const shortPath = this.getShortPath(page.path);
-    const pathPrefix = page.path.replace(new RegExp(shortPath + '(/)?$'), '');
+    const pathPrefix = page.path.replace(new RegExp(`${shortPath}(/)?$`), '');
 
     return (
       <span className="page-path">
-        {pathPrefix}<strong>{shortPath}</strong>
+        {pathPrefix}
+        <strong>{shortPath}</strong>
       </span>
     );
   }
+
 }
 
 PagePath.propTypes = {
@@ -25,5 +27,4 @@ PagePath.propTypes = {
 };
 
 PagePath.defaultProps = {
-  page: {},
 };

+ 6 - 4
src/client/js/components/Page/RevisionBody.js

@@ -39,7 +39,7 @@ export default class RevisionBody extends React.Component {
   }
 
   generateInnerHtml(html) {
-    return {__html: html};
+    return { __html: html };
   }
 
   render() {
@@ -52,15 +52,17 @@ export default class RevisionBody extends React.Component {
             this.props.inputRef(elm);
           }
         }}
-        className={`wiki ${additionalClassName}`} dangerouslySetInnerHTML={this.generateInnerHtml(this.props.html)}>
-      </div>
+        className={`wiki ${additionalClassName}`}
+        dangerouslySetInnerHTML={this.generateInnerHtml(this.props.html)}
+      />
     );
   }
+
 }
 
 RevisionBody.propTypes = {
   html: PropTypes.string,
-  inputRef: PropTypes.func,  // for getting div element
+  inputRef: PropTypes.func, // for getting div element
   isMathJaxEnabled: PropTypes.bool,
   renderMathJaxOnInit: PropTypes.bool,
   renderMathJaxInRealtime: PropTypes.bool,

+ 13 - 9
src/client/js/components/Page/RevisionLoader.jsx

@@ -43,7 +43,7 @@ export default class RevisionLoader extends React.Component {
 
     // load data with REST API
     this.props.crowi.apiGet('/revisions.get', requestData)
-      .then(res => {
+      .then((res) => {
         if (!res.ok) {
           throw new Error(res.error);
         }
@@ -53,7 +53,7 @@ export default class RevisionLoader extends React.Component {
           error: null,
         });
       })
-      .catch(err => {
+      .catch((err) => {
         this.setState({ error: err });
       })
       .finally(() => {
@@ -70,9 +70,11 @@ export default class RevisionLoader extends React.Component {
   render() {
     // ----- before load -----
     if (this.props.lazy && !this.state.isLoaded) {
-      return <Waypoint onPositionChange={this.onWaypointChange} bottomOffset='-100px'>
-        <div className="wiki"></div>
-      </Waypoint>;
+      return (
+        <Waypoint onPositionChange={this.onWaypointChange} bottomOffset="-100px">
+          <div className="wiki"></div>
+        </Waypoint>
+      );
     }
 
     // ----- loading -----
@@ -94,13 +96,15 @@ export default class RevisionLoader extends React.Component {
 
     return (
       <RevisionRenderer
-          crowi={this.props.crowi} crowiRenderer={this.props.crowiRenderer}
-          pagePath={this.props.pagePath}
-          markdown={markdown}
-          highlightKeywords={this.props.highlightKeywords}
+        crowi={this.props.crowi}
+        crowiRenderer={this.props.crowiRenderer}
+        pagePath={this.props.pagePath}
+        markdown={markdown}
+        highlightKeywords={this.props.highlightKeywords}
       />
     );
   }
+
 }
 
 RevisionLoader.propTypes = {

+ 23 - 16
src/client/js/components/Page/RevisionPath.js

@@ -24,25 +24,25 @@ export default class RevisionPath extends React.Component {
     this.setState({ isListPage });
 
     // whether set link to '/'
-    const behaviorType = this.props.crowi.getConfig()['behaviorType'];
-    const isLinkToListPage = (!behaviorType || 'crowi' === behaviorType);
+    const behaviorType = this.props.crowi.getConfig().behaviorType;
+    const isLinkToListPage = (!behaviorType || behaviorType === 'crowi');
     this.setState({ isLinkToListPage });
 
     // generate pages obj
-    let splitted = this.props.pagePath.split(/\//);
-    splitted.shift();   // omit first element with shift()
-    if (splitted[splitted.length-1] === '') {
-      splitted.pop();   // omit last element with unshift()
+    const splitted = this.props.pagePath.split(/\//);
+    splitted.shift(); // omit first element with shift()
+    if (splitted[splitted.length - 1] === '') {
+      splitted.pop(); // omit last element with unshift()
     }
 
-    let pages = [];
+    const pages = [];
     let parentPath = '/';
     splitted.forEach((pageName) => {
       pages.push({
         pagePath: parentPath + encodeURIComponent(pageName),
         pageName: this.xss.process(pageName),
       });
-      parentPath += pageName + '/';
+      parentPath += `${pageName}/`;
     });
 
     this.setState({ pages });
@@ -56,8 +56,9 @@ export default class RevisionPath extends React.Component {
   }
 
   generateLinkElementToListPage(pagePath, isLinkToListPage, isLastElement) {
+    /* eslint-disable no-else-return */
     if (isLinkToListPage) {
-      return <a href={pagePath+'/'} className={(isLastElement && !this.state.isListPage) ? 'last-path' : ''}>/</a>;
+      return <a href={`${pagePath}/`} className={(isLastElement && !this.state.isListPage) ? 'last-path' : ''}>/</a>;
     }
     else if (!isLastElement) {
       return <span>/</span>;
@@ -65,6 +66,7 @@ export default class RevisionPath extends React.Component {
     else {
       return <span></span>;
     }
+    /* eslint-enable no-else-return */
   }
 
   render() {
@@ -85,20 +87,20 @@ export default class RevisionPath extends React.Component {
 
     const afterElements = [];
     this.state.pages.forEach((page, index) => {
-      const isLastElement = (index == pageLength-1);
+      const isLastElement = (index === pageLength - 1);
 
       // add elements for page
       afterElements.push(
         <span key={page.pagePath} className="path-segment">
           <a href={page.pagePath}>{page.pageName}</a>
-        </span>
+        </span>,
       );
 
       // add elements for '/'
       afterElements.push(
-        <span key={page.pagePath+'/'} className="separator" style={separatorStyle}>
+        <span key={`${page.pagePath}/`} className="separator" style={separatorStyle}>
           {this.generateLinkElementToListPage(page.pagePath, this.state.isLinkToListPage, isLastElement)}
-        </span>
+        </span>,
       );
     });
 
@@ -108,14 +110,19 @@ export default class RevisionPath extends React.Component {
           <a href="/">/</a>
         </span>
         {afterElements}
-        <CopyButton buttonId="btnCopyRevisionPath" text={this.props.pagePath}
-            buttonClassName="btn btn-default btn-copy" iconClassName="ti-clipboard" />
+        <CopyButton
+          buttonId="btnCopyRevisionPath"
+          text={this.props.pagePath}
+          buttonClassName="btn btn-default btn-copy"
+          iconClassName="ti-clipboard"
+        />
         <a href="#edit" className="btn btn-default btn-edit" style={editButtonStyle}>
-          <i className="icon-note"></i>
+          <i className="icon-note" />
         </a>
       </span>
     );
   }
+
 }
 
 RevisionPath.propTypes = {

+ 15 - 14
src/client/js/components/Page/RevisionRenderer.jsx

@@ -39,8 +39,8 @@ export default class RevisionRenderer extends React.Component {
         return;
       }
       const k = keyword
-            .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
-            .replace(/(^"|"$)/g, ''); // for phrase (quoted) keyword
+        .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+        .replace(/(^"|"$)/g, ''); // for phrase (quoted) keyword
       const keywordExp = new RegExp(`(${k}(?!(.*?")))`, 'ig');
       returnBody = returnBody.replace(keywordExp, '<em class="highlighted">$&</em>');
     });
@@ -49,7 +49,7 @@ export default class RevisionRenderer extends React.Component {
   }
 
   renderHtml(markdown, highlightKeywords) {
-    let context = {
+    const context = {
       markdown,
       currentPagePath: this.props.pagePath,
     };
@@ -57,15 +57,15 @@ export default class RevisionRenderer extends React.Component {
     const crowiRenderer = this.props.crowiRenderer;
     const interceptorManager = this.props.crowi.interceptorManager;
     interceptorManager.process('preRender', context)
-      .then(() => interceptorManager.process('prePreProcess', context))
+      .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => {
         context.markdown = crowiRenderer.preProcess(context.markdown);
       })
-      .then(() => interceptorManager.process('postPreProcess', context))
+      .then(() => { return interceptorManager.process('postPreProcess', context) })
       .then(() => {
-        context['parsedHTML'] = crowiRenderer.process(context.markdown);
+        context.parsedHTML = crowiRenderer.process(context.markdown);
       })
-      .then(() => interceptorManager.process('prePostProcess', context))
+      .then(() => { return interceptorManager.process('prePostProcess', context) })
       .then(() => {
         context.parsedHTML = crowiRenderer.postProcess(context.parsedHTML);
 
@@ -74,13 +74,13 @@ export default class RevisionRenderer extends React.Component {
           context.parsedHTML = this.getHighlightedBody(context.parsedHTML, highlightKeywords);
         }
       })
-      .then(() => interceptorManager.process('postPostProcess', context))
-      .then(() => interceptorManager.process('preRenderHtml', context))
+      .then(() => { return interceptorManager.process('postPostProcess', context) })
+      .then(() => { return interceptorManager.process('preRenderHtml', context) })
       .then(() => {
-        this.setState({ html: context.parsedHTML, markdown });
+        this.setState({ html: context.parsedHTML });
       })
       // process interceptors for post rendering
-      .then(() => interceptorManager.process('postRenderHtml', context));
+      .then(() => { return interceptorManager.process('postRenderHtml', context) });
 
   }
 
@@ -90,12 +90,13 @@ export default class RevisionRenderer extends React.Component {
 
     return (
       <RevisionBody
-          html={this.state.html}
-          isMathJaxEnabled={isMathJaxEnabled}
-          renderMathJaxOnInit={true}
+        html={this.state.html}
+        isMathJaxEnabled={isMathJaxEnabled}
+        renderMathJaxOnInit
       />
     );
   }
+
 }
 
 RevisionRenderer.propTypes = {

+ 12 - 6
src/client/js/components/Page/RevisionUrl.js

@@ -14,24 +14,30 @@ export default class RevisionUrl extends React.Component {
 
   render() {
     const buttonStyle = {
-      fontSize: '1em'
+      fontSize: '1em',
     };
 
     const pagePath = this.xss.process(this.props.pagePath);
 
     const url = (this.props.pageId == null)
-      ? decodeURIComponent(location.href)
-      : `${location.origin}/${this.props.pageId}`;
-    const copiedText = pagePath + '\n' + url;
+      ? decodeURIComponent(window.location.href)
+      : `${window.location.origin}/${this.props.pageId}`;
+    const copiedText = `${pagePath}\n${url}`;
 
     return (
       <span>
         {url}
-        <CopyButton buttonId="btnCopyRevisionUrl" text={copiedText}
-            buttonClassName="btn btn-default btn-copy-link" buttonStyle={buttonStyle} iconClassName="ti-clipboard" />
+        <CopyButton
+          buttonId="btnCopyRevisionUrl"
+          text={copiedText}
+          buttonClassName="btn btn-default btn-copy-link"
+          buttonStyle={buttonStyle}
+          iconClassName="ti-clipboard"
+        />
       </span>
     );
   }
+
 }
 
 RevisionUrl.propTypes = {

+ 107 - 0
src/client/js/components/Page/TagViewer.jsx

@@ -0,0 +1,107 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Button from 'react-bootstrap/es/Button';
+import OverlayTrigger from 'react-bootstrap/es/OverlayTrigger';
+import Tooltip from 'react-bootstrap/es/Tooltip';
+import Modal from 'react-bootstrap/es/Modal';
+import PageTagForm from '../PageTagForm';
+
+/**
+ * show tag labels on view and edit tag button on edit
+ * tag labels on view is not implemented yet(GC-1391)
+ */
+export default class TagViewer extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      currentPageTags: [],
+      newPageTags: [],
+      isOpenModal: false,
+    };
+
+    this.addNewTag = this.addNewTag.bind(this);
+    this.handleShowModal = this.handleShowModal.bind(this);
+    this.handleCloseModal = this.handleCloseModal.bind(this);
+    this.handleSubmit = this.handleSubmit.bind(this);
+  }
+
+  async componentWillMount() {
+    // set pageTag on button
+    const pageId = this.props.pageId;
+    if (pageId) {
+      const res = await this.props.crowi.apiGet('/pages.getPageTag', { pageId });
+      this.setState({ currentPageTags: res.tags });
+      this.props.sendTagData(res.tags);
+    }
+  }
+
+  // receive new tag from PageTagForm component
+  addNewTag(newPageTags) {
+    this.setState({ newPageTags });
+  }
+
+  handleCloseModal() {
+    this.setState({ isOpenModal: false });
+  }
+
+  handleShowModal() {
+    this.setState({ isOpenModal: true });
+  }
+
+  handleSubmit() {
+    this.props.sendTagData(this.state.newPageTags);
+    this.setState({ currentPageTags: this.state.newPageTags, isOpenModal: false });
+  }
+
+  render() {
+    const tagEditorButtonStyle = {
+      marginLeft: '0.2em',
+      padding: '0 2px',
+    };
+
+    return (
+      <span className="btn-tag-container">
+        <OverlayTrigger
+          key="tooltip"
+          placement="bottom"
+          overlay={(
+            <Tooltip id="tag-edit-button-tooltip" className="tag-tooltip">
+              {this.state.currentPageTags.length !== 0 ? this.state.currentPageTags.join() : 'tag is not set' }
+            </Tooltip>
+          )}
+        >
+          <Button
+            variant="primary"
+            onClick={this.handleShowModal}
+            className="btn btn-default btn-tag"
+            style={tagEditorButtonStyle}
+          >
+            <i className="fa fa-tags"></i>{this.state.currentPageTags.length}
+          </Button>
+        </OverlayTrigger>
+        <Modal show={this.state.isOpenModal} onHide={this.handleCloseModal} id="editTagModal">
+          <Modal.Header closeButton className="bg-primary">
+            <Modal.Title className="text-white">Page Tag</Modal.Title>
+          </Modal.Header>
+          <Modal.Body>
+            <PageTagForm crowi={this.props.crowi} currentPageTags={this.state.currentPageTags} addNewTag={this.addNewTag} />
+          </Modal.Body>
+          <Modal.Footer>
+            <Button variant="primary" onClick={this.handleSubmit}>
+              Done
+            </Button>
+          </Modal.Footer>
+        </Modal>
+      </span>
+    );
+  }
+
+}
+
+TagViewer.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  pageId: PropTypes.string,
+  sendTagData: PropTypes.func.isRequired,
+};

+ 16 - 11
src/client/js/components/PageAttachment.js

@@ -1,3 +1,4 @@
+/* eslint-disable react/no-access-state-in-setstate */
 import React from 'react';
 import PropTypes from 'prop-types';
 
@@ -5,6 +6,7 @@ import PageAttachmentList from './PageAttachment/PageAttachmentList';
 import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
 
 export default class PageAttachment extends React.Component {
+
   constructor(props) {
     super(props);
 
@@ -24,21 +26,21 @@ export default class PageAttachment extends React.Component {
     const pageId = this.props.pageId;
 
     if (!pageId) {
-      return ;
+      return;
     }
 
-    this.props.crowi.apiGet('/attachments.list', {page_id: pageId })
-      .then(res => {
+    this.props.crowi.apiGet('/attachments.list', { page_id: pageId })
+      .then((res) => {
         const attachments = res.attachments;
-        let inUse = {};
+        const inUse = {};
 
         for (const attachment of attachments) {
           inUse[attachment._id] = this.checkIfFileInUse(attachment);
         }
 
         this.setState({
-          attachments: attachments,
-          inUse: inUse,
+          attachments,
+          inUse,
         });
       });
   }
@@ -62,16 +64,18 @@ export default class PageAttachment extends React.Component {
       deleting: true,
     });
 
-    this.props.crowi.apiPost('/attachments.remove', {attachment_id: attachmentId})
-      .then(res => {
+    this.props.crowi.apiPost('/attachments.remove', { attachment_id: attachmentId })
+      .then((res) => {
         this.setState({
           attachments: this.state.attachments.filter((at) => {
+            // comparing ObjectId
+            // eslint-disable-next-line eqeqeq
             return at._id != attachmentId;
           }),
           attachmentToDelete: null,
           deleting: false,
         });
-      }).catch(err => {
+      }).catch((err) => {
         this.setState({
           deleteError: 'Something went wrong.',
           deleting: false,
@@ -87,10 +91,10 @@ export default class PageAttachment extends React.Component {
     let deleteAttachmentModal = '';
     if (this.isUserLoggedIn()) {
       const attachmentToDelete = this.state.attachmentToDelete;
-      let deleteModalClose = () => {
+      const deleteModalClose = () => {
         this.setState({ attachmentToDelete: null, deleteError: '' });
       };
-      let showModal = attachmentToDelete !== null;
+      const showModal = attachmentToDelete !== null;
 
       let deleteInUse = null;
       if (attachmentToDelete !== null) {
@@ -126,6 +130,7 @@ export default class PageAttachment extends React.Component {
       </div>
     );
   }
+
 }
 
 PageAttachment.propTypes = {

+ 10 - 5
src/client/js/components/PageAttachment/Attachment.js

@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
 import User from '../User/User';
 
 export default class Attachment extends React.Component {
+
   constructor(props) {
     super(props);
 
@@ -36,19 +37,22 @@ export default class Attachment extends React.Component {
     const btnDownload = (this.props.isUserLoggedIn)
       ? (
         <a className="attachment-download" href={attachment.downloadPathProxied}>
-          <i className="icon-cloud-download"></i>
-        </a>)
+          <i className="icon-cloud-download" />
+        </a>
+      )
       : '';
 
     const btnTrash = (this.props.isUserLoggedIn)
       ? (
+        /* eslint-disable-next-line */
         <a className="text-danger attachment-delete" onClick={this._onAttachmentDeleteClicked}>
-          <i className="icon-trash"></i>
-        </a>)
+          <i className="icon-trash" />
+        </a>
+      )
       : '';
 
     return (
-      <li className='attachment'>
+      <li className="attachment">
         <User user={attachment.creator} />
 
         <a href={attachment.filePathProxied}><i className={formatIcon}></i> {attachment.originalName}</a>
@@ -62,6 +66,7 @@ export default class Attachment extends React.Component {
       </li>
     );
   }
+
 }
 
 Attachment.propTypes = {

+ 11 - 5
src/client/js/components/PageAttachment/DeleteAttachmentModal.js

@@ -1,3 +1,4 @@
+/* eslint-disable react/prop-types */
 import React from 'react';
 import Button from 'react-bootstrap/es/Button';
 import Modal from 'react-bootstrap/es/Modal';
@@ -5,6 +6,7 @@ import Modal from 'react-bootstrap/es/Modal';
 import User from '../User/User';
 
 export default class DeleteAttachmentModal extends React.Component {
+
   constructor(props) {
     super(props);
 
@@ -25,7 +27,7 @@ export default class DeleteAttachmentModal extends React.Component {
 
   renderByFileFormat(attachment) {
     const content = (attachment.fileFormat.match(/image\/.+/i))
-      ? <img src={attachment.filePathProxied} />
+      ? <img src={attachment.filePathProxied} alt="deleting image" />
       : '';
 
 
@@ -63,7 +65,7 @@ export default class DeleteAttachmentModal extends React.Component {
       deletingIndicator = <span>{this.props.deleteError}</span>;
     }
 
-    let renderAttachment = this.renderByFileFormat(attachment);
+    const renderAttachment = this.renderByFileFormat(attachment);
 
     return (
       <Modal {...props} className="attachment-delete-modal" bsSize="large" aria-labelledby="contained-modal-title-lg">
@@ -77,11 +79,15 @@ export default class DeleteAttachmentModal extends React.Component {
           <div className="mr-3 d-inline-block">
             {deletingIndicator}
           </div>
-          <Button onClick={this._onDeleteConfirm} bsStyle="danger"
-            disabled={this.props.deleting}>Delete!</Button>
+          <Button
+            onClick={this._onDeleteConfirm}
+            bsStyle="danger"
+            disabled={this.props.deleting}
+          >Delete!
+          </Button>
         </Modal.Footer>
       </Modal>
     );
   }
-}
 
+}

+ 7 - 6
src/client/js/components/PageAttachment/PageAttachmentList.js

@@ -1,3 +1,4 @@
+/* eslint-disable react/prop-types */
 import React from 'react';
 
 import Attachment from './Attachment';
@@ -12,25 +13,25 @@ export default class PageAttachmentList extends React.Component {
     const attachmentList = this.props.attachments.map((attachment, idx) => {
       return (
         <Attachment
-          key={'page:attachment:' + attachment._id}
+          key={`page:attachment:${attachment._id}`}
           attachment={attachment}
           inUse={this.props.inUse[attachment._id] || false}
           onAttachmentDeleteClicked={this.props.onAttachmentDeleteClicked}
           isUserLoggedIn={this.props.isUserLoggedIn}
-         />
+        />
       );
     });
 
     return (
       <div>
-        {(attachmentList.length != 0) &&
-          <h5><strong>Attachments</strong></h5>
+        {(attachmentList.length !== 0)
+          && <h5><strong>Attachments</strong></h5>
         }
-        <ul className='pl-2'>
+        <ul className="pl-2">
           {attachmentList}
         </ul>
       </div>
     );
   }
-}
 
+}

+ 22 - 20
src/client/js/components/PageComment/Comment.js

@@ -41,9 +41,8 @@ export default class Comment extends React.Component {
     this.renderHtml(nextProps.comment.comment);
   }
 
-  //not used
+  // not used
   setMarkdown(markdown) {
-    this.setState({ markdown });
     this.renderHtml(markdown);
   }
 
@@ -56,13 +55,13 @@ export default class Comment extends React.Component {
   }
 
   getRootClassName() {
-    return 'page-comment '
-        + (this.isCurrentUserEqualsToAuthor() ? 'page-comment-me ' : '');
+    return `page-comment ${
+      this.isCurrentUserEqualsToAuthor() ? 'page-comment-me ' : ''}`;
   }
 
   getRevisionLabelClassName() {
-    return 'page-comment-revision label '
-        + (this.isCurrentRevision() ? 'label-primary' : 'label-default');
+    return `page-comment-revision label ${
+      this.isCurrentRevision() ? 'label-primary' : 'label-default'}`;
   }
 
   deleteBtnClickedHandler() {
@@ -73,10 +72,12 @@ export default class Comment extends React.Component {
     const config = this.props.crowi.getConfig();
     const isMathJaxEnabled = !!config.env.MATHJAX;
     return (
-      <RevisionBody html={this.state.html}
-          isMathJaxEnabled={isMathJaxEnabled}
-          renderMathJaxOnInit={true}
-          additionalClassName="comment" />
+      <RevisionBody
+        html={this.state.html}
+        isMathJaxEnabled={isMathJaxEnabled}
+        renderMathJaxOnInit
+        additionalClassName="comment"
+      />
     );
   }
 
@@ -88,26 +89,26 @@ export default class Comment extends React.Component {
     const crowiRenderer = this.props.crowiRenderer;
     const interceptorManager = this.props.crowi.interceptorManager;
     interceptorManager.process('preRenderComment', context)
-      .then(() => interceptorManager.process('prePreProcess', context))
+      .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => {
         context.markdown = crowiRenderer.preProcess(context.markdown);
       })
-      .then(() => interceptorManager.process('postPreProcess', context))
+      .then(() => { return interceptorManager.process('postPreProcess', context) })
       .then(() => {
-        var parsedHTML = crowiRenderer.process(context.markdown);
-        context['parsedHTML'] = parsedHTML;
+        const parsedHTML = crowiRenderer.process(context.markdown);
+        context.parsedHTML = parsedHTML;
       })
-      .then(() => interceptorManager.process('prePostProcess', context))
+      .then(() => { return interceptorManager.process('prePostProcess', context) })
       .then(() => {
         context.parsedHTML = crowiRenderer.postProcess(context.parsedHTML);
       })
-      .then(() => interceptorManager.process('postPostProcess', context))
-      .then(() => interceptorManager.process('preRenderCommentHtml', context))
+      .then(() => { return interceptorManager.process('postPostProcess', context) })
+      .then(() => { return interceptorManager.process('preRenderCommentHtml', context) })
       .then(() => {
         this.setState({ html: context.parsedHTML });
       })
       // process interceptors for post rendering
-      .then(() => interceptorManager.process('postRenderCommentHtml', context));
+      .then(() => { return interceptorManager.process('postRenderCommentHtml', context) });
 
   }
 
@@ -118,7 +119,7 @@ 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() : ReactUtils.nl2br(comment.comment);
     const creatorsPage = `/user/${creator.username}`;
     const revHref = `?revision=${comment.revision}`;
     const revFirst8Letters = comment.revision.substr(-8);
@@ -139,7 +140,7 @@ export default class Comment extends React.Component {
             <a className={revisionLavelClassName} href={revHref}>{revFirst8Letters}</a>
           </div>
           <div className="page-comment-control">
-            <button className="btn btn-link" onClick={this.deleteBtnClickedHandler}>
+            <button type="button" className="btn btn-link" onClick={this.deleteBtnClickedHandler}>
               <i className="ti-close"></i>
             </button>
           </div>
@@ -147,6 +148,7 @@ export default class Comment extends React.Component {
       </div>
     );
   }
+
 }
 
 Comment.propTypes = {

+ 131 - 103
src/client/js/components/PageComment/CommentForm.jsx

@@ -1,12 +1,12 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import ReactUtils from '../ReactUtils';
 
 import Button from 'react-bootstrap/es/Button';
 import Tab from 'react-bootstrap/es/Tab';
 import Tabs from 'react-bootstrap/es/Tabs';
-import UserPicture from '../User/UserPicture';
 import * as toastr from 'toastr';
+import UserPicture from '../User/UserPicture';
+import ReactUtils from '../ReactUtils';
 
 import GrowiRenderer from '../../util/GrowiRenderer';
 
@@ -47,7 +47,7 @@ export default class CommentForm extends React.Component {
       slackChannels: this.props.slackChannels,
     };
 
-    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, {mode: 'comment'});
+    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, { mode: 'comment' });
 
     this.updateState = this.updateState.bind(this);
     this.updateStateCheckbox = this.updateStateCheckbox.bind(this);
@@ -70,19 +70,19 @@ export default class CommentForm extends React.Component {
       return;
     }
 
-    const layoutType = this.props.crowi.getConfig()['layoutType'];
-    this.setState({isLayoutTypeGrowi: 'crowi-plus' === layoutType || 'growi' === layoutType});
+    const layoutType = this.props.crowi.getConfig().layoutType;
+    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
   }
 
   updateState(value) {
-    this.setState({comment: value});
+    this.setState({ comment: value });
   }
 
   updateStateCheckbox(event) {
     const value = event.target.checked;
-    this.setState({isMarkdown: value});
+    this.setState({ isMarkdown: value });
     // changeMode
-    this.refs.editor.setGfmMode(value);
+    this.editor.setGfmMode(value);
   }
 
   handleSelect(key) {
@@ -91,11 +91,11 @@ export default class CommentForm extends React.Component {
   }
 
   onSlackEnabledFlagChange(value) {
-    this.setState({isSlackEnabled: value});
+    this.setState({ isSlackEnabled: value });
   }
 
   onSlackChannelsChange(value) {
-    this.setState({slackChannels: value});
+    this.setState({ slackChannels: value });
   }
 
   /**
@@ -117,34 +117,35 @@ export default class CommentForm extends React.Component {
       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,
+      .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 });
       });
-      // reset value
-      this.refs.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} />
+        inputRef={(el) => { this.previewElement = el }}
+        html={this.state.html}
+      />
     );
   }
 
@@ -156,30 +157,30 @@ export default class CommentForm extends React.Component {
     const growiRenderer = this.growiRenderer;
     const interceptorManager = this.props.crowi.interceptorManager;
     interceptorManager.process('preRenderCommnetPreview', context)
-      .then(() => interceptorManager.process('prePreProcess', context))
+      .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => {
         context.markdown = growiRenderer.preProcess(context.markdown);
       })
-      .then(() => interceptorManager.process('postPreProcess', context))
+      .then(() => { return interceptorManager.process('postPreProcess', context) })
       .then(() => {
         const parsedHTML = growiRenderer.process(context.markdown);
-        context['parsedHTML'] = parsedHTML;
+        context.parsedHTML = parsedHTML;
       })
-      .then(() => interceptorManager.process('prePostProcess', context))
+      .then(() => { return interceptorManager.process('prePostProcess', context) })
       .then(() => {
         context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
       })
-      .then(() => interceptorManager.process('postPostProcess', context))
-      .then(() => interceptorManager.process('preRenderCommentPreviewHtml', context))
+      .then(() => { return interceptorManager.process('postPostProcess', context) })
+      .then(() => { return interceptorManager.process('preRenderCommentPreviewHtml', context) })
       .then(() => {
         this.setState({ html: context.parsedHTML });
       })
       // process interceptors for post rendering
-      .then(() => interceptorManager.process('postRenderCommentPreviewHtml', context));
+      .then(() => { return interceptorManager.process('postRenderCommentPreviewHtml', context) });
   }
 
   generateInnerHtml(html) {
-    return {__html: html};
+    return { __html: html };
   }
 
   onUpload(file) {
@@ -202,14 +203,14 @@ export default class CommentForm extends React.Component {
         // when image
         if (attachment.fileFormat.startsWith('image/')) {
           // modify to "![fileName](url)" syntax
-          insertText = '!' + insertText;
+          insertText = `!${insertText}`;
         }
-        this.refs.editor.insertText(insertText);
+        this.editor.insertText(insertText);
       })
       .catch(this.apiErrorHandler)
       // finally
       .then(() => {
-        this.refs.editor.terminateUploadingState();
+        this.editor.terminateUploadingState();
       });
   }
 
@@ -238,7 +239,7 @@ export default class CommentForm extends React.Component {
     const user = crowi.findUser(username);
     const creatorsPage = `/user/${username}`;
     const comment = this.state.comment;
-    const commentPreview = this.state.isMarkdown ? this.getCommentHtml(): ReactUtils.nl2br(comment);
+    const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : ReactUtils.nl2br(comment);
     const emojiStrategy = this.props.crowi.getEmojiStrategy();
 
     const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
@@ -254,91 +255,118 @@ export default class CommentForm extends React.Component {
       <div>
 
         <form className="form page-comment-form" id="page-comment-form" onSubmit={this.postComment}>
-          { username &&
+          { username
+            && (
             <div className="comment-form">
-              { isLayoutTypeGrowi &&
+              { isLayoutTypeGrowi
+                && (
                 <div className="comment-form-user">
                   <a href={creatorsPage}>
                     <UserPicture user={user} />
                   </a>
                 </div>
+                )
               }
               <div className="comment-form-main">
                 {/* Add Comment Button */}
-                { !this.state.isFormShown &&
-                  <button className={`btn btn-lg ${isLayoutTypeGrowi ? 'btn-link' : 'btn-primary'} center-block`} onClick={this.showCommentFormBtnClickHandler}>
+                { !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="editor"
-                          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 == true &&
-                      <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} /> Markdown
-                        </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
-                            crowi={this.props.crowi}
-                            pageId={this.props.pageId}
-                            pagePath={this.props.pagePath}
-                            isSlackEnabled={this.state.isSlackEnabled}
-                            slackChannels={this.state.slackChannels}
-                            onEnabledFlagChange={this.onSlackEnabledFlagChange}
-                            onChannelChange={this.onSlackChannelsChange}
+                { 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}
                           />
-                        </div>
-                      }
-                      <div className="hidden-xs">{submitButton}</div>
+                        </Tab>
+                        { this.state.isMarkdown
+                          && (
+                          <Tab eventKey={2} title="Preview">
+                            <div className="comment-form-preview">
+                              {commentPreview}
+                            </div>
+                          </Tab>
+                          )
+                        }
+                      </Tabs>
                     </div>
-                    <div className="visible-xs mt-2">
-                      <div className="d-flex justify-content-end">
-                        { this.state.errorMessage && errorMessage }
-                        <div>{submitButton}</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>
-                  </div>
-                </React.Fragment>}
+                  </React.Fragment>
+                  )
+                }
               </div>
             </div>
+            )
           }
         </form>
 
       </div>
     );
   }
+
 }
 
 CommentForm.propTypes = {

+ 7 - 8
src/client/js/components/PageComment/CommentPreview.js

@@ -8,17 +8,15 @@ import RevisionBody from '../Page/RevisionBody';
  */
 export default class CommentPreview extends React.Component {
 
-  constructor(props) {
-    super(props);
-  }
-
   render() {
     return (
-      <div className="page-comment-preview-body"
-          ref={(elm) => {
+      <div
+        className="page-comment-preview-body"
+        ref={(elm) => {
             this.previewElement = elm;
             this.props.inputRef(elm);
-          }}>
+          }}
+      >
 
         <RevisionBody
           {...this.props}
@@ -27,9 +25,10 @@ export default class CommentPreview extends React.Component {
       </div>
     );
   }
+
 }
 
 CommentPreview.propTypes = {
   html: PropTypes.string,
-  inputRef: PropTypes.func.isRequired,  // for getting div element
+  inputRef: PropTypes.func.isRequired, // for getting div element
 };

+ 2 - 6
src/client/js/components/PageComment/DeleteCommentModal.js

@@ -16,10 +16,6 @@ export default class DeleteCommentModal extends React.Component {
    */
   static get OMIT_BODY_THRES() { return 400 }
 
-  constructor(props) {
-    super(props);
-  }
-
   componentWillMount() {
   }
 
@@ -34,7 +30,7 @@ export default class DeleteCommentModal extends React.Component {
     // generate body
     let commentBody = comment.comment;
     if (commentBody.length > DeleteCommentModal.OMIT_BODY_THRES) { // omit
-      commentBody = commentBody.substr(0, DeleteCommentModal.OMIT_BODY_THRES) + '...';
+      commentBody = `${commentBody.substr(0, DeleteCommentModal.OMIT_BODY_THRES)}...`;
     }
     commentBody = ReactUtils.nl2br(commentBody);
 
@@ -68,6 +64,6 @@ DeleteCommentModal.propTypes = {
   isShown: PropTypes.bool.isRequired,
   comment: PropTypes.object,
   errorMessage: PropTypes.string,
-  cancel: PropTypes.func.isRequired,            // for cancel evnet handling
+  cancel: PropTypes.func.isRequired, // for cancel evnet handling
   confirmedToDelete: PropTypes.func.isRequired, // for confirmed event handling
 };

+ 34 - 26
src/client/js/components/PageComments.js

@@ -1,3 +1,4 @@
+/* eslint-disable react/no-access-state-in-setstate */
 import React from 'react';
 import PropTypes from 'prop-types';
 
@@ -32,7 +33,7 @@ export default class PageComments extends React.Component {
       errorMessageForDeleting: undefined,
     };
 
-    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, {mode: 'comment'});
+    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, { mode: 'comment' });
 
     this.init = this.init.bind(this);
     this.confirmToDeleteComment = this.confirmToDeleteComment.bind(this);
@@ -51,8 +52,8 @@ export default class PageComments extends React.Component {
       return;
     }
 
-    const layoutType = this.props.crowi.getConfig()['layoutType'];
-    this.setState({isLayoutTypeGrowi: 'crowi-plus' === layoutType || 'growi' === layoutType});
+    const layoutType = this.props.crowi.getConfig().layoutType;
+    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
 
     this.retrieveData();
   }
@@ -62,35 +63,36 @@ export default class PageComments extends React.Component {
    */
   retrieveData() {
     // get data (desc order array)
-    this.props.crowi.apiGet('/comments.get', {page_id: this.props.pageId})
-      .then(res => {
+    this.props.crowi.apiGet('/comments.get', { page_id: this.props.pageId })
+      .then((res) => {
         if (res.ok) {
-          this.setState({comments: res.comments});
+          this.setState({ comments: res.comments });
         }
       });
   }
 
   confirmToDeleteComment(comment) {
-    this.setState({commentToDelete: 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});
-    });
+    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) {
-    let comments = this.state.comments;
+    const comments = this.state.comments;
 
     const index = comments.indexOf(comment);
     if (index < 0) {
@@ -98,11 +100,11 @@ export default class PageComments extends React.Component {
     }
     comments.splice(index, 1);
 
-    this.setState({comments});
+    this.setState({ comments });
   }
 
   showDeleteConfirmModal() {
-    this.setState({isDeleteConfirmModalShown: true});
+    this.setState({ isDeleteConfirmModalShown: true });
   }
 
   closeDeleteConfirmModal() {
@@ -123,35 +125,40 @@ export default class PageComments extends React.Component {
   generateCommentElements(comments) {
     return comments.map((comment) => {
       return (
-        <Comment key={comment._id} comment={comment}
+        <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} />
+          crowiRenderer={this.growiRenderer}
+        />
       );
     });
   }
 
   render() {
-    let currentComments = [];
-    let newerComments = [];
-    let olderComments = [];
+    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
+      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) {
+      else if (Date.parse(comment.createdAt) / 1000 > revisionCreatedAt) {
         newerComments.push(comment);
       }
       else {
@@ -237,6 +244,7 @@ export default class PageComments extends React.Component {
       </div>
     );
   }
+
 }
 
 PageComments.propTypes = {

+ 33 - 37
src/client/js/components/PageEditor.js

@@ -3,13 +3,13 @@ import PropTypes from 'prop-types';
 
 import { throttle, debounce } from 'throttle-debounce';
 
+import * as toastr from 'toastr';
 import GrowiRenderer from '../util/GrowiRenderer';
 
 import { EditorOptions, PreviewOptions } from './PageEditor/OptionsSelector';
 import Editor from './PageEditor/Editor';
 import Preview from './PageEditor/Preview';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
-import * as toastr from 'toastr';
 
 
 export default class PageEditor extends React.Component {
@@ -33,7 +33,7 @@ export default class PageEditor extends React.Component {
       previewOptions: this.props.previewOptions,
     };
 
-    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiRenderer, {mode: 'editor'});
+    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiRenderer, { mode: 'editor' });
 
     this.setCaretLine = this.setCaretLine.bind(this);
     this.focusToEditor = this.focusToEditor.bind(this);
@@ -71,12 +71,12 @@ export default class PageEditor extends React.Component {
   setMarkdown(markdown, updateEditorValue = true) {
     this.setState({ markdown });
     if (updateEditorValue) {
-      this.refs.editor.setValue(markdown);
+      this.editor.setValue(markdown);
     }
   }
 
   focusToEditor() {
-    this.refs.editor.forceToFocus();
+    this.editor.forceToFocus();
   }
 
   /**
@@ -84,7 +84,7 @@ export default class PageEditor extends React.Component {
    * @param {number} line
    */
   setCaretLine(line) {
-    this.refs.editor.setCaretLine(line);
+    this.editor.setCaretLine(line);
     scrollSyncHelper.scrollPreview(this.previewElement, line);
   }
 
@@ -115,33 +115,22 @@ export default class PageEditor extends React.Component {
 
   /**
    * the upload event handler
-   * @param {any} files
+   * @param {any} file
    */
   async onUpload(file) {
     try {
-      let res  = await this.props.crowi.apiGet('/attachments.limit', {_csrf: this.props.crowi.csrfToken, fileSize: file.size});
+      let res = await this.props.crowi.apiGet('/attachments.limit', { _csrf: this.props.crowi.csrfToken, fileSize: file.size });
       if (!res.isUploadable) {
-        toastr.error(undefined, 'MongoDB for uploading files reaches limit', {
-          closeButton: true,
-          progressBar: true,
-          newestOnTop: false,
-          showDuration: '100',
-          hideDuration: '100',
-          timeOut: '5000',
-        });
-        throw new Error('MongoDB for uploading files reaches limit');
+        throw new Error(res.errorMessage);
       }
-      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.state.pageId || 0);
 
-      // post
-      res = await this.props.crowi.apiPost(endpoint, formData);
+      res = await this.props.crowi.apiPost('/attachments.add', formData);
       const attachment = res.attachment;
       const fileName = attachment.originalName;
 
@@ -149,9 +138,9 @@ export default class PageEditor extends React.Component {
       // when image
       if (attachment.fileFormat.startsWith('image/')) {
         // modify to "![fileName](url)" syntax
-        insertText = '!' + insertText;
+        insertText = `!${insertText}`;
       }
-      this.refs.editor.insertText(insertText);
+      this.editor.insertText(insertText);
 
       // when if created newly
       if (res.pageCreated) {
@@ -162,13 +151,14 @@ export default class PageEditor extends React.Component {
       this.apiErrorHandler(e);
     }
     finally {
-      this.refs.editor.terminateUploadingState();
+      this.editor.terminateUploadingState();
     }
   }
 
   /**
    * the scroll event handler from codemirror
-   * @param {any} data {left, top, width, height, clientWidth, clientHeight} object that represents the current scroll position, the size of the scrollable area, and the size of the visible area (minus scrollbars).
+   * @param {any} data {left, top, width, height, clientWidth, clientHeight} object that represents the current scroll position,
+   *                    the size of the scrollable area, and the size of the visible area (minus scrollbars).
    *                    And data.line is also available that is added by Editor component
    * @see https://codemirror.net/doc/manual.html#events
    */
@@ -253,13 +243,13 @@ export default class PageEditor extends React.Component {
 
     // prevent circular invocation
     if (this.isOriginOfScrollSyncEditor) {
-      this.isOriginOfScrollSyncEditor = false;  // turn off the flag
+      this.isOriginOfScrollSyncEditor = false; // turn off the flag
       return;
     }
 
     // turn on the flag
     this.isOriginOfScrollSyncPreview = true;
-    scrollSyncHelper.scrollEditor(this.refs.editor, this.previewElement, offset);
+    scrollSyncHelper.scrollEditor(this.editor, this.previewElement, offset);
   }
 
   saveDraft() {
@@ -268,6 +258,7 @@ export default class PageEditor extends React.Component {
       this.props.crowi.saveDraft(this.props.pagePath, this.state.markdown);
     }
   }
+
   clearDraft() {
     this.props.crowi.clearDraft(this.props.pagePath);
   }
@@ -278,32 +269,32 @@ export default class PageEditor extends React.Component {
     // render html
     const context = {
       markdown: this.state.markdown,
-      currentPagePath: decodeURIComponent(location.pathname)
+      currentPagePath: decodeURIComponent(window.location.pathname),
     };
 
     const growiRenderer = this.growiRenderer;
     const interceptorManager = this.props.crowi.interceptorManager;
     interceptorManager.process('preRenderPreview', context)
-      .then(() => interceptorManager.process('prePreProcess', context))
+      .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => {
         context.markdown = growiRenderer.preProcess(context.markdown);
       })
-      .then(() => interceptorManager.process('postPreProcess', context))
+      .then(() => { return interceptorManager.process('postPreProcess', context) })
       .then(() => {
         const parsedHTML = growiRenderer.process(context.markdown);
-        context['parsedHTML'] = parsedHTML;
+        context.parsedHTML = parsedHTML;
       })
-      .then(() => interceptorManager.process('prePostProcess', context))
+      .then(() => { return interceptorManager.process('prePostProcess', context) })
       .then(() => {
         context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
       })
-      .then(() => interceptorManager.process('postPostProcess', context))
-      .then(() => interceptorManager.process('preRenderPreviewHtml', context))
+      .then(() => { return interceptorManager.process('postPostProcess', context) })
+      .then(() => { return interceptorManager.process('preRenderPreviewHtml', context) })
       .then(() => {
         this.setState({ html: context.parsedHTML });
       })
       // process interceptors for post rendering
-      .then(() => interceptorManager.process('postRenderPreviewHtml', context));
+      .then(() => { return interceptorManager.process('postRenderPreviewHtml', context) });
 
   }
 
@@ -326,7 +317,9 @@ export default class PageEditor extends React.Component {
     return (
       <div className="row">
         <div className="col-md-6 col-sm-12 page-editor-editor-container">
-          <Editor ref="editor" value={this.state.markdown}
+          <Editor
+            ref={(c) => { this.editor = c }}
+            value={this.state.markdown}
             editorOptions={this.state.editorOptions}
             noCdn={noCdn}
             isMobile={this.props.crowi.isMobile}
@@ -343,8 +336,10 @@ export default class PageEditor extends React.Component {
           />
         </div>
         <div className="col-md-6 hidden-sm hidden-xs page-editor-preview-container">
-          <Preview html={this.state.html}
-            inputRef={el => this.previewElement = el}
+          <Preview
+            html={this.state.html}
+            // eslint-disable-next-line no-return-assign
+            inputRef={(el) => { return this.previewElement = el }}
             isMathJaxEnabled={this.state.isMathJaxEnabled}
             renderMathJaxOnInit={false}
             previewOptions={this.state.previewOptions}
@@ -354,6 +349,7 @@ export default class PageEditor extends React.Component {
       </div>
     );
   }
+
 }
 
 PageEditor.propTypes = {

+ 4 - 2
src/client/js/components/PageEditor/AbstractEditor.js

@@ -1,3 +1,5 @@
+/* eslint-disable react/no-unused-prop-types */
+
 import React from 'react';
 import PropTypes from 'prop-types';
 
@@ -116,11 +118,12 @@ export default class AbstractEditor extends React.Component {
   getNavbarItems() {
     return null;
   }
+
 }
 
 AbstractEditor.propTypes = {
   value: PropTypes.string,
-  ifGfmMode: PropTypes.bool,
+  isGfmMode: PropTypes.bool,
   editorOptions: PropTypes.object,
   onChange: PropTypes.func,
   onScroll: PropTypes.func,
@@ -133,4 +136,3 @@ AbstractEditor.propTypes = {
 AbstractEditor.defaultProps = {
   isGfmMode: true,
 };
-

+ 22 - 18
src/client/js/components/PageEditor/Cheatsheet.js

@@ -1,8 +1,11 @@
+/* eslint-disable max-len */
+
 import React from 'react';
 import PropTypes from 'prop-types';
-import { translate } from 'react-i18next';
+import { withTranslation } from 'react-i18next';
 
 class Cheatsheet extends React.Component {
+
   render() {
     const { t } = this.props;
 
@@ -11,9 +14,9 @@ class Cheatsheet extends React.Component {
         <div className="col-sm-6">
           <h4>{t('sandbox.header')}</h4>
           <ul className="hljs">
-            <li><code># </code>{t('sandbox.header_x', {index: '1'})}</li>
-            <li><code>## </code>{t('sandbox.header_x', {index: '2'})}</li>
-            <li><code>### </code>{t('sandbox.header_x', {index: '3'})}</li>
+            <li><code># </code>{t('sandbox.header_x', { index: '1' })}</li>
+            <li><code>## </code>{t('sandbox.header_x', { index: '2' })}</li>
+            <li><code>### </code>{t('sandbox.header_x', { index: '3' })}</li>
           </ul>
           <h4>{t('sandbox.block')}</h4>
           <p className="mb-1"><code>[{t('sandbox.empty_line')}]</code>{t('sandbox.block_detail')}</p>
@@ -33,7 +36,7 @@ class Cheatsheet extends React.Component {
             <li><i>*{t('sandbox.italics')}*</i></li>
             <li><b>**{t('sandbox.bold')}**</b></li>
             <li><i><b>***{t('sandbox.italic_bold')}***</b></i></li>
-            <li>~~{t('sandbox.strikethrough')}~~ => <s>{t('sandbox.strikethrough')}</s></li>
+            <li>~~{t('sandbox.strikethrough')}~~ =&lt; <s>{t('sandbox.strikethrough')}</s></li>
           </ul>
           <h4>{t('sandbox.link')}</h4>
           <ul className="hljs">
@@ -50,13 +53,13 @@ class Cheatsheet extends React.Component {
         <div className="col-sm-6">
           <h4>{t('sandbox.list')}</h4>
           <ul className="hljs">
-            <li>- {t('sandbox.unordered_list_x', {index: '1'})}</li>
-            <li>&nbsp;&nbsp;- {t('sandbox.unordered_list_x', {index: '1.1'})}</li>
-            <li>- {t('sandbox.unordered_list_x', {index: '2'})}</li>
+            <li>- {t('sandbox.unordered_list_x', { index: '1' })}</li>
+            <li>&nbsp;&nbsp;- {t('sandbox.unordered_list_x', { index: '1.1' })}</li>
+            <li>- {t('sandbox.unordered_list_x', { index: '2' })}</li>
           </ul>
           <ul className="hljs">
-            <li>1. {t('sandbox.ordered_list_x', {index: '1'})}</li>
-            <li>1. {t('sandbox.ordered_list_x', {index: '2'})}</li>
+            <li>1. {t('sandbox.ordered_list_x', { index: '1' })}</li>
+            <li>1. {t('sandbox.ordered_list_x', { index: '2' })}</li>
           </ul>
           <ul className="hljs">
             <li>- [ ] {t('sandbox.task')}({t('sandbox.task_unchecked')})</li>
@@ -64,13 +67,13 @@ class Cheatsheet extends React.Component {
           </ul>
           <h4>{t('sandbox.quote')}</h4>
           <ul className="hljs">
-            <li>> {t('sandbox.quote1')}</li>
-            <li>> {t('sandbox.quote2')}</li>
+            <li>&gt; {t('sandbox.quote1')}</li>
+            <li>&gt; {t('sandbox.quote2')}</li>
           </ul>
           <ul className="hljs">
-            <li>>> {t('sandbox.quote_nested')}</li>
-            <li>>>> {t('sandbox.quote_nested')}</li>
-            <li>>>>> {t('sandbox.quote_nested')}</li>
+            <li>&gt;&gt; {t('sandbox.quote_nested')}</li>
+            <li>&gt;&gt;&gt; {t('sandbox.quote_nested')}</li>
+            <li>&gt;&gt;&gt;&gt; {t('sandbox.quote_nested')}</li>
           </ul>
           <h4>{t('sandbox.table')}</h4>
           <ul className="hljs text-center">
@@ -87,16 +90,17 @@ class Cheatsheet extends React.Component {
 
           <hr />
           <a href="/Sandbox" className="btn btn-info btn-block" target="_blank">
-            <i className="icon-share-alt"/> {t('sandbox.open_sandbox')}
+            <i className="icon-share-alt" /> {t('sandbox.open_sandbox')}
           </a>
         </div>
       </div>
     );
   }
+
 }
 
 Cheatsheet.propTypes = {
-  t: PropTypes.func.isRequired,               // i18next
+  t: PropTypes.func.isRequired, // i18next
 };
 
-export default translate()(Cheatsheet);
+export default withTranslation()(Cheatsheet);

+ 174 - 120
src/client/js/components/PageEditor/CodeMirrorEditor.js

@@ -3,14 +3,25 @@ import PropTypes from 'prop-types';
 
 import Modal from 'react-bootstrap/es/Modal';
 import Button from 'react-bootstrap/es/Button';
+import urljoin from 'url-join';
+import * as codemirror from 'codemirror';
+
+import { UnControlled as ReactCodeMirror } from 'react-codemirror2';
 
 import InterceptorManager from '@commons/service/interceptor-manager';
 
-import urljoin from 'url-join';
+import AbstractEditor from './AbstractEditor';
+import SimpleCheatsheet from './SimpleCheatsheet';
+import Cheatsheet from './Cheatsheet';
+import pasteHelper from './PasteHelper';
+import EmojiAutoCompleteHelper from './EmojiAutoCompleteHelper';
+import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
+import MarkdownTableInterceptor from './MarkdownTableInterceptor';
+import mtu from './MarkdownTableUtil';
+import HandsontableModal from './HandsontableModal';
+
 const loadScript = require('simple-load-script');
 const loadCssSync = require('load-css-file');
-
-import * as codemirror from 'codemirror';
 // set save handler
 codemirror.commands.save = (instance) => {
   if (instance.codeMirrorEditor != null) {
@@ -19,9 +30,6 @@ codemirror.commands.save = (instance) => {
 };
 // set CodeMirror instance as 'CodeMirror' so that CDN addons can reference
 window.CodeMirror = require('codemirror');
-
-
-import { UnControlled as ReactCodeMirror } from 'react-codemirror2';
 require('codemirror/addon/display/placeholder');
 require('codemirror/addon/edit/matchbrackets');
 require('codemirror/addon/edit/matchtags');
@@ -42,19 +50,6 @@ require('codemirror/addon/display/placeholder');
 require('codemirror/mode/gfm/gfm');
 require('../../util/codemirror/autorefresh.ext');
 
-import AbstractEditor from './AbstractEditor';
-
-import SimpleCheatsheet from './SimpleCheatsheet';
-import Cheatsheet from './Cheatsheet';
-
-import pasteHelper from './PasteHelper';
-import EmojiAutoCompleteHelper from './EmojiAutoCompleteHelper';
-
-import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
-import MarkdownTableInterceptor from './MarkdownTableInterceptor';
-import mtu from './MarkdownTableUtil';
-import HandsontableModal from './HandsontableModal';
-
 export default class CodeMirrorEditor extends AbstractEditor {
 
   constructor(props) {
@@ -110,14 +105,14 @@ export default class CodeMirrorEditor extends AbstractEditor {
       new MarkdownTableInterceptor(),
     ]);
 
-    this.loadedThemeSet = new Set(['eclipse', 'elegant']);   // themes imported in _vendor.scss
+    this.loadedThemeSet = new Set(['eclipse', 'elegant']); // themes imported in _vendor.scss
     this.loadedKeymapSet = new Set();
   }
 
   componentWillMount() {
     if (this.props.emojiStrategy != null) {
       this.emojiAutoCompleteHelper = new EmojiAutoCompleteHelper(this.props.emojiStrategy);
-      this.setState({isEnabledEmojiAutoComplete: true});
+      this.setState({ isEnabledEmojiAutoComplete: true });
     }
   }
 
@@ -137,7 +132,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
   }
 
   getCodeMirror() {
-    return this.refs.cm.editor;
+    return this.cm.editor;
   }
 
   /**
@@ -185,14 +180,14 @@ export default class CodeMirrorEditor extends AbstractEditor {
    * @inheritDoc
    */
   setCaretLine(line) {
-    if (isNaN(line)) {
+    if (Number.isNaN(line)) {
       return;
     }
 
     const editor = this.getCodeMirror();
     const linePosition = Math.max(0, line);
 
-    editor.setCursor({line: linePosition});   // leave 'ch' field as null/undefined to indicate the end of line
+    editor.setCursor({ line: linePosition }); // leave 'ch' field as null/undefined to indicate the end of line
     this.setScrollTopByLine(linePosition);
   }
 
@@ -200,13 +195,13 @@ export default class CodeMirrorEditor extends AbstractEditor {
    * @inheritDoc
    */
   setScrollTopByLine(line) {
-    if (isNaN(line)) {
+    if (Number.isNaN(line)) {
       return;
     }
 
     const editor = this.getCodeMirror();
     // get top position of the line
-    const top = editor.charCoords({line, ch: 0}, 'local').top;
+    const top = editor.charCoords({ line, ch: 0 }, 'local').top;
     editor.scrollTo(null, top);
   }
 
@@ -331,11 +326,11 @@ export default class CodeMirrorEditor extends AbstractEditor {
    */
   loadKeymapMode(keymapMode) {
     const loadCss = this.loadCss;
-    let scriptList = [];
-    let cssList = [];
+    const scriptList = [];
+    const cssList = [];
 
     // add dependencies
-    if (this.loadedKeymapSet.size == 0) {
+    if (this.loadedKeymapSet.size === 0) {
       const dialogScriptUrl = this.props.noCdn
         ? urljoin(this.cmNoCdnScriptRoot, 'codemirror-dialog.js')
         : urljoin(this.cmCdnRoot, 'addon/dialog/dialog.min.js');
@@ -394,14 +389,14 @@ export default class CodeMirrorEditor extends AbstractEditor {
     }
 
     const context = {
-      handlers: [],  // list of handlers which process enter key
+      handlers: [], // list of handlers which process enter key
       editor: this,
     };
 
     const interceptorManager = this.interceptorManager;
     interceptorManager.process('preHandleEnter', context)
       .then(() => {
-        if (context.handlers.length == 0) {
+        if (context.handlers.length === 0) {
           codemirror.commands.newlineAndIndentContinueMarkdownList(this.getCodeMirror());
         }
       });
@@ -432,13 +427,14 @@ export default class CodeMirrorEditor extends AbstractEditor {
     if (mtu.isEndOfLine(editor) && mtu.linePartOfTableRE.test(strFromBol)) {
       if (!hasCustomClass) {
         additionalClassSet.add(autoformatTableClass);
-        this.setState({additionalClassSet});
+        this.setState({ additionalClassSet });
       }
     }
     else {
+      // eslint-disable-next-line no-lonely-if
       if (hasCustomClass) {
         additionalClassSet.delete(autoformatTableClass);
-        this.setState({additionalClassSet});
+        this.setState({ additionalClassSet });
       }
     }
   }
@@ -479,16 +475,13 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
   /**
    * update states which related to cheatsheet
-   * @param {boolean} isGfmMode (use state.isGfmMode if null is set)
-   * @param {string} value (get value from codemirror if null is set)
+   * @param {boolean} isGfmModeTmp (use state.isGfmMode if null is set)
+   * @param {string} valueTmp (get value from codemirror if null is set)
    */
-  updateCheatsheetStates(isGfmMode, value) {
-    if (isGfmMode == null) {
-      isGfmMode = this.state.isGfmMode;
-    }
-    if (value == null) {
-      value = this.getCodeMirror().getDoc().getValue();
-    }
+  updateCheatsheetStates(isGfmModeTmp, valueTmp) {
+    const isGfmMode = isGfmModeTmp || this.state.isGfmMode;
+    const value = valueTmp || this.getCodeMirror().getDoc().getValue();
+
     // update isSimpleCheatsheetShown, isCheatsheetModalButtonShown
     const isSimpleCheatsheetShown = isGfmMode && value.length === 0;
     const isCheatsheetModalButtonShown = isGfmMode && value.length > 0;
@@ -505,11 +498,13 @@ export default class CodeMirrorEditor extends AbstractEditor {
     };
 
     return this.state.isLoadingKeymap
-      ? <div className="overlay overlay-loading-keymap">
+      ? (
+        <div className="overlay overlay-loading-keymap">
           <span style={style} className="overlay-content">
             <div className="speeding-wheel d-inline-block"></div> Loading Keymap ...
           </span>
         </div>
+      )
       : '';
   }
 
@@ -523,27 +518,27 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
   renderCheatsheetModalButton() {
     const showCheatsheetModal = () => {
-      this.setState({isCheatsheetModalShown: true});
+      this.setState({ isCheatsheetModalShown: true });
     };
 
     const hideCheatsheetModal = () => {
-      this.setState({isCheatsheetModalShown: false});
+      this.setState({ isCheatsheetModalShown: false });
     };
 
     return (
       <React.Fragment>
         <Modal className="modal-gfm-cheatsheet" show={this.state.isCheatsheetModalShown} onHide={() => { hideCheatsheetModal() }}>
           <Modal.Header closeButton>
-            <Modal.Title><i className="icon-fw icon-question"/>Markdown Help</Modal.Title>
+            <Modal.Title><i className="icon-fw icon-question" />Markdown Help</Modal.Title>
           </Modal.Header>
           <Modal.Body className="pt-1">
             { this.renderCheatsheetModalBody() }
           </Modal.Body>
         </Modal>
 
-        <a className="gfm-cheatsheet-modal-link text-muted small" onClick={() => { showCheatsheetModal() }}>
+        <button type="button" className="btn-link gfm-cheatsheet-modal-link text-muted small mr-3" onClick={() => { showCheatsheetModal() }}>
           <i className="icon-question" /> Markdown
-        </a>
+        </button>
       </React.Fragment>
     );
   }
@@ -590,8 +585,8 @@ export default class CodeMirrorEditor extends AbstractEditor {
       for (let i = startLineNum; i <= endLineNum; i++) {
         lines.push(prefix + cm.getDoc().getLine(i));
       }
-      const replacement = lines.join('\n') + '\n';
-      cm.getDoc().replaceRange(replacement, {line: startLineNum, ch: 0}, {line: endLineNum + 1, ch: 0});
+      const replacement = `${lines.join('\n')}\n`;
+      cm.getDoc().replaceRange(replacement, { line: startLineNum, ch: 0 }, { line: endLineNum + 1, ch: 0 });
 
       cm.setCursor(endLineNum, cm.getDoc().getLine(endLineNum).length);
       cm.focus();
@@ -611,69 +606,123 @@ export default class CodeMirrorEditor extends AbstractEditor {
     if (!line.startsWith('#')) {
       prefix += ' ';
     }
-    cm.getDoc().replaceRange(prefix, {line: lineNum, ch: 0}, {line: lineNum, ch: 0});
+    cm.getDoc().replaceRange(prefix, { line: lineNum, ch: 0 }, { line: lineNum, ch: 0 });
     cm.focus();
   }
 
   showHandsonTableHandler() {
-    this.refs.handsontableModal.show(mtu.getMarkdownTable(this.getCodeMirror()));
+    this.handsontableModal.show(mtu.getMarkdownTable(this.getCodeMirror()));
   }
 
   getNavbarItems() {
     // The following styles will be removed after creating icons for the editor navigation bar.
-    const paddingTopBottom54 = {'paddingTop': '6px', 'paddingBottom': '5px'};
-    const paddingBottom6 = {'paddingBottom': '7px'};
-    const fontSize18 = {'fontSize': '18px'};
+    const paddingTopBottom54 = { paddingTop: '6px', paddingBottom: '5px' };
+    const paddingBottom6 = { paddingBottom: '7px' };
+    const fontSize18 = { fontSize: '18px' };
 
     return [
-      <Button key='nav-item-bold' bsSize="small" title={'Bold'}
-              onClick={ this.createReplaceSelectionHandler('**', '**') }>
-        <i className={'fa fa-bold'}></i>
+      <Button
+        key="nav-item-bold"
+        bsSize="small"
+        title="Bold"
+        onClick={this.createReplaceSelectionHandler('**', '**')}
+      >
+        <i className="fa fa-bold"></i>
       </Button>,
-      <Button key='nav-item-italic' bsSize="small" title={'Italic'}
-              onClick={ this.createReplaceSelectionHandler('*', '*') }>
-        <i className={'fa fa-italic'}></i>
+      <Button
+        key="nav-item-italic"
+        bsSize="small"
+        title="Italic"
+        onClick={this.createReplaceSelectionHandler('*', '*')}
+      >
+        <i className="fa fa-italic"></i>
       </Button>,
-      <Button key='nav-item-strikethough' bsSize="small" title={'Strikethrough'}
-              onClick={ this.createReplaceSelectionHandler('~~', '~~') }>
-        <i className={'fa fa-strikethrough'}></i>
+      <Button
+        key="nav-item-strikethough"
+        bsSize="small"
+        title="Strikethrough"
+        onClick={this.createReplaceSelectionHandler('~~', '~~')}
+      >
+        <i className="fa fa-strikethrough"></i>
       </Button>,
-      <Button key='nav-item-header' bsSize="small" title={'Heading'}
-              onClick={ this.makeHeaderHandler }>
-        <i className={'fa fa-header'}></i>
+      <Button
+        key="nav-item-header"
+        bsSize="small"
+        title="Heading"
+        onClick={this.makeHeaderHandler}
+      >
+        <i className="fa fa-header"></i>
       </Button>,
-      <Button key='nav-item-code' bsSize="small" title={'Inline Code'}
-              onClick={ this.createReplaceSelectionHandler('`', '`') }>
-        <i className={'fa fa-code'}></i>
+      <Button
+        key="nav-item-code"
+        bsSize="small"
+        title="Inline Code"
+        onClick={this.createReplaceSelectionHandler('`', '`')}
+      >
+        <i className="fa fa-code"></i>
       </Button>,
-      <Button key='nav-item-quote' bsSize="small" title={'Quote'}
-              onClick={ this.createAddPrefixToEachLinesHandler('> ') } style={paddingBottom6}>
-        <i className={'ti-quote-right'}></i>
+      <Button
+        key="nav-item-quote"
+        bsSize="small"
+        title="Quote"
+        onClick={this.createAddPrefixToEachLinesHandler('> ')}
+        style={paddingBottom6}
+      >
+        <i className="ti-quote-right"></i>
       </Button>,
-      <Button key='nav-item-ul' bsSize="small" title={'List'}
-              onClick={ this.createAddPrefixToEachLinesHandler('- ') } style={paddingTopBottom54}>
-        <i className={'ti-list'} style={fontSize18}></i>
+      <Button
+        key="nav-item-ul"
+        bsSize="small"
+        title="List"
+        onClick={this.createAddPrefixToEachLinesHandler('- ')}
+        style={paddingTopBottom54}
+      >
+        <i className="ti-list" style={fontSize18}></i>
       </Button>,
-      <Button key='nav-item-ol' bsSize="small" title={'Numbered List'}
-              onClick={ this.createAddPrefixToEachLinesHandler('1. ') } style={paddingTopBottom54}>
-        <i className={'ti-list-ol'} style={fontSize18}></i>
+      <Button
+        key="nav-item-ol"
+        bsSize="small"
+        title="Numbered List"
+        onClick={this.createAddPrefixToEachLinesHandler('1. ')}
+        style={paddingTopBottom54}
+      >
+        <i className="ti-list-ol" style={fontSize18}></i>
       </Button>,
-      <Button key='nav-item-checkbox' bsSize="small" title={'Check List'}
-              onClick={ this.createAddPrefixToEachLinesHandler('- [ ] ') } style={paddingBottom6}>
-        <i className={'ti-check-box'}></i>
+      <Button
+        key="nav-item-checkbox"
+        bsSize="small"
+        title="Check List"
+        onClick={this.createAddPrefixToEachLinesHandler('- [ ] ')}
+        style={paddingBottom6}
+      >
+        <i className="ti-check-box"></i>
       </Button>,
-      <Button key='nav-item-link' bsSize="small" title={'Link'}
-              onClick={ this.createReplaceSelectionHandler('[', ']()') } style={paddingBottom6}>
-        <i className={'icon-link'}></i>
+      <Button
+        key="nav-item-link"
+        bsSize="small"
+        title="Link"
+        onClick={this.createReplaceSelectionHandler('[', ']()')}
+        style={paddingBottom6}
+      >
+        <i className="icon-link"></i>
       </Button>,
-      <Button key='nav-item-image' bsSize="small" title={'Image'}
-              onClick={ this.createReplaceSelectionHandler('![', ']()') } style={paddingBottom6}>
-        <i className={'icon-picture'}></i>
+      <Button
+        key="nav-item-image"
+        bsSize="small"
+        title="Image"
+        onClick={this.createReplaceSelectionHandler('![', ']()')}
+        style={paddingBottom6}
+      >
+        <i className="icon-picture"></i>
+      </Button>,
+      <Button
+        key="nav-item-table"
+        bsSize="small"
+        title="Table"
+        onClick={this.showHandsonTableHandler}
+      >
+        <img src="/images/icons/editor/table.svg" alt="icon-table" width="14" height="14" />
       </Button>,
-      <Button key='nav-item-table' bsSize="small" title={'Table'}
-              onClick={ this.showHandsonTableHandler }>
-        <img src="/images/icons/editor/table.svg" width="14" height="14" />
-      </Button>
     ];
   }
 
@@ -688,50 +737,51 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
     const placeholder = this.state.isGfmMode ? 'Input with Markdown..' : 'Input with Plane Text..';
 
-    return <React.Fragment>
+    return (
+      <React.Fragment>
 
-      <ReactCodeMirror
-        ref="cm"
-        className={additionalClasses}
-        placeholder="search"
-        editorDidMount={(editor) => {
+        <ReactCodeMirror
+          ref={(c) => { this.cm = c }}
+          className={additionalClasses}
+          placeholder="search"
+          editorDidMount={(editor) => {
           // add event handlers
           editor.on('paste', this.pasteHandler);
           editor.on('scrollCursorIntoView', this.scrollCursorIntoViewHandler);
         }}
-        value={this.state.value}
-        options={{
-          mode: mode,
+          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
+          autoRefresh: { force: true }, // force option is enabled by autorefresh.ext.js -- Yuki Takei
           autoCloseTags: true,
-          placeholder: placeholder,
+          placeholder,
           matchBrackets: true,
-          matchTags: {bothTags: 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},
+          highlightSelectionMatches: { annotateScrollbar: true },
           // markdown mode options
           highlightFormatting: true,
           // continuelist, indentlist
           extraKeys: {
-            'Enter': this.handleEnterKey,
+            Enter: this.handleEnterKey,
             'Ctrl-Enter': this.handleCtrlEnterKey,
             'Cmd-Enter': this.handleCtrlEnterKey,
-            'Tab': 'indentMore',
+            Tab: 'indentMore',
             'Shift-Tab': 'indentLess',
             'Ctrl-Q': (cm) => { cm.foldCode(cm.getCursor()) },
-          }
+          },
         }}
-        onCursor={this.cursorHandler}
-        onScroll={(editor, data) => {
+          onCursor={this.cursorHandler}
+          onScroll={(editor, data) => {
           if (this.props.onScroll != null) {
             // add line data
             const line = editor.lineAtHeight(data.top, 'local');
@@ -739,23 +789,27 @@ export default class CodeMirrorEditor extends AbstractEditor {
             this.props.onScroll(data);
           }
         }}
-        onChange={this.changeHandler}
-        onDragEnter={(editor, event) => {
+          onChange={this.changeHandler}
+          onDragEnter={(editor, event) => {
           if (this.props.onDragEnter != null) {
             this.props.onDragEnter(event);
           }
         }}
-      />
+        />
 
-      { this.renderLoadingKeymapOverlay() }
+        { this.renderLoadingKeymapOverlay() }
 
-      <div className="overlay overlay-gfm-cheatsheet mt-1 p-3 pt-3">
-        { this.state.isSimpleCheatsheetShown && this.renderSimpleCheatsheet() }
-        { this.state.isCheatsheetModalButtonShown && this.renderCheatsheetModalButton() }
-      </div>
+        <div className="overlay overlay-gfm-cheatsheet mt-1 p-3 pt-3">
+          { this.state.isSimpleCheatsheetShown && this.renderSimpleCheatsheet() }
+          { this.state.isCheatsheetModalButtonShown && this.renderCheatsheetModalButton() }
+        </div>
 
-      <HandsontableModal ref='handsontableModal' onSave={ table => mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }/>
-    </React.Fragment>;
+        <HandsontableModal
+          ref={(c) => { this.handsontableModal = c }}
+          onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}
+        />
+      </React.Fragment>
+    );
   }
 
 }

+ 92 - 67
src/client/js/components/PageEditor/Editor.jsx

@@ -1,11 +1,11 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import Dropzone from 'react-dropzone';
 import AbstractEditor from './AbstractEditor';
 import CodeMirrorEditor from './CodeMirrorEditor';
 import TextAreaEditor from './TextAreaEditor';
 
-import Dropzone from 'react-dropzone';
 
 import pasteHelper from './PasteHelper';
 
@@ -28,7 +28,7 @@ export default class Editor extends AbstractEditor {
     this.dragLeaveHandler = this.dragLeaveHandler.bind(this);
     this.dropHandler = this.dropHandler.bind(this);
 
-    this.getDropzoneAccept = this.getDropzoneAccept.bind(this);
+    this.getAcceptableType = this.getAcceptableType.bind(this);
     this.getDropzoneClassName = this.getDropzoneClassName.bind(this);
     this.renderDropzoneOverlay = this.renderDropzoneOverlay.bind(this);
   }
@@ -39,8 +39,8 @@ export default class Editor extends AbstractEditor {
 
   getEditorSubstance() {
     return this.props.isMobile
-      ? this.refs.taEditor
-      : this.refs.cmEditor;
+      ? this.taEditor
+      : this.cmEditor;
   }
 
   /**
@@ -104,8 +104,24 @@ export default class Editor extends AbstractEditor {
     }
   }
 
+  /**
+   * get acceptable(uploadable) file type
+   */
+  getAcceptableType() {
+    let accept = 'null'; // reject all
+    if (this.props.isUploadable) {
+      if (!this.props.isUploadableFile) {
+        accept = 'image/*'; // image only
+      }
+      else {
+        accept = ''; // allow all
+      }
+    }
+
+    return accept;
+  }
+
   pasteFilesHandler(event) {
-    const dropzone = this.refs.dropzone;
     const items = event.clipboardData.items || event.clipboardData.files || [];
 
     // abort if length is not 1
@@ -116,11 +132,8 @@ export default class Editor extends AbstractEditor {
     for (let i = 0; i < items.length; i++) {
       try {
         const file = items[i].getAsFile();
-        // check type and size
-        if (file != null &&
-            pasteHelper.fileAccepted(file, dropzone.props.accept) &&
-            pasteHelper.fileMatchSize(file, dropzone.props.maxSize, dropzone.props.minSize)) {
-
+        // check file type (the same process as Dropzone)
+        if (file != null && pasteHelper.isAcceptableType(file, this.getAcceptableType())) {
           this.dispatchUpload(file);
           this.setState({ isUploading: true });
         }
@@ -148,7 +161,7 @@ export default class Editor extends AbstractEditor {
 
   dropHandler(accepted, rejected) {
     // rejected
-    if (accepted.length != 1) { // length should be 0 or 1 because `multiple={false}` is set
+    if (accepted.length !== 1) { // length should be 0 or 1 because `multiple={false}` is set
       this.setState({ dropzoneActive: false });
       return;
     }
@@ -158,21 +171,7 @@ export default class Editor extends AbstractEditor {
     this.setState({ isUploading: true });
   }
 
-  getDropzoneAccept() {
-    let accept = 'null';    // reject all
-    if (this.props.isUploadable) {
-      if (!this.props.isUploadableFile) {
-        accept = 'image/*'; // image only
-      }
-      else {
-        accept = '';        // allow all
-      }
-    }
-
-    return accept;
-  }
-
-  getDropzoneClassName() {
+  getDropzoneClassName(isDragAccept, isDragReject) {
     let className = 'dropzone';
     if (!this.props.isUploadable) {
       className += ' dropzone-unuploadable';
@@ -190,17 +189,27 @@ export default class Editor extends AbstractEditor {
       className += ' dropzone-uploading';
     }
 
+    if (isDragAccept) {
+      className += ' dropzone-accepted';
+    }
+
+    if (isDragReject) {
+      className += ' dropzone-rejected';
+    }
+
     return className;
   }
 
   renderDropzoneOverlay() {
     return (
       <div className="overlay overlay-dropzone-active">
-        {this.state.isUploading &&
+        {this.state.isUploading
+          && (
           <span className="overlay-content">
             <div className="speeding-wheel d-inline-block"></div>
             <span className="sr-only">Uploading...</span>
           </span>
+          )
         }
         {!this.state.isUploading && <span className="overlay-content"></span>}
       </div>
@@ -212,7 +221,8 @@ export default class Editor extends AbstractEditor {
       <div className="m-0 navbar navbar-default navbar-editor" style={{ minHeight: 'unset' }}>
         <ul className="pl-2 nav nav-navbar">
           { this.getNavbarItems() != null && this.getNavbarItems().map((item, idx) => {
-            return <li key={idx}>{item}</li>;
+            // eslint-disable-next-line react/no-array-index-key
+            return <li key={`navbarItem-${idx}`}>{item}</li>;
           }) }
         </ul>
       </div>
@@ -239,46 +249,61 @@ export default class Editor extends AbstractEditor {
     return (
       <div style={flexContainer} className="editor-container">
         <Dropzone
-            ref="dropzone"
-            disableClick
-            accept={this.getDropzoneAccept()}
-            className={this.getDropzoneClassName()}
-            acceptClassName="dropzone-accepted"
-            rejectClassName="dropzone-rejected"
-            multiple={false}
-            onDragLeave={this.dragLeaveHandler}
-            onDrop={this.dropHandler}
-          >
-
-          { this.state.dropzoneActive && this.renderDropzoneOverlay() }
-
-          { this.state.isComponentDidMount && this.renderNavbar() }
-
-          {/* for PC */}
-          { !isMobile &&
-            <CodeMirrorEditor
-              ref="cmEditor"
-              onPasteFiles={this.pasteFilesHandler}
-              onDragEnter={this.dragEnterHandler}
-              {...this.props}
-            />
-          }
-
-          {/* for mobile */}
-          { isMobile &&
-            <TextAreaEditor
-              ref="taEditor"
-              onPasteFiles={this.pasteFilesHandler}
-              onDragEnter={this.dragEnterHandler}
-              {...this.props}
-            />
-          }
-
+          ref={(c) => { this.dropzone = c }}
+          accept={this.getAcceptableType()}
+          noClick
+          noKeyboard
+          multiple={false}
+          onDragLeave={this.dragLeaveHandler}
+          onDrop={this.dropHandler}
+        >
+          {({
+            getRootProps,
+            getInputProps,
+            isDragAccept,
+            isDragReject,
+          }) => {
+            return (
+              <div className={this.getDropzoneClassName(isDragAccept, isDragReject)} {...getRootProps()}>
+                { this.state.dropzoneActive && this.renderDropzoneOverlay() }
+
+                { this.state.isComponentDidMount && this.renderNavbar() }
+
+                {/* for PC */}
+                { !isMobile && (
+                  <CodeMirrorEditor
+                    ref={(c) => { this.cmEditor = c }}
+                    onPasteFiles={this.pasteFilesHandler}
+                    onDragEnter={this.dragEnterHandler}
+                    {...this.props}
+                  />
+                  )
+                }
+
+                {/* for mobile */}
+                { isMobile && (
+                  <TextAreaEditor
+                    ref={(c) => { this.taEditor = c }}
+                    onPasteFiles={this.pasteFilesHandler}
+                    onDragEnter={this.dragEnterHandler}
+                    {...this.props}
+                  />
+                  )
+                }
+
+                <input {...getInputProps()} />
+              </div>
+            );
+          }}
         </Dropzone>
 
-        { this.props.isUploadable &&
-          <button type="button" className="btn btn-default btn-block btn-open-dropzone"
-            onClick={() => {this.refs.dropzone.open()}}>
+        { this.props.isUploadable
+          && (
+          <button
+            type="button"
+            className="btn btn-default btn-block btn-open-dropzone"
+            onClick={() => { this.dropzone.open() }}
+          >
 
             <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
             Attach files
@@ -288,6 +313,7 @@ export default class Editor extends AbstractEditor {
               or pasting from the clipboard.
             </span>
           </button>
+          )
         }
       </div>
     );
@@ -304,4 +330,3 @@ Editor.propTypes = Object.assign({
   onChange: PropTypes.func,
   onUpload: PropTypes.func,
 }, AbstractEditor.propTypes);
-

+ 17 - 17
src/client/js/components/PageEditor/EmojiAutoCompleteHelper.js

@@ -14,8 +14,7 @@ class EmojiAutoCompleteHelper {
   }
 
   initEmojiImageMap() {
-    for (let unicode in this.emojiStrategy) {
-      const data = this.emojiStrategy[unicode];
+    for (const data of Object.values(this.emojiStrategy)) {
       const shortname = data.shortname;
       // add image tag
       this.emojiShortnameImageMap[shortname] = emojione.shortnameToImage(shortname);
@@ -62,7 +61,7 @@ class EmojiAutoCompleteHelper {
       // closeOnUnfocus: false,  // for debug
       hint: () => {
         const matched = editor.getDoc().getRange(sc.from(), sc.to());
-        const term = matched.replace(':', '');  // remove ':' in the head
+        const term = matched.replace(':', ''); // remove ':' in the head
 
         // get a list of shortnames
         const shortnames = this.searchEmojiShortnames(term);
@@ -87,10 +86,9 @@ class EmojiAutoCompleteHelper {
         text: shortname,
         className: 'crowi-emoji-autocomplete',
         render: (element) => {
-          element.innerHTML =
-            `<div class="img-container">${this.emojiShortnameImageMap[shortname]}</div>` +
-            `<span class="shortname-container">${shortname}</span>`;
-        }
+          element.innerHTML = `<div class="img-container">${this.emojiShortnameImageMap[shortname]}</div>`
+            + `<span class="shortname-container">${shortname}</span>`;
+        },
       };
     });
   }
@@ -103,16 +101,18 @@ class EmojiAutoCompleteHelper {
   searchEmojiShortnames(term) {
     const maxLength = 12;
 
-    let results1 = [], results2 = [], results3 = [], results4 = [];
-    const countLen1 = () => { results1.length };
-    const countLen2 = () => { countLen1() + results2.length };
-    const countLen3 = () => { countLen2() + results3.length };
-    const countLen4 = () => { countLen3() + results4.length };
+    const results1 = [];
+    const results2 = [];
+    const results3 = [];
+    const results4 = [];
+    const countLen1 = () => { return results1.length };
+    const countLen2 = () => { return countLen1() + results2.length };
+    const countLen3 = () => { return countLen2() + results3.length };
+    const countLen4 = () => { return countLen3() + results4.length };
+
     // TODO performance tune
     // when total length of all results is less than `maxLength`
-    for (let unicode in this.emojiStrategy) {
-      const data = this.emojiStrategy[unicode];
-
+    for (const data of Object.values(this.emojiStrategy)) {
       if (maxLength <= countLen1()) { break }
       // prefix match to shortname
       else if (data.shortname.indexOf(`:${term}`) > -1) {
@@ -127,13 +127,13 @@ class EmojiAutoCompleteHelper {
       }
       else if (maxLength <= countLen3()) { continue }
       // partial match to elements of aliases
-      else if ((data.aliases != null) && data.aliases.find(elem => elem.indexOf(term) > -1)) {
+      else if ((data.aliases != null) && data.aliases.find((elem) => { return elem.indexOf(term) > -1 })) {
         results3.push(data.shortname);
         continue;
       }
       else if (maxLength <= countLen4()) { continue }
       // partial match to elements of keywords
-      else if ((data.keywords != null) && data.keywords.find(elem => elem.indexOf(term) > -1)) {
+      else if ((data.keywords != null) && data.keywords.find((elem) => { return elem.indexOf(term) > -1 })) {
         results4.push(data.shortname);
       }
     }

+ 57 - 48
src/client/js/components/PageEditor/HandsontableModal.jsx

@@ -13,10 +13,10 @@ import MarkdownTable from '../../models/MarkdownTable';
 
 const DEFAULT_HOT_HEIGHT = 300;
 const MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING = {
-  'r': 'htRight',
-  'c': 'htCenter',
-  'l': 'htLeft',
-  '': ''
+  r: 'htRight',
+  c: 'htCenter',
+  l: 'htLeft',
+  '': '',
 };
 
 export default class HandsontableModal extends React.PureComponent {
@@ -81,7 +81,7 @@ export default class HandsontableModal extends React.PureComponent {
       {
         markdownTableOnInit: initMarkdownTable,
         markdownTable: initMarkdownTable.clone(),
-      }
+      },
     );
 
     this.manuallyResizedColumnIndicesSet.clear();
@@ -90,11 +90,15 @@ export default class HandsontableModal extends React.PureComponent {
   createCustomizedContextMenu() {
     return {
       items: {
-        'row_above': {}, 'row_below': {}, 'col_left': {}, 'col_right': {},
-        'separator1': Handsontable.plugins.ContextMenu.SEPARATOR,
-        'remove_row': {}, 'remove_col': {},
-        'separator2': Handsontable.plugins.ContextMenu.SEPARATOR,
-        'custom_alignment': {
+        row_above: {},
+        row_below: {},
+        col_left: {},
+        col_right: {},
+        separator1: Handsontable.plugins.ContextMenu.SEPARATOR,
+        remove_row: {},
+        remove_col: {},
+        separator2: Handsontable.plugins.ContextMenu.SEPARATOR,
+        custom_alignment: {
           name: 'Align columns',
           key: 'align_columns',
           submenu: {
@@ -102,20 +106,20 @@ export default class HandsontableModal extends React.PureComponent {
               {
                 name: 'Left',
                 key: 'align_columns:1',
-                callback: (key, selection) => {this.align('l', selection[0].start.col, selection[0].end.col)}
+                callback: (key, selection) => { this.align('l', selection[0].start.col, selection[0].end.col) },
               }, {
                 name: 'Center',
                 key: 'align_columns:2',
-                callback: (key, selection) => {this.align('c', selection[0].start.col, selection[0].end.col)}
+                callback: (key, selection) => { this.align('c', selection[0].start.col, selection[0].end.col) },
               }, {
                 name: 'Right',
                 key: 'align_columns:3',
-                callback: (key, selection) => {this.align('r', selection[0].start.col, selection[0].end.col)}
-              }
-            ]
-          }
-        }
-      }
+                callback: (key, selection) => { this.align('r', selection[0].start.col, selection[0].end.col) },
+              },
+            ],
+          },
+        },
+      },
     };
   }
 
@@ -149,8 +153,8 @@ export default class HandsontableModal extends React.PureComponent {
 
   save() {
     const markdownTable = new MarkdownTable(
-      this.refs.hotTable.hotInstance.getData(),
-      {align: [].concat(this.state.markdownTable.options.align)}
+      this.hotTable.hotInstance.getData(),
+      { align: [].concat(this.state.markdownTable.options.align) },
     ).normalizeCells();
 
     if (this.props.onSave != null) {
@@ -176,7 +180,8 @@ export default class HandsontableModal extends React.PureComponent {
    * In detail, when the setState method is called with those state passed,
    * React will start re-render process for the HotTable of this component because the HotTable receives those state values by props.
    * HotTable#shouldComponentUpdate is called in this re-render process and calls the updateSettings method for the Handsontable instance.
-   * In updateSettings method, the loadData method is called in some case. (refs: https://github.com/handsontable/handsontable/blob/6.2.0/src/core.js#L1652-L1657)
+   * In updateSettings method, the loadData method is called in some case.
+   *  (refs: https://github.com/handsontable/handsontable/blob/6.2.0/src/core.js#L1652-L1657)
    * The updateSettings method calls in the HotTable always lead to call the loadData method because the HotTable passes data source by settings.data.
    * After the loadData method is executed, afterLoadData hooks are called.
    */
@@ -211,7 +216,7 @@ export default class HandsontableModal extends React.PureComponent {
     // store column index
     this.manuallyResizedColumnIndicesSet.add(currentColumn);
     // force re-render
-    const hotInstance = this.refs.hotTable.hotInstance;
+    const hotInstance = this.hotTable.hotInstance;
     hotInstance.render();
   }
 
@@ -284,11 +289,11 @@ export default class HandsontableModal extends React.PureComponent {
     else if (columns[columns.length - 1] < target) {
       insertPosition = target - columns.length;
     }
-    align.splice.apply(align, [insertPosition, 0].concat(removed));
+    align.splice(...[insertPosition, 0].concat(removed));
 
     this.setState((prevState) => {
       // change only align info, so share table data to avoid redundant copy
-      const newMarkdownTable = new MarkdownTable(prevState.markdownTable.table, {align: align});
+      const newMarkdownTable = new MarkdownTable(prevState.markdownTable.table, { align });
       return { markdownTable: newMarkdownTable };
     }, () => {
       this.synchronizeAlignment();
@@ -301,8 +306,8 @@ export default class HandsontableModal extends React.PureComponent {
   align(direction, startCol, endCol) {
     this.setState((prevState) => {
       // change only align info, so share table data to avoid redundant copy
-      const newMarkdownTable = new MarkdownTable(prevState.markdownTable.table, {align: [].concat(prevState.markdownTable.options.align)});
-      for (let i = startCol; i <= endCol ; i++) {
+      const newMarkdownTable = new MarkdownTable(prevState.markdownTable.table, { align: [].concat(prevState.markdownTable.options.align) });
+      for (let i = startCol; i <= endCol; i++) {
         newMarkdownTable.options.align[i] = direction;
       }
       return { markdownTable: newMarkdownTable };
@@ -315,12 +320,12 @@ export default class HandsontableModal extends React.PureComponent {
    * synchronize the handsontable alignment to the markdowntable alignment
    */
   synchronizeAlignment() {
-    if (this.refs.hotTable == null) {
+    if (this.hotTable == null) {
       return;
     }
 
     const align = this.state.markdownTable.options.align;
-    const hotInstance = this.refs.hotTable.hotInstance;
+    const hotInstance = this.hotTable.hotInstance;
 
     for (let i = 0; i < align.length; i++) {
       for (let j = 0; j < hotInstance.countRows(); j++) {
@@ -331,7 +336,7 @@ export default class HandsontableModal extends React.PureComponent {
   }
 
   alignButtonHandler(direction) {
-    const selectedRange = this.refs.hotTable.hotInstance.getSelectedRange();
+    const selectedRange = this.hotTable.hotInstance.getSelectedRange();
     if (selectedRange == null) return;
 
     let startCol;
@@ -383,8 +388,8 @@ export default class HandsontableModal extends React.PureComponent {
    *  according to the height of this.refs.hotTableContainer
    */
   expandHotTableHeight() {
-    if (this.state.isWindowExpanded && this.refs.hotTableContainer != null) {
-      const height = this.refs.hotTableContainer.getBoundingClientRect().height;
+    if (this.state.isWindowExpanded && this.hotTableContainer != null) {
+      const height = this.hotTableContainer.getBoundingClientRect().height;
       this.setState({ handsontableHeight: height });
     }
   }
@@ -392,7 +397,7 @@ export default class HandsontableModal extends React.PureComponent {
   renderExpandOrContractButton() {
     const iconClassName = this.state.isWindowExpanded ? 'icon-size-actual' : 'icon-size-fullscreen';
     return (
-      <button className="close mr-3" onClick={this.state.isWindowExpanded ? this.contractWindow : this.expandWindow}>
+      <button type="button" className="close mr-3" onClick={this.state.isWindowExpanded ? this.contractWindow : this.expandWindow}>
         <i className={iconClassName} style={{ fontSize: '0.8em' }} aria-hidden="true"></i>
       </button>
     );
@@ -415,7 +420,7 @@ export default class HandsontableModal extends React.PureComponent {
         <Modal.Body className="p-0 d-flex flex-column">
           <div className="px-4 py-3 modal-navbar">
             <Button className="m-r-20 data-import-button" onClick={this.toggleDataImportArea}>
-              Data Import<i className={this.state.isDataImportAreaExpanded ? 'fa fa-angle-up' : 'fa fa-angle-down' }></i>
+              Data Import<i className={this.state.isDataImportAreaExpanded ? 'fa fa-angle-up' : 'fa fa-angle-down'}></i>
             </Button>
             <ButtonGroup>
               <Button onClick={() => { this.alignButtonHandler('l') }}><i className="ti-align-left"></i></Button>
@@ -424,20 +429,23 @@ export default class HandsontableModal extends React.PureComponent {
             </ButtonGroup>
             <Collapse in={this.state.isDataImportAreaExpanded}>
               <div> {/* This div is necessary for smoothing animations. (https://react-bootstrap.github.io/utilities/transitions/#transitions-collapse) */}
-                <MarkdownTableDataImportForm onCancel={this.toggleDataImportArea} onImport={this.importData}/>
+                <MarkdownTableDataImportForm onCancel={this.toggleDataImportArea} onImport={this.importData} />
               </div>
             </Collapse>
           </div>
-          <div ref="hotTableContainer" className="m-4 hot-table-container">
-            <HotTable ref='hotTable' data={this.state.markdownTable.table}
-                settings={this.handsontableSettings} height={this.state.handsontableHeight}
-                afterLoadData={this.afterLoadDataHandler}
-                modifyColWidth={this.modifyColWidthHandler}
-                beforeColumnMove={this.beforeColumnMoveHandler}
-                beforeColumnResize={this.beforeColumnResizeHandler}
-                afterColumnResize={this.afterColumnResizeHandler}
-                afterColumnMove={this.afterColumnMoveHandler}
-              />
+          <div ref={(c) => { this.hotTableContainer = c }} className="m-4 hot-table-container">
+            <HotTable
+              ref={(c) => { this.hotTable = c }}
+              data={this.state.markdownTable.table}
+              settings={this.handsontableSettings}
+              height={this.state.handsontableHeight}
+              afterLoadData={this.afterLoadDataHandler}
+              modifyColWidth={this.modifyColWidthHandler}
+              beforeColumnMove={this.beforeColumnMoveHandler}
+              beforeColumnResize={this.beforeColumnResizeHandler}
+              afterColumnResize={this.afterColumnResizeHandler}
+              afterColumnMove={this.afterColumnMoveHandler}
+            />
           </div>
         </Modal.Body>
         <Modal.Footer>
@@ -461,8 +469,8 @@ export default class HandsontableModal extends React.PureComponent {
         ['', '', ''],
       ],
       {
-        align: ['', '', '']
-      }
+        align: ['', '', ''],
+      },
     );
   }
 
@@ -475,11 +483,12 @@ export default class HandsontableModal extends React.PureComponent {
       manualColumnMove: true,
       manualColumnResize: true,
       selectionMode: 'multiple',
-      outsideClickDeselects: false
+      outsideClickDeselects: false,
     };
   }
+
 }
 
 HandsontableModal.propTypes = {
-  onSave: PropTypes.func
+  onSave: PropTypes.func,
 };

+ 1 - 1
src/client/js/components/PageEditor/MarkdownListUtil.js

@@ -88,7 +88,7 @@ class MarkdownListUtil {
     // not listful data
     else {
       // append `indentAndMark` at the beginning of all lines (except the first line)
-      const replacedText = text.replace(/(\r\n|\r|\n)/g, '$1' + indentAndMark);
+      const replacedText = text.replace(/(\r\n|\r|\n)/g, `$1${indentAndMark}`);
       // append `indentAndMark` to the first line
       adjusted = indentAndMark + replacedText;
     }

+ 19 - 10
src/client/js/components/PageEditor/MarkdownTableDataImportForm.jsx

@@ -4,8 +4,8 @@ import FormGroup from 'react-bootstrap/es/FormGroup';
 import ControlLabel from 'react-bootstrap/es/ControlLabel';
 import FormControl from 'react-bootstrap/es/FormControl';
 import Button from 'react-bootstrap/es/Button';
-import MarkdownTable from '../../models/MarkdownTable';
 import Collapse from 'react-bootstrap/es/Collapse';
+import MarkdownTable from '../../models/MarkdownTable';
 
 export default class MarkdownTableDataImportForm extends React.Component {
 
@@ -15,7 +15,7 @@ export default class MarkdownTableDataImportForm extends React.Component {
     this.state = {
       dataFormat: 'csv',
       data: '',
-      parserErrorMessage: null
+      parserErrorMessage: null,
     };
 
     this.importButtonHandler = this.importButtonHandler.bind(this);
@@ -25,10 +25,10 @@ export default class MarkdownTableDataImportForm extends React.Component {
     try {
       const markdownTable = this.convertFormDataToMarkdownTable();
       this.props.onImport(markdownTable);
-      this.setState({parserErrorMessage: null});
+      this.setState({ parserErrorMessage: null });
     }
     catch (e) {
-      this.setState({parserErrorMessage: e.message});
+      this.setState({ parserErrorMessage: e.message });
     }
   }
 
@@ -53,8 +53,12 @@ export default class MarkdownTableDataImportForm extends React.Component {
       <form action="" className="data-import-form pt-5">
         <FormGroup>
           <ControlLabel>Select Data Format</ControlLabel>
-          <FormControl componentClass="select" placeholder="select"
-                       value={this.state.dataFormat} onChange={e => this.setState({dataFormat: e.target.value})}>
+          <FormControl
+            componentClass="select"
+            placeholder="select"
+            value={this.state.dataFormat}
+            onChange={(e) => { return this.setState({ dataFormat: e.target.value }) }}
+          >
             <option value="csv">CSV</option>
             <option value="tsv">TSV</option>
             <option value="html">HTML</option>
@@ -62,13 +66,17 @@ export default class MarkdownTableDataImportForm extends React.Component {
         </FormGroup>
         <FormGroup>
           <ControlLabel>Import Data</ControlLabel>
-          <FormControl componentClass="textarea" placeholder="Paste table data" style={{ height: 200 }}
-                       onChange={e => this.setState({data: e.target.value})}/>
+          <FormControl
+            componentClass="textarea"
+            placeholder="Paste table data"
+            style={{ height: 200 }}
+            onChange={(e) => { return this.setState({ data: e.target.value }) }}
+          />
         </FormGroup>
         <Collapse in={this.state.parserErrorMessage != null}>
           <FormGroup>
             <ControlLabel>Parse Error</ControlLabel>
-            <FormControl componentClass="textarea" style={{ height: 100 }} value={this.state.parserErrorMessage || ''} readOnly/>
+            <FormControl componentClass="textarea" style={{ height: 100 }} value={this.state.parserErrorMessage || ''} readOnly />
           </FormGroup>
         </Collapse>
         <div className="d-flex justify-content-end">
@@ -78,9 +86,10 @@ export default class MarkdownTableDataImportForm extends React.Component {
       </form>
     );
   }
+
 }
 
 MarkdownTableDataImportForm.propTypes = {
   onCancel: PropTypes.func,
-  onImport: PropTypes.func
+  onImport: PropTypes.func,
 };

+ 4 - 7
src/client/js/components/PageEditor/MarkdownTableInterceptor.js

@@ -1,4 +1,4 @@
-import { BasicInterceptor } from 'growi-pluginkit';
+import { BasicInterceptor } from 'growi-commons';
 
 import mtu from './MarkdownTableUtil';
 import MarkdownTable from '../../models/MarkdownTable';
@@ -8,10 +8,6 @@ import MarkdownTable from '../../models/MarkdownTable';
  */
 export default class MarkdownTableInterceptor extends BasicInterceptor {
 
-  constructor() {
-    super();
-  }
-
   /**
    * @inheritdoc
    */
@@ -32,8 +28,8 @@ export default class MarkdownTableInterceptor extends BasicInterceptor {
    * @inheritdoc
    */
   process(contextName, ...args) {
-    const context = Object.assign(args[0]);   // clone
-    const editor = context.editor;            // AbstractEditor instance
+    const context = Object.assign(args[0]); // clone
+    const editor = context.editor; // AbstractEditor instance
 
     // do nothing if editor is not a CodeMirrorEditor
     if (editor == null || editor.getCodeMirror() == null) {
@@ -67,4 +63,5 @@ export default class MarkdownTableInterceptor extends BasicInterceptor {
     // resolve
     return Promise.resolve(context);
   }
+
 }

+ 13 - 13
src/client/js/components/PageEditor/MarkdownTableUtil.js

@@ -9,7 +9,7 @@ class MarkdownTableUtil {
     // https://github.com/markdown-it/markdown-it/blob/d29f421927e93e88daf75f22089a3e732e195bd2/lib/rules_block/table.js#L83
     // https://regex101.com/r/7BN2fR/7
     this.tableAlignmentLineRE = /^[-:|][-:|\s]*$/;
-    this.tableAlignmentLineNegRE = /^[^-:]*$/;  // it is need to check to ignore empty row which is matched above RE
+    this.tableAlignmentLineNegRE = /^[^-:]*$/; // it is need to check to ignore empty row which is matched above RE
     this.linePartOfTableRE = /^\|[^\r\n]*|[^\r\n]*\|$|([^|\r\n]+\|[^|\r\n]*)+/; // own idea
 
     this.getBot = this.getBot.bind(this);
@@ -29,7 +29,7 @@ class MarkdownTableUtil {
   getBot(editor) {
     const curPos = editor.getCursor();
     if (!this.isInTable(editor)) {
-      return { line: curPos.line, ch: curPos.ch};
+      return { line: curPos.line, ch: curPos.ch };
     }
 
     const firstLine = editor.getDoc().firstLine();
@@ -51,7 +51,7 @@ class MarkdownTableUtil {
   getEot(editor) {
     const curPos = editor.getCursor();
     if (!this.isInTable(editor)) {
-      return { line: curPos.line, ch: curPos.ch};
+      return { line: curPos.line, ch: curPos.ch };
     }
 
     const lastLine = editor.getDoc().lastLine();
@@ -109,7 +109,7 @@ class MarkdownTableUtil {
    */
   isEndOfLine(editor) {
     const curPos = editor.getCursor();
-    return (curPos.ch == editor.getDoc().getLine(curPos.line).length);
+    return (curPos.ch === editor.getDoc().getLine(curPos.line).length);
   }
 
   /**
@@ -127,8 +127,8 @@ class MarkdownTableUtil {
    */
   addRowToMarkdownTable(mdtable) {
     const numCol = mdtable.table.length > 0 ? mdtable.table[0].length : 1;
-    let newRow = [];
-    (new Array(numCol)).forEach(() => newRow.push('')); // create cols
+    const newRow = [];
+    (new Array(numCol)).forEach(() => { return newRow.push('') }); // create cols
     mdtable.table.push(newRow);
   }
 
@@ -137,15 +137,14 @@ class MarkdownTableUtil {
    * (The merged markdown table options are used for the first markdown table.)
    * @param {Array} array of markdown table
    */
-  mergeMarkdownTable(mdtable_list) {
-    if (mdtable_list == undefined
-        || !(mdtable_list instanceof Array)) {
+  mergeMarkdownTable(mdtableList) {
+    if (mdtableList == null || !(mdtableList instanceof Array)) {
       return undefined;
     }
 
     let newTable = [];
-    const options = mdtable_list[0].options; // use option of first markdown-table
-    mdtable_list.forEach((mdtable) => {
+    const options = mdtableList[0].options; // use option of first markdown-table
+    mdtableList.forEach((mdtable) => {
       newTable = newTable.concat(mdtable.table);
     });
     return (new MarkdownTable(newTable, options));
@@ -176,15 +175,16 @@ class MarkdownTableUtil {
 
     let newMarkdown = '';
     if (markdownBeforeTable.length > 0) {
-      newMarkdown += markdownBeforeTable.join('\n') + '\n';
+      newMarkdown += `${markdownBeforeTable.join('\n')}\n`;
     }
     newMarkdown += table;
     if (markdownAfterTable.length > 0) {
-      newMarkdown += '\n' + markdownAfterTable.join('\n');
+      newMarkdown += `\n${markdownAfterTable.join('\n')}`;
     }
 
     return newMarkdown;
   }
+
 }
 
 // singleton pattern

+ 70 - 44
src/client/js/components/PageEditor/OptionsSelector.js

@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import { translate } from 'react-i18next';
+import { withTranslation } from 'react-i18next';
 
 import FormGroup from 'react-bootstrap/es/FormGroup';
 import FormControl from 'react-bootstrap/es/FormControl';
@@ -9,6 +9,28 @@ 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);
+  }
+
+}
+
+export class PreviewOptions {
+
+  constructor(props) {
+    this.renderMathJaxInRealtime = false;
+
+    Object.assign(this, props);
+  }
+
+}
+
 class OptionsSelector extends React.Component {
 
   constructor(props) {
@@ -25,7 +47,7 @@ class OptionsSelector extends React.Component {
     };
 
     this.availableThemes = [
-      'eclipse', 'elegant', 'neo', 'mdn-like', 'material', 'dracula', 'monokai', 'twilight'
+      'eclipse', 'elegant', 'neo', 'mdn-like', 'material', 'dracula', 'monokai', 'twilight',
     ];
     this.keymapModes = {
       default: 'Default',
@@ -52,8 +74,8 @@ class OptionsSelector extends React.Component {
 
   onChangeTheme() {
     const newValue = this.themeSelectorInputEl.value;
-    const newOpts = Object.assign(this.state.editorOptions, {theme: newValue});
-    this.setState({editorOptions: newOpts});
+    const newOpts = Object.assign(this.state.editorOptions, { theme: newValue });
+    this.setState({ editorOptions: newOpts });
 
     // dispatch event
     this.dispatchOnChange();
@@ -61,8 +83,8 @@ class OptionsSelector extends React.Component {
 
   onChangeKeymapMode() {
     const newValue = this.keymapModeSelectorInputEl.value;
-    const newOpts = Object.assign(this.state.editorOptions, {keymapMode: newValue});
-    this.setState({editorOptions: newOpts});
+    const newOpts = Object.assign(this.state.editorOptions, { keymapMode: newValue });
+    this.setState({ editorOptions: newOpts });
 
     // dispatch event
     this.dispatchOnChange();
@@ -73,8 +95,8 @@ class OptionsSelector extends React.Component {
     this._cddForceOpen = true;
 
     const newValue = !this.state.editorOptions.styleActiveLine;
-    const newOpts = Object.assign(this.state.editorOptions, {styleActiveLine: newValue});
-    this.setState({editorOptions: newOpts});
+    const newOpts = Object.assign(this.state.editorOptions, { styleActiveLine: newValue });
+    this.setState({ editorOptions: newOpts });
 
     // dispatch event
     this.dispatchOnChange();
@@ -85,8 +107,8 @@ class OptionsSelector extends React.Component {
     this._cddForceOpen = true;
 
     const newValue = !this.state.previewOptions.renderMathJaxInRealtime;
-    const newOpts = Object.assign(this.state.previewOptions, {renderMathJaxInRealtime: newValue});
-    this.setState({previewOptions: newOpts});
+    const newOpts = Object.assign(this.state.previewOptions, { renderMathJaxInRealtime: newValue });
+    this.setState({ previewOptions: newOpts });
 
     // dispatch event
     this.dispatchOnChange();
@@ -122,9 +144,15 @@ class OptionsSelector extends React.Component {
     return (
       <FormGroup controlId="formControlsSelect" className="my-0">
         <ControlLabel>Theme:</ControlLabel>
-        <FormControl componentClass="select" placeholder="select" bsClass={bsClassName} className="btn-group-sm selectpicker"
-            onChange={this.onChangeTheme}
-            inputRef={ el => this.themeSelectorInputEl=el }>
+        <FormControl
+          componentClass="select"
+          placeholder="select"
+          bsClass={bsClassName}
+          className="btn-group-sm selectpicker"
+          onChange={this.onChangeTheme}
+          // eslint-disable-next-line no-return-assign
+          inputRef={(el) => { return this.themeSelectorInputEl = el }}
+        >
 
           {optionElems}
 
@@ -135,13 +163,14 @@ class OptionsSelector extends React.Component {
 
   renderKeymapModeSelector() {
     const optionElems = [];
-    for (let mode in this.keymapModes) {
+    // eslint-disable-next-line guard-for-in, no-restricted-syntax
+    for (const mode in this.keymapModes) {
       const label = this.keymapModes[mode];
       const dataContent = (mode === 'default')
         ? label
         : `<img src='/images/icons/${mode}.png' width='16px' class='m-r-5'></img> ${label}`;
       optionElems.push(
-        <option key={mode} value={mode} data-content={dataContent}>{label}</option>
+        <option key={mode} value={mode} data-content={dataContent}>{label}</option>,
       );
     }
 
@@ -150,9 +179,15 @@ class OptionsSelector extends React.Component {
     return (
       <FormGroup controlId="formControlsSelect" className="my-0">
         <ControlLabel>Keymap:</ControlLabel>
-        <FormControl componentClass="select" placeholder="select" bsClass={bsClassName} className="btn-group-sm selectpicker"
-            onChange={this.onChangeKeymapMode}
-            inputRef={ el => this.keymapModeSelectorInputEl=el }>
+        <FormControl
+          componentClass="select"
+          placeholder="select"
+          bsClass={bsClassName}
+          className="btn-group-sm selectpicker"
+          onChange={this.onChangeKeymapMode}
+          // eslint-disable-next-line no-return-assign
+          inputRef={(el) => { return this.keymapModeSelectorInputEl = el }}
+        >
 
           {optionElems}
 
@@ -165,8 +200,13 @@ class OptionsSelector extends React.Component {
     return (
       <FormGroup controlId="formControlsSelect" className="my-0">
 
-        <Dropdown dropup id="configurationDropdown" className="configuration-dropdown"
-            open={this.state.isCddMenuOpened} onToggle={this.onToggleConfigurationDropdown}>
+        <Dropdown
+          dropup
+          id="configurationDropdown"
+          className="configuration-dropdown"
+          open={this.state.isCddMenuOpened}
+          onToggle={this.onToggleConfigurationDropdown}
+        >
 
           <Dropdown.Toggle bsSize="sm">
             <i className="icon-settings"></i>
@@ -219,7 +259,7 @@ class OptionsSelector extends React.Component {
 
     return (
       <MenuItem onClick={this.onClickRenderMathJaxInRealtime}>
-        <span className="icon-container"><img src="/images/icons/fx.svg" width="14px"></img></span>
+        <span className="icon-container"><img src="/images/icons/fx.svg" width="14px" alt="fx"></img></span>
         <span className="menuitem-label">MathJax Rendering</span>
         <i className={iconClassName}></i>
       </MenuItem>
@@ -227,38 +267,24 @@ class OptionsSelector extends React.Component {
   }
 
   render() {
-    return <div className="d-flex flex-row">
-      <span className="m-l-5">{this.renderThemeSelector()}</span>
-      <span className="m-l-5">{this.renderKeymapModeSelector()}</span>
-      <span className="m-l-5">{this.renderConfigurationDropdown()}</span>
-    </div>;
+    return (
+      <div className="d-flex flex-row">
+        <span className="m-l-5">{this.renderThemeSelector()}</span>
+        <span className="m-l-5">{this.renderKeymapModeSelector()}</span>
+        <span className="m-l-5">{this.renderConfigurationDropdown()}</span>
+      </div>
+    );
   }
-}
-
-export class EditorOptions {
-  constructor(props) {
-    this.theme = 'elegant';
-    this.keymapMode = 'default';
-    this.styleActiveLine = false;
 
-    Object.assign(this, props);
-  }
 }
 
-export class PreviewOptions {
-  constructor(props) {
-    this.renderMathJaxInRealtime = false;
-
-    Object.assign(this, props);
-  }
-}
 
 OptionsSelector.propTypes = {
-  t: PropTypes.func.isRequired,               // i18next
+  t: PropTypes.func.isRequired, // i18next
   crowi: PropTypes.object.isRequired,
   editorOptions: PropTypes.instanceOf(EditorOptions).isRequired,
   previewOptions: PropTypes.instanceOf(PreviewOptions).isRequired,
   onChange: PropTypes.func.isRequired,
 };
 
-export default translate()(OptionsSelector);
+export default withTranslation()(OptionsSelector);

+ 3 - 13
src/client/js/components/PageEditor/PasteHelper.js

@@ -17,7 +17,7 @@ class PasteHelper {
     // get data in clipboard
     const text = event.clipboardData.getData('text/plain');
 
-    if (text.length == 0) {
+    if (text.length === 0) {
       return;
     }
 
@@ -32,20 +32,10 @@ class PasteHelper {
    * @param {*} file
    * @param {*} accept
    */
-  fileAccepted(file, accept) {
+  isAcceptableType(file, accept) {
     return file.type === 'application/x-moz-file' || accepts(file, accept);
   }
-  /**
-   * transplanted from react-dropzone
-   * @see https://github.com/react-dropzone/react-dropzone/blob/master/src/utils/index.js
-   *
-   * @param {*} file
-   * @param {number} maxSize
-   * @param {number} minSize
-   */
-  fileMatchSize(file, maxSize, minSize) {
-    return file.size <= maxSize && file.size >= minSize;
-  }
+
 }
 
 // singleton pattern

+ 5 - 7
src/client/js/components/PageEditor/PreventMarkdownListInterceptor.js

@@ -1,12 +1,9 @@
-import { BasicInterceptor } from 'growi-pluginkit';
+import { BasicInterceptor } from 'growi-commons';
+
 import mlu from './MarkdownListUtil';
 
 export default class PreventMarkdownListInterceptor extends BasicInterceptor {
 
-  constructor() {
-    super();
-  }
-
   /**
    * @inheritdoc
    */
@@ -27,8 +24,8 @@ export default class PreventMarkdownListInterceptor extends BasicInterceptor {
    * @inheritdoc
    */
   process(contextName, ...args) {
-    const context = Object.assign(args[0]);   // clone
-    const editor = context.editor;            // AbstractEditor instance
+    const context = Object.assign(args[0]); // clone
+    const editor = context.editor; // AbstractEditor instance
 
     // get strings from current position to EOL(end of line) before break the line
     const strToEol = editor.getStrToEol();
@@ -43,4 +40,5 @@ export default class PreventMarkdownListInterceptor extends BasicInterceptor {
     // resolve
     return Promise.resolve(context);
   }
+
 }

+ 8 - 9
src/client/js/components/PageEditor/Preview.js

@@ -10,24 +10,22 @@ import { PreviewOptions } from './OptionsSelector';
  */
 export default class Preview extends React.Component {
 
-  constructor(props) {
-    super(props);
-  }
-
   render() {
     const renderMathJaxInRealtime = this.props.previewOptions.renderMathJaxInRealtime;
 
     return (
-      <div className="page-editor-preview-body"
-          ref={(elm) => {
+      <div
+        className="page-editor-preview-body"
+        ref={(elm) => {
             this.previewElement = elm;
             this.props.inputRef(elm);
           }}
-          onScroll={(event) => {
+        onScroll={(event) => {
             if (this.props.onScroll != null) {
               this.props.onScroll(event.target.scrollTop);
             }
-          }}>
+          }}
+      >
 
         <RevisionBody
           {...this.props}
@@ -36,11 +34,12 @@ export default class Preview extends React.Component {
       </div>
     );
   }
+
 }
 
 Preview.propTypes = {
   html: PropTypes.string,
-  inputRef: PropTypes.func.isRequired,  // for getting div element
+  inputRef: PropTypes.func.isRequired, // for getting div element
   isMathJaxEnabled: PropTypes.bool,
   renderMathJaxOnInit: PropTypes.bool,
   previewOptions: PropTypes.instanceOf(PreviewOptions),

+ 38 - 36
src/client/js/components/PageEditor/ScrollSyncHelper.js

@@ -5,11 +5,8 @@
 class ScrollSyncHelper {
 
   /**
-	 * @typedef {{ element: Element, line: number }} CodeLineElement
-	 */
-
-  constructor() {
-  }
+   * @typedef {{ element: Element, line: number }} CodeLineElement
+   */
 
   getCodeLineElements(parentElement) {
     /** @type {CodeLineElement[]} */
@@ -17,26 +14,27 @@ class ScrollSyncHelper {
     if (!elements) {
       elements = Array.prototype.map.call(
         parentElement.getElementsByClassName('code-line'),
-        element => {
+        (element) => {
           const line = +element.getAttribute('data-line');
           return { element, line };
-        })
-        .filter(x => !isNaN(x.line));
+        },
+      )
+        .filter((x) => { return !Number.isNaN(x.line) });
     }
     return elements;
   }
 
   /**
-	 * Find the html elements that map to a specific target line in the editor.
-	 *
-	 * If an exact match, returns a single element. If the line is between elements,
-	 * returns the element prior to and the element after the given line.
-	 *
+   * Find the html elements that map to a specific target line in the editor.
+   *
+   * If an exact match, returns a single element. If the line is between elements,
+   * returns the element prior to and the element after the given line.
+   *
    * @param {Element} element
-	 * @param {number} targetLine
-	 *
-	 * @returns {{ previous: CodeLineElement, next?: CodeLineElement }}
-	 */
+   * @param {number} targetLine
+   *
+   * @returns {{ previous: CodeLineElement, next?: CodeLineElement }}
+   */
   getElementsForSourceLine(element, targetLine) {
     const lines = this.getCodeLineElements(element);
     let previous = lines[0] || null;
@@ -44,7 +42,7 @@ class ScrollSyncHelper {
       if (entry.line === targetLine) {
         return { previous: entry, next: null };
       }
-      else if (entry.line > targetLine) {
+      if (entry.line > targetLine) {
         return { previous, next: entry };
       }
       previous = entry;
@@ -53,13 +51,13 @@ class ScrollSyncHelper {
   }
 
   /**
-	 * Find the html elements that are at a specific pixel offset on the page.
-	 *
+   * Find the html elements that are at a specific pixel offset on the page.
+   *
    * @param {Element} parentElement
-	 * @param {number} offset
+   * @param {number} offset
    *
-	 * @returns {{ previous: CodeLineElement, next?: CodeLineElement }}
-	 */
+   * @returns {{ previous: CodeLineElement, next?: CodeLineElement }}
+   */
   getLineElementsAtPageOffset(parentElement, offset) {
     const lines = this.getCodeLineElements(parentElement);
 
@@ -82,7 +80,7 @@ class ScrollSyncHelper {
     if (hi >= 1 && hiElement.element.getBoundingClientRect().top > position) {
       const loElement = lines[lo];
       const bounds = loElement.element.getBoundingClientRect();
-      let previous = { element: loElement.element, line: loElement.line };
+      const previous = { element: loElement.element, line: loElement.line };
       if (bounds.height > 0) {
         previous.line += (position - bounds.top) / (bounds.height);
       }
@@ -99,12 +97,14 @@ class ScrollSyncHelper {
     const { previous, next } = this.getLineElementsAtPageOffset(parentElement, offset);
     if (previous) {
       if (next) {
-        const betweenProgress = (offset - parentElement.scrollTop - previous.element.getBoundingClientRect().top) / (next.element.getBoundingClientRect().top - previous.element.getBoundingClientRect().top);
+        const betweenProgress = (
+          offset - parentElement.scrollTop - previous.element.getBoundingClientRect().top)
+          / (next.element.getBoundingClientRect().top - previous.element.getBoundingClientRect().top);
         return previous.line + betweenProgress * (next.line - previous.line);
       }
-      else {
-        return previous.line;
-      }
+
+      return previous.line;
+
     }
     return null;
   }
@@ -123,11 +123,11 @@ class ScrollSyncHelper {
   }
 
   /**
-	 * Attempt to scroll preview element for a source line in the editor.
-	 *
+   * Attempt to scroll preview element for a source line in the editor.
+   *
    * @param {Element} previewElement
-	 * @param {number} line
-	 */
+   * @param {number} line
+   */
   scrollPreview(previewElement, line) {
     const { previous, next } = this.getElementsForSourceLine(previewElement, line);
     if (previous) {
@@ -149,12 +149,13 @@ class ScrollSyncHelper {
   }
 
   /**
-	 * Attempt to reveal the element that is overflowing from previewElement.
-	 *
+   * Attempt to reveal the element that is overflowing from previewElement.
+   *
    * @param {Element} previewElement
-	 * @param {number} line
-	 */
+   * @param {number} line
+   */
   scrollPreviewToRevealOverflowing(previewElement, line) {
+    // eslint-disable-next-line no-unused-vars
     const { previous, next } = this.getElementsForSourceLine(previewElement, line);
     if (previous) {
       const parentElementOffset = this.getParentElementOffset(previewElement);
@@ -191,6 +192,7 @@ class ScrollSyncHelper {
     line = Math.floor(line);
     editor.setScrollTopByLine(line);
   }
+
 }
 
 // singleton pattern

+ 12 - 10
src/client/js/components/PageEditor/SimpleCheatsheet.js

@@ -1,8 +1,9 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import { translate } from 'react-i18next';
+import { withTranslation } from 'react-i18next';
 
 class SimpleCheatsheet extends React.Component {
+
   render() {
     const { t } = this.props;
 
@@ -12,8 +13,8 @@ class SimpleCheatsheet extends React.Component {
           <div className="row">
             <div className="col-xs-6">
               <p>
-                # {t('sandbox.header_x', {index: '1'})}<br />
-                ## {t('sandbox.header_x', {index: '2'})}
+                # {t('sandbox.header_x', { index: '1' })}<br />
+                ## {t('sandbox.header_x', { index: '2' })}
               </p>
               <p><i>*{t('sandbox.italics')}*</i>&nbsp;&nbsp;<b>**{t('sandbox.bold')}**</b></p>
               <p>
@@ -28,11 +29,11 @@ class SimpleCheatsheet extends React.Component {
             </div>
             <div className="col-xs-6">
               <p>
-                - {t('sandbox.unordered_list_x', {index: '1'})}<br />
-                &nbsp;&nbsp;&nbsp;- {t('sandbox.unordered_list_x', {index: '1.1'})}<br />
-                - {t('sandbox.unordered_list_x', {index: '2'})}<br />
-                1. {t('sandbox.ordered_list_x', {index: '1'})}<br />
-                1. {t('sandbox.ordered_list_x', {index: '2'})}
+                - {t('sandbox.unordered_list_x', { index: '1' })}<br />
+                &nbsp;&nbsp;&nbsp;- {t('sandbox.unordered_list_x', { index: '1.1' })}<br />
+                - {t('sandbox.unordered_list_x', { index: '2' })}<br />
+                1. {t('sandbox.ordered_list_x', { index: '1' })}<br />
+                1. {t('sandbox.ordered_list_x', { index: '2' })}
               </p>
               <hr />
               <p>[ ][ ] {t('sandbox.block_detail')}</p>
@@ -42,10 +43,11 @@ class SimpleCheatsheet extends React.Component {
       </div>
     );
   }
+
 }
 
 SimpleCheatsheet.propTypes = {
-  t: PropTypes.func.isRequired,               // i18next
+  t: PropTypes.func.isRequired, // i18next
 };
 
-export default translate()(SimpleCheatsheet);
+export default withTranslation()(SimpleCheatsheet);

+ 23 - 18
src/client/js/components/PageEditor/TextAreaEditor.js

@@ -79,18 +79,20 @@ export default class TextAreaEditor extends AbstractEditor {
    * @inheritDoc
    */
   setCaretLine(line) {
-    if (isNaN(line)) {
+    if (Number.isNaN(line)) {
       return;
     }
 
     // scroll to bottom
     this.textarea.scrollTop = this.textarea.scrollHeight;
 
-    const lines = this.textarea.value.split('\n').slice(0, line+1);
+    const lines = this.textarea.value.split('\n').slice(0, line + 1);
+    /* eslint-disable no-param-reassign, no-return-assign */
     const pos = lines
-        .map(lineStr => lineStr.length + 1) // correct length+1 of each lines
-        .reduce((a, x) => a += x, 0)        // sum
-        - 1;                                // -1
+      .map((lineStr) => { return lineStr.length + 1 }) // correct length+1 of each lines
+      .reduce((a, x) => { return a += x }, 0) //          sum
+        - 1; //                                           -1
+    /* eslint-enable no-param-reassign, no-return-assign */
 
     this.textarea.setSelectionRange(pos, pos);
   }
@@ -149,13 +151,13 @@ export default class TextAreaEditor extends AbstractEditor {
 
   getBolPos() {
     const currentPos = this.textarea.selectionStart;
-    return this.textarea.value.lastIndexOf('\n', currentPos-1) + 1;
+    return this.textarea.value.lastIndexOf('\n', currentPos - 1) + 1;
   }
 
   getEolPos() {
     const currentPos = this.textarea.selectionStart;
     const pos = this.textarea.value.indexOf('\n', currentPos);
-    if (pos < 0) {  // not found but EOF
+    if (pos < 0) { // not found but EOF
       return this.textarea.value.length;
     }
     return pos;
@@ -197,7 +199,7 @@ export default class TextAreaEditor extends AbstractEditor {
     }
 
     const context = {
-      handlers: [],  // list of handlers which process enter key
+      handlers: [], // list of handlers which process enter key
       editor: this,
     };
 
@@ -205,7 +207,7 @@ export default class TextAreaEditor extends AbstractEditor {
     interceptorManager.process('preHandleEnter', context)
       .then(() => {
         event.preventDefault();
-        if (context.handlers.length == 0) {
+        if (context.handlers.length === 0) {
           mlu.newlineAndIndentContinueMarkdownList(this);
         }
       });
@@ -240,21 +242,24 @@ export default class TextAreaEditor extends AbstractEditor {
   }
 
   render() {
-    return <React.Fragment>
-      <FormControl
-        componentClass="textarea" className="textarea-editor"
-        inputRef={ref => { this.textarea = ref }}
-        defaultValue={this.state.value}
-        onChange={(e) => {
+    return (
+      <React.Fragment>
+        <FormControl
+          componentClass="textarea"
+          className="textarea-editor"
+          inputRef={(ref) => { this.textarea = ref }}
+          defaultValue={this.state.value}
+          onChange={(e) => {
           if (this.props.onChange != null) {
             this.props.onChange(e.target.value);
           }
-        }} />
-    </React.Fragment>;
+        }}
+        />
+      </React.Fragment>
+    );
   }
 
 }
 
 TextAreaEditor.propTypes = Object.assign({
 }, AbstractEditor.propTypes);
-

+ 47 - 21
src/client/js/components/PageEditorByHackmd.jsx

@@ -44,8 +44,8 @@ export default class PageEditorByHackmd extends React.PureComponent {
       return Promise.reject(new Error('HackmdEditor component has not initialized'));
     }
 
-    return this.refs.hackmdEditor.getValue()
-      .then(document => {
+    return this.hackmdEditor.getValue()
+      .then((document) => {
         this.setState({ markdown: document });
         return document;
       });
@@ -54,7 +54,7 @@ export default class PageEditorByHackmd extends React.PureComponent {
   setMarkdown(markdown, updateEditorValue = true) {
     this.setState({ markdown });
     if (this.state.isInitialized && updateEditorValue) {
-      this.refs.hackmdEditor.setValue(markdown);
+      this.hackmdEditor.setValue(markdown);
     }
   }
 
@@ -73,7 +73,6 @@ export default class PageEditorByHackmd extends React.PureComponent {
       initialRevisionId: updatedRevisionId,
       revisionId: updatedRevisionId,
       revisionIdHackmdSynced: updatedRevisionIdHackmdSynced,
-      isDraftUpdatingInRealtime: false,
     });
   }
 
@@ -123,7 +122,7 @@ export default class PageEditorByHackmd extends React.PureComponent {
       pageId: this.props.pageId,
     };
     this.props.crowi.apiPost('/hackmd.integrate', params)
-      .then(res => {
+      .then((res) => {
         if (!res.ok) {
           throw new Error(res.error);
         }
@@ -136,7 +135,7 @@ export default class PageEditorByHackmd extends React.PureComponent {
       })
       .catch(this.apiErrorHandler)
       .then(() => {
-        this.setState({isInitializing: false});
+        this.setState({ isInitializing: false });
       });
   }
 
@@ -144,14 +143,14 @@ export default class PageEditorByHackmd extends React.PureComponent {
    * Start to edit w/o any api request
    */
   resumeToEdit() {
-    this.setState({isInitialized: true});
+    this.setState({ isInitialized: true });
   }
 
   /**
    * Reset draft
    */
   discardChanges() {
-    this.setState({hasDraftOnHackmd: false});
+    this.setState({ hasDraftOnHackmd: false });
   }
 
   /**
@@ -174,10 +173,10 @@ export default class PageEditorByHackmd extends React.PureComponent {
       pageId: this.props.pageId,
     };
     this.props.crowi.apiPost('/hackmd.saveOnHackmd', params)
-      .then(res => {
+      .then((res) => {
         // do nothing
       })
-      .catch(err => {
+      .catch((err) => {
         // do nothing
       });
   }
@@ -202,7 +201,7 @@ export default class PageEditorByHackmd extends React.PureComponent {
     if (this.state.isInitialized) {
       return (
         <HackmdEditor
-          ref='hackmdEditor'
+          ref={(c) => { this.hackmdEditor = c }}
           hackmdUri={hackmdUri}
           pageIdOnHackmd={this.state.pageIdOnHackmd}
           initializationMarkdown={isResume ? null : this.state.markdown}
@@ -218,7 +217,7 @@ export default class PageEditorByHackmd extends React.PureComponent {
     const isRevisionOutdated = this.state.initialRevisionId !== this.state.revisionId;
     const isHackmdDocumentOutdated = this.state.revisionId !== this.state.revisionIdHackmdSynced;
 
-    let content = undefined;
+    let content;
     /*
      * HackMD is not setup
      */
@@ -238,31 +237,53 @@ export default class PageEditorByHackmd extends React.PureComponent {
         <React.Fragment>
           <span className="btn-label"><i className="icon-control-end"></i></span>
           Resume to edit with HackMD
-        </React.Fragment>);
+        </React.Fragment>
+      );
       content = (
         <div>
           <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
           <div className="text-center hackmd-resume-button-container mb-3">
-            <SplitButton id='split-button-resume-hackmd' title={title} bsStyle="success" bsSize="large"
-                className="btn-resume waves-effect waves-light" onClick={() => this.resumeToEdit()}>
-              <MenuItem className="text-center" onClick={() => this.discardChanges()}>
+            <SplitButton
+              id="split-button-resume-hackmd"
+              title={title}
+              bsStyle="success"
+              bsSize="large"
+              className="btn-resume waves-effect waves-light"
+              onClick={() => { return this.resumeToEdit() }}
+            >
+              <MenuItem className="text-center" onClick={() => { return this.discardChanges() }}>
                 <i className="icon-control-rewind"></i> Discard changes
               </MenuItem>
             </SplitButton>
           </div>
           <p className="text-center">
             Click to edit from the previous continuation<br />
-            or <button className="btn btn-link text-danger p-0 hackmd-discard-button" onClick={() => this.discardChanges()}>Discard changes</button>.
+            or
+            <button
+              type="button"
+              className="btn btn-link text-danger p-0 hackmd-discard-button"
+              onClick={() => { return this.discardChanges() }}
+            >
+              Discard changes
+            </button>.
           </p>
-          { isHackmdDocumentOutdated &&
+          { isHackmdDocumentOutdated
+            && (
             <div className="panel panel-warning mt-5">
               <div className="panel-heading"><i className="icon-fw icon-info"></i> DRAFT MAY BE OUTDATED</div>
               <div className="panel-body text-center">
                 The current draft on HackMD is based on&nbsp;
                 <a href={`?revision=${revisionIdHackmdSynced}`}><span className="label label-default">{revisionIdHackmdSynced.substr(-8)}</span></a>.<br />
-                <button className="btn btn-link text-danger p-0 hackmd-discard-button" onClick={() => this.discardChanges()}>Discard it</button> to start to edit with current revision.
+                <button
+                  type="button"
+                  className="btn btn-link text-danger p-0 hackmd-discard-button"
+                  onClick={() => { return this.discardChanges() }}
+                >
+                  Discard it
+                </button> to start to edit with current revision.
               </div>
             </div>
+            )
           }
         </div>
       );
@@ -275,8 +296,12 @@ export default class PageEditorByHackmd extends React.PureComponent {
         <div>
           <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
           <div className="text-center hackmd-start-button-container mb-3">
-            <button className="btn btn-info btn-lg waves-effect waves-light" type="button" disabled={isRevisionOutdated || this.state.isInitializing}
-                onClick={() => this.startToEdit()}>
+            <button
+              className="btn btn-info btn-lg waves-effect waves-light"
+              type="button"
+              disabled={isRevisionOutdated || this.state.isInitializing}
+              onClick={() => { return this.startToEdit() }}
+            >
               <span className="btn-label"><i className="icon-paper-plane"></i></span>
               Start to edit with HackMD
             </button>
@@ -292,6 +317,7 @@ export default class PageEditorByHackmd extends React.PureComponent {
       </div>
     );
   }
+
 }
 
 PageEditorByHackmd.propTypes = {

+ 15 - 11
src/client/js/components/PageEditorByHackmd/HackmdEditor.jsx

@@ -1,8 +1,9 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import Penpal from 'penpal';
-// Penpal.debug = true;
+import connectToChild from 'penpal/lib/connectToChild';
+
+const DEBUG_PENPAL = false;
 
 export default class HackmdEditor extends React.PureComponent {
 
@@ -26,23 +27,25 @@ export default class HackmdEditor extends React.PureComponent {
   }
 
   initHackmdWithPenpal() {
-    const _this = this;   // for in methods scope
+    const _this = this; // for in methods scope
 
-    const url = `${this.props.hackmdUri}/${this.props.pageIdOnHackmd}?both`;
+    const iframe = document.createElement('iframe');
+    iframe.src = `${this.props.hackmdUri}/${this.props.pageIdOnHackmd}?both`;
+    this.iframeContainer.appendChild(iframe);
 
-    const connection = Penpal.connectToChild({
-      url,
-      appendTo: this.refs.iframeContainer,
-      methods: {  // expose methods to HackMD
+    const connection = connectToChild({
+      iframe,
+      methods: { // expose methods to HackMD
         notifyBodyChanges(document) {
           _this.notifyBodyChangesHandler(document);
         },
         saveWithShortcut(document) {
           _this.saveWithShortcutHandler(document);
-        }
+        },
       },
+      debug: DEBUG_PENPAL,
     });
-    connection.promise.then(child => {
+    connection.promise.then((child) => {
       this.hackmd = child;
       if (this.props.initializationMarkdown != null) {
         child.setValueOnInit(this.props.initializationMarkdown);
@@ -78,9 +81,10 @@ export default class HackmdEditor extends React.PureComponent {
   render() {
     return (
       // will be rendered in componentDidMount
-      <div id='iframe-hackmd-container' ref='iframeContainer'></div>
+      <div id="iframe-hackmd-container" ref={(c) => { this.iframeContainer = c }}></div>
     );
   }
+
 }
 
 HackmdEditor.propTypes = {

+ 64 - 59
src/client/js/components/PageHistory.js

@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import { translate } from 'react-i18next';
+import { withTranslation } from 'react-i18next';
 
 import PageRevisionList from './PageHistory/PageRevisionList';
 
@@ -22,52 +22,55 @@ class PageHistory extends React.Component {
     const pageId = this.props.pageId;
 
     if (!pageId) {
-      return ;
+      return;
     }
 
-    this.props.crowi.apiGet('/revisions.ids', {page_id: pageId})
-    .then(res => {
+    this.props.crowi.apiGet('/revisions.ids', { page_id: pageId })
+      .then((res) => {
+
+        const rev = res.revisions;
+        const diffOpened = {};
+        const lastId = rev.length - 1;
+        res.revisions.forEach((revision, i) => {
+          const user = this.props.crowi.findUserById(revision.author);
+          if (user) {
+            rev[i].author = user;
+          }
+
+          if (i === 0 || i === lastId) {
+            diffOpened[revision._id] = true;
+          }
+          else {
+            diffOpened[revision._id] = false;
+          }
+        });
 
-      const rev = res.revisions;
-      let diffOpened = {};
-      const lastId = rev.length - 1;
-      res.revisions.map((revision, i) => {
-        const user = this.props.crowi.findUserById(revision.author);
-        if (user) {
-          rev[i].author = user;
-        }
+        this.setState({
+          revisions: rev,
+          diffOpened,
+        });
 
-        if (i === 0 || i === lastId) {
-          diffOpened[revision._id] = true;
+        // load 0, and last default
+        if (rev[0]) {
+          this.fetchPageRevisionBody(rev[0]);
         }
-        else {
-          diffOpened[revision._id] = false;
+        if (rev[1]) {
+          this.fetchPageRevisionBody(rev[1]);
         }
-      });
-
-      this.setState({
-        revisions: rev,
-        diffOpened: diffOpened,
-      });
-
-      // load 0, and last default
-      if (rev[0]) {
-        this.fetchPageRevisionBody(rev[0]);
-      }
-      if (rev[1]) {
-        this.fetchPageRevisionBody(rev[1]);
-      }
-      if (lastId !== 0 && lastId !== 1 && rev[lastId]) {
-        this.fetchPageRevisionBody(rev[lastId]);
-      }
-    }).catch(err => {
+        if (lastId !== 0 && lastId !== 1 && rev[lastId]) {
+          this.fetchPageRevisionBody(rev[lastId]);
+        }
+      })
+      .catch((err) => {
       // do nothing
-    });
+      });
   }
 
   getPreviousRevision(currentRevision) {
     let cursor = null;
-    for (let revision of this.state.revisions) {
+    for (const revision of this.state.revisions) {
+      // comparing ObjectId
+      // eslint-disable-next-line eqeqeq
       if (cursor && cursor._id == currentRevision._id) {
         cursor = revision;
         break;
@@ -80,12 +83,12 @@ class PageHistory extends React.Component {
   }
 
   onDiffOpenClicked(revision) {
-    const diffOpened = this.state.diffOpened,
-      revisionId = revision._id;
+    const diffOpened = this.state.diffOpened;
+    const revisionId = revision._id;
 
     diffOpened[revisionId] = !(diffOpened[revisionId]);
     this.setState({
-      diffOpened
+      diffOpened,
     });
 
     this.fetchPageRevisionBody(revision);
@@ -94,28 +97,29 @@ class PageHistory extends React.Component {
 
   fetchPageRevisionBody(revision) {
     if (revision.body) {
-      return ;
+      return;
     }
 
     this.props.crowi.apiGet('/revisions.get',
-      { page_id: this.props.pageId, revision_id: revision._id}
-    )
-    .then(res => {
-      if (res.ok) {
-        this.setState({
-          revisions: this.state.revisions.map((rev) => {
-            if (rev._id == res.revision._id) {
-              return res.revision;
-            }
-
-            return rev;
-          })
-        });
-      }
-    })
-    .catch(err => {
+      { page_id: this.props.pageId, revision_id: revision._id })
+      .then((res) => {
+        if (res.ok) {
+          this.setState({
+            revisions: this.state.revisions.map((rev) => {
+              // comparing ObjectId
+              // eslint-disable-next-line eqeqeq
+              if (rev._id == res.revision._id) {
+                return res.revision;
+              }
+
+              return rev;
+            }),
+          });
+        }
+      })
+      .catch((err) => {
 
-    });
+      });
   }
 
   render() {
@@ -131,12 +135,13 @@ class PageHistory extends React.Component {
       </div>
     );
   }
+
 }
 
 PageHistory.propTypes = {
-  t: PropTypes.func.isRequired,               // i18next
+  t: PropTypes.func.isRequired, // i18next
   pageId: PropTypes.string,
   crowi: PropTypes.object.isRequired,
 };
 
-export default translate()(PageHistory);
+export default withTranslation()(PageHistory);

+ 24 - 18
src/client/js/components/PageHistory/PageRevisionList.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import Revision     from './Revision';
+import Revision from './Revision';
 import RevisionDiff from './RevisionDiff';
 
 export default class PageRevisionList extends React.Component {
@@ -46,14 +46,16 @@ export default class PageRevisionList extends React.Component {
           isCompactNodiffRevisions={this.state.isCompactNodiffRevisions}
           onDiffOpenClicked={this.props.onDiffOpenClicked}
           key={`revision-history-rev-${revisionId}`}
-          />
-        { hasDiff &&
+        />
+        { hasDiff
+          && (
           <RevisionDiff
             revisionDiffOpened={revisionDiffOpened}
             currentRevision={revision}
             previousRevision={previousRevision}
             key={`revision-deff-${revisionId}`}
           />
+          )
         }
       </div>
     );
@@ -62,14 +64,16 @@ export default class PageRevisionList extends React.Component {
   render() {
     const { t } = this.props;
 
-    const revisions = this.props.revisions,
-      revisionCount = this.props.revisions.length;
+    const revisions = this.props.revisions;
+
+
+    const revisionCount = this.props.revisions.length;
 
     let hasDiffPrev;
 
     const revisionList = this.props.revisions.map((revision, idx) => {
       let previousRevision;
-      if (idx+1 < revisionCount) {
+      if (idx + 1 < revisionCount) {
         previousRevision = revisions[idx + 1];
       }
       else {
@@ -89,23 +93,25 @@ export default class PageRevisionList extends React.Component {
       classNames.push('revision-history-list-compact');
     }
 
-    return <React.Fragment>
-      <div className='checkbox checkbox-info pull-right'>
-        <input id='cbCompactize' type='checkbox' value={true} checked={this.state.isCompactNodiffRevisions} onChange={this.cbCompactizeChangeHandler}></input>
-        <label htmlFor='cbCompactize'>{ t('Shrink versions that have no diffs') }</label>
-      </div>
-      <div className="clearfix"></div>
-      <div className={classNames.join(' ')}>
-        {revisionList}
-      </div>
-    </React.Fragment>;
+    return (
+      <React.Fragment>
+        <div className="checkbox checkbox-info pull-right">
+          <input id="cbCompactize" type="checkbox" value checked={this.state.isCompactNodiffRevisions} onChange={this.cbCompactizeChangeHandler}></input>
+          <label htmlFor="cbCompactize">{ t('Shrink versions that have no diffs') }</label>
+        </div>
+        <div className="clearfix"></div>
+        <div className={classNames.join(' ')}>
+          {revisionList}
+        </div>
+      </React.Fragment>
+    );
   }
+
 }
 
 PageRevisionList.propTypes = {
-  t: PropTypes.func.isRequired,               // i18next
+  t: PropTypes.func.isRequired, // i18next
   revisions: PropTypes.array,
   diffOpened: PropTypes.object,
   onDiffOpenClicked: PropTypes.func.isRequired,
 };
-

+ 16 - 14
src/client/js/components/PageHistory/Revision.jsx

@@ -1,8 +1,8 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import UserDate     from '../Common/UserDate';
-import UserPicture  from '../User/UserPicture';
+import UserDate from '../Common/UserDate';
+import UserPicture from '../User/UserPicture';
 
 export default class Revision extends React.Component {
 
@@ -26,7 +26,7 @@ export default class Revision extends React.Component {
 
     let pic = '';
     if (typeof author === 'object') {
-      pic = <UserPicture user={author} size='sm' />;
+      pic = <UserPicture user={author} size="sm" />;
     }
 
     return (
@@ -52,7 +52,7 @@ export default class Revision extends React.Component {
 
     let pic = '';
     if (typeof author === 'object') {
-      pic = <UserPicture user={author} size='lg' />;
+      pic = <UserPicture user={author} size="lg" />;
     }
 
     const iconClass = this.props.revisionDiffOpened ? 'caret caret-opened' : 'caret';
@@ -70,17 +70,19 @@ export default class Revision extends React.Component {
               <UserDate dateTime={revision.createdAt} />
             </p>
             <p>
-              <span className='d-inline-block' style={{ minWidth: '90px' }}>
-                { !this.props.hasDiff &&
-                  <span className='text-muted'>{ t('No diff') }</span>
+              <span className="d-inline-block" style={{ minWidth: '90px' }}>
+                { !this.props.hasDiff
+                  && <span className="text-muted">{ t('No diff') }</span>
                 }
-                { this.props.hasDiff &&
+                { this.props.hasDiff
+                  && (
                   <a className="diff-view" onClick={this._onDiffOpenClicked}>
                     <i className={iconClass}></i> { t('View diff') }
                   </a>
+                  )
                 }
               </span>
-              <a href={'?revision=' + revision._id } className="m-l-10">
+              <a href={`?revision=${revision._id}`} className="m-l-10">
                 <i className="icon-login"></i> { t('Go to this version') }
               </a>
             </p>
@@ -96,18 +98,18 @@ export default class Revision extends React.Component {
     if (this.props.isCompactNodiffRevisions && !this.props.hasDiff) {
       return this.renderSimplifiedNodiff(revision);
     }
-    else {
-      return this.renderFull(revision);
-    }
+
+    return this.renderFull(revision);
+
   }
+
 }
 
 Revision.propTypes = {
-  t: PropTypes.func.isRequired,               // i18next
+  t: PropTypes.func.isRequired, // i18next
   revision: PropTypes.object,
   revisionDiffOpened: PropTypes.bool.isRequired,
   hasDiff: PropTypes.bool.isRequired,
   isCompactNodiffRevisions: PropTypes.bool.isRequired,
   onDiffOpenClicked: PropTypes.func.isRequired,
 };
-

+ 8 - 5
src/client/js/components/PageHistory/RevisionDiff.js

@@ -7,9 +7,9 @@ import { Diff2Html } from 'diff2html';
 export default class RevisionDiff extends React.Component {
 
   render() {
-    const currentRevision = this.props.currentRevision,
-      previousRevision = this.props.previousRevision,
-      revisionDiffOpened = this.props.revisionDiffOpened;
+    const currentRevision = this.props.currentRevision;
+    const previousRevision = this.props.previousRevision;
+    const revisionDiffOpened = this.props.revisionDiffOpened;
 
 
     let diffViewHTML = '';
@@ -18,6 +18,8 @@ export default class RevisionDiff extends React.Component {
       && revisionDiffOpened) {
 
       let previousText = previousRevision.body;
+      // comparing ObjectId
+      // eslint-disable-next-line eqeqeq
       if (currentRevision._id == previousRevision._id) {
         previousText = '';
       }
@@ -25,15 +27,16 @@ export default class RevisionDiff extends React.Component {
       const patch = createPatch(
         currentRevision.path,
         previousText,
-        currentRevision.body
+        currentRevision.body,
       );
 
       diffViewHTML = Diff2Html.getPrettyHtml(patch);
     }
 
-    const diffView = {__html: diffViewHTML};
+    const diffView = { __html: diffViewHTML };
     return <div className="revision-history-diff" dangerouslySetInnerHTML={diffView} />;
   }
+
 }
 
 RevisionDiff.propTypes = {

+ 3 - 3
src/client/js/components/PageList/ListView.js

@@ -7,17 +7,18 @@ export default class ListView extends React.Component {
 
   render() {
     const listView = this.props.pages.map((page) => {
-      return <Page page={page} key={'page-list:list-view:' + page._id} />;
+      return <Page page={page} key={`page-list:list-view:${page._id}`} />;
     });
 
     return (
       <div className="page-list">
         <ul className="page-list-ul page-list-ul-flat">
-        {listView}
+          {listView}
         </ul>
       </div>
     );
   }
+
 }
 
 ListView.propTypes = {
@@ -25,5 +26,4 @@ ListView.propTypes = {
 };
 
 ListView.defaultProps = {
-  pages: [],
 };

+ 3 - 4
src/client/js/components/PageList/Page.js

@@ -15,7 +15,7 @@ export default class Page extends React.Component {
     }
 
     const styleFlex = {
-      flex: 1
+      flex: 1,
     };
 
     return (
@@ -26,20 +26,19 @@ export default class Page extends React.Component {
         </a>
         <PageListMeta page={page} />
         <div style={styleFlex}></div>
-        {this.props.children}
       </li>
     );
   }
+
 }
 
 Page.propTypes = {
   page: PropTypes.object.isRequired,
   linkTo: PropTypes.string,
+  excludePathString: PropTypes.string,
 };
 
 Page.defaultProps = {
-  page: {},
   linkTo: '',
   excludePathString: '',
 };
-

+ 2 - 3
src/client/js/components/PageList/PageListMeta.js

@@ -39,7 +39,7 @@ export default class PageListMeta extends React.Component {
     }
 
     let locked;
-    if (page.grant != 1) {
+    if (page.grant !== 1) {
       locked = <span><i className="icon-lock" /></span>;
     }
 
@@ -53,6 +53,7 @@ export default class PageListMeta extends React.Component {
       </span>
     );
   }
+
 }
 
 PageListMeta.propTypes = {
@@ -60,6 +61,4 @@ PageListMeta.propTypes = {
 };
 
 PageListMeta.defaultProps = {
-  page: {},
 };
-

+ 4 - 6
src/client/js/components/PageList/PagePath.js

@@ -6,7 +6,7 @@ import escapeStringRegexp from 'escape-string-regexp';
 export default class PagePath extends React.Component {
 
   getShortPath(path) {
-    let name = path.replace(/(\/)$/, '');
+    const name = path.replace(/(\/)$/, '');
 
     // /.../hoge/YYYY/MM/DD 形式のページ
     if (name.match(/.+\/([^/]+\/\d{4}\/\d{2}\/\d{2})$/)) {
@@ -34,7 +34,7 @@ export default class PagePath extends React.Component {
     const shortPath = this.getShortPath(pagePath);
 
     const shortPathEscaped = escapeStringRegexp(shortPath);
-    const pathPrefix = pagePath.replace(new RegExp(shortPathEscaped + '(/)?$'), '');
+    const pathPrefix = pagePath.replace(new RegExp(`${shortPathEscaped}(/)?$`), '');
 
     let classNames = ['page-path'];
     classNames = classNames.concat(this.props.additionalClassNames);
@@ -42,11 +42,10 @@ export default class PagePath extends React.Component {
     if (isShortPathOnly) {
       return <span className={classNames.join(' ')}>{shortPath}</span>;
     }
-    else {
-      return <span className={classNames.join(' ')}>{pathPrefix}<strong>{shortPath}</strong></span>;
-    }
 
+    return <span className={classNames.join(' ')}>{pathPrefix}<strong>{shortPath}</strong></span>;
   }
+
 }
 
 PagePath.propTypes = {
@@ -57,7 +56,6 @@ PagePath.propTypes = {
 };
 
 PagePath.defaultProps = {
-  page: {},
   additionalClassNames: [],
   excludePathString: '',
 };

+ 7 - 5
src/client/js/components/PagePathAutoComplete.jsx

@@ -1,7 +1,8 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import * as pathUtils from '@commons/util/path-utils';
+import { pathUtils } from 'growi-commons';
+
 import SearchTypeahead from './SearchTypeahead';
 
 export default class PagePathAutoComplete extends React.Component {
@@ -11,8 +12,8 @@ export default class PagePathAutoComplete extends React.Component {
     super(props);
 
     this.state = {
-      searchError: null,
     };
+
     this.crowi = this.props.crowi;
 
     this.onSubmit = this.onSubmit.bind(this);
@@ -27,7 +28,7 @@ export default class PagePathAutoComplete extends React.Component {
 
   onSubmit(query) {
     // get the closest form element
-    const elem = this.refs.rootDom;
+    const elem = this.rootDom;
     const form = elem.closest('form');
     // submit with jQuery
     $(form).submit();
@@ -41,12 +42,12 @@ export default class PagePathAutoComplete extends React.Component {
 
   render() {
     return (
-      <div ref='rootDom'>
+      <div ref={(c) => { this.rootDom = c }}>
         <SearchTypeahead
           ref={this.searchTypeaheadDom}
           crowi={this.crowi}
           onSubmit={this.onSubmit}
-          inputName='new_path'
+          inputName="new_path"
           emptyLabelExceptError={null}
           placeholder="Input page path"
           keywordOnInit={this.getKeywordOnInit(this.props.initializedPath)}
@@ -54,6 +55,7 @@ export default class PagePathAutoComplete extends React.Component {
       </div>
     );
   }
+
 }
 
 PagePathAutoComplete.propTypes = {

+ 14 - 12
src/client/js/components/PageStatusAlert.jsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import { translate } from 'react-i18next';
+import { withTranslation } from 'react-i18next';
 
 /**
  *
@@ -58,6 +58,10 @@ class PageStatusAlert extends React.Component {
     });
   }
 
+  refreshPage() {
+    window.location.reload();
+  }
+
   renderSomeoneEditingAlert() {
     return (
       <div className="alert-hackmd-someone-editing myadmin-alert alert-success myadmin-alert-bottom alertbottom2">
@@ -100,7 +104,7 @@ class PageStatusAlert extends React.Component {
         &nbsp;
         <i className="fa fa-angle-double-right"></i>
         &nbsp;
-        <a href="javascript:location.reload();">
+        <a onClick={this.refreshPage}>
           {label2}
         </a>
       </div>
@@ -116,22 +120,20 @@ class PageStatusAlert extends React.Component {
     if (isHackmdDocumentOutdated && isRevisionOutdated) {
       content = this.renderUpdatedAlert();
     }
-    else {
-      if (this.state.isDraftUpdatingInRealtime) {
-        content = this.renderSomeoneEditingAlert();
-      }
-      else if (this.state.hasDraftOnHackmd) {
-        content = this.renderDraftExistsAlert();
-      }
+    else if (this.state.isDraftUpdatingInRealtime) {
+      content = this.renderSomeoneEditingAlert();
+    }
+    else if (this.state.hasDraftOnHackmd) {
+      content = this.renderDraftExistsAlert();
     }
 
     return content;
   }
+
 }
 
 PageStatusAlert.propTypes = {
-  t: PropTypes.func.isRequired,               // i18next
-  crowi: PropTypes.object.isRequired,
+  t: PropTypes.func.isRequired, // i18next
   hasDraftOnHackmd: PropTypes.bool.isRequired,
   revisionId: PropTypes.string,
   revisionIdHackmdSynced: PropTypes.string,
@@ -140,4 +142,4 @@ PageStatusAlert.propTypes = {
 PageStatusAlert.defaultProps = {
 };
 
-export default translate()(PageStatusAlert);
+export default withTranslation(null, { withRef: true })(PageStatusAlert);

+ 37 - 30
src/client/js/components/PageTagForm.jsx

@@ -1,5 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 
 /**
  *
@@ -16,47 +17,53 @@ export default class PageTagForm extends React.Component {
     super(props);
 
     this.state = {
-      pageTags: this.props.pageTags,
+      resultTags: [],
+      isLoading: false,
+      selected: this.props.currentPageTags,
     };
-
-    this.updateState = this.updateState.bind(this);
-    this.handleSubmit = this.handleSubmit.bind(this);
-  }
-
-  componentWillReceiveProps(nextProps) {
-    this.setState({
-      pageTags: nextProps.pageTags
-    });
-  }
-
-  handleSubmit() {
-    this.props.submitTags(this.state.pageTags);
-  }
-
-  updateState(value) {
-    this.setState({pageTags: value});
+    this.crowi = this.props.crowi;
   }
 
   render() {
     return (
-      <div className="input-group-sm mx-1">
-        <input className="form-control page-tag-form" type="text" value={this.state.pageTags} placeholder="tag name"
-          data-toggle="popover"
-          title="タグ"
-          data-content="タグ付けによりページをカテゴライズすることができます。"
-          data-trigger="focus"
-          data-placement="right"
-          onChange={e => this.updateState(e.target.value)}
-          onBlur={this.handleSubmit}
-          />
+      <div className="tag-typeahead">
+        <AsyncTypeahead
+          allowNew
+          caseSensitive={false}
+          defaultSelected={this.props.currentPageTags}
+          emptyLabel=""
+          isLoading={this.state.isLoading}
+          minLength={1}
+          multiple
+          newSelectionPrefix=""
+          onChange={(selected) => {
+            this.setState({ selected }, () => {
+              this.props.addNewTag(this.state.selected);
+            });
+          }}
+          onSearch={async(query) => {
+            this.setState({ isLoading: true });
+            const res = await this.crowi.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
+              isLoading: false,
+            });
+          }}
+          options={this.state.resultTags} // Search result (Some tag names)
+          placeholder="tag name"
+          selectHintOnEnter
+        />
       </div>
     );
   }
+
 }
 
 PageTagForm.propTypes = {
-  pageTags: PropTypes.string,
-  submitTags: PropTypes.func,
+  crowi: PropTypes.object.isRequired,
+  currentPageTags: PropTypes.array.isRequired,
+  addNewTag: PropTypes.func.isRequired,
 };
 
 PageTagForm.defaultProps = {

+ 6 - 6
src/client/js/components/ReactUtils.js

@@ -14,14 +14,14 @@ export default class ReactUtils {
    * @memberOf ReactUtils
    */
   static nl2br(text) {
-    var regex = /(\n)/g;
-    return text.split(regex).map(function(line) {
+    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)});
-      }
-      else {
-        return line;
+        return React.createElement('br', { key: Math.random().toString(10).substr(2, 10) });
       }
+
+      return line;
+
     });
   }
 

+ 30 - 27
src/client/js/components/RecentCreated/RecentCreated.jsx

@@ -1,8 +1,9 @@
 import React from 'react';
-import Page from '../PageList/Page';
 
 import PropTypes from 'prop-types';
 import Pagination from 'react-bootstrap/lib/Pagination';
+import Page from '../PageList/Page';
+
 export default class RecentCreated extends React.Component {
 
   constructor(props) {
@@ -28,8 +29,10 @@ export default class RecentCreated extends React.Component {
     const offset = (selectPageNumber - 1) * limit;
 
     // pagesList get and pagination calculate
-    this.props.crowi.apiGet('/pages.recentCreated', { page_id: pageId, user: userId, limit, offset })
-      .then(res => {
+    this.props.crowi.apiGet('/pages.recentCreated', {
+      page_id: pageId, user: userId, limit, offset,
+    })
+      .then((res) => {
         const totalCount = res.totalCount;
         const pages = res.pages;
         const activePage = selectPageNumber;
@@ -45,18 +48,18 @@ export default class RecentCreated extends React.Component {
 
   calculatePagination(limit, totalCount, activePage) {
     // calc totalPageNumber
-    const totalPage = Math.floor(totalCount / limit) + (totalCount % limit === 0 ? 0  : 1);
+    const totalPage = Math.floor(totalCount / limit) + (totalCount % limit === 0 ? 0 : 1);
 
     let paginationStart = activePage - 2;
-    let maxViewPageNum =  activePage + 2;
+    let maxViewPageNum = activePage + 2;
     // pagiNation Number area size = 5 , pageNuber calculate in here
     // activePage Position calculate ex. 4 5 [6] 7 8 (Page8 over is Max), 3 4 5 [6] 7 (Page7 is Max)
-    if ( paginationStart < 1 ) {
+    if (paginationStart < 1) {
       const diff = 1 - paginationStart;
       paginationStart += diff;
       maxViewPageNum = Math.min(totalPage, maxViewPageNum + diff);
     }
-    if ( maxViewPageNum > totalPage ) {
+    if (maxViewPageNum > totalPage) {
       const diff = maxViewPageNum - totalPage;
       maxViewPageNum -= diff;
       paginationStart = Math.max(1, paginationStart - diff);
@@ -68,6 +71,7 @@ export default class RecentCreated extends React.Component {
       maxViewPageNum,
     };
   }
+
   /**
    * generate Elements of Page
    *
@@ -75,8 +79,8 @@ export default class RecentCreated extends React.Component {
    *
    */
   generatePageList(pages) {
-    return pages.map(page => {
-      return <Page page={page} key={'recent-created:list-view:' + page._id} />;
+    return pages.map((page) => {
+      return <Page page={page} key={`recent-created:list-view:${page._id}`} />;
     });
 
   }
@@ -87,21 +91,21 @@ export default class RecentCreated extends React.Component {
    * this function set << & <
    */
   generateFirstPrev(activePage) {
-    let paginationItems = [];
-    if (1 != activePage) {
+    const paginationItems = [];
+    if (activePage !== 1) {
       paginationItems.push(
-        <Pagination.First key="first" onClick={() => this.getRecentCreatedList(1)} />
+        <Pagination.First key="first" onClick={() => { return this.getRecentCreatedList(1) }} />,
       );
       paginationItems.push(
-        <Pagination.Prev key="prev" onClick={() => this.getRecentCreatedList(this.state.activePage - 1)} />
+        <Pagination.Prev key="prev" onClick={() => { return this.getRecentCreatedList(this.state.activePage - 1) }} />,
       );
     }
     else {
       paginationItems.push(
-        <Pagination.First key="first" disabled />
+        <Pagination.First key="first" disabled />,
       );
       paginationItems.push(
-        <Pagination.Prev key="prev" disabled />
+        <Pagination.Prev key="prev" disabled />,
       );
 
     }
@@ -114,10 +118,10 @@ export default class RecentCreated extends React.Component {
    * this function set  numbers
    */
   generatePaginations(activePage, paginationStart, maxViewPageNum) {
-    let paginationItems = [];
+    const paginationItems = [];
     for (let number = paginationStart; number <= maxViewPageNum; number++) {
       paginationItems.push(
-        <Pagination.Item key={number} active={number === activePage} onClick={ () => this.getRecentCreatedList(number)}>{number}</Pagination.Item>
+        <Pagination.Item key={number} active={number === activePage} onClick={() => { return this.getRecentCreatedList(number) }}>{number}</Pagination.Item>,
       );
     }
     return paginationItems;
@@ -129,21 +133,21 @@ export default class RecentCreated extends React.Component {
    * this function set > & >>
    */
   generateNextLast(activePage, totalPage) {
-    let paginationItems = [];
-    if (totalPage != activePage) {
+    const paginationItems = [];
+    if (totalPage !== activePage) {
       paginationItems.push(
-        <Pagination.Next key="next" onClick={() => this.getRecentCreatedList(this.state.activePage + 1)} />
+        <Pagination.Next key="next" onClick={() => { return this.getRecentCreatedList(this.state.activePage + 1) }} />,
       );
       paginationItems.push(
-        <Pagination.Last key="last" onClick={() => this.getRecentCreatedList(totalPage)} />
+        <Pagination.Last key="last" onClick={() => { return this.getRecentCreatedList(totalPage) }} />,
       );
     }
     else {
       paginationItems.push(
-        <Pagination.Next key="next" disabled />
+        <Pagination.Next key="next" disabled />,
       );
       paginationItems.push(
-        <Pagination.Last key="last" disabled />
+        <Pagination.Last key="last" disabled />,
       );
 
     }
@@ -154,12 +158,12 @@ export default class RecentCreated extends React.Component {
   render() {
     const pageList = this.generatePageList(this.state.pages);
 
-    let paginationItems = [];
+    const paginationItems = [];
 
     const activePage = this.state.activePage;
     const totalPage = this.state.paginationNumbers.totalPage;
     const paginationStart = this.state.paginationNumbers.paginationStart;
-    const maxViewPageNum =  this.state.paginationNumbers.maxViewPageNum;
+    const maxViewPageNum = this.state.paginationNumbers.maxViewPageNum;
     const firstPrevItems = this.generateFirstPrev(activePage);
     paginationItems.push(firstPrevItems);
     const paginations = this.generatePaginations(activePage, paginationStart, maxViewPageNum);
@@ -176,8 +180,8 @@ export default class RecentCreated extends React.Component {
       </div>
     );
   }
-}
 
+}
 
 
 RecentCreated.propTypes = {
@@ -188,4 +192,3 @@ RecentCreated.propTypes = {
 
 RecentCreated.defaultProps = {
 };
-

+ 46 - 30
src/client/js/components/SavePageControls.jsx

@@ -1,9 +1,9 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import { translate } from 'react-i18next';
+import { withTranslation } from 'react-i18next';
 
 import ButtonToolbar from 'react-bootstrap/es/ButtonToolbar';
-import SplitButton  from 'react-bootstrap/es/SplitButton';
+import SplitButton from 'react-bootstrap/es/SplitButton';
 import MenuItem from 'react-bootstrap/es/MenuItem';
 
 import SlackNotification from './SlackNotification';
@@ -18,6 +18,10 @@ class SavePageControls extends React.PureComponent {
       pageId: this.props.pageId,
     };
 
+    const config = this.props.crowi.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);
@@ -27,9 +31,11 @@ class SavePageControls extends React.PureComponent {
   }
 
   getCurrentOptionsToSave() {
-    const slackNotificationOptions = this.refs.slackNotification.getCurrentOptionsToSave();
-    const grantSelectorOptions = this.refs.grantSelector.getCurrentOptionsToSave();
-    return Object.assign(slackNotificationOptions, grantSelectorOptions);
+    let currentOptions = this.grantSelector.getCurrentOptionsToSave();
+    if (this.hasSlackConfig) {
+      currentOptions = Object.assign(currentOptions, this.slackNotification.getCurrentOptionsToSave());
+    }
+    return currentOptions;
   }
 
   /**
@@ -37,7 +43,7 @@ class SavePageControls extends React.PureComponent {
    * @param {string} pageId
    */
   setPageId(pageId) {
-    this.setState({pageId});
+    this.setState({ pageId });
   }
 
   submit() {
@@ -50,41 +56,51 @@ class SavePageControls extends React.PureComponent {
 
   render() {
     const { t } = this.props;
-
-    const config = this.props.crowi.getConfig();
-    const isAclEnabled = config.isAclEnabled;
     const labelSubmitButton = this.state.pageId == null ? t('Create') : t('Update');
     const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
 
     return (
       <div className="d-flex align-items-center form-inline">
-        <div className="mr-2">
-          <SlackNotification
-              ref='slackNotification'
-              crowi={this.props.crowi}
-              pageId={this.props.pageId}
-              pagePath={this.props.pagePath}
+        {this.hasSlackConfig
+          && (
+          <div className="mr-2">
+            <SlackNotification
+              ref={(c) => { this.slackNotification = c }}
               isSlackEnabled={false}
-              slackChannels={this.props.slackChannels} />
-        </div>
+              slackChannels={this.props.slackChannels}
+            />
+          </div>
+          )
+        }
 
-        {isAclEnabled &&
+        {this.isAclEnabled
+          && (
           <div className="mr-2">
-            <GrantSelector crowi={this.props.crowi}
-                ref={(elem) => {
-                  if (this.refs.grantSelector == null) {
-                    this.refs.grantSelector = elem.getWrappedInstance();
+            <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} />
+              grant={this.props.grant}
+              grantGroupId={this.props.grantGroupId}
+              grantGroupName={this.props.grantGroupName}
+            />
           </div>
+          )
         }
 
         <ButtonToolbar>
-          <SplitButton id="spl-btn-submit" bsStyle="primary" className="btn-submit" dropup pullRight onClick={this.submit}
-              title={labelSubmitButton}>
+          <SplitButton
+            id="spl-btn-submit"
+            bsStyle="primary"
+            className="btn-submit"
+            dropup
+            pullRight
+            onClick={this.submit}
+            title={labelSubmitButton}
+          >
             <MenuItem eventKey="1" onClick={this.submitAndOverwriteScopesOfDescendants}>{labelOverwriteScopes}</MenuItem>
             {/* <MenuItem divider /> */}
           </SplitButton>
@@ -92,15 +108,15 @@ class SavePageControls extends React.PureComponent {
       </div>
     );
   }
+
 }
 
 SavePageControls.propTypes = {
-  t: PropTypes.func.isRequired,               // i18next
+  t: PropTypes.func.isRequired, // i18next
   crowi: PropTypes.object.isRequired,
   onSubmit: PropTypes.func.isRequired,
   pageId: PropTypes.string,
   // for SlackNotification
-  pagePath: PropTypes.string,
   slackChannels: PropTypes.string,
   // for GrantSelector
   grant: PropTypes.number,
@@ -108,4 +124,4 @@ SavePageControls.propTypes = {
   grantGroupName: PropTypes.string,
 };
 
-export default translate()(SavePageControls);
+export default withTranslation(null, { withRef: true })(SavePageControls);

+ 78 - 45
src/client/js/components/SavePageControls/GrantSelector.jsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import { translate } from 'react-i18next';
+import { withTranslation } from 'react-i18next';
 
 import FormGroup from 'react-bootstrap/es/FormGroup';
 import FormControl from 'react-bootstrap/es/FormControl';
@@ -23,23 +23,33 @@ class GrantSelector extends React.Component {
     super(props);
 
     this.availableGrants = [
-      { grant: 1, iconClass: 'icon-people', styleClass: '', label: 'Public' },
-      { grant: 2, iconClass: 'icon-link', styleClass: 'text-info', label: 'Anyone with the link' },
+      {
+        grant: 1, iconClass: 'icon-people', styleClass: '', label: 'Public',
+      },
+      {
+        grant: 2, iconClass: 'icon-link', styleClass: 'text-info', label: 'Anyone with the link',
+      },
       // { grant: 3, iconClass: '', label: 'Specified users only' },
-      { grant: 4, iconClass: 'icon-lock', styleClass: 'text-danger', label: 'Just me' },
-      { grant: 5, iconClass: 'icon-options', styleClass: '', label: 'Only inside the group' },  // appeared only one of these 'grant: 5'
-      { grant: 5, iconClass: 'icon-options', styleClass: '', label: 'Reselect the group' },     // appeared only one of these 'grant: 5'
+      {
+        grant: 4, iconClass: 'icon-lock', styleClass: 'text-danger', label: 'Just me',
+      },
+      {
+        grant: 5, iconClass: 'icon-options', styleClass: '', label: 'Only inside the group',
+      }, // appeared only one of these 'grant: 5'
+      {
+        grant: 5, iconClass: 'icon-options', styleClass: '', label: 'Reselect the group',
+      }, // appeared only one of these 'grant: 5'
     ];
 
     this.state = {
-      grant: this.props.grant || 1,  // default: 1
+      grant: this.props.grant || 1, // default: 1
       userRelatedGroups: [],
       isSelectGroupModalShown: false,
     };
     if (this.props.grantGroupId !== '') {
       this.state.grantGroup = {
         _id: this.props.grantGroupId,
-        name: this.props.grantGroupName
+        name: this.props.grantGroupName,
       };
     }
 
@@ -68,7 +78,7 @@ class GrantSelector extends React.Component {
     // refresh bootstrap-select
     // see https://silviomoreto.github.io/bootstrap-select/methods/#selectpickerrefresh
     $('.grant-selector .selectpicker').selectpicker('refresh');
-    //// DIRTY HACK -- 2018.05.25 Yuki Takei
+    // // DIRTY HACK -- 2018.05.25 Yuki Takei
     // set group name to the bootstrap-select options
     //  cz: .selectpicker('refresh') doesn't replace data-content
     $('.grant-selector .group-name').text(this.getGroupName());
@@ -77,7 +87,7 @@ class GrantSelector extends React.Component {
 
   getCurrentOptionsToSave() {
     const options = {
-      grant: this.state.grant
+      grant: this.state.grant,
     };
     if (this.state.grantGroup != null) {
       options.grantUserGroupId = this.state.grantGroup._id;
@@ -89,6 +99,7 @@ class GrantSelector extends React.Component {
     this.retrieveUserGroupRelations();
     this.setState({ isSelectGroupModalShown: true });
   }
+
   hideSelectGroupModal() {
     this.setState({ isSelectGroupModalShown: false });
   }
@@ -103,14 +114,14 @@ class GrantSelector extends React.Component {
    */
   retrieveUserGroupRelations() {
     this.props.crowi.apiGet('/me/user-group-relations')
-      .then(res => {
+      .then((res) => {
         return res.userGroupRelations;
       })
-      .then(userGroupRelations => {
-        const userRelatedGroups = userGroupRelations.map(relation => {
+      .then((userGroupRelations) => {
+        const userRelatedGroups = userGroupRelations.map((relation) => {
           return relation.relatedGroup;
         });
-        this.setState({userRelatedGroups});
+        this.setState({ userRelatedGroups });
       });
   }
 
@@ -177,18 +188,28 @@ class GrantSelector extends React.Component {
 
     // add specified group option
     grantElems.push(
-      <option ref="specifiedGroupOption" key="specifiedGroupKey" value={SPECIFIED_GROUP_VALUE} style={{ display: grantGroup ? 'inherit' : 'none' }}
-          data-content={`<i class="icon icon-fw icon-organization text-success"></i> <span class="group-name text-success">${this.getGroupName()}</span>`}>
+      <option
+        key="specifiedGroupKey"
+        value={SPECIFIED_GROUP_VALUE}
+        style={{ display: grantGroup ? 'inherit' : 'none' }}
+        data-content={`<i class="icon icon-fw icon-organization text-success"></i> <span class="group-name text-success">${this.getGroupName()}</span>`}
+      >
         {this.getGroupName()}
-      </option>
+      </option>,
     );
 
     const bsClassName = 'form-control-dummy'; // set form-control* to shrink width
     return (
       <FormGroup className="grant-selector m-b-0">
-        <FormControl componentClass="select" placeholder="select" defaultValue={selectedValue} bsClass={bsClassName} className="btn-group-sm selectpicker"
+        <FormControl
+          componentClass="select"
+          placeholder="select"
+          defaultValue={selectedValue}
+          bsClass={bsClassName}
+          className="btn-group-sm selectpicker"
           onChange={this.changeGrantHandler}
-          inputRef={ el => this.grantSelectorInputEl=el }>
+          inputRef={(el) => { this.grantSelectorInputEl = el }}
+        >
 
           {grantElems}
 
@@ -206,53 +227,65 @@ class GrantSelector extends React.Component {
   renderSelectGroupModal() {
     const generateGroupListItems = () => {
       return this.state.userRelatedGroups.map((group) => {
-        return <ListGroupItem key={group._id} header={group.name} onClick={() => { this.groupListItemClickHandler(group) }}>
+        return (
+          <ListGroupItem key={group._id} header={group.name} onClick={() => { this.groupListItemClickHandler(group) }}>
             (TBD) List group members
-          </ListGroupItem>;
+          </ListGroupItem>
+        );
       });
     };
 
-    let content = this.state.userRelatedGroups.length === 0
-      ? <div>
+    const content = this.state.userRelatedGroups.length === 0
+      ? (
+        <div>
           <h4>There is no group to which you belong.</h4>
-          { this.props.crowi.isAdmin &&
-            <p><a href="/admin/user-groups"><i className="icon icon-fw icon-login"></i> Manage Groups</a></p>
+          { this.props.crowi.isAdmin
+            && <p><a href="/admin/user-groups"><i className="icon icon-fw icon-login"></i> Manage Groups</a></p>
           }
         </div>
-      : <ListGroup>
-        {generateGroupListItems()}
-      </ListGroup>;
+      )
+      : (
+        <ListGroup>
+          {generateGroupListItems()}
+        </ListGroup>
+      );
 
     return (
-        <Modal className="select-grant-group"
-          container={this} show={this.state.isSelectGroupModalShown} onHide={this.hideSelectGroupModal}
-        >
-          <Modal.Header closeButton>
-            <Modal.Title>
+      <Modal
+        className="select-grant-group"
+        container={this}
+        show={this.state.isSelectGroupModalShown}
+        onHide={this.hideSelectGroupModal}
+      >
+        <Modal.Header closeButton>
+          <Modal.Title>
               Select a Group
-            </Modal.Title>
-          </Modal.Header>
-          <Modal.Body>
-            {content}
-          </Modal.Body>
-        </Modal>
+          </Modal.Title>
+        </Modal.Header>
+        <Modal.Body>
+          {content}
+        </Modal.Body>
+      </Modal>
     );
   }
 
   render() {
-    return <React.Fragment>
-      {this.renderGrantSelector()}
-      {this.renderSelectGroupModal()}
-    </React.Fragment>;
+    return (
+      <React.Fragment>
+        {this.renderGrantSelector()}
+        {this.renderSelectGroupModal()}
+      </React.Fragment>
+    );
   }
+
 }
 
 GrantSelector.propTypes = {
-  t: PropTypes.func.isRequired,               // i18next
+  t: PropTypes.func.isRequired, // i18next
   crowi: PropTypes.object.isRequired,
   grant: PropTypes.number,
   grantGroupId: PropTypes.string,
   grantGroupName: PropTypes.string,
 };
 
-export default translate()(GrantSelector);
+export default withTranslation(null, { withRef: true })(GrantSelector);

+ 5 - 4
src/client/js/components/SearchForm.js

@@ -29,7 +29,7 @@ export default class SearchForm extends React.Component {
   }
 
   onChange(selected) {
-    const page = selected[0];  // should be single page selected
+    const page = selected[0]; // should be single page selected
 
     // navigate to page
     if (page != null) {
@@ -43,7 +43,7 @@ export default class SearchForm extends React.Component {
     return (
       <table className="table m-1 search-help">
         <caption className="text-left text-primary p-2 mb-2">
-          <h5 className="m-1"><i className="icon-magnifier pr-2 mb-2"/>{ t('search_help.title') }</h5>
+          <h5 className="m-1"><i className="icon-magnifier pr-2 mb-2" />{ t('search_help.title') }</h5>
         </caption>
         <tbody>
           <tr>
@@ -55,7 +55,7 @@ export default class SearchForm extends React.Component {
           </tr>
           <tr>
             <th className="text-right pt-2">
-              <code>"This is GROWI"</code><br></br>
+              <code>&quot;This is GROWI&quot;</code><br></br>
               <small>({ t('search_help.phrase.syntax help') })</small>
             </th>
             <td><h6 className="m-0 pt-1">{ t('search_help.phrase.desc', { phrase: 'This is GROWI' }) }</h6></td>
@@ -97,10 +97,11 @@ export default class SearchForm extends React.Component {
       />
     );
   }
+
 }
 
 SearchForm.propTypes = {
-  t: PropTypes.func.isRequired,               // i18next
+  t: PropTypes.func.isRequired, // i18next
   crowi: PropTypes.object.isRequired,
   keyword: PropTypes.string,
   onSubmit: PropTypes.func.isRequired,

+ 33 - 34
src/client/js/components/SearchPage.js

@@ -2,7 +2,7 @@
 
 import React from 'react';
 import PropTypes from 'prop-types';
-import { translate } from 'react-i18next';
+import { withTranslation } from 'react-i18next';
 
 import SearchPageForm from './SearchPage/SearchPageForm';
 import SearchResult from './SearchPage/SearchResult';
@@ -13,12 +13,10 @@ class SearchPage extends React.Component {
     super(props);
 
     this.state = {
-      location: location,
       searchingKeyword: this.props.query.q || '',
       searchedKeyword: '',
       searchedPages: [],
       searchResultMeta: {},
-      searchError: null,
     };
 
     this.search = this.search.bind(this);
@@ -27,17 +25,17 @@ class SearchPage extends React.Component {
 
   componentDidMount() {
     const keyword = this.state.searchingKeyword;
-    if (keyword !== '')  {
-      this.search({keyword});
+    if (keyword !== '') {
+      this.search({ keyword });
     }
   }
 
   static getQueryByLocation(location) {
-    let search = location.search || '';
-    let query = {};
+    const search = location.search || '';
+    const query = {};
 
-    search.replace(/^\?/, '').split('&').forEach(function(element) {
-      let queryParts = element.split('=');
+    search.replace(/^\?/, '').split('&').forEach((element) => {
+      const queryParts = element.split('=');
       query[queryParts[0]] = decodeURIComponent(queryParts[1]).replace(/\+/g, ' ');
     });
 
@@ -45,7 +43,7 @@ class SearchPage extends React.Component {
   }
 
   changeURL(keyword, refreshHash) {
-    let hash = location.hash || '';
+    let hash = window.location.hash || '';
     // TODO 整理する
     if (refreshHash || this.state.searchedKeyword !== '') {
       hash = '';
@@ -62,7 +60,6 @@ class SearchPage extends React.Component {
         searchingKeyword: '',
         searchedPages: [],
         searchResultMeta: {},
-        searchError: null,
       });
 
       return true;
@@ -72,54 +69,56 @@ class SearchPage extends React.Component {
       searchingKeyword: keyword,
     });
 
-    this.props.crowi.apiGet('/search', {q: keyword})
-    .then(res => {
-      this.changeURL(keyword);
-
-      this.setState({
-        searchedKeyword: keyword,
-        searchedPages: res.data,
-        searchResultMeta: res.meta,
-      });
-    }).catch(err => {
-      // TODO error
-      this.setState({
-        searchError: err,
+    this.props.crowi.apiGet('/search', { q: keyword })
+      .then((res) => {
+        this.changeURL(keyword);
+
+        this.setState({
+          searchedKeyword: keyword,
+          searchedPages: res.data,
+          searchResultMeta: res.meta,
+        });
+      })
+      .catch((err) => {
+        // TODO error
+        // this.setState({
+        // });
       });
-    });
   }
 
   render() {
     return (
       <div>
         <div className="search-page-input">
-          <SearchPageForm t={this.props.t}
+          <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}
+          crowi={this.props.crowi}
+          crowiRenderer={this.props.crowiRenderer}
           pages={this.state.searchedPages}
           searchingKeyword={this.state.searchingKeyword}
           searchResultMeta={this.state.searchResultMeta}
-          />
+        />
       </div>
     );
   }
+
 }
 
 SearchPage.propTypes = {
-  t: PropTypes.func.isRequired,               // i18next
+  t: PropTypes.func.isRequired, // i18next
   crowi: PropTypes.object.isRequired,
   crowiRenderer: PropTypes.object.isRequired,
   query: PropTypes.object,
 };
 SearchPage.defaultProps = {
-  //pollInterval: 1000,
-  query: SearchPage.getQueryByLocation(location || {}),
-  searchError: null,
+  // pollInterval: 1000,
+  query: SearchPage.getQueryByLocation(window.location || {}),
 };
 
-export default translate()(SearchPage);
+export default withTranslation()(SearchPage);

+ 4 - 8
src/client/js/components/SearchPage/DeletePageListModal.js

@@ -12,15 +12,11 @@ export default class DeletePageListModal extends React.Component {
    */
   static get OMIT_BODY_THRES() { return 400 }
 
-  constructor(props) {
-    super(props);
-  }
-
   componentWillMount() {
   }
 
   render() {
-    if (this.props.pages === undefined || this.props.pages.length == 0) {
+    if (this.props.pages == null || this.props.pages.length === 0) {
       return <div></div>;
     }
 
@@ -44,7 +40,7 @@ export default class DeletePageListModal extends React.Component {
           <div className="d-flex justify-content-between">
             <span className="text-danger">{this.props.errorMessage}</span>
             <span className="d-flex align-items-center">
-              <Checkbox className="text-danger" onClick={this.props.toggleDeleteCompletely} inline={true}>Delete completely</Checkbox>
+              <Checkbox className="text-danger" onClick={this.props.toggleDeleteCompletely} inline>Delete completely</Checkbox>
               <span className="m-l-10">
                 <Button onClick={this.props.confirmedToDelete}><i className="icon-trash"></i>Delete</Button>
               </span>
@@ -61,7 +57,7 @@ DeletePageListModal.propTypes = {
   isShown: PropTypes.bool.isRequired,
   pages: PropTypes.array,
   errorMessage: PropTypes.string,
-  cancel: PropTypes.func.isRequired,                 // for cancel evnet handling
-  confirmedToDelete: PropTypes.func.isRequired,      // for confirmed event handling
+  cancel: PropTypes.func.isRequired, //                 for cancel evnet handling
+  confirmedToDelete: PropTypes.func.isRequired, //      for confirmed event handling
   toggleDeleteCompletely: PropTypes.func.isRequired, // for delete completely check event handling
 };

+ 23 - 19
src/client/js/components/SearchPage/SearchPageForm.js

@@ -24,35 +24,39 @@ export default class SearchPageForm extends React.Component {
 
   search() {
     const keyword = this.state.keyword;
-    this.props.onSearchFormChanged({keyword: keyword});
-    this.setState({searchedKeyword: keyword});
+    this.props.onSearchFormChanged({ keyword });
+    this.setState({ searchedKeyword: keyword });
   }
 
   onInputChange(input) { // for only submitting with button
-    this.setState({keyword: input});
+    this.setState({ keyword: input });
   }
 
   render() {
-    return <FormGroup>
-      <InputGroup>
-        <SearchForm t={this.props.t}
-          crowi={this.props.crowi}
-          onSubmit={this.search}
-          keyword={this.state.searchedKeyword}
-          onInputChange={this.onInputChange}
-        />
-        <InputGroup.Button className="">
-          <Button onClick={this.search}>
-            <i className="icon-magnifier"></i>
-          </Button >
-        </InputGroup.Button>
-      </InputGroup>
-    </FormGroup>;
+    return (
+      <FormGroup>
+        <InputGroup>
+          <SearchForm
+            t={this.props.t}
+            crowi={this.props.crowi}
+            onSubmit={this.search}
+            keyword={this.state.searchedKeyword}
+            onInputChange={this.onInputChange}
+          />
+          <InputGroup.Button className="">
+            <Button onClick={this.search}>
+              <i className="icon-magnifier"></i>
+            </Button>
+          </InputGroup.Button>
+        </InputGroup>
+      </FormGroup>
+    );
   }
+
 }
 
 SearchPageForm.propTypes = {
-  t: PropTypes.func.isRequired,               // i18next
+  t: PropTypes.func.isRequired, // i18next
   crowi: PropTypes.object.isRequired,
   keyword: PropTypes.string,
   onSearchFormChanged: PropTypes.func.isRequired,

+ 88 - 70
src/client/js/components/SearchPage/SearchResult.js

@@ -1,3 +1,5 @@
+/* eslint-disable jsx-a11y/label-has-associated-control */
+/* eslint-disable jsx-a11y/label-has-for */
 import React from 'react';
 import PropTypes from 'prop-types';
 import * as toastr from 'toastr';
@@ -8,6 +10,7 @@ import DeletePageListModal from './DeletePageListModal';
 
 // Search.SearchResult
 export default class SearchResult extends React.Component {
+
   constructor(props) {
     super(props);
     this.state = {
@@ -50,8 +53,8 @@ export default class SearchResult extends React.Component {
     else {
       this.state.selectedPages.add(page);
     }
-    this.setState({isDeleteConfirmModalShown: false});
-    this.setState({selectedPages: this.state.selectedPages});
+    this.setState({ isDeleteConfirmModalShown: false });
+    this.setState({ selectedPages: this.state.selectedPages });
   }
 
   /**
@@ -61,7 +64,7 @@ export default class SearchResult extends React.Component {
    * @memberof SearchResult
    */
   isAllSelected() {
-    return this.state.selectedPages.size == this.props.pages.length;
+    return this.state.selectedPages.size === this.props.pages.length;
   }
 
   /**
@@ -77,10 +80,12 @@ export default class SearchResult extends React.Component {
       this.state.selectedPages.clear();
       this.props.pages.map((page) => {
         this.state.selectedPages.add(page);
+        return;
       });
     }
-    this.setState({selectedPages: this.state.selectedPages});
+    this.setState({ selectedPages: this.state.selectedPages });
   }
+
   /**
    * change deletion mode
    *
@@ -88,7 +93,7 @@ export default class SearchResult extends React.Component {
    */
   handleDeletionModeChange() {
     this.state.selectedPages.clear();
-    this.setState({deletionMode: !this.state.deletionMode});
+    this.setState({ deletionMode: !this.state.deletionMode });
   }
 
   /**
@@ -98,7 +103,7 @@ export default class SearchResult extends React.Component {
    */
   toggleDeleteCompletely() {
     // request で completely が undefined でないと指定アリと見なされるため
-    this.setState({isDeleteCompletely: this.state.isDeleteCompletely? undefined : true});
+    this.setState({ isDeleteCompletely: this.state.isDeleteCompletely ? undefined : true });
   }
 
   /**
@@ -107,41 +112,41 @@ export default class SearchResult extends React.Component {
    * @memberof SearchResult
    */
   deleteSelectedPages() {
-    let deleteCompletely = this.state.isDeleteCompletely;
+    const deleteCompletely = this.state.isDeleteCompletely;
     Promise.all(Array.from(this.state.selectedPages).map((page) => {
       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})
-          .then(res => {
+        this.props.crowi.apiPost('/pages.remove', { page_id: pageId, revision_id: revisionId, completely: deleteCompletely })
+          .then((res) => {
             if (res.ok) {
               this.state.selectedPages.delete(page);
               return resolve();
             }
-            else {
-              return reject();
-            }
+
+            return reject();
+
           })
-          .catch(err => {
-            console.log(err.message);   // eslint-disable-line no-console
-            this.setState({errorMessageForDeleting: err.message});
+          .catch((err) => {
+            console.log(err.message); // eslint-disable-line no-console
+            this.setState({ errorMessageForDeleting: err.message });
             return reject();
           });
       });
     }))
-    .then(() => {
-      window.location.reload();
-    })
-    .catch(err => {
-      toastr.error(err, 'Error occured', {
-        closeButton: true,
-        progressBar: true,
-        newestOnTop: false,
-        showDuration: '100',
-        hideDuration: '100',
-        timeOut: '3000',
+      .then(() => {
+        window.location.reload();
+      })
+      .catch((err) => {
+        toastr.error(err, 'Error occured', {
+          closeButton: true,
+          progressBar: true,
+          newestOnTop: false,
+          showDuration: '100',
+          hideDuration: '100',
+          timeOut: '3000',
+        });
       });
-    });
   }
 
   /**
@@ -150,7 +155,7 @@ export default class SearchResult extends React.Component {
    * @memberof SearchResult
    */
   showDeleteConfirmModal() {
-    this.setState({isDeleteConfirmModalShown: true});
+    this.setState({ isDeleteConfirmModalShown: true });
   }
 
   /**
@@ -168,8 +173,8 @@ export default class SearchResult extends React.Component {
   render() {
     const excludePathString = this.props.tree;
 
-    //console.log(this.props.searchError);
-    //console.log(this.isError());
+    // console.log(this.props.searchError);
+    // console.log(this.isError());
     if (this.isError()) {
       return (
         <div className="content-main">
@@ -189,7 +194,7 @@ export default class SearchResult extends React.Component {
       }
       return (
         <div className="content-main">
-            <i className="icon-fw icon-info" /> No page found with &quot;{this.props.searchingKeyword}&quot;{under}
+          <i className="icon-fw icon-info" /> No page found with &quot;{this.props.searchingKeyword}&quot;{under}
         </div>
       );
 
@@ -199,48 +204,63 @@ export default class SearchResult extends React.Component {
     let allSelectCheck = '';
 
     if (this.state.deletionMode) {
-      deletionModeButtons =
-      <div className="btn-group">
-        <button type="button" className="btn btn-rounded btn-default btn-xs" onClick={() => this.handleDeletionModeChange()}>
-          <i className="icon-ban"/> Cancel
-        </button>
-        <button type="button" className="btn btn-rounded btn-danger btn-xs" onClick={() => this.showDeleteConfirmModal()} disabled={this.state.selectedPages.size == 0}>
-          <i className="icon-trash"/> Delete
-        </button>
-      </div>;
-      allSelectCheck =
-      <div>
-        <label>
-          <input
-            type="checkbox"
-            onClick={() => this.handleAllSelect()}
-            checked={this.isAllSelected()} />
+      deletionModeButtons = (
+        <div className="btn-group">
+          <button type="button" className="btn btn-rounded btn-default btn-xs" onClick={() => { return this.handleDeletionModeChange() }}>
+            <i className="icon-ban" /> Cancel
+          </button>
+          <button
+            type="button"
+            className="btn btn-rounded btn-danger btn-xs"
+            onClick={() => { return this.showDeleteConfirmModal() }}
+            disabled={this.state.selectedPages.size === 0}
+          >
+            <i className="icon-trash" /> Delete
+          </button>
+        </div>
+      );
+      allSelectCheck = (
+        <div>
+          <label>
+            <input
+              type="checkbox"
+              onClick={() => { return this.handleAllSelect() }}
+              checked={this.isAllSelected()}
+            />
             &nbsp;Check All
-        </label>
-      </div>;
+          </label>
+        </div>
+      );
     }
     else {
-      deletionModeButtons =
-      <div className="btn-group">
-        <button type="button" className="btn btn-default btn-rounded btn-xs" onClick={() => this.handleDeletionModeChange()}>
-          <i className="ti-check-box"/> DeletionMode
-        </button>
-      </div>;
+      deletionModeButtons = (
+        <div className="btn-group">
+          <button type="button" className="btn btn-default btn-rounded btn-xs" onClick={() => { return this.handleDeletionModeChange() }}>
+            <i className="ti-check-box" /> DeletionMode
+          </button>
+        </div>
+      );
     }
 
     const listView = this.props.pages.map((page) => {
-      const pageId = '#' + page._id;
+      const pageId = `#${page._id}`;
       return (
-        <Page page={page}
+        <Page
+          page={page}
           linkTo={pageId}
           key={page._id}
           excludePathString={excludePathString}
-          >
-          { this.state.deletionMode &&
-            <input type="checkbox" className="search-result-list-delete-checkbox"
+        >
+          { this.state.deletionMode
+            && (
+            <input
+              type="checkbox"
+              className="search-result-list-delete-checkbox"
               value={pageId}
               checked={this.state.selectedPages.has(page)}
-              onClick={() => this.toggleCheckbox(page)} />
+              onClick={() => { return this.toggleCheckbox(page) }}
+            />
+)
             }
           <div className="page-list-option">
             <a href={page.path}><i className="icon-login" /></a>
@@ -251,7 +271,7 @@ export default class SearchResult extends React.Component {
 
     // TODO あとでなんとかする
     setTimeout(() => {
-      $('#search-result-list > nav').affix({ offset: { top: 50 }});
+      $('#search-result-list > nav').affix({ offset: { top: 50 } });
     }, 1200);
 
     /*
@@ -280,10 +300,11 @@ 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}
+              crowi={this.props.crowi}
+              crowiRenderer={this.props.crowiRenderer}
               pages={this.props.pages}
               searchingKeyword={this.props.searchingKeyword}
-              />
+            />
           </div>
         </div>
         <DeletePageListModal
@@ -295,9 +316,10 @@ export default class SearchResult extends React.Component {
           toggleDeleteCompletely={this.toggleDeleteCompletely}
         />
 
-      </div>//content-main
+      </div>// content-main
     );
   }
+
 }
 
 SearchResult.propTypes = {
@@ -307,12 +329,8 @@ SearchResult.propTypes = {
   pages: PropTypes.array.isRequired,
   searchingKeyword: PropTypes.string.isRequired,
   searchResultMeta: PropTypes.object.isRequired,
-  searchError: PropTypes.object
+  searchError: PropTypes.object,
 };
 SearchResult.defaultProps = {
-  tree: '',
-  pages: [],
-  searchingKeyword: '',
-  searchResultMeta: {},
   searchError: null,
 };

+ 3 - 4
src/client/js/components/SearchPage/SearchResultList.js

@@ -10,7 +10,7 @@ export default class SearchResultList extends React.Component {
   constructor(props) {
     super(props);
 
-    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiRenderer, {mode: 'searchresult'});
+    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiRenderer, { mode: 'searchresult' });
   }
 
   render() {
@@ -32,10 +32,11 @@ export default class SearchResultList extends React.Component {
 
     return (
       <div>
-      {resultList}
+        {resultList}
       </div>
     );
   }
+
 }
 
 SearchResultList.propTypes = {
@@ -46,6 +47,4 @@ SearchResultList.propTypes = {
 };
 
 SearchResultList.defaultProps = {
-  pages: [],
-  searchingKeyword: '',
 };

+ 25 - 27
src/client/js/components/SearchTypeahead.js

@@ -16,8 +16,6 @@ export default class SearchTypeahead extends React.Component {
 
     this.state = {
       input: this.props.keywordOnInit,
-      keyword: '',
-      searchedKeyword: '',
       pages: [],
       isLoading: false,
       searchError: null,
@@ -39,7 +37,7 @@ export default class SearchTypeahead extends React.Component {
    * Get instance of AsyncTypeahead
    */
   getTypeahead() {
-    return this.refs.typeahead ? this.refs.typeahead.getInstance() : null;
+    return this.typeahead ? this.typeahead.getInstance() : null;
   }
 
   componentDidMount() {
@@ -54,7 +52,7 @@ export default class SearchTypeahead extends React.Component {
   restoreInitialData() {
     // see https://github.com/ericgio/react-bootstrap-typeahead/issues/266#issuecomment-414987723
     const text = this.props.keywordOnInit;
-    const instance = this.refs.typeahead.getInstance();
+    const instance = this.typeahead.getInstance();
     instance.clear();
     instance.setState({ text });
   }
@@ -62,18 +60,14 @@ export default class SearchTypeahead extends React.Component {
   search(keyword) {
 
     if (keyword === '') {
-      this.setState({
-        keyword: '',
-        searchedKeyword: '',
-      });
       return;
     }
 
-    this.setState({isLoading: true});
+    this.setState({ isLoading: true });
 
-    this.crowi.apiGet('/search', {q: keyword})
-      .then(res => { this.onSearchSuccess(res) })
-      .catch(err => { this.onSearchError(err) });
+    this.crowi.apiGet('/search', { q: keyword })
+      .then((res) => { this.onSearchSuccess(res) })
+      .catch((err) => { this.onSearchError(err) });
   }
 
   /**
@@ -83,10 +77,11 @@ export default class SearchTypeahead extends React.Component {
   onSearchSuccess(res) {
     this.setState({
       isLoading: false,
-      keyword: '',
       pages: res.data,
     });
-    this.props.onSearchSuccess && this.props.onSearchSuccess(res);
+    if (this.props.onSearchSuccess != null) {
+      this.props.onSearchSuccess(res);
+    }
   }
 
   /**
@@ -98,14 +93,16 @@ export default class SearchTypeahead extends React.Component {
       isLoading: false,
       searchError: err,
     });
-    this.props.onSearchError && this.props.onSearchError(err);
+    if (this.props.onSearchError != null) {
+      this.props.onSearchError(err);
+    }
   }
 
   onInputChange(text) {
-    this.setState({input: text});
+    this.setState({ input: text });
     this.props.onInputChange(text);
     if (text === '') {
-      this.setState({pages: []});
+      this.setState({ pages: [] });
     }
   }
 
@@ -141,9 +138,9 @@ export default class SearchTypeahead extends React.Component {
    * Get restore form button to initialize button
    */
   getRestoreFormButton() {
-    let isHidden = (this.state.input === this.props.keywordOnInit);
+    const isHidden = (this.state.input === this.props.keywordOnInit);
 
-    return isHidden ? <span></span> : (
+    return isHidden ? <span /> : (
       <button type="button" className="btn btn-link search-clear" onMouseDown={this.restoreInitialData}>
         <i className="icon-close" />
       </button>
@@ -154,16 +151,16 @@ export default class SearchTypeahead extends React.Component {
     const page = option;
     return (
       <span>
-      <UserPicture user={page.lastUpdateUser} size="sm" />
-      <PagePath page={page} />
-      <PageListMeta page={page} />
+        <UserPicture user={page.lastUpdateUser} size="sm" />
+        <PagePath page={page} />
+        <PageListMeta page={page} />
       </span>
     );
   }
 
   render() {
-    const defaultSelected = (this.props.keywordOnInit != '')
-      ? [{path: this.props.keywordOnInit}]
+    const defaultSelected = (this.props.keywordOnInit !== '')
+      ? [{ path: this.props.keywordOnInit }]
       : [];
     const inputProps = { autoComplete: 'off' };
     if (this.props.inputName != null) {
@@ -176,7 +173,7 @@ export default class SearchTypeahead extends React.Component {
       <div className="search-typeahead">
         <AsyncTypeahead
           {...this.props}
-          ref="typeahead"
+          ref={(c) => { this.typeahead = c }}
           inputProps={inputProps}
           isLoading={this.state.isLoading}
           labelKey="path"
@@ -187,8 +184,8 @@ export default class SearchTypeahead extends React.Component {
               // DIRTY HACK
               //  note: The default searchText string has been shown wrongly even if isLoading is false
               //        since upgrade react-bootstrap-typeahead to v3.3.2 -- 2019.02.05 Yuki Takei
-          align='left'
-          submitFormOnEnter={true}
+          align="left"
+          submitFormOnEnter
           onSearch={this.search}
           onInputChange={this.onInputChange}
           onKeyDown={this.onKeyDown}
@@ -201,6 +198,7 @@ export default class SearchTypeahead extends React.Component {
       </div>
     );
   }
+
 }
 
 /**

+ 14 - 12
src/client/js/components/SlackNotification.jsx

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

+ 8 - 9
src/client/js/components/User/User.js

@@ -7,7 +7,7 @@ export default class User extends React.Component {
 
   render() {
     const user = this.props.user;
-    const userLink = '/user/' + user.username;
+    const userLink = `/user/${user.username}`;
 
     const username = this.props.username;
     const name = this.props.name;
@@ -17,25 +17,24 @@ export default class User extends React.Component {
         <a href={userLink}>
           <UserPicture user={user} />
 
-          {username &&
-              <span className="user-component-username">@{user.username}</span>
+          {username
+              && <span className="user-component-username">@{user.username}</span>
           }
-          {name &&
-              <span className="user-component-name">({user.name})</span>
+          {name
+              && <span className="user-component-name">({user.name})</span>
           }
         </a>
       </span>
     );
   }
+
 }
 
 User.propTypes = {
   user: PropTypes.object.isRequired,
-  name: PropTypes.bool.isRequired,
-  username: PropTypes.bool.isRequired,
+  name: PropTypes.bool,
+  username: PropTypes.bool,
 };
 
 User.defaultProps = {
-  name: false,
-  username: false,
 };

+ 9 - 9
src/client/js/components/User/UserPicture.js

@@ -11,15 +11,15 @@ export default class UserPicture extends React.Component {
       return this.generateGravatarSrc(user);
     }
     // uploaded image
-    else if (user.image != null) {
+    if (user.image != null) {
       return user.image;
     }
-    else if (user.imageAttachment != null) {
+    if (user.imageAttachment != null) {
       return user.imageAttachment.filePathProxied;
     }
-    else {
-      return '/images/icons/user.svg';
-    }
+
+    return '/images/icons/user.svg';
+
   }
 
   generateGravatarSrc(user) {
@@ -29,10 +29,10 @@ export default class UserPicture extends React.Component {
   }
 
   getClassName() {
-    let className = ['img-circle', 'picture'];
+    const className = ['img-circle', 'picture'];
     // size
     if (this.props.size) {
-      className.push('picture-' + this.props.size);
+      className.push(`picture-${this.props.size}`);
     }
 
     return className.join(' ');
@@ -46,9 +46,10 @@ export default class UserPicture extends React.Component {
         src={this.getUserPicture(user)}
         alt={user.username}
         className={this.getClassName()}
-        />
+      />
     );
   }
+
 }
 
 UserPicture.propTypes = {
@@ -57,6 +58,5 @@ UserPicture.propTypes = {
 };
 
 UserPicture.defaultProps = {
-  user: {},
   size: null,
 };

+ 25 - 23
src/client/js/hackmd-agent.js

@@ -9,14 +9,14 @@
  *
  * @author Yuki Takei <yuki@weseek.co.jp>
  */
-import Penpal from 'penpal';
-// Penpal.debug = true;
-
+import connectToParent from 'penpal/lib/connectToParent';
 import { debounce } from 'throttle-debounce';
 
+const DEBUG_PENPAL = false;
+
 /* eslint-disable no-console  */
 
-const allowedOrigin = '{{origin}}';         // will be replaced by swig
+const allowedOrigin = '{{origin}}'; // will be replaced by swig
 
 
 /**
@@ -47,14 +47,14 @@ function setValueToCodemirrorOnInit(newValue) {
     setValueToCodemirror(newValue);
     return;
   }
-  else {
-    const intervalId = setInterval(() => {
-      if (window.cmClient != null) {
-        clearInterval(intervalId);
-        setValueToCodemirror(newValue);
-      }
-    }, 250);
-  }
+
+  const intervalId = setInterval(() => {
+    if (window.cmClient != null) {
+      clearInterval(intervalId);
+      setValueToCodemirror(newValue);
+    }
+  }, 250);
+
 }
 
 /**
@@ -86,7 +86,7 @@ function addEventListenersToCodemirror() {
     return;
   }
 
-  //// change event
+  // == change event
   editor.on('change', (cm, change) => {
     if (change.origin === 'ignoreHistory') {
       // do nothing because this operation triggered by other user
@@ -95,7 +95,7 @@ function addEventListenersToCodemirror() {
     debouncedPostParentToNotifyBodyChanges(cm.doc.getValue());
   });
 
-  //// save event
+  // == save event
   // Reset save commands and Cmd-S/Ctrl-S shortcuts that initialized by HackMD
   codemirror.commands.save = function(cm) {
     postParentToSaveWithShortcut(cm.doc.getValue());
@@ -105,7 +105,7 @@ function addEventListenersToCodemirror() {
 }
 
 function connectToParentWithPenpal() {
-  const connection = Penpal.connectToParent({
+  const connection = connectToParent({
     parentOrigin: allowedOrigin,
     // Methods child is exposing to parent
     methods: {
@@ -117,14 +117,17 @@ function connectToParentWithPenpal() {
       },
       setValueOnInit(newValue) {
         setValueToCodemirrorOnInit(newValue);
-      }
-    }
-  });
-  connection.promise.then(parent => {
-    window.growi = parent;
-  }).catch(err => {
-    console.log(err);
+      },
+    },
+    debug: DEBUG_PENPAL,
   });
+  connection.promise
+    .then((parent) => {
+      window.growi = parent;
+    })
+    .catch((err) => {
+      console.log(err);
+    });
 }
 
 /**
@@ -148,4 +151,3 @@ function connectToParentWithPenpal() {
 
   console.log('[HackMD] GROWI agent for HackMD has successfully loaded.');
 }());
-

+ 1 - 1
src/client/js/hackmd-styles.js

@@ -12,7 +12,7 @@
 
 /* eslint-disable no-console  */
 
-const styles = '{{styles}}';         // will be replaced by swig
+const styles = '{{styles}}'; // will be replaced by swig
 
 /**
  * Insert link tag to load style file

+ 7 - 5
src/client/js/i18n.js

@@ -1,6 +1,6 @@
 import i18n from 'i18next';
 import LanguageDetector from 'i18next-browser-languagedetector';
-import { reactI18nextModule } from 'react-i18next';
+import { initReactI18next } from 'react-i18next';
 
 import resources from '@alias/locales';
 
@@ -16,9 +16,9 @@ export default (userlang) => {
     },
   });
 
-  return i18n
+  i18n
     .use(langDetector)
-    .use(reactI18nextModule) // if not using I18nextProvider
+    .use(initReactI18next) // if not using I18nextProvider
     .init({
       debug: (process.env.NODE_ENV !== 'production'),
       resources,
@@ -39,7 +39,9 @@ export default (userlang) => {
         withRef: true,
         bindI18n: 'languageChanged loaded',
         bindStore: 'added removed',
-        nsMode: 'default'
-      }
+        nsMode: 'default',
+      },
     });
+
+  return i18n;
 };

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