Jelajahi Sumber

Merge pull request #1172 from weseek/master

release v3.5.10
Yuki Takei 6 tahun lalu
induk
melakukan
f07a46b7e7
32 mengubah file dengan 660 tambahan dan 353 penghapusan
  1. 11 1
      CHANGES.md
  2. 1 1
      config/logger/config.dev.js
  3. 3 3
      package.json
  4. 9 7
      resource/locales/en-US/translation.json
  5. 9 7
      resource/locales/ja/translation.json
  6. 36 4
      src/client/js/components/Page/RevisionPath.jsx
  7. 8 4
      src/client/js/components/PageAttachment/Attachment.jsx
  8. 12 1
      src/client/js/components/PageAttachment/PageAttachmentList.jsx
  9. 4 4
      src/client/js/components/PageComment/Comment.jsx
  10. 2 2
      src/client/js/components/PageComment/DeleteCommentModal.jsx
  11. 74 48
      src/client/js/components/PageHistory.jsx
  12. 4 3
      src/client/js/components/User/UserDate.jsx
  13. 6 6
      src/lib/service/cdn-resources-service.js
  14. 14 2
      src/server/crowi/index.js
  15. 20 0
      src/server/models/GlobalNotificationSetting.js
  16. 8 3
      src/server/models/GlobalNotificationSetting/GlobalNotificationMailSetting.js
  17. 8 3
      src/server/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.js
  18. 2 2
      src/server/models/GlobalNotificationSetting/index.js
  19. 31 11
      src/server/routes/admin.js
  20. 17 5
      src/server/routes/comment.js
  21. 8 5
      src/server/routes/page.js
  22. 0 189
      src/server/service/global-notification.js
  23. 120 0
      src/server/service/global-notification/global-notification-mail.js
  24. 131 0
      src/server/service/global-notification/global-notification-slack.js
  25. 44 0
      src/server/service/global-notification/index.js
  26. 1 1
      src/server/util/mailer.js
  27. 2 2
      src/server/util/middlewares.js
  28. 32 0
      src/server/util/slack.js
  29. 1 1
      src/server/util/swigFunctions.js
  30. 5 6
      src/server/views/admin/global-notification-detail.html
  31. 29 25
      src/server/views/widget/page_alerts.html
  32. 8 7
      yarn.lock

+ 11 - 1
CHANGES.md

@@ -1,6 +1,16 @@
 # CHANGES
 
-## 3.5.9-RC
+## 3.5.10-RC
+
+* Improvement: Show loading spinner when fetching page history data
+* Improvement: Hierarchical page link when the page is in /Trash
+* Fix: Code Highlight Theme does not change
+    * Introduced by 3.5.2
+* Support: Upgrade libs
+    * date-fns
+    * eslint-config-weseek
+
+## 3.5.9
 
 * Fix: Editing table with Spreadsheet like GUI (Handsontable) is failed
 * Fix: Plugins are not initialized when first launching

+ 1 - 1
config/logger/config.dev.js

@@ -20,7 +20,7 @@ module.exports = {
   // 'growi:service:GlobalNotification': 'debug',
   // 'growi:lib:importer': 'debug',
   // 'growi:routes:page': 'debug',
-  // 'growi-plugin:*': 'debug',
+  'growi-plugin:*': 'debug',
   // 'growi:InterceptorManager': 'debug',
 
   // email

+ 3 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.5.9-RC",
+  "version": "3.5.10-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -79,7 +79,7 @@
     "cookie-parser": "^1.4.3",
     "cross-env": "^5.0.5",
     "csrf": "^3.1.0",
-    "date-fns": "^1.29.0",
+    "date-fns": "^2.0.0",
     "diff": "^4.0.1",
     "elasticsearch": "^16.0.0",
     "entities": "=1.1.2",
@@ -163,7 +163,7 @@
     "diff2html": "^2.3.3",
     "eazy-logger": "^3.0.2",
     "eslint": "^6.0.1",
-    "eslint-config-weseek": "^1.0.2",
+    "eslint-config-weseek": "^1.0.3",
     "eslint-plugin-import": "^2.18.0",
     "eslint-plugin-jest": "^22.7.1",
     "eslint-plugin-react": "^7.14.2",

+ 9 - 7
resource/locales/en-US/translation.json

@@ -8,6 +8,7 @@
   "Click to copy": "Click to copy",
   "Move/Rename": "Move/Rename",
   "Moved": "Moved",
+  "Redirected": "Redirected",
   "Unlinked": "Unlinked",
   "Like!": "Like!",
   "Seen by": "Seen by",
@@ -242,13 +243,14 @@
   },
 
   "page_page": {
-      "notice": {
-          "version": "This is not the current version.",
-          "moved": "This page was moved from <code>%s</code>",
-          "duplicated": "This page was duplicated from <code>%s</code>",
-          "unlinked": "Redirect pages to this page have been deleted.",
-          "restricted": "Access to this page is restricted"
-      }
+    "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>",
+      "unlinked": "Redirect pages to this page have been deleted.",
+      "restricted": "Access to this page is restricted"
+    }
   },
 
   "page_edit": {

+ 9 - 7
resource/locales/ja/translation.json

@@ -8,6 +8,7 @@
   "Click to copy": "クリックでコピー",
   "Move/Rename": "移動/名前変更",
   "Moved": "移動しました",
+  "Redirected": "リダイレクトされました",
   "Unlinked": "リダイレクト削除",
   "Like!": "いいね!",
   "Seen by": "見た人",
@@ -240,13 +241,14 @@
   },
 
   "page_page": {
-      "notice": {
-          "version": "これは現在の版ではありません。",
-          "moved": "このページは <code>%s</code> から移動しました。",
-          "duplicated": "このページは <code>%s</code> から複製されました。",
-          "unlinked": "このページへのリダイレクトは削除されました。",
-          "restricted": "このページの閲覧は制限されています"
-      }
+    "notice": {
+      "version": "これは現在の版ではありません。",
+      "moved": "このページは <code>%s</code> から移動しました。",
+      "redirected": "リダイレクト元 >> <code>%s</code>",
+      "duplicated": "このページは <code>%s</code> から複製されました。",
+      "unlinked": "このページへのリダイレクトは削除されました。",
+      "restricted": "このページの閲覧は制限されています"
+    }
   },
 
   "page_edit": {

+ 36 - 4
src/client/js/components/Page/RevisionPath.jsx

@@ -3,6 +3,8 @@ import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
 
+import urljoin from 'url-join';
+
 import CopyDropdown from './CopyDropdown';
 
 class RevisionPath extends React.Component {
@@ -14,6 +16,7 @@ class RevisionPath extends React.Component {
       pages: [],
       isListPage: false,
       isLinkToListPage: true,
+      isInTrash: false,
     };
 
     // retrieve xss library from window
@@ -30,6 +33,23 @@ class RevisionPath extends React.Component {
     const isLinkToListPage = (behaviorType === 'crowi');
     this.setState({ isLinkToListPage });
 
+    this.generateHierarchyData();
+  }
+
+  /**
+   * 1. split `pagePath` with '/'
+   * 2. list hierararchical page paths
+   *
+   * e.g.
+   *  when `pagePath` is '/foo/bar/baz`
+   *  return:
+   *  [
+   *    { pagePath: '/foo',         pageName: 'foo' },
+   *    { pagePath: '/foo/bar',     pageName: 'bar' },
+   *    { pagePath: '/foo/bar/baz', pageName: 'baz' },
+   *  ]
+   */
+  generateHierarchyData() {
     // generate pages obj
     const splitted = this.props.pagePath.split(/\//);
     splitted.shift(); // omit first element with shift()
@@ -38,13 +58,19 @@ class RevisionPath extends React.Component {
     }
 
     const pages = [];
-    let parentPath = '/';
+    const pagePaths = [];
     splitted.forEach((pageName) => {
+      // skip trash
+      if (pageName === 'trash' && splitted.length > 1) {
+        this.setState({ isInTrash: true });
+        return;
+      }
+
+      pagePaths.push(encodeURIComponent(pageName));
       pages.push({
-        pagePath: parentPath + encodeURIComponent(pageName),
+        pagePath: urljoin('/', ...pagePaths),
         pageName: this.xss.process(pageName),
       });
-      parentPath += `${pageName}/`;
     });
 
     this.setState({ pages });
@@ -86,6 +112,7 @@ class RevisionPath extends React.Component {
       padding: '0 2px',
     };
 
+    const { isInTrash } = this.state;
     const pageLength = this.state.pages.length;
 
     const afterElements = [];
@@ -109,7 +136,12 @@ class RevisionPath extends React.Component {
 
     return (
       <span className="d-flex align-items-center">
-        <span className="separator" style={rootStyle}>
+        { isInTrash && (
+          <span className="path-segment">
+            <a href="/trash"><i className="icon-trash"></i></a>
+          </span>
+        ) }
+        <span className="separator" style={isInTrash ? separatorStyle : rootStyle}>
           <a href="/">/</a>
         </span>
         {afterElements}

+ 8 - 4
src/client/js/components/PageAttachment/Attachment.jsx

@@ -20,7 +20,9 @@ export default class Attachment extends React.Component {
   }
 
   _onAttachmentDeleteClicked(event) {
-    this.props.onAttachmentDeleteClicked(this.props.attachment);
+    if (this.props.onAttachmentDeleteClicked != null) {
+      this.props.onAttachmentDeleteClicked(this.props.attachment);
+    }
   }
 
   render() {
@@ -73,10 +75,12 @@ export default class Attachment extends React.Component {
 
 Attachment.propTypes = {
   attachment: PropTypes.object.isRequired,
-  inUse: PropTypes.bool.isRequired,
-  onAttachmentDeleteClicked: PropTypes.func.isRequired,
-  isUserLoggedIn: PropTypes.bool.isRequired,
+  inUse: PropTypes.bool,
+  onAttachmentDeleteClicked: PropTypes.func,
+  isUserLoggedIn: PropTypes.bool,
 };
 
 Attachment.defaultProps = {
+  inUse: false,
+  isUserLoggedIn: false,
 };

+ 12 - 1
src/client/js/components/PageAttachment/PageAttachmentList.jsx

@@ -1,5 +1,5 @@
-/* eslint-disable react/prop-types */
 import React from 'react';
+import PropTypes from 'prop-types';
 
 import Attachment from './Attachment';
 
@@ -35,3 +35,14 @@ export default class PageAttachmentList extends React.Component {
   }
 
 }
+
+PageAttachmentList.propTypes = {
+  attachments: PropTypes.arrayOf(PropTypes.object),
+  inUse: PropTypes.objectOf(PropTypes.bool),
+  onAttachmentDeleteClicked: PropTypes.func,
+  isUserLoggedIn: PropTypes.bool,
+};
+PageAttachmentList.defaultProps = {
+  attachments: [],
+  inUse: {},
+};

+ 4 - 4
src/client/js/components/PageComment/Comment.jsx

@@ -1,8 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import { distanceInWordsStrict } from 'date-fns';
-import dateFnsFormat from 'date-fns/format';
+import { format, formatDistanceStrict } from 'date-fns';
 
 import Button from 'react-bootstrap/es/Button';
 import Tooltip from 'react-bootstrap/es/Tooltip';
@@ -219,9 +218,10 @@ class Comment extends React.Component {
     const comment = this.props.comment;
     const creator = comment.creator;
     const isMarkdown = comment.isMarkdown;
+    const createdAt = new Date(comment.createdAt);
 
     const rootClassName = this.getRootClassName(comment);
-    const commentDate = distanceInWordsStrict(comment.createdAt, new Date());
+    const commentDate = formatDistanceStrict(createdAt, new Date());
     const commentBody = isMarkdown ? this.renderRevisionBody() : this.renderText(comment.comment);
     const revHref = `?revision=${comment.revision}`;
     const revFirst8Letters = comment.revision.substr(-8);
@@ -229,7 +229,7 @@ class Comment extends React.Component {
 
     const commentDateTooltip = (
       <Tooltip id={`commentDateTooltip-${comment._id}`}>
-        {dateFnsFormat(comment.createdAt, 'YYYY/MM/DD HH:mm')}
+        {format(createdAt, 'yyyy/MM/dd HH:mm')}
       </Tooltip>
     );
 

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

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
 import Button from 'react-bootstrap/es/Button';
 import Modal from 'react-bootstrap/es/Modal';
 
-import dateFnsFormat from 'date-fns/format';
+import { format } from 'date-fns';
 
 import UserPicture from '../User/UserPicture';
 import Username from '../User/Username';
@@ -25,7 +25,7 @@ export default class DeleteCommentModal extends React.Component {
     }
 
     const comment = this.props.comment;
-    const commentDate = dateFnsFormat(comment.createdAt, 'YYYY/MM/DD HH:mm');
+    const commentDate = format(new Date(comment.createdAt), 'yyyy/MM/dd HH:mm');
 
     // generate body
     let commentBody = comment.comment;

+ 74 - 48
src/client/js/components/PageHistory.jsx

@@ -1,15 +1,21 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
+
 import { withTranslation } from 'react-i18next';
 
 import PageRevisionList from './PageHistory/PageRevisionList';
 
+const logger = loggerFactory('growi:PageHistory');
 class PageHistory extends React.Component {
 
   constructor(props) {
     super(props);
 
     this.state = {
+      isLoaded: false,
+      isLoading: false,
+      errorMessage: null,
       revisions: [],
       diffOpened: {},
     };
@@ -18,52 +24,60 @@ class PageHistory extends React.Component {
     this.onDiffOpenClicked = this.onDiffOpenClicked.bind(this);
   }
 
-  componentDidMount() {
+  async componentWillMount() {
     const pageId = this.props.pageId;
 
     if (!pageId) {
       return;
     }
 
-    this.props.crowi.apiGet('/revisions.ids', { page_id: pageId })
-      .then((res) => {
+    let res;
+    try {
+      this.setState({ isLoading: true });
+      res = await this.props.crowi.apiGet('/revisions.ids', { page_id: pageId });
+    }
+    catch (err) {
+      logger.error(err);
+      this.setState({ errorMessage: err });
+      return;
+    }
+    finally {
+      this.setState({ isLoading: false });
+    }
 
-        const rev = res.revisions;
-        const diffOpened = {};
-        const lastId = rev.length - 1;
-        res.revisions.forEach((revision, i) => {
-          const user = this.props.crowi.findUserById(revision.author);
-          if (user) {
-            rev[i].author = user;
-          }
-
-          if (i === 0 || i === lastId) {
-            diffOpened[revision._id] = true;
-          }
-          else {
-            diffOpened[revision._id] = false;
-          }
-        });
-
-        this.setState({
-          revisions: rev,
-          diffOpened,
-        });
-
-        // load 0, and last default
-        if (rev[0]) {
-          this.fetchPageRevisionBody(rev[0]);
-        }
-        if (rev[1]) {
-          this.fetchPageRevisionBody(rev[1]);
-        }
-        if (lastId !== 0 && lastId !== 1 && rev[lastId]) {
-          this.fetchPageRevisionBody(rev[lastId]);
-        }
-      })
-      .catch((err) => {
-      // do nothing
-      });
+    const rev = res.revisions;
+    const diffOpened = {};
+    const lastId = rev.length - 1;
+    res.revisions.forEach((revision, i) => {
+      const user = this.props.crowi.findUserById(revision.author);
+      if (user) {
+        rev[i].author = user;
+      }
+
+      if (i === 0 || i === lastId) {
+        diffOpened[revision._id] = true;
+      }
+      else {
+        diffOpened[revision._id] = false;
+      }
+    });
+
+    this.setState({
+      isLoaded: true,
+      revisions: rev,
+      diffOpened,
+    });
+
+    // load 0, and last default
+    if (rev[0]) {
+      this.fetchPageRevisionBody(rev[0]);
+    }
+    if (rev[1]) {
+      this.fetchPageRevisionBody(rev[1]);
+    }
+    if (lastId !== 0 && lastId !== 1 && rev[lastId]) {
+      this.fetchPageRevisionBody(rev[lastId]);
+    }
   }
 
   getPreviousRevision(currentRevision) {
@@ -124,15 +138,27 @@ class PageHistory extends React.Component {
 
   render() {
     return (
-      <div>
-        <PageRevisionList
-          t={this.props.t}
-          revisions={this.state.revisions}
-          diffOpened={this.state.diffOpened}
-          getPreviousRevision={this.getPreviousRevision}
-          onDiffOpenClicked={this.onDiffOpenClicked}
-        />
-      </div>
+      <React.Fragment>
+        { this.state.isLoading && (
+          <div className="my-5 text-center">
+            <i className="fa fa-lg fa-spinner fa-pulse mx-auto text-muted"></i>
+          </div>
+        ) }
+        { this.state.errorMessage && (
+          <div className="my-5">
+            <div className="text-danger">{this.state.errorMessage}</div>
+          </div>
+        ) }
+        { this.state.isLoaded && (
+          <PageRevisionList
+            t={this.props.t}
+            revisions={this.state.revisions}
+            diffOpened={this.state.diffOpened}
+            getPreviousRevision={this.getPreviousRevision}
+            onDiffOpenClicked={this.onDiffOpenClicked}
+          />
+        ) }
+      </React.Fragment>
     );
   }
 

+ 4 - 3
src/client/js/components/User/UserDate.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import dateFnsFormat from 'date-fns/format';
+import { format } from 'date-fns';
 
 /**
  * UserDate
@@ -11,7 +11,8 @@ import dateFnsFormat from 'date-fns/format';
 export default class UserDate extends React.Component {
 
   render() {
-    const dt = dateFnsFormat(this.props.dateTime, this.props.format);
+    const date = new Date(this.props.dateTime);
+    const dt = format(date, this.props.format);
 
     return (
       <span className={this.props.className}>
@@ -29,6 +30,6 @@ UserDate.propTypes = {
 };
 
 UserDate.defaultProps = {
-  format: 'YYYY/MM/DD HH:mm:ss',
+  format: 'yyyy/MM/dd HH:mm:ss',
   className: '',
 };

+ 6 - 6
src/lib/service/cdn-resources-service.js

@@ -19,15 +19,15 @@ class CdnResourcesService {
     this.loadManifests();
   }
 
+  get noCdn() {
+    return envUtils.toBoolean(process.env.NO_CDN);
+  }
+
   loadManifests() {
     this.cdnManifests = require('@root/resource/cdn-manifests');
     this.logger.debug('manifest data loaded : ', this.cdnManifests);
   }
 
-  noCdn() {
-    return envUtils.toBoolean(process.env.NO_CDN);
-  }
-
   getScriptManifestByName(name) {
     const manifests = this.cdnManifests.js
       .filter((manifest) => { return manifest.name === name });
@@ -91,7 +91,7 @@ class CdnResourcesService {
 
     // TODO process integrity
 
-    const url = this.noCdn()
+    const url = this.noCdn
       ? `${urljoin(cdnLocalScriptWebRoot, manifest.name)}.js`
       : manifest.url;
     return `<script src="${url}" ${attrs.join(' ')}></script>`;
@@ -130,7 +130,7 @@ class CdnResourcesService {
 
     // TODO process integrity
 
-    const url = this.noCdn()
+    const url = this.noCdn
       ? `${urljoin(cdnLocalStyleWebRoot, manifest.name)}.css`
       : manifest.url;
 

+ 14 - 2
src/server/crowi/index.js

@@ -98,13 +98,17 @@ Crowi.prototype.init = async function() {
     this.setupMailer(),
     this.setupSlack(),
     this.setupCsrf(),
-    this.setUpGlobalNotification(),
     this.setUpFileUpload(),
     this.setUpAcl(),
     this.setUpCustomize(),
     this.setUpRestQiitaAPI(),
     this.setupUserGroup(),
   ]);
+
+  // globalNotification depends on slack and mailer
+  await Promise.all([
+    this.setUpGlobalNotification(),
+  ]);
 };
 
 Crowi.prototype.initForTest = async function() {
@@ -127,12 +131,16 @@ Crowi.prototype.initForTest = async function() {
   //   this.setupMailer(),
   //   this.setupSlack(),
   //   this.setupCsrf(),
-  //   this.setUpGlobalNotification(),
   //   this.setUpFileUpload(),
     this.setUpAcl(),
   //   this.setUpCustomize(),
   //   this.setUpRestQiitaAPI(),
   ]);
+
+  // globalNotification depends on slack and mailer
+  // await Promise.all([
+  //   this.setUpGlobalNotification(),
+  // ]);
 };
 
 Crowi.prototype.isPageId = function(pageId) {
@@ -266,6 +274,10 @@ Crowi.prototype.getMailer = function() {
   return this.mailer;
 };
 
+Crowi.prototype.getSlack = function() {
+  return this.slack;
+};
+
 Crowi.prototype.getInterceptorManager = function() {
   return this.interceptorManager;
 };

+ 20 - 0
src/server/models/GlobalNotificationSetting.js

@@ -7,6 +7,26 @@ const GlobalNotificationSetting = require('./GlobalNotificationSetting/index');
 const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
 const GlobalNotificationSettingSchema = GlobalNotificationSetting.schema;
 
+/**
+ * global notifcation event master
+ */
+GlobalNotificationSettingSchema.statics.EVENT = {
+  PAGE_CREATE: 'pageCreate',
+  PAGE_EDIT: 'pageEdit',
+  PAGE_DELETE: 'pageDelete',
+  PAGE_MOVE: 'pageMove',
+  PAGE_LIKE: 'pageLike',
+  COMMENT: 'comment',
+};
+
+/**
+ * global notifcation type master
+ */
+GlobalNotificationSettingSchema.statics.TYPE = {
+  MAIL: 'mail',
+  SLACK: 'slack',
+};
+
 module.exports = function(crowi) {
   GlobalNotificationSettingClass.crowi = crowi;
   GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);

+ 8 - 3
src/server/models/GlobalNotificationSetting/GlobalNotificationMailSetting.js

@@ -9,9 +9,14 @@ module.exports = function(crowi) {
   GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
 
   const GlobalNotificationSettingModel = mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
-  const GlobalNotificationMailSettingModel = GlobalNotificationSettingModel.discriminator('mail', new mongoose.Schema({
-    toEmail: String,
-  }, { discriminatorKey: 'type' }));
+  const GlobalNotificationMailSettingModel = GlobalNotificationSettingModel.discriminator(
+    GlobalNotificationSetting.schema.statics.TYPE.MAIL,
+    new mongoose.Schema({
+      toEmail: String,
+    }, {
+      discriminatorKey: 'type',
+    }),
+  );
 
   return GlobalNotificationMailSettingModel;
 };

+ 8 - 3
src/server/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.js

@@ -9,9 +9,14 @@ module.exports = function(crowi) {
   GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
 
   const GlobalNotificationSettingModel = mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
-  const GlobalNotificationSlackSettingModel = GlobalNotificationSettingModel.discriminator('slack', new mongoose.Schema({
-    slackChannels: String,
-  }, { discriminatorKey: 'type' }));
+  const GlobalNotificationSlackSettingModel = GlobalNotificationSettingModel.discriminator(
+    GlobalNotificationSetting.schema.statics.TYPE.SLACK,
+    new mongoose.Schema({
+      slackChannels: String,
+    }, {
+      discriminatorKey: 'type',
+    }),
+  );
 
   return GlobalNotificationSlackSettingModel;
 };

+ 2 - 2
src/server/models/GlobalNotificationSetting/index.js

@@ -92,12 +92,13 @@ class GlobalNotificationSetting {
    * @param {string} path
    * @param {string} event
    */
-  static async findSettingByPathAndEvent(path, event) {
+  static async findSettingByPathAndEvent(event, path, type) {
     const pathsToMatch = generatePathsToMatch(path);
 
     const settings = await this.find({
       triggerPath: { $in: pathsToMatch },
       triggerEvents: event,
+      __t: type,
       isEnabled: true,
     })
       .sort({ triggerPath: 1 });
@@ -107,7 +108,6 @@ class GlobalNotificationSetting {
 
 }
 
-
 module.exports = {
   class: GlobalNotificationSetting,
   schema: globalNotificationSettingSchema,

+ 31 - 11
src/server/routes/admin.js

@@ -331,14 +331,14 @@ module.exports = function(crowi, app) {
     let setting;
 
     switch (form.notifyToType) {
-      case 'mail':
+      case GlobalNotificationSetting.TYPE.MAIL:
         setting = new GlobalNotificationMailSetting(crowi);
         setting.toEmail = form.toEmail;
         break;
-      // case 'slack':
-      //   setting = new GlobalNotificationSlackSetting(crowi);
-      //   setting.slackChannels = form.slackChannels;
-      //   break;
+      case GlobalNotificationSetting.TYPE.SLACK:
+        setting = new GlobalNotificationSlackSetting(crowi);
+        setting.slackChannels = form.slackChannels;
+        break;
       default:
         logger.error('GlobalNotificationSetting Type Error: undefined type');
         req.flash('errorMessage', 'Error occurred in creating a new global notification setting: undefined notification type');
@@ -354,24 +354,44 @@ module.exports = function(crowi, app) {
 
   actions.globalNotification.update = async(req, res) => {
     const form = req.form.notificationGlobal;
-    const setting = await GlobalNotificationSetting.findOne({ _id: form.id });
+
+    const models = {
+      [GlobalNotificationSetting.TYPE.MAIL]: GlobalNotificationMailSetting,
+      [GlobalNotificationSetting.TYPE.SLACK]: GlobalNotificationSlackSetting,
+    };
+
+    let setting = await GlobalNotificationSetting.findOne({ _id: form.id });
+    setting = setting.toObject();
+
+    // when switching from one type to another,
+    // remove toEmail from slack setting and slackChannels from mail setting
+    if (setting.__t !== form.notifyToType) {
+      setting = models[setting.__t].hydrate(setting);
+      setting.toEmail = undefined;
+      setting.slackChannels = undefined;
+      await setting.save();
+      setting = setting.toObject();
+    }
 
     switch (form.notifyToType) {
-      case 'mail':
+      case GlobalNotificationSetting.TYPE.MAIL:
+        setting = GlobalNotificationMailSetting.hydrate(setting);
         setting.toEmail = form.toEmail;
         break;
-      // case 'slack':
-      //   setting.slackChannels = form.slackChannels;
-      //   break;
+      case GlobalNotificationSetting.TYPE.SLACK:
+        setting = GlobalNotificationSlackSetting.hydrate(setting);
+        setting.slackChannels = form.slackChannels;
+        break;
       default:
         logger.error('GlobalNotificationSetting Type Error: undefined type');
         req.flash('errorMessage', 'Error occurred in updating the global notification setting: undefined notification type');
         return res.redirect('/admin/notification#global-notification');
     }
 
+    setting.__t = form.notifyToType;
     setting.triggerPath = form.triggerPath;
     setting.triggerEvents = getNotificationEvents(form);
-    setting.save();
+    await setting.save();
 
     return res.redirect('/admin/notification#global-notification');
   };

+ 17 - 5
src/server/routes/comment.js

@@ -3,6 +3,7 @@ module.exports = function(crowi, app) {
   const Comment = crowi.model('Comment');
   const User = crowi.model('User');
   const Page = crowi.model('Page');
+  const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const ApiResponse = require('../util/apiResponse');
   const globalNotificationService = crowi.getGlobalNotificationService();
   const { body } = require('express-validator/check');
@@ -107,10 +108,19 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Current user is not accessible to this page.'));
     }
 
-    const createdComment = await Comment.create(pageId, req.user._id, revisionId, comment, position, isMarkdown, replyTo)
-      .catch((err) => {
-        return res.json(ApiResponse.error(err));
-      });
+    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);
+      return res.json(ApiResponse.error(err));
+    }
 
     // update page
     const page = await Page.findOneAndUpdate({ _id: pageId }, {
@@ -123,7 +133,9 @@ module.exports = function(crowi, app) {
     const path = page.path;
 
     // global notification
-    globalNotificationService.notifyComment(createdComment, path);
+    globalNotificationService.fire(GlobalNotificationSetting.EVENT.COMMENT, path, req.user, {
+      comment: createdComment,
+    });
 
     // slack notification
     if (slackNotificationForm.isSlackEnabled) {

+ 8 - 5
src/server/routes/page.js

@@ -11,6 +11,7 @@ module.exports = function(crowi, app) {
   const Bookmark = crowi.model('Bookmark');
   const PageTagRelation = crowi.model('PageTagRelation');
   const UpdatePost = crowi.model('UpdatePost');
+  const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
 
   const ApiResponse = require('../util/apiResponse');
   const getToday = require('../util/getToday');
@@ -607,7 +608,7 @@ module.exports = function(crowi, app) {
 
     // global notification
     try {
-      await globalNotificationService.notifyPageCreate(createdPage);
+      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, createdPage.path, req.user);
     }
     catch (err) {
       logger.error(err);
@@ -694,7 +695,7 @@ module.exports = function(crowi, app) {
 
     // global notification
     try {
-      await globalNotificationService.notifyPageEdit(page);
+      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_EDIT, page.path, req.user);
     }
     catch (err) {
       logger.error(err);
@@ -862,7 +863,7 @@ module.exports = function(crowi, app) {
 
     try {
       // global notification
-      globalNotificationService.notifyPageLike(page, req.user);
+      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_LIKE, page.path, req.user);
     }
     catch (err) {
       logger.error('Like failed', err);
@@ -999,7 +1000,7 @@ module.exports = function(crowi, app) {
     res.json(ApiResponse.success(result));
 
     // global notification
-    return globalNotificationService.notifyPageDelete(page);
+    await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_DELETE, page.path, req.user);
   };
 
   /**
@@ -1104,7 +1105,9 @@ module.exports = function(crowi, app) {
     res.json(ApiResponse.success(result));
 
     // global notification
-    globalNotificationService.notifyPageMove(page, req.body.path, req.user);
+    globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_MOVE, page.path, req.user, {
+      oldPath: req.body.path,
+    });
 
     return page;
   };

+ 0 - 189
src/server/service/global-notification.js

@@ -1,189 +0,0 @@
-const logger = require('@alias/logger')('growi:service:GlobalNotification');
-const nodePath = require('path');
-/**
- * the service class of GlobalNotificationSetting
- */
-class GlobalNotificationService {
-
-  constructor(crowi) {
-    this.crowi = crowi;
-    this.mailer = crowi.getMailer();
-    this.GlobalNotification = crowi.model('GlobalNotificationSetting');
-    this.User = crowi.model('User');
-    this.appTitle = crowi.appService.getAppTitle();
-  }
-
-  notifyByMail(notification, mailOption) {
-    this.mailer.send(Object.assign(mailOption, { to: notification.toEmail }));
-  }
-
-  notifyBySlack(notification, slackOption) {
-    // send slack notification here
-  }
-
-  sendNotification(notifications, option) {
-    notifications.forEach((notification) => {
-      if (notification.__t === 'mail') {
-        this.notifyByMail(notification, option.mail);
-      }
-      else if (notification.__t === 'slack') {
-        this.notifyBySlack(notification, option.slack);
-      }
-    });
-  }
-
-  /**
-   * send notification at page creation
-   * @memberof GlobalNotification
-   * @param {obejct} page
-   */
-  async notifyPageCreate(page) {
-    const notifications = await this.GlobalNotification.findSettingByPathAndEvent(page.path, 'pageCreate');
-    const lang = 'en-US'; // FIXME
-    const option = {
-      mail: {
-        subject: `#pageCreate - ${page.creator.username} created ${page.path}`,
-        template: nodePath.join(this.crowi.localeDir, `${lang}/notifications/pageCreate.txt`),
-        vars: {
-          appTitle: this.appTitle,
-          path: page.path,
-          username: page.creator.username,
-        },
-      },
-      slack: {},
-    };
-
-    logger.debug('notifyPageCreate', option);
-
-    this.sendNotification(notifications, option);
-  }
-
-  /**
-   * send notification at page edit
-   * @memberof GlobalNotification
-   * @param {obejct} page
-   */
-  async notifyPageEdit(page) {
-    const notifications = await this.GlobalNotification.findSettingByPathAndEvent(page.path, 'pageEdit');
-    const lang = 'en-US'; // FIXME
-    const option = {
-      mail: {
-        subject: `#pageEdit - ${page.creator.username} edited ${page.path}`,
-        template: nodePath.join(this.crowi.localeDir, `${lang}/notifications/pageEdit.txt`),
-        vars: {
-          appTitle: this.appTitle,
-          path: page.path,
-          username: page.creator.username,
-        },
-      },
-      slack: {},
-    };
-
-    logger.debug('notifyPageEdit', option);
-
-    this.sendNotification(notifications, option);
-  }
-
-  /**
-   * send notification at page deletion
-   * @memberof GlobalNotification
-   * @param {obejct} page
-   */
-  async notifyPageDelete(page) {
-    const notifications = await this.GlobalNotification.findSettingByPathAndEvent(page.path, 'pageDelete');
-    const lang = 'en-US'; // FIXME
-    const option = {
-      mail: {
-        subject: `#pageDelete - ${page.creator.username} deleted ${page.path}`, // FIXME
-        template: nodePath.join(this.crowi.localeDir, `${lang}/notifications/pageDelete.txt`),
-        vars: {
-          appTitle: this.appTitle,
-          path: page.path,
-          username: page.creator.username,
-        },
-      },
-      slack: {},
-    };
-
-    this.sendNotification(notifications, option);
-  }
-
-  /**
-   * send notification at page move
-   * @memberof GlobalNotification
-   * @param {obejct} page
-   */
-  async notifyPageMove(page, oldPagePath, user) {
-    const notifications = await this.GlobalNotification.findSettingByPathAndEvent(page.path, 'pageMove');
-    const lang = 'en-US'; // FIXME
-    const option = {
-      mail: {
-        subject: `#pageMove - ${user.username} moved ${page.path} to ${page.path}`, // FIXME
-        template: nodePath.join(this.crowi.localeDir, `${lang}/notifications/pageMove.txt`),
-        vars: {
-          appTitle: this.appTitle,
-          oldPath: oldPagePath,
-          newPath: page.path,
-          username: user.username,
-        },
-      },
-      slack: {},
-    };
-
-    this.sendNotification(notifications, option);
-  }
-
-  /**
-   * send notification at page like
-   * @memberof GlobalNotification
-   * @param {obejct} page
-   */
-  async notifyPageLike(page, user) {
-    const notifications = await this.GlobalNotification.findSettingByPathAndEvent(page.path, 'pageLike');
-    const lang = 'en-US'; // FIXME
-    const option = {
-      mail: {
-        subject: `#pageLike - ${user.username} liked ${page.path}`,
-        template: nodePath.join(this.crowi.localeDir, `${lang}/notifications/pageLike.txt`),
-        vars: {
-          appTitle: this.appTitle,
-          path: page.path,
-          username: page.creator.username,
-        },
-      },
-      slack: {},
-    };
-
-    this.sendNotification(notifications, option);
-  }
-
-  /**
-   * send notification at page comment
-   * @memberof GlobalNotification
-   * @param {obejct} page
-   * @param {obejct} comment
-   */
-  async notifyComment(comment, path) {
-    const notifications = await this.GlobalNotification.findSettingByPathAndEvent(path, 'comment');
-    const lang = 'en-US'; // FIXME
-    const user = await this.User.findOne({ _id: comment.creator });
-    const option = {
-      mail: {
-        subject: `#comment - ${user.username} commented on ${path}`,
-        template: nodePath.join(this.crowi.localeDir, `${lang}/notifications/comment.txt`),
-        vars: {
-          appTitle: this.appTitle,
-          path,
-          username: user.username,
-          comment: comment.comment,
-        },
-      },
-      slack: {},
-    };
-
-    this.sendNotification(notifications, option);
-  }
-
-}
-
-module.exports = GlobalNotificationService;

+ 120 - 0
src/server/service/global-notification/global-notification-mail.js

@@ -0,0 +1,120 @@
+const logger = require('@alias/logger')('growi:service:GlobalNotificationMailService'); // eslint-disable-line no-unused-vars
+const nodePath = require('path');
+
+/**
+ * sub service class of GlobalNotificationSetting
+ */
+class GlobalNotificationMailService {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+    this.mailer = crowi.getMailer();
+    this.type = crowi.model('GlobalNotificationSetting').TYPE.MAIL;
+    this.event = crowi.model('GlobalNotificationSetting').EVENT;
+  }
+
+  /**
+   * send mail global notification
+   *
+   * @memberof GlobalNotificationMailService
+   *
+   * @param {string} event event name triggered
+   * @param {string} path path triggered the event
+   * @param {User} triggeredBy user who triggered the event
+   * @param {{ comment: Comment, oldPath: string }} _ event specific vars
+   */
+  async fire(event, path, triggeredBy, vars) {
+    const GlobalNotification = this.crowi.model('GlobalNotificationSetting');
+    const notifications = await GlobalNotification.findSettingByPathAndEvent(event, path, this.type);
+
+    const option = this.generateOption(event, path, triggeredBy, vars);
+
+    await Promise.all(notifications.map((notification) => {
+      return this.mailer.send({ ...option, to: notification.toEmail });
+    }));
+  }
+
+  /**
+   * fire global notification
+   *
+   * @memberof GlobalNotificationMailService
+   *
+   * @param {string} event event name triggered
+   * @param {string} path path triggered the event
+   * @param {User} triggeredBy user triggered the event
+   * @param {{ comment: Comment, oldPath: string }} _ event specific vars
+   *
+   * @return  {{ subject: string, template: string, vars: object }}
+   */
+  generateOption(event, path, triggeredBy, { comment, oldPath }) {
+    // validate for all events
+    if (event == null || path == null || triggeredBy == null) {
+      throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);
+    }
+
+    const template = nodePath.join(this.crowi.localeDir, `${this.defaultLang}/notifications/${event}.txt`);
+    let subject;
+    let vars = {
+      appTitle: this.crowi.appService.getAppTitle(),
+      path,
+      username: triggeredBy.username,
+    };
+
+    switch (event) {
+      case this.event.PAGE_CREATE:
+        subject = `#${event} - ${triggeredBy.username} created ${path}`;
+        break;
+
+      case this.event.PAGE_EDIT:
+        subject = `#${event} - ${triggeredBy.username} edited ${path}`;
+        break;
+
+      case this.event.PAGE_DELETE:
+        subject = `#${event} - ${triggeredBy.username} deleted ${path}`;
+        break;
+
+      case this.event.PAGE_MOVE:
+        // validate for page move
+        if (oldPath == null) {
+          throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);
+        }
+
+        subject = `#${event} - ${triggeredBy.username} moved ${oldPath} to ${path}`;
+        vars = {
+          ...vars,
+          oldPath,
+          newPath: path,
+        };
+        break;
+
+      case this.event.PAGE_LIKE:
+        subject = `#${event} - ${triggeredBy.username} liked ${path}`;
+        break;
+
+      case this.event.COMMENT:
+        // validate for comment
+        if (comment == null) {
+          throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);
+        }
+
+        subject = `#${event} - ${triggeredBy.username} commented on ${path}`;
+        vars = {
+          ...vars,
+          comment: comment.comment,
+        };
+        break;
+
+      default:
+        throw new Error(`unknown global notificaiton event: ${event}`);
+    }
+
+    return {
+      subject,
+      template,
+      vars,
+    };
+  }
+
+}
+
+module.exports = GlobalNotificationMailService;

+ 131 - 0
src/server/service/global-notification/global-notification-slack.js

@@ -0,0 +1,131 @@
+const logger = require('@alias/logger')('growi:service:GlobalNotificationSlackService'); // eslint-disable-line no-unused-vars
+const urljoin = require('url-join');
+
+/**
+ * sub service class of GlobalNotificationSetting
+ */
+class GlobalNotificationSlackService {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+    this.slack = crowi.getSlack();
+    this.type = crowi.model('GlobalNotificationSetting').TYPE.SLACK;
+    this.event = crowi.model('GlobalNotificationSetting').EVENT;
+  }
+
+  /**
+   * send slack global notification
+   *
+   * @memberof GlobalNotificationSlackService
+   *
+   * @param {string} event
+   * @param {string} path
+   * @param {User} triggeredBy user who triggered the event
+   * @param {{ comment: Comment, oldPath: string }} _ event specific vars
+   */
+  async fire(event, path, triggeredBy, vars) {
+    const GlobalNotification = this.crowi.model('GlobalNotificationSetting');
+    const notifications = await GlobalNotification.findSettingByPathAndEvent(event, path, this.type);
+
+    const messageBody = this.generateMessageBody(event, path, triggeredBy, vars);
+    const attachmentBody = this.generateAttachmentBody(event, path, triggeredBy, vars);
+
+    await Promise.all(notifications.map((notification) => {
+      return this.slack.sendGlobalNotification(messageBody, attachmentBody, notification.slackChannels);
+    }));
+  }
+
+  /**
+   * generate slack message body
+   *
+   * @memberof GlobalNotificationSlackService
+   *
+   * @param {string} event event name triggered
+   * @param {string} path path triggered the event
+   * @param {User} triggeredBy user triggered the event
+   * @param {{ comment: Comment, oldPath: string }} _ event specific vars
+   *
+   * @return  {string} slack message body
+   */
+  generateMessageBody(event, path, triggeredBy, { comment, oldPath }) {
+    const pageUrl = `<${urljoin(this.crowi.appService.getSiteUrl(), path)}|${path}>`;
+    const username = `<${urljoin(this.crowi.appService.getSiteUrl(), 'user', triggeredBy.username)}|${triggeredBy.username}>`;
+    let messageBody;
+
+    switch (event) {
+      case this.event.PAGE_CREATE:
+        messageBody = `:bell: ${username} created ${pageUrl}`;
+        break;
+      case this.event.PAGE_EDIT:
+        messageBody = `:bell: ${username} edited ${pageUrl}`;
+        break;
+      case this.event.PAGE_DELETE:
+        messageBody = `:bell: ${username} deleted ${pageUrl}`;
+        break;
+      case this.event.PAGE_MOVE:
+        // validate for page move
+        if (oldPath == null) {
+          throw new Error(`invalid vars supplied to GlobalNotificationSlackService.generateOption for event ${event}`);
+        }
+        // eslint-disable-next-line no-case-declarations
+        const oldPageUrl = `<${urljoin(this.crowi.appService.getSiteUrl(), oldPath)}|${oldPath}>`;
+        messageBody = `:bell: ${username} moved ${oldPageUrl} to ${pageUrl}`;
+        break;
+      case this.event.PAGE_LIKE:
+        messageBody = `:bell: ${username} liked ${pageUrl}`;
+        break;
+      case this.event.COMMENT:
+        // validate for comment
+        if (comment == null) {
+          throw new Error(`invalid vars supplied to GlobalNotificationSlackService.generateOption for event ${event}`);
+        }
+        messageBody = `:bell: ${username} commented on ${pageUrl}`;
+        break;
+      default:
+        throw new Error(`unknown global notificaiton event: ${event}`);
+    }
+
+    return messageBody;
+  }
+
+  /**
+   * generate slack attachment body
+   *
+   * @memberof GlobalNotificationSlackService
+   *
+   * @param {string} event event name triggered
+   * @param {string} path path triggered the event
+   * @param {User} triggeredBy user triggered the event
+   * @param {{ comment: Comment, oldPath: string }} _ event specific vars
+   *
+   * @return  {string} slack attachment body
+   */
+  generateAttachmentBody(event, path, triggeredBy, { comment, oldPath }) {
+    const attachmentBody = '';
+
+    // TODO: create attachment
+    // attachment body is intended for comment or page diff
+
+    // switch (event) {
+    //   case this.event.PAGE_CREATE:
+    //     break;
+    //   case this.event.PAGE_EDIT:
+    //     break;
+    //   case this.event.PAGE_DELETE:
+    //     break;
+    //   case this.event.PAGE_MOVE:
+    //     break;
+    //   case this.event.PAGE_LIKE:
+    //     break;
+    //   case this.event.COMMENT:
+    //     break;
+    //   default:
+    //     throw new Error(`unknown global notificaiton event: ${event}`);
+    // }
+
+    return attachmentBody;
+  }
+
+}
+
+module.exports = GlobalNotificationSlackService;

+ 44 - 0
src/server/service/global-notification/index.js

@@ -0,0 +1,44 @@
+const logger = require('@alias/logger')('growi:service:GlobalNotificationService');
+const GloabalNotificationSlack = require('./global-notification-slack');
+const GloabalNotificationMail = require('./global-notification-mail');
+
+/**
+ * service class of GlobalNotificationSetting
+ */
+class GlobalNotificationService {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+    this.defaultLang = 'en-US'; // TODO: get defaultLang from app global config
+
+    this.gloabalNotificationMail = new GloabalNotificationMail(crowi);
+    this.gloabalNotificationSlack = new GloabalNotificationSlack(crowi);
+  }
+
+  /**
+   * fire global notification
+   *
+   * @memberof GlobalNotificationService
+   *
+   * @param {string} event event name triggered
+   * @param {string} path path triggered the event
+   * @param {User} triggeredBy user who triggered the event
+   * @param {object} vars event specific vars
+   */
+  async fire(event, path, triggeredBy, vars = {}) {
+    logger.debug(`global notficatoin event ${event} was triggered`);
+
+    // validation
+    if (event == null || path == null || triggeredBy == null) {
+      throw new Error(`invalid vars supplied to GlobalNotificationSlackService.generateOption for event ${event}`);
+    }
+
+    await Promise.all([
+      this.gloabalNotificationMail.fire(event, path, triggeredBy, vars),
+      this.gloabalNotificationSlack.fire(event, path, triggeredBy, vars),
+    ]);
+  }
+
+}
+
+module.exports = GlobalNotificationService;

+ 1 - 1
src/server/util/mailer.js

@@ -93,7 +93,7 @@ module.exports = function(crowi) {
     return mc;
   }
 
-  function send(config, callback) {
+  function send(config, callback = () => {}) {
     if (mailer) {
       const templateVars = config.vars || {};
       return swig.renderFile(

+ 2 - 2
src/server/util/middlewares.js

@@ -1,6 +1,6 @@
 const debug = require('debug')('growi:lib:middlewares');
 const logger = require('@alias/logger')('growi:lib:middlewares');
-const { distanceInWordsStrict } = require('date-fns');
+const { formatDistanceStrict } = require('date-fns');
 const pathUtils = require('growi-commons').pathUtils;
 const md5 = require('md5');
 const entities = require('entities');
@@ -121,7 +121,7 @@ module.exports = (crowi, app) => {
       });
 
       swig.setFilter('dateDistance', (input) => {
-        return distanceInWordsStrict(input, new Date());
+        return formatDistanceStrict(input, new Date());
       });
 
       swig.setFilter('nl2br', (string) => {

+ 32 - 0
src/server/util/slack.js

@@ -168,6 +168,32 @@ module.exports = function(crowi) {
     return message;
   };
 
+  /**
+   * For GlobalNotification
+   *
+   * @param {string} messageBody
+   * @param {string} attachmentBody
+   * @param {string} slackChannel
+  */
+  const prepareSlackMessageForGlobalNotification = async(messageBody, attachmentBody, slackChannel) => {
+    const appTitle = crowi.appService.getAppTitle();
+
+    const attachment = {
+      color: '#263a3c',
+      text: attachmentBody,
+      mrkdwn_in: ['text'],
+    };
+
+    const message = {
+      channel: `#${slackChannel}`,
+      username: appTitle,
+      text: messageBody,
+      attachments: [attachment],
+    };
+
+    return message;
+  };
+
   const getSlackMessageTextForPage = function(path, pageId, user, updateType) {
     let text;
     const url = crowi.appService.getSiteUrl();
@@ -204,6 +230,12 @@ module.exports = function(crowi) {
     return slackPost(messageObj);
   };
 
+  slack.sendGlobalNotification = async(messageBody, attachmentBody, slackChannel) => {
+    const messageObj = await prepareSlackMessageForGlobalNotification(messageBody, attachmentBody, slackChannel);
+
+    return slackPost(messageObj);
+  };
+
   const slackPost = (messageObj) => {
     // when incoming Webhooks is prioritized
     if (configManager.getConfig('notification', 'slack:isIncomingWebhookPrioritized')) {

+ 1 - 1
src/server/util/swigFunctions.js

@@ -73,7 +73,7 @@ module.exports = function(crowi, app, req, locals) {
   locals.pathUtils = pathUtils;
 
   locals.noCdn = function() {
-    return cdnResourcesService.noCdn();
+    return cdnResourcesService.noCdn;
   };
 
   locals.cdnScriptTag = function(name) {

+ 5 - 6
src/server/views/admin/global-notification-detail.html

@@ -57,22 +57,21 @@
               <div class="form-group form-inline">
                 <h3>{{ t('notification_setting.notify_to') }}</h3>
                 <div class="radio radio-primary">
-                  <input type="radio" id="mail" name="notificationGlobal[notifyToType]" value="mail" {% if setting.__t == 'mail' %}checked{% endif %} checked>
+                  <input type="radio" id="mail" name="notificationGlobal[notifyToType]" value="mail" {% if setting.__t == 'mail' %}checked{% endif %}>
                   <label for="mail">
                     <p class="font-weight-bold">Email</p>
                   </label>
                 </div>
                 <div class="radio radio-primary">
-                  <input type="radio" id="slack" name="notificationGlobal[notifyToType]" value="slack" {% if setting.__t == 'slack' %}checked{% endif %} disabled>
+                  <input type="radio" id="slack" name="notificationGlobal[notifyToType]" value="slack" {% if setting.__t == 'slack' %}checked{% endif %}>
                   <label for="slack">
                     <p class="font-weight-bold">Slack</p>
                   </label>
                 </div>
               </div>
 
-              <!-- <div class="form-group notify-to-option {% if setting.__t != 'mail' %}d-none{% endif %}" id="mail-input"> -->
-              <div class="form-group notify-to-option" id="mail-input">
-                <input class="form-control" type="text" name="notificationGlobal[toEmail]" value="{{ setting.toEmail || '' }}">
+              <div class="form-group notify-to-option {% if setting.__t != 'mail' %}d-none{% endif %}" id="mail-input">
+                <input class="form-control" type="text" name="notificationGlobal[toEmail]" placeholder="Email" value="{{ setting.toEmail || '' }}">
                 <p class="help">
                   <b>Hint: </b>
                   <a href="https://ifttt.com/create" target="_blank">{{ t('notification_setting.email.ifttt_link') }} <i class="icon-share-alt"></i></a>
@@ -80,7 +79,7 @@
               </div>
 
               <div class="form-group notify-to-option {% if setting.__t != 'slack' %}d-none{% endif %}" id="slack-input">
-                <input class="form-control" type="text" name="notificationGlobal[slackChannels]" value="{{ setting.slackChannels || '' }}" disabled>
+                <input class="form-control" type="text" name="notificationGlobal[slackChannels]" placeholder="Slack Channel" value="{{ setting.slackChannels || '' }}">
               </div>
             </fieldset>
 

+ 29 - 25
src/server/views/widget/page_alerts.html

@@ -13,35 +13,18 @@
       </p>
     {% endif %}
 
-    {% if isTrashPage() %}
-    <div class="alert alert-warning alert-trash d-flex align-items-center justify-content-between">
-      <div>
-        <i class="icon-trash" aria-hidden="true"></i>
-        This page is in the trash.
-        {% if page.isDeleted() %}
-        <br>Deleted by <img src="{{ page.lastUpdateUser|picture }}" class="picture picture-sm img-circle"> {{ page.lastUpdateUser.name }} at {{ page.updatedAt|datetz('Y-m-d H:i:s') }}
-        {% endif %}
-      </div>
-      {% if page.isDeleted() and user %}
-      <ul class="list-inline">
-        <li>
-          <button href="#" class="btn btn-default btn-rounded btn-sm" data-target="#putBackPage" data-toggle="modal"><i class="icon-action-undo" aria-hidden="true"></i> {{ t('Put Back') }}</button>
-        </li>
-        <li>
-            <button href="#" class="btn btn-danger btn-rounded btn-sm" {% if !user.canDeleteCompletely(page.creator._id) %} disabled="disabled" {% endif %} data-target="#deletePage" data-toggle="modal"><i class="icon-fire" aria-hidden="true"></i> {{ t('Delete Completely') }}</button>
-        </li>
-      </ul>{# /.pull-right #}
-      {% endif %}
-    </div>
-    {% endif %}
-
-    {% if not page.isDeleted() and (redirectFrom or req.query.renamed or req.query.redirectFrom) %}
+    {% if redirectFrom or req.query.renamed or req.query.redirectFrom %}
     <div class="alert alert-info alert-moved d-flex align-items-center justify-content-between">
       <span>
         {% set fromPath = req.query.renamed or req.query.redirectFrom %}
-        <strong>{{ t('Moved') }}: </strong> {{ t('page_page.notice.moved', req.sanitize(fromPath)) }}
+        {% if redirectFrom or req.query.redirectFrom %}
+          <strong>{{ t('Redirected') }}:</strong> {{ t('page_page.notice.redirected', req.sanitize(fromPath)) }}
+        {% endif %}
+        {% if req.query.renamed %}
+          <strong>{{ t('Moved') }}:</strong> {{ t('page_page.notice.moved', req.sanitize(fromPath)) }}
+        {% endif %}
       </span>
-      {% if user %}
+      {% if user and not page.isDeleted() %}
       <form role="form" id="unlink-page-form" onsubmit="return false;">
         <input type="hidden" name="_csrf" value="{{ csrf() }}">
         <input type="hidden" name="path" value="{{ path }}">
@@ -81,5 +64,26 @@
       {{ dmessage }}
     </div>
     {% endif %}
+
+    {% if isTrashPage() %}
+    <div class="alert alert-warning alert-trash d-flex align-items-center justify-content-between">
+      <div>
+        This page is in the trash <i class="icon-trash" aria-hidden="true"></i>.
+        {% if page.isDeleted() %}
+        <br>Deleted by <img src="{{ page.lastUpdateUser|picture }}" class="picture picture-sm img-circle"> {{ page.lastUpdateUser.name }} at {{ page.updatedAt|datetz('Y-m-d H:i:s') }}
+        {% endif %}
+      </div>
+      {% if page.isDeleted() and user %}
+      <ul class="list-inline">
+        <li>
+          <button href="#" class="btn btn-default btn-rounded btn-sm" data-target="#putBackPage" data-toggle="modal"><i class="icon-action-undo" aria-hidden="true"></i> {{ t('Put Back') }}</button>
+        </li>
+        <li>
+            <button href="#" class="btn btn-danger btn-rounded btn-sm" {% if !user.canDeleteCompletely(page.creator._id) %} disabled="disabled" {% endif %} data-target="#deletePage" data-toggle="modal"><i class="icon-fire" aria-hidden="true"></i> {{ t('Delete Completely') }}</button>
+        </li>
+      </ul>{# /.pull-right #}
+      {% endif %}
+    </div>
+    {% endif %}
   </div>
 </div>

+ 8 - 7
yarn.lock

@@ -3307,9 +3307,10 @@ date-fns@1.30.1:
   version "1.30.1"
   resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c"
 
-date-fns@^1.29.0:
-  version "1.29.0"
-  resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.29.0.tgz#12e609cdcb935127311d04d33334e2960a2a54e6"
+date-fns@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.0.tgz#52f05c6ae1fe0e395670082c72b690ab781682d0"
+  integrity sha512-nGZDA64Ktq5uTWV4LEH3qX+foV4AguT5qxwRlJDzJtf57d4xLNwtwrfb7SzKCoikoae8Bvxf0zdaEG/xWssp/w==
 
 date-now@^0.1.4:
   version "0.1.4"
@@ -4019,10 +4020,10 @@ eslint-config-airbnb@^17.1.0:
     object.assign "^4.1.0"
     object.entries "^1.0.4"
 
-eslint-config-weseek@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/eslint-config-weseek/-/eslint-config-weseek-1.0.2.tgz#ae96be9d1ef81ac5cdb4c76212badea767f56e00"
-  integrity sha512-Ak3nV0qh3fx8029h05WB9Eql62hQk+bzOAqdeY3jmiPaYo2TI2kRD+gqe4a8wOkMmcKrQDxynuwmP9tmhkGBtw==
+eslint-config-weseek@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/eslint-config-weseek/-/eslint-config-weseek-1.0.3.tgz#420f583371447def71af11a78baf39d65a3d9e4a"
+  integrity sha512-AXOuaZomA/h34EHMT+CmhU7TTDbsyqCl702yaY0PGt6wype/YWra9phTbPyHjuI+Uh8gh9eKX2tAhnruKK3Ivw==
   dependencies:
     eslint-config-airbnb "^17.1.0"