Răsfoiți Sursa

Merge branch 'master' into feat/growi-bot

Yuki Takei 5 ani în urmă
părinte
comite
38b5dd8746
70 a modificat fișierele cu 828 adăugiri și 405 ștergeri
  1. 18 0
      .github/workflows/ci.yml
  2. 33 2
      CHANGES.md
  3. 0 1
      Procfile
  4. 1 1
      README.md
  5. 1 1
      bin/github-actions/list-branches.js
  6. 0 3
      bin/heroku/install-packages.sh
  7. 5 1
      package.json
  8. 4 0
      resource/Contributor.js
  9. 8 0
      resource/locales/en_US/admin/admin.json
  10. 8 0
      resource/locales/ja_JP/admin/admin.json
  11. 9 1
      resource/locales/zh_CN/admin/admin.json
  12. 3 0
      src/client/js/app.jsx
  13. 0 3
      src/client/js/base.jsx
  14. 115 0
      src/client/js/components/Admin/MarkdownSetting/IndentForm.jsx
  15. 8 0
      src/client/js/components/Admin/MarkdownSetting/MarkDownSettingContents.jsx
  16. 26 4
      src/client/js/components/Navbar/PageEditorModeManager.jsx
  17. 2 0
      src/client/js/components/Page/RevisionRenderer.jsx
  18. 5 0
      src/client/js/components/PageComment/CommentEditor.jsx
  19. 10 0
      src/client/js/components/PageEditor.jsx
  20. 1 1
      src/client/js/components/PageEditor/CodeMirrorEditor.jsx
  21. 2 1
      src/client/js/components/PageEditor/DrawioModal.jsx
  22. 1 0
      src/client/js/components/PageEditor/Editor.jsx
  23. 39 1
      src/client/js/components/PageEditor/OptionsSelector.jsx
  24. 2 6
      src/client/js/components/PageHistory.jsx
  25. 13 3
      src/client/js/components/PageHistory/PageRevisionTable.jsx
  26. 1 1
      src/client/js/components/PageHistory/RevisionDiff.jsx
  27. 20 10
      src/client/js/components/RevisionComparer/RevisionComparer.jsx
  28. 75 27
      src/client/js/components/Sidebar/CustomSidebar.jsx
  29. 1 1
      src/client/js/components/Sidebar/SidebarNav.jsx
  30. 20 4
      src/client/js/components/StaffCredit/StaffCredit.jsx
  31. 2 2
      src/client/js/components/TableOfContents.jsx
  32. 22 0
      src/client/js/services/AdminMarkDownContainer.js
  33. 1 7
      src/client/js/services/AdminUserGroupDetailContainer.js
  34. 1 0
      src/client/js/services/EditorContainer.js
  35. 12 2
      src/client/js/services/NavigationContainer.js
  36. 5 0
      src/client/js/services/PageHistoryContainer.js
  37. 13 0
      src/client/js/util/locale-utils.js
  38. 14 0
      src/client/styles/scss/_on-edit.scss
  39. 7 3
      src/client/styles/scss/_page-history.scss
  40. 50 0
      src/client/styles/scss/_sidebar-wiki.scss
  41. 1 0
      src/client/styles/scss/style-app.scss
  42. 10 0
      src/client/styles/scss/theme/_apply-colors.scss
  43. 0 49
      src/server/models/bookmark.js
  44. 4 0
      src/server/models/config.js
  45. 7 44
      src/server/models/page.js
  46. 0 57
      src/server/models/revision.js
  47. 1 5
      src/server/models/user-group-relation.js
  48. 2 2
      src/server/models/user-group.js
  49. 2 2
      src/server/models/user.js
  50. 4 5
      src/server/routes/apiv3/attachment.js
  51. 8 1
      src/server/routes/apiv3/bookmarks.js
  52. 1 0
      src/server/routes/apiv3/index.js
  53. 29 0
      src/server/routes/apiv3/markdown-setting.js
  54. 17 2
      src/server/routes/apiv3/pages.js
  55. 14 5
      src/server/routes/apiv3/revisions.js
  56. 52 0
      src/server/routes/apiv3/staffs.js
  57. 2 1
      src/server/routes/apiv3/user-group-relation.js
  58. 23 17
      src/server/routes/apiv3/user-group.js
  59. 1 1
      src/server/routes/attachment.js
  60. 23 27
      src/server/routes/comment.js
  61. 0 1
      src/server/routes/index.js
  62. 34 56
      src/server/routes/page.js
  63. 6 0
      src/server/routes/search.js
  64. 0 11
      src/server/routes/user.js
  65. 3 3
      src/server/service/page.js
  66. 1 1
      src/server/service/search-delegator/elasticsearch.js
  67. 14 4
      src/server/service/user-notification/index.js
  68. 2 2
      src/server/views/widget/page_content.html
  69. 4 23
      src/test/models/page.test.js
  70. 5 0
      yarn.lock

+ 18 - 0
.github/workflows/ci.yml

@@ -75,6 +75,10 @@ jobs:
         image: mongo:4.4
         ports:
         - 27017/tcp
+      mongodb36:
+        image: mongo:3.6
+        ports:
+        - 27017/tcp
 
     steps:
     - uses: actions/checkout@v2
@@ -114,6 +118,11 @@ jobs:
         yarn test
       env:
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_test
+    - name: yarn test with MongoDB 3.6
+      run: |
+        yarn test
+      env:
+        MONGO_URI: mongodb://localhost:${{ job.services.mongodb36.ports['27017'] }}/growi_test
 
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
@@ -209,6 +218,10 @@ jobs:
         image: mongo:4.4
         ports:
         - 27017/tcp
+      mongodb36:
+        image: mongo:3.6
+        ports:
+        - 27017/tcp
 
     steps:
     - uses: actions/checkout@v2
@@ -271,6 +284,11 @@ jobs:
         yarn server:prod:ci
       env:
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi-${{ steps.getdbname.outputs.suffix }}
+    - name: yarn server:prod:ci with MongoDB 3.6
+      run: |
+        yarn server:prod:ci
+      env:
+        MONGO_URI: mongodb://localhost:${{ job.services.mongodb36.ports['27017'] }}/growi-${{ steps.getdbname.outputs.suffix }}
     - name: Upload report as artifact
       uses: actions/upload-artifact@v2
       with:

+ 33 - 2
CHANGES.md

@@ -1,11 +1,42 @@
 # CHANGES
 
-## v4.2.9-RC
+## v4.2.14-RC
+
+* 
+
+## v4.2.13
+
+* Feature: Detect indent size automatically
+* Fix: Some API responses includes email unintentionally
+* Fix: An error always displayed in admin pages
+
+## v4.2.12
+
+* Feature: Custom Sidebar
+* Fix: Set language correctly for draw.io (diagrams.net)
+
+## v4.2.11
+
+* Fix: Rename decendants is not working
+    * Introduced by v4.2.8
+
+
+## v4.2.10
+
+* Feature: Staff Credits for apps on GROWI.cloud 
+* Improvement: Hackmd button behavior when disabled
+* Improvement: Layout of comparing revisions
+* Fix: Empty trash is not working
+
+## v4.2.9
 
 * Feature: Comparing revisions
 * Improvement: Memory consumption when re-indexing for full text searching
 * Improvement: Site URL settings valildation
-* Fix: Screen transition without displaying notice on browsers except Chrome
+* Fix: Show comfirmation when transiting page without save
+* Fix: Save slack channels history when user trigger notification is invoked
+* Fix: The label of alerts for move/rename/delete are borken
+
 ## v4.2.8
 
 * Improvement: Performance for pages to rename/duplicate/delete/revert pages

+ 0 - 1
Procfile

@@ -1 +0,0 @@
-web: npm run server:prod

+ 1 - 1
README.md

@@ -31,7 +31,7 @@ Table Of Contents
 - [Features](#features)
 - [Quick Start for Production](#quick-start-for-production)
     - [docker-compose](#docker-compose)
-    - [Helm (Experimental)](#helm)
+    - [Helm (Experimental)](#helm-experimental)
     - [On-premise](#on-premise)
 - [Environment Variables](#environment-variables)
 - [Documentation](#documentation)

+ 1 - 1
bin/github-actions/list-branches.js

@@ -14,7 +14,7 @@ const url = require('url');
 
 const EXCLUDE_TERM_DAYS = 14;
 const EXCLUDE_PATTERNS = [
-  /^feat\/custom-sidebar-2$/,
+  /^support\/apply-tsed$/,
   // https://regex101.com/r/Lnx7Pz/3
   /^dev\/[\d.x]*$/,
   /^release\/.+$/,

+ 0 - 3
bin/heroku/install-packages.sh

@@ -1,3 +0,0 @@
-#!/bin/sh
-
-yarn add $ADDITIONAL_PACKAGES

+ 5 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.2.9-RC",
+  "version": "4.2.14-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -24,12 +24,15 @@
     "build:apiv3:jsdoc": "cross-env API_VERSION=3 npm run build:api:jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
     "build:apiv1:jsdoc": "cross-env API_VERSION=1 npm run build:api:jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
     "build:dev:app:watch": "npm run build:dev:app -- --watch",
+    "build:dev:app:watch:poll": "npm run build:dev:app -- --watch --watch-poll",
     "build:dev:app": "env-cmd -f config/env.dev.js webpack --config config/webpack.dev.js --progress",
     "build:dev:watch": "npm run build:dev:app:watch",
+    "build:dev:watch:poll": "npm run build:dev:app:watch:poll",
     "build:dev": "npm run build:dev:app",
     "build:prod:analyze": "cross-env ANALYZE=1 npm run build:prod",
     "build:prod": "env-cmd -f config/env.prod.js webpack --config config/webpack.prod.js --profile --bail",
     "build": "npm run build:dev:watch",
+    "build:poll": "npm run build:dev:watch:poll",
     "clean:app": "rimraf -- public/js public/styles",
     "clean:report": "rimraf -- report",
     "clean": "npm-run-all -p clean:*",
@@ -94,6 +97,7 @@
     "cross-env": "^7.0.0",
     "csrf": "^3.1.0",
     "date-fns": "^2.0.0",
+    "detect-indent": "^6.0.0",
     "diff": "^4.0.1",
     "elasticsearch": "^16.0.0",
     "entities": "^2.0.0",

+ 4 - 0
src/client/js/components/StaffCredit/Contributor.js → resource/Contributor.js

@@ -1,5 +1,6 @@
 const contributors = [
   {
+    order: 1,
     sectionName: 'GROWI VILLAGE',
     additionalClass: '',
     memberGroups: [
@@ -47,6 +48,7 @@ const contributors = [
     ],
   },
   {
+    order: 10,
     sectionName: 'CONTRIBUTER',
     additionalClass: '',
     memberGroups: [
@@ -92,6 +94,7 @@ const contributors = [
     ],
   },
   {
+    order: 100,
     sectionName: 'VULNERABILITY HUNTER',
     additionalClass: '',
     memberGroups: [
@@ -111,6 +114,7 @@ const contributors = [
     ],
   },
   {
+    order: 200,
     sectionName: 'SPECIAL THANKS',
     additionalClass: '',
     memberGroups: [

+ 8 - 0
resource/locales/en_US/admin/admin.json

@@ -78,6 +78,14 @@
       "enable_lineBreak_for_comment": "Enable line break in comment",
       "enable_lineBreak_for_comment_desc": "Convert line break in comment to<code>&lt;br&gt;</code>in HTML"
     },
+    "indent_header": "Indent setting",
+    "indent_desc": "You can change indent settings.",
+    "indent_options": {
+      "indentSize": "Default indent size",
+      "indentSize_desc": "Set the default indent size for the Markdown editor",
+      "disallow_indent_change": "Disallow change of indent size by users",
+      "disallow_indent_change_desc": "Force users to use ther default indent size."
+    },
     "presentation_header": "Presentation setting",
     "presentation_desc": "You can change presentation settings.",
     "presentation_options": {

+ 8 - 0
resource/locales/ja_JP/admin/admin.json

@@ -78,6 +78,14 @@
       "enable_lineBreak_for_comment": "コメント欄で Line Break を有効にする",
       "enable_lineBreak_for_comment_desc": "コメント中の改行を、HTML内で<code>&lt;br&gt;</code>として扱います"
     },
+    "indent_header": "インデント設定",
+    "indent_desc": "インデントの設定を変更できます。",
+    "indent_options": {
+      "indentSize": "既定のインデント幅",
+      "indentSize_desc": "既定のインデント幅を指定します。",
+      "disallow_indent_change": "ユーザによるインデント幅変更を許可しない",
+      "disallow_indent_change_desc": "ユーザにデフォルトのインデント幅の使用を強制します。"
+    },
     "presentation_header": "プレゼンテーション設定",
     "presentation_desc": "プレゼンテーションの設定を変更できます。",
     "presentation_options": {

+ 9 - 1
resource/locales/zh_CN/admin/admin.json

@@ -77,7 +77,15 @@
 			"enable_lineBreak_for_comment": "注释中启用换行符",
 			"enable_lineBreak_for_comment_desc": "HTML中将注释中的换行符转换为<code>&lt;br&gt;</code>"
 		},
-		"presentation_header": "演示文稿设置",
+    "indent_header": "缩进设置",
+    "indent_desc": "您可以更改缩进设置。",
+    "indent_options": {
+      "indentSize": "默认的缩进值",
+      "indentSize_desc": "您可以更改Markdown编辑器的默认的缩进值。",
+      "disallow_indent_change": "不允许用户更改缩进值",
+      "disallow_indent_change_desc": "您可以不允许用户更改缩进值。"
+    },
+    "presentation_header": "演示文稿设置",
 		"presentation_desc": "您可以更改演示文稿设置。",
 		"presentation_options": {
 			"page_break_setting": "分页设置",

+ 3 - 0
src/client/js/app.jsx

@@ -6,6 +6,7 @@ import { I18nextProvider } from 'react-i18next';
 import loggerFactory from '@alias/logger';
 
 import ErrorBoundary from './components/ErrorBoudary';
+import Sidebar from './components/Sidebar';
 import SearchPage from './components/SearchPage';
 import TagsList from './components/TagsList';
 import DisplaySwitcher from './components/Page/DisplaySwitcher';
@@ -79,6 +80,8 @@ logger.info('unstated containers have been initialized');
  *  value: React Element
  */
 Object.assign(componentMappings, {
+  'grw-sidebar-wrapper': <Sidebar />,
+
   'search-page': <SearchPage crowi={appContainer} />,
 
   // 'revision-history': <PageHistory pageId={pageId} />,

+ 0 - 3
src/client/js/base.jsx

@@ -5,7 +5,6 @@ import Xss from '@commons/service/xss';
 
 import GrowiNavbar from './components/Navbar/GrowiNavbar';
 import GrowiNavbarBottom from './components/Navbar/GrowiNavbarBottom';
-import Sidebar from './components/Sidebar';
 import HotkeysManager from './components/Hotkeys/HotkeysManager';
 
 import AppContainer from './services/AppContainer';
@@ -42,8 +41,6 @@ const componentMappings = {
 
   'page-create-modal': <PageCreateModal />,
 
-  'grw-sidebar-wrapper': <Sidebar />,
-
   'grw-hotkeys-manager': <HotkeysManager />,
 
 };

+ 115 - 0
src/client/js/components/Admin/MarkdownSetting/IndentForm.jsx

@@ -0,0 +1,115 @@
+/* eslint-disable react/no-danger */
+import React from 'react';
+
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
+import {
+  UncontrolledDropdown, DropdownToggle, DropdownMenu, DropdownItem,
+} from 'reactstrap';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AdminMarkDownContainer from '../../../services/AdminMarkDownContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+const logger = loggerFactory('growi:importer');
+
+const IndentForm = (props) => {
+  const onClickSubmit = async(props) => {
+    const { t } = props;
+
+    try {
+      await props.adminMarkDownContainer.updateIndentSetting();
+      toastSuccess(t('toaster.update_successed', { target: t('admin:markdown_setting.indent_header') }));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  };
+
+  const renderIndentSizeOption = (props) => {
+    const { t, adminMarkDownContainer } = props;
+    const { adminPreferredIndentSize } = adminMarkDownContainer.state;
+
+    return (
+      <div className="col">
+        <div>
+          <label htmlFor="adminPreferredIndentSize">{t('admin:markdown_setting.indent_options.indentSize')}</label>
+          <UncontrolledDropdown id="adminPreferredIndentSize">
+            <DropdownToggle caret className="col-3 col-sm-2 col-md-5 col-lg-5 col-xl-3 text-right">
+              <span className="float-left">
+                {adminPreferredIndentSize || 4}
+              </span>
+            </DropdownToggle>
+            <DropdownMenu className="dropdown-menu" role="menu">
+              {[2, 4].map((num) => {
+                return (
+                  <DropdownItem key={num} role="presentation" onClick={() => adminMarkDownContainer.setAdminPreferredIndentSize(num)}>
+                    <a role="menuitem">{num}</a>
+                  </DropdownItem>
+                );
+              })}
+            </DropdownMenu>
+          </UncontrolledDropdown>
+        </div>
+        <p className="form-text text-muted">
+          {t('admin:markdown_setting.indent_options.indentSize_desc')}
+        </p>
+      </div>
+    );
+  };
+
+  const renderIndentForceOption = (props) => {
+    const { t, adminMarkDownContainer } = props;
+    const { isIndentSizeForced } = adminMarkDownContainer.state;
+
+    const helpIndentInComment = { __html: t('admin:markdown_setting.indent_options.disallow_indent_change_desc') };
+
+    return (
+      <div className="col">
+        <div className="custom-control custom-checkbox custom-checkbox-success">
+          <input
+            type="checkbox"
+            className="custom-control-input"
+            id="isIndentSizeForced"
+            checked={isIndentSizeForced || false}
+            onChange={() => {
+              adminMarkDownContainer.setState({ isIndentSizeForced: !isIndentSizeForced });
+            }}
+          />
+          <label className="custom-control-label" htmlFor="isIndentSizeForced">
+            {t('admin:markdown_setting.indent_options.disallow_indent_change')}
+          </label>
+        </div>
+        <p className="form-text text-muted" dangerouslySetInnerHTML={helpIndentInComment} />
+      </div>
+    );
+  };
+
+  const { adminMarkDownContainer } = props;
+
+  return (
+    <React.Fragment>
+      <fieldset className="form-group row row-cols-1 row-cols-md-2 mx-3">
+        {renderIndentSizeOption(props)}
+        {renderIndentForceOption(props)}
+      </fieldset>
+      <AdminUpdateButtonRow onClick={() => onClickSubmit(props)} disabled={adminMarkDownContainer.state.retrieveError != null} />
+    </React.Fragment>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const IndentFormWrapper = withUnstatedContainers(IndentForm, [AdminMarkDownContainer]);
+
+IndentForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  adminMarkDownContainer: PropTypes.instanceOf(AdminMarkDownContainer).isRequired,
+};
+
+export default withTranslation()(IndentFormWrapper);

+ 8 - 0
src/client/js/components/Admin/MarkdownSetting/MarkDownSettingContents.jsx

@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
 import LineBreakForm from './LineBreakForm';
+import IndentForm from './IndentForm';
 import PresentationForm from './PresentationForm';
 import XssForm from './XssForm';
 
@@ -21,6 +22,13 @@ class MarkDownSettingContents extends React.Component {
         </Card>
         <LineBreakForm />
 
+        {/* Indent Setting */}
+        <h2 className="admin-setting-header">{t('admin:markdown_setting.indent_header')}</h2>
+        <Card className="card well my-3">
+          <CardBody className="px-0 py-2">{t('admin:markdown_setting.indent_desc') }</CardBody>
+        </Card>
+        <IndentForm />
+
         {/* Presentation Setting */}
         <h2 className="admin-setting-header">{ t('admin:markdown_setting.presentation_header') }</h2>
         <Card className="card well my-3">

+ 26 - 4
src/client/js/components/Navbar/PageEditorModeManager.jsx

@@ -3,9 +3,12 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 
+import { withUnstatedContainers } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+
 /* eslint-disable react/prop-types */
 const PageEditorModeButtonWrapper = React.memo(({
-  editorMode, isBtnDisabled, onClick, targetMode, icon, label,
+  editorMode, isBtnDisabled, onClick, targetMode, icon, label, id,
 }) => {
   const classNames = [`btn btn-outline-primary ${targetMode}-button px-1`];
   if (editorMode === targetMode) {
@@ -20,6 +23,7 @@ const PageEditorModeButtonWrapper = React.memo(({
       type="button"
       className={classNames.join(' ')}
       onClick={() => { onClick(targetMode) }}
+      id={id}
     >
       <span className="d-flex flex-column flex-md-row justify-content-center">
         <span className="grw-page-editor-mode-manager-icon mr-md-1">{icon}</span>
@@ -32,9 +36,14 @@ const PageEditorModeButtonWrapper = React.memo(({
 
 function PageEditorModeManager(props) {
   const {
-    t, editorMode, onPageEditorModeButtonClicked, isBtnDisabled, isDeviceSmallerThanMd,
+    t, appContainer,
+    editorMode, onPageEditorModeButtonClicked, isBtnDisabled, isDeviceSmallerThanMd,
   } = props;
 
+  const isAdmin = appContainer.isAdmin;
+  const isHackmdEnabled = appContainer.config.env.HACKMD_URI != null;
+  const showHackmdBtn = isHackmdEnabled || isAdmin;
+  const showHackmdDisabledTooltip = isAdmin && !isHackmdEnabled && editorMode !== 'hackmd';
 
   const pageEditorModeButtonClickedHandler = useCallback((viewType) => {
     if (isBtnDisabled) {
@@ -73,7 +82,7 @@ function PageEditorModeManager(props) {
             label={t('Edit')}
           />
         )}
-        {(!isDeviceSmallerThanMd || editorMode === 'view') && (
+        {(!isDeviceSmallerThanMd || editorMode === 'view') && showHackmdBtn && (
           <PageEditorModeButtonWrapper
             editorMode={editorMode}
             isBtnDisabled={isBtnDisabled}
@@ -81,6 +90,7 @@ function PageEditorModeManager(props) {
             targetMode="hackmd"
             icon={<i className="fa fa-file-text-o" />}
             label={t('hackmd.hack_md')}
+            id="grw-page-editor-mode-manager-hackmd-button"
           />
         )}
       </div>
@@ -89,6 +99,11 @@ function PageEditorModeManager(props) {
           {t('Not available for guest')}
         </UncontrolledTooltip>
       )}
+      {!isBtnDisabled && showHackmdDisabledTooltip && (
+        <UncontrolledTooltip placement="top" target="grw-page-editor-mode-manager-hackmd-button" fade={false}>
+          {t('hackmd.not_set_up')}
+        </UncontrolledTooltip>
+      )}
     </>
   );
 
@@ -96,6 +111,8 @@ function PageEditorModeManager(props) {
 
 PageEditorModeManager.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   onPageEditorModeButtonClicked: PropTypes.func,
   isBtnDisabled: PropTypes.bool,
   editorMode: PropTypes.string,
@@ -107,4 +124,9 @@ PageEditorModeManager.defaultProps = {
   isDeviceSmallerThanMd: false,
 };
 
-export default withTranslation()(PageEditorModeManager);
+/**
+ * Wrapper component for using unstated
+ */
+const PageEditorModeManagerWrapper = withUnstatedContainers(PageEditorModeManager, [AppContainer]);
+
+export default withTranslation()(PageEditorModeManagerWrapper);

+ 2 - 0
src/client/js/components/Page/RevisionRenderer.jsx

@@ -110,6 +110,7 @@ class RevisionRenderer extends React.PureComponent {
       <RevisionBody
         html={this.state.html}
         isMathJaxEnabled={isMathJaxEnabled}
+        additionalClassName={this.props.additionalClassName}
         renderMathJaxOnInit
       />
     );
@@ -129,6 +130,7 @@ RevisionRenderer.propTypes = {
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
   highlightKeywords: PropTypes.string,
+  additionalClassName: PropTypes.string,
 };
 
 export default RevisionRendererWrapper;

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

@@ -23,6 +23,7 @@ import CommentPreview from './CommentPreview';
 import NotAvailableForGuest from '../NotAvailableForGuest';
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
 
+
 const navTabMapping = {
   comment_editor: {
     Icon: () => <i className="icon-settings" />,
@@ -314,6 +315,10 @@ class CommentEditor extends React.Component {
                 onUpload={this.uploadHandler}
                 onCtrlEnter={this.ctrlEnterHandler}
               />
+              {/*
+                Note: <OptionsSelector /> is not optimized for ComentEditor in terms of responsive design.
+                See a review comment in https://github.com/weseek/growi/pull/3473
+              */}
             </TabPane>
             <TabPane tabId="comment_preview">
               <div className="comment-form-preview">

+ 10 - 0
src/client/js/components/PageEditor.jsx

@@ -1,6 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import loggerFactory from '@alias/logger';
+import detectIndent from 'detect-indent';
 
 import { throttle, debounce } from 'throttle-debounce';
 import { envUtils } from 'growi-commons';
@@ -59,6 +60,15 @@ class PageEditor extends React.Component {
       this.setState({ markdown: value });
     }));
     this.saveDraftWithDebounce = debounce(800, this.saveDraft);
+
+    // Detect indent size from contents (only when users are allowed to change it)
+    // TODO: https://youtrack.weseek.co.jp/issue/GW-5368
+    if (!this.props.appContainer.config.isIndentSizeForced && this.state.markdown) {
+      const detectedIndent = detectIndent(this.state.markdown);
+      if (detectedIndent.type === 'space' && new Set([2, 4]).has(detectedIndent.amount)) {
+        this.props.editorContainer.setState({ indentSize: detectedIndent.amount });
+      }
+    }
   }
 
   componentWillMount() {

+ 1 - 1
src/client/js/components/PageEditor/CodeMirrorEditor.jsx

@@ -843,7 +843,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
             styleActiveLine: this.props.editorOptions.styleActiveLine,
             lineNumbers: this.props.lineNumbers,
             tabSize: 4,
-            indentUnit: 4,
+            indentUnit: this.props.indentSize,
             lineWrapping: true,
             autoRefresh: { force: true }, // force option is enabled by autorefresh.ext.js -- Yuki Takei
             autoCloseTags: true,

+ 2 - 1
src/client/js/components/PageEditor/DrawioModal.jsx

@@ -10,6 +10,7 @@ import {
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
 import EditorContainer from '../../services/EditorContainer';
+import { getDiagramsNetLangCode } from '../../util/locale-utils';
 
 class DrawioModal extends React.PureComponent {
 
@@ -125,7 +126,7 @@ class DrawioModal extends React.PureComponent {
     // refs: https://desk.draw.io/support/solutions/articles/16000042546-what-url-parameters-are-supported-
     url.searchParams.append('spin', 1);
     url.searchParams.append('embed', 1);
-    url.searchParams.append('lang', i18next.language);
+    url.searchParams.append('lang', getDiagramsNetLangCode(i18next.language));
     url.searchParams.append('ui', 'atlas');
     url.searchParams.append('configure', 1);
 

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

@@ -309,6 +309,7 @@ export default class Editor extends AbstractEditor {
                       // eslint-disable-next-line arrow-body-style
                       <CodeMirrorEditor
                         ref={(c) => { this.cmEditor = c }}
+                        indentSize={editorContainer.state.indentSize}
                         editorOptions={editorContainer.state.editorOptions}
                         onPasteFiles={this.pasteFilesHandler}
                         onDragEnter={this.dragEnterHandler}

+ 39 - 1
src/client/js/components/PageEditor/OptionsSelector.jsx

@@ -44,6 +44,7 @@ class OptionsSelector extends React.Component {
       emacs: 'Emacs',
       sublime: 'Sublime Text',
     };
+    this.typicalIndentSizes = [2, 4];
 
     this.onChangeTheme = this.onChangeTheme.bind(this);
     this.onChangeKeymapMode = this.onChangeKeymapMode.bind(this);
@@ -51,6 +52,7 @@ class OptionsSelector extends React.Component {
     this.onClickRenderMathJaxInRealtime = this.onClickRenderMathJaxInRealtime.bind(this);
     this.onClickMarkdownTableAutoFormatting = this.onClickMarkdownTableAutoFormatting.bind(this);
     this.onToggleConfigurationDropdown = this.onToggleConfigurationDropdown.bind(this);
+    this.onChangeIndentSize = this.onChangeIndentSize.bind(this);
   }
 
   onChangeTheme(newValue) {
@@ -113,6 +115,11 @@ class OptionsSelector extends React.Component {
     this.setState({ isCddMenuOpened: !this.state.isCddMenuOpened });
   }
 
+  onChangeIndentSize(newValue) {
+    const { editorContainer } = this.props;
+    editorContainer.setState({ indentSize: newValue });
+  }
+
   renderThemeSelector() {
     const { editorContainer } = this.props;
 
@@ -279,11 +286,42 @@ class OptionsSelector extends React.Component {
     );
   }
 
+  renderIndentSizeSelector() {
+    const { appContainer, editorContainer } = this.props;
+    const menuItems = this.typicalIndentSizes.map((indent) => {
+      return <button key={indent} className="dropdown-item" type="button" onClick={() => this.onChangeIndentSize(indent)}>{indent}</button>;
+    });
+    return (
+      <div className="input-group flex-nowrap">
+        <div className="input-group-prepend">
+          <span className="input-group-text" id="igt-indent">Indent</span>
+        </div>
+        <div className="input-group-append dropup">
+          <button
+            type="button"
+            className="btn btn-outline-secondary dropdown-toggle"
+            data-toggle="dropdown"
+            aria-haspopup="true"
+            aria-expanded="false"
+            aria-describedby="igt-indent"
+            disabled={appContainer.config.isIndentSizeForced}
+          >
+            {editorContainer.state.indentSize}
+          </button>
+          <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
+            {menuItems}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
   render() {
     return (
       <div className="d-flex flex-row">
         <span>{this.renderThemeSelector()}</span>
-        <span className="ml-2 ml-sm-4">{this.renderKeymapModeSelector()}</span>
+        <span className="d-none d-sm-block ml-2 ml-sm-4">{this.renderKeymapModeSelector()}</span>
+        <span className="ml-2 ml-sm-4">{this.renderIndentSizeSelector()}</span>
         <span className="ml-2 ml-sm-4">{this.renderConfigurationDropdown()}</span>
       </div>
     );

+ 2 - 6
src/client/js/components/PageHistory.jsx

@@ -2,7 +2,6 @@ import React, { useCallback } from 'react';
 import PropTypes from 'prop-types';
 import loggerFactory from '@alias/logger';
 
-import { withTranslation } from 'react-i18next';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '../util/apiNotification';
 
@@ -17,7 +16,7 @@ import RevisionComparerContainer from '../services/RevisionComparerContainer';
 const logger = loggerFactory('growi:PageHistory');
 
 function PageHistory(props) {
-  const { pageHistoryContainer, revisionComparerContainer, t } = props;
+  const { pageHistoryContainer, revisionComparerContainer } = props;
   const { getPreviousRevision } = pageHistoryContainer;
   const {
     activePage, totalPages, pagingLimit, revisions, diffOpened,
@@ -70,7 +69,6 @@ function PageHistory(props) {
 
   return (
     <div className="revision-history">
-      <h3 className="pb-3">{t('page_history.revision_list')}</h3>
       <PageRevisionTable
         pageHistoryContainer={pageHistoryContainer}
         revisionComparerContainer={revisionComparerContainer}
@@ -90,10 +88,8 @@ function PageHistory(props) {
 const RenderPageHistoryWrapper = withUnstatedContainers(withLoadingSppiner(PageHistory), [PageHistroyContainer, RevisionComparerContainer]);
 
 PageHistory.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
   pageHistoryContainer: PropTypes.instanceOf(PageHistroyContainer).isRequired,
   revisionComparerContainer: PropTypes.instanceOf(RevisionComparerContainer).isRequired,
 };
 
-export default withTranslation()(RenderPageHistoryWrapper);
+export default RenderPageHistoryWrapper;

+ 13 - 3
src/client/js/components/PageHistory/PageRevisionTable.jsx

@@ -18,7 +18,7 @@ class PageRevisionTable extends React.Component {
    */
   renderRow(revision, previousRevision, hasDiff, isContiguousNodiff) {
     const { revisionComparerContainer, t } = this.props;
-    const { latestRevision } = this.props.pageHistoryContainer.state;
+    const { latestRevision, oldestRevision } = this.props.pageHistoryContainer.state;
     const revisionId = revision._id;
     const revisionDiffOpened = this.props.diffOpened[revisionId] || false;
     const { sourceRevision, targetRevision } = revisionComparerContainer.state;
@@ -48,10 +48,19 @@ class PageRevisionTable extends React.Component {
             {hasDiff && (
               <div className="ml-md-3 mt-auto">
                 <div className="btn-group">
-                  <button type="button" className="btn btn-outline-secondary btn-sm" onClick={handleCompareLatestRevisionButton}>
+                  <button
+                    type="button"
+                    className="btn btn-outline-secondary btn-sm"
+                    onClick={handleCompareLatestRevisionButton}
+                  >
                     {t('page_history.compare_latest')}
                   </button>
-                  <button type="button" className="btn btn-outline-secondary btn-sm" onClick={handleComparePreviousRevisionButton}>
+                  <button
+                    type="button"
+                    className="btn btn-outline-secondary btn-sm"
+                    onClick={handleComparePreviousRevisionButton}
+                    disabled={revision === oldestRevision}
+                  >
                     {t('page_history.compare_previous')}
                   </button>
                 </div>
@@ -117,6 +126,7 @@ class PageRevisionTable extends React.Component {
         previousRevision = revision; // if it is the first revision, show full text as diff text
       }
 
+
       const hasDiff = revision.hasDiffToPrev !== false; // set 'true' if undefined for backward compatibility
       const isContiguousNodiff = !hasDiff && !hasDiffPrev;
 

+ 1 - 1
src/client/js/components/PageHistory/RevisionDiff.jsx

@@ -63,7 +63,7 @@ class RevisionDiff extends React.Component {
             </div>
           </div>
         </div>
-        <div className="revision-history-diff" dangerouslySetInnerHTML={diffView} />
+        <div className="revision-history-diff pb-1" dangerouslySetInnerHTML={diffView} />
       </>
     );
   }

+ 20 - 10
src/client/js/components/RevisionComparer/RevisionComparer.jsx

@@ -56,7 +56,12 @@ const RevisionComparer = (props) => {
   };
 
   const { sourceRevision, targetRevision } = revisionComparerContainer.state;
-  const showDiff = (sourceRevision && targetRevision);
+
+  if (sourceRevision == null || targetRevision == null) {
+    return null;
+  }
+
+  const isNodiff = sourceRevision._id === targetRevision._id;
 
   return (
     <div className="revision-compare">
@@ -73,7 +78,7 @@ const RevisionComparer = (props) => {
           >
             <i className="ti-clipboard"></i>
           </DropdownToggle>
-          <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: null } }}>
+          <DropdownMenu positionFixed right modifiers={{ preventOverflow: { boundariesElement: null } }}>
             {/* Page path URL */}
             <CopyToClipboard text={pagePathUrl()}>
               <DropdownItem className="px-3">
@@ -85,14 +90,19 @@ const RevisionComparer = (props) => {
         </Dropdown>
       </div>
 
-      <div className="revision-compare-outer">
-        { showDiff && (
-          <RevisionDiff
-            revisionDiffOpened
-            previousRevision={sourceRevision}
-            currentRevision={targetRevision}
-          />
-        )}
+      <div className={`revision-compare-container ${isNodiff ? 'nodiff' : ''}`}>
+        { isNodiff
+          ? (
+            <span className="h3 text-muted">{t('No diff')}</span>
+          )
+          : (
+            <RevisionDiff
+              revisionDiffOpened
+              previousRevision={sourceRevision}
+              currentRevision={targetRevision}
+            />
+          )
+        }
       </div>
     </div>
   );

+ 75 - 27
src/client/js/components/Sidebar/CustomSidebar.jsx

@@ -1,45 +1,93 @@
-import React from 'react';
-// import PropTypes from 'prop-types';
+import React, {
+  useState, useCallback, useEffect,
+} from 'react';
+import PropTypes from 'prop-types';
 
-import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
+import RevisionRenderer from '../Page/RevisionRenderer';
 
-class CustomSidebar extends React.Component {
+const logger = loggerFactory('growi:cli:CustomSidebar');
 
-  static propTypes = {
-  };
 
-  state = {
-  };
+const SidebarNotFound = () => {
+  return (
+    <div className="grw-sidebar-content-header h5 text-center p-3">
+      <a href="/Sidebar#edit">
+        <i className="icon-magic-wand"></i> Create <strong>/Sidebar</strong> page
+      </a>
+    </div>
+  );
+};
 
-  renderHeaderWordmark() {
-    return <h3>Custom Sidebar</h3>;
-  }
+const CustomSidebar = (props) => {
 
-  render() {
-    return (
-      <>
-        <div className="grw-sidebar-content-header p-3 d-flex">
-          <h3 className="mb-0">Custom Sidebar</h3>
-          <button type="button" className="btn btn-sm btn-outline-secondary ml-auto" onClick={this.reloadData}>
-            <i className="icon icon-reload"></i>
-          </button>
-        </div>
-        <div className="grw-sidebar-content-header p-3">
-          (TBD) Under implementation
+  const { appContainer } = props;
+  const { apiGet } = appContainer;
+
+  const [isMounted, setMounted] = useState(false);
+  const [markdown, setMarkdown] = useState();
+
+  const growiRenderer = appContainer.getRenderer('sidebar');
+
+  // TODO: refactor with SWR
+  const fetchDataAndRenderHtml = useCallback(async() => {
+    let page = null;
+    try {
+      const result = await apiGet('/pages.get', { path: '/Sidebar' });
+      page = result.page;
+    }
+    catch (e) {
+      logger.warn(e.message);
+      return;
+    }
+    finally {
+      setMounted(true);
+    }
+
+    setMarkdown(page.revision.body);
+  }, [apiGet]);
+
+  useEffect(() => {
+    fetchDataAndRenderHtml();
+  }, [fetchDataAndRenderHtml]);
+
+  return (
+    <>
+      <div className="grw-sidebar-content-header p-3 d-flex">
+        <h3 className="mb-0">
+          Custom Sidebar
+          <a className="h6 ml-2" href="/Sidebar"><i className="icon-pencil"></i></a>
+        </h3>
+        <button type="button" className="btn btn-sm btn-outline-secondary ml-auto" onClick={fetchDataAndRenderHtml}>
+          <i className="icon icon-reload"></i>
+        </button>
+      </div>
+      { isMounted && markdown == null && <SidebarNotFound /> }
+      {/* eslint-disable-next-line react/no-danger */}
+      { markdown != null && (
+        <div className="p-3">
+          <RevisionRenderer
+            growiRenderer={growiRenderer}
+            markdown={markdown}
+            additionalClassName="grw-custom-sidebar-content"
+          />
         </div>
-      </>
-    );
+      ) }
+    </>
+  );
 
-  }
+};
 
-}
+CustomSidebar.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
 
 /**
  * Wrapper component for using unstated
  */
 const CustomSidebarWrapper = withUnstatedContainers(CustomSidebar, [AppContainer]);
 
-export default withTranslation()(CustomSidebarWrapper);
+export default CustomSidebarWrapper;

+ 1 - 1
src/client/js/components/Sidebar/SidebarNav.jsx

@@ -23,7 +23,7 @@ class SidebarNav extends React.Component {
       onItemSelected(contentsId);
     }
 
-    navigationContainer.setState({ sidebarContentsId: contentsId });
+    navigationContainer.selectSidebarContents(contentsId);
   }
 
   PrimaryItem = ({ id, label, iconName }) => {

+ 20 - 4
src/client/js/components/StaffCredit/StaffCredit.jsx

@@ -4,7 +4,8 @@ import loggerFactory from '@alias/logger';
 import {
   Modal, ModalBody,
 } from 'reactstrap';
-import contributors from './Contributor';
+import AppContainer from '../../services/AppContainer';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 /**
  * Page staff credit component
@@ -17,13 +18,14 @@ import contributors from './Contributor';
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:cli:StaffCredit');
 
-export default class StaffCredit extends React.Component {
+class StaffCredit extends React.Component {
 
   constructor(props) {
 
     super(props);
     this.state = {
       isShown: true,
+      contributors: null,
     };
     this.deleteCredit = this.deleteCredit.bind(this);
   }
@@ -57,7 +59,7 @@ export default class StaffCredit extends React.Component {
 
   renderContributors() {
     if (this.state.isShown) {
-      const credit = contributors.map((contributor) => {
+      const credit = this.state.contributors.map((contributor) => {
         // construct members elements
         const memberGroups = contributor.memberGroups.map((memberGroup, idx) => {
           return this.renderMembers(memberGroup, `${contributor.sectionName}-group${idx}`);
@@ -83,7 +85,11 @@ export default class StaffCredit extends React.Component {
     return null;
   }
 
-  componentDidMount() {
+  async componentDidMount() {
+    const res = await this.props.appContainer.apiv3Get('/staffs');
+    const contributors = res.data.contributors;
+    this.setState({ contributors });
+
     setTimeout(() => {
       // px / sec
       const scrollSpeed = 200;
@@ -103,6 +109,10 @@ export default class StaffCredit extends React.Component {
   render() {
     const { onClosed } = this.props;
 
+    if (this.state.contributors === null) {
+      return <></>;
+    }
+
     return (
       <Modal
         isOpen={this.state.isShown}
@@ -123,6 +133,12 @@ export default class StaffCredit extends React.Component {
   }
 
 }
+
+const StaffCreditWrapper = withUnstatedContainers(StaffCredit, [AppContainer]);
+
 StaffCredit.propTypes = {
   onClosed: PropTypes.func,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 };
+
+export default StaffCreditWrapper;

+ 2 - 2
src/client/js/components/TableOfContents.jsx

@@ -33,8 +33,8 @@ const TableOfContents = (props) => {
     const containerComputedStyle = getComputedStyle(containerElem);
     const containerPaddingTop = parseFloat(containerComputedStyle['padding-top']);
 
-    // get smaller bottom line of window height - .system-version height) and containerTop
-    let bottom = Math.min(window.innerHeight - 20, parentBottom);
+    // get smaller bottom line of window height - the height of ContentLinkButtons and .system-version height) and containerTop
+    let bottom = Math.min(window.innerHeight - 41 - 20, parentBottom);
 
     if (isUserPage) {
       // raise the bottom line by the height and margin-top of UserContentLinks

+ 22 - 0
src/client/js/services/AdminMarkDownContainer.js

@@ -18,6 +18,8 @@ export default class AdminMarkDownContainer extends Container {
       // set dummy value tile for using suspense
       isEnabledLinebreaks: this.dummyIsEnabledLinebreaks,
       isEnabledLinebreaksInComments: false,
+      adminPreferredIndentSize: 4,
+      isIndentSizeForced: false,
       pageBreakSeparator: 1,
       pageBreakCustomSeparator: '',
       isEnabledXss: false,
@@ -27,6 +29,7 @@ export default class AdminMarkDownContainer extends Container {
     };
 
     this.switchEnableXss = this.switchEnableXss.bind(this);
+    this.setAdminPreferredIndentSize = this.setAdminPreferredIndentSize.bind(this);
   }
 
   /**
@@ -46,6 +49,8 @@ export default class AdminMarkDownContainer extends Container {
     this.setState({
       isEnabledLinebreaks: markdownParams.isEnabledLinebreaks,
       isEnabledLinebreaksInComments: markdownParams.isEnabledLinebreaksInComments,
+      adminPreferredIndentSize: markdownParams.adminPreferredIndentSize,
+      isIndentSizeForced: markdownParams.isIndentSizeForced,
       pageBreakSeparator: markdownParams.pageBreakSeparator,
       pageBreakCustomSeparator: markdownParams.pageBreakCustomSeparator || '',
       isEnabledXss: markdownParams.isEnabledXss,
@@ -55,6 +60,10 @@ export default class AdminMarkDownContainer extends Container {
     });
   }
 
+  setAdminPreferredIndentSize(adminPreferredIndentSize) {
+    this.setState({ adminPreferredIndentSize });
+  }
+
   /**
    * Switch PageBreakSeparator
    */
@@ -92,6 +101,19 @@ export default class AdminMarkDownContainer extends Container {
     return response;
   }
 
+  /**
+   * Update
+   */
+  async updateIndentSetting() {
+
+    const response = await this.appContainer.apiv3.put('/markdown-setting/indent', {
+      adminPreferredIndentSize: this.state.adminPreferredIndentSize,
+      isIndentSizeForced: this.state.isIndentSizeForced,
+    });
+
+    return response;
+  }
+
   /**
    * Update Xss Setting
    */

+ 1 - 7
src/client/js/services/AdminUserGroupDetailContainer.js

@@ -161,13 +161,7 @@ export default class AdminAdminUserGroupDetailContainer extends Container {
     // do not add users for ducaplicate
     if (res.data.userGroupRelation == null) { return }
 
-    const { userGroupRelation } = res.data;
-
-    this.setState((prevState) => {
-      return {
-        userGroupRelations: [...prevState.userGroupRelations, userGroupRelation],
-      };
-    });
+    this.init();
   }
 
   /**

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

@@ -35,6 +35,7 @@ export default class EditorContainer extends Container {
 
       editorOptions: {},
       previewOptions: {},
+      indentSize: this.appContainer.config.adminPreferredIndentSize || 4,
     };
 
     this.isSetBeforeunloadEventHandler = false;

+ 12 - 2
src/client/js/services/NavigationContainer.js

@@ -31,7 +31,7 @@ export default class NavigationContainer extends Container {
       isDrawerMode: null,
       isDrawerOpened: false,
 
-      sidebarContentsId: 'recent',
+      sidebarContentsId: localStorage.sidebarContentsId || 'recent',
 
       isScrollTop: true,
 
@@ -109,6 +109,7 @@ export default class NavigationContainer extends Container {
       $('body').removeClass('on-edit');
       $('body').removeClass('builtin-editor');
       $('body').removeClass('hackmd');
+      $('body').removeClass('pathname-sidebar');
       window.history.replaceState(null, '', window.location.pathname);
     }
 
@@ -116,6 +117,10 @@ export default class NavigationContainer extends Container {
       $('body').addClass('on-edit');
       $('body').addClass('builtin-editor');
       $('body').removeClass('hackmd');
+      // editing /Sidebar
+      if (window.location.pathname === '/Sidebar') {
+        $('body').addClass('pathname-sidebar');
+      }
       window.location.hash = '#edit';
     }
 
@@ -123,8 +128,8 @@ export default class NavigationContainer extends Container {
       $('body').addClass('on-edit');
       $('body').addClass('hackmd');
       $('body').removeClass('builtin-editor');
+      $('body').removeClass('pathname-sidebar');
       window.location.hash = '#hackmd';
-
     }
 
     this.updateDrawerMode({ ...this.state, editorMode }); // generate newest state object
@@ -185,6 +190,11 @@ export default class NavigationContainer extends Container {
     this.setState({ isDrawerMode, isDrawerOpened });
   }
 
+  selectSidebarContents(contentsId) {
+    window.localStorage.setItem('sidebarContentsId', contentsId);
+    this.setState({ sidebarContentsId: contentsId });
+  }
+
   openPageCreateModal() {
     if (this.appContainer.currentUser == null) {
       logger.warn('Please login or signup to create a new page.');

+ 5 - 0
src/client/js/services/PageHistoryContainer.js

@@ -25,6 +25,7 @@ export default class PageHistoryContainer extends Container {
       // set dummy rivisions for using suspense
       revisions: this.dummyRevisions,
       latestRevision: this.dummyRevisions,
+      oldestRevision: this.dummyRevisions,
       diffOpened: {},
 
       totalPages: 0,
@@ -100,6 +101,10 @@ export default class PageHistoryContainer extends Container {
       this.setState({ latestRevision: rev[0] });
     }
 
+    if (selectedPage === res.data.totalPages) {
+      this.setState({ oldestRevision: rev[lastId] });
+    }
+
     // load 0, and last default
     if (rev[0]) {
       this.fetchPageRevisionBody(rev[0]);

+ 13 - 0
src/client/js/util/locale-utils.js

@@ -0,0 +1,13 @@
+// https://docs.google.com/spreadsheets/d/1FoYdyEraEQuWofzbYCDPKN7EdKgS_2ZrsDrOA8scgwQ
+const DIAGRAMS_NET_LANG_MAP = {
+  ja_JP: 'ja',
+  zh_CN: 'zh',
+};
+
+const getDiagramsNetLangCode = (lang) => {
+  return DIAGRAMS_NET_LANG_MAP[lang];
+};
+
+module.exports = {
+  getDiagramsNetLangCode,
+};

+ 14 - 0
src/client/styles/scss/_on-edit.scss

@@ -256,6 +256,20 @@ body.on-edit {
 
   // .builtin-editor .tab-pane#edit
 
+  // editing /Sidebar
+  &.pathname-sidebar {
+    .page-editor-preview-body {
+      width: 320px;
+      padding-top: 0;
+      margin-right: auto;
+      margin-left: auto;
+
+      .wiki {
+        @extend %grw-custom-sidebar-content;
+      }
+    }
+  }
+
   &.hackmd {
     .hackmd-preinit,
     #iframe-hackmd-container > iframe {

+ 7 - 3
src/client/styles/scss/_page-history.scss

@@ -47,10 +47,14 @@
 }
 
 .revision-compare {
-  .revision-compare-outer {
+  .revision-compare-container {
     min-height: 100px;
-    max-height: 250px;
-    overflow: auto;
+
+    &.nodiff {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
   }
   .d2h-file-header {
     display: none;

+ 50 - 0
src/client/styles/scss/_sidebar-wiki.scss

@@ -0,0 +1,50 @@
+%grw-custom-sidebar-content {
+  h1,
+  h2,
+  h3,
+  h4,
+  h5,
+  h6 {
+    margin-top: 1em;
+    margin-bottom: 0.4em;
+
+    &:first-child {
+      margin-top: 0;
+    }
+  }
+
+  h1 {
+    padding-top: 0.2em;
+    font-size: 1.4em;
+    line-height: 1em;
+  }
+  h2 {
+    padding-top: 0.2em;
+    font-size: 1.2em;
+    line-height: 1em;
+  }
+  h3 {
+    font-size: 1.1em;
+  }
+  h4 {
+    font-size: 1.05em;
+  }
+  h5 {
+    font-size: 1.03em;
+  }
+
+  ul,
+  ol {
+    padding-left: 20px;
+    margin: 10px 0;
+  }
+
+  .page-list .page-list-ul {
+    padding-left: 0;
+    margin: 0;
+  }
+}
+
+.grw-custom-sidebar-content.wiki {
+  @extend %grw-custom-sidebar-content;
+}

+ 1 - 0
src/client/styles/scss/style-app.scss

@@ -59,6 +59,7 @@
 @import 'search';
 @import 'shortcuts';
 @import 'sidebar';
+@import 'sidebar-wiki';
 @import 'subnav';
 @import 'tag';
 @import 'toc';

+ 10 - 0
src/client/styles/scss/theme/_apply-colors.scss

@@ -439,6 +439,16 @@ body.on-edit {
   }
 }
 
+/*
+ * Preview for editing /Sidebar
+ */
+body.pathname-sidebar {
+  .page-editor-preview-body {
+    color: $color-sidebar-context;
+    background-color: $bgcolor-sidebar-context;
+  }
+}
+
 /*
  * GROWI Grid Edit Modal
  */

+ 0 - 49
src/server/models/bookmark.js

@@ -43,18 +43,6 @@ module.exports = function(crowi) {
     return idToCountMap;
   };
 
-  bookmarkSchema.statics.populatePage = async function(bookmarks) {
-    const Bookmark = this;
-    const User = crowi.model('User');
-
-    return Bookmark.populate(bookmarks, {
-      path: 'page',
-      populate: {
-        path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS,
-      },
-    });
-  };
-
   // bookmark チェック用
   bookmarkSchema.statics.findByPageIdAndUserId = function(pageId, userId) {
     const Bookmark = this;
@@ -70,43 +58,6 @@ module.exports = function(crowi) {
     }));
   };
 
-  /**
-   * option = {
-   *  limit: Int
-   *  offset: Int
-   *  requestUser: User
-   * }
-   */
-  bookmarkSchema.statics.findByUser = function(user, option) {
-    const Bookmark = this;
-    const requestUser = option.requestUser || null;
-
-    debug('Finding bookmark with requesting user:', requestUser);
-
-    const limit = option.limit || 50;
-    const offset = option.offset || 0;
-    const populatePage = option.populatePage || false;
-
-    return new Promise(((resolve, reject) => {
-      Bookmark
-        .find({ user: user._id })
-        .sort({ createdAt: -1 })
-        .skip(offset)
-        .limit(limit)
-        .exec((err, bookmarks) => {
-          if (err) {
-            return reject(err);
-          }
-
-          if (!populatePage) {
-            return resolve(bookmarks);
-          }
-
-          return Bookmark.populatePage(bookmarks, requestUser).then(resolve);
-        });
-    }));
-  };
-
   bookmarkSchema.statics.add = async function(page, user) {
     const Bookmark = this;
 

+ 4 - 0
src/server/models/config.js

@@ -137,6 +137,8 @@ module.exports = function(crowi) {
       'markdown:xss:attrWhiteList': [],
       'markdown:isEnabledLinebreaks': false,
       'markdown:isEnabledLinebreaksInComments': true,
+      'markdown:adminPreferredIndentSize': 4,
+      'markdown:isIndentSizeForced': false,
       'markdown:presentation:pageBreakSeparator': 1,
       'markdown:presentation:pageBreakCustomSeparator': undefined,
     };
@@ -196,6 +198,8 @@ module.exports = function(crowi) {
       themeType: crowi.configManager.getConfig('crowi', 'customize:theme'),
       isEnabledLinebreaks: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
       isEnabledLinebreaksInComments: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
+      adminPreferredIndentSize: crowi.configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
+      isIndentSizeForced: crowi.configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
       pageBreakSeparator: crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakSeparator'),
       pageBreakCustomSeparator: crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakCustomSeparator'),
       isEnabledXssPrevention: crowi.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),

+ 7 - 44
src/server/models/page.js

@@ -46,21 +46,7 @@ const pageSchema = new mongoose.Schema({
   liker: [{ type: ObjectId, ref: 'User' }],
   seenUsers: [{ type: ObjectId, ref: 'User' }],
   commentCount: { type: Number, default: 0 },
-  extended: {
-    type: String,
-    default: '{}',
-    get(data) {
-      try {
-        return JSON.parse(data);
-      }
-      catch (e) {
-        return data;
-      }
-    },
-    set(data) {
-      return JSON.stringify(data);
-    },
-  },
+  slackChannels: { type: String },
   pageIdOnHackmd: String,
   revisionHackmdSynced: { type: ObjectId, ref: 'Revision' }, // the revision that is synced to HackMD
   hasDraftOnHackmd: { type: Boolean }, // set true if revision and revisionHackmdSynced are same but HackMD document has modified
@@ -426,33 +412,10 @@ module.exports = function(crowi) {
     return saved;
   };
 
-  pageSchema.methods.getSlackChannel = function() {
-    const extended = this.get('extended');
-    if (!extended) {
-      return '';
-    }
-
-    return extended.slack || '';
-  };
-
-  pageSchema.methods.updateSlackChannel = function(slackChannel) {
-    const extended = this.extended;
-    extended.slack = slackChannel;
-
-    return this.updateExtended(extended);
-  };
+  pageSchema.methods.updateSlackChannels = function(slackChannels) {
+    this.slackChannels = slackChannels;
 
-  pageSchema.methods.updateExtended = function(extended) {
-    const page = this;
-    page.extended = extended;
-    return new Promise(((resolve, reject) => {
-      return page.save((err, doc) => {
-        if (err) {
-          return reject(err);
-        }
-        return resolve(doc);
-      });
-    }));
+    return this.save();
   };
 
   pageSchema.methods.initLatestRevisionField = async function(revisionId) {
@@ -466,7 +429,7 @@ module.exports = function(crowi) {
     validateCrowi();
 
     const User = crowi.model('User');
-    return populateDataToShowRevision(this, User.USER_PUBLIC_FIELDS)
+    return populateDataToShowRevision(this, User.USER_FIELDS_EXCEPT_CONFIDENTIAL)
       .execPopulate();
   };
 
@@ -785,7 +748,7 @@ module.exports = function(crowi) {
     const totalCount = await builder.query.exec('count');
 
     // find
-    builder.populateDataToList(User.USER_PUBLIC_FIELDS);
+    builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
     const pages = await builder.query.exec('find');
 
     const result = {
@@ -828,7 +791,7 @@ module.exports = function(crowi) {
 
     // find
     builder.addConditionToPagenate(opt.offset, opt.limit, sortOpt);
-    builder.populateDataToList(User.USER_PUBLIC_FIELDS);
+    builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
     const pages = await builder.query.exec('find');
 
     const result = {

+ 0 - 57
src/server/models/revision.js

@@ -27,43 +27,6 @@ module.exports = function(crowi) {
   });
   revisionSchema.plugin(mongoosePaginate);
 
-  /*
-   * preparation for https://github.com/weseek/growi/issues/216
-   */
-  // // create a XSS Filter instance
-  // // TODO read options
-  // this.xss = new Xss(true);
-  // // prevent XSS when pre save
-  // revisionSchema.pre('save', function(next) {
-  //   this.body = xss.process(this.body);
-  //   next();
-  // });
-
-  revisionSchema.statics.findRevisions = function(ids) {
-    const Revision = this;
-
-
-    const User = crowi.model('User');
-
-    if (!Array.isArray(ids)) {
-      return Promise.reject(new Error('The argument was not Array.'));
-    }
-
-    return new Promise(((resolve, reject) => {
-      Revision
-        .find({ _id: { $in: ids } })
-        .sort({ createdAt: -1 })
-        .populate('author', User.USER_PUBLIC_FIELDS)
-        .exec((err, revisions) => {
-          if (err) {
-            return reject(err);
-          }
-
-          return resolve(revisions);
-        });
-    }));
-  };
-
   revisionSchema.statics.findRevisionIdList = function(path) {
     return this.find({ path })
       .select('_id author createdAt hasDiffToPrev')
@@ -71,26 +34,6 @@ module.exports = function(crowi) {
       .exec();
   };
 
-  revisionSchema.statics.findRevisionList = function(path, options) {
-    const Revision = this;
-
-
-    const User = crowi.model('User');
-
-    return new Promise(((resolve, reject) => {
-      Revision.find({ path })
-        .sort({ createdAt: -1 })
-        .populate('author', User.USER_PUBLIC_FIELDS)
-        .exec((err, data) => {
-          if (err) {
-            return reject(err);
-          }
-
-          return resolve(data);
-        });
-    }));
-  };
-
   revisionSchema.statics.updateRevisionListByPath = function(path, updateData, options) {
     const Revision = this;
 

+ 1 - 5
src/server/models/user-group-relation.js

@@ -85,14 +85,10 @@ class UserGroupRelation {
    * @memberof UserGroupRelation
    */
   static findAllRelationForUserGroup(userGroup) {
-    const User = UserGroupRelation.crowi.model('User');
     debug('findAllRelationForUserGroup is called', userGroup);
     return this
       .find({ relatedGroup: userGroup })
-      .populate({
-        path: 'relatedUser',
-        select: User.USER_PUBLIC_FIELDS,
-      })
+      .populate('relatedUser')
       .exec();
   }
 

+ 2 - 2
src/server/models/user-group.js

@@ -90,7 +90,7 @@ class UserGroup {
   }
 
   // グループの完全削除
-  static async removeCompletelyById(deleteGroupId, action, transferToUserGroupId) {
+  static async removeCompletelyById(deleteGroupId, action, transferToUserGroupId, user) {
     const UserGroupRelation = mongoose.model('UserGroupRelation');
 
     const groupToDelete = await this.findById(deleteGroupId);
@@ -101,7 +101,7 @@ class UserGroup {
 
     await Promise.all([
       UserGroupRelation.removeAllByUserGroup(deletedGroup),
-      UserGroup.crowi.pageService.handlePrivatePagesForDeletedGroup(deletedGroup, action, transferToUserGroupId),
+      UserGroup.crowi.pageService.handlePrivatePagesForDeletedGroup(deletedGroup, action, transferToUserGroupId, user),
     ]);
 
     return deletedGroup;

+ 2 - 2
src/server/models/user.js

@@ -21,7 +21,7 @@ module.exports = function(crowi) {
   const STATUS_SUSPENDED = 3;
   const STATUS_DELETED = 4;
   const STATUS_INVITED = 5;
-  const USER_PUBLIC_FIELDS = '_id image isEmailPublished isGravatarEnabled googleId name username email introduction'
+  const USER_FIELDS_EXCEPT_CONFIDENTIAL = '_id image isEmailPublished isGravatarEnabled googleId name username email introduction'
   + ' status lang createdAt lastLoginAt admin imageUrlCached';
 
   const PAGE_ITEMS = 50;
@@ -724,7 +724,7 @@ module.exports = function(crowi) {
   userSchema.statics.STATUS_SUSPENDED = STATUS_SUSPENDED;
   userSchema.statics.STATUS_DELETED = STATUS_DELETED;
   userSchema.statics.STATUS_INVITED = STATUS_INVITED;
-  userSchema.statics.USER_PUBLIC_FIELDS = USER_PUBLIC_FIELDS;
+  userSchema.statics.USER_FIELDS_EXCEPT_CONFIDENTIAL = USER_FIELDS_EXCEPT_CONFIDENTIAL;
   userSchema.statics.PAGE_ITEMS = PAGE_ITEMS;
 
   return mongoose.model('User', userSchema);

+ 4 - 5
src/server/routes/apiv3/attachment.js

@@ -6,6 +6,7 @@ const express = require('express');
 
 const router = express.Router();
 const { query } = require('express-validator');
+const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
@@ -69,15 +70,13 @@ module.exports = (crowi) => {
         {
           limit,
           offset,
-          populate: {
-            path: 'creator',
-            select: User.USER_PUBLIC_FIELDS,
-          },
+          populate: 'creator',
         },
       );
+
       paginateResult.docs.forEach((doc) => {
         if (doc.creator != null && doc.creator instanceof User) {
-          doc.creator = doc.creator.toObject();
+          doc.creator = serializeUserSecurely(doc.creator);
         }
       });
 

+ 8 - 1
src/server/routes/apiv3/bookmarks.js

@@ -4,6 +4,7 @@ const logger = loggerFactory('growi:routes:apiv3:bookmarks'); // eslint-disable-
 
 const express = require('express');
 const { body, query, param } = require('express-validator');
+const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 const router = express.Router();
 
@@ -205,13 +206,19 @@ module.exports = (crowi) => {
             populate: {
               path: 'lastUpdateUser',
               model: 'User',
-              select: User.USER_PUBLIC_FIELDS,
             },
           },
           page,
           limit,
         },
       );
+
+      paginationResult.docs.forEach((doc) => {
+        if (doc.page.lastUpdateUser != null && doc.page.lastUpdateUser instanceof User) {
+          doc.page.lastUpdateUser = serializeUserSecurely(doc.page.lastUpdateUser);
+        }
+      });
+
       return res.apiv3({ paginationResult });
     }
     catch (err) {

+ 1 - 0
src/server/routes/apiv3/index.js

@@ -47,6 +47,7 @@ module.exports = (crowi) => {
   router.use('/attachment', require('./attachment')(crowi));
 
   router.use('/slack-bot', require('./slack-bot')(crowi));
+  router.use('/staffs', require('./staffs')(crowi));
 
   return router;
 };

+ 29 - 0
src/server/routes/apiv3/markdown-setting.js

@@ -15,6 +15,10 @@ const validator = {
     body('isEnabledLinebreaks').isBoolean(),
     body('isEnabledLinebreaksInComments').isBoolean(),
   ],
+  indent: [
+    body('adminPreferredIndentSize').isIn([2, 4]),
+    body('isIndentSizeForced').isBoolean(),
+  ],
   presentationSetting: [
     body('pageBreakSeparator').isInt().not().isEmpty(),
   ],
@@ -111,6 +115,8 @@ module.exports = (crowi) => {
     const markdownParams = {
       isEnabledLinebreaks: await crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
       isEnabledLinebreaksInComments: await crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
+      adminPreferredIndentSize: await crowi.configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
+      isIndentSizeForced: await crowi.configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
       pageBreakSeparator: await crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakSeparator'),
       pageBreakCustomSeparator: await crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakCustomSeparator'),
       isEnabledXss: await crowi.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
@@ -168,6 +174,29 @@ module.exports = (crowi) => {
 
   });
 
+  router.put('/indent', loginRequiredStrictly, adminRequired, csrf, validator.indent, apiV3FormValidator, async(req, res) => {
+
+    const requestIndentParams = {
+      'markdown:adminPreferredIndentSize': req.body.adminPreferredIndentSize,
+      'markdown:isIndentSizeForced': req.body.isIndentSizeForced,
+    };
+
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('markdown', requestIndentParams);
+      const indentParams = {
+        adminPreferredIndentSize: await crowi.configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
+        isIndentSizeForced: await crowi.configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
+      };
+      return res.apiv3({ indentParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating indent';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-indent-failed'));
+    }
+
+  });
+
   /**
    * @swagger
    *

+ 17 - 2
src/server/routes/apiv3/pages.js

@@ -112,6 +112,7 @@ module.exports = (crowi) => {
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const Page = crowi.model('Page');
+  const User = crowi.model('User');
   const PageTagRelation = crowi.model('PageTagRelation');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
 
@@ -120,6 +121,7 @@ module.exports = (crowi) => {
 
   const { serializePageSecurely } = require('../../models/serializers/page-serializer');
   const { serializeRevisionSecurely } = require('../../models/serializers/revision-serializer');
+  const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
   const validator = {
     createPage: [
@@ -252,7 +254,7 @@ module.exports = (crowi) => {
     // user notification
     if (isSlackEnabled) {
       try {
-        const results = await userNotificationService.fire(createdPage, req.user, slackChannels, 'create', false);
+        const results = await userNotificationService.fire(createdPage, req.user, slackChannels, 'create');
         results.forEach((result) => {
           if (result.status === 'rejected') {
             logger.error('Create user notification failed', result.reason);
@@ -299,6 +301,12 @@ module.exports = (crowi) => {
         result.pages.pop();
       }
 
+      result.pages.forEach((page) => {
+        if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
+          page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
+        }
+      });
+
       return res.apiv3(result);
     }
     catch (err) {
@@ -436,7 +444,7 @@ module.exports = (crowi) => {
     const options = { socketClientId };
 
     try {
-      const pages = await crowi.pageService.deletePageRecursivelyCompletely({ path: '/trash' }, req.user, options);
+      const pages = await crowi.pageService.deleteCompletelyDescendantsWithStream({ path: '/trash' }, req.user, options);
       return res.apiv3({ pages });
     }
     catch (err) {
@@ -470,6 +478,13 @@ module.exports = (crowi) => {
 
     try {
       const result = await Page.findListWithDescendants(path, req.user, queryOptions);
+
+      result.pages.forEach((page) => {
+        if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
+          page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
+        }
+      });
+
       return res.apiv3(result);
     }
     catch (err) {

+ 14 - 5
src/server/routes/apiv3/revisions.js

@@ -5,6 +5,7 @@ const logger = loggerFactory('growi:routes:apiv3:pages');
 const express = require('express');
 
 const { query, param } = require('express-validator');
+const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 const router = express.Router();
@@ -128,13 +129,16 @@ module.exports = (crowi) => {
           page: selectedPage,
           limit,
           sort: { createdAt: -1 },
-          populate: {
-            path: 'author',
-            select: User.USER_PUBLIC_FIELDS,
-          },
+          populate: 'author',
         },
       );
 
+      paginateResult.docs.forEach((doc) => {
+        if (doc.author != null && doc.author instanceof User) {
+          doc.author = serializeUserSecurely(doc.author);
+        }
+      });
+
       return res.apiv3(paginateResult);
     }
     catch (err) {
@@ -181,7 +185,12 @@ module.exports = (crowi) => {
     }
 
     try {
-      const revision = await Revision.findById(revisionId).populate('author', User.USER_PUBLIC_FIELDS);
+      const revision = await Revision.findById(revisionId).populate('author');
+
+      if (revision.author != null && revision.author instanceof User) {
+        revision.author = serializeUserSecurely(revision.author);
+      }
+
       return res.apiv3({ revision });
     }
     catch (err) {

+ 52 - 0
src/server/routes/apiv3/staffs.js

@@ -0,0 +1,52 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:staffs'); // eslint-disable-line no-unused-vars
+
+const express = require('express');
+
+const axios = require('axios');
+
+const router = express.Router();
+const { isAfter, addHours } = require('date-fns');
+
+const contributors = require('../../../../resource/Contributor');
+
+let expiredAt;
+const contributorsCache = contributors;
+let gcContributors;
+
+// Sorting contributors by this method
+const compareFunction = function(a, b) {
+  return a.order - b.order;
+};
+
+module.exports = (crowi) => {
+
+  router.get('/', async(req, res) => {
+    const now = new Date();
+    const growiCloudUri = await crowi.configManager.getConfig('crowi', 'app:growiCloudUri');
+
+    if (growiCloudUri != null && (expiredAt == null || isAfter(now, expiredAt))) {
+      const url = new URL('_api/staffCredit', growiCloudUri);
+      try {
+        const gcContributorsRes = await axios.get(url.toString());
+        if (gcContributors == null) {
+          gcContributors = gcContributorsRes.data;
+          // merging contributors
+          contributorsCache.push(gcContributors);
+        }
+        // Change the order of section
+        contributorsCache.sort(compareFunction);
+        // caching 'expiredAt' for 1 hour
+        expiredAt = addHours(now, 1);
+      }
+      catch (err) {
+        logger.warn('Getting GROWI.cloud staffcredit is failed');
+      }
+    }
+    return res.apiv3({ contributors: contributorsCache });
+  });
+
+  return router;
+
+};

+ 2 - 1
src/server/routes/apiv3/user-group-relation.js

@@ -4,6 +4,7 @@ const logger = loggerFactory('growi:routes:apiv3:user-group-relation'); // eslin
 
 const express = require('express');
 
+const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 const router = express.Router();
@@ -52,7 +53,7 @@ module.exports = (crowi) => {
       await Promise.all(userGroups.map(async(userGroup) => {
         const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
         userGroupRelationsObj[userGroup._id] = userGroupRelations.map((userGroupRelation) => {
-          return userGroupRelation.relatedUser;
+          return serializeUserSecurely(userGroupRelation.relatedUser);
         });
       }));
 

+ 23 - 17
src/server/routes/apiv3/user-group.js

@@ -13,6 +13,7 @@ const mongoose = require('mongoose');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
+const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 const { toPagingLimit, toPagingOffset } = require('../../util/express-validator/sanitizer');
 
 const validator = {};
@@ -174,7 +175,7 @@ module.exports = (crowi) => {
     const { actionName, transferToUserGroupId } = req.query;
 
     try {
-      const userGroup = await UserGroup.removeCompletelyById(deleteGroupId, actionName, transferToUserGroupId);
+      const userGroup = await UserGroup.removeCompletelyById(deleteGroupId, actionName, transferToUserGroupId, req.user);
 
       return res.apiv3({ userGroup });
     }
@@ -288,7 +289,7 @@ module.exports = (crowi) => {
       const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
 
       const users = userGroupRelations.map((userGroupRelation) => {
-        return userGroupRelation.relatedUser;
+        return serializeUserSecurely(userGroupRelation.relatedUser);
       });
 
       return res.apiv3({ users });
@@ -344,7 +345,14 @@ module.exports = (crowi) => {
       const userGroup = await UserGroup.findById(id);
       const users = await UserGroupRelation.findUserByNotRelatedGroup(userGroup, queryOptions);
 
-      return res.apiv3({ users });
+      // return email only this api
+      const serializedUsers = users.map((user) => {
+        const { email } = user;
+        const serializedUser = serializeUserSecurely(user);
+        serializedUser.email = email;
+        return serializedUser;
+      });
+      return res.apiv3({ users: serializedUsers });
     }
     catch (err) {
       const msg = `Error occurred in fetching unrelated users for group: ${id}`;
@@ -411,9 +419,9 @@ module.exports = (crowi) => {
       }
 
       const userGroupRelation = await UserGroupRelation.createRelation(userGroup, user);
-      await userGroupRelation.populate('relatedUser', User.USER_PUBLIC_FIELDS).execPopulate();
+      const serializedUser = serializeUserSecurely(user);
 
-      return res.apiv3({ user, userGroup, userGroupRelation });
+      return res.apiv3({ user: serializedUser, userGroup, userGroupRelation });
     }
     catch (err) {
       const msg = `Error occurred in adding the user "${username}" to group "${id}"`;
@@ -471,14 +479,10 @@ module.exports = (crowi) => {
         User.findUserByUsername(username),
       ]);
 
-      const userGroupRelation = await UserGroupRelation.findOne({ relatedUser: new ObjectId(user._id), relatedGroup: new ObjectId(userGroup._id) });
-      if (userGroupRelation == null) {
-        throw new Error(`Group "${id}" does not exist or user "${username}" does not belong to group "${id}"`);
-      }
-
-      await userGroupRelation.remove();
+      const userGroupRelation = await UserGroupRelation.findOneAndDelete({ relatedUser: new ObjectId(user._id), relatedGroup: new ObjectId(userGroup._id) });
+      const serializedUser = serializeUserSecurely(user);
 
-      return res.apiv3({ user, userGroup, userGroupRelation });
+      return res.apiv3({ user: serializedUser, userGroup, userGroupRelation });
     }
     catch (err) {
       const msg = `Error occurred in removing the user "${username}" from group "${id}"`;
@@ -584,16 +588,18 @@ module.exports = (crowi) => {
       }, {
         offset,
         limit,
-        populate: {
-          path: 'lastUpdateUser',
-          select: User.USER_PUBLIC_FIELDS,
-        },
+        populate: 'lastUpdateUser',
       });
 
       const current = offset / limit + 1;
 
+      const pages = docs.map((doc) => {
+        doc.lastUpdateUser = serializeUserSecurely(doc.lastUpdateUser);
+        return doc;
+      });
+
       // TODO: create a common moudule for paginated response
-      return res.apiv3({ total: totalDocs, current, pages: docs });
+      return res.apiv3({ total: totalDocs, current, pages });
     }
     catch (err) {
       const msg = `Error occurred in fetching pages for group: ${id}`;

+ 1 - 1
src/server/routes/attachment.js

@@ -188,7 +188,7 @@ module.exports = function(crowi, app) {
     const user = req.user;
     const isAccessible = await isAccessibleByViewer(user, attachment);
     if (!isAccessible) {
-      return res.json(ApiResponse.error(`Forbidden to access to the attachment '${attachment.id}'`));
+      return res.json(ApiResponse.error(`Forbidden to access to the attachment '${attachment.id}'. This attachment might belong to other pages.`));
     }
 
     // add headers before evaluating 'req.fresh'

+ 23 - 27
src/server/routes/comment.js

@@ -4,6 +4,8 @@
  *    name: Comments
  */
 
+const { serializeUserSecurely } = require('../models/serializers/user-serializer');
+
 /**
  * @swagger
  *
@@ -48,7 +50,10 @@ module.exports = function(crowi, app) {
   const Page = crowi.model('Page');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const ApiResponse = require('../util/apiResponse');
+
   const globalNotificationService = crowi.getGlobalNotificationService();
+  const userNotificationService = crowi.getUserNotificationService();
+
   const { body } = require('express-validator');
   const mongoose = require('mongoose');
   const ObjectId = mongoose.Types.ObjectId;
@@ -127,9 +132,12 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error(err));
     }
 
-    const comments = await fetcher.populate(
-      { path: 'creator', select: User.USER_PUBLIC_FIELDS },
-    );
+    const comments = await fetcher.populate('creator');
+    comments.forEach((comment) => {
+      if (comment.creator != null && comment.creator instanceof User) {
+        comment.creator = serializeUserSecurely(comment.creator);
+      }
+    });
 
     res.json(ApiResponse.success({ comments }));
   };
@@ -231,11 +239,6 @@ module.exports = function(crowi, app) {
     let createdComment;
     try {
       createdComment = await Comment.create(pageId, req.user._id, revisionId, comment, position, isMarkdown, replyTo);
-
-      await Comment.populate(createdComment, [
-        { path: 'creator', model: 'User', select: User.USER_PUBLIC_FIELDS },
-      ]);
-
     }
     catch (err) {
       logger.error(err);
@@ -253,8 +256,6 @@ module.exports = function(crowi, app) {
 
     res.json(ApiResponse.success({ comment: createdComment }));
 
-    const path = page.path;
-
     // global notification
     try {
       await globalNotificationService.fire(GlobalNotificationSetting.EVENT.COMMENT, page, req.user, {
@@ -265,26 +266,21 @@ module.exports = function(crowi, app) {
       logger.error('Comment notification failed', err);
     }
 
-
     // slack notification
     if (slackNotificationForm.isSlackEnabled) {
-      const user = await User.findUserByUsername(req.user.username);
-      const channelsStr = slackNotificationForm.slackChannels || null;
-
-      page.updateSlackChannel(channelsStr).catch((err) => {
-        logger.error('Error occured in updating slack channels: ', err);
-      });
-
-      const channels = channelsStr != null ? channelsStr.split(',') : [null];
-
-      const promises = channels.map((chan) => {
-        return crowi.slack.postComment(createdComment, user, chan, path);
-      });
-
-      Promise.all(promises)
-        .catch((err) => {
-          logger.error('Error occured in sending slack notification: ', err);
+      const { slackChannels } = slackNotificationForm;
+
+      try {
+        const results = await userNotificationService.fire(page, req.user, slackChannels, 'comment', {}, createdComment);
+        results.forEach((result) => {
+          if (result.status === 'rejected') {
+            logger.error('Create user notification failed', result.reason);
+          }
         });
+      }
+      catch (err) {
+        logger.error('Create user notification failed', err);
+      }
     }
   };
 

+ 0 - 1
src/server/routes/index.js

@@ -133,7 +133,6 @@ module.exports = function(crowi, app) {
 
   app.get('/_api/check_username'           , user.api.checkUsername);
   app.get('/_api/me/user-group-relations'  , accessTokenParser , loginRequiredStrictly , me.api.userGroupRelations);
-  app.get('/_api/user/bookmarks'           , loginRequired , user.api.bookmarks);
 
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
   app.get('/_api/users.list'          , accessTokenParser , loginRequired , user.api.list);

+ 34 - 56
src/server/routes/page.js

@@ -1,5 +1,6 @@
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 const { serializeRevisionSecurely } = require('../models/serializers/revision-serializer');
+const { serializeUserSecurely } = require('../models/serializers/user-serializer');
 
 /**
  * @swagger
@@ -134,18 +135,17 @@ module.exports = function(crowi, app) {
 
   const Page = crowi.model('Page');
   const User = crowi.model('User');
-  const Bookmark = crowi.model('Bookmark');
   const PageTagRelation = crowi.model('PageTagRelation');
-  const UpdatePost = crowi.model('UpdatePost');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const ShareLink = crowi.model('ShareLink');
 
   const ApiResponse = require('../util/apiResponse');
   const getToday = require('../util/getToday');
 
-  const { slackNotificationService, configManager, xssService } = crowi;
+  const { configManager, xssService } = crowi;
   const interceptorManager = crowi.getInterceptorManager();
   const globalNotificationService = crowi.getGlobalNotificationService();
+  const userNotificationService = crowi.getUserNotificationService();
 
   const XssOption = require('../../lib/service/xss/xssOption');
   const Xss = require('../../lib/service/xss/index');
@@ -194,37 +194,6 @@ module.exports = function(crowi, app) {
     };
   }
 
-  // user notification
-  // TODO create '/service/user-notification' module
-  /**
-   *
-   * @param {Page} page
-   * @param {User} user
-   * @param {string} slackChannelsStr comma separated string. e.g. 'general,channel1,channel2'
-   * @param {boolean} updateOrCreate
-   * @param {string} previousRevision
-   */
-  async function notifyToSlackByUser(page, user, slackChannelsStr, updateOrCreate, previousRevision) {
-    await page.updateSlackChannel(slackChannelsStr)
-      .catch((err) => {
-        logger.error('Error occured in updating slack channels: ', err);
-      });
-
-
-    if (slackNotificationService.hasSlackConfig()) {
-      const slackChannels = slackChannelsStr != null ? slackChannelsStr.split(',') : [null];
-
-      const promises = slackChannels.map((chan) => {
-        return crowi.slack.postPage(page, user, chan, updateOrCreate, previousRevision);
-      });
-
-      Promise.all(promises)
-        .catch((err) => {
-          logger.error('Error occured in sending slack notification: ', err);
-        });
-    }
-  }
-
   function addRenderVarsForPage(renderVars, page) {
     renderVars.page = page;
     renderVars.revision = page.revision;
@@ -253,12 +222,11 @@ module.exports = function(crowi, app) {
     renderVars.revision = page.revision;
   }
 
-  async function addRenderVarsForUserPage(renderVars, page, requestUser) {
+  async function addRenderVarsForUserPage(renderVars, page) {
     const userData = await User.findUserByUsername(User.getUsernameByPath(page.path));
 
     if (userData != null) {
-      renderVars.pageUser = userData.toObject();
-      renderVars.bookmarkList = await Bookmark.findByUser(userData, { limit: 10, populatePage: true, requestUser });
+      renderVars.pageUser = serializeUserSecurely(userData);
     }
   }
 
@@ -268,10 +236,6 @@ module.exports = function(crowi, app) {
     renderVars.grantedGroupName = page.grantedGroup ? page.grantedGroup.name : null;
   }
 
-  async function addRenderVarsForSlack(renderVars, page) {
-    renderVars.slack = await getSlackChannels(page);
-  }
-
   async function addRenderVarsForDescendants(renderVars, path, requestUser, offset, limit, isRegExpEscapedFromPath) {
     const SEENER_THRESHOLD = 10;
 
@@ -349,7 +313,6 @@ module.exports = function(crowi, app) {
     portalPage = await portalPage.populateDataToShowRevision();
 
     addRenderVarsForPage(renderVars, portalPage);
-    await addRenderVarsForSlack(renderVars, portalPage);
 
     const sharelinksNumber = await ShareLink.countDocuments({ relatedPage: portalPage._id });
     renderVars.sharelinksNumber = sharelinksNumber;
@@ -399,7 +362,6 @@ module.exports = function(crowi, app) {
     addRenderVarsForPage(renderVars, page);
     addRenderVarsForScope(renderVars, page);
 
-    await addRenderVarsForSlack(renderVars, page);
     await addRenderVarsForDescendants(renderVars, path, req.user, offset, limit, true);
 
     const sharelinksNumber = await ShareLink.countDocuments({ relatedPage: page._id });
@@ -408,23 +370,13 @@ module.exports = function(crowi, app) {
     if (isUserPage(page.path)) {
       // change template
       view = 'layout-growi/user_page';
-      await addRenderVarsForUserPage(renderVars, page, req.user);
+      await addRenderVarsForUserPage(renderVars, page);
     }
 
     await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     return res.render(view, renderVars);
   }
 
-  const getSlackChannels = async(page) => {
-    if (page.extended.slack) {
-      return page.extended.slack;
-    }
-
-    const data = await UpdatePost.findSettingsByPath(page.path);
-    const channels = data.map((e) => { return e.channel }).join(', ');
-    return channels;
-  };
-
   actions.showTopPage = function(req, res) {
     return showTopPage(req, res);
   };
@@ -701,6 +653,12 @@ module.exports = function(crowi, app) {
         result.pages.pop();
       }
 
+      result.pages.forEach((page) => {
+        if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
+          page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
+        }
+      });
+
       return res.json(ApiResponse.success(result));
     }
     catch (err) {
@@ -769,7 +727,17 @@ module.exports = function(crowi, app) {
 
     // user notification
     if (isSlackEnabled) {
-      await notifyToSlackByUser(createdPage, req.user, slackChannels, 'create', false);
+      try {
+        const results = await userNotificationService.fire(createdPage, req.user, slackChannels, 'create');
+        results.forEach((result) => {
+          if (result.status === 'rejected') {
+            logger.error('Create user notification failed', result.reason);
+          }
+        });
+      }
+      catch (err) {
+        logger.error('Create user notification failed', err);
+      }
     }
   };
 
@@ -904,7 +872,17 @@ module.exports = function(crowi, app) {
 
     // user notification
     if (isSlackEnabled) {
-      await notifyToSlackByUser(page, req.user, slackChannels, 'update', previousRevision);
+      try {
+        const results = await userNotificationService.fire(page, req.user, slackChannels, 'update', { previousRevision });
+        results.forEach((result) => {
+          if (result.status === 'rejected') {
+            logger.error('Create user notification failed', result.reason);
+          }
+        });
+      }
+      catch (err) {
+        logger.error('Create user notification failed', err);
+      }
     }
   };
 

+ 6 - 0
src/server/routes/search.js

@@ -1,3 +1,5 @@
+const { serializeUserSecurely } = require('../models/serializers/user-serializer');
+
 /**
  * @swagger
  *
@@ -27,6 +29,7 @@
 module.exports = function(crowi, app) {
   // var debug = require('debug')('growi:routes:search')
   const Page = crowi.model('Page');
+  const User = crowi.model('User');
   const ApiResponse = require('../util/apiResponse');
   const ApiPaginate = require('../util/apiPaginate');
 
@@ -159,6 +162,9 @@ module.exports = function(crowi, app) {
       result.totalCount = findResult.totalCount;
       result.data = findResult.pages
         .map((page) => {
+          if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
+            page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
+          }
           page.bookmarkCount = (page._source && page._source.bookmark_count) || 0;
           return page;
         })

+ 0 - 11
src/server/routes/user.js

@@ -47,7 +47,6 @@
 
 module.exports = function(crowi, app) {
   const User = crowi.model('User');
-  const Bookmark = crowi.model('Bookmark');
   const ApiResponse = require('../util/apiResponse');
 
   const actions = {};
@@ -57,16 +56,6 @@ module.exports = function(crowi, app) {
 
   actions.api = api;
 
-  api.bookmarks = function(req, res) {
-    const options = {
-      skip: req.query.offset || 0,
-      limit: req.query.limit || 50,
-    };
-    Bookmark.findByUser(req.user, options, (err, bookmarks) => {
-      res.json(bookmarks);
-    });
-  };
-
   api.checkUsername = function(req, res) {
     const username = req.query.username;
 

+ 3 - 3
src/server/service/page.js

@@ -102,7 +102,7 @@ class PageService {
       const revisionId = new mongoose.Types.ObjectId();
 
       if (updateMetadata) {
-        unorderedBulkOp.find({ _id: page._id }).update([{ $set: { path: newPagePath, lastUpdateUser: user._id, updatedAt: { $toDate: Date.now() } } }]);
+        unorderedBulkOp.find({ _id: page._id }).update({ $set: { path: newPagePath, lastUpdateUser: user._id, updatedAt:  Date.now() } });
       }
       else {
         unorderedBulkOp.find({ _id: page._id }).update({ $set: { path: newPagePath } });
@@ -721,7 +721,7 @@ class PageService {
   }
 
 
-  async handlePrivatePagesForDeletedGroup(deletedGroup, action, transferToUserGroupId) {
+  async handlePrivatePagesForDeletedGroup(deletedGroup, action, transferToUserGroupId, user) {
     const Page = this.crowi.model('Page');
     const pages = await Page.find({ grantedGroup: deletedGroup });
 
@@ -732,7 +732,7 @@ class PageService {
         }));
         break;
       case 'delete':
-        return this.deleteMultiplePagesCompletely(pages);
+        return this.deleteMultipleCompletely(pages, user);
       case 'transfer':
         await Promise.all(pages.map((page) => {
           return Page.transferPageToGroup(page, transferToUserGroupId);

+ 1 - 1
src/server/service/search-delegator/elasticsearch.js

@@ -390,7 +390,7 @@ class ElasticsearchDelegator {
         { path: 'revision', model: 'Revision', select: 'body' },
       ])
       .lean()
-      .cursor({ batchSize: BULK_REINDEX_SIZE });
+      .cursor();
 
     let skipped = 0;
     const thinOutStream = new Transform({

+ 14 - 4
src/server/service/user-notification/index.js

@@ -19,13 +19,17 @@ class UserNotificationService {
    * @param {Page} page
    * @param {User} user
    * @param {string} slackChannelsStr comma separated string. e.g. 'general,channel1,channel2'
-   * @param {boolean} updateOrCreate
+   * @param {string} mode 'create' or 'update' or 'comment'
    * @param {string} previousRevision
+   * @param {Comment} comment
    */
-  async fire(page, user, slackChannelsStr, updateOrCreate, previousRevision) {
+  async fire(page, user, slackChannelsStr, mode, option, comment = {}) {
     const { slackNotificationService, slack } = this.crowi;
 
-    await page.updateSlackChannel(slackChannelsStr);
+    const opt = option || {};
+    const previousRevision = opt.previousRevision || '';
+
+    await page.updateSlackChannels(slackChannelsStr);
 
     if (!slackNotificationService.hasSlackConfig()) {
       throw new Error('slackNotificationService has not been set up');
@@ -35,7 +39,13 @@ class UserNotificationService {
     const slackChannels = toArrayFromCsv(slackChannelsStr);
 
     const promises = slackChannels.map(async(chan) => {
-      const res = await slack.postPage(page, user, chan, updateOrCreate, previousRevision);
+      let res;
+      if (mode === 'comment') {
+        res = await slack.postComment(comment, user, chan, page.path);
+      }
+      else {
+        res = await slack.postPage(page, user, chan, mode, previousRevision);
+      }
       if (res.status !== 'ok') {
         throw new Error(`fail to send slack notification to #${chan} channel`);
       }

+ 2 - 2
src/server/views/widget/page_content.html

@@ -18,7 +18,7 @@
   data-page-is-deletable="{% if isDeletablePage() %}true{% else %}false{% endif %}"
   data-page-is-not-creatable="false"
   data-page-is-able-to-delete-completely="{% if user.canDeleteCompletely(page.creator._id) %}true{% else %}false{% endif %}"
-  data-slack-channels="{{ slack|default('') }}"
+  data-slack-channels="{% if page %}{{ page.slackChannels }}{% endif %}"
   data-page-created-at="{% if page %}{{ page.createdAt|datetz('Y/m/d H:i:s') }}{% endif %}"
   data-page-creator="{% if page && page.creator %}{{ page.creator|json }}{% endif %}"
   data-page-last-update-username="{% if page && page.lastUpdateUser %}{{ page.lastUpdateUser.name }}{% endif %}"
@@ -36,7 +36,7 @@
 <div id="content-main" class="content-main d-flex"
   data-path="{{ encodeURI(path) }}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
-  data-slack-channels="{{ slack|default('') }}"
+  data-slack-channels="{% if page %}{{ page.slackChannels }}{% endif %}"
   data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
   data-page-has-children="{% if pages.length > 0 %}true{% else %}false{% endif %}"
   >

+ 4 - 23
src/test/models/page.test.js

@@ -93,10 +93,9 @@ describe('Page', () => {
         creator: testUser0,
       },
       {
-        path: '/page/for/extended',
+        path: '/page/child/without/parents',
         grant: Page.GRANT_PUBLIC,
         creator: testUser0,
-        extended: { hoge: 1 },
       },
       {
         path: '/grant/groupacl',
@@ -266,24 +265,6 @@ describe('Page', () => {
     });
   });
 
-  describe('Extended field', () => {
-    describe('Slack Channel.', () => {
-      test('should be empty', async() => {
-        const page = await Page.findOne({ path: '/page/for/extended' });
-        expect(page.extended.hoge).toEqual(1);
-        expect(page.getSlackChannel()).toEqual('');
-      });
-
-      test('set slack channel and should get it and should keep hoge ', async() => {
-        let page = await Page.findOne({ path: '/page/for/extended' });
-        await page.updateSlackChannel('slack-channel1');
-        page = await Page.findOne({ path: '/page/for/extended' });
-        expect(page.extended.hoge).toEqual(1);
-        expect(page.getSlackChannel()).toEqual('slack-channel1');
-      });
-    });
-  });
-
   describe('.findPage', () => {
     describe('findByIdAndViewer', () => {
       test('should find page (public)', async() => {
@@ -341,7 +322,7 @@ describe('Page', () => {
       expect(result.length).toEqual(1);
       // assert paths
       const pagePaths = result.map((page) => { return page.path });
-      expect(pagePaths).toContainEqual('/page/for/extended');
+      expect(pagePaths).toContainEqual('/page/child/without/parents');
     });
 
     test('can retrieve descendants of /page1', async() => {
@@ -370,7 +351,7 @@ describe('Page', () => {
       expect(result.length).toEqual(1);
       // assert paths
       const pagePaths = result.map((page) => { return page.path });
-      expect(pagePaths).toContainEqual('/page/for/extended');
+      expect(pagePaths).toContainEqual('/page/child/without/parents');
     });
 
     test('can retrieve only descendants of /page1', async() => {
@@ -398,7 +379,7 @@ describe('Page', () => {
       expect(result.length).toEqual(4);
       // assert paths
       const pagePaths = result.map((page) => { return page.path });
-      expect(pagePaths).toContainEqual('/page/for/extended');
+      expect(pagePaths).toContainEqual('/page/child/without/parents');
       expect(pagePaths).toContainEqual('/page1');
       expect(pagePaths).toContainEqual('/page1/child1');
       expect(pagePaths).toContainEqual('/page2');

+ 5 - 0
yarn.lock

@@ -5178,6 +5178,11 @@ detect-indent@^5.0.0:
   resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-5.0.0.tgz#3871cc0a6a002e8c3e5b3cf7f336264675f06b9d"
   integrity sha1-OHHMCmoALow+Wzz38zYmRnXwa50=
 
+detect-indent@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-6.0.0.tgz#0abd0f549f69fc6659a254fe96786186b6f528fd"
+  integrity sha512-oSyFlqaTHCItVRGK5RmrmjB+CmaMOW7IaNA/kdxqhoa6d17j/5ce9O9eWXmV/KEdRwqpQA+Vqe8a8Bsybu4YnA==
+
 detect-libc@^1.0.2:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"