Browse Source

Merge branch 'master' into support/vrt-with-cypress

Yuki Takei 4 years ago
parent
commit
d0113c43a9
51 changed files with 1205 additions and 447 deletions
  1. 25 1
      CHANGELOG.md
  2. 1 1
      lerna.json
  3. 1 1
      package.json
  4. 3 3
      packages/app/config/webpack.prod.js
  5. 2 2
      packages/app/docker/README.md
  6. 13 12
      packages/app/package.json
  7. 12 0
      packages/app/resource/locales/en_US/translation.json
  8. 12 0
      packages/app/resource/locales/ja_JP/translation.json
  9. 12 0
      packages/app/resource/locales/zh_CN/translation.json
  10. 1 0
      packages/app/src/client/services/EditorContainer.js
  11. 53 3
      packages/app/src/client/services/PageContainer.js
  12. 6 2
      packages/app/src/client/util/apiv1-client.ts
  13. 0 33
      packages/app/src/components/ExpandOrContractButton.jsx
  14. 37 0
      packages/app/src/components/ExpandOrContractButton.tsx
  15. 5 9
      packages/app/src/components/Page/TagLabels.jsx
  16. 35 45
      packages/app/src/components/PageEditor/AbstractEditor.tsx
  17. 2 9
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  18. 282 0
      packages/app/src/components/PageEditor/ConflictDiffModal.tsx
  19. 100 83
      packages/app/src/components/PageEditor/Editor.jsx
  20. 52 1
      packages/app/src/components/PageStatusAlert.jsx
  21. 8 0
      packages/app/src/components/SavePageControls.jsx
  22. 10 7
      packages/app/src/components/Sidebar/RecentChanges.tsx
  23. 4 0
      packages/app/src/components/Sidebar/SidebarContents.tsx
  24. 1 1
      packages/app/src/components/Sidebar/SidebarNav.tsx
  25. 44 0
      packages/app/src/components/Sidebar/Tag.tsx
  26. 38 0
      packages/app/src/components/TagCloudBox.tsx
  27. 44 18
      packages/app/src/components/TagsList.jsx
  28. 58 0
      packages/app/src/components/UncontrolledCodeMirror.tsx
  29. 7 0
      packages/app/src/interfaces/revision.ts
  30. 1 0
      packages/app/src/interfaces/ui.ts
  31. 69 0
      packages/app/src/migrations/20210921173042-add-is-trashed-field.js
  32. 3 0
      packages/app/src/server/crowi/express-init.js
  33. 45 25
      packages/app/src/server/models/page-tag-relation.js
  34. 5 1
      packages/app/src/server/models/vo/s2c-message.js
  35. 2 0
      packages/app/src/server/routes/apiv3/pages.js
  36. 11 2
      packages/app/src/server/routes/page.js
  37. 16 22
      packages/app/src/server/routes/tag.js
  38. 6 0
      packages/app/src/server/service/config-loader.ts
  39. 6 0
      packages/app/src/server/service/page.js
  40. 2 1
      packages/app/src/server/service/passport.ts
  41. 2 1
      packages/app/src/server/util/apiResponse.js
  42. 2 5
      packages/app/src/server/views/tags.html
  43. 1 1
      packages/codemirror-textlint/package.json
  44. 1 1
      packages/core/package.json
  45. 1 1
      packages/plugin-attachment-refs/package.json
  46. 1 1
      packages/plugin-lsx/package.json
  47. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  48. 1 1
      packages/slack/package.json
  49. 2 2
      packages/slackbot-proxy/package.json
  50. 1 1
      packages/ui/package.json
  51. 158 150
      yarn.lock

+ 25 - 1
CHANGELOG.md

@@ -1,9 +1,33 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.5.4...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v4.5.5...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v4.5.5](https://github.com/weseek/growi/compare/v4.5.4...v4.5.5) - 2022-01-05
+
+### 💎 Features
+
+- feat: OIDC reconnection (#5016) @mudana-grune
+- feat: In-App Notification (#4792) @kaoritokashiki
+
+### 🚀 Improvement
+
+- imprv: Improve tags functions (#5001) @yuto-oweseek
+- imprv: Migrate editor container grant to SWR (#4957) @stevenfukase
+
+### 🐛 Bug Fixes
+
+- Bug: Error: The specified instance couldn't register because same id has already been registered (#5031) by 573216c @yuki-takei
+
+### 🧰 Maintenance
+
+- fix: dependabot alert trim-newlines (#4931) @mudana-grune
+- fix: dependabot alert dot-prop (#4921) @mudana-grune
+- ci(deps-dev): bump tsconfig-paths-webpack-plugin from 3.5.1 to 3.5.2 (#4852) @dependabot
+- ci(deps): bump ua-parser-js from 0.7.17 to 0.7.31 (#4895) @dependabot
+- support: dependabot alert ssri (#4973) @mudana-grune
+
 ## [v4.5.4](https://github.com/weseek/growi/compare/v4.5.3...v4.5.4) - 2021-12-23
 
 ### 💎 Features

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
   "npmClient": "yarn",
   "useWorkspaces": true,
-  "version": "4.5.5-RC.0",
+  "version": "4.5.6-RC.0",
   "packages": [
     "packages/*"
   ]

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.5.5-RC.0",
+  "version": "4.5.6-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 3 - 3
packages/app/config/webpack.prod.js

@@ -35,10 +35,10 @@ module.exports = require('./webpack.common')({
             loader: 'postcss-loader',
             options: {
               sourceMap: false,
-              plugins: () => {
-                return [
+              postcssOptions: {
+                plugins: [
                   require('autoprefixer')(),
-                ];
+                ],
               },
             },
           },

+ 2 - 2
packages/app/docker/README.md

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`4.5.4`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.4/docker/Dockerfile)
-* [`4.5.4-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.4/docker/Dockerfile)
+* [`4.5.5`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.5/docker/Dockerfile)
+* [`4.5.5-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.5/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 

+ 13 - 12
packages/app/package.json

@@ -1,12 +1,12 @@
 {
   "name": "@growi/app",
-  "version": "4.5.5-RC.0",
+  "version": "4.5.6-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
     "start": "yarn build && yarn server",
     "build": "run-p build:*",
-    "build:client": "yarn cross-env NODE_ENV=production webpack --config config/webpack.prod.js --profile --bail",
+    "build:client": "yarn cross-env NODE_ENV=production webpack --config config/webpack.prod.js",
     "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
     "clean": "npx shx rm -rf dist transpiled",
     "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
@@ -52,18 +52,17 @@
   "// comments for dependencies": {
     "openid-client": "Node.js 12 or higher is required for openid-client@3 and above.",
     "escape-string-regexp": "5.0.0 or above exports only ESM",
-    "mongoose": "5.13.13 causes an error like 't.versions.node is undefined' about 'browser.umd.js' on browser",
     "string-width": "5.0.0 or above exports only ESM."
   },
   "dependencies": {
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^4.5.5-RC.0",
-    "@growi/plugin-attachment-refs": "^4.5.5-RC.0",
-    "@growi/plugin-lsx": "^4.5.5-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^4.5.5-RC.0",
-    "@growi/slack": "^4.5.5-RC.0",
+    "@growi/codemirror-textlint": "^4.5.6-RC.0",
+    "@growi/plugin-attachment-refs": "^4.5.6-RC.0",
+    "@growi/plugin-lsx": "^4.5.6-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^4.5.6-RC.0",
+    "@growi/slack": "^4.5.6-RC.0",
     "@promster/express": "^5.1.0",
     "@promster/server": "^6.0.3",
     "@slack/events-api": "^3.0.0",
@@ -79,6 +78,7 @@
     "browser-bunyan": "^1.6.3",
     "bunyan": "^1.8.15",
     "check-node-version": "^4.1.0",
+    "compression": "^1.7.4",
     "connect-flash": "~0.1.1",
     "connect-mongo": "^4.6.0",
     "connect-redis": "^4.0.4",
@@ -87,6 +87,7 @@
     "date-fns": "^2.23.0",
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
+    "diff_match_patch": "^0.1.1",
     "elasticsearch": "^16.0.0",
     "entities": "^2.0.0",
     "esa-nodejs": "^0.0.7",
@@ -136,6 +137,7 @@
     "re2": "^1.17.1",
     "react-card-flip": "^1.0.10",
     "react-image-crop": "^8.3.0",
+    "react-tagcloud": "^2.1.1",
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
     "rimraf": "^3.0.0",
@@ -159,7 +161,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^4.5.5-RC.0",
+    "@growi/ui": "^4.5.6-RC.0",
     "@handsontable/react": "=2.1.0",
     "@testing-library/cypress": "^8.0.2",
     "@types/compression": "^1.7.0",
@@ -247,10 +249,9 @@
     "tsc-alias": "^1.2.9",
     "tsconfig-paths-webpack-plugin": "^3.5.1",
     "unstated": "^2.1.1",
-    "webpack": "^4.39.3",
+    "webpack": "^4.46.0",
     "webpack-assets-manifest": "^3.1.1",
     "webpack-bundle-analyzer": "^3.9.0",
-    "webpack-cli": "^3.3.7",
-    "webpack-merge": "^4.2.2"
+    "webpack-cli": "^4.9.1"
   }
 }

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

@@ -138,6 +138,7 @@
   "Shareable link": "Shareable link",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
   "Add tags for this page": "Add tags for this page",
+  "Check All tags": "check all tags",
   "You have no tag, You can set tags on pages": "You have no tag, You can set tags on pages",
   "Show latest": "Show latest",
   "Load latest": "Load latest",
@@ -477,6 +478,17 @@
     "enable_textlint": "Enable Textlint",
     "dont_ask_again": "Don't ask again"
   },
+  "modal_resolve_conflict": {
+    "file_conflicting_with_newer_remote": "This file is conflicting with newer remote file",
+    "resolve_conflict_message": "Please select page body",
+    "resolve_conflict": "Resolve Conflict",
+    "resolve_and_save" : "Resolve and save",
+    "select_revision" : "Select {{revision}}",
+    "requested_revision": "mine",
+    "origin_revision": "origin",
+    "latest_revision": "theirs",
+    "selected_editable_revision": "Selected Page Body (Editable)"
+  },
   "link_edit": {
     "edit_link": "Edit Link",
     "set_link_and_label": "Set link and label",

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

@@ -137,6 +137,7 @@
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "Add tags for this page": "タグを付ける",
+  "Check All tags": "全てのタグをチェックする",
   "You have no tag, You can set tags on pages": "使用中のタグがありません",
   "Show latest": "最新のページを表示",
   "Load latest": "最新版を読み込む",
@@ -477,6 +478,17 @@
     "enable_textlint": "Textlintを有効にする",
     "dont_ask_again": "常に許可する"
   },
+  "modal_resolve_conflict": {
+    "file_conflicting_with_newer_remote": "サーバー側の新しいファイルと衝突します。",
+    "resolve_conflict_message": "ページ本文を選んでください",
+    "resolve_conflict": "衝突を解消",
+    "resolve_and_save" : "解消し保存する",
+    "select_revision" : "{{revision}}にする",
+    "requested_revision": "送信された本文",
+    "origin_revision": "送信する前の本文",
+    "latest_revision": "最新の本文",
+    "selected_editable_revision": "保存するページ本文(編集可能)"
+  },
   "link_edit": {
     "edit_link": "リンク編集",
     "set_link_and_label": "リンク情報",

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

@@ -146,6 +146,7 @@
 	"Shareable link": "可分享链接",
 	"The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
 	"Add tags for this page": "添加标签",
+  "Check All tags": "检查所有标签",
 	"You have no tag, You can set tags on pages": "你没有标签,可以在页面上设置标签",
 	"Show latest": "显示最新",
 	"Load latest": "家在最新",
@@ -455,6 +456,17 @@
     "enable_textlint": "启用Textlint",
     "dont_ask_again": "不要再问"
   },
+  "modal_resolve_conflict": {
+    "file_conflicting_with_newer_remote": "此文件与较新的远程文件冲突",
+    "resolve_conflict_message": "选择页面正文",
+    "resolve_conflict": "解决冲突",
+    "resolve_and_save" : "解决冲突并保存",
+    "select_revision" : "选择{{revision}}",
+    "requested_revision": "发送的页面正文",
+    "origin_revision": "发送前的页面正文",
+    "latest_revision": "最新页面正文",
+    "selected_editable_revision": "选定的可编辑页面正文"
+  },
   "link_edit": {
     "edit_link": "Edit Link",
     "set_link_and_label": "Set link and label",

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

@@ -41,6 +41,7 @@ export default class EditorContainer extends Container {
 
     this.initDrafts();
 
+    this.editorOptions = null;
     this.initEditorOptions('editorOptions', 'editorOptions', defaultEditorOptions);
     this.initEditorOptions('previewOptions', 'previewOptions', defaultPreviewOptions);
   }

+ 53 - 3
packages/app/src/client/services/PageContainer.js

@@ -4,6 +4,8 @@ import { Container } from 'unstated';
 import * as entities from 'entities';
 import * as toastr from 'toastr';
 import { pagePathUtils } from '@growi/core';
+
+import { apiPost } from '../util/apiv1-client';
 import loggerFactory from '~/utils/logger';
 import { toastError } from '../util/apiNotification';
 
@@ -86,12 +88,15 @@ export default class PageContainer extends Container {
 
       // latest(on remote) information
       remoteRevisionId: revisionId,
+      remoteRevisionBody: null,
+      remoteRevisionUpdateAt: null,
       revisionIdHackmdSynced: mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null,
       lastUpdateUsername: mainContent.getAttribute('data-page-last-update-username') || null,
       deleteUsername: mainContent.getAttribute('data-page-delete-username') || null,
       pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd') || null,
       hasDraftOnHackmd: !!mainContent.getAttribute('data-page-has-draft-on-hackmd'),
       isHackmdDraftUpdatingInRealtime: false,
+      isConflictDiffModalOpen: false,
     };
 
     // parse creator, lastUpdateUser and revisionAuthor
@@ -103,6 +108,7 @@ export default class PageContainer extends Container {
     }
     try {
       this.state.revisionAuthor = JSON.parse(mainContent.getAttribute('data-page-revision-author'));
+      this.state.lastUpdateUser = JSON.parse(mainContent.getAttribute('data-page-revision-author'));
     }
     catch (e) {
       logger.warn('The data of \'data-page-revision-author\' is invalid', e);
@@ -347,8 +353,12 @@ export default class PageContainer extends Container {
   setLatestRemotePageData(s2cMessagePageUpdated) {
     const newState = {
       remoteRevisionId: s2cMessagePageUpdated.revisionId,
+      remoteRevisionBody: s2cMessagePageUpdated.revisionBody,
+      remoteRevisionUpdateAt: s2cMessagePageUpdated.revisionUpdateAt,
       revisionIdHackmdSynced: s2cMessagePageUpdated.revisionIdHackmdSynced,
+      // TODO // TODO remove lastUpdateUsername and refactor parts that lastUpdateUsername is used
       lastUpdateUsername: s2cMessagePageUpdated.lastUpdateUsername,
+      lastUpdateUser: s2cMessagePageUpdated.remoteLastUpdateUser,
     };
 
     if (s2cMessagePageUpdated.hasDraftOnHackmd != null) {
@@ -377,6 +387,7 @@ export default class PageContainer extends Container {
       revisionId: revision._id,
       revisionCreatedAt: new Date(revision.createdAt).getTime() / 1000,
       remoteRevisionId: revision._id,
+      revisionAuthor: revision.author,
       revisionIdHackmdSynced: page.revisionHackmdSynced,
       hasDraftOnHackmd: page.hasDraftOnHackmd,
       markdown: revision.body,
@@ -404,8 +415,31 @@ export default class PageContainer extends Container {
       }
     }
 
-    // hidden input
-    $('input[name="revision_id"]').val(newState.revisionId);
+  }
+
+  /**
+   * update page meta data
+   * @param {object} page Page instance
+   * @param {object} revision Revision instance
+   * @param {String[]} tags Array of Tag
+   */
+  updatePageMetaData(page, revision, tags) {
+
+    const newState = {
+      revisionId: revision._id,
+      revisionCreatedAt: new Date(revision.createdAt).getTime() / 1000,
+      remoteRevisionId: revision._id,
+      revisionAuthor: revision.author,
+      revisionIdHackmdSynced: page.revisionHackmdSynced,
+      hasDraftOnHackmd: page.hasDraftOnHackmd,
+      updatedAt: page.updatedAt,
+    };
+    if (tags != null) {
+      newState.tags = tags;
+    }
+
+    this.setState(newState);
+
   }
 
   /**
@@ -417,7 +451,6 @@ export default class PageContainer extends Container {
   async save(markdown, editorMode, optionsToSave = {}) {
     const { pageId, path } = this.state;
     let { revisionId } = this.state;
-
     const options = Object.assign({}, optionsToSave);
 
     if (editorMode === 'hackmd') {
@@ -639,4 +672,21 @@ export default class PageContainer extends Container {
   retrieveMyBookmarkList() {
   }
 
+  async resolveConflict(markdown, editorMode) {
+
+    const { pageId, remoteRevisionId, path } = this.state;
+    const editorContainer = this.appContainer.getContainer('EditorContainer');
+    const options = editorContainer.getCurrentOptionsToSave();
+    const optionsToSave = Object.assign({}, options);
+
+    const res = await this.updatePage(pageId, remoteRevisionId, markdown, optionsToSave);
+
+    editorContainer.clearDraft(path);
+    this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
+
+    editorContainer.setState({ tags: res.tags });
+
+    return res;
+  }
+
 }

+ 6 - 2
packages/app/src/client/util/apiv1-client.ts

@@ -17,11 +17,15 @@ class Apiv1ErrorHandler extends Error {
 
   code;
 
-  constructor(message = '', code = '') {
+  data;
+
+  constructor(message = '', code = '', data = '') {
     super();
 
     this.message = message;
     this.code = code;
+    this.data = data;
+
   }
 
 }
@@ -35,7 +39,7 @@ export async function apiRequest(method: string, path: string, params: unknown):
 
   // Return error code if code is exist
   if (res.data.code != null) {
-    const error = new Apiv1ErrorHandler(res.data.error, res.data.code);
+    const error = new Apiv1ErrorHandler(res.data.error, res.data.code, res.data.data);
     throw error;
   }
 

+ 0 - 33
packages/app/src/components/ExpandOrContractButton.jsx

@@ -1,33 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-function ExpandOrContractButton(props) {
-  const { isWindowExpanded, contractWindow, expandWindow } = props;
-
-  const clickContractButtonHandler = () => {
-    if (contractWindow != null) {
-      contractWindow();
-    }
-  };
-
-  const clickExpandButtonHandler = () => {
-    if (expandWindow != null) {
-      expandWindow();
-    }
-  };
-
-  return (
-    <button type="button" className="close" onClick={isWindowExpanded ? clickContractButtonHandler : clickExpandButtonHandler}>
-      <i className={`${isWindowExpanded ? 'icon-size-actual' : 'icon-size-fullscreen'}`} style={{ fontSize: '0.8em' }} aria-hidden="true"></i>
-    </button>
-  );
-}
-
-ExpandOrContractButton.propTypes = {
-  isWindowExpanded: PropTypes.bool,
-  contractWindow: PropTypes.func,
-  expandWindow: PropTypes.func,
-};
-
-
-export default ExpandOrContractButton;

+ 37 - 0
packages/app/src/components/ExpandOrContractButton.tsx

@@ -0,0 +1,37 @@
+import React, { FC } from 'react';
+
+
+type Props = {
+  isWindowExpanded: boolean,
+  contractWindow?: () => void,
+  expandWindow?: () => void,
+};
+
+const ExpandOrContractButton: FC<Props> = (props: Props) => {
+  const { isWindowExpanded, contractWindow, expandWindow } = props;
+
+  const clickContractButtonHandler = (): void => {
+    if (contractWindow != null) {
+      contractWindow();
+    }
+  };
+
+  const clickExpandButtonHandler = (): void => {
+    if (expandWindow != null) {
+      expandWindow();
+    }
+  };
+
+  return (
+    <button
+      type="button"
+      className="close"
+      onClick={isWindowExpanded ? clickContractButtonHandler : clickExpandButtonHandler}
+    >
+      <i className={`${isWindowExpanded ? 'icon-size-actual' : 'icon-size-fullscreen'}`} style={{ fontSize: '0.8em' }} aria-hidden="true"></i>
+    </button>
+  );
+};
+
+
+export default ExpandOrContractButton;

+ 5 - 9
packages/app/src/components/Page/TagLabels.jsx

@@ -50,21 +50,17 @@ class TagLabels extends React.Component {
       appContainer, editorContainer, pageContainer, editorMode,
     } = this.props;
 
-    const { pageId } = pageContainer.state;
-
+    const { pageId, revisionId } = pageContainer.state;
     // It will not be reflected in the DB until the page is refreshed
     if (editorMode === EditorMode.Editor) {
       return editorContainer.setState({ tags: newTags });
     }
-
     try {
-      const { tags } = await appContainer.apiPost('/tags.update', { pageId, tags: newTags });
-
-      // update pageContainer.state
-      pageContainer.setState({ tags });
-      // update editorContainer.state
+      const { tags, savedPage } = await appContainer.apiPost('/tags.update', {
+        pageId, tags: newTags, revisionId,
+      });
       editorContainer.setState({ tags });
-
+      pageContainer.updatePageMetaData(savedPage, savedPage.revision, tags);
       toastSuccess('updated tags successfully');
     }
     catch (err) {

+ 35 - 45
packages/app/src/components/PageEditor/AbstractEditor.jsx → packages/app/src/components/PageEditor/AbstractEditor.tsx

@@ -1,11 +1,20 @@
-/* eslint-disable react/no-unused-prop-types */
-
+/* eslint-disable @typescript-eslint/no-unused-vars */
 import React from 'react';
-import PropTypes from 'prop-types';
+import { ICodeMirror } from 'react-codemirror2';
+
+
+export interface AbstractEditorProps extends ICodeMirror {
+  value?: string;
+  isGfmMode?: boolean;
+  onScrollCursorIntoView?: (line: number) => void;
+  onSave?: () => Promise<void>;
+  onPasteFiles?: (event: Event) => void;
+  onCtrlEnter?: (event: Event) => void;
+}
 
-export default class AbstractEditor extends React.Component {
+export default class AbstractEditor<T extends AbstractEditorProps> extends React.Component<T, Record<string, unknown>> {
 
-  constructor(props) {
+  constructor(props: Readonly<T>) {
     super(props);
 
     this.forceToFocus = this.forceToFocus.bind(this);
@@ -20,91 +29,87 @@ export default class AbstractEditor extends React.Component {
     this.dispatchSave = this.dispatchSave.bind(this);
   }
 
-  forceToFocus() {
-  }
+  forceToFocus(): void {}
 
   /**
    * set new value
    */
-  setValue(newValue) {
-  }
+  setValue(_newValue: string): void {}
 
   /**
    * Enable/Disable GFM mode
-   * @param {bool} bool
+   * @param {bool} _bool
    */
-  setGfmMode(bool) {
-  }
+  setGfmMode(_bool: boolean): void {}
 
   /**
    * set caret position of codemirror
    * @param {string} number
    */
-  setCaretLine(line) {
-  }
+  setCaretLine(_line: number): void {}
 
   /**
    * scroll
-   * @param {number} line
+   * @param {number} _line
    */
-  setScrollTopByLine(line) {
-  }
+  setScrollTopByLine(_line: number): void {}
 
   /**
    * return strings from BOL(beginning of line) to current position
    */
-  getStrFromBol() {
+  getStrFromBol(): Error {
     throw new Error('this method should be impelemented in subclass');
   }
 
   /**
    * return strings from current position to EOL(end of line)
    */
-  getStrToEol() {
+  getStrToEol(): Error {
     throw new Error('this method should be impelemented in subclass');
   }
 
   /**
    * return strings from BOL(beginning of line) to current position
    */
-  getStrFromBolToSelectedUpperPos() {
+  getStrFromBolToSelectedUpperPos(): Error {
     throw new Error('this method should be impelemented in subclass');
   }
 
   /**
    * replace Beggining Of Line to current position with param 'text'
-   * @param {string} text
+   * @param {string} _text
    */
-  replaceBolToCurrentPos(text) {
+  replaceBolToCurrentPos(_text: string): Error {
     throw new Error('this method should be impelemented in subclass');
   }
 
   /**
    * replace the current line with param 'text'
-   * @param {string} text
+   * @param {string} _text
    */
-  replaceLine(text) {
+  replaceLine(_text: string): Error {
     throw new Error('this method should be impelemented in subclass');
   }
 
   /**
    * insert text
-   * @param {string} text
+   * @param {string} _text
    */
-  insertText(text) {
+  insertText(_text: string): Error {
+    throw new Error('this method should be impelemented in subclass');
   }
 
   /**
    * insert line break to the current position
    */
-  insertLinebreak() {
+  insertLinebreak(): void {
     this.insertText('\n');
   }
 
   /**
    * dispatch onSave event
    */
-  dispatchSave() {
+  dispatchSave(): void {
     if (this.props.onSave != null) {
       this.props.onSave();
     }
@@ -114,7 +119,7 @@ export default class AbstractEditor extends React.Component {
    * dispatch onPasteFiles event
    * @param {object} event
    */
-  dispatchPasteFiles(event) {
+  dispatchPasteFiles(event: Event): void {
     if (this.props.onPasteFiles != null) {
       this.props.onPasteFiles(event);
     }
@@ -123,23 +128,8 @@ export default class AbstractEditor extends React.Component {
   /**
    * returns items(an array of react elements) in navigation bar for editor
    */
-  getNavbarItems() {
+  getNavbarItems(): null {
     return null;
   }
 
 }
-
-AbstractEditor.propTypes = {
-  value: PropTypes.string,
-  isGfmMode: PropTypes.bool,
-  onChange: PropTypes.func,
-  onScroll: PropTypes.func,
-  onScrollCursorIntoView: PropTypes.func,
-  onSave: PropTypes.func,
-  onPasteFiles: PropTypes.func,
-  onDragEnter: PropTypes.func,
-  onCtrlEnter: PropTypes.func,
-};
-AbstractEditor.defaultProps = {
-  isGfmMode: true,
-};

+ 2 - 9
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -5,7 +5,6 @@ import urljoin from 'url-join';
 import * as codemirror from 'codemirror';
 
 import { Button } from 'reactstrap';
-import { UnControlled as ReactCodeMirror } from 'react-codemirror2';
 
 import { JSHINT } from 'jshint';
 
@@ -32,6 +31,7 @@ import LinkEditModal from './LinkEditModal';
 import HandsontableModal from './HandsontableModal';
 import EditorIcon from './EditorIcon';
 import DrawioModal from './DrawioModal';
+import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 
 // Textlint
 window.JSHINT = JSHINT;
@@ -908,7 +908,6 @@ export default class CodeMirrorEditor extends AbstractEditor {
   }
 
   render() {
-    const mode = this.state.isGfmMode ? 'gfm-growi' : undefined;
     const lint = this.props.isTextlintEnabled ? this.codemirrorLintConfig : false;
     const additionalClasses = Array.from(this.state.additionalClassSet).join(' ');
     const placeholder = this.state.isGfmMode ? 'Input with Markdown..' : 'Input with Plain Text..';
@@ -924,7 +923,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
     return (
       <React.Fragment>
 
-        <ReactCodeMirror
+        <UncontrolledCodeMirror
           ref={(c) => { this.cm = c }}
           className={additionalClasses}
           placeholder="search"
@@ -935,12 +934,6 @@ export default class CodeMirrorEditor extends AbstractEditor {
           }}
           value={this.state.value}
           options={{
-            mode,
-            theme: this.props.editorOptions.theme,
-            styleActiveLine: this.props.editorOptions.styleActiveLine,
-            lineNumbers: this.props.lineNumbers,
-            tabSize: 4,
-            indentUnit: this.props.indentSize,
             lineWrapping: true,
             scrollPastEnd: true,
             autoRefresh: { force: true }, // force option is enabled by autorefresh.ext.js -- Yuki Takei

+ 282 - 0
packages/app/src/components/PageEditor/ConflictDiffModal.tsx

@@ -0,0 +1,282 @@
+import React, {
+  useState, useEffect, FC, useRef,
+} from 'react';
+import PropTypes from 'prop-types';
+import { UserPicture } from '@growi/ui';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+import { format } from 'date-fns';
+import CodeMirror from 'codemirror/lib/codemirror';
+
+import PageContainer from '../../client/services/PageContainer';
+import AppContainer from '../../client/services/AppContainer';
+import ExpandOrContractButton from '../ExpandOrContractButton';
+
+import { useEditorMode } from '~/stores/ui';
+
+import { IRevisionOnConflict } from '../../interfaces/revision';
+import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
+
+require('codemirror/lib/codemirror.css');
+require('codemirror/addon/merge/merge');
+require('codemirror/addon/merge/merge.css');
+const DMP = require('diff_match_patch');
+
+Object.keys(DMP).forEach((key) => { window[key] = DMP[key] });
+
+type ConflictDiffModalProps = {
+  isOpen: boolean | null;
+  onClose?: (() => void);
+  pageContainer: PageContainer;
+  appContainer: AppContainer;
+  markdownOnEdit: string;
+};
+
+type IRevisionOnConflictWithStringDate = Omit<IRevisionOnConflict, 'createdAt'> & {
+  createdAt: string
+}
+
+export const ConflictDiffModal: FC<ConflictDiffModalProps> = (props) => {
+  const { t } = useTranslation('');
+  const [resolvedRevision, setResolvedRevision] = useState<string>('');
+  const [isRevisionselected, setIsRevisionSelected] = useState<boolean>(false);
+  const [isModalExpanded, setIsModalExpanded] = useState<boolean>(false);
+  const [codeMirrorRef, setCodeMirrorRef] = useState<HTMLDivElement | null>(null);
+
+  const { data: editorMode } = useEditorMode();
+
+  const uncontrolledRef = useRef<CodeMirror>(null);
+
+  const { pageContainer, appContainer } = props;
+
+  const currentTime: Date = new Date();
+
+  const request: IRevisionOnConflictWithStringDate = {
+    revisionId: '',
+    revisionBody: props.markdownOnEdit,
+    createdAt: format(currentTime, 'yyyy/MM/dd HH:mm:ss'),
+    user: appContainer.currentUser,
+  };
+  const origin: IRevisionOnConflictWithStringDate = {
+    revisionId: pageContainer.state.revisionId || '',
+    revisionBody: pageContainer.state.markdown || '',
+    createdAt: pageContainer.state.updatedAt || '',
+    user: pageContainer.state.revisionAuthor,
+  };
+  const latest: IRevisionOnConflictWithStringDate = {
+    revisionId: pageContainer.state.remoteRevisionId || '',
+    revisionBody: pageContainer.state.remoteRevisionBody || '',
+    createdAt: format(new Date(pageContainer.state.remoteRevisionUpdateAt || currentTime.toString()), 'yyyy/MM/dd HH:mm:ss'),
+    user: pageContainer.state.lastUpdateUser,
+  };
+
+  useEffect(() => {
+    if (codeMirrorRef != null) {
+      CodeMirror.MergeView(codeMirrorRef, {
+        value: origin.revisionBody,
+        origLeft: request.revisionBody,
+        origRight: latest.revisionBody,
+        lineNumbers: true,
+        collapseIdentical: true,
+        showDifferences: true,
+        highlightDifferences: true,
+        connect: 'connect',
+        readOnly: true,
+        revertButtons: false,
+      });
+    }
+  }, [codeMirrorRef, origin.revisionBody, request.revisionBody, latest.revisionBody]);
+
+  const onClose = () => {
+    if (props.onClose != null) {
+      props.onClose();
+    }
+  };
+
+  const onResolveConflict = async() : Promise<void> => {
+    // disable button after clicked
+    setIsRevisionSelected(false);
+
+    const codeMirrorVal = uncontrolledRef.current?.editor.doc.getValue();
+
+    try {
+      await pageContainer.resolveConflict(codeMirrorVal, editorMode);
+      onClose();
+      pageContainer.showSuccessToastr();
+    }
+    catch (error) {
+      pageContainer.showErrorToastr(error);
+    }
+
+  };
+
+  const onExpandModal = () => {
+    setIsModalExpanded(true);
+  };
+
+  const onContractModal = () => {
+    setIsModalExpanded(false);
+  };
+
+  const resizeAndCloseButtons = (
+    <div className="d-flex flex-nowrap">
+      <ExpandOrContractButton
+        isWindowExpanded={isModalExpanded}
+        expandWindow={onExpandModal}
+        contractWindow={onContractModal}
+      />
+      <button type="button" className="close text-white" onClick={onClose} aria-label="Close">
+        <span aria-hidden="true">&times;</span>
+      </button>
+    </div>
+  );
+
+  return (
+    <Modal
+      isOpen={props.isOpen || false}
+      toggle={onClose}
+      backdrop="static"
+      className={`${isModalExpanded ? ' grw-modal-expanded' : ''}`}
+      size="xl"
+    >
+      <ModalHeader tag="h4" toggle={onClose} className="bg-primary text-light align-items-center py-3" close={resizeAndCloseButtons}>
+        <i className="icon-fw icon-exclamation" />{t('modal_resolve_conflict.resolve_conflict')}
+      </ModalHeader>
+      <ModalBody className="mx-4 my-1">
+        { props.isOpen
+        && (
+          <div className="row">
+            <div className="col-12 text-center mt-2 mb-4">
+              <h2 className="font-weight-bold">{t('modal_resolve_conflict.resolve_conflict_message')}</h2>
+            </div>
+            <div className="col-4">
+              <h3 className="font-weight-bold my-2">{t('modal_resolve_conflict.requested_revision')}</h3>
+              <div className="d-flex align-items-center my-3">
+                <div>
+                  <UserPicture user={request.user} size="lg" noLink noTooltip />
+                </div>
+                <div className="ml-3 text-muted">
+                  <p className="my-0">updated by {request.user.username}</p>
+                  <p className="my-0">{request.createdAt}</p>
+                </div>
+              </div>
+            </div>
+            <div className="col-4">
+              <h3 className="font-weight-bold my-2">{t('modal_resolve_conflict.origin_revision')}</h3>
+              <div className="d-flex align-items-center my-3">
+                <div>
+                  <UserPicture user={origin.user} size="lg" noLink noTooltip />
+                </div>
+                <div className="ml-3 text-muted">
+                  <p className="my-0">updated by {origin.user.username}</p>
+                  <p className="my-0">{origin.createdAt}</p>
+                </div>
+              </div>
+            </div>
+            <div className="col-4">
+              <h3 className="font-weight-bold my-2">{t('modal_resolve_conflict.latest_revision')}</h3>
+              <div className="d-flex align-items-center my-3">
+                <div>
+                  <UserPicture user={latest.user} size="lg" noLink noTooltip />
+                </div>
+                <div className="ml-3 text-muted">
+                  <p className="my-0">updated by {latest.user.username}</p>
+                  <p className="my-0">{latest.createdAt}</p>
+                </div>
+              </div>
+            </div>
+            <div className="col-12" ref={(el) => { setCodeMirrorRef(el) }}></div>
+            <div className="col-4">
+              <div className="text-center my-4">
+                <button
+                  type="button"
+                  className="btn btn-outline-primary"
+                  onClick={() => {
+                    setIsRevisionSelected(true);
+                    setResolvedRevision(request.revisionBody);
+                  }}
+                >
+                  <i className="icon-fw icon-arrow-down-circle"></i>
+                  {t('modal_resolve_conflict.select_revision', { revision: 'mine' })}
+                </button>
+              </div>
+            </div>
+            <div className="col-4">
+              <div className="text-center my-4">
+                <button
+                  type="button"
+                  className="btn btn-outline-primary"
+                  onClick={() => {
+                    setIsRevisionSelected(true);
+                    setResolvedRevision(origin.revisionBody);
+                  }}
+                >
+                  <i className="icon-fw icon-arrow-down-circle"></i>
+                  {t('modal_resolve_conflict.select_revision', { revision: 'origin' })}
+                </button>
+              </div>
+            </div>
+            <div className="col-4">
+              <div className="text-center my-4">
+                <button
+                  type="button"
+                  className="btn btn-outline-primary"
+                  onClick={() => {
+                    setIsRevisionSelected(true);
+                    setResolvedRevision(latest.revisionBody);
+                  }}
+                >
+                  <i className="icon-fw icon-arrow-down-circle"></i>
+                  {t('modal_resolve_conflict.select_revision', { revision: 'theirs' })}
+                </button>
+              </div>
+            </div>
+            <div className="col-12">
+              <div className="border border-dark">
+                <h3 className="font-weight-bold my-2 mx-2">{t('modal_resolve_conflict.selected_editable_revision')}</h3>
+                <UncontrolledCodeMirror
+                  ref={uncontrolledRef}
+                  value={resolvedRevision}
+                  options={{
+                    placeholder: t('modal_resolve_conflict.resolve_conflict_message'),
+                  }}
+                />
+              </div>
+            </div>
+          </div>
+        )}
+      </ModalBody>
+      <ModalFooter>
+        <button
+          type="button"
+          className="btn btn-outline-secondary"
+          onClick={onClose}
+        >
+          {t('Cancel')}
+        </button>
+        <button
+          type="button"
+          className="btn btn-primary ml-3"
+          onClick={onResolveConflict}
+          disabled={!isRevisionselected}
+        >
+          {t('modal_resolve_conflict.resolve_and_save')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+ConflictDiffModal.propTypes = {
+  isOpen: PropTypes.bool,
+  onClose: PropTypes.func,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  markdownOnEdit: PropTypes.string.isRequired,
+};
+
+ConflictDiffModal.defaultProps = {
+  isOpen: false,
+};

+ 100 - 83
packages/app/src/components/PageEditor/Editor.jsx

@@ -10,6 +10,8 @@ import {
 import Dropzone from 'react-dropzone';
 
 import EditorContainer from '~/client/services/EditorContainer';
+import PageContainer from '~/client/services/PageContainer';
+import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 import Cheatsheet from './Cheatsheet';
@@ -18,6 +20,7 @@ import CodeMirrorEditor from './CodeMirrorEditor';
 import TextAreaEditor from './TextAreaEditor';
 
 import pasteHelper from './PasteHelper';
+import { ConflictDiffModal } from './ConflictDiffModal';
 
 class Editor extends AbstractEditor {
 
@@ -276,6 +279,7 @@ class Editor extends AbstractEditor {
     );
   }
 
+
   render() {
     const flexContainer = {
       height: '100%',
@@ -286,88 +290,97 @@ class Editor extends AbstractEditor {
     const isMobile = this.props.isMobile;
 
     return (
-      <div style={flexContainer} className="editor-container">
-        <Dropzone
-          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 && (
-                  <Subscribe to={[EditorContainer]}>
-                    { editorContainer => (
-                      // eslint-disable-next-line arrow-body-style
-                      <CodeMirrorEditor
-                        ref={(c) => { this.cmEditor = c }}
-                        indentSize={editorContainer.state.indentSize}
-                        editorOptions={editorContainer.state.editorOptions}
-                        isTextlintEnabled={editorContainer.state.isTextlintEnabled}
-                        textlintRules={editorContainer.state.textlintRules}
-                        onInitializeTextlint={editorContainer.retrieveEditorSettings}
-                        onPasteFiles={this.pasteFilesHandler}
-                        onDragEnter={this.dragEnterHandler}
-                        onMarkdownHelpButtonClicked={this.showMarkdownHelp}
-                        onAddAttachmentButtonClicked={this.addAttachmentHandler}
-                        {...this.props}
-                      />
-                    )}
-                  </Subscribe>
-                )}
-
-                {/* 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-outline-secondary btn-block btn-open-dropzone"
-              onClick={this.addAttachmentHandler}
-            >
-              <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
-              Attach files
-              <span className="d-none d-sm-inline">
-              &nbsp;by dragging &amp; dropping,&nbsp;
-                <span className="btn-link">selecting them</span>,&nbsp;
-                or pasting from the clipboard.
-              </span>
-
-            </button>
-          )
-        }
-
-        { this.renderCheatsheetModal() }
-
-      </div>
+      <>
+        <div style={flexContainer} className="editor-container">
+          <Dropzone
+            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 && (
+                    <Subscribe to={[EditorContainer]}>
+                      { editorContainer => (
+                        // eslint-disable-next-line arrow-body-style
+                        <CodeMirrorEditor
+                          ref={(c) => { this.cmEditor = c }}
+                          indentSize={editorContainer.state.indentSize}
+                          editorOptions={editorContainer.state.editorOptions}
+                          isTextlintEnabled={editorContainer.state.isTextlintEnabled}
+                          textlintRules={editorContainer.state.textlintRules}
+                          onInitializeTextlint={editorContainer.retrieveEditorSettings}
+                          onPasteFiles={this.pasteFilesHandler}
+                          onDragEnter={this.dragEnterHandler}
+                          onMarkdownHelpButtonClicked={this.showMarkdownHelp}
+                          onAddAttachmentButtonClicked={this.addAttachmentHandler}
+                          {...this.props}
+                        />
+                      )}
+                    </Subscribe>
+                  )}
+
+                  {/* 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-outline-secondary btn-block btn-open-dropzone"
+                onClick={this.addAttachmentHandler}
+              >
+                <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
+                Attach files
+                <span className="d-none d-sm-inline">
+                &nbsp;by dragging &amp; dropping,&nbsp;
+                  <span className="btn-link">selecting them</span>,&nbsp;
+                  or pasting from the clipboard.
+                </span>
+
+              </button>
+            )
+          }
+
+          { this.renderCheatsheetModal() }
+
+        </div>
+        <ConflictDiffModal
+          isOpen={this.props.pageContainer.state.isConflictDiffModalOpen}
+          onClose={() => this.props.pageContainer.setState({ isConflictDiffModalOpen: false })}
+          appContainer={this.props.appContainer}
+          pageContainer={this.props.pageContainer}
+          markdownOnEdit={this.props.value}
+        />
+      </>
     );
   }
 
@@ -375,6 +388,8 @@ class Editor extends AbstractEditor {
 
 Editor.propTypes = Object.assign({
   noCdn: PropTypes.bool,
+  // this value is markdown
+  value: PropTypes.string,
   isMobile: PropTypes.bool,
   isUploadable: PropTypes.bool,
   isUploadableFile: PropTypes.bool,
@@ -382,6 +397,8 @@ Editor.propTypes = Object.assign({
   onChange: PropTypes.func,
   onUpload: PropTypes.func,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 }, AbstractEditor.propTypes);
 
-export default withUnstatedContainers(Editor, [EditorContainer]);
+export default withUnstatedContainers(Editor, [EditorContainer, PageContainer, AppContainer]);

+ 52 - 1
packages/app/src/components/PageStatusAlert.jsx

@@ -26,14 +26,22 @@ class PageStatusAlert extends React.Component {
     };
 
     this.getContentsForSomeoneEditingAlert = this.getContentsForSomeoneEditingAlert.bind(this);
+    this.getContentsForRevisionOutdated = this.getContentsForRevisionOutdated.bind(this);
     this.getContentsForDraftExistsAlert = this.getContentsForDraftExistsAlert.bind(this);
     this.getContentsForUpdatedAlert = this.getContentsForUpdatedAlert.bind(this);
+    this.onClickResolveConflict = this.onClickResolveConflict.bind(this);
   }
 
   refreshPage() {
     window.location.reload();
   }
 
+  onClickResolveConflict() {
+    this.props.pageContainer.setState({
+      isConflictDiffModalOpen: true,
+    });
+  }
+
   getContentsForSomeoneEditingAlert() {
     const { t } = this.props;
     return [
@@ -49,6 +57,45 @@ class PageStatusAlert extends React.Component {
     ];
   }
 
+  getContentsForRevisionOutdated() {
+    const { t, appContainer, pageContainer } = this.props;
+    const pageEditor = appContainer.getComponentInstance('PageEditor');
+
+    let markdownOnEdit = '';
+    let isConflictOnEdit = false;
+
+    if (pageEditor != null) {
+      markdownOnEdit = pageEditor.getMarkdown();
+      isConflictOnEdit = markdownOnEdit !== pageContainer.state.markdown;
+    }
+
+    return [
+      ['bg-warning', 'd-hackmd-none'],
+      <>
+        <i className="icon-fw icon-pencil"></i>
+        {t('modal_resolve_conflict.file_conflicting_with_newer_remote')}
+      </>,
+      <>
+        <button type="button" onClick={() => this.refreshPage()} className="btn btn-outline-white mr-4">
+          <i className="icon-fw icon-reload mr-1"></i>
+          {t('Load latest')}
+        </button>
+        {isConflictOnEdit
+          && (
+            <button
+              type="button"
+              onClick={this.onClickResolveConflict}
+              className="btn btn-outline-white"
+            >
+              <i className="fa fa-fw fa-file-text-o mr-1"></i>
+              {t('modal_resolve_conflict.resolve_conflict')}
+            </button>
+          )
+        }
+      </>,
+    ];
+  }
+
   getContentsForDraftExistsAlert(isRealtime) {
     const { t } = this.props;
     return [
@@ -92,8 +139,12 @@ class PageStatusAlert extends React.Component {
 
     let getContentsFunc = null;
 
+    // when conflicting on save
+    if (isRevisionOutdated) {
+      getContentsFunc = this.getContentsForRevisionOutdated;
+    }
     // when remote revision is newer than both
-    if (isHackmdDocumentOutdated && isRevisionOutdated) {
+    else if (isHackmdDocumentOutdated && isRevisionOutdated) {
       getContentsFunc = this.getContentsForUpdatedAlert;
     }
     // when someone editing with HackMD

+ 8 - 0
packages/app/src/components/SavePageControls.jsx

@@ -66,6 +66,14 @@ class SavePageControls extends React.Component {
     catch (error) {
       logger.error('failed to save', error);
       pageContainer.showErrorToastr(error);
+      if (error.code === 'conflict') {
+        pageContainer.setState({
+          remoteRevisionId: error.data.revisionId,
+          remoteRevisionBody: error.data.revisionBody,
+          remoteRevisionUpdateAt: error.data.createdAt,
+          lastUpdateUser: error.data.user,
+        });
+      }
     }
   }
 

+ 10 - 7
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -56,13 +56,16 @@ function LargePageItem({ page }) {
   }
 
   const tags = page.tags;
-  const tagElements = tags.map((tag) => {
-    return (
-      <a key={tag.name} href={`/_search?q=tag:${tag.name}`} className="grw-tag-label badge badge-secondary mr-2 small">
-        {tag.name}
-      </a>
-    );
-  });
+  // when tag document is deleted from database directly tags includes null
+  const tagElements = tags.includes(null)
+    ? <></>
+    : tags.map((tag) => {
+      return (
+        <a key={tag.name} href={`/_search?q=tag:${tag.name}`} className="grw-tag-label badge badge-secondary mr-2 small">
+          {tag.name}
+        </a>
+      );
+    });
 
   return (
     <li className="list-group-item py-3 px-0">

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

@@ -5,6 +5,7 @@ import { useCurrentSidebarContents } from '~/stores/ui';
 
 import RecentChanges from './RecentChanges';
 import CustomSidebar from './CustomSidebar';
+import Tag from './Tag';
 
 type Props = {
 };
@@ -18,6 +19,9 @@ const SidebarContents: FC<Props> = (props: Props) => {
     case SidebarContentsType.RECENT:
       Contents = RecentChanges;
       break;
+    case SidebarContentsType.TAG:
+      Contents = Tag;
+      break;
     default:
       Contents = CustomSidebar;
   }

+ 1 - 1
packages/app/src/components/Sidebar/SidebarNav.tsx

@@ -79,7 +79,7 @@ const SidebarNav: FC<Props> = (props: Props) => {
       <div className="grw-sidebar-nav-primary-container">
         {!isSharedUser && <PrimaryItem contents={SidebarContentsType.CUSTOM} label="Custom Sidebar" iconName="code" onItemSelected={onItemSelected} />}
         {!isSharedUser && <PrimaryItem contents={SidebarContentsType.RECENT} label="Recent Changes" iconName="update" onItemSelected={onItemSelected} />}
-        {/* <PrimaryItem id="tag" label="Tags" iconName="icon-tag" /> */}
+        {!isSharedUser && <PrimaryItem contents={SidebarContentsType.TAG} label="Tags" iconName="tag" onItemSelected={onItemSelected} /> }
         {/* <PrimaryItem id="favorite" label="Favorite" iconName="icon-star" /> */}
       </div>
       <div className="grw-sidebar-nav-secondary-container">

+ 44 - 0
packages/app/src/components/Sidebar/Tag.tsx

@@ -0,0 +1,44 @@
+import React, { FC, useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import TagsList from '../TagsList';
+
+const Tag: FC = () => {
+  const { t } = useTranslation('');
+  const [isOnReload, setIsOnReload] = useState<boolean>(false);
+
+  useEffect(() => {
+    setIsOnReload(false);
+  }, [isOnReload]);
+
+  return (
+    <>
+      <div className="grw-sidebar-content-header p-3 d-flex">
+        <h3 className="mb-0">{t('Tags')}</h3>
+        <button
+          type="button"
+          className="btn btn-sm ml-auto grw-btn-reload-rc"
+          onClick={() => {
+            setIsOnReload(true);
+          }}
+        >
+          <i className="icon icon-reload"></i>
+        </button>
+      </div>
+      <div className="d-flex justify-content-center">
+        <button
+          className="btn btn-primary my-4"
+          type="button"
+          onClick={() => { window.location.href = '/tags' }}
+        >
+          {t('Check All tags')}
+        </button>
+      </div>
+      <div className="grw-container-convertible mb-5 pb-5">
+        <TagsList isOnReload={isOnReload} />
+      </div>
+    </>
+  );
+
+};
+
+export default Tag;

+ 38 - 0
packages/app/src/components/TagCloudBox.tsx

@@ -0,0 +1,38 @@
+import React, { FC } from 'react';
+
+import { TagCloud } from 'react-tagcloud';
+
+type Tag = {
+  _id: string,
+  name: string,
+  count: number,
+}
+
+type Props = {
+  tags:Tag[],
+  minSize?: number,
+  maxSize?: number,
+}
+
+const MIN_FONT_SIZE = 12;
+const MAX_FONT_SIZE = 36;
+
+const TagCloudBox: FC<Props> = (props:Props) => {
+  return (
+    <>
+      <TagCloud
+        minSize={props.minSize || MIN_FONT_SIZE}
+        maxSize={props.maxSize || MAX_FONT_SIZE}
+        tags={props.tags.map((tag) => {
+          return { value: tag.name, count: tag.count };
+        })}
+        style={{ cursor: 'pointer' }}
+        className="simple-cloud"
+        onClick={(target) => { window.location.href = `/_search?q=tag:${encodeURIComponent(target.value)}` }}
+      />
+    </>
+  );
+
+};
+
+export default TagCloudBox;

+ 44 - 18
packages/app/src/components/TagsList.jsx

@@ -4,6 +4,9 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
 import PaginationWrapper from './PaginationWrapper';
+import TagCloudBox from './TagCloudBox';
+import { apiGet } from '../client/util/apiv1-client';
+import { toastError } from '../client/util/apiNotification';
 
 class TagsList extends React.Component {
 
@@ -25,6 +28,12 @@ class TagsList extends React.Component {
     await this.getTagList(1);
   }
 
+  async componentDidUpdate() {
+    if (this.props.isOnReload) {
+      await this.getTagList(this.state.activePage);
+    }
+  }
+
   async handlePage(selectedPage) {
     await this.getTagList(selectedPage);
   }
@@ -32,7 +41,14 @@ class TagsList extends React.Component {
   async getTagList(selectPageNumber) {
     const limit = this.state.pagingLimit;
     const offset = (selectPageNumber - 1) * limit;
-    const res = await this.props.crowi.apiGet('/tags.list', { limit, offset });
+    let res;
+
+    try {
+      res = await apiGet('/tags.list', { limit, offset });
+    }
+    catch (error) {
+      toastError(error);
+    }
 
     const totalTags = res.totalCount;
     const tagData = res.data;
@@ -67,34 +83,44 @@ class TagsList extends React.Component {
     const messageForNoTag = this.state.tagData.length ? null : <h3>{ t('You have no tag, You can set tags on pages') }</h3>;
 
     return (
-      <div className="text-center">
-        <div className="tag-list">
-          <ul className="list-group text-left">
-            {this.generateTagList(this.state.tagData)}
-          </ul>
-          {messageForNoTag}
-        </div>
-        <div className="tag-list-pagination">
-          <PaginationWrapper
-            activePage={this.state.activePage}
-            changePage={this.handlePage}
-            totalItemsCount={this.state.totalTags}
-            pagingLimit={this.state.pagingLimit}
-            size="sm"
-          />
+      <>
+        <header className="py-0">
+          <h1 className="title text-center mt-5 mb-3 font-weight-bold">{`${t('Tags')}(${this.state.totalTags})`}</h1>
+        </header>
+        <div className="row text-center">
+          <div className="col-12 mb-5 px-5">
+            <TagCloudBox tags={this.state.tagData} minSize={20} />
+          </div>
+          <div className="col-12 tag-list mb-4">
+            <ul className="list-group text-left">
+              {this.generateTagList(this.state.tagData)}
+            </ul>
+            {messageForNoTag}
+          </div>
+          <div className="col-12 tag-list-pagination">
+            <PaginationWrapper
+              activePage={this.state.activePage}
+              changePage={this.handlePage}
+              totalItemsCount={this.state.totalTags}
+              pagingLimit={this.state.pagingLimit}
+              align="center"
+              size="md"
+            />
+          </div>
         </div>
-      </div>
+      </>
     );
   }
 
 }
 
 TagsList.propTypes = {
-  crowi: PropTypes.object.isRequired,
+  isOnReload: PropTypes.bool,
   t: PropTypes.func.isRequired, // i18next
 };
 
 TagsList.defaultProps = {
+  isOnReload: false,
 };
 
 export default withTranslation()(TagsList);

+ 58 - 0
packages/app/src/components/UncontrolledCodeMirror.tsx

@@ -0,0 +1,58 @@
+import React, { forwardRef, ReactNode, Ref } from 'react';
+import { ICodeMirror, UnControlled as CodeMirror } from 'react-codemirror2';
+import { Container, Subscribe } from 'unstated';
+import EditorContainer from '~/client/services/EditorContainer';
+import AbstractEditor, { AbstractEditorProps } from '~/components/PageEditor/AbstractEditor';
+
+window.CodeMirror = require('codemirror');
+require('codemirror/addon/display/placeholder');
+require('~/client/util/codemirror/gfm-growi.mode');
+
+export interface UncontrolledCodeMirrorProps extends AbstractEditorProps {
+  value: string;
+  options?: ICodeMirror['options'];
+  isGfmMode?: boolean;
+  indentSize?: number;
+  lineNumbers?: boolean;
+}
+
+interface UncontrolledCodeMirrorCoreProps extends UncontrolledCodeMirrorProps {
+  editorContainer: Container<EditorContainer>;
+  forwardedRef: Ref<UncontrolledCodeMirrorCore>;
+}
+
+class UncontrolledCodeMirrorCore extends AbstractEditor<UncontrolledCodeMirrorCoreProps> {
+
+  render(): ReactNode {
+
+    const {
+      value, isGfmMode, indentSize, lineNumbers, editorContainer, options, forwardedRef, ...rest
+    } = this.props;
+
+    const { editorOptions } = editorContainer.state;
+
+    return (
+      <CodeMirror
+        ref={forwardedRef}
+        value={value}
+        options={{
+          lineNumbers: lineNumbers ?? true,
+          mode: isGfmMode ? 'gfm-growi' : undefined,
+          theme: editorOptions.theme,
+          styleActiveLine: editorOptions.styleActiveLine,
+          tabSize: 4,
+          indentUnit: indentSize,
+          ...options,
+        }}
+        {...rest}
+      />
+    );
+  }
+
+}
+
+export const UncontrolledCodeMirror = forwardRef<UncontrolledCodeMirrorCore, UncontrolledCodeMirrorProps>((props, ref) => (
+  <Subscribe to={[EditorContainer]}>
+    {(EditorContainer: Container<EditorContainer>) => <UncontrolledCodeMirrorCore {...props} forwardedRef={ref} editorContainer={EditorContainer} />}
+  </Subscribe>
+));

+ 7 - 0
packages/app/src/interfaces/revision.ts

@@ -7,3 +7,10 @@ export type IRevision = {
   createdAt: Date,
   updatedAt: Date,
 }
+
+export type IRevisionOnConflict = {
+  revisionId: string,
+  revisionBody: string,
+  createdAt: Date,
+  user: IUser
+}

+ 1 - 0
packages/app/src/interfaces/ui.ts

@@ -1,6 +1,7 @@
 export const SidebarContentsType = {
   CUSTOM: 'custom',
   RECENT: 'recent',
+  TAG: 'tag',
 } as const;
 export const AllSidebarContentsType = Object.values(SidebarContentsType);
 export type SidebarContentsType = typeof SidebarContentsType[keyof typeof SidebarContentsType];

+ 69 - 0
packages/app/src/migrations/20210921173042-add-is-trashed-field.js

@@ -0,0 +1,69 @@
+import mongoose from 'mongoose';
+
+import { getMongoUri, mongoOptions } from '@growi/core';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:migrate:add-column-is-trashed');
+const Page = require('~/server/models/page')();
+
+const LIMIT = 1000;
+
+/**
+ * set isPageTrashed of pagetagrelations included in updateIdList as true
+ */
+const updateIsPageTrashed = async(db, updateIdList) => {
+  await db.collection('pagetagrelations').updateMany(
+    { relatedPage: { $in: updateIdList } },
+    { $set: { isPageTrashed: true } },
+  );
+};
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    let updateDeletedPageIds = [];
+
+    // set isPageTrashed as false temporarily
+    await db.collection('pagetagrelations').updateMany(
+      {},
+      { $set: { isPageTrashed: false } },
+    );
+
+    for await (const deletedPage of Page.find({ status: Page.STATUS_DELETED }).select('_id').cursor()) {
+      updateDeletedPageIds.push(deletedPage._id);
+      // excute updateMany by one thousand ids
+      if (updateDeletedPageIds.length === LIMIT) {
+        await updateIsPageTrashed(db, updateDeletedPageIds);
+        updateDeletedPageIds = [];
+      }
+    }
+
+    // use ids that have not been updated
+    if (updateDeletedPageIds.length > 0) {
+      await updateIsPageTrashed(db, updateDeletedPageIds);
+    }
+
+    logger.info('Migration has successfully applied');
+
+  },
+
+  async down(db) {
+    logger.info('Rollback migration');
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    try {
+      await db.collection('pagetagrelations').updateMany(
+        {},
+        { $unset: { isPageTrashed: '' } },
+      );
+      logger.info('Migration has been successfully rollbacked');
+    }
+    catch (err) {
+      logger.error(err);
+      logger.info('Migration has failed');
+    }
+
+  },
+};

+ 3 - 0
packages/app/src/server/crowi/express-init.js

@@ -4,6 +4,7 @@ module.exports = function(crowi, app) {
   const debug = require('debug')('growi:crowi:express-init');
   const path = require('path');
   const express = require('express');
+  const compression = require('compression');
   const helmet = require('helmet');
   const bodyParser = require('body-parser');
   const cookieParser = require('cookie-parser');
@@ -53,6 +54,8 @@ module.exports = function(crowi, app) {
       nsSeparator: '::',
     });
 
+  app.use(compression());
+
   app.use(helmet({
     contentSecurityPolicy: false,
     expectCt: false,

+ 45 - 25
packages/app/src/server/models/page-tag-relation.js

@@ -24,6 +24,13 @@ const schema = new mongoose.Schema({
     type: ObjectId,
     ref: 'Tag',
     required: true,
+    index: true,
+  },
+  isPageTrashed: {
+    type: Boolean,
+    default: false,
+    required: true,
+    index: true,
   },
 });
 // define unique compound index
@@ -39,27 +46,34 @@ schema.plugin(uniqueValidator);
 class PageTagRelation {
 
   static async createTagListWithCount(option) {
-    const Tag = mongoose.model('Tag');
     const opt = option || {};
     const sortOpt = opt.sortOpt || {};
-    const offset = opt.offset || 0;
-    const limit = opt.limit || 50;
+    const offset = opt.offset;
+    const limit = opt.limit;
 
-    const existTagIds = await Tag.find().distinct('_id');
     const tags = await this.aggregate()
-      .match({ relatedTag: { $in: existTagIds } })
-      .group({ _id: '$relatedTag', count: { $sum: 1 } })
-      .sort(sortOpt);
-
-    const list = tags.slice(offset, offset + limit);
-    const totalCount = tags.length;
-
-    return { list, totalCount };
+      .match({ isPageTrashed: false })
+      .lookup({
+        from: 'tags',
+        localField: 'relatedTag',
+        foreignField: '_id',
+        as: 'tag',
+      })
+      .unwind('$tag')
+      .group({ _id: '$relatedTag', count: { $sum: 1 }, name: { $first: '$tag.name' } })
+      .sort(sortOpt)
+      .skip(offset)
+      .limit(limit);
+
+    const totalCount = (await this.find({ isPageTrashed: false }).distinct('relatedTag')).length;
+
+    return { data: tags, totalCount };
   }
 
-  static async findByPageId(pageId) {
-    const relations = await this.find({ relatedPage: pageId }).populate('relatedTag').select('-_id relatedTag');
-    return relations.filter((relation) => { return relation.relatedTag !== null });
+  static async findByPageId(pageId, options = {}) {
+    const isAcceptRelatedTagNull = options.nullable || null;
+    const relations = await this.find({ relatedPage: pageId }).populate('relatedTag').select('relatedTag');
+    return isAcceptRelatedTagNull ? relations : relations.filter((relation) => { return relation.relatedTag !== null });
   }
 
   static async listTagNamesByPage(pageId) {
@@ -125,17 +139,23 @@ class PageTagRelation {
     const Tag = mongoose.model('Tag');
 
     // get relations for this page
-    const relations = await this.findByPageId(pageId);
-
-    // unlink relations
-    const unlinkTagRelations = relations.filter((relation) => { return !tags.includes(relation.relatedTag.name) });
-    const bulkDeletePromise = this.deleteMany({
-      relatedPage: pageId,
-      relatedTag: { $in: unlinkTagRelations.map((relation) => { return relation.relatedTag._id }) },
+    const relations = await this.findByPageId(pageId, { nullable: true });
+
+    const unlinkTagRelationIds = [];
+    const relatedTagNames = [];
+
+    relations.forEach((relation) => {
+      if (relation.relatedTag == null) {
+        unlinkTagRelationIds.push(relation._id);
+      }
+      else {
+        relatedTagNames.push(relation.relatedTag.name);
+        if (!tags.includes(relation.relatedTag.name)) {
+          unlinkTagRelationIds.push(relation._id);
+        }
+      }
     });
-
-    // filter tags to create
-    const relatedTagNames = relations.map((relation) => { return relation.relatedTag.name });
+    const bulkDeletePromise = this.deleteMany({ _id: { $in: unlinkTagRelationIds } });
     // find or create tags
     const tagsToCreate = tags.filter((tag) => { return !relatedTagNames.includes(tag) });
     const tagEntities = await Tag.findOrCreateMany(tagsToCreate);

+ 5 - 1
packages/app/src/server/models/vo/s2c-message.js

@@ -10,15 +10,19 @@ class S2cMessagePageUpdated {
     const serializedPage = serializePageSecurely(page);
 
     const {
-      _id, revision, revisionHackmdSynced, hasDraftOnHackmd,
+      _id, revision, updatedAt, revisionHackmdSynced, hasDraftOnHackmd,
     } = serializedPage;
 
     this.pageId = _id;
     this.revisionId = revision;
+    this.revisionBody = page.revision.body;
+    this.revisionUpdateAt = updatedAt;
     this.revisionIdHackmdSynced = revisionHackmdSynced;
     this.hasDraftOnHackmd = hasDraftOnHackmd;
 
     if (user != null) {
+      this.remoteLastUpdateUser = user;
+      // TODO remove lastUpdateUsername and refactor parts that lastUpdateUsername is used
       this.lastUpdateUsername = user.name;
     }
   }

+ 2 - 0
packages/app/src/server/routes/apiv3/pages.js

@@ -197,7 +197,9 @@ module.exports = (crowi) => {
 
   async function saveTagsAction({ createdPage, pageTags }) {
     if (pageTags != null) {
+      const tagEvent = crowi.event('tag');
       await PageTagRelation.updatePageTags(createdPage.id, pageTags);
+      tagEvent.emit('update', createdPage, pageTags);
       return PageTagRelation.listTagNamesByPage(createdPage.id);
     }
 

+ 11 - 2
packages/app/src/server/routes/page.js

@@ -828,9 +828,17 @@ module.exports = function(crowi, app) {
     }
 
     // check revision
+    const Revision = crowi.model('Revision');
     let page = await Page.findByIdAndViewer(pageId, req.user);
     if (page != null && revisionId != null && !page.isUpdatable(revisionId)) {
-      return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'outdated'));
+      const latestRevision = await Revision.findById(page.revision).populate('author');
+      const returnLatestRevision = {
+        revisionId: latestRevision._id.toString(),
+        revisionBody: xss.process(latestRevision.body),
+        createdAt: latestRevision.createdAt,
+        user: serializeUserSecurely(latestRevision.author),
+      };
+      return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'conflict', returnLatestRevision));
     }
 
     const options = { isSyncRevisionToHackmd };
@@ -839,7 +847,6 @@ module.exports = function(crowi, app) {
       options.grantUserGroupId = grantUserGroupId;
     }
 
-    const Revision = crowi.model('Revision');
     const previousRevision = await Revision.findById(revisionId);
     try {
       page = await Page.updatePage(page, pageBody, previousRevision.body, req.user, options);
@@ -851,8 +858,10 @@ module.exports = function(crowi, app) {
 
     let savedTags;
     if (pageTags != null) {
+      const tagEvent = crowi.event('tag');
       await PageTagRelation.updatePageTags(pageId, pageTags);
       savedTags = await PageTagRelation.listTagNamesByPage(pageId);
+      tagEvent.emit('update', page, savedTags);
     }
 
     const result = {

+ 16 - 22
packages/app/src/server/routes/tag.js

@@ -136,15 +136,27 @@ module.exports = function(crowi, app) {
    */
   api.update = async function(req, res) {
     const Page = crowi.model('Page');
+    const User = crowi.model('User');
     const PageTagRelation = crowi.model('PageTagRelation');
+    const Revision = crowi.model('Revision');
     const tagEvent = crowi.event('tag');
     const pageId = req.body.pageId;
     const tags = req.body.tags;
+    const userId = req.user._id;
+    const revisionId = req.body.revisionId;
 
     const result = {};
     try {
       // TODO GC-1921 consider permission
       const page = await Page.findById(pageId);
+      const user = await User.findById(userId);
+
+      if (!await Page.isAccessiblePageByViewer(page._id, user)) {
+        return res.json(ApiResponse.error("You don't have permission to update this page."));
+      }
+
+      const previousRevision = await Revision.findById(revisionId);
+      result.savedPage = await Page.updatePage(page, previousRevision.body, previousRevision.body, req.user);
       await PageTagRelation.updatePageTags(pageId, tags);
       result.tags = await PageTagRelation.listTagNamesByPage(pageId);
 
@@ -203,32 +215,14 @@ module.exports = function(crowi, app) {
   api.list = async function(req, res) {
     const limit = +req.query.limit || 50;
     const offset = +req.query.offset || 0;
-    const sortOpt = { count: -1 };
+    const sortOpt = { count: -1, _id: -1 };
     const queryOptions = { offset, limit, sortOpt };
-    const result = {};
 
     try {
-      // get tag list contains id and count properties
-      const listData = await PageTagRelation.createTagListWithCount(queryOptions);
-      const ids = listData.list.map((obj) => { return obj._id });
-
-      // get tag documents for add name data to the list
-      const tags = await Tag.find({ _id: { $in: ids } });
-
-      // add name property
-      result.data = listData.list.map((elm) => {
-        const data = {};
-        const tag = tags.find((tag) => { return (tag.id === elm._id.toString()) });
-
-        data._id = elm._id;
-        data.name = tag.name;
-        data.count = elm.count; // the number of related pages
-        return data;
-      });
-
-      result.totalCount = listData.totalCount;
+      // get tag list contains id name and count properties
+      const tagsWithCount = await PageTagRelation.createTagListWithCount(queryOptions);
 
-      return res.json(ApiResponse.success(result));
+      return res.json(ApiResponse.success(tagsWithCount));
     }
     catch (err) {
       return res.json(ApiResponse.error(err));

+ 6 - 0
packages/app/src/server/service/config-loader.ts

@@ -403,6 +403,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.NUMBER,
     default: 3,
   },
+  OIDC_CLIENT_CLOCK_TOLERANCE: {
+    ns: 'crowi',
+    key: 'security:passport-oidc:oidcClientClockTolerance',
+    type: ValueType.NUMBER,
+    default: 10,
+  },
   S3_REFERENCE_FILE_WITH_RELAY_MODE: {
     ns:      'crowi',
     key:     'aws:referenceFileWithRelayMode',

+ 6 - 0
packages/app/src/server/service/page.js

@@ -23,6 +23,7 @@ class PageService {
   constructor(crowi) {
     this.crowi = crowi;
     this.pageEvent = crowi.event('page');
+    this.tagEvent = crowi.event('tag');
 
     // init
     this.initPageEvent();
@@ -379,6 +380,7 @@ class PageService {
     if (originTags != null) {
       await PageTagRelation.updatePageTags(createdPage.id, originTags);
       savedTags = await PageTagRelation.listTagNamesByPage(createdPage.id);
+      this.tagEvent.emit('update', createdPage, savedTags);
     }
 
     const result = serializePageSecurely(createdPage);
@@ -513,6 +515,7 @@ class PageService {
 
   async deletePage(page, user, options = {}, isRecursively = false) {
     const Page = this.crowi.model('Page');
+    const PageTagRelation = this.crowi.model('PageTagRelation');
     const Revision = this.crowi.model('Revision');
 
     const newPath = Page.getDeletedPageName(page.path);
@@ -537,6 +540,7 @@ class PageService {
         path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(),
       },
     }, { new: true });
+    await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: true } });
     const body = `redirect ${newPath}`;
     await Page.create(page.path, body, user, { redirectTo: newPath });
 
@@ -755,6 +759,7 @@ class PageService {
 
   async revertDeletedPage(page, user, options = {}, isRecursively = false) {
     const Page = this.crowi.model('Page');
+    const PageTagRelation = this.crowi.model('PageTagRelation');
     const Revision = this.crowi.model('Revision');
 
     const newPath = Page.getRevertDeletedPageName(page.path);
@@ -783,6 +788,7 @@ class PageService {
         path: newPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null,
       },
     }, { new: true });
+    await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: false } });
     await Revision.updateMany({ path: page.path }, { $set: { path: newPath } });
 
     return updatedPage;

+ 2 - 1
packages/app/src/server/service/passport.ts

@@ -677,7 +677,8 @@ class PassportService implements S2sMessageHandlable {
       });
       // prevent error AssertionError [ERR_ASSERTION]: id_token issued in the future
       // Doc: https://github.com/panva/node-openid-client/tree/v2.x#allow-for-system-clock-skew
-      client.CLOCK_TOLERANCE = 5;
+      const OIDC_CLIENT_CLOCK_TOLERANCE = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:oidcClientClockTolerance');
+      client.CLOCK_TOLERANCE = OIDC_CLIENT_CLOCK_TOLERANCE;
       passport.use('oidc', new OidcStrategy(
         {
           client,

+ 2 - 1
packages/app/src/server/util/apiResponse.js

@@ -1,11 +1,12 @@
 function ApiResponse() {
 }
 
-ApiResponse.error = function(err, code) {
+ApiResponse.error = function(err, code, data) {
   const result = {};
 
   result.ok = false;
   result.code = code;
+  result.data = data;
 
   if (err instanceof Error) {
     result.error = err.toString();

+ 2 - 5
packages/app/src/server/views/tags.html

@@ -5,11 +5,8 @@
 {% block html_base_css %}tags-page{% endblock %}
 
 {% block layout_main %}
-<header class="py-0">
-  <h1 class="title">{{ t('Tags') }}</h1>
-</header>
-
-<div class="container-fluid">
+<div id="grw-fav-sticky-trigger" class="sticky-top"></div>
+<div class="grw-container-convertible">
   <div class="row">
     <div id="main" class="main mt-3 col-md-12 tags-page">
       <div class="" id="tags-page"></div>

+ 1 - 1
packages/codemirror-textlint/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/codemirror-textlint",
-  "version": "4.5.5-RC.0",
+  "version": "4.5.6-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "scripts": {

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "4.5.5-RC.0",
+  "version": "4.5.6-RC.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-attachment-refs",
-  "version": "4.5.5-RC.0",
+  "version": "4.5.6-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-lsx/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-lsx",
-  "version": "4.5.5-RC.0",
+  "version": "4.5.6-RC.0",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-pukiwiki-like-linker/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-pukiwiki-like-linker",
-  "version": "4.5.5-RC.0",
+  "version": "4.5.6-RC.0",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "4.5.5-RC.0",
+  "version": "4.5.6-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",

+ 2 - 2
packages/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "4.5.5-slackbot-proxy.0",
+  "version": "4.5.6-slackbot-proxy.0",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -25,7 +25,7 @@
   },
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^4.5.5-RC.0",
+    "@growi/slack": "^4.5.6-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/ui",
-  "version": "4.5.5-RC.0",
+  "version": "4.5.6-RC.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": [

+ 158 - 150
yarn.lock

@@ -829,6 +829,11 @@
     debug "^3.1.0"
     lodash.once "^4.1.1"
 
+"@discoveryjs/json-ext@^0.5.0":
+  version "0.5.6"
+  resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.6.tgz#d5e0706cf8c6acd8c6032f8d54070af261bbbb2f"
+  integrity sha512-ws57AidsDvREKrZKYffXddNkyaF14iHNHm8VQnZH6t99E8gczjNN0GpvcGny0imC80yQ0tHz1xVUKk/KFQSUyA==
+
 "@emotion/is-prop-valid@^0.8.3":
   version "0.8.8"
   resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a"
@@ -3430,6 +3435,23 @@
     "@webassemblyjs/wast-parser" "1.9.0"
     "@xtuc/long" "4.2.2"
 
+"@webpack-cli/configtest@^1.1.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@webpack-cli/configtest/-/configtest-1.1.0.tgz#8342bef0badfb7dfd3b576f2574ab80c725be043"
+  integrity sha512-ttOkEkoalEHa7RaFYpM0ErK1xc4twg3Am9hfHhL7MVqlHebnkYd2wuI/ZqTDj0cVzZho6PdinY0phFZV3O0Mzg==
+
+"@webpack-cli/info@^1.4.0":
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/@webpack-cli/info/-/info-1.4.0.tgz#b9179c3227ab09cbbb149aa733475fcf99430223"
+  integrity sha512-F6b+Man0rwE4n0409FyAJHStYA5OIZERxmnUfLVwv0mc0V1wLad3V7jqRlMkgKBeAq07jUvglacNaa6g9lOpuw==
+  dependencies:
+    envinfo "^7.7.3"
+
+"@webpack-cli/serve@^1.6.0":
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-1.6.0.tgz#2c275aa05c895eccebbfc34cfb223c6e8bd591a2"
+  integrity sha512-ZkVeqEmRpBV2GHvjjUZqEai2PpUbuq8Bqd//vEYsp63J8WyexI8ppCqVS3Zs0QADf6aWuPdU+0XsPI647PVlQA==
+
 "@xmldom/xmldom@^0.7.0", "@xmldom/xmldom@^0.7.5":
   version "0.7.5"
   resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.7.5.tgz#09fa51e356d07d0be200642b0e4f91d8e6dd408d"
@@ -5270,14 +5292,6 @@ chainsaw@~0.1.0:
   dependencies:
     traverse ">=0.3.0 <0.4"
 
-chalk@2.4.2, chalk@^2.0, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.4.1, chalk@^2.4.2:
-  version "2.4.2"
-  resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
-  dependencies:
-    ansi-styles "^3.2.1"
-    escape-string-regexp "^1.0.5"
-    supports-color "^5.3.0"
-
 chalk@4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.0.0.tgz#6e98081ed2d17faab615eb52ac66ec1fe6209e72"
@@ -5304,6 +5318,14 @@ chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3:
     strip-ansi "^3.0.0"
     supports-color "^2.0.0"
 
+chalk@^2.0, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.4.1, chalk@^2.4.2:
+  version "2.4.2"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
+  dependencies:
+    ansi-styles "^3.2.1"
+    escape-string-regexp "^1.0.5"
+    supports-color "^5.3.0"
+
 chalk@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4"
@@ -5619,15 +5641,6 @@ cliui@^4.0.0:
     strip-ansi "^4.0.0"
     wrap-ansi "^2.0.0"
 
-cliui@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5"
-  integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==
-  dependencies:
-    string-width "^3.1.0"
-    strip-ansi "^5.2.0"
-    wrap-ansi "^5.1.0"
-
 cliui@^6.0.0:
   version "6.0.0"
   resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1"
@@ -5798,7 +5811,7 @@ colorette@^1.2.2:
   resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94"
   integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==
 
-colorette@^2.0.16:
+colorette@^2.0.14, colorette@^2.0.16:
   version "2.0.16"
   resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.16.tgz#713b9af84fdb000139f04546bd4a93f62a5085da"
   integrity sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g==
@@ -5861,6 +5874,11 @@ commander@^6.2.1:
   resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c"
   integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==
 
+commander@^7.0.0:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
+  integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
+
 commander@^8.1.0:
   version "8.3.0"
   resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66"
@@ -6487,7 +6505,16 @@ cross-env@^7.0.0:
   dependencies:
     cross-spawn "^7.0.1"
 
-cross-spawn@6.0.5, cross-spawn@^6.0.0, cross-spawn@^6.0.5:
+cross-spawn@^5.0.1:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
+  integrity sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=
+  dependencies:
+    lru-cache "^4.0.1"
+    shebang-command "^1.2.0"
+    which "^1.2.9"
+
+cross-spawn@^6.0.0, cross-spawn@^6.0.5:
   version "6.0.5"
   resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
   integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==
@@ -6498,15 +6525,6 @@ cross-spawn@6.0.5, cross-spawn@^6.0.0, cross-spawn@^6.0.5:
     shebang-command "^1.2.0"
     which "^1.2.9"
 
-cross-spawn@^5.0.1:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
-  integrity sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=
-  dependencies:
-    lru-cache "^4.0.1"
-    shebang-command "^1.2.0"
-    which "^1.2.9"
-
 cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
   version "7.0.3"
   resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
@@ -7169,6 +7187,11 @@ diff@^5.0.0:
   resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b"
   integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==
 
+diff_match_patch@^0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/diff_match_patch/-/diff_match_patch-0.1.1.tgz#d3f14d5b76fb4b5a9cf44706261dadb5bd97edbc"
+  integrity sha1-0/FNW3b7S1qc9EcGJh2ttb2X7bw=
+
 diffie-hellman@^5.0.0:
   version "5.0.2"
   resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.2.tgz#b5835739270cfe26acf632099fded2a07f209e5e"
@@ -7620,14 +7643,6 @@ engine.io@~5.2.0:
     engine.io-parser "~4.0.0"
     ws "~7.4.2"
 
-enhanced-resolve@4.1.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.1.0.tgz#41c7e0bfdfe74ac1ffe1e57ad6a5c6c9f3742a7f"
-  dependencies:
-    graceful-fs "^4.1.2"
-    memory-fs "^0.4.0"
-    tapable "^1.0.0"
-
 enhanced-resolve@^4.0.0, enhanced-resolve@^4.5.0:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz#2f3cfd84dbe3b487f18f2db2ef1e064a571ca5ec"
@@ -7681,7 +7696,7 @@ env-paths@^2.2.0:
   resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2"
   integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==
 
-envinfo@^7.7.4:
+envinfo@^7.7.3, envinfo@^7.7.4:
   version "7.8.1"
   resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.8.1.tgz#06377e3e5f4d379fea7ac592d5ad8927e0c4d475"
   integrity sha512-/o+BXHmB7ocbHEAs6F2EnG0ogybVVUdkRunTT2glZU9XAaGmhqskrvKwqXuDfNjEO0LZKWdejEEpnq8aM0tOaw==
@@ -8971,16 +8986,6 @@ find-up@^4.0.0, find-up@^4.1.0:
     locate-path "^5.0.0"
     path-exists "^4.0.0"
 
-findup-sync@3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-3.0.0.tgz#17b108f9ee512dfb7a5c7f3c8b27ea9e1a9c08d1"
-  integrity sha512-YbffarhcicEhOrm4CtrwdKBdCuz576RLdhJDsIfvNtxUuhdRet1qZcsMjqbePtAseKdAnDyM/IyXbu7PRPRLYg==
-  dependencies:
-    detect-file "^1.0.0"
-    is-glob "^4.0.0"
-    micromatch "^3.0.4"
-    resolve-dir "^1.0.1"
-
 findup-sync@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-4.0.0.tgz#956c9cdde804052b881b428512905c4a5f2cdef0"
@@ -9624,13 +9629,6 @@ global-dirs@^3.0.0:
   dependencies:
     ini "2.0.0"
 
-global-modules@2.0.0, global-modules@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780"
-  integrity sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==
-  dependencies:
-    global-prefix "^3.0.0"
-
 global-modules@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea"
@@ -9639,6 +9637,13 @@ global-modules@^1.0.0:
     is-windows "^1.0.1"
     resolve-dir "^1.0.0"
 
+global-modules@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780"
+  integrity sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==
+  dependencies:
+    global-prefix "^3.0.0"
+
 global-prefix@^1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-1.0.2.tgz#dbf743c6c14992593c655568cb66ed32c0122ebe"
@@ -10502,13 +10507,6 @@ import-lazy@^4.0.0:
   resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-4.0.0.tgz#e8eb627483a0a43da3c03f3e35548be5cb0cc153"
   integrity sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==
 
-import-local@2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/import-local/-/import-local-2.0.0.tgz#55070be38a5993cf18ef6db7e961f5bee5c5a09d"
-  dependencies:
-    pkg-dir "^3.0.0"
-    resolve-cwd "^2.0.0"
-
 import-local@^0.1.1:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/import-local/-/import-local-0.1.1.tgz#b1179572aacdc11c6a91009fb430dbcab5f668a8"
@@ -10675,16 +10673,16 @@ internal-slot@^1.0.3:
     has "^1.0.3"
     side-channel "^1.0.4"
 
-interpret@1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296"
-  integrity sha512-mT34yGKMNceBQUoVn7iCDKDntA7SC6gycMAWzGx1z/CMCTV7b2AAtXlo3nRyHZ1FelRkQbQjprHSYGwzLtkVbw==
-
 interpret@^1.0.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e"
   integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==
 
+interpret@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9"
+  integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==
+
 into-stream@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/into-stream/-/into-stream-3.1.0.tgz#96fb0a936c12babd6ff1752a17d05616abd094c6"
@@ -10872,6 +10870,13 @@ is-core-module@^2.4.0:
   dependencies:
     has "^1.0.3"
 
+is-core-module@^2.8.0:
+  version "2.8.0"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.8.0.tgz#0321336c3d0925e497fd97f5d95cb114a5ccd548"
+  integrity sha512-vd15qHsaqrRL7dtH6QNuy0ndJmRDrS9HAM1CAiSifNUFv4x1a0CCVsj18hJ1mShxIG6T2i1sO78MkP56r0nYRw==
+  dependencies:
+    has "^1.0.3"
+
 is-data-descriptor@^0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56"
@@ -12527,15 +12532,6 @@ loader-runner@^2.4.0:
   resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.4.0.tgz#ed47066bfe534d7e84c4c7b9998c2a75607d9357"
   integrity sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==
 
-loader-utils@1.2.3, loader-utils@^1.2.3:
-  version "1.2.3"
-  resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.2.3.tgz#1ff5dc6911c9f0a062531a4c04b609406108c2c7"
-  integrity sha512-fkpz8ejdnEMG3s37wGL07iSBDg99O9D5yflE9RGNH3hRdx9SOwYfnGYdZOUIZitN8E+E2vkq3MUMYMvPYl5ZZA==
-  dependencies:
-    big.js "^5.2.2"
-    emojis-list "^2.0.0"
-    json5 "^1.0.1"
-
 loader-utils@^1.0.2, loader-utils@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.1.0.tgz#c98aef488bcceda2ffb5e2de646d6a754429f5cd"
@@ -12544,6 +12540,15 @@ loader-utils@^1.0.2, loader-utils@^1.1.0:
     emojis-list "^2.0.0"
     json5 "^0.5.0"
 
+loader-utils@^1.2.3:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.2.3.tgz#1ff5dc6911c9f0a062531a4c04b609406108c2c7"
+  integrity sha512-fkpz8ejdnEMG3s37wGL07iSBDg99O9D5yflE9RGNH3hRdx9SOwYfnGYdZOUIZitN8E+E2vkq3MUMYMvPYl5ZZA==
+  dependencies:
+    big.js "^5.2.2"
+    emojis-list "^2.0.0"
+    json5 "^1.0.1"
+
 loader-utils@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.0.tgz#e4cace5b816d425a166b5f097e10cd12b36064b0"
@@ -13234,7 +13239,7 @@ mem@^4.0.0:
     mimic-fn "^1.0.0"
     p-is-promise "^2.0.0"
 
-memory-fs@^0.4.0, memory-fs@^0.4.1:
+memory-fs@^0.4.1:
   version "0.4.1"
   resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552"
   dependencies:
@@ -13477,7 +13482,7 @@ micromatch@^2.3.11:
     parse-glob "^3.0.4"
     regex-cache "^0.4.2"
 
-micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4:
+micromatch@^3.1.10, micromatch@^3.1.4:
   version "3.1.10"
   resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
   dependencies:
@@ -15103,7 +15108,7 @@ os-homedir@^1.0.0:
   resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
   integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M=
 
-os-locale@^3.0.0, os-locale@^3.1.0:
+os-locale@^3.0.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-3.1.0.tgz#a802a6ee17f24c10483ab9935719cef4ed16bf1a"
   dependencies:
@@ -15697,7 +15702,7 @@ path-key@^3.1.0:
   resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.0.tgz#99a10d870a803bdd5ee6f0470e58dfcd2f9a54d3"
   integrity sha512-8cChqz0RP6SHJkMt48FW0A7+qUOn+OsnOsVtzI59tZ8m+5bCSk7hzwET0pulwOM2YMn9J1efb07KB9l9f30SGg==
 
-path-parse@^1.0.5, path-parse@^1.0.6:
+path-parse@^1.0.5, path-parse@^1.0.6, path-parse@^1.0.7:
   version "1.0.7"
   resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
   integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
@@ -16788,7 +16793,7 @@ randombytes@^2.1.0:
   dependencies:
     safe-buffer "^5.1.0"
 
-randomcolor@>=0.5.4:
+randomcolor@>=0.5.4, randomcolor@^0.5.4:
   version "0.5.4"
   resolved "https://registry.yarnpkg.com/randomcolor/-/randomcolor-0.5.4.tgz#df615b13f25b89ea58c5f8f72647f0a6f07adcc3"
   integrity sha512-nYd4nmTuuwMFzHL6W+UWR5fNERGZeVauho8mrJDUSXdNDbao4rbrUwhuLgKC/j8VCS5+34Ria8CsTDuBjrIrQA==
@@ -17052,6 +17057,15 @@ react-scrolllock@^1.0.9:
     create-react-class "^15.5.2"
     prop-types "^15.5.10"
 
+react-tagcloud@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/react-tagcloud/-/react-tagcloud-2.1.1.tgz#b8883634f76b5681c91a178689070efa0d442657"
+  integrity sha512-cM96jzUOKQqu2qlzwcO91r239MSDbFiAslFNk4Hja3MaZ4Y89goIzbTyXZwonkeJck1zY5wkNhJYeJ8YSdOwXg==
+  dependencies:
+    prop-types "^15.6.2"
+    randomcolor "^0.5.4"
+    shuffle-array "^1.0.1"
+
 react-transition-group@^2.2.0:
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.2.1.tgz#e9fb677b79e6455fd391b03823afe84849df4a10"
@@ -17354,6 +17368,13 @@ rechoir@^0.6.2:
   dependencies:
     resolve "^1.1.6"
 
+rechoir@^0.7.0:
+  version "0.7.1"
+  resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.7.1.tgz#9478a96a1ca135b5e88fc027f03ee92d6c645686"
+  integrity sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==
+  dependencies:
+    resolve "^1.9.0"
+
 reconnecting-websocket@^4.4.0:
   version "4.4.0"
   resolved "https://registry.yarnpkg.com/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz#3b0e5b96ef119e78a03135865b8bb0af1b948783"
@@ -17771,6 +17792,15 @@ resolve@^1.3.2:
   dependencies:
     path-parse "^1.0.6"
 
+resolve@^1.9.0:
+  version "1.21.0"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.21.0.tgz#b51adc97f3472e6a5cf4444d34bc9d6b9037591f"
+  integrity sha512-3wCbTpk5WJlyE4mSOtDLhqQmGFi0/TD9VPwmiolnk8U0wRgMEktqCXd3vy5buTO3tljvalNvKrjHEfrd2WpEKA==
+  dependencies:
+    is-core-module "^2.8.0"
+    path-parse "^1.0.7"
+    supports-preserve-symlinks-flag "^1.0.0"
+
 resolve@^2.0.0-next.3:
   version "2.0.0-next.3"
   resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.3.tgz#d41016293d4a8586a39ca5d9b5f15cbea1f55e46"
@@ -18479,6 +18509,11 @@ should@^13.2.1:
     should-type-adaptors "^1.0.1"
     should-util "^1.0.0"
 
+shuffle-array@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/shuffle-array/-/shuffle-array-1.0.1.tgz#c4ff3cfe74d16f93730592301b25e6577b12898b"
+  integrity sha1-xP88/nTRb5NzBZIwGyXmV3sSiYs=
+
 side-channel@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
@@ -19184,7 +19219,7 @@ string-width@^1.0.1, string-width@^1.0.2:
     is-fullwidth-code-point "^3.0.0"
     strip-ansi "^6.0.1"
 
-string-width@^3.0.0, string-width@^3.1.0:
+string-width@^3.0.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961"
   integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==
@@ -19284,7 +19319,7 @@ stringify-entities@^1.0.1:
     is-alphanumerical "^1.0.0"
     is-hexadecimal "^1.0.0"
 
-strip-ansi@5.2.0, strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0:
+strip-ansi@5.2.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0:
   version "5.2.0"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae"
   dependencies:
@@ -19565,12 +19600,6 @@ superagent@^1.2.0:
     readable-stream "1.0.27-1"
     reduce-component "1.0.1"
 
-supports-color@6.1.0, supports-color@^6.1.0:
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3"
-  dependencies:
-    has-flag "^3.0.0"
-
 supports-color@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7"
@@ -19602,6 +19631,12 @@ supports-color@^5.5.0:
   dependencies:
     has-flag "^3.0.0"
 
+supports-color@^6.1.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3"
+  dependencies:
+    has-flag "^3.0.0"
+
 supports-color@^7.0.0, supports-color@^7.1.0:
   version "7.1.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1"
@@ -19624,6 +19659,11 @@ supports-hyperlinks@^2.0.0:
     has-flag "^4.0.0"
     supports-color "^7.0.0"
 
+supports-preserve-symlinks-flag@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
+  integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
+
 svg-tags@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/svg-tags/-/svg-tags-1.0.0.tgz#58f71cee3bd519b59d4b2a843b6c7de64ac04764"
@@ -21323,11 +21363,6 @@ uuid@^3.0.1, uuid@^3.1.0, uuid@^3.3.2:
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
   integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
 
-v8-compile-cache@2.0.3:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.0.3.tgz#00f7494d2ae2b688cfe2899df6ed2c54bef91dbe"
-  integrity sha512-CNmdbwQMBjwr9Gsmohvm0pbL954tJrNzf6gWL3K+QMQf00PF7ERGrEiLgjuU3mKreLC2MeGhUsNV9ybTbLgd3w==
-
 v8-compile-cache@^2.0.3, v8-compile-cache@^2.3.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
@@ -21568,29 +21603,31 @@ webpack-bundle-analyzer@^3.9.0:
     opener "^1.5.1"
     ws "^6.0.0"
 
-webpack-cli@^3.3.7:
-  version "3.3.7"
-  resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-3.3.7.tgz#77c8580dd8e92f69d635e0238eaf9d9c15759a91"
-  integrity sha512-OhTUCttAsr+IZSMVwGROGRHvT+QAs8H6/mHIl4SvhAwYywjiylYjpwybGx7WQ9Hkb45FhjtsymkwiRRbGJ1SZQ==
-  dependencies:
-    chalk "2.4.2"
-    cross-spawn "6.0.5"
-    enhanced-resolve "4.1.0"
-    findup-sync "3.0.0"
-    global-modules "2.0.0"
-    import-local "2.0.0"
-    interpret "1.2.0"
-    loader-utils "1.2.3"
-    supports-color "6.1.0"
-    v8-compile-cache "2.0.3"
-    yargs "13.2.4"
-
-webpack-merge@^4.2.2:
-  version "4.2.2"
-  resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-4.2.2.tgz#a27c52ea783d1398afd2087f547d7b9d2f43634d"
-  integrity sha512-TUE1UGoTX2Cd42j3krGYqObZbOD+xF7u28WB7tfUordytSjbWTIjK/8V0amkBfTYN4/pB/GIDlJZZ657BGG19g==
+webpack-cli@^4.9.1:
+  version "4.9.1"
+  resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-4.9.1.tgz#b64be825e2d1b130f285c314caa3b1ba9a4632b3"
+  integrity sha512-JYRFVuyFpzDxMDB+v/nanUdQYcZtqFPGzmlW4s+UkPMFhSpfRNmf1z4AwYcHJVdvEFAM7FFCQdNTpsBYhDLusQ==
+  dependencies:
+    "@discoveryjs/json-ext" "^0.5.0"
+    "@webpack-cli/configtest" "^1.1.0"
+    "@webpack-cli/info" "^1.4.0"
+    "@webpack-cli/serve" "^1.6.0"
+    colorette "^2.0.14"
+    commander "^7.0.0"
+    execa "^5.0.0"
+    fastest-levenshtein "^1.0.12"
+    import-local "^3.0.2"
+    interpret "^2.2.0"
+    rechoir "^0.7.0"
+    webpack-merge "^5.7.3"
+
+webpack-merge@^5.7.3:
+  version "5.8.0"
+  resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-5.8.0.tgz#2b39dbf22af87776ad744c390223731d30a68f61"
+  integrity sha512-/SaI7xY0831XwP6kzuwhKWVKDP9t1QY1h65lAFLbZqMPIuYcD9QAW4u9STIbU9kaJbPBB/geU/gLr1wDjOhQ+Q==
   dependencies:
-    lodash "^4.17.15"
+    clone-deep "^4.0.1"
+    wildcard "^2.0.0"
 
 webpack-sources@^1.0.0:
   version "1.3.0"
@@ -21614,7 +21651,7 @@ webpack-sources@^1.1.0:
     source-list-map "^2.0.0"
     source-map "~0.6.1"
 
-webpack@^4.39.3:
+webpack@^4.46.0:
   version "4.46.0"
   resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.46.0.tgz#bf9b4404ea20a073605e0a011d188d77cb6ad542"
   integrity sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==
@@ -21742,6 +21779,11 @@ widest-line@^2.0.0:
   dependencies:
     string-width "^2.1.1"
 
+wildcard@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.0.tgz#a77d20e5200c6faaac979e4b3aadc7b3dd7f8fec"
+  integrity sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==
+
 window-size@0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.0.tgz#5438cd2ea93b202efa3a19fe8887aee7c94f9c9d"
@@ -21785,15 +21827,6 @@ wrap-ansi@^2.0.0:
     string-width "^1.0.1"
     strip-ansi "^3.0.1"
 
-wrap-ansi@^5.1.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09"
-  integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==
-  dependencies:
-    ansi-styles "^3.2.0"
-    string-width "^3.0.0"
-    strip-ansi "^5.0.0"
-
 wrap-ansi@^6.2.0:
   version "6.2.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
@@ -22112,14 +22145,6 @@ yargs-parser@^11.1.1:
     camelcase "^5.0.0"
     decamelize "^1.2.0"
 
-yargs-parser@^13.1.0:
-  version "13.1.1"
-  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.1.tgz#d26058532aa06d365fe091f6a1fc06b2f7e5eca0"
-  integrity sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ==
-  dependencies:
-    camelcase "^5.0.0"
-    decamelize "^1.2.0"
-
 yargs-parser@^18.1.2, yargs-parser@^18.1.3:
   version "18.1.3"
   resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0"
@@ -22128,23 +22153,6 @@ yargs-parser@^18.1.2, yargs-parser@^18.1.3:
     camelcase "^5.0.0"
     decamelize "^1.2.0"
 
-yargs@13.2.4:
-  version "13.2.4"
-  resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.2.4.tgz#0b562b794016eb9651b98bd37acf364aa5d6dc83"
-  integrity sha512-HG/DWAJa1PAnHT9JAhNa8AbAv3FPaiLzioSjCcmuXXhP8MlpHO5vwls4g4j6n30Z74GVQj8Xa62dWVx1QCGklg==
-  dependencies:
-    cliui "^5.0.0"
-    find-up "^3.0.0"
-    get-caller-file "^2.0.1"
-    os-locale "^3.1.0"
-    require-directory "^2.1.1"
-    require-main-filename "^2.0.0"
-    set-blocking "^2.0.0"
-    string-width "^3.0.0"
-    which-module "^2.0.0"
-    y18n "^4.0.0"
-    yargs-parser "^13.1.0"
-
 yargs@17.1.1:
   version "17.1.1"
   resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.1.1.tgz#c2a8091564bdb196f7c0a67c1d12e5b85b8067ba"