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

Merge branch 'master' into feat/4328-display-growi-cloud-member-staffcredit

kntowd 5 лет назад
Родитель
Сommit
856446eb4d
31 измененных файлов с 443 добавлено и 579 удалено
  1. 11 3
      CHANGES.md
  2. 1 1
      package.json
  3. 10 6
      resource/locales/en_US/translation.json
  4. 10 6
      resource/locales/ja_JP/translation.json
  5. 10 6
      resource/locales/zh_CN/translation.json
  6. 2 2
      src/client/js/components/Page/DisplaySwitcher.jsx
  7. 3 1
      src/client/js/components/Page/DuplicatedAlert.jsx
  8. 3 1
      src/client/js/components/Page/RedirectedAlert.jsx
  9. 3 1
      src/client/js/components/Page/RenamedAlert.jsx
  10. 5 1
      src/client/js/components/PageAccessories.jsx
  11. 8 6
      src/client/js/components/PageAccessoriesModal.jsx
  12. 16 6
      src/client/js/components/PageAccessoriesModalControl.jsx
  13. 13 7
      src/client/js/components/PageHistory.jsx
  14. 0 159
      src/client/js/components/PageHistory/PageRevisionList.jsx
  15. 165 0
      src/client/js/components/PageHistory/PageRevisionTable.jsx
  16. 14 50
      src/client/js/components/PageHistory/Revision.jsx
  17. 32 3
      src/client/js/components/PageHistory/RevisionDiff.jsx
  18. 14 21
      src/client/js/components/RevisionComparer/RevisionComparer.jsx
  19. 0 67
      src/client/js/components/RevisionComparer/RevisionSelector.jsx
  20. 3 0
      src/client/js/services/EditorContainer.js
  21. 5 12
      src/client/js/services/PageHistoryContainer.js
  22. 0 17
      src/client/js/services/RevisionComparerContainer.js
  23. 4 0
      src/client/styles/scss/_page-accessories-modal.scss
  24. 46 61
      src/client/styles/scss/_page-history.scss
  25. 4 41
      src/server/models/page.js
  26. 2 2
      src/server/routes/apiv3/pages.js
  27. 15 19
      src/server/routes/comment.js
  28. 24 51
      src/server/routes/page.js
  29. 14 4
      src/server/service/user-notification/index.js
  30. 2 2
      src/server/views/widget/page_content.html
  31. 4 23
      src/test/models/page.test.js

+ 11 - 3
CHANGES.md

@@ -1,9 +1,17 @@
 # CHANGES
 
-## v4.2.9-RC
+## v4.2.10-RC
 
-* Improvement: Add batchSize option for flow rate limit about stream 
-* Improvement: Even if slash is at the end, siteUrl is saved with it removed.
+* 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: 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
 

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.2.9-RC",
+  "version": "4.2.10-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 10 - 6
resource/locales/en_US/translation.json

@@ -76,7 +76,6 @@
   "Go to this version": "View this version",
   "View diff": "View diff",
   "No diff": "No diff",
-  "Shrink versions that have no diffs": "Shrink versions that have no diffs",
   "User ID": "User ID",
   "User Information": "User information",
   "Basic Info": "Basic info",
@@ -166,6 +165,7 @@
   },
   "not_found_page": {
     "Create Page": "Create Page",
+    "page_not_exist": "This page does not exist.",
     "page_not_exist_alert": "This page does not exist. Please create a new page."
   },
   "custom_navigation": {
@@ -292,9 +292,12 @@
   "page_page": {
     "notice": {
       "version": "This is not the current version.",
-      "moved": "This page was moved from <code>%s</code>",
-      "redirected": "You are redirected from <code>%s</code>",
-      "duplicated": "This page was duplicated from <code>%s</code>",
+      "moved": "This page was moved from",
+      "moved_period": ".",
+      "redirected": "You are redirected from",
+      "redirected_period": ".",
+      "duplicated": "This page was duplicated from",
+      "duplicated_period": ".",
       "unlinked": "Redirect pages to this page have been deleted.",
       "restricted": "Access to this page is restricted",
       "stale": "More than {{count}} year has passed since last update.",
@@ -328,8 +331,9 @@
     "revision": "version",
     "comparing_source": "Source",
     "comparing_target": "Target",
-    "comparing_revisions": "Comparing versions",
-    "comparing_with_latest": "Always compare with the latest version"
+    "comparing_revisions": "Comparing the difference",
+    "compare_latest":"Compare latest revision",
+    "compare_previous":"Compare previous revision"
   },
   "modal_rename": {
     "label": {

+ 10 - 6
resource/locales/ja_JP/translation.json

@@ -77,7 +77,6 @@
   "Go to this version": "このバージョンを見る",
   "View diff": "差分を表示",
   "No diff": "差分なし",
-  "Shrink versions that have no diffs": "差分のないバージョンをコンパクトに表示する",
   "User ID": "ユーザーID",
   "User Information": "ユーザー情報",
   "Basic Info": "ユーザーの基本情報",
@@ -169,6 +168,7 @@
   },
   "not_found_page": {
     "Create Page": "ページを作成する",
+    "page_not_exist": "このページは存在しません。",
     "page_not_exist_alert": "このページは存在しません。新たに作成する必要があります。"
   },
   "custom_navigation": {
@@ -295,9 +295,12 @@
   "page_page": {
     "notice": {
       "version": "これは現在の版ではありません。",
-      "moved": "このページは <code>%s</code> から移動しました。",
-      "redirected": "リダイレクト元 >> <code>%s</code>",
-      "duplicated": "このページは <code>%s</code> から複製されました。",
+      "moved": "このページは",
+      "moved_period":"から移動しました。",
+      "redirected": "リダイレクト元 >>",
+      "redirected_period":"",
+      "duplicated": "このページは",
+      "duplicated_period": "から複製されました。",
       "unlinked": "このページへのリダイレクトは削除されました。",
       "restricted": "このページの閲覧は制限されています",
       "stale": "このページは最終更新日から{{count}}年以上が経過しています。",
@@ -330,8 +333,9 @@
     "revision": "バージョン",
     "comparing_source": "ソース",
     "comparing_target": "ターゲット",
-    "comparing_revisions": "比較",
-    "comparing_with_latest": "常に最新バージョンと比較する"
+    "comparing_revisions": "差分を比較する",
+    "compare_latest":"最新と比較",
+    "compare_previous":"1つ前のバージョンと比較"
   },
   "modal_rename": {
     "label": {

+ 10 - 6
resource/locales/zh_CN/translation.json

@@ -80,7 +80,6 @@
 	"Go to this version": "查看此版本",
 	"View diff": "查看差异",
 	"No diff": "无差异",
-	"Shrink versions that have no diffs": "收缩没有差异的版本",
 	"User ID": "用户ID",
 	"Home": "首页",
 	"My Drafts": "My Drafts",
@@ -167,6 +166,7 @@
   },
   "not_found_page": {
     "Create Page": "创建页面",
+    "page_not_exist": "该页面不存在",
     "page_not_exist_alert": "该页面不存在,请创建一个新页面"
   },
   "custom_navigation": {
@@ -274,9 +274,12 @@
 	"page_page": {
 		"notice": {
 			"version": "这不是当前版本。",
-			"moved": "此页已从<code>%s</code>",
-			"redirected": "您将从<code>%s</code>",
-			"duplicated": "此页来自<code>%s</code>",
+			"moved": "此页已从",
+      "moved_period": "",
+			"redirected": "您将从",
+      "redirected_period": "",
+			"duplicated": "此页来自",
+      "duplicated_period": "",
 			"unlinked": "将网页重定向到此网页已被删除。",
 			"restricted": "访问此页受到限制",
 			"stale": "自上次更新以来,已超过{{count}年。",
@@ -309,8 +312,9 @@
     "revision": "版本",
     "comparing_source": "源头",
     "comparing_target": "目标",
-    "comparing_revisions": "比较版本",
-    "comparing_with_latest": "一定要与最新版本进行比较"
+    "comparing_revisions": "比较两者的区别",
+    "compare_latest":"比較最新版本",
+    "compare_previous":"比較以前的版本"
   },
 	"modal_rename": {
 		"label": {

+ 2 - 2
src/client/js/components/Page/DisplaySwitcher.jsx

@@ -19,7 +19,7 @@ const DisplaySwitcher = (props) => {
     navigationContainer, pageContainer,
   } = props;
   const { editorMode } = navigationContainer.state;
-  const { pageUser } = pageContainer.state;
+  const { isPageExist, pageUser } = pageContainer.state;
 
   return (
     <>
@@ -30,7 +30,7 @@ const DisplaySwitcher = (props) => {
             <div className="grw-side-contents-container">
               <div className="grw-side-contents-sticky-container">
                 <div className="border-bottom pb-1">
-                  <PageAccessories />
+                  <PageAccessories isNotFoundPage={!isPageExist} />
                 </div>
 
                 <div className="d-none d-lg-block">

+ 3 - 1
src/client/js/components/Page/DuplicatedAlert.jsx

@@ -5,11 +5,13 @@ import { withTranslation } from 'react-i18next';
 
 const DuplicatedAlert = (props) => {
   const { t } = props;
+  const urlParams = new URLSearchParams(window.location.search);
+  const fromPath = urlParams.get('duplicated');
 
   return (
     <div className="alert alert-success py-3 px-4">
       <strong>
-        { t('Duplicated') }:{t('page_page.notice.duplicated')}
+        { t('Duplicated') }: {t('page_page.notice.duplicated')} <code>{fromPath}</code> {t('page_page.notice.duplicated_period')}
       </strong>
     </div>
   );

+ 3 - 1
src/client/js/components/Page/RedirectedAlert.jsx

@@ -5,10 +5,12 @@ import { withTranslation } from 'react-i18next';
 
 const RedirectedAlert = (props) => {
   const { t } = props;
+  const urlParams = new URLSearchParams(window.location.search);
+  const fromPath = urlParams.get('redirectFrom');
 
   return (
     <>
-      <strong>{ t('Redirected') }:</strong>{ t('page_page.notice.redirected')}
+      <strong>{ t('Redirected') }:</strong> { t('page_page.notice.redirected')} <code>{fromPath}</code> {t('page_page.notice.redirected_period')}
     </>
   );
 };

+ 3 - 1
src/client/js/components/Page/RenamedAlert.jsx

@@ -5,10 +5,12 @@ import { withTranslation } from 'react-i18next';
 
 const RenamedAlert = (props) => {
   const { t } = props;
+  const urlParams = new URLSearchParams(window.location.search);
+  const fromPath = urlParams.get('renamedFrom');
 
   return (
     <>
-      <strong>{ t('Moved') }:</strong>{t('page_page.notice.moved')}
+      <strong>{ t('Moved') }:</strong> {t('page_page.notice.moved')} <code>{fromPath}</code> {t('page_page.notice.moved_period')}
     </>
   );
 };

+ 5 - 1
src/client/js/components/PageAccessories.jsx

@@ -9,7 +9,7 @@ import AppContainer from '../services/AppContainer';
 import PageAccessoriesContainer from '../services/PageAccessoriesContainer';
 
 const PageAccessories = (props) => {
-  const { appContainer, pageAccessoriesContainer } = props;
+  const { appContainer, pageAccessoriesContainer, isNotFoundPage } = props;
   const { isGuestUser, isSharedUser } = appContainer;
 
   return (
@@ -17,10 +17,12 @@ const PageAccessories = (props) => {
       <PageAccessoriesModalControl
         isGuestUser={isGuestUser}
         isSharedUser={isSharedUser}
+        isNotFoundPage={isNotFoundPage}
       />
       <PageAccessoriesModal
         isGuestUser={isGuestUser}
         isSharedUser={isSharedUser}
+        isNotFoundPage={isNotFoundPage}
         isOpen={pageAccessoriesContainer.state.isPageAccessoriesModalShown}
         onClose={pageAccessoriesContainer.closePageAccessoriesModal}
       />
@@ -35,6 +37,8 @@ const PageAccessoriesWrapper = withUnstatedContainers(PageAccessories, [AppConta
 PageAccessories.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
+
+  isNotFoundPage: PropTypes.bool.isRequired,
 };
 
 export default PageAccessoriesWrapper;

+ 8 - 6
src/client/js/components/PageAccessoriesModal.jsx

@@ -24,7 +24,7 @@ import ExpandOrContractButton from './ExpandOrContractButton';
 
 const PageAccessoriesModal = (props) => {
   const {
-    t, pageAccessoriesContainer, onClose, isGuestUser, isSharedUser,
+    t, pageAccessoriesContainer, onClose, isGuestUser, isSharedUser, isNotFoundPage,
   } = props;
   const { switchActiveTab } = pageAccessoriesContainer;
   const { activeTab, activeComponents } = pageAccessoriesContainer.state;
@@ -48,21 +48,22 @@ const PageAccessoriesModal = (props) => {
         Icon: HistoryIcon,
         i18n: t('History'),
         index: 2,
-        isLinkEnabled: v => !isGuestUser && !isSharedUser,
+        isLinkEnabled: v => !isGuestUser && !isSharedUser && !isNotFoundPage,
       },
       attachment: {
         Icon: AttachmentIcon,
         i18n: t('attachment_data'),
         index: 3,
+        isLinkEnabled: v => !isNotFoundPage,
       },
       shareLink: {
         Icon: ShareLinkIcon,
         i18n: t('share_links.share_link_management'),
         index: 4,
-        isLinkEnabled: v => !isGuestUser && !isSharedUser,
+        isLinkEnabled: v => !isGuestUser && !isSharedUser && !isNotFoundPage,
       },
     };
-  }, [t, isGuestUser, isSharedUser]);
+  }, [t, isGuestUser, isSharedUser, isNotFoundPage]);
 
   const closeModalHandler = useCallback(() => {
     if (onClose == null) {
@@ -109,10 +110,10 @@ const PageAccessoriesModal = (props) => {
             hideBorderBottom
           />
         </ModalHeader>
-        <ModalBody className="overflow-auto grw-modal-body-style p-0">
+        <ModalBody className="overflow-auto grw-modal-body-style">
           {/* Do not use CustomTabContent because of performance problem:
               the 'navTabMapping[tabId].Content' for PageAccessoriesModal depends on activeComponents */}
-          <TabContent activeTab={activeTab} className="p-5">
+          <TabContent activeTab={activeTab}>
             <TabPane tabId="pagelist">
               {activeComponents.has('pagelist') && <PageList />}
             </TabPane>
@@ -149,6 +150,7 @@ PageAccessoriesModal.propTypes = {
   pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
   isGuestUser: PropTypes.bool.isRequired,
   isSharedUser: PropTypes.bool.isRequired,
+  isNotFoundPage: PropTypes.bool.isRequired,
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func,
 };

+ 16 - 6
src/client/js/components/PageAccessoriesModalControl.jsx

@@ -17,7 +17,7 @@ import { withUnstatedContainers } from './UnstatedUtils';
 
 const PageAccessoriesModalControl = (props) => {
   const {
-    t, pageAccessoriesContainer, isGuestUser, isSharedUser,
+    t, pageAccessoriesContainer, isGuestUser, isSharedUser, isNotFoundPage,
   } = props;
 
   const accessoriesBtnList = useMemo(() => {
@@ -37,27 +37,36 @@ const PageAccessoriesModalControl = (props) => {
       {
         name: 'pageHistory',
         Icon: <HistoryIcon />,
-        disabled: isGuestUser || isSharedUser,
+        disabled: isGuestUser || isSharedUser || isNotFoundPage,
         i18n: t('History'),
       },
       {
         name: 'attachment',
         Icon: <AttachmentIcon />,
-        disabled: false,
+        disabled: isNotFoundPage,
         i18n: t('attachment_data'),
       },
       {
         name: 'shareLink',
         Icon: <ShareLinkIcon />,
-        disabled: isGuestUser || isSharedUser,
+        disabled: isGuestUser || isSharedUser || isNotFoundPage,
         i18n: t('share_links.share_link_management'),
       },
     ];
-  }, [t, isGuestUser, isSharedUser]);
+  }, [t, isGuestUser, isSharedUser, isNotFoundPage]);
 
   return (
     <div className="grw-page-accessories-control d-flex flex-nowrap align-items-center justify-content-end justify-content-lg-between">
       {accessoriesBtnList.map((accessory) => {
+
+        let tooltipMessage;
+        if (accessory.disabled) {
+          tooltipMessage = isNotFoundPage ? t('not_found_page.page_not_exist') : t('Not available for guest');
+        }
+        else {
+          tooltipMessage = accessory.i18n;
+        }
+
         return (
           <Fragment key={accessory.name}>
             <div id={`shareLink-btn-wrapper-for-tooltip-for-${accessory.name}`}>
@@ -70,7 +79,7 @@ const PageAccessoriesModalControl = (props) => {
               </button>
             </div>
             <UncontrolledTooltip placement="top" target={`shareLink-btn-wrapper-for-tooltip-for-${accessory.name}`} fade={false}>
-              {accessory.disabled ? t('Not available for guest') : accessory.i18n}
+              {tooltipMessage}
             </UncontrolledTooltip>
           </Fragment>
         );
@@ -94,6 +103,7 @@ PageAccessoriesModalControl.propTypes = {
 
   isGuestUser: PropTypes.bool.isRequired,
   isSharedUser: PropTypes.bool.isRequired,
+  isNotFoundPage: PropTypes.bool.isRequired,
 };
 
 export default withTranslation()(PageAccessoriesModalControlWrapper);

+ 13 - 7
src/client/js/components/PageHistory.jsx

@@ -2,11 +2,12 @@ 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';
 
 import { withLoadingSppiner } from './SuspenseUtils';
-import PageRevisionList from './PageHistory/PageRevisionList';
+import PageRevisionTable from './PageHistory/PageRevisionTable';
 
 import PageHistroyContainer from '../services/PageHistoryContainer';
 import PaginationWrapper from './PaginationWrapper';
@@ -16,8 +17,8 @@ import RevisionComparerContainer from '../services/RevisionComparerContainer';
 const logger = loggerFactory('growi:PageHistory');
 
 function PageHistory(props) {
-  const { pageHistoryContainer } = props;
-  const { getPreviousRevision, onDiffOpenClicked } = pageHistoryContainer;
+  const { pageHistoryContainer, revisionComparerContainer, t } = props;
+  const { getPreviousRevision } = pageHistoryContainer;
   const {
     activePage, totalPages, pagingLimit, revisions, diffOpened,
   } = pageHistoryContainer.state;
@@ -69,14 +70,17 @@ function PageHistory(props) {
 
   return (
     <div className="revision-history">
-      <PageRevisionList
+      <h3 className="pb-3">{t('page_history.revision_list')}</h3>
+      <PageRevisionTable
         pageHistoryContainer={pageHistoryContainer}
+        revisionComparerContainer={revisionComparerContainer}
         revisions={revisions}
         diffOpened={diffOpened}
         getPreviousRevision={getPreviousRevision}
-        onDiffOpenClicked={onDiffOpenClicked}
       />
-      {pager()}
+      <div className="my-3">
+        {pager()}
+      </div>
       <RevisionComparer />
     </div>
   );
@@ -86,8 +90,10 @@ 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 RenderPageHistoryWrapper;
+export default withTranslation()(RenderPageHistoryWrapper);

+ 0 - 159
src/client/js/components/PageHistory/PageRevisionList.jsx

@@ -1,159 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-import PageHistroyContainer from '../../services/PageHistoryContainer';
-
-import Revision from './Revision';
-import RevisionDiff from './RevisionDiff';
-import RevisionSelector from '../RevisionComparer/RevisionSelector';
-
-class PageRevisionList extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isCompactNodiffRevisions: true,
-    };
-
-    this.cbCompactizeChangeHandler = this.cbCompactizeChangeHandler.bind(this);
-  }
-
-  cbCompactizeChangeHandler() {
-    this.setState({ isCompactNodiffRevisions: !this.state.isCompactNodiffRevisions });
-  }
-
-  /**
-   * render a row (Revision component and RevisionDiff component)
-   * @param {Revison} revision
-   * @param {Revision} previousRevision
-   * @param {boolean} hasDiff whether revision has difference to previousRevision
-   * @param {boolean} isContiguousNodiff true if the current 'hasDiff' and one of previous row is both false
-   */
-  renderRow(revision, previousRevision, hasDiff, isContiguousNodiff) {
-    const { latestRevision } = this.props.pageHistoryContainer.state;
-    const revisionId = revision._id;
-    const revisionDiffOpened = this.props.diffOpened[revisionId] || false;
-
-    const classNames = ['revision-history-outer', 'row', 'no-gutters'];
-    if (isContiguousNodiff) {
-      classNames.push('revision-history-outer-contiguous-nodiff');
-    }
-
-    return (
-      <div className={classNames.join(' ')} key={`revision-history-${revisionId}`}>
-        <div className="col-8" key={`revision-history-top-${revisionId}`}>
-          <Revision
-            t={this.props.t}
-            revision={revision}
-            isLatestRevision={revision === latestRevision}
-            revisionDiffOpened={revisionDiffOpened}
-            hasDiff={hasDiff}
-            isCompactNodiffRevisions={this.state.isCompactNodiffRevisions}
-            onDiffOpenClicked={this.props.onDiffOpenClicked}
-            key={`revision-history-rev-${revisionId}`}
-          />
-        </div>
-        <div className="col-4 align-self-center">
-          <RevisionSelector
-            revision={revision}
-            hasDiff={hasDiff}
-            key={`revision-compare-target-selector-${revisionId}`}
-          />
-        </div>
-        { hasDiff
-          && (
-          <RevisionDiff
-            revisionDiffOpened={revisionDiffOpened}
-            currentRevision={revision}
-            previousRevision={previousRevision}
-            key={`revision-deff-${revisionId}`}
-          />
-          )
-        }
-      </div>
-    );
-  }
-
-  render() {
-    const { t, pageHistoryContainer } = this.props;
-
-    const revisions = this.props.revisions;
-    const revisionCount = this.props.revisions.length;
-
-    let hasDiffPrev;
-
-    const revisionList = this.props.revisions.map((revision, idx) => {
-      // Returns null because the last revision is for the bottom diff display
-      if (idx === pageHistoryContainer.state.pagingLimit) {
-        return null;
-      }
-
-      let previousRevision;
-      if (idx + 1 < revisionCount) {
-        previousRevision = revisions[idx + 1];
-      }
-      else {
-        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;
-
-      hasDiffPrev = hasDiff;
-
-      return this.renderRow(revision, previousRevision, hasDiff, isContiguousNodiff);
-    });
-
-    const classNames = ['revision-history-list'];
-    if (this.state.isCompactNodiffRevisions) {
-      classNames.push('revision-history-list-compact');
-    }
-
-    return (
-      <React.Fragment>
-        <div className="d-flex">
-          <h3>{t('page_history.revision_list')}</h3>
-          <div className="custom-control custom-checkbox custom-checkbox-info ml-auto">
-            <input
-              type="checkbox"
-              id="cbCompactize"
-              className="custom-control-input"
-              checked={this.state.isCompactNodiffRevisions}
-              onChange={this.cbCompactizeChangeHandler}
-            />
-            <label className="custom-control-label" htmlFor="cbCompactize">{ t('Shrink versions that have no diffs') }</label>
-          </div>
-        </div>
-        <hr />
-        <div className={classNames.join(' ')}>
-          <div className="revision-history-list-container">
-            <div className="revision-history-list-content-header sticky-top bg-white">
-              <div className="row no-gutters">
-                <div className="col-8">{ t('page_history.revision') }</div>
-                <div className="col-2 text-center">{ t('page_history.comparing_source') }</div>
-                <div className="col-2 text-center">{ t('page_history.comparing_target') }</div>
-              </div>
-            </div>
-            <div className="revision-history-list-content-body">
-              {revisionList}
-            </div>
-          </div>
-        </div>
-      </React.Fragment>
-    );
-  }
-
-}
-
-PageRevisionList.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  pageHistoryContainer: PropTypes.instanceOf(PageHistroyContainer).isRequired,
-
-  revisions: PropTypes.array,
-  diffOpened: PropTypes.object,
-  onDiffOpenClicked: PropTypes.func.isRequired,
-};
-
-export default withTranslation()(PageRevisionList);

+ 165 - 0
src/client/js/components/PageHistory/PageRevisionTable.jsx

@@ -0,0 +1,165 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+import PageHistroyContainer from '../../services/PageHistoryContainer';
+import RevisionComparerContainer from '../../services/RevisionComparerContainer';
+
+import Revision from './Revision';
+
+class PageRevisionTable extends React.Component {
+
+  /**
+   * render a row (Revision component and RevisionDiff component)
+   * @param {Revison} revision
+   * @param {Revision} previousRevision
+   * @param {boolean} hasDiff whether revision has difference to previousRevision
+   * @param {boolean} isContiguousNodiff true if the current 'hasDiff' and one of previous row is both false
+   */
+  renderRow(revision, previousRevision, hasDiff, isContiguousNodiff) {
+    const { revisionComparerContainer, t } = this.props;
+    const { latestRevision, oldestRevision } = this.props.pageHistoryContainer.state;
+    const revisionId = revision._id;
+    const revisionDiffOpened = this.props.diffOpened[revisionId] || false;
+    const { sourceRevision, targetRevision } = revisionComparerContainer.state;
+
+    const handleCompareLatestRevisionButton = () => {
+      revisionComparerContainer.setState({ sourceRevision: revision });
+      revisionComparerContainer.setState({ targetRevision: latestRevision });
+    };
+
+    const handleComparePreviousRevisionButton = () => {
+      revisionComparerContainer.setState({ sourceRevision: previousRevision });
+      revisionComparerContainer.setState({ targetRevision: revision });
+    };
+
+    return (
+      <tr className="d-flex" key={`revision-history-${revisionId}`}>
+        <td className="col" key={`revision-history-top-${revisionId}`}>
+          <div className="d-lg-flex">
+            <Revision
+              t={this.props.t}
+              revision={revision}
+              isLatestRevision={revision === latestRevision}
+              revisionDiffOpened={revisionDiffOpened}
+              hasDiff={hasDiff}
+              key={`revision-history-rev-${revisionId}`}
+            />
+            {hasDiff && (
+              <div className="ml-md-3 mt-auto">
+                <div className="btn-group">
+                  <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}
+                    disabled={revision === oldestRevision}
+                  >
+                    {t('page_history.compare_previous')}
+                  </button>
+                </div>
+              </div>
+           )}
+          </div>
+        </td>
+        <td className="col-1">
+          {(hasDiff || revision._id === sourceRevision?._id) && (
+            <div className="custom-control custom-radio custom-control-inline mr-0">
+              <input
+                type="radio"
+                className="custom-control-input"
+                id={`compareSource-${revision._id}`}
+                name="compareSource"
+                value={revision._id}
+                checked={revision._id === sourceRevision?._id}
+                onChange={() => revisionComparerContainer.setState({ sourceRevision: revision })}
+              />
+              <label className="custom-control-label" htmlFor={`compareSource-${revision._id}`} />
+            </div>
+          )}
+        </td>
+        <td className="col-2">
+          {(hasDiff || revision._id === targetRevision?._id) && (
+            <div className="custom-control custom-radio custom-control-inline mr-0">
+              <input
+                type="radio"
+                className="custom-control-input"
+                id={`compareTarget-${revision._id}`}
+                name="compareTarget"
+                value={revision._id}
+                checked={revision._id === targetRevision?._id}
+                onChange={() => revisionComparerContainer.setState({ targetRevision: revision })}
+              />
+              <label className="custom-control-label" htmlFor={`compareTarget-${revision._id}`} />
+            </div>
+          )}
+        </td>
+      </tr>
+    );
+  }
+
+  render() {
+    const { t, pageHistoryContainer } = this.props;
+
+    const revisions = this.props.revisions;
+    const revisionCount = this.props.revisions.length;
+
+    let hasDiffPrev;
+
+    const revisionList = this.props.revisions.map((revision, idx) => {
+      // Returns null because the last revision is for the bottom diff display
+      if (idx === pageHistoryContainer.state.pagingLimit) {
+        return null;
+      }
+
+      let previousRevision;
+      if (idx + 1 < revisionCount) {
+        previousRevision = revisions[idx + 1];
+      }
+      else {
+        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;
+
+      hasDiffPrev = hasDiff;
+
+      return this.renderRow(revision, previousRevision, hasDiff, isContiguousNodiff);
+    });
+
+    return (
+      <table className="table revision-history-table">
+        <thead>
+          <tr className="d-flex">
+            <th className="col">{ t('page_history.revision') }</th>
+            <th className="col-1">{ t('page_history.comparing_source') }</th>
+            <th className="col-2">{ t('page_history.comparing_target') }</th>
+          </tr>
+        </thead>
+        <tbody className="overflow-auto d-block">
+          {revisionList}
+        </tbody>
+      </table>
+    );
+  }
+
+}
+
+PageRevisionTable.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  pageHistoryContainer: PropTypes.instanceOf(PageHistroyContainer).isRequired,
+  revisionComparerContainer: PropTypes.instanceOf(RevisionComparerContainer).isRequired,
+
+  revisions: PropTypes.array,
+  diffOpened: PropTypes.object,
+};
+
+export default withTranslation()(PageRevisionTable);

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

@@ -7,20 +7,9 @@ import UserPicture from '../User/UserPicture';
 
 export default class Revision extends React.Component {
 
-  constructor(props) {
-    super(props);
-
-    this._onDiffOpenClicked = this._onDiffOpenClicked.bind(this);
-  }
-
   componentDidMount() {
   }
 
-  _onDiffOpenClicked(e) {
-    e.preventDefault();
-    this.props.onDiffOpenClicked(this.props.revision);
-  }
-
   renderSimplifiedNodiff(revision) {
     const { t } = this.props;
 
@@ -37,11 +26,9 @@ export default class Revision extends React.Component {
           {pic}
         </div>
         <div className="ml-3">
-          <div className="revision-history-meta">
-            <span className="text-muted small">
-              <UserDate dateTime={revision.createdAt} /> ({ t('No diff') })
-            </span>
-          </div>
+          <span className="text-muted small">
+            <UserDate dateTime={revision.createdAt} /> ({ t('No diff') })
+          </span>
         </div>
       </div>
     );
@@ -57,43 +44,22 @@ export default class Revision extends React.Component {
       pic = <UserPicture user={author} size="lg" />;
     }
 
-    const iconClass = this.props.revisionDiffOpened ? 'fa fa-caret-down caret caret-opened' : 'fa fa-caret-down caret';
     return (
-      <div className="revision-history-main d-flex mt-3">
-        <div className="mt-2">
+      <div className="revision-history-main d-flex">
+        <div className="picture-container">
           {pic}
         </div>
         <div className="ml-2">
-          <div className="revision-history-author">
+          <div className="revision-history-author mb-1">
             <strong><Username user={author}></Username></strong>
-            { this.props.isLatestRevision
-              && (
-              <span className="badge badge-info ml-2">Latest</span>
-              )
-            }
+            {this.props.isLatestRevision && <span className="badge badge-info ml-2">Latest</span>}
           </div>
-          <div className="revision-history-meta">
-            <p>
-              <UserDate dateTime={revision.createdAt} />
-            </p>
-            <p>
-              <span className="d-inline-block" style={{ minWidth: '90px' }}>
-                { !this.props.hasDiff
-                  && <span className="text-muted">{ t('No diff') }</span>
-                }
-                { this.props.hasDiff
-                  && (
-                  // use dummy href attr (with preventDefault()), because don't apply style by a:not([href])
-                  <a className="diff-view" href="" onClick={this._onDiffOpenClicked}>
-                    <i className={iconClass}></i> {t('View diff')}
-                  </a>
-                  )
-                }
-              </span>
-              <a href={`?revision=${revision._id}`} className="ml-2">
-                <i className="icon-login"></i> { t('Go to this version') }
-              </a>
-            </p>
+          <div className="mb-1">
+            <UserDate dateTime={revision.createdAt} />
+            <br className="d-xl-none d-block" />
+            <a className="ml-xl-3" href={`?revision=${revision._id}`}>
+              <i className="icon-login"></i> { t('Go to this version') }
+            </a>
           </div>
         </div>
       </div>
@@ -103,7 +69,7 @@ export default class Revision extends React.Component {
   render() {
     const revision = this.props.revision;
 
-    if (this.props.isCompactNodiffRevisions && !this.props.hasDiff) {
+    if (!this.props.hasDiff) {
       return this.renderSimplifiedNodiff(revision);
     }
 
@@ -119,6 +85,4 @@ Revision.propTypes = {
   isLatestRevision: PropTypes.bool.isRequired,
   revisionDiffOpened: PropTypes.bool.isRequired,
   hasDiff: PropTypes.bool.isRequired,
-  isCompactNodiffRevisions: PropTypes.bool.isRequired,
-  onDiffOpenClicked: PropTypes.func.isRequired,
 };

+ 32 - 3
src/client/js/components/PageHistory/RevisionDiff.jsx

@@ -1,12 +1,16 @@
+/* eslint-disable react/no-danger */
 import React from 'react';
 import PropTypes from 'prop-types';
 
 import { createPatch } from 'diff';
 import { html } from 'diff2html';
+import { withTranslation } from 'react-i18next';
+import UserDate from '../User/UserDate';
 
-export default class RevisionDiff extends React.Component {
+class RevisionDiff extends React.Component {
 
   render() {
+    const { t } = this.props;
     const currentRevision = this.props.currentRevision;
     const previousRevision = this.props.previousRevision;
     const revisionDiffOpened = this.props.revisionDiffOpened;
@@ -38,14 +42,39 @@ export default class RevisionDiff extends React.Component {
     }
 
     const diffView = { __html: diffViewHTML };
-    // eslint-disable-next-line react/no-danger
-    return <div className="revision-history-diff" dangerouslySetInnerHTML={diffView} />;
+    return (
+      <>
+        <div className="comparison-header">
+          <div className="container pt-1 pr-0">
+            <div className="row">
+              <div className="col comparison-source-wrapper pt-1 px-0">
+                <span className="comparison-source pr-3">{t('page_history.comparing_source')}</span><UserDate dateTime={previousRevision.createdAt} />
+                <a href={`?revision=${previousRevision._id}`} className="ml-3">
+                  <i className="icon-login"></i>
+                </a>
+
+              </div>
+              <div className="col comparison-target-wrapper pt-1">
+                <span className="comparison-target pr-3">{t('page_history.comparing_target')}</span><UserDate dateTime={currentRevision.createdAt} />
+                <a href={`?revision=${currentRevision._id}`} className="ml-3">
+                  <i className="icon-login"></i>
+                </a>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div className="revision-history-diff" dangerouslySetInnerHTML={diffView} />
+      </>
+    );
   }
 
 }
 
 RevisionDiff.propTypes = {
+  t: PropTypes.func.isRequired,
   currentRevision: PropTypes.object.isRequired,
   previousRevision: PropTypes.object.isRequired,
   revisionDiffOpened: PropTypes.bool.isRequired,
 };
+
+export default withTranslation()(RevisionDiff);

+ 14 - 21
src/client/js/components/RevisionComparer/RevisionComparer.jsx

@@ -45,31 +45,26 @@ const RevisionComparer = (props) => {
     const { path } = revisionComparerContainer.pageContainer.state;
     const { sourceRevision, targetRevision } = revisionComparerContainer.state;
 
-    const urlParams = (sourceRevision && targetRevision ? `?compare=${sourceRevision._id}...${targetRevision._id}` : '');
-    return encodeSpaces(decodeURI(`${origin}/${path}${urlParams}`));
+    const url = new URL(path, origin);
+
+    if (sourceRevision != null && targetRevision != null) {
+      const urlParams = `${sourceRevision._id}...${targetRevision._id}`;
+      url.searchParams.set('compare', urlParams);
+    }
+
+    return encodeSpaces(decodeURI(url));
   };
 
   const { sourceRevision, targetRevision } = revisionComparerContainer.state;
-  const showDiff = (sourceRevision && targetRevision);
+
+  if (sourceRevision == null || targetRevision == null) {
+    return null;
+  }
 
   return (
     <div className="revision-compare">
       <div className="d-flex">
-        <h3 className="align-self-center mb-0">{ t('page_history.comparing_revisions') }</h3>
-        <div className="align-self-center ml-3">
-          <div className="custom-control custom-switch">
-            <input
-              type="checkbox"
-              className="custom-control-input"
-              id="comparingWithLatest"
-              checked={revisionComparerContainer.state.compareWithLatest}
-              onChange={() => revisionComparerContainer.toggleCompareWithLatest()}
-            />
-            <label className="custom-control-label" htmlFor="comparingWithLatest">
-              { t('page_history.comparing_with_latest') }
-            </label>
-          </div>
-        </div>
+        <h4 className="align-self-center">{ t('page_history.comparing_revisions') }</h4>
         <Dropdown
           className="grw-copy-dropdown align-self-center ml-auto"
           isOpen={dropdownOpen}
@@ -93,10 +88,8 @@ const RevisionComparer = (props) => {
         </Dropdown>
       </div>
 
-      <hr />
-
       <div className="revision-compare-outer">
-        { showDiff && (
+        {sourceRevision._id === targetRevision._id ? t('No diff') : (
           <RevisionDiff
             revisionDiffOpened
             previousRevision={sourceRevision}

+ 0 - 67
src/client/js/components/RevisionComparer/RevisionSelector.jsx

@@ -1,67 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import { withLoadingSppiner } from '../SuspenseUtils';
-
-import RevisionComparerContainer from '../../services/RevisionComparerContainer';
-
-const RevisionSelector = (props) => {
-
-  const { revision, hasDiff, revisionComparerContainer } = props;
-  const { sourceRevision, targetRevision } = revisionComparerContainer.state;
-
-  if (!hasDiff) {
-    return <></>;
-  }
-
-  return (
-    <React.Fragment>
-      <div className="container-fluid px-0">
-        <div className="row no-gutters">
-          <div className="col text-center">
-            <div className="custom-control custom-radio custom-control-inline mr-0">
-              <input
-                type="radio"
-                className="custom-control-input"
-                id={`compareSource-${revision._id}`}
-                name="compareSource"
-                value={revision._id}
-                checked={revision._id === sourceRevision?._id}
-                onChange={() => revisionComparerContainer.setState({ sourceRevision: revision })}
-              />
-              <label className="custom-control-label" htmlFor={`compareSource-${revision._id}`} />
-            </div>
-          </div>
-          <div className="col text-center">
-            <div className="custom-control custom-radio custom-control-inline mr-0">
-              <input
-                type="radio"
-                className="custom-control-input"
-                id={`compareTarget-${revision._id}`}
-                name="compareTarget"
-                value={revision._id}
-                checked={revision._id === targetRevision?._id}
-                onChange={() => revisionComparerContainer.setState({ targetRevision: revision })}
-                disabled={revisionComparerContainer.state.compareWithLatest}
-              />
-              <label className="custom-control-label" htmlFor={`compareTarget-${revision._id}`} />
-            </div>
-          </div>
-        </div>
-      </div>
-    </React.Fragment>
-  );
-
-};
-
-const RevisionSelectorWrapper = withUnstatedContainers(withLoadingSppiner(RevisionSelector), [RevisionComparerContainer]);
-
-RevisionSelector.propTypes = {
-  revisionComparerContainer: PropTypes.instanceOf(RevisionComparerContainer).isRequired,
-
-  revision: PropTypes.object,
-  hasDiff: PropTypes.bool.isRequired,
-};
-
-export default RevisionSelectorWrapper;

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

@@ -151,7 +151,10 @@ export default class EditorContainer extends Container {
     return opt;
   }
 
+  // See https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#example
   showUnsavedWarning(e) {
+    // Cancel the event
+    e.preventDefault();
     // display browser default message
     e.returnValue = '';
     return '';

+ 5 - 12
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,
@@ -33,7 +34,6 @@ export default class PageHistoryContainer extends Container {
     };
 
     this.retrieveRevisions = this.retrieveRevisions.bind(this);
-    this.onDiffOpenClicked = this.onDiffOpenClicked.bind(this);
     this.getPreviousRevision = this.getPreviousRevision.bind(this);
     this.fetchPageRevisionBody = this.fetchPageRevisionBody.bind(this);
   }
@@ -101,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]);
@@ -115,17 +119,6 @@ export default class PageHistoryContainer extends Container {
     return;
   }
 
-  onDiffOpenClicked(revision) {
-    const { diffOpened } = this.state;
-    const revisionId = revision._id;
-
-    diffOpened[revisionId] = !(diffOpened[revisionId]);
-    this.setState(diffOpened);
-
-    this.fetchPageRevisionBody(revision);
-    this.fetchPageRevisionBody(this.getPreviousRevision(revision));
-  }
-
   getPreviousRevision(currentRevision) {
     let cursor = null;
     for (const revision of this.state.revisions) {

+ 0 - 17
src/client/js/services/RevisionComparerContainer.js

@@ -24,11 +24,9 @@ export default class RevisionComparerContainer extends Container {
       sourceRevision: null,
       targetRevision: null,
       latestRevision: null,
-      compareWithLatest: true,
     };
 
     this.initRevisions = this.initRevisions.bind(this);
-    this.toggleCompareWithLatest = this.toggleCompareWithLatest.bind(this);
   }
 
   /**
@@ -111,19 +109,4 @@ export default class RevisionComparerContainer extends Container {
     return null;
   }
 
-  /**
-   * toggle state "compareWithLatest", and if true, set "targetRevision" to the latest revision
-   */
-  toggleCompareWithLatest() {
-    const { compareWithLatest } = this.state;
-    const newCompareWithLatest = !compareWithLatest;
-
-    this.setState(
-      Object.assign(
-        { compareWithLatest: newCompareWithLatest },
-        (newCompareWithLatest === true ? { targetRevision: this.state.latestRevision } : {}),
-      ),
-    );
-  }
-
 }

+ 4 - 0
src/client/styles/scss/_page-accessories-modal.scss

@@ -5,6 +5,10 @@
     }
   }
 
+  .modal-body {
+    padding: 25px 30px;
+  }
+
   .grw-modal-body-style {
     max-height: calc(100vh - 100px);
   }

+ 46 - 61
src/client/styles/scss/_page-history.scss

@@ -1,73 +1,58 @@
-.revision-history {
-  .revision-history-list {
-    .revision-history-list-container {
-      min-height: 200px;
-      max-height: calc(100vh - 100px - 550px);
-      overflow: auto;
-    }
-
-    .revision-history-list-content-body {
-      .revision-history-outer {
-        // add border-top except of first element
-        &:not(:first-of-type) {
-          @extend .border-top;
-        }
-
-        .revision-history-main {
-          .picture-lg {
-            width: 32px;
-            height: 32px;
-          }
-
-          .revision-history-meta {
-            a:hover {
-              cursor: pointer;
-            }
-          }
+// @import '../scss/variables';
+// @import '../scss/override-bootstrap-variables';
 
-          .caret {
-            transition: 0.4s;
-            transform: rotate(-90deg);
+.revision-history-table {
+  tbody {
+    max-height: 250px;
+  }
+}
 
-            &.caret-opened {
-              transform: rotate(0deg);
-            }
-          }
-        }
+.revision-history-main {
+  img.picture-lg {
+    width: 32px;
+    height: 32px;
+  }
+}
 
-        .revision-history-main-nodiff {
-          .picture-container {
-            min-width: 32px;
-            text-align: center; // centering .picture
-          }
-        }
+.revision-history-main-nodiff {
+  .picture-container {
+    min-width: 32px;
+    text-align: center; // centering .picture
+  }
+}
 
-        .revision-history-diff {
-          padding-left: 40px;
-          color: $gray-900;
-          table-layout: fixed;
-        }
-      }
+.revision-history-diff {
+  color: $gray-900;
+  table-layout: fixed;
+}
 
-      li {
-        position: relative;
-        list-style: none;
-      }
+.comparison-header {
+  height: 34px;
+  background-color: #ffffff;
+  border: 1px solid $gray-300;
+  .comparison-source-wrapper {
+    height: 26px;
+    margin-right: 1px;
+    border-right: 1px solid $gray-300;
+    .comparison-source {
+      color: $gray-500;
     }
   }
-
-  // compacted list
-  .revision-history-list-compact {
-    .revision-history-outer-contiguous-nodiff {
-      border-top: unset !important; // force unset border
+  .comparison-target-wrapper {
+    height: 26px;
+    .comparison-target {
+      color: $gray-500;
     }
   }
+}
 
-  .revision-compare {
-    .revision-compare-outer {
-      min-height: 100px;
-      max-height: 250px;
-      overflow: auto;
-    }
+.revision-compare {
+  .revision-compare-outer {
+    min-height: 100px;
+    max-height: 250px;
+    overflow: auto;
+  }
+  .d2h-file-header {
+    display: none;
   }
 }

+ 4 - 41
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) {

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

@@ -252,7 +252,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);
@@ -436,7 +436,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) {

+ 15 - 19
src/server/routes/comment.js

@@ -48,7 +48,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;
@@ -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);
+      }
     }
   };
 

+ 24 - 51
src/server/routes/page.js

@@ -136,16 +136,16 @@ module.exports = function(crowi, app) {
   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;
@@ -268,10 +237,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 +314,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 +363,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 });
@@ -415,16 +378,6 @@ module.exports = function(crowi, app) {
     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);
   };
@@ -769,7 +722,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 +867,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);
+      }
     }
   };
 

+ 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');