Răsfoiți Sursa

Merge branch 'master' into feat/integrate-with-hackmd

# Conflicts:
#	CHANGES.md
#	lib/routes/page.js
#	resource/js/legacy/crowi-form.js
#	resource/styles/scss/_on-edit.scss
Yuki Takei 7 ani în urmă
părinte
comite
e3eb8b82af

+ 9 - 1
CHANGES.md

@@ -2,19 +2,27 @@ CHANGES
 ========
 
 ## 3.2.0-RC
+
 * Feature: Simultaneously edit by multiple people with HackMD integration
 * Support: Upgrade libs
     * react
     * react-dom
 
-## 3.1.12-RC
+## 3.1.13-RC
+
+* 
+
+## 3.1.12
 
 * Feature: Add XSS Settings
+* Feature: Notify to Slack when comment
 * Improvement: Prevent XSS in various situations
+* Improvement: Show forbidden message when the user accesses to ungranted page
 * Improvement: Add overlay styles for pasting file to comment form
 * Fix: Omit unnecessary css link
     * Introduced by 3.1.10
 * Fix: Invitation mail do not be sent
+* Fix: Edit template button on New Page modal doesn't work
 
 ## 3.1.11
 

+ 4 - 1
lib/form/comment.js

@@ -8,5 +8,8 @@ module.exports = form(
   field('commentForm.revision_id').trim().required(),
   field('commentForm.comment').trim().required(),
   field('commentForm.comment_position').trim().toInt(),
-  field('commentForm.is_markdown').trim().toBooleanStrict()
+  field('commentForm.is_markdown').trim().toBooleanStrict(),
+
+  field('slackNotificationForm.isSlackEnabled').trim().toBooleanStrict().required(),
+  field('slackNotificationForm.slackChannels').trim(),
 );

+ 1 - 0
lib/models/config.js

@@ -567,6 +567,7 @@ module.exports = function(crowi) {
       attrWhiteList: Config.attrWhiteList(config),
       highlightJsStyleBorder: Config.highlightJsStyleBorder(config),
       isSavedStatesOfTabChanges: Config.isSavedStatesOfTabChanges(config),
+      hasSlackConfig: Config.hasSlackConfig(config),
       env: {
         PLANTUML_URI: env.PLANTUML_URI || null,
         BLOCKDIAG_URI: env.BLOCKDIAG_URI || null,

+ 7 - 6
lib/models/page-group-relation.js

@@ -176,13 +176,14 @@ class PageGroupRelation {
    * @returns is exists granted group(or not)
    * @memberof PageGroupRelation
    */
-  static isExistsGrantedGroupForPageAndUser(pageData, userData) {
-    var UserGroupRelation = PageGroupRelation.crowi.model('UserGroupRelation');
+  static async isExistsGrantedGroupForPageAndUser(pageData, userData) {
+    const UserGroupRelation = PageGroupRelation.crowi.model('UserGroupRelation');
 
-    return this.findByPage(pageData)
-      .then(pageRelation => {
-        return UserGroupRelation.isRelatedUserForGroup(userData, pageRelation.relatedGroup);
-      });
+    const pageRelation = await this.findByPage(pageData);
+    if (pageRelation == null) {
+      return false;
+    }
+    return await UserGroupRelation.isRelatedUserForGroup(userData, pageRelation.relatedGroup);
   }
 
   /**

+ 35 - 35
lib/models/page.js

@@ -1,3 +1,16 @@
+/**
+ * The Exception class thrown when the user has no grant to see the page
+ *
+ * @class UserHasNoGrantException
+ */
+class UserHasNoGrantException {
+  constructor(message, user) {
+    this.name = this.constructor.name;
+    this.message = message;
+    this.user = user;
+  }
+}
+
 module.exports = function(crowi) {
   var debug = require('debug')('growi:models:page')
     , mongoose = require('mongoose')
@@ -490,46 +503,33 @@ module.exports = function(crowi) {
   };
 
   // find page and check if granted user
-  pageSchema.statics.findPage = function(path, userData, revisionId, ignoreNotFound) {
-    const self = this;
+  pageSchema.statics.findPage = async function(path, userData, revisionId, ignoreNotFound) {
     const PageGroupRelation = crowi.model('PageGroupRelation');
 
-    return new Promise(function(resolve, reject) {
-      self.findOne({path: path}, function(err, pageData) {
-        if (err) {
-          return reject(err);
-        }
+    const pageData = await this.findOne({path: path});
 
-        if (pageData === null) {
-          if (ignoreNotFound) {
-            return resolve(null);
-          }
+    if (pageData == null) {
+      if (ignoreNotFound) {
+        return null;
+      }
 
-          const pageNotFoundError = new Error('Page Not Found');
-          pageNotFoundError.name = 'Crowi:Page:NotFound';
-          return reject(pageNotFoundError);
-        }
+      const pageNotFoundError = new Error('Page Not Found');
+      pageNotFoundError.name = 'Crowi:Page:NotFound';
+      throw new Error(pageNotFoundError);
+    }
 
-        if (!pageData.isGrantedFor(userData)) {
-          PageGroupRelation.isExistsGrantedGroupForPageAndUser(pageData, userData)
-            .then(isExists => {
-              if (!isExists) {
-                return reject(new Error('Page is not granted for the user')); //PAGE_GRANT_ERROR, null);
-              }
-              else {
-                // return resolve(pageData);
-                self.populatePageData(pageData, revisionId || null).then(resolve).catch(reject);
-              }
-            })
-            .catch(function(err) {
-              return reject(err);
-            });
-        }
-        else {
-          self.populatePageData(pageData, revisionId || null).then(resolve).catch(reject);
-        }
-      });
-    });
+    if (!pageData.isGrantedFor(userData)) {
+      const isRelationExists = await PageGroupRelation.isExistsGrantedGroupForPageAndUser(pageData, userData);
+      if (isRelationExists) {
+        return await this.populatePageData(pageData, revisionId || null);
+      }
+      else {
+        throw new UserHasNoGrantException('Page is not granted for the user', userData);
+      }
+    }
+    else {
+      return await this.populatePageData(pageData, revisionId || null);
+    }
   };
 
   /**

+ 33 - 8
lib/routes/comment.js

@@ -2,7 +2,9 @@ module.exports = function(crowi, app) {
   'use strict';
 
   const debug = require('debug')('growi:routs:comment')
+    , logger = require('@alias/logger')('growi:routes:comment')
     , Comment = crowi.model('Comment')
+    , User = crowi.model('User')
     , Page = crowi.model('Page')
     , ApiResponse = require('../util/apiResponse')
     , actions = {}
@@ -50,18 +52,19 @@ module.exports = function(crowi, app) {
    * @apiParam {Number} comment_position=-1 Line number of the comment
    */
   api.add = async function(req, res) {
-    const form = req.form.commentForm;
+    const commentForm = req.form.commentForm;
+    const slackNotificationForm = req.form.slackNotificationForm;
 
     if (!req.form.isValid) {
       // return res.json(ApiResponse.error('Invalid comment.'));
       return res.json(ApiResponse.error('コメントを入力してください。'));
     }
 
-    const pageId = form.page_id;
-    const revisionId = form.revision_id;
-    const comment = form.comment;
-    const position = form.comment_position || -1;
-    const isMarkdown = form.is_markdown;
+    const pageId = commentForm.page_id;
+    const revisionId = commentForm.revision_id;
+    const comment = commentForm.comment;
+    const position = commentForm.comment_position || -1;
+    const isMarkdown = commentForm.is_markdown;
 
     const createdComment = await Comment.create(pageId, req.user._id, revisionId, comment, position, isMarkdown)
       .catch(function(err) {
@@ -69,12 +72,34 @@ module.exports = function(crowi, app) {
       });
 
     // update page
-    await Page.findOneAndUpdate({ _id: pageId }, {
+    const page = await Page.findOneAndUpdate({ _id: pageId }, {
       lastUpdateUser: req.user,
       updatedAt: new Date()
     });
 
-    return res.json(ApiResponse.success({comment: createdComment}));
+    res.json(ApiResponse.success({comment: createdComment}));
+
+    // slack notification
+    if (slackNotificationForm.isSlackEnabled) {
+      const user = await User.findUserByUsername(req.user.username);
+      const path = page.path;
+      const channels = slackNotificationForm.slackChannels;
+
+      if (channels) {
+        page.updateSlackChannel(channels).catch(err => {
+          logger.error('Error occured in updating slack channels: ', err);
+        });
+
+        const promises = channels.split(',').map(function(chan) {
+          return crowi.slack.postComment(createdComment, user, chan, path);
+        });
+
+        Promise.all(promises)
+          .catch(err => {
+            logger.error('Error occured in sending slack notification: ', err);
+          });
+      }
+    }
   };
 
   /**

+ 103 - 45
lib/routes/page.js

@@ -1,15 +1,16 @@
 module.exports = function(crowi, app) {
   'use strict';
 
-  var debug = require('debug')('growi:routes:page')
+  const debug = require('debug')('growi:routes:page')
+    , logger = require('@alias/logger')('growi:routes:page')
     , Page = crowi.model('Page')
     , User = crowi.model('User')
     , Config   = crowi.model('Config')
     , config   = crowi.getConfig()
     , Revision = crowi.model('Revision')
     , Bookmark = crowi.model('Bookmark')
-    , UserGroupRelation = crowi.model('UserGroupRelation')
     , PageGroupRelation = crowi.model('PageGroupRelation')
+    , UpdatePost = crowi.model('UpdatePost')
     , ApiResponse = require('../util/apiResponse')
     , interceptorManager = crowi.getInterceptorManager()
     , pagePathUtil = require('../util/pagePathUtil')
@@ -221,7 +222,7 @@ module.exports = function(crowi, app) {
   };
 
   actions.pageListShowForCrowiPlus = function(req, res) {
-    var path = getPathFromRequest(req);
+    let path = getPathFromRequest(req);
     // omit the slash of the last
     path = path.replace((/\/$/), '');
     // redirect
@@ -257,9 +258,10 @@ module.exports = function(crowi, app) {
       pageRelatedGroup: null,
       template: null,
       revisionHackmdSynced: null,
+      slack: '',
     };
 
-    let pageTeamplate = 'customlayout-selector/page';
+    let view = 'customlayout-selector/page';
 
     let isRedirect = false;
     Page.findPage(path, req.user, req.query.revision)
@@ -294,13 +296,19 @@ module.exports = function(crowi, app) {
             renderVars.pageRelatedGroup = pageGroupRelation.relatedGroup;
           }
         })
+        .then(() => {
+          return getSlackChannels(page);
+        })
+        .then((channels) => {
+          renderVars.slack = channels;
+        })
         .then(function() {
           const userPage = isUserPage(page.path);
           let userData = null;
 
           if (userPage) {
             // change template
-            pageTeamplate = 'customlayout-selector/user_page';
+            view = 'customlayout-selector/user_page';
 
             return User.findUserByUsername(User.getUsernameByPath(page.path))
             .then(function(data) {
@@ -326,18 +334,30 @@ module.exports = function(crowi, app) {
         });
       }
     })
-    // look for templates if page not exists
+    // page is not found or user is forbidden
     .catch(function(err) {
-      pageTeamplate = 'customlayout-selector/not_found';
+      let isForbidden = false;
+      if (err.name === 'UserHasNoGrantException') {
+        isForbidden = true;
+      }
 
-      return Page.findTemplate(path)
-        .then(template => {
-          if (template) {
-            template = replacePlaceholders(template, req);
-          }
+      if (isForbidden) {
+        view = 'customlayout-selector/forbidden';
+        return;
+      }
+      else {
+        view = 'customlayout-selector/not_found';
 
-          renderVars.template = template;
-        });
+        // look for templates
+        return Page.findTemplate(path)
+          .then(template => {
+            if (template) {
+              template = replacePlaceholders(template, req);
+            }
+
+            renderVars.template = template;
+          });
+      }
     })
     // get list pages
     .then(function() {
@@ -362,16 +382,26 @@ module.exports = function(crowi, app) {
             return interceptorManager.process('beforeRenderPage', req, res, renderVars);
           })
           .then(function() {
-            res.render(req.query.presentation ? 'page_presentation' : pageTeamplate, renderVars);
+            res.render(req.query.presentation ? 'page_presentation' : view, renderVars);
           })
           .catch(function(err) {
-            console.log(err);
-            debug('Error on rendering pageListShowForCrowiPlus', err);
+            logger.error('Error on rendering pageListShowForCrowiPlus', err);
           });
       }
     });
   };
 
+  const getSlackChannels = async page => {
+    if (page.extended.slack) {
+      return page.extended.slack;
+    }
+    else {
+      const data = await UpdatePost.findSettingsByPath(page.path);
+      const channels = data.map(e => e.channel).join(', ');
+      return channels;
+    }
+  };
+
   const replacePlaceholders = (template, req) => {
     const definitions = {
       pagepath: getPathFromRequest(req),
@@ -452,22 +482,28 @@ module.exports = function(crowi, app) {
     });
   };
 
-  function renderPage(pageData, req, res) {
-    // create page
+  async function renderPage(pageData, req, res, isForbidden) {
     if (!pageData) {
-      const path = getPathFromRequest(req);
-      return Page.findTemplate(path)
-        .then(template => {
-          if (template) {
-            template = replacePlaceholders(template, req);
-          }
+      let view = 'customlayout-selector/not_found';
+      let template = undefined;
 
-          return res.render('customlayout-selector/not_found', {
-            author: {},
-            page: false,
-            template,
-          });
-        });
+      // forbidden
+      if (isForbidden) {
+        view = 'customlayout-selector/forbidden';
+      }
+      else {
+        const path = getPathFromRequest(req);
+        template = await Page.findTemplate(path);
+        if (template != null) {
+          template = replacePlaceholders(template, req);
+        }
+      }
+
+      return res.render(view, {
+        author: {},
+        page: false,
+        template,
+      });
     }
 
 
@@ -475,14 +511,15 @@ module.exports = function(crowi, app) {
       return res.redirect(encodeURI(pageData.redirectTo + '?redirectFrom=' + pagePathUtil.encodePagePath(pageData.path)));
     }
 
-    var renderVars = {
+    const renderVars = {
       path: pageData.path,
       page: pageData,
       revision: pageData.revision || {},
       author: pageData.revision.author || false,
+      slack: '',
     };
-    var userPage = isUserPage(pageData.path);
-    var userData = null;
+    const userPage = isUserPage(pageData.path);
+    let userData = null;
 
     Revision.findRevisionList(pageData.path, {})
     .then(function(tree) {
@@ -496,6 +533,12 @@ module.exports = function(crowi, app) {
         renderVars.pageRelatedGroup = pageGroupRelation.relatedGroup;
       }
     })
+    .then(() => {
+      return getSlackChannels(pageData);
+    })
+    .then(channels => {
+      renderVars.slack = channels;
+    })
     .then(function() {
       if (userPage) {
         return User.findUserByUsername(User.getUsernameByPath(pageData.path))
@@ -525,11 +568,11 @@ module.exports = function(crowi, app) {
     }).then(function() {
       return interceptorManager.process('beforeRenderPage', req, res, renderVars);
     }).then(function() {
-      var defaultPageTeamplate = 'customlayout-selector/page';
+      let view = 'customlayout-selector/page';
       if (userData) {
-        defaultPageTeamplate = 'customlayout-selector/user_page';
+        view = 'customlayout-selector/user_page';
       }
-      res.render(req.query.presentation ? 'page_presentation' : defaultPageTeamplate, renderVars);
+      res.render(req.query.presentation ? 'page_presentation' : view, renderVars);
     }).catch(function(err) {
       debug('Error: renderPage()', err);
       if (err) {
@@ -556,7 +599,14 @@ module.exports = function(crowi, app) {
       }
 
       return renderPage(page, req, res);
-    }).catch(function(err) {
+    })
+    // page is not found or the user is forbidden
+    .catch(function(err) {
+
+      let isForbidden = false;
+      if (err.name === 'UserHasNoGrantException') {
+        isForbidden = true;
+      }
 
       const normalizedPath = Page.normalizePath(path);
       if (normalizedPath !== path) {
@@ -567,7 +617,7 @@ module.exports = function(crowi, app) {
       // これ以前に定義されているはずなので、こうしてしまって問題ない。
       if (!Page.isCreatableName(path)) {
         // 削除済みページの場合 /trash 以下に移動しているので creatableName になっていないので、表示を許可
-        debug('Page is not creatable name.', path);
+        logger.warn('Page is not creatable name.', path);
         res.redirect('/');
         return ;
       }
@@ -585,9 +635,9 @@ module.exports = function(crowi, app) {
           return res.redirect(pagePathUtil.encodePagePath(path) + '/');
         }
         else {
-          var fixed = Page.fixToCreatableName(path);
+          const fixed = Page.fixToCreatableName(path);
           if (fixed !== path) {
-            debug('fixed page name', fixed);
+            logger.warn('fixed page name', fixed);
             res.redirect(pagePathUtil.encodePagePath(fixed));
             return ;
           }
@@ -599,7 +649,7 @@ module.exports = function(crowi, app) {
 
           // render editor
           debug('Catch pageShow', err);
-          return renderPage(null, req, res);
+          return renderPage(null, req, res, isForbidden);
         }
       }).catch(function(err) {
         debug('Error on rendering pageShow (redirect to portal)', err);
@@ -669,11 +719,19 @@ module.exports = function(crowi, app) {
       // TODO: move to events
       if (notify.slack) {
         if (notify.slack.on && notify.slack.channel) {
-          data.updateSlackChannel(notify.slack.channel).then(function() {}).catch(function() {});
+          data.updateSlackChannel(notify.slack.channel)
+          .catch(err => {
+            logger.error('Error occured in updating slack channels: ', err);
+          });
 
           if (crowi.slack) {
-            notify.slack.channel.split(',').map(function(chan) {
-              crowi.slack.post(pageData, req.user, chan, updateOrCreate, previousRevision);
+            const promises = notify.slack.channel.split(',').map(function(chan) {
+              return crowi.slack.postPage(pageData, req.user, chan, updateOrCreate, previousRevision);
+            });
+
+            Promise.all(promises)
+            .catch(err => {
+              logger.error('Error occured in sending slack notification: ', err);
             });
           }
         }

+ 115 - 53
lib/util/slack.js

@@ -11,19 +11,37 @@ module.exports = function(crowi) {
     Slack = require('slack-node'),
     slack = {};
 
-  const postWithIwh = function(messageObj, callback) {
-    const client = new Slack();
-    client.setWebhook(config.notification['slack:incomingWebhookUrl']);
-    client.webhook(messageObj, callback);
+  const postWithIwh = function(messageObj) {
+    return new Promise((resolve, reject) => {
+      const client = new Slack();
+      client.setWebhook(config.notification['slack:incomingWebhookUrl']);
+      client.webhook(messageObj, function(err, res) {
+        if (err) {
+          debug('Post error', err, res);
+          debug('Sent data to slack is:', messageObj);
+          return reject(err);
+        }
+        resolve(res);
+      });
+    });
   };
 
-  const postWithWebApi = function(messageObj, callback) {
-    const client = new Slack(config.notification['slack:token']);
-    // stringify attachments
-    if (messageObj.attachments != null) {
-      messageObj.attachments = JSON.stringify(messageObj.attachments);
-    }
-    client.api('chat.postMessage', messageObj, callback);
+  const postWithWebApi = function(messageObj) {
+    return new Promise((resolve, reject) => {
+      const client = new Slack(config.notification['slack:token']);
+      // stringify attachments
+      if (messageObj.attachments != null) {
+        messageObj.attachments = JSON.stringify(messageObj.attachments);
+      }
+      client.api('chat.postMessage', messageObj, function(err, res) {
+        if (err) {
+          debug('Post error', err, res);
+          debug('Sent data to slack is:', messageObj);
+          return reject(err);
+        }
+        resolve(res);
+      });
+    });
   };
 
   const convertMarkdownToMrkdwn = function(body) {
@@ -80,9 +98,23 @@ module.exports = function(crowi) {
     return diffText;
   };
 
-  const prepareSlackMessage = function(page, user, channel, updateType, previousRevision) {
-    var url = config.crowi['app:url'] || '';
-    var body = page.revision.body;
+  const prepareAttachmentTextForComment = function(comment) {
+    let body = comment.comment;
+    if (body.length > 2000) {
+      body = body.substr(0, 2000) + '...';
+    }
+
+    if (comment.isMarkdown) {
+      return convertMarkdownToMrkdwn(body);
+    }
+    else {
+      return body;
+    }
+  };
+
+  const prepareSlackMessageForPage = function(page, user, channel, updateType, previousRevision) {
+    const url = config.crowi['app:url'] || '';
+    let body = page.revision.body;
 
     if (updateType == 'create') {
       body = prepareAttachmentTextForCreate(page, user);
@@ -91,7 +123,7 @@ module.exports = function(crowi) {
       body = prepareAttachmentTextForUpdate(page, user, previousRevision);
     }
 
-    var attachment = {
+    const attachment = {
       color: '#263a3c',
       author_name: '@' + user.username,
       author_link: url + '/user/' + user.username,
@@ -105,17 +137,43 @@ module.exports = function(crowi) {
       attachment.author_icon = user.image;
     }
 
-    var message = {
+    const message = {
       channel: '#' + channel,
       username: Config.appTitle(config),
-      text: getSlackMessageText(page.path, user, updateType),
+      text: getSlackMessageTextForPage(page.path, user, updateType),
       attachments: [attachment],
     };
 
     return message;
   };
 
-  const getSlackMessageText = function(path, user, updateType) {
+  const prepareSlackMessageForComment = function(comment, user, channel, path) {
+    const url = config.crowi['app:url'] || '';
+    const body = prepareAttachmentTextForComment(comment);
+
+    const attachment = {
+      color: '#263a3c',
+      author_name: '@' + user.username,
+      author_link: url + '/user/' + user.username,
+      author_icon: user.image,
+      text: body,
+      mrkdwn_in: ['text'],
+    };
+    if (user.image) {
+      attachment.author_icon = user.image;
+    }
+
+    const message = {
+      channel: '#' + channel,
+      username: Config.appTitle(config),
+      text: getSlackMessageTextForComment(path, user),
+      attachments: [attachment],
+    };
+
+    return message;
+  };
+
+  const getSlackMessageTextForPage = function(path, user, updateType) {
     let text;
     const url = config.crowi['app:url'] || '';
 
@@ -130,46 +188,50 @@ module.exports = function(crowi) {
     return text;
   };
 
+  const getSlackMessageTextForComment = function(path, user) {
+    const url = config.crowi['app:url'] || '';
+    const pageUrl = `<${url}${path}|${path}>`;
+    const text = `:speech_balloon: ${user.username} commented on ${pageUrl}`;
+
+    return text;
+  };
+
   // slack.post = function (channel, message, opts) {
-  slack.post = (page, user, channel, updateType, previousRevision) => {
-    const messageObj = prepareSlackMessage(page, user, channel, updateType, previousRevision);
+  slack.postPage = (page, user, channel, updateType, previousRevision) => {
+    const messageObj = prepareSlackMessageForPage(page, user, channel, updateType, previousRevision);
 
-    return new Promise((resolve, reject) => {
-      // define callback function for Promise
-      const callback = function(err, res) {
-        if (err) {
-          debug('Post error', err, res);
-          debug('Sent data to slack is:', messageObj);
-          return reject(err);
-        }
-        resolve(res);
-      };
+    return slackPost(messageObj);
+  };
 
-      // when incoming Webhooks is prioritized
-      if (Config.isIncomingWebhookPrioritized(config)) {
-        if (Config.hasSlackIwhUrl(config)) {
-          debug('posting message with IncomingWebhook');
-          postWithIwh(messageObj, callback);
-        }
-        else if (Config.hasSlackToken(config)) {
-          debug('posting message with Web API');
-          postWithWebApi(messageObj, callback);
-        }
+  slack.postComment = (comment, user, channel, path) => {
+    const messageObj = prepareSlackMessageForComment(comment, user, channel, path);
+
+    return slackPost(messageObj);
+  };
+
+  const slackPost = (messageObj) => {
+    // when incoming Webhooks is prioritized
+    if (Config.isIncomingWebhookPrioritized(config)) {
+      if (Config.hasSlackIwhUrl(config)) {
+        debug('posting message with IncomingWebhook');
+        return postWithIwh(messageObj);
       }
-      // else
-      else {
-        if (Config.hasSlackToken(config)) {
-          debug('posting message with Web API');
-          postWithWebApi(messageObj, callback);
-        }
-        else if (Config.hasSlackIwhUrl(config)) {
-          debug('posting message with IncomingWebhook');
-          postWithIwh(messageObj, callback);
-        }
+      else if (Config.hasSlackToken(config)) {
+        debug('posting message with Web API');
+        return postWithWebApi(messageObj);
       }
-
-      resolve();
-    });
+    }
+    // else
+    else {
+      if (Config.hasSlackToken(config)) {
+        debug('posting message with Web API');
+        return postWithWebApi(messageObj);
+      }
+      else if (Config.hasSlackIwhUrl(config)) {
+        debug('posting message with IncomingWebhook');
+        return postWithIwh(messageObj);
+      }
+    }
   };
 
   return slack;

+ 0 - 8
lib/util/swigFunctions.js

@@ -172,14 +172,6 @@ module.exports = function(crowi, app, req, locals) {
     return Config.isEnabledTimeline(config);
   };
 
-  locals.slackConfigured = function() {
-    let config = crowi.getConfig();
-    if (Config.hasSlackToken(config) || Config.hasSlackIwhUrl(config)) {
-      return true;
-    }
-    return false;
-  };
-
   locals.isUploadable = function() {
     let config = crowi.getConfig();
     return Config.isUploadable(config);

+ 2 - 20
lib/views/_form.html

@@ -24,27 +24,9 @@
       <div id="page-editor-options-selector"></div>
     </div>
 
-    <div class="form-inline page-form-setting d-flex align-items-center" id="page-form-setting" data-slack-configured="{{ slackConfigured() }}">
-      {% if slackConfigured() %}
-      <span class="input-group input-group-sm input-group-slack extended-setting m-r-5">
-        <div class="input-group-addon">
-          <img id="slack-mark-white" src="/images/icons/slack/mark-monochrome_white.svg" width="18" height="18">
-          <img id="slack-mark-black" src="/images/icons/slack/mark-monochrome_black.svg" width="18" height="18">
-          <input class="" type="checkbox" name="pageForm[notify][slack][on]" value="1">
-        </div>
-        <input class="form-control" type="text" name="pageForm[notify][slack][channel]" value="{{ page.extended.slack|default('') }}" placeholder="slack-channel-name"
-          id="page-form-slack-channel"
-          data-toggle="popover"
-          title="Slack通知"
-          data-content="通知するにはチェックを入れてください。カンマ区切りで複数チャンネルに通知することができます。"
-          data-trigger="focus"
-          data-placement="top"
-        >
-      </span>
-      {% endif %}
-
+    <div class="form-inline d-flex align-items-center" id="page-form-setting">
+      <div id="editor-slack-notification"></div>
       <div id="page-grant-selector"></div>
-
       <input type="hidden" id="page-grant" name="pageForm[grant]" value="{{ page.grant }}">
       <input type="hidden" id="grant-group" name="pageForm[grantUserGroupId]" value="{{ pageRelatedGroup._id.toString() }}">
       <input type="hidden" id="edit-form-csrf" name="_csrf" value="{{ csrf() }}">

+ 5 - 0
lib/views/customlayout-selector/forbidden.html

@@ -0,0 +1,5 @@
+{% if !layoutType() || 'crowi' === layoutType() %}
+  {% include '../layout-crowi/forbidden.html' %}
+{% else %}
+  {% include '../layout-growi/forbidden.html' %}
+{% endif %}

+ 41 - 0
lib/views/layout-crowi/forbidden.html

@@ -0,0 +1,41 @@
+{% extends 'base/layout.html' %}
+
+{% block content_header %}
+
+  {% block content_header_before %}
+  {% endblock %}
+
+  <div class="header-wrap">
+    <header id="page-header">
+      <div>
+        <div>
+          <h1 class="title" id="revision-path"></h1>
+          <div id="revision-url" class="url-line"></div>
+        </div>
+      </div>
+
+    </header>
+  </div>
+
+  {% block content_header_after %}
+  {% endblock %}
+
+{% endblock %} {# /content_head #}
+
+
+{% block content_main_before %}
+  {% include '../widget/page_alerts.html' %}
+{% endblock %}
+
+
+{% block content_main %}
+  {% include '../widget/forbidden_content.html' %}
+{% endblock %}
+
+
+{% block content_main_after %}
+{% endblock %}
+
+
+{% block content_footer %}
+{% endblock %}

+ 25 - 0
lib/views/layout-growi/forbidden.html

@@ -0,0 +1,25 @@
+{% extends 'base/layout.html' %}
+
+
+{% block content_header %}
+  {% include 'widget/header.html' %}
+{% endblock %}
+
+
+{% block content_main_before %}
+  {% include '../widget/page_alerts.html' %}
+{% endblock %}
+
+
+{% block content_main %}
+  <div class="row">
+    <div class="col-lg-10 col-md-9">
+      {% include '../widget/forbidden_content.html' %}
+    </div> {# /.col- #}
+  </div>
+{% endblock %}
+
+{% block body_end %}
+  <div id="crowi-modals">
+  </div>
+{% endblock %}

+ 14 - 1
lib/views/modal/create_page.html

@@ -46,7 +46,7 @@
 
         <div id="template-form" class="row form-horizontal m-t-15">
           <fieldset class="col-xs-12">
-            <legend>{{ t('template.modal_label.Create template under', parentPath(path)) | preventXss }}</legend>
+            <legend>{{ t('template.modal_label.Create template under', parentPath(path | preventXss)) }}</legend>
             <div class="d-flex create-page-input-container">
               <div class="create-page-input-row d-flex align-items-center">
                 <select id="template-type" class="form-control selectpicker" title="{{ t('template.option_label.select') }}">
@@ -64,6 +64,19 @@
           </fieldset>
         </div>
 
+        <script>
+          $('#template-type').on('change', function() {
+            // enable button
+            $('#link-to-template').removeClass('disabled');
+
+            // modify href
+            const value = $(this).val();
+            const pageName = (value === 'children') ? '_template' : '__template';
+            const link = '{{ page.path || path }}/' + pageName + '#edit-form';
+            $('#link-to-template').attr('href', link);
+          });
+        </script>
+
       </div><!-- /.modal-body -->
 
     </div><!-- /.modal-content -->

+ 50 - 0
lib/views/widget/forbidden_content.html

@@ -0,0 +1,50 @@
+{% block html_head_loading_legacy %}
+  <script src="{{ webpack_asset('js/legacy-form.js') }}" defer></script>  {# load legacy-form for using bootstrap-select(.selectpicker) #}
+  {% parent %}
+{% endblock %}
+
+<div class="row not-found-message-row m-b-20">
+  <div class="col-md-12">
+    <h2 class="text-muted">
+      <i class="icon-ban" aria-hidden="true"></i>
+      Forbidden
+    </h2>
+  </div>
+</div>
+
+<div id="content-main" class="content-main content-main-not-found page-list"
+  data-path="{{ path | preventXss }}"
+  data-path-shortname="{{ path|path2name | preventXss }}"
+  data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
+  >
+
+  <div class="row row-alerts">
+    <div class="col-xs-12">
+        <p class="alert alert-inverse alert-grant">
+          <i class="icon-fw icon-lock" aria-hidden="true"></i> Browsing of this page is restricted
+        </p>
+    </div>
+  </div>
+
+  <ul class="nav nav-tabs hidden-print">
+    <li class="nav-main-left-tab active">
+      <a href="#revision-body" data-toggle="tab">
+        <i class="icon-notebook"></i> List
+      </a>
+    </li>
+  </ul>
+
+  <div class="tab-content">
+    {# list view #}
+    <div class="p-t-10 active tab-pane page-list-container" id="revision-body">
+      {% if pages.length == 0 %}
+        <div class="m-t-10">
+          There are no pages under <strong>{{ path }}</strong>.
+        </div>
+      {% endif  %}
+
+      {% include '../widget/page_list.html' with { pages: pages, pager: pager, viewConfig: viewConfig } %}
+    </div>
+
+  </div>
+</div>

+ 1 - 0
lib/views/widget/page_content.html

@@ -8,6 +8,7 @@
   data-page-revision-id-hackmd-synced="{% if revisionHackmdSynced %}{{ revisionHackmdSynced.toString() }}{% endif %}"
   data-page-id-on-hackmd="{% if pageIdOnHackmd %}{{ pageIdOnHackmd.toString() }}{% endif %}"
   data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
+  data-slack-channels="{{ slack|default('') }}"
   >
 
   {% include 'page_alerts.html' %}

+ 1 - 1
package.json

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

+ 25 - 1
resource/js/app.js

@@ -22,6 +22,7 @@ import PageListSearch   from './components/PageListSearch';
 import PageHistory      from './components/PageHistory';
 import PageComments     from './components/PageComments';
 import CommentForm from './components/PageComment/CommentForm';
+import SlackNotification from './components/SlackNotification';
 import PageAttachment   from './components/PageAttachment';
 import SeenUserList     from './components/SeenUserList';
 import RevisionPath     from './components/Page/RevisionPath';
@@ -56,6 +57,7 @@ let pagePath;
 let pageContent = '';
 let markdown = '';
 let pageGrant = null;
+let slackChannels = '';
 if (mainContent !== null) {
   pageId = mainContent.getAttribute('data-page-id');
   pageRevisionId = mainContent.getAttribute('data-page-revision-id');
@@ -63,6 +65,7 @@ if (mainContent !== null) {
   pageRevisionIdHackmdSynced = mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null;
   pageIdOnHackmd = mainContent.getAttribute('data-page-id-on-hackmd') || null;
   pagePath = mainContent.attributes['data-path'].value;
+  slackChannels = mainContent.getAttribute('data-slack-channels');
   const rawText = document.getElementById('raw-text-original');
   if (rawText) {
     pageContent = rawText.innerHTML;
@@ -186,10 +189,31 @@ if (writeCommentElem) {
     }
   };
   ReactDOM.render(
-    <CommentForm crowi={crowi} crowiOriginRenderer={crowiRenderer} pageId={pageId} revisionId={pageRevisionId} pagePath={pagePath} onPostComplete={postCompleteHandler} editorOptions={editorOptions}/>,
+    <CommentForm crowi={crowi}
+      crowiOriginRenderer={crowiRenderer}
+      pageId={pageId}
+      revisionId={pageRevisionId}
+      pagePath={pagePath}
+      onPostComplete={postCompleteHandler}
+      editorOptions={editorOptions}
+      slackChannels = {slackChannels}/>,
     writeCommentElem);
 }
 
+// render slack notification form
+const editorSlackElem = document.getElementById('editor-slack-notification');
+if (editorSlackElem) {
+  ReactDOM.render(
+    <SlackNotification
+      crowi={crowi}
+      pageId={pageId}
+      pagePath={pagePath}
+      isSlackEnabled={false}
+      slackChannels={slackChannels}
+      formName='pageForm' />,
+    editorSlackElem);
+}
+
 // render OptionsSelector
 const pageEditorOptionsSelectorElem = document.getElementById('page-editor-options-selector');
 if (pageEditorOptionsSelectorElem) {

+ 51 - 17
resource/js/components/PageComment/CommentForm.js

@@ -12,6 +12,7 @@ import GrowiRenderer from '../../util/GrowiRenderer';
 
 import Editor from '../PageEditor/Editor';
 import CommentPreview from '../PageComment/CommentPreview';
+import SlackNotification from '../SlackNotification';
 
 /**
  *
@@ -39,6 +40,9 @@ export default class CommentForm extends React.Component {
       isUploadable,
       isUploadableFile,
       errorMessage: undefined,
+      hasSlackConfig: config.hasSlackConfig,
+      isSlackEnabled: false,
+      slackChannels: this.props.slackChannels,
     };
 
     this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, {mode: 'comment'});
@@ -50,6 +54,8 @@ export default class CommentForm extends React.Component {
     this.handleSelect = this.handleSelect.bind(this);
     this.apiErrorHandler = this.apiErrorHandler.bind(this);
     this.onUpload = this.onUpload.bind(this);
+    this.onChannelChange = this.onChannelChange.bind(this);
+    this.onSlackOnChange = this.onSlackOnChange.bind(this);
   }
 
   updateState(value) {
@@ -68,6 +74,14 @@ export default class CommentForm extends React.Component {
     this.renderHtml(this.state.comment);
   }
 
+  onSlackOnChange(value) {
+    this.setState({isSlackEnabled: value});
+  }
+
+  onChannelChange(value) {
+    this.setState({slackChannels: value});
+  }
+
   /**
    * Load data of comments and rerender <PageComments />
    */
@@ -83,26 +97,31 @@ export default class CommentForm extends React.Component {
         page_id: this.props.pageId,
         revision_id: this.props.revisionId,
         is_markdown: this.state.isMarkdown,
+      },
+      slackNotificationForm: {
+        isSlackEnabled: this.state.isSlackEnabled,
+        slackChannels: this.state.slackChannels,
       }
     })
-      .then((res) => {
-        if (this.props.onPostComplete != null) {
-          this.props.onPostComplete(res.comment);
-        }
-        this.setState({
-          comment: '',
-          isMarkdown: true,
-          html: '',
-          key: 1,
-          errorMessage: undefined,
-        });
-        // reset value
-        this.refs.editor.setValue('');
-      })
-      .catch(err => {
-        const errorMessage = err.message || 'An unknown error occured when posting comment';
-        this.setState({ errorMessage });
+    .then((res) => {
+      if (this.props.onPostComplete != null) {
+        this.props.onPostComplete(res.comment);
+      }
+      this.setState({
+        comment: '',
+        isMarkdown: true,
+        html: '',
+        key: 1,
+        errorMessage: undefined,
+        isSlackEnabled: false,
       });
+      // reset value
+      this.refs.editor.setValue('');
+    })
+    .catch(err => {
+      const errorMessage = err.message || 'An unknown error occured when posting comment';
+      this.setState({ errorMessage });
+    });
   }
 
   getCommentHtml() {
@@ -243,10 +262,24 @@ export default class CommentForm extends React.Component {
                       <input type="checkbox" id="comment-form-is-markdown" name="isMarkdown" checked={this.state.isMarkdown} value="1" onChange={this.updateStateCheckbox} /> Markdown
                     </label>
                   }
+
                   <div style={{flex: 1}}></div>{/* spacer */}
                   { this.state.errorMessage &&
                     <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>
                   }
+                  { this.state.hasSlackConfig &&
+                    <div className="form-inline d-flex align-items-center">
+                      <SlackNotification
+                      crowi={this.props.crowi}
+                      pageId={this.props.pageId}
+                      pagePath={this.props.pagePath}
+                      onSlackOnChange={this.onSlackOnChange}
+                      onChannelChange={this.onChannelChange}
+                      isSlackEnabled={this.state.isSlackEnabled}
+                      slackChannels={this.state.slackChannels}
+                      />
+                    </div>
+                  }
                   <Button type="submit" value="Submit" bsStyle="primary" className="fcbtn btn btn-sm btn-primary btn-outline btn-rounded btn-1b">
                     Comment
                   </Button>
@@ -268,6 +301,7 @@ CommentForm.propTypes = {
   revisionId: PropTypes.string,
   pagePath: PropTypes.string,
   editorOptions: PropTypes.object,
+  slackChannels: PropTypes.string,
 };
 CommentForm.defaultProps = {
   editorOptions: {},

+ 6 - 3
resource/js/components/PageEditor/Editor.js

@@ -251,9 +251,12 @@ export default class Editor extends AbstractEditor {
           onClick={() => {this.refs.dropzone.open()}}>
 
           <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
-          Attach files by dragging &amp; dropping,&nbsp;
-          <span className="btn-link">selecting them</span>,&nbsp;
-          or pasting from the clipboard.
+          Attach files
+          <span className="desc-long">
+            &nbsp;by dragging &amp; dropping,&nbsp;
+            <span className="btn-link">selecting them</span>,&nbsp;
+            or pasting from the clipboard.
+          </span>
         </button>
 
       </div>

+ 78 - 0
resource/js/components/SlackNotification.js

@@ -0,0 +1,78 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+/**
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ * @export
+ * @class SlackNotification
+ * @extends {React.Component}
+ */
+
+export default class SlackNotification extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isSlackEnabled: this.props.isSlackEnabled,
+      slackChannels: this.props.slackChannels,
+    };
+
+    this.updateState = this.updateState.bind(this);
+    this.updateStateCheckbox = this.updateStateCheckbox.bind(this);
+  }
+
+  componentWillReceiveProps(nextProps) {
+    this.setState({
+      isSlackEnabled: nextProps.isSlackEnabled,
+      slackChannels: nextProps.slackChannels
+    });
+  }
+
+  updateState(value) {
+    this.setState({slackChannels: value});
+    this.props.onChannelChange(value);
+  }
+
+  updateStateCheckbox(event) {
+    const value = event.target.checked;
+    this.setState({isSlackEnabled: value});
+    this.props.onSlackOnChange(value);
+  }
+
+  render() {
+    const formNameSlackOn = this.props.formName && this.props.formName + '[notify][slack][on]';
+    const formNameChannels = this.props.formName && this.props.formName + '[notify][slack][channel]';
+
+    return (
+      <div className="input-group input-group-sm input-group-slack extended-setting m-r-5">
+        <label className="input-group-addon">
+          <img id="slack-mark-white" src="/images/icons/slack/mark-monochrome_white.svg" width="18" height="18"/>
+          <img id="slack-mark-black" src="/images/icons/slack/mark-monochrome_black.svg" width="18" height="18"/>
+          <input type="checkbox" name={formNameSlackOn} value="1" checked={this.state.isSlackEnabled} onChange={this.updateStateCheckbox}/>
+        </label>
+        <input className="form-control" type="text" name={formNameChannels} value={this.state.slackChannels} placeholder="slack channel name"
+          data-toggle="popover"
+          title="Slack通知"
+          data-content="通知するにはチェックを入れてください。カンマ区切りで複数チャンネルに通知することができます。"
+          data-trigger="focus"
+          data-placement="top"
+          onChange={e => this.updateState(e.target.value)}
+          />
+      </div>
+    );
+  }
+}
+
+SlackNotification.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  pageId: PropTypes.string,
+  pagePath: PropTypes.string,
+  onChannelChange: PropTypes.func,
+  onSlackOnChange: PropTypes.func,
+  isSlackEnabled: PropTypes.bool,
+  slackChannels: PropTypes.string,
+  formName: PropTypes.string,
+};

+ 0 - 31
resource/js/legacy/crowi-form.js

@@ -3,56 +3,25 @@ var pagePath= $('#content-main').data('path');
 
 require('bootstrap-select');
 
-// show/hide
-function FetchPagesUpdatePostAndInsert(path) {
-  $.get('/_api/pages.updatePost', {path: path}, function(res) {
-    if (res.ok) {
-      var $slackChannels = $('#page-form-slack-channel');
-      $slackChannels.val(res.updatePost.join(','));
-    }
-  });
-}
-
-function initSlack() {
-  if (slackConfigured) {
-    var $slackChannels = $('#page-form-slack-channel');
-    var slackChannels = $slackChannels.val();
-    // if slackChannels is empty, then fetch default (admin setting)
-    // if not empty, it means someone specified this setting for the page.
-    if (slackChannels === '') {
-      FetchPagesUpdatePostAndInsert(pagePath);
-    }
-  }
-}
-
-var slackConfigured = $('#page-form-setting').data('slack-configured');
-
 // for new page
 if (!pageId) {
   if (!pageId && pagePath.match(/(20\d{4}|20\d{6}|20\d{2}_\d{1,2}|20\d{2}_\d{1,2}_\d{1,2})/)) {
     $('#page-warning-modal').modal('show');
   }
-
-  if (slackConfigured) {
-    FetchPagesUpdatePostAndInsert(pagePath);
-  }
 }
 
 $('a[data-toggle="tab"][href="#edit"]').on('show.bs.tab', function() {
   $('body').addClass('on-edit');
   $('body').addClass('builtin-editor');
-  initSlack();
 });
 
 $('a[data-toggle="tab"][href="#edit"]').on('hide.bs.tab', function() {
   $('body').removeClass('on-edit');
   $('body').removeClass('builtin-editor');
 });
-
 $('a[data-toggle="tab"][href="#hackmd"]').on('show.bs.tab', function() {
   $('body').addClass('on-edit');
   $('body').addClass('hackmd');
-  initSlack();
 });
 
 $('a[data-toggle="tab"][href="#hackmd"]').on('hide.bs.tab', function() {

+ 10 - 1
resource/styles/agile-admin/inverse/colors/_apply-colors-dark.scss

@@ -154,7 +154,7 @@ legend {
 /*
  * GROWI admin page #themeOptions
  */
- .admin-page {
+.admin-page {
   #themeOptions {
     a.active {
       background-color: darken($themecolor,15%);
@@ -162,3 +162,12 @@ legend {
     }
   }
 }
+
+/*
+ * GROWI comment form
+ */
+.comment-form {
+  #slack-mark-black {
+    display: none;
+  }
+}

+ 10 - 1
resource/styles/agile-admin/inverse/colors/_apply-colors-light.scss

@@ -70,7 +70,7 @@
 /*
  * GROWI admin page #themeOptions
  */
- .admin-page {
+.admin-page {
   #themeOptions {
     a.active {
       background-color: lighten($themecolor,20%);
@@ -78,3 +78,12 @@
     }
   }
 }
+
+/*
+ * GROWI comment form
+ */
+.comment-form {
+  #slack-mark-white {
+    display: none;
+  }
+}

+ 3 - 0
resource/styles/scss/_create-page.scss

@@ -20,6 +20,9 @@
         }
         .create-page-button-container {
           margin-left: 15px;
+          .btn {
+            min-width: 105px;
+          }
         }
 
         // change layout by screen size

+ 29 - 0
resource/styles/scss/_editor-overlay.scss → resource/styles/scss/_editor-attachment.scss

@@ -122,4 +122,33 @@
   .loading-keymap {
     @include overlay-processing-style();
   }
+
+  .btn-open-dropzone {
+    z-index: 2;
+    font-size: small;
+    padding-top: 3px;
+    padding-bottom: 3px;
+    border: none;
+    border-radius: 0;
+    border-top: 1px dotted #ccc;
+    &:active {
+      box-shadow: none;
+    }
+  }
+
+}
+
+#page-editor {
+  @media (max-width: $screen-xs) {
+    .desc-long {
+      display: none;
+    }
+  }
+}
+.comment-form {
+  @media (max-width: $screen-sm) {
+    .desc-long {
+      display: none;
+    }
+  }
 }

+ 10 - 0
resource/styles/scss/_notification.scss

@@ -0,0 +1,10 @@
+// Slack
+.input-group-slack {
+  .input-group-addon {
+    padding: 2px 8px;
+    line-height: 1em;
+    img, input {
+      vertical-align: middle;
+    }
+  }
+}

+ 0 - 35
resource/styles/scss/_on-edit.scss

@@ -19,7 +19,6 @@ body.on-edit {
                       + 1px                     // .page-editor-footer border-top
                       + 40px;                   // .page-editor-footer min-height
   $editor-margin: $header-plus-footer + 22px;   // .btn-open-dropzone height
-  $editor-margin-sm: $header-plus-footer;
 
   // hide unnecessary elements
   .navbar.navbar-static-top,
@@ -134,17 +133,6 @@ body.on-edit {
     min-height: 40px;
     border-top: solid 1px transparent;
 
-    // slack
-    .input-group-slack {
-      .input-group-addon {
-        padding: 2px 8px;
-        line-height: 1em;
-        img, input {
-          vertical-align: middle;
-        }
-      }
-    }
-
     .btn-submit {
       width: 100px;
     }
@@ -171,10 +159,6 @@ body.on-edit {
         .react-codemirror2, .CodeMirror, .CodeMirror-scroll,
         .textarea-editor {
           height: calc(100vh - #{$editor-margin});
-          // less than smartphone
-          @media (max-width: $screen-xs) {
-            height: calc(100vh - #{$editor-margin-sm});
-          }
         }
       }
     }
@@ -306,25 +290,6 @@ body.on-edit {
         @include overlay-processing-style();
       }
 
-      .btn-open-dropzone {
-        z-index: 2;
-        font-size: small;
-        text-align: right;
-        padding-top: 3px;
-        padding-bottom: 0;
-        border: none;
-        border-radius: 0;
-        border-top: 1px dotted #ccc;
-
-        &:active {
-          box-shadow: none;
-        }
-
-        // hide if screen size is less than smartphone
-        @media (max-width: $screen-xs) {
-          display: none;
-        }
-      }
     }
     .page-editor-preview-container {
     }

+ 2 - 1
resource/styles/scss/style.scss

@@ -21,12 +21,13 @@
 @import 'comment_growi';
 @import 'create-page';
 @import 'create-template';
-@import 'editor-overlay';
+@import 'editor-attachment';
 @import 'layout';
 @import 'layout_crowi';
 @import 'layout_crowi_sidebar';
 @import 'layout_growi';
 @import 'login';
+@import 'notification';
 @import 'on-edit';
 @import 'page_list';
 @import 'page';

+ 6 - 2
test/util/slack.test.js

@@ -10,7 +10,11 @@ describe('Slack Util', function () {
   var crowi = new (require(ROOT_DIR + '/lib/crowi'))(ROOT_DIR, process.env);
   var slack = require(crowi.libDir + '/util/slack')(crowi);
 
-  it('post method exists', function() {
-    expect(slack).to.respondTo('post');
+  it('post comment method exists', function() {
+    expect(slack).to.respondTo('postComment');
+  });
+
+  it('post page method exists', function() {
+    expect(slack).to.respondTo('postPage');
   });
 });