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

Merge branch 'master' into feat/Markdown-editor-for-comment

yusuketk 7 лет назад
Родитель
Сommit
3dac5ae8ca
36 измененных файлов с 701 добавлено и 460 удалено
  1. 15 3
      CHANGES.md
  2. 1 1
      config/logger/config.prod.js
  3. 10 0
      config/webpack.common.js
  4. 7 3
      lib/crowi/dev.js
  5. 1 0
      lib/form/revision.js
  6. 1 0
      lib/locales/en-US/translation.json
  7. 2 0
      lib/locales/index.js
  8. 1 0
      lib/locales/ja/translation.json
  9. 18 52
      lib/models/page-group-relation.js
  10. 62 92
      lib/models/page.js
  11. 0 12
      lib/models/user-group-relation.js
  12. 4 3
      lib/routes/index.js
  13. 14 2
      lib/routes/me.js
  14. 65 70
      lib/routes/page.js
  15. 5 44
      lib/views/_form.html
  16. 2 0
      lib/views/admin/customize.html
  17. 1 0
      lib/views/admin/widget/theme-colorbox.html
  18. 0 1
      lib/views/layout-growi/not_found.html
  19. 2 0
      lib/views/layout/layout.html
  20. 0 23
      lib/views/modal/select_grant_group.html
  21. 9 3
      lib/views/widget/page_alerts.html
  22. 1 1
      lib/views/widget/page_list.html
  23. 0 1
      lib/views/widget/page_modals.html
  24. 5 2
      package.json
  25. 30 27
      resource/js/app.js
  26. 15 0
      resource/js/components/PageEditor/CodeMirrorEditor.js
  27. 200 112
      resource/js/components/PageEditor/GrantSelector.js
  28. 6 3
      resource/js/components/PageEditor/TextAreaEditor.js
  29. 44 0
      resource/js/i18n.js
  30. 1 1
      resource/js/util/Crowi.js
  31. 54 0
      resource/styles/agile-admin/inverse/colors/blue-night.scss
  32. 36 0
      resource/styles/agile-admin/inverse/colors/future.scss
  33. 17 0
      resource/styles/scss/_on-edit.scss
  34. 8 0
      resource/styles/scss/theme/blue-night.scss
  35. 8 0
      resource/styles/scss/theme/future.scss
  36. 56 4
      yarn.lock

+ 15 - 3
CHANGES.md

@@ -1,17 +1,29 @@
 CHANGES
 CHANGES
 ========
 ========
 
 
-## 3.1.0-RC
+## 3.1.1-RC
+
+* Improvement: Add 'future' theme
+* Improvement: List up pages which restricted for Group ACL
+* Fix: PageGroupRelation didn't remove when page is removed completely
+
+
+## 3.1.0
 
 
 * Improvement: Group Access Control List - Select group modal
 * Improvement: Group Access Control List - Select group modal
-* Improvement: Auto-format markdown tables which includes multibyte text
+* Improvement: Better input on mobile
+* Improvement: Detach code blocks correctly
+* Improvement: Auto-format markdown table which includes multibyte text
+* Improvement: Show icon when auto-format markdown table is activated
 * Improvement: Enable to switch show/hide border for highlight.js
 * Improvement: Enable to switch show/hide border for highlight.js
 * Improvement: BindDN field allows also ActiveDirectory styles 
 * Improvement: BindDN field allows also ActiveDirectory styles 
 * Improvement: Show LDAP logs when testing login
 * Improvement: Show LDAP logs when testing login
-* Improvement: Detach code blocks correctly
 * Fix: Comment body doesn't break long terms
 * Fix: Comment body doesn't break long terms
 * Fix: lsx plugin lists up pages that hit by forward match wrongly
 * Fix: lsx plugin lists up pages that hit by forward match wrongly
     * Introduced by 3.0.4
     * Introduced by 3.0.4
+* Fix: Editor is broken on IE11
+* Support: Multilingualize React components with i18next
+* Support: Organize dependencies
 * Support: Upgrade libs
 * Support: Upgrade libs
     * elasticsearch
     * elasticsearch
     * googleapis
     * googleapis

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

@@ -1,3 +1,3 @@
 module.exports = {
 module.exports = {
-  default: 'info',
+  default: 'warn',
 };
 };

+ 10 - 0
config/webpack.common.js

@@ -30,6 +30,8 @@ module.exports = function(options) {
       'style-theme-default-dark':  './resource/styles/scss/theme/default-dark.scss',
       'style-theme-default-dark':  './resource/styles/scss/theme/default-dark.scss',
       'style-theme-nature':   './resource/styles/scss/theme/nature.scss',
       'style-theme-nature':   './resource/styles/scss/theme/nature.scss',
       'style-theme-mono-blue':   './resource/styles/scss/theme/mono-blue.scss',
       'style-theme-mono-blue':   './resource/styles/scss/theme/mono-blue.scss',
+      'style-theme-future': './resource/styles/scss/theme/future.scss',
+      'style-theme-blue-night': './resource/styles/scss/theme/blue-night.scss',
       'style-presentation':   './resource/styles/scss/style-presentation.scss',
       'style-presentation':   './resource/styles/scss/style-presentation.scss',
     },
     },
     externals: {
     externals: {
@@ -45,6 +47,7 @@ module.exports = function(options) {
       alias: {
       alias: {
         '@root': helpers.root('/'),
         '@root': helpers.root('/'),
         '@alias/logger': helpers.root('lib/service/logger'),
         '@alias/logger': helpers.root('lib/service/logger'),
+        '@alias/locales': helpers.root('lib/locales'),
         // replace bunyan
         // replace bunyan
         'bunyan': 'browser-bunyan',
         'bunyan': 'browser-bunyan',
       }
       }
@@ -67,6 +70,13 @@ module.exports = function(options) {
             }
             }
           }]
           }]
         },
         },
+        {
+          test: /locales/,
+          loader: '@alienfast/i18next-loader',
+          options: {
+            basenameAsNamespace: true,
+          }
+        },
         {
         {
           test: /\.css$/,
           test: /\.css$/,
           use: ['style-loader', 'css-loader'],
           use: ['style-loader', 'css-loader'],

+ 7 - 3
lib/crowi/dev.js

@@ -41,9 +41,13 @@ class CrowiDev {
    */
    */
   requireForAutoReloadServer() {
   requireForAutoReloadServer() {
     // load all json files for live reloading
     // load all json files for live reloading
-    fs.readdirSync(this.crowi.localeDir).map((dirname) => {
-      require(path.join(this.crowi.localeDir, dirname, 'translation.json'));
-    });
+    fs.readdirSync(this.crowi.localeDir)
+      .filter(filename => {
+        return fs.statSync(path.join(this.crowi.localeDir, filename)).isDirectory();
+      })
+      .map((dirname) => {
+        require(path.join(this.crowi.localeDir, dirname, 'translation.json'));
+      });
   }
   }
 
 
   /**
   /**

+ 1 - 0
lib/form/revision.js

@@ -8,5 +8,6 @@ module.exports = form(
   field('pageForm.body').required().custom(function(value) { return value.replace(/\r/g, '\n') }),
   field('pageForm.body').required().custom(function(value) { return value.replace(/\r/g, '\n') }),
   field('pageForm.currentRevision'),
   field('pageForm.currentRevision'),
   field('pageForm.grant').toInt().required(),
   field('pageForm.grant').toInt().required(),
+  field('pageForm.grantUserGroupId'),
   field('pageForm.notify')
   field('pageForm.notify')
 );
 );

+ 1 - 0
lib/locales/en-US/translation.json

@@ -86,6 +86,7 @@
   "Specified users only": "Specified users only",
   "Specified users only": "Specified users only",
   "Just me": "Just me",
   "Just me": "Just me",
   "Only inside the group": "Only inside the group",
   "Only inside the group": "Only inside the group",
+  "Reselect the group": "Reselect the group",
   "Shareable link": "Shareable link",
   "Shareable link": "Shareable link",
 
 
   "Show latest": "Show latest",
   "Show latest": "Show latest",

+ 2 - 0
lib/locales/index.js

@@ -0,0 +1,2 @@
+// !!DO NOT REMOVE THIS FILE!!
+// entry point for @alienfast/i18next-loader

+ 1 - 0
lib/locales/ja/translation.json

@@ -100,6 +100,7 @@
   "Specified users": "特定ユーザーのみ",
   "Specified users": "特定ユーザーのみ",
   "Just me": "自分のみ",
   "Just me": "自分のみ",
   "Only inside the group": "特定グループのみ",
   "Only inside the group": "特定グループのみ",
+  "Reselect the group": "グループの再選択",
   "Shareable link": "このページの共有用URL",
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "Selecting authentication mechanism": "認証機構選択",
   "Selecting authentication mechanism": "認証機構選択",

+ 18 - 52
lib/models/page-group-relation.js

@@ -88,21 +88,18 @@ class PageGroupRelation {
    * @returns {Promise<any>} mongoose-paginate result object
    * @returns {Promise<any>} mongoose-paginate result object
    * @memberof UserGroupRelation
    * @memberof UserGroupRelation
    */
    */
-  static findPageGroupRelationsWithPagination(userGroup, opts) {
-    const query = { relatedGroup: userGroup };
-    const options = Object.assign({}, opts);
-    if (options.page == null) {
-      options.page = 1;
-    }
-    if (options.limit == null) {
-      options.limit = UserGroupRelation.PAGE_ITEMS;
-    }
-
-    return this.paginate(query, options)
-      .catch((err) => {
-        debug('Error on pagination:', err);
-      });
-  }
+  // static findPageGroupRelationsWithPagination(userGroup, opts) {
+  //   const query = { relatedGroup: userGroup };
+  //   const options = Object.assign({}, opts);
+  //   if (options.page == null) {
+  //     options.page = 1;
+  //   }
+  //   if (options.limit == null) {
+  //     options.limit = UserGroupRelation.PAGE_ITEMS;
+  //   }
+
+  //   return this.paginate(query, options);
+  // }
 
 
   /**
   /**
    * find the relation or create(if not exists) for page and group
    * find the relation or create(if not exists) for page and group
@@ -126,10 +123,6 @@ class PageGroupRelation {
         else {
         else {
           return this.createRelation(userGroup, page);
           return this.createRelation(userGroup, page);
         }
         }
-      })
-      .catch((err) => {
-        debug('An Error occured.', err);
-        return reject(err);
       });
       });
   }
   }
 
 
@@ -147,7 +140,7 @@ class PageGroupRelation {
       return null;
       return null;
     }
     }
     return this
     return this
-      .find({ targetPage: page.id })
+      .findOne({ targetPage: page.id })
       .populate('relatedGroup')
       .populate('relatedGroup')
       .exec();
       .exec();
   }
   }
@@ -165,25 +158,8 @@ class PageGroupRelation {
     var UserGroupRelation = PageGroupRelation.crowi.model('UserGroupRelation');
     var UserGroupRelation = PageGroupRelation.crowi.model('UserGroupRelation');
 
 
     return this.findByPage(pageData)
     return this.findByPage(pageData)
-      .then((pageRelations) => {
-        return pageRelations.map((pageRelation) => {
-          return UserGroupRelation.isRelatedUserForGroup(userData, pageRelation.relatedGroup);
-        });
-      })
-      .then((checkPromises) => {
-        return Promise.all(checkPromises);
-      })
-      .then((checkResults) => {
-        var checkResult = false;
-        checkResults.map((result) => {
-          if (result) {
-            checkResult = true;
-          }
-        });
-        return checkResult;
-      })
-      .catch((err) => {
-        return reject(err);
+      .then(pageRelation => {
+        return UserGroupRelation.isRelatedUserForGroup(userData, pageRelation.relatedGroup);
       });
       });
   }
   }
 
 
@@ -237,15 +213,9 @@ class PageGroupRelation {
   static removeAllByPage(page) {
   static removeAllByPage(page) {
 
 
     return this.findByPage(page)
     return this.findByPage(page)
-      .then((relations) => {
-        debug('remove relations are ', relations);
-        if (relations == null) {
-          return;
-        }
-        else {
-          relations.map((relation) => {
-            relation.remove();
-          });
+      .then(relation => {
+        if (relation != null) {
+          relation.remove();
         }
         }
       });
       });
   }
   }
@@ -268,10 +238,6 @@ class PageGroupRelation {
         else {
         else {
           relationData.remove();
           relationData.remove();
         }
         }
-      })
-      .catch((err) => {
-        debug('Error on find a removing page-group-relation', err);
-        return reject(err);
       });
       });
   }
   }
 }
 }

+ 62 - 92
lib/models/page.js

@@ -344,7 +344,7 @@ module.exports = function(crowi) {
     grantLabels[GRANT_PUBLIC]     = 'Public'; // 公開
     grantLabels[GRANT_PUBLIC]     = 'Public'; // 公開
     grantLabels[GRANT_RESTRICTED] = 'Anyone with the link'; // リンクを知っている人のみ
     grantLabels[GRANT_RESTRICTED] = 'Anyone with the link'; // リンクを知っている人のみ
     //grantLabels[GRANT_SPECIFIED]  = 'Specified users only'; // 特定ユーザーのみ
     //grantLabels[GRANT_SPECIFIED]  = 'Specified users only'; // 特定ユーザーのみ
-    // grantLabels[GRANT_USER_GROUP] = 'Only inside the group'; // 特定グループのみ
+    grantLabels[GRANT_USER_GROUP] = 'Only inside the group'; // 特定グループのみ
     grantLabels[GRANT_OWNER]      = 'Just me'; // 自分のみ
     grantLabels[GRANT_OWNER]      = 'Just me'; // 自分のみ
 
 
     return grantLabels;
     return grantLabels;
@@ -507,8 +507,8 @@ module.exports = function(crowi) {
 
 
         if (!pageData.isGrantedFor(userData)) {
         if (!pageData.isGrantedFor(userData)) {
           PageGroupRelation.isExistsGrantedGroupForPageAndUser(pageData, userData)
           PageGroupRelation.isExistsGrantedGroupForPageAndUser(pageData, userData)
-            .then(function(checkResult) {
-              if (!checkResult) {
+            .then(isExists => {
+              if (!isExists) {
                 return reject(new Error('Page is not granted for the user')); //PAGE_GRANT_ERROR, null);
                 return reject(new Error('Page is not granted for the user')); //PAGE_GRANT_ERROR, null);
               }
               }
               else {
               else {
@@ -767,6 +767,7 @@ module.exports = function(crowi) {
         {grant: GRANT_RESTRICTED, grantedUsers: userData._id},
         {grant: GRANT_RESTRICTED, grantedUsers: userData._id},
         {grant: GRANT_SPECIFIED, grantedUsers: userData._id},
         {grant: GRANT_SPECIFIED, grantedUsers: userData._id},
         {grant: GRANT_OWNER, grantedUsers: userData._id},
         {grant: GRANT_OWNER, grantedUsers: userData._id},
+        {grant: GRANT_USER_GROUP},
       ], })
       ], })
       .and({
       .and({
         $or: pathCondition
         $or: pathCondition
@@ -801,10 +802,11 @@ module.exports = function(crowi) {
   pageSchema.statics.updateGrant = function(page, grant, userData, grantUserGroupId) {
   pageSchema.statics.updateGrant = function(page, grant, userData, grantUserGroupId) {
     var Page = this;
     var Page = this;
 
 
-    if (grant == GRANT_USER_GROUP && grantUserGroupId == null) {
-      throw new Error('grant userGroupId is not specified');
-    }
     return new Promise(function(resolve, reject) {
     return new Promise(function(resolve, reject) {
+      if (grant == GRANT_USER_GROUP && grantUserGroupId == null) {
+        reject('grant userGroupId is not specified');
+      }
+
       page.grant = grant;
       page.grant = grant;
       if (grant == GRANT_PUBLIC || grant == GRANT_USER_GROUP) {
       if (grant == GRANT_PUBLIC || grant == GRANT_USER_GROUP) {
         page.grantedUsers = [];
         page.grantedUsers = [];
@@ -838,12 +840,12 @@ module.exports = function(crowi) {
       return UserGroupRelation.findByGroupIdAndUser(grantUserGroupId, userData)
       return UserGroupRelation.findByGroupIdAndUser(grantUserGroupId, userData)
       .then((relation) => {
       .then((relation) => {
         if (relation == null) {
         if (relation == null) {
-          return reject(new Error('no relations were exist for group and user.'));
+          return new Error('no relations were exist for group and user.');
         }
         }
         return PageGroupRelation.findOrCreateRelationForPageAndGroup(page, relation.relatedGroup);
         return PageGroupRelation.findOrCreateRelationForPageAndGroup(page, relation.relatedGroup);
       })
       })
       .catch((err) => {
       .catch((err) => {
-        return reject(new Error('No UserGroup is exists. userGroupId : ', grantUserGroupId));
+        return new Error('No UserGroup is exists. userGroupId : ', grantUserGroupId);
       });
       });
     }
     }
     else {
     else {
@@ -916,10 +918,10 @@ module.exports = function(crowi) {
       grant = GRANT_PUBLIC;
       grant = GRANT_PUBLIC;
     }
     }
 
 
-    return new Promise(function(resolve, reject) {
-      Page.findOne({path: path}, function(err, pageData) {
+    return Page.findOne({path: path})
+      .then(pageData => {
         if (pageData) {
         if (pageData) {
-          return reject(new Error('Cannot create new page to existed path'));
+          throw new Error('Cannot create new page to existed path');
         }
         }
 
 
         var newPage = new Page();
         var newPage = new Page();
@@ -934,28 +936,18 @@ module.exports = function(crowi) {
         newPage.grantedUsers = [];
         newPage.grantedUsers = [];
         newPage.grantedUsers.push(user);
         newPage.grantedUsers.push(user);
 
 
-        newPage.save(function(err, newPage) {
-          if (err) {
-            return reject(err);
-          }
-
-          if (newPage.grant == Page.GRANT_USER_GROUP && grantUserGroupId != null) {
-            Page.updateGrantUserGroup(newPage, grant, grantUserGroupId, user)
-              .catch((err) => {
-                return reject(err);
-              });
-          }
-          var newRevision = Revision.prepareRevision(newPage, body, user, {format: format});
-          Page.pushRevision(newPage, newRevision, user).then(function(data) {
-            resolve(data);
-            pageEvent.emit('create', data, user);
-          }).catch(function(err) {
-            debug('Push Revision Error on create page', err);
-            return reject(err);
+        return newPage.save();
+      })
+      .then((newPage) => {
+        const newRevision = Revision.prepareRevision(newPage, body, user, {format: format});
+        return Page.pushRevision(newPage, newRevision, user)
+          .then(() => {
+            return Page.updateGrantUserGroup(newPage, grant, grantUserGroupId, user);
           });
           });
-        });
+      })
+      .then((data) => {
+        pageEvent.emit('create', data, user);
       });
       });
-    });
   };
   };
 
 
   pageSchema.statics.updatePage = function(pageData, body, user, options) {
   pageSchema.statics.updatePage = function(pageData, body, user, options) {
@@ -967,25 +959,15 @@ module.exports = function(crowi) {
     // update existing page
     // update existing page
     var newRevision = Revision.prepareRevision(pageData, body, user);
     var newRevision = Revision.prepareRevision(pageData, body, user);
 
 
-    return new Promise(function(resolve, reject) {
-      Page.pushRevision(pageData, newRevision, user)
+    return Page.pushRevision(pageData, newRevision, user)
       .then(function(revision) {
       .then(function(revision) {
-        if (grant != pageData.grant) {
-          return Page.updateGrant(pageData, grant, user, grantUserGroupId).then(function(data) {
-            debug('Page grant update:', data);
-            resolve(data);
-            pageEvent.emit('update', data, user);
-          });
-        }
-        else {
-          resolve(pageData);
-          pageEvent.emit('update', pageData, user);
-        }
-      }).catch(function(err) {
-        debug('Error on update', err);
-        debug('Error on update', err.stack);
+        return Page.updateGrant(pageData, grant, user, grantUserGroupId);
+      })
+      .then(function(data) {
+        debug('Page grant update:', data);
+        pageEvent.emit('update', data, user);
+        return data;
       });
       });
-    });
   };
   };
 
 
   pageSchema.statics.deletePage = function(pageData, user, options) {
   pageSchema.statics.deletePage = function(pageData, user, options) {
@@ -993,20 +975,13 @@ module.exports = function(crowi) {
       , newPath = Page.getDeletedPageName(pageData.path)
       , newPath = Page.getDeletedPageName(pageData.path)
       ;
       ;
     if (Page.isDeletableName(pageData.path)) {
     if (Page.isDeletableName(pageData.path)) {
-      return new Promise(function(resolve, reject) {
-        Page.updatePageProperty(pageData, {status: STATUS_DELETED, lastUpdateUser: user})
-        .then(function(data) {
-          pageData.status = STATUS_DELETED;
-
-          // ページ名が /trash/ 以下に存在する場合、おかしなことになる
-          // が、 /trash 以下にページが有るのは、個別に作っていたケースのみ。
-          // 一応しばらく前から uncreatable pages になっているのでこれでいいことにする
-          debug('Deleted the page, and rename it', pageData.path, newPath);
-          return Page.rename(pageData, newPath, user, {createRedirectPage: true});
-        }).then(function(pageData) {
-          resolve(pageData);
-        }).catch(reject);
-      });
+      return Page.rename(pageData, newPath, user, {createRedirectPage: true})
+        .then((updatedPageData) => {
+          return Page.updatePageProperty(updatedPageData, {status: STATUS_DELETED, lastUpdateUser: user});
+        })
+        .then(() => {
+          return pageData;
+        });
     }
     }
     else {
     else {
       return Promise.reject('Page is not deletable.');
       return Promise.reject('Page is not deletable.');
@@ -1019,18 +994,15 @@ module.exports = function(crowi) {
       , options = options || {}
       , options = options || {}
       ;
       ;
 
 
-    return new Promise(function(resolve, reject) {
-      Page
-      .generateQueryToListWithDescendants(path, user, options)
+    return Page.generateQueryToListWithDescendants(path, user, options)
       .then(function(pages) {
       .then(function(pages) {
-        Promise.all(pages.map(function(page) {
+        return Promise.all(pages.map(function(page) {
           return Page.deletePage(page, user, options);
           return Page.deletePage(page, user, options);
-        }))
-        .then(function(data) {
-          return resolve(pageData);
-        });
+        }));
+      })
+      .then(function(data) {
+        return pageData;
       });
       });
-    });
 
 
   };
   };
 
 
@@ -1073,6 +1045,7 @@ module.exports = function(crowi) {
     return new Promise(function(resolve, reject) {
     return new Promise(function(resolve, reject) {
       Page
       Page
         .generateQueryToListWithDescendants(path, user, options)
         .generateQueryToListWithDescendants(path, user, options)
+        .exec()
         .then(function(pages) {
         .then(function(pages) {
           Promise.all(pages.map(function(page) {
           Promise.all(pages.map(function(page) {
             return Page.revertDeletedPage(page, user, options);
             return Page.revertDeletedPage(page, user, options);
@@ -1093,6 +1066,7 @@ module.exports = function(crowi) {
       , Attachment = crowi.model('Attachment')
       , Attachment = crowi.model('Attachment')
       , Comment = crowi.model('Comment')
       , Comment = crowi.model('Comment')
       , Revision = crowi.model('Revision')
       , Revision = crowi.model('Revision')
+      , PageGroupRelation = crowi.model('PageGroupRelation')
       , Page = this
       , Page = this
       , pageId = pageData._id
       , pageId = pageData._id
       ;
       ;
@@ -1112,6 +1086,8 @@ module.exports = function(crowi) {
         return Page.removePageById(pageId);
         return Page.removePageById(pageId);
       }).then(function(done) {
       }).then(function(done) {
         return Page.removeRedirectOriginPageByPath(pageData.path);
         return Page.removeRedirectOriginPageByPath(pageData.path);
+      }).then(function(done) {
+        return PageGroupRelation.removeAllByPage(pageData);
       }).then(function(done) {
       }).then(function(done) {
         pageEvent.emit('delete', pageData, user); // update as renamed page
         pageEvent.emit('delete', pageData, user); // update as renamed page
         resolve(pageData);
         resolve(pageData);
@@ -1199,25 +1175,22 @@ module.exports = function(crowi) {
       , createRedirectPage = options.createRedirectPage || 0
       , createRedirectPage = options.createRedirectPage || 0
       , moveUnderTrees     = options.moveUnderTrees || 0;
       , moveUnderTrees     = options.moveUnderTrees || 0;
 
 
-    return new Promise(function(resolve, reject) {
-      // pageData の path を変更
-      Page.updatePageProperty(pageData, {updatedAt: Date.now(), path: newPagePath, lastUpdateUser: user})
-      .then(function(data) {
+    return Page.updatePageProperty(pageData, {updatedAt: Date.now(), path: newPagePath, lastUpdateUser: user})  // pageData の path を変更
+      .then((data) => {
         // reivisions の path を変更
         // reivisions の path を変更
         return Revision.updateRevisionListByPath(path, {path: newPagePath}, {});
         return Revision.updateRevisionListByPath(path, {path: newPagePath}, {});
-      }).then(function(data) {
+      })
+      .then(function(data) {
         pageData.path = newPagePath;
         pageData.path = newPagePath;
 
 
         if (createRedirectPage) {
         if (createRedirectPage) {
           var body = 'redirect ' + newPagePath;
           var body = 'redirect ' + newPagePath;
-          Page.create(path, body, user, {redirectTo: newPagePath}).then(resolve).catch(reject);
-        }
-        else {
-          resolve(data);
+          Page.create(path, body, user, {redirectTo: newPagePath});
         }
         }
         pageEvent.emit('update', pageData, user); // update as renamed page
         pageEvent.emit('update', pageData, user); // update as renamed page
+
+        return pageData;
       });
       });
-    });
   };
   };
 
 
   pageSchema.statics.renameRecursively = function(pageData, newPagePathPrefix, user, options) {
   pageSchema.statics.renameRecursively = function(pageData, newPagePathPrefix, user, options) {
@@ -1225,20 +1198,17 @@ module.exports = function(crowi) {
       , path = pageData.path
       , path = pageData.path
       , pathRegExp = new RegExp('^' + escapeStringRegexp(path), 'i');
       , pathRegExp = new RegExp('^' + escapeStringRegexp(path), 'i');
 
 
-    return new Promise(function(resolve, reject) {
-      Page
-      .generateQueryToListWithDescendants(path, user, options)
+    return Page.generateQueryToListWithDescendants(path, user, options)
       .then(function(pages) {
       .then(function(pages) {
-        Promise.all(pages.map(function(page) {
-          newPagePath = page.path.replace(pathRegExp, newPagePathPrefix);
+        return Promise.all(pages.map(function(page) {
+          const newPagePath = page.path.replace(pathRegExp, newPagePathPrefix);
           return Page.rename(page, newPagePath, user, options);
           return Page.rename(page, newPagePath, user, options);
-        }))
-        .then(function() {
-          pageData.path = newPagePathPrefix;
-          return resolve();
-        });
+        }));
+      })
+      .then(function() {
+        pageData.path = newPagePathPrefix;
+        return pageData;
       });
       });
-    });
   };
   };
 
 
   pageSchema.statics.getHistories = function() {
   pageSchema.statics.getHistories = function() {

+ 0 - 12
lib/models/user-group-relation.js

@@ -66,8 +66,6 @@ class UserGroupRelation {
    */
    */
   static findAllRelationForUserGroup(userGroup) {
   static findAllRelationForUserGroup(userGroup) {
     debug('findAllRelationForUserGroup is called', userGroup);
     debug('findAllRelationForUserGroup is called', userGroup);
-    var UserGroupRelation = this;
-
     return this
     return this
       .find({ relatedGroup: userGroup })
       .find({ relatedGroup: userGroup })
       .populate('relatedUser')
       .populate('relatedUser')
@@ -83,7 +81,6 @@ class UserGroupRelation {
    * @memberof UserGroupRelation
    * @memberof UserGroupRelation
    */
    */
   static findAllRelationForUserGroups(userGroups) {
   static findAllRelationForUserGroups(userGroups) {
-
     return this
     return this
       .find({ relatedGroup: { $in: userGroups } })
       .find({ relatedGroup: { $in: userGroups } })
       .populate('relatedUser')
       .populate('relatedUser')
@@ -99,7 +96,6 @@ class UserGroupRelation {
    * @memberof UserGroupRelation
    * @memberof UserGroupRelation
    */
    */
   static findAllRelationForUser(user) {
   static findAllRelationForUser(user) {
-
     return this
     return this
       .find({ relatedUser: user.id })
       .find({ relatedUser: user.id })
       .populate('relatedGroup')
       .populate('relatedGroup')
@@ -199,10 +195,6 @@ class UserGroupRelation {
       .then((count) => {
       .then((count) => {
         // return true or false of the relation is exists(not count)
         // return true or false of the relation is exists(not count)
         return (0 < count);
         return (0 < count);
-      })
-      .catch((err) => {
-        debug('An Error occured.', err);
-        reject(err);
       });
       });
   }
   }
 
 
@@ -263,10 +255,6 @@ class UserGroupRelation {
         else {
         else {
           relationData.remove();
           relationData.remove();
         }
         }
-      })
-      .catch((err) => {
-        debug('Error on find a removing user-group-relation', err);
-        reject(err);
       });
       });
   }
   }
 
 

+ 4 - 3
lib/routes/index.js

@@ -148,9 +148,10 @@ module.exports = function(crowi, app) {
   app.get( '/_search'                 , loginRequired(crowi, app, false) , search.searchPage);
   app.get( '/_search'                 , loginRequired(crowi, app, false) , search.searchPage);
   app.get( '/_api/search'             , accessTokenParser , loginRequired(crowi, app, false) , search.api.search);
   app.get( '/_api/search'             , accessTokenParser , loginRequired(crowi, app, false) , search.api.search);
 
 
-  app.get( '/_api/check_username'     , user.api.checkUsername);
-  app.post('/_api/me/picture/upload'  , loginRequired(crowi, app) , uploads.single('userPicture'), me.api.uploadPicture);
-  app.get( '/_api/user/bookmarks'     , loginRequired(crowi, app, false) , user.api.bookmarks);
+  app.get( '/_api/check_username'           , user.api.checkUsername);
+  app.post('/_api/me/picture/upload'        , loginRequired(crowi, app) , uploads.single('userPicture'), me.api.uploadPicture);
+  app.get( '/_api/me/user-group-relations'  , accessTokenParser , loginRequired(crowi, app) , me.api.userGroupRelations);
+  app.get( '/_api/user/bookmarks'           , loginRequired(crowi, app, false) , user.api.bookmarks);
 
 
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
   app.get('/_api/users.list'          , accessTokenParser , loginRequired(crowi, app, false) , user.api.list);
   app.get('/_api/users.list'          , accessTokenParser , loginRequired(crowi, app, false) , user.api.list);

+ 14 - 2
lib/routes/me.js

@@ -5,10 +5,10 @@ module.exports = function(crowi, app) {
     , fs = require('fs')
     , fs = require('fs')
     , models = crowi.models
     , models = crowi.models
     , config = crowi.getConfig()
     , config = crowi.getConfig()
-    , Page = models.Page
     , User = models.User
     , User = models.User
+    , UserGroupRelation = models.UserGroupRelation
     , ExternalAccount = models.ExternalAccount
     , ExternalAccount = models.ExternalAccount
-    , Revision = models.Revision
+    , ApiResponse = require('../util/apiResponse')
     //, pluginService = require('../service/plugin')
     //, pluginService = require('../service/plugin')
     , actions = {}
     , actions = {}
     , api = {}
     , api = {}
@@ -76,6 +76,18 @@ module.exports = function(crowi, app) {
     });
     });
   };
   };
 
 
+  /**
+   * retrieve user-group-relation documents
+   * @param {object} req
+   * @param {object} res
+   */
+  api.userGroupRelations = function(req, res) {
+    UserGroupRelation.findAllRelationForUser(req.user)
+      .then(userGroupRelations => {
+        return res.json(ApiResponse.success({userGroupRelations}));
+      });
+  };
+
   actions.index = function(req, res) {
   actions.index = function(req, res) {
     var userForm = req.body.userForm;
     var userForm = req.body.userForm;
     var userData = req.user;
     var userData = req.user;

+ 65 - 70
lib/routes/page.js

@@ -181,11 +181,13 @@ module.exports = function(crowi, app) {
       else {
       else {
         return Promise.resolve([]);
         return Promise.resolve([]);
       }
       }
-    }).then(function(tree) {
+    })
+    .then(function(tree) {
       renderVars.tree = tree;
       renderVars.tree = tree;
 
 
       return Page.findListByStartWith(path, req.user, queryOptions);
       return Page.findListByStartWith(path, req.user, queryOptions);
-    }).then(function(pageList) {
+    })
+    .then(function(pageList) {
 
 
       if (pageList.length > limit) {
       if (pageList.length > limit) {
         pageList.pop();
         pageList.pop();
@@ -198,6 +200,16 @@ module.exports = function(crowi, app) {
       };
       };
       renderVars.pager = generatePager(pagerOptions);
       renderVars.pager = generatePager(pagerOptions);
       renderVars.pages = pagePathUtil.encodePagesPath(pageList);
       renderVars.pages = pagePathUtil.encodePagesPath(pageList);
+    })
+    .then(() => {
+      return PageGroupRelation.findByPage(renderVars.page);
+    })
+    .then((pageGroupRelation) => {
+      if (pageGroupRelation != null) {
+        renderVars.pageRelatedGroup = pageGroupRelation.relatedGroup;
+      }
+    })
+    .then(() => {
       res.render('customlayout-selector/page_list', renderVars);
       res.render('customlayout-selector/page_list', renderVars);
     }).catch(function(err) {
     }).catch(function(err) {
       debug('Error on rendering pageListShow', err);
       debug('Error on rendering pageListShow', err);
@@ -238,7 +250,6 @@ module.exports = function(crowi, app) {
       author: false,
       author: false,
       pages: [],
       pages: [],
       tree: [],
       tree: [],
-      userRelatedGroups: [],
       pageRelatedGroup: null,
       pageRelatedGroup: null,
     };
     };
 
 
@@ -266,8 +277,16 @@ module.exports = function(crowi, app) {
         return Revision.findRevisionList(page.path, {})
         return Revision.findRevisionList(page.path, {})
         .then(function(tree) {
         .then(function(tree) {
           renderVars.tree = tree;
           renderVars.tree = tree;
-          return Promise.resolve();
-        }).then(function() {
+        })
+        .then(() => {
+          return PageGroupRelation.findByPage(renderVars.page);
+        })
+        .then((pageGroupRelation) => {
+          if (pageGroupRelation != null) {
+            renderVars.pageRelatedGroup = pageGroupRelation.relatedGroup;
+          }
+        })
+        .then(function() {
           var userPage = isUserPage(page.path);
           var userPage = isUserPage(page.path);
           var userData = null;
           var userData = null;
 
 
@@ -296,14 +315,8 @@ module.exports = function(crowi, app) {
               // pass
               // pass
             });
             });
           }
           }
-          else {
-            return Promise.resolve();
-          }
         });
         });
       }
       }
-      else {
-        return Promise.resolve();
-      }
     })
     })
     // page not exists
     // page not exists
     .catch(function(err) {
     .catch(function(err) {
@@ -315,47 +328,32 @@ module.exports = function(crowi, app) {
     .then(function() {
     .then(function() {
       if (!isRedirect) {
       if (!isRedirect) {
         Page.findListWithDescendants(path, req.user, queryOptions)
         Page.findListWithDescendants(path, req.user, queryOptions)
-        .then(function(pageList) {
-          if (pageList.length > limit) {
-            pageList.pop();
-          }
-
-          pagerOptions.length = pageList.length;
-
-          renderVars.viewConfig = {
-            seener_threshold: SEENER_THRESHOLD,
-          };
-          renderVars.pager = generatePager(pagerOptions);
-          renderVars.pages = pagePathUtil.encodePagesPath(pageList);
-
-          return Promise.resolve();
-        })
-        .then(function() {
-          return interceptorManager.process('beforeRenderPage', req, res, renderVars);
-        })
-        .then(function() {
-          res.render(req.query.presentation ? 'page_presentation' : pageTeamplate, renderVars);
-        })
-        .catch(function(err) {
-          console.log(err);
-          debug('Error on rendering pageListShowForCrowiPlus', err);
-        });
-      }
-    })
-    .then(function() {
-      return UserGroupRelation.findAllRelationForUser(req.user);
-    }).then(function(groupRelations) {
-      if (groupRelations != null) {
-        renderVars.userRelatedGroups = groupRelations.map(relation => relation.relatedGroup);
-      }
-
-      return PageGroupRelation.findByPage(renderVars.page);
-    }).then((pageGroupRelation) => {
-      if (pageGroupRelation != null) {
-        renderVars.pageRelatedGroup = pageGroupRelation.relatedGroup;
+          .then(function(pageList) {
+            if (pageList.length > limit) {
+              pageList.pop();
+            }
+
+            pagerOptions.length = pageList.length;
+
+            renderVars.viewConfig = {
+              seener_threshold: SEENER_THRESHOLD,
+            };
+            renderVars.pager = generatePager(pagerOptions);
+            renderVars.pages = pagePathUtil.encodePagesPath(pageList);
+
+            return;
+          })
+          .then(function() {
+            return interceptorManager.process('beforeRenderPage', req, res, renderVars);
+          })
+          .then(function() {
+            res.render(req.query.presentation ? 'page_presentation' : pageTeamplate, renderVars);
+          })
+          .catch(function(err) {
+            console.log(err);
+            debug('Error on rendering pageListShowForCrowiPlus', err);
+          });
       }
       }
-
-      return Promise.resolve();
     });
     });
   };
   };
 
 
@@ -431,19 +429,10 @@ module.exports = function(crowi, app) {
   function renderPage(pageData, req, res) {
   function renderPage(pageData, req, res) {
     // create page
     // create page
     if (!pageData) {
     if (!pageData) {
-      var userRelatedGroups
-      UserGroupRelation.findAllRelationForUser(req.user)
-        .then((groupRelations) => {
-          userRelatedGroups = groupRelations.map(relation => relation.relatedGroup);
-          return Promise.resolve();
-        }).then(() => {
-          debug('not found page user group resolver : ', userRelatedGroups);
-          return res.render('customlayout-selector/not_found', {
-            author: {},
-            page: false,
-            userRelatedGroups: userRelatedGroups,
-          });
-        });
+      return res.render('customlayout-selector/not_found', {
+        author: {},
+        page: false,
+      });
     }
     }
 
 
     if (pageData.redirectTo) {
     if (pageData.redirectTo) {
@@ -462,9 +451,16 @@ module.exports = function(crowi, app) {
     Revision.findRevisionList(pageData.path, {})
     Revision.findRevisionList(pageData.path, {})
     .then(function(tree) {
     .then(function(tree) {
       renderVars.tree = tree;
       renderVars.tree = tree;
-
-      return Promise.resolve();
-    }).then(function() {
+    })
+    .then(() => {
+      return PageGroupRelation.findByPage(renderVars.page);
+    })
+    .then((pageGroupRelation) => {
+      if (pageGroupRelation != null) {
+        renderVars.pageRelatedGroup = pageGroupRelation.relatedGroup;
+      }
+    })
+    .then(function() {
       if (userPage) {
       if (userPage) {
         return User.findUserByUsername(User.getUsernameByPath(pageData.path))
         return User.findUserByUsername(User.getUsernameByPath(pageData.path))
         .then(function(data) {
         .then(function(data) {
@@ -508,7 +504,6 @@ module.exports = function(crowi, app) {
 
 
   actions.pageShow = function(req, res) {
   actions.pageShow = function(req, res) {
     var path = path || getPathFromRequest(req);
     var path = path || getPathFromRequest(req);
-    var options = {};
 
 
     // FIXME: せっかく getPathFromRequest になってるのにここが生 params[0] だとダサイ
     // FIXME: せっかく getPathFromRequest になってるのにここが生 params[0] だとダサイ
     var isMarkdown = req.params[0].match(/.+\.md$/) || false;
     var isMarkdown = req.params[0].match(/.+\.md$/) || false;
@@ -623,12 +618,12 @@ module.exports = function(crowi, app) {
 
 
       if (data) {
       if (data) {
         previousRevision = data.revision;
         previousRevision = data.revision;
-        return Page.updatePage(data, body, req.user, { grant: grant, grantUserGroupId: grantUserGroupId});
+        return Page.updatePage(data, body, req.user, { grant, grantUserGroupId });
       }
       }
       else {
       else {
         // new page
         // new page
         updateOrCreate = 'create';
         updateOrCreate = 'create';
-        return Page.create(path, body, req.user, { grant: grant, grantUserGroupId: grantUserGroupId});
+        return Page.create(path, body, req.user, { grant, grantUserGroupId });
       }
       }
     }).then(function(data) {
     }).then(function(data) {
       // data is a saved page data.
       // data is a saved page data.

+ 5 - 44
lib/views/_form.html

@@ -46,54 +46,15 @@
       </span>
       </span>
       {% endif %}
       {% endif %}
 
 
-      {% if forceGrant %}
-      <input type="hidden" name="pageForm[grant]" value="{{ forceGrant }}">
-      {% else %}
-      <div>
-        <div id="page-grant-selector"></div>
-      </div>
-      <input type="hidden" id="page-grant" name="pageForm[grant]" value="{{ pageForm.grant|default(page.grant) }}">
-      <input id="grant-group" type="hidden" value="{% if pageForm.grant %}{{ pageForm.grant }}{% endif %}">
-<!--
-      <select id="select-grant" name="pageForm[grant]" class="m-r-5 selectpicker btn-group-sm">
-        {% for grantId, grantLabel in consts.pageGrants %}
-        <option value="{{ grantId }}" {% if pageForm.grant|default(page.grant) == grantId %}selected{% endif %}>{{ t(grantLabel) }}</option>
-        {% endfor %}
-        {% if user and user.admin && userRelatedGroups %}
-        <option id="no-group" value="/admin/user-groups">{{ t('Only inside the group') }} you have no groups.</option>
-        {% endif %}
-        <option id="group-grant" value="5">{{ t('Only inside the group') }}</option>
-        {% if pageForm.grant|default(page.grant) == "5" && pageRelatedGroup != null %}
-        <option id="group-grant" value="5" selected>{{pageRelatedGroup}}</option>
-        {% endif %}
-      </select>
-      <input id="select-grant-pre" type="hidden" value="{{ page.grant }}">
-      {% endif %}
-      <input id="grant-group" type="hidden" name="pageForm[grantUserGroupId]" value="">
-      {% if userRelatedGroups.length != 0 %}
-      <div class="collapse width">
-        <select name="pageForm[grantUserGroupId]" class="selectpicker btn-group-sm">
-          {% for userGroup in userRelatedGroups %}
-          <option value="{{ userGroup.id }}">{{ userGroup.name }}</option>
-          {% endfor %}
-        </select>
-      </div>
-      {% endif %} -->
-      {% if userRelatedGroups.length != 0 %}
-      <div>
-        <select name="pageForm[grantUserGroupId]" class="selectpicker btn-group-sm">
-          {% for userGroup in userRelatedGroups %}
-          <option value="{{ userGroup.id }}">{{ userGroup.name }}</option>
-          {% endfor %}
-        </select>
-      </div>
-      {% endif %}
-      <!-- <input type="hidden" id="page-grant" value="{{ page.grant }}"> -->
-      <input type="hidden" id="user-related-group-data" value="{{userRelatedGroups}}">
+      <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() }}">
       <input type="hidden" id="edit-form-csrf" name="_csrf" value="{{ csrf() }}">
       <button type="submit" class="btn btn-primary btn-submit" id="edit-form-submit">{{ t('Update') }}</button>
       <button type="submit" class="btn btn-primary btn-submit" id="edit-form-submit">{{ t('Update') }}</button>
     </div>
     </div>
   </div>
   </div>
 </form>
 </form>
+<input type="hidden" id="grant-group-name" value="{{ pageRelatedGroup.name }}">{# for storing group name #}
 <div class="file-module hidden">
 <div class="file-module hidden">
 </div>
 </div>

+ 2 - 0
lib/views/admin/customize.html

@@ -73,6 +73,8 @@
             {# Dark Themes #}
             {# Dark Themes #}
             <div class="d-flex">
             <div class="d-flex">
               {% include 'widget/theme-colorbox.html' with { name: 'default-dark', bg: '#212731', topbar: '#151515', theme: '#f75b36' } %}
               {% include 'widget/theme-colorbox.html' with { name: 'default-dark', bg: '#212731', topbar: '#151515', theme: '#f75b36' } %}
+              {# {% include 'widget/theme-colorbox.html' with { name: 'future', bg: '#16282D', topbar: '#011414', theme: '#04B4AE' } %} #}
+              {% include 'widget/theme-colorbox.html' with { name: 'blue-night', bg: '#061F2F', topbar: '#27343B', theme: '#0090C8' } %}
             </div>
             </div>
           </div>
           </div>
 
 

+ 1 - 0
lib/views/admin/widget/theme-colorbox.html

@@ -4,6 +4,7 @@
     data-theme="{{ webpack_asset('style-theme-' + name).css }}">
     data-theme="{{ webpack_asset('style-theme-' + name).css }}">
 
 
   <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
   <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
+    <title>{{name}}</title>
     <g>
     <g>
       <path d="M -1 -1 L65 -1 L65 65 L-1 65 L-1 -1 Z" fill="{{bg}}"></path>
       <path d="M -1 -1 L65 -1 L65 65 L-1 65 L-1 -1 Z" fill="{{bg}}"></path>
       <path d="M -1 -1 L65 -1 L65 15 L-1 15 L-1 -1 Z" fill="{{topbar}}"></path>
       <path d="M -1 -1 L65 -1 L65 15 L-1 15 L-1 -1 Z" fill="{{topbar}}"></path>

+ 0 - 1
lib/views/layout-growi/not_found.html

@@ -25,6 +25,5 @@
   </div>
   </div>
 
 
   <div id="crowi-modals">
   <div id="crowi-modals">
-    {% include '../modal/select_grant_group.html' %}
   </div>
   </div>
 {% endblock %}
 {% endblock %}

+ 2 - 0
lib/views/layout/layout.html

@@ -118,10 +118,12 @@ gh/highlightjs/cdn-release@9.12.0/build/languages/yaml.min.js
   class="main-container content-wrapper {% block html_base_css %}{% endblock %}
   class="main-container content-wrapper {% block html_base_css %}{% endblock %}
       {% if !layoutType() || 'crowi' === layoutType() %}crowi{% else %}growi{% endif %}"
       {% if !layoutType() || 'crowi' === layoutType() %}crowi{% else %}growi{% endif %}"
   data-me="{{ user._id.toString() }}"
   data-me="{{ user._id.toString() }}"
+  data-is-admin="{{ user.admin }}"
   data-plugin-enabled="{{ isEnabledPlugins() }}"
   data-plugin-enabled="{{ isEnabledPlugins() }}"
   {% block html_base_attr %}{% endblock %}
   {% block html_base_attr %}{% endblock %}
   data-csrftoken="{{ csrf() }}"
   data-csrftoken="{{ csrf() }}"
   data-current-username="{% if user %}{{ user.username }}{% endif %}"
   data-current-username="{% if user %}{{ user.username }}{% endif %}"
+  data-userlang="{% if user %}{{ user.lang }}{% endif %}"
  >
  >
 
 
 <div id="wrapper">
 <div id="wrapper">

+ 0 - 23
lib/views/modal/select_grant_group.html

@@ -1,23 +0,0 @@
-<div class="modal select-grant-group" id="select-grant-group">
-  <div class="modal-dialog">
-    <div class="modal-content">
-
-      <div class="modal-header bg-primary">
-        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-        <div class="modal-title">{{ t('SelectGrantGroup') }}</div>
-      </div>
-
-      <div class="modal-body">
-        <p>グループを下のリストから選択</p>
-
-        <ul class="list-inline">
-          {% for sGroup in userRelatedGroups %}
-          <li><button class="btn btn-xs btn-primary" onclick="$('#grant-group').val('{{sGroup.id}}')" data-dismiss="modal">{{sGroup.name}}</button></li>
-          {% endfor %}
-        </ul>
-
-      </div><!-- /.modal-body -->
-
-    </div><!-- /.modal-content -->
-  </div><!-- /.modal-dialog -->
-</div><!-- /.modal -->

+ 9 - 3
lib/views/widget/page_alerts.html

@@ -1,9 +1,15 @@
 <div class="row row-alerts">
 <div class="row row-alerts">
   <div class="col-xs-12">
   <div class="col-xs-12">
     {% if page && page.grant != 1 %}
     {% if page && page.grant != 1 %}
-    <p class="alert alert-inverse alert-grant">
-      <i class="icon-fw icon-lock"></i><strong>{{ consts.pageGrants[page.grant] }}</strong> ({{ t('Browsing of this page is restricted') }})
-    </p>
+      <p class="alert alert-inverse alert-grant">
+      {% if page.grant == 2 %}
+        <i class="icon-fw icon-link"></i><strong>{{ consts.pageGrants[page.grant] }}</strong> ({{ t('Browsing of this page is restricted') }})
+      {% elseif page.grant == 4 %}
+        <i class="icon-fw icon-lock"></i><strong>{{ consts.pageGrants[page.grant] }}</strong> ({{ t('Browsing of this page is restricted') }})
+      {% elseif page.grant == 5 %}
+        <i class="icon-fw icon-organization"></i><strong>'{{ pageRelatedGroup.name }}' only</strong> ({{ t('Browsing of this page is restricted') }})
+      {% endif %}
+      </p>
     {% endif %}
     {% endif %}
 
 
     {% if page.isDeleted() %}
     {% if page.isDeleted() %}

+ 1 - 1
lib/views/widget/page_list.html

@@ -39,7 +39,7 @@
 
 
     {% if !page.isPublic() %}
     {% if !page.isPublic() %}
     <span>
     <span>
-      <i class="fa fa-lock"></i>
+      <i class="icon icon-lock"></i>
     </span>
     </span>
     {% endif %}
     {% endif %}
   </span>
   </span>

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

@@ -3,4 +3,3 @@
 {% include '../modal/duplicate.html' %}
 {% include '../modal/duplicate.html' %}
 {% include '../modal/put_back.html' %}
 {% include '../modal/put_back.html' %}
 {% include '../modal/page_name_warning.html' %}
 {% include '../modal/page_name_warning.html' %}
-{% include '../modal/select_grant_group.html' %}

+ 5 - 2
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "3.1.0-RC",
+  "version": "3.1.1-RC",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",
@@ -97,15 +97,17 @@
     "passport-local": "^1.0.0",
     "passport-local": "^1.0.0",
     "react": "^16.2.0",
     "react": "^16.2.0",
     "react-dom": "^16.2.0",
     "react-dom": "^16.2.0",
+    "react-i18next": "^7.6.1",
     "rimraf": "^2.6.1",
     "rimraf": "^2.6.1",
     "slack-node": "^0.1.8",
     "slack-node": "^0.1.8",
     "socket.io": "^2.0.3",
     "socket.io": "^2.0.3",
     "string-width": "^2.1.1",
     "string-width": "^2.1.1",
     "swig-templates": "^2.0.2",
     "swig-templates": "^2.0.2",
     "uglifycss": "^0.0.29",
     "uglifycss": "^0.0.29",
-    "xss": "^0.3.5"
+    "xss": "^0.3.7"
   },
   },
   "devDependencies": {
   "devDependencies": {
+    "@alienfast/i18next-loader": "^1.0.16",
     "assets-webpack-plugin": "~3.5.1",
     "assets-webpack-plugin": "~3.5.1",
     "autoprefixer": "^8.2.0",
     "autoprefixer": "^8.2.0",
     "babel-core": "^6.25.0",
     "babel-core": "^6.25.0",
@@ -134,6 +136,7 @@
     "eslint-plugin-react": "^7.7.0",
     "eslint-plugin-react": "^7.7.0",
     "extract-text-webpack-plugin": "^3.0.2",
     "extract-text-webpack-plugin": "^3.0.2",
     "file-loader": "^1.1.0",
     "file-loader": "^1.1.0",
+    "i18next-browser-languagedetector": "^2.2.0",
     "jquery-slimscroll": "^1.3.8",
     "jquery-slimscroll": "^1.3.8",
     "jquery-ui": "^1.12.1",
     "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
     "jquery.cookie": "~1.4.1",

+ 30 - 27
resource/js/app.js

@@ -1,5 +1,8 @@
 import React from 'react';
 import React from 'react';
 import ReactDOM from 'react-dom';
 import ReactDOM from 'react-dom';
+import { I18nextProvider } from 'react-i18next';
+
+import i18nFactory from './i18n';
 
 
 import Crowi from './util/Crowi';
 import Crowi from './util/Crowi';
 // import CrowiRenderer from './util/CrowiRenderer';
 // import CrowiRenderer from './util/CrowiRenderer';
@@ -10,7 +13,7 @@ import SearchPage       from './components/SearchPage';
 import PageEditor       from './components/PageEditor';
 import PageEditor       from './components/PageEditor';
 import OptionsSelector  from './components/PageEditor/OptionsSelector';
 import OptionsSelector  from './components/PageEditor/OptionsSelector';
 import { EditorOptions, PreviewOptions } from './components/PageEditor/OptionsSelector';
 import { EditorOptions, PreviewOptions } from './components/PageEditor/OptionsSelector';
-import GrantSelector, { UserGroup, PageGrant } from './components/PageEditor/GrantSelector';
+import GrantSelector    from './components/PageEditor/GrantSelector';
 import Page             from './components/Page';
 import Page             from './components/Page';
 import PageListSearch   from './components/PageListSearch';
 import PageListSearch   from './components/PageListSearch';
 import PageHistory      from './components/PageHistory';
 import PageHistory      from './components/PageHistory';
@@ -34,6 +37,9 @@ if (!window) {
   window = {};
   window = {};
 }
 }
 
 
+const userlang = $('body').data('userlang');
+const i18n = i18nFactory(userlang);
+
 const mainContent = document.querySelector('#content-main');
 const mainContent = document.querySelector('#content-main');
 let pageId = null;
 let pageId = null;
 let pageRevisionId = null;
 let pageRevisionId = null;
@@ -58,6 +64,7 @@ const isLoggedin = document.querySelector('.main-container.nologin') == null;
 // FIXME
 // FIXME
 const crowi = new Crowi({
 const crowi = new Crowi({
   me: $('body').data('current-username'),
   me: $('body').data('current-username'),
+  isAdmin: $('body').data('is-admin'),
   csrfToken: $('body').data('csrftoken'),
   csrfToken: $('body').data('csrftoken'),
 }, window);
 }, window);
 window.crowi = crowi;
 window.crowi = crowi;
@@ -190,37 +197,33 @@ if (pageEditorOptionsSelectorElem) {
   );
   );
 }
 }
 // render GrantSelector
 // render GrantSelector
-const userRelatedGroupsElem = document.getElementById('user-related-group-data');
 const pageEditorGrantSelectorElem = document.getElementById('page-grant-selector');
 const pageEditorGrantSelectorElem = document.getElementById('page-grant-selector');
-const pageGrantElem = document.getElementById('page-grant');
-const pageGrantGroupElem = document.getElementById('grant-group');
-function updatePageGrantElems(newPageGrant) {
-  pageGrantElem.value = newPageGrant.grant;
-  pageGrantGroupElem.value = newPageGrant.grantGroup.userGroupId || '';
-}
 if (pageEditorGrantSelectorElem) {
 if (pageEditorGrantSelectorElem) {
-  let userRelatedGroups;
-  if (userRelatedGroupsElem != null) {
-    let userRelatedGroupsJSONString = userRelatedGroupsElem.textContent;
-    if (userRelatedGroupsJSONString != null && userRelatedGroupsJSONString.length > 0) {
-      userRelatedGroups = JSON.parse(userRelatedGroupsJSONString || '{}', (value) => {
-        return new UserGroup(value);
-      });
-    }
+  const grantElem = document.getElementById('page-grant');
+  const grantGroupElem = document.getElementById('grant-group');
+  const grantGroupNameElem = document.getElementById('grant-group-name');
+  /* eslint-disable no-inner-declarations */
+  function updateGrantElem(pageGrant) {
+    grantElem.value = pageGrant;
+  }
+  function updateGrantGroupElem(grantGroupId) {
+    grantGroupElem.value = grantGroupId;
   }
   }
-  pageGrant = new PageGrant();
-  pageGrant.grant = document.getElementById('page-grant').value;
-  const grantGroupData = JSON.parse(document.getElementById('grant-group').textContent || '{}');
-  if (grantGroupData != null) {
-    const grantGroup = new UserGroup();
-    grantGroup.userGroupId = grantGroupData.id;
-    grantGroup.userGroup = grantGroupData;
-    pageGrant.grantGroup = grantGroup;
+  function updateGrantGroupNameElem(grantGroupName) {
+    grantGroupNameElem.value = grantGroupName;
   }
   }
+  /* eslint-enable */
+  const pageGrant = +grantElem.value;
+  const pageGrantGroupId = grantGroupElem.value;
+  const pageGrantGroupName = grantGroupNameElem.value;
   ReactDOM.render(
   ReactDOM.render(
-    <GrantSelector crowi={crowi}
-      userRelatedGroups={userRelatedGroups} pageGrant={pageGrant}
-      onChange={updatePageGrantElems} />,
+    <I18nextProvider i18n={i18n}>
+      <GrantSelector crowi={crowi}
+        pageGrant={pageGrant} pageGrantGroupId={pageGrantGroupId} pageGrantGroupName={pageGrantGroupName}
+        onChangePageGrant={updateGrantElem}
+        onDeterminePageGrantGroupId={updateGrantGroupElem}
+        onDeterminePageGrantGroupName={updateGrantGroupNameElem} />
+    </I18nextProvider>,
     pageEditorGrantSelectorElem
     pageEditorGrantSelectorElem
   );
   );
 }
 }

+ 15 - 0
resource/js/components/PageEditor/CodeMirrorEditor.js

@@ -35,6 +35,7 @@ import InterceptorManager from '../../../../lib/util/interceptor-manager';
 
 
 import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
 import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
 import MarkdownTableInterceptor from './MarkdownTableInterceptor';
 import MarkdownTableInterceptor from './MarkdownTableInterceptor';
+import mtu from './MarkdownTableUtil';
 
 
 export default class CodeMirrorEditor extends AbstractEditor {
 export default class CodeMirrorEditor extends AbstractEditor {
 
 
@@ -46,6 +47,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
       value: this.props.value,
       value: this.props.value,
       isEnabledEmojiAutoComplete: false,
       isEnabledEmojiAutoComplete: false,
       isLoadingKeymap: false,
       isLoadingKeymap: false,
+      additionalClass: '',
     };
     };
 
 
     this.init();
     this.init();
@@ -62,6 +64,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
 
     this.scrollCursorIntoViewHandler = this.scrollCursorIntoViewHandler.bind(this);
     this.scrollCursorIntoViewHandler = this.scrollCursorIntoViewHandler.bind(this);
     this.pasteHandler = this.pasteHandler.bind(this);
     this.pasteHandler = this.pasteHandler.bind(this);
+    this.cursorHandler = this.cursorHandler.bind(this);
 
 
     this.renderLoadingKeymapOverlay = this.renderLoadingKeymapOverlay.bind(this);
     this.renderLoadingKeymapOverlay = this.renderLoadingKeymapOverlay.bind(this);
   }
   }
@@ -304,6 +307,16 @@ export default class CodeMirrorEditor extends AbstractEditor {
     }
     }
   }
   }
 
 
+  cursorHandler(editor, event) {
+    const strFromBol = this.getStrFromBol();
+    if (mtu.isEndOfLine(editor) && mtu.linePartOfTableRE.test(strFromBol)) {
+      this.setState({additionalClass: 'autoformat-markdown-table-activated'});
+    }
+    else {
+      this.setState({additionalClass: ''});
+    }
+  }
+
   /**
   /**
    * CodeMirror paste event handler
    * CodeMirror paste event handler
    * see: https://codemirror.net/doc/manual.html#events
    * see: https://codemirror.net/doc/manual.html#events
@@ -352,6 +365,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
     return <React.Fragment>
     return <React.Fragment>
       <ReactCodeMirror
       <ReactCodeMirror
         ref="cm"
         ref="cm"
+        className={this.state.additionalClass}
         editorDidMount={(editor) => {
         editorDidMount={(editor) => {
           // add event handlers
           // add event handlers
           editor.on('paste', this.pasteHandler);
           editor.on('paste', this.pasteHandler);
@@ -385,6 +399,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
             'Ctrl-Q': (cm) => { cm.foldCode(cm.getCursor()) },
             'Ctrl-Q': (cm) => { cm.foldCode(cm.getCursor()) },
           }
           }
         }}
         }}
+        onCursor={this.cursorHandler}
         onScroll={(editor, data) => {
         onScroll={(editor, data) => {
           if (this.props.onScroll != null) {
           if (this.props.onScroll != null) {
             // add line data
             // add line data

+ 200 - 112
resource/js/components/PageEditor/GrantSelector.js

@@ -1,12 +1,14 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+import { translate } from 'react-i18next';
 
 
 import FormGroup from 'react-bootstrap/es/FormGroup';
 import FormGroup from 'react-bootstrap/es/FormGroup';
 import FormControl from 'react-bootstrap/es/FormControl';
 import FormControl from 'react-bootstrap/es/FormControl';
-import ControlLabel from 'react-bootstrap/es/ControlLabel';
-// import Button from 'react-bootstrap/es/Button';
+import ListGroup from 'react-bootstrap/es/ListGroup';
+import ListGroupItem from 'react-bootstrap/es/ListGroupItem';
+import Modal from 'react-bootstrap/es/Modal';
 
 
-// import Modal from 'react-bootstrap/es/Modal';
+const SPECIFIED_GROUP_VALUE = 'specifiedGroup';
 
 
 /**
 /**
  * Page grant select component
  * Page grant select component
@@ -15,79 +17,134 @@ import ControlLabel from 'react-bootstrap/es/ControlLabel';
  * @class GrantSelector
  * @class GrantSelector
  * @extends {React.Component}
  * @extends {React.Component}
  */
  */
-export default class GrantSelector extends React.Component {
+class GrantSelector extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
 
 
+    this.availableGrants = [
+      { pageGrant: 1, iconClass: 'icon-people', styleClass: '', label: 'Public' },
+      { pageGrant: 2, iconClass: 'icon-link', styleClass: 'text-info', label: 'Anyone with the link' },
+      // { pageGrant: 3, iconClass: '', label: 'Specified users only' },
+      { pageGrant: 4, iconClass: 'icon-lock', styleClass: 'text-danger', label: 'Just me' },
+      { pageGrant: 5, iconClass: 'icon-options', styleClass: '', label: 'Only inside the group' },  // appeared only one of these 'pageGrant: 5'
+      { pageGrant: 5, iconClass: 'icon-options', styleClass: '', label: 'Reselect the group' },     // appeared only one of these 'pageGrant: 5'
+    ];
+
     this.state = {
     this.state = {
-      pageGrant: this.props.pageGrant,
-      isGroupModalShown: false,
+      pageGrant: this.props.pageGrant || 1,  // default: 1
+      userRelatedGroups: [],
+      isSelectGroupModalShown: false,
     };
     };
+    if (this.props.pageGrantGroupId !== '') {
+      this.state.pageGrantGroup = {
+        _id: this.props.pageGrantGroupId,
+        name: this.props.pageGrantGroupName
+      };
+    }
 
 
-    this.availableGrants = [1, 2, /*3, */4, 5];
+    this.showSelectGroupModal = this.showSelectGroupModal.bind(this);
+    this.hideSelectGroupModal = this.hideSelectGroupModal.bind(this);
 
 
-    this.availableGrantLabels = {
-      1: 'Public',
-      2: 'Anyone with the linc',
-      // 3:'Specified users only',
-      4: 'Just me',
-      5: 'Only inside the group',
-    };
+    this.getGroupName = this.getGroupName.bind(this);
 
 
-    this.onChangeGrant = this.onChangeGrant.bind(this);
+    this.changeGrantHandler = this.changeGrantHandler.bind(this);
+    this.groupListItemClickHandler = this.groupListItemClickHandler.bind(this);
   }
   }
 
 
-  // Init component when the component did mount.
-  componentDidMount() {
-    this.init();
+  componentDidUpdate(prevProps, prevState) {
+    /*
+     * set SPECIFIED_GROUP_VALUE to grant selector
+     *  cz: bootstrap-select input element has the defferent state to React component
+     */
+    if (this.state.pageGrantGroup != null) {
+      this.grantSelectorInputEl.value = SPECIFIED_GROUP_VALUE;
+    }
+
+    // refresh bootstrap-select
+    // see https://silviomoreto.github.io/bootstrap-select/methods/#selectpickerrefresh
+    $('.page-grant-selector.selectpicker').selectpicker('refresh');
+    //// DIRTY HACK -- 2018.05.25 Yuki Takei
+    // set group name to the bootstrap-select options
+    //  cz: .selectpicker('refresh') doesn't replace data-content
+    $('.page-grant-selector .group-name').text(this.getGroupName());
+
   }
   }
 
 
-  // Initialize the component.
-  init() {
-    this.grantSelectorInputEl.value = this.state.pageGrant.grant;
+  showSelectGroupModal() {
+    this.retrieveUserGroupRelations();
+    this.setState({ isSelectGroupModalShown: true });
+  }
+  hideSelectGroupModal() {
+    this.setState({ isSelectGroupModalShown: false });
+  }
+
+  getGroupName() {
+    const pageGrantGroup = this.state.pageGrantGroup;
+    return pageGrantGroup ? pageGrantGroup.name : '';
   }
   }
 
 
   /**
   /**
-   * On change event handler for pagegrant.
-   * @param {any} grant page grant
-   * @memberof GrantSelector
+   * Retrieve user-group-relations data from backend
+   */
+  retrieveUserGroupRelations() {
+    this.props.crowi.apiGet('/me/user-group-relations')
+      .then(res => {
+        return res.userGroupRelations;
+      })
+      .then(userGroupRelations => {
+        const userRelatedGroups = userGroupRelations.map(relation => {
+          return relation.relatedGroup;
+        });
+        this.setState({userRelatedGroups});
+      });
+  }
+
+  /**
+   * change event handler for pageGrant selector
    */
    */
-  onChangeGrant(grant) {
-    const newValue = this.grantSelectorInputEl.value;
-    const newGrant = Object.assign(this.state.pageGrant, {grant: newValue});
-    this.setState({ pageGrant: newGrant });
+  changeGrantHandler() {
+    const pageGrant = +this.grantSelectorInputEl.value;
+
+    // select group
+    if (pageGrant === 5) {
+      this.showSelectGroupModal();
+      /*
+       * reset grant selector to state
+       */
+      this.grantSelectorInputEl.value = this.state.pageGrant;
+      return;
+    }
 
 
+    this.setState({ pageGrant, pageGrantGroup: null });
     // dispatch event
     // dispatch event
-    this.dispatchOnChange();
+    this.dispatchOnChangePageGrant(pageGrant);
+    this.dispatchOnDeterminePageGrantGroup(null);
   }
   }
 
 
-  // (TBD)
-  // /**
-  //  * On click event handler for grant usergroup.
-  //  *
-  //  * @memberof GrantSelector
-  //  */
-  // onClickGrantGroup() {
-  //   const newValue = this.groupSelectorInputEl.value;
-  //   const newGrant = Object.assign(this.state.pageGrant, { grantGroup: newValue });
-  //   this.setState({ pageGrant: newGrant });
-
-  //   // dispatch event
-  //   this.dispatchOnChange();
-  //   // close group select modal
-  //   if (this.state.isModalShown) {
-  //     this.setState({ isGroupModalShown: false });
-  //   }
-  // }
+  groupListItemClickHandler(pageGrantGroup) {
+    this.setState({ pageGrant: 5, pageGrantGroup });
 
 
-  /**
-   * dispatch onChange event
-   * @memberof GrantSelector
-   */
-  dispatchOnChange() {
-    if (this.props.onChange != null) {
-      this.props.onChange(this.state.pageGrant);
+    // dispatch event
+    this.dispatchOnChangePageGrant(5);
+    this.dispatchOnDeterminePageGrantGroup(pageGrantGroup);
+
+    // hide modal
+    this.hideSelectGroupModal();
+  }
+
+  dispatchOnChangePageGrant(pageGrant) {
+    if (this.props.onChangePageGrant != null) {
+      this.props.onChangePageGrant(pageGrant);
+    }
+  }
+
+  dispatchOnDeterminePageGrantGroup(pageGrantGroup) {
+    if (this.props.onDeterminePageGrantGroupId != null) {
+      this.props.onDeterminePageGrantGroupId(pageGrantGroup ? pageGrantGroup._id : '');
+    }
+    if (this.props.onDeterminePageGrantGroupName != null) {
+      this.props.onDeterminePageGrantGroupName(pageGrantGroup ? pageGrantGroup.name : '');
     }
     }
   }
   }
 
 
@@ -97,17 +154,48 @@ export default class GrantSelector extends React.Component {
    * @memberof GrantSelector
    * @memberof GrantSelector
    */
    */
   renderGrantSelector() {
   renderGrantSelector() {
+    const { t } = this.props;
+
+    let index = 0;
+    let selectedValue = this.state.pageGrant;
     const grantElems = this.availableGrants.map((grant) => {
     const grantElems = this.availableGrants.map((grant) => {
-      return <option key={grant} value={grant}>{this.availableGrantLabels[grant]}</option>;
+      const dataContent = `<i class="icon icon-fw ${grant.iconClass} ${grant.styleClass}"></i> <span class="${grant.styleClass}">${t(grant.label)}</span>`;
+      return <option key={index++} value={grant.pageGrant} data-content={dataContent}>{t(grant.label)}</option>;
     });
     });
 
 
-    const bsClassName = 'form-control-dummy'; // set form-control* to shrink width
+    const pageGrantGroup = this.state.pageGrantGroup;
+    if (pageGrantGroup != null) {
+      selectedValue = SPECIFIED_GROUP_VALUE;
+      // DIRTY HACK -- 2018.05.25 Yuki Takei
+      // remove 'Only inside the group' item
+      //  cz: .selectpicker('refresh') doesn't replace data-content
+      grantElems.splice(3, 1);
+    }
+    else {
+      // DIRTY HACK -- 2018.05.25 Yuki Takei
+      // remove 'Reselect the group' item
+      //  cz: .selectpicker('refresh') doesn't replace data-content
+      grantElems.splice(4, 1);
+    }
+
+    /*
+     * react-bootstrap couldn't be rendered only with React feature.
+     * see also 'componentDidUpdate'
+     */
 
 
+    // add specified group option
+    grantElems.push(
+      <option ref="specifiedGroupOption" key="specifiedGroupKey" value={SPECIFIED_GROUP_VALUE} style={{ display: pageGrantGroup ? 'inherit' : 'none' }}
+          data-content={`<i class="icon icon-fw icon-organization text-success"></i> <span class="group-name text-success">${this.getGroupName()}</span>`}>
+        {this.getGroupName()}
+      </option>
+    );
+
+    const bsClassName = 'form-control-dummy'; // set form-control* to shrink width
     return (
     return (
-      <FormGroup controlId="formControlsSelect">
-        <ControlLabel>Grant:</ControlLabel>
-        <FormControl componentClass="select" placeholder="select" defaultValue={this.state.pageGrant.grant} bsClass={bsClassName} className="btn-group-sm selectpicker"
-          onChange={this.onChangeGrant}
+      <FormGroup className="m-b-0">
+        <FormControl componentClass="select" placeholder="select" defaultValue={selectedValue} bsClass={bsClassName} className="btn-group-sm page-grant-selector selectpicker"
+          onChange={this.changeGrantHandler}
           inputRef={ el => this.grantSelectorInputEl=el }>
           inputRef={ el => this.grantSelectorInputEl=el }>
 
 
           {grantElems}
           {grantElems}
@@ -117,66 +205,66 @@ export default class GrantSelector extends React.Component {
     );
     );
   }
   }
 
 
-  // (TBD)
-  // /**
-  //  * Render select grantgroup modal.
-  //  *
-  //  * @returns
-  //  * @memberof GrantSelector
-  //  */
-  // renderSelectGroupModal() {
-  //   // const userRelatedGroups = this.props.userRelatedGroups;
-  //   const groupList = this.userRelatedGroups.map((group) => {
-  //     return <li>
-  //         <Button onClick={this.onClickGrantGroup(group)} bsClass="btn btn-sm btn-primary">{group.name}</Button>
-  //       </li>;
-  //   });
-  //   return (
-  //     <Modal show={this.props.isGroupModalShown} className="select-grant-group">
-  //       <Modal.Header closeButton>
-  //         <Modal.Title>
-  //           Select a Group
-  //         </Modal.Title>
-  //       </Modal.Header>
-  //       <Modal.Body>
-
-  //         <ul className="list-inline">
-  //           {groupList}
-  //         </ul>
-  //       </Modal.Body>
-  //     </Modal>
-  //   );
-  // }
-
-  render() {
-    return <span>
-      <span className="m-l-5">{this.renderGrantSelector()}</span>
-    </span>;
-  }
-}
+  /**
+   * Render select grantgroup modal.
+   *
+   * @returns
+   * @memberof GrantSelector
+   */
+  renderSelectGroupModal() {
+    const generateGroupListItems = () => {
+      return this.state.userRelatedGroups.map((group) => {
+        return <ListGroupItem key={group._id} header={group.name} onClick={() => { this.groupListItemClickHandler(group) }}>
+            (TBD) List group members
+          </ListGroupItem>;
+      });
+    };
 
 
-export class PageGrant {
-  constructor(props) {
-    this.grant = '';
-    this.grantGroup = null;
+    let content = this.state.userRelatedGroups.length === 0
+      ? <div>
+          <h4>There is no group to which you belong.</h4>
+          { this.props.crowi.isAdmin &&
+            <p><a href="/admin/user-groups"><i className="icon icon-fw icon-login"></i> Manage Groups</a></p>
+          }
+        </div>
+      : <ListGroup>
+        {generateGroupListItems()}
+      </ListGroup>;
 
 
-    Object.assign(this, props);
+    return (
+        <Modal className="select-grant-group"
+          container={this} show={this.state.isSelectGroupModalShown} onHide={this.hideSelectGroupModal}
+        >
+          <Modal.Header closeButton>
+            <Modal.Title>
+              Select a Group
+            </Modal.Title>
+          </Modal.Header>
+          <Modal.Body>
+            {content}
+          </Modal.Body>
+        </Modal>
+    );
   }
   }
-}
-
-export class UserGroup {
-  constructor(props) {
-    this.userGroupId = '';
-    this.userGroup;
 
 
-    Object.assign(this, props);
+  render() {
+    return <React.Fragment>
+      <div className="m-r-5">{this.renderGrantSelector()}</div>
+      {this.renderSelectGroupModal()}
+    </React.Fragment>;
   }
   }
 }
 }
 
 
 GrantSelector.propTypes = {
 GrantSelector.propTypes = {
+  t: PropTypes.func.isRequired,               // i18next
   crowi: PropTypes.object.isRequired,
   crowi: PropTypes.object.isRequired,
   isGroupModalShown: PropTypes.bool,
   isGroupModalShown: PropTypes.bool,
-  userRelatedGroups: PropTypes.object,
-  pageGrant: PropTypes.instanceOf(PageGrant),
-  onChange: PropTypes.func,
+  pageGrant: PropTypes.number,
+  pageGrantGroupId: PropTypes.string,
+  pageGrantGroupName: PropTypes.string,
+  onChangePageGrant: PropTypes.func,
+  onDeterminePageGrantGroupId: PropTypes.func,
+  onDeterminePageGrantGroupName: PropTypes.func,
 };
 };
+
+export default translate()(GrantSelector);

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

@@ -90,7 +90,6 @@ export default class TextAreaEditor extends AbstractEditor {
   insertText(text) {
   insertText(text) {
     const startPos = this.textarea.selectionStart;
     const startPos = this.textarea.selectionStart;
     const endPos = this.textarea.selectionEnd;
     const endPos = this.textarea.selectionEnd;
-
     this.replaceValue(text, startPos, endPos);
     this.replaceValue(text, startPos, endPos);
   }
   }
 
 
@@ -125,13 +124,17 @@ export default class TextAreaEditor extends AbstractEditor {
 
 
   getEolPos() {
   getEolPos() {
     const currentPos = this.textarea.selectionStart;
     const currentPos = this.textarea.selectionStart;
-    return this.textarea.value.indexOf('\n', currentPos);
+    const pos = this.textarea.value.indexOf('\n', currentPos);
+    if (pos < 0) {  // not found but EOF
+      return this.textarea.value.length;
+    }
+    return pos;
   }
   }
 
 
   replaceValue(text, startPos, endPos) {
   replaceValue(text, startPos, endPos) {
     // create new value
     // create new value
     const value = this.textarea.value;
     const value = this.textarea.value;
-    const newValue = value.substring(0, startPos) + text + value.substring(endPos, value.length-1);
+    const newValue = value.substring(0, startPos) + text + value.substring(endPos, value.length);
     // calculate new position
     // calculate new position
     const newPos = startPos + text.length;
     const newPos = startPos + text.length;
 
 

+ 44 - 0
resource/js/i18n.js

@@ -0,0 +1,44 @@
+import i18n from 'i18next';
+import LanguageDetector from 'i18next-browser-languagedetector';
+import { reactI18nextModule } from 'react-i18next';
+
+import resources from '@alias/locales';
+
+export default (userlang) => {
+  // setup LanguageDetector
+  const langDetector = new LanguageDetector();
+  langDetector.addDetector({
+    name: 'userSettingDetector',
+    lookup(options) {
+      return userlang;
+    },
+    cacheUserlanguage(lng, options) {
+    },
+  });
+
+  return i18n
+    .use(langDetector)
+    .use(reactI18nextModule) // if not using I18nextProvider
+    .init({
+      debug: (process.env.NODE_ENV !== 'production'),
+      resources,
+      load: 'currentOnly',
+
+      fallbackLng: 'en-US',
+      detection: {
+        order: ['userSettingDetector', 'querystring', 'localStorage'],
+      },
+
+      interpolation: {
+        escapeValue: false, // not needed for react!!
+      },
+
+      // react i18next special options (optional)
+      react: {
+        wait: false,
+        bindI18n: 'languageChanged loaded',
+        bindStore: 'added removed',
+        nsMode: 'default'
+      }
+    });
+};

+ 1 - 1
resource/js/util/Crowi.js

@@ -38,13 +38,13 @@ export default class Crowi {
 
 
     // FIXME
     // FIXME
     this.me = context.me;
     this.me = context.me;
+    this.isAdmin = context.isAdmin;
 
 
     this.users = [];
     this.users = [];
     this.userByName = {};
     this.userByName = {};
     this.userById   = {};
     this.userById   = {};
     this.draft = {};
     this.draft = {};
     this.editorOptions = {};
     this.editorOptions = {};
-    this.userRelatedGroups = {};
 
 
     this.recoverData();
     this.recoverData();
   }
   }

+ 54 - 0
resource/styles/agile-admin/inverse/colors/blue-night.scss

@@ -0,0 +1,54 @@
+@import '../variables';
+
+$basecolor:#061f2f;
+$themecolor:#0090c8;
+
+$linkcolor: #97d1f0;
+
+$topbar:#27343b;
+$sidebar:#061f2f;
+$bodycolor:#061f2f;
+$headingtext: #fff;
+$bodytext: #d3d4d4;
+$linktext: $linkcolor;
+$linktext-hover: rgba($linktext, 0.8);
+$sidebar-text:#d3d4d4;
+$dark-themecolor:#4f5467;
+
+$primary: $themecolor;
+
+$logo-mark-fill: lighten(desaturate($topbar, 10%), 15%);
+$wikilinktext: $linkcolor;
+$wikilinktext-hover: rgba($linktext, 0.8);
+
+$dark: darken($bodytext, 5%);
+$border: #fff;
+$navbar-border: lighten($basecolor, 25%);
+$active-navbar-border: darken($navbar-border, 3%);
+$btn-default-bgcolor: darken($basecolor, 10%);
+$inline-code-color: #c1f1f0;
+$inline-code-bg: #0a121b;
+
+@import 'apply-colors';
+@import 'apply-colors-dark';
+
+.wiki {
+  .highlighted {
+   background-color: lighten($themecolor, 20%);
+  }
+}
+
+.panel {
+  &, &.panel-white, &.panel-default {
+    border-color: $bodytext;
+    .panel-heading {
+      color: $basecolor;
+      background-color: 1px solid $bodytext;
+    }
+  }
+}
+
+:not(.hljs) > pre:not(.hljs) {
+  color: $bodytext;
+  background-color: $inline-code-bg;
+}

+ 36 - 0
resource/styles/agile-admin/inverse/colors/future.scss

@@ -0,0 +1,36 @@
+@import '../variables';
+
+$basecolor: #16282D;
+$themecolor:rgba(11, 79, 104, 0.616);
+
+$topbar:#011414;
+$sidebar:#fff;
+$bodycolor:$basecolor;
+$headingtext: #D9A364;
+$bodytext: #97D7CF;
+$linktext: darken($themecolor, 15%);
+$linktext-hover: lighten($linktext, 80%);
+$sidebar-text:rgb(65, 133, 124);
+$dark-themecolor:#4F5467;
+
+
+$primary: $themecolor;
+$info: lighten($themecolor,20%);
+
+$logo-mark-fill: rgb(170, 245, 237);
+$wikilinktext: saturate($bodytext, 20%);
+$wikilinktext-hover: darken($wikilinktext, 5%);
+
+$dark: darken($bodytext, 5%);
+$border: lighten($basecolor, 15%);
+$navbar-border: lighten($border, 10%);
+$active-navbar-border: darken($border, 3%);
+$btn-default-bgcolor: darken($basecolor, 10%);
+$inline-code-bg: darken($bodycolor, 5%);
+
+@import 'apply-colors';
+@import 'apply-colors-dark';
+
+.bg-title{
+  border-bottom: 1px solid rgb(131, 228, 215);
+}

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

@@ -82,10 +82,12 @@ body.on-edit {
         .row,
         .row,
         .page-editor-preview-container,
         .page-editor-preview-container,
         .page-editor-preview-body {
         .page-editor-preview-body {
+          min-height: calc(100vh - #{$header-plus-footer});   // for IE11
           height: calc(100vh - #{$header-plus-footer});
           height: calc(100vh - #{$header-plus-footer});
         }
         }
         // left(editor)
         // left(editor)
         .page-editor-editor-container {
         .page-editor-editor-container {
+          min-height: calc(100vh - #{$header-plus-footer});   // for IE11
           height: calc(100vh - #{$header-plus-footer});
           height: calc(100vh - #{$header-plus-footer});
 
 
           .react-codemirror2, .CodeMirror, .CodeMirror-scroll,
           .react-codemirror2, .CodeMirror, .CodeMirror-scroll,
@@ -99,6 +101,7 @@ body.on-edit {
         }
         }
       }
       }
 
 
+
       .page-editor-footer {
       .page-editor-footer {
         width: 100%;
         width: 100%;
         margin: 0;
         margin: 0;
@@ -203,6 +206,13 @@ body.on-edit {
         color: #444;
         color: #444;
       }
       }
     }
     }
+    // add icon on cursor
+    .autoformat-markdown-table-activated .CodeMirror-cursor {
+      &:after {
+        font-family: 'FontAwesome';
+        content: '\f0ce';
+      }
+    }
 
 
     // for Dropzone
     // for Dropzone
     .dropzone {
     .dropzone {
@@ -341,6 +351,13 @@ body.on-edit {
     }
     }
   }
   }
 
 
+  #page-grant-selector {
+    .btn-group {
+      min-width: 150px;
+    }
+  }
+
+
 } // }}}
 } // }}}
 
 
 /*
 /*

+ 8 - 0
resource/styles/scss/theme/blue-night.scss

@@ -0,0 +1,8 @@
+// import colors
+@import '../../agile-admin/inverse/colors/blue-night';
+
+// apply agile-admin theme
+@import '../../agile-admin/inverse/style';
+
+// override
+@import 'override-agileadmin';

+ 8 - 0
resource/styles/scss/theme/future.scss

@@ -0,0 +1,8 @@
+// import colors
+@import '../../agile-admin/inverse/colors/future';
+
+// apply agile-admin theme
+@import '../../agile-admin/inverse/style';
+
+// override
+@import 'override-agileadmin';

+ 56 - 4
yarn.lock

@@ -2,6 +2,15 @@
 # yarn lockfile v1
 # yarn lockfile v1
 
 
 
 
+"@alienfast/i18next-loader@^1.0.16":
+  version "1.0.16"
+  resolved "https://registry.yarnpkg.com/@alienfast/i18next-loader/-/i18next-loader-1.0.16.tgz#218e9736c94457d36bdae68a5085ed68434c9bbf"
+  dependencies:
+    glob-all "^3.1.0"
+    js-yaml "^3.11.0"
+    loader-utils "^1.1.0"
+    lodash "^4.17.10"
+
 "@browser-bunyan/console-formatted-stream@^1.3.0":
 "@browser-bunyan/console-formatted-stream@^1.3.0":
   version "1.3.0"
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/@browser-bunyan/console-formatted-stream/-/console-formatted-stream-1.3.0.tgz#3dc059aa5c1b2a7a1f26e2706e2bdeb9a09bbe57"
   resolved "https://registry.yarnpkg.com/@browser-bunyan/console-formatted-stream/-/console-formatted-stream-1.3.0.tgz#3dc059aa5c1b2a7a1f26e2706e2bdeb9a09bbe57"
@@ -3130,6 +3139,13 @@ getpass@^0.1.1:
   dependencies:
   dependencies:
     assert-plus "^1.0.0"
     assert-plus "^1.0.0"
 
 
+glob-all@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/glob-all/-/glob-all-3.1.0.tgz#8913ddfb5ee1ac7812656241b03d5217c64b02ab"
+  dependencies:
+    glob "^7.0.5"
+    yargs "~1.2.6"
+
 glob-base@^0.3.0:
 glob-base@^0.3.0:
   version "0.3.0"
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4"
   resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4"
@@ -3396,6 +3412,10 @@ hogan.js@^3.0.2:
     mkdirp "0.3.0"
     mkdirp "0.3.0"
     nopt "1.0.10"
     nopt "1.0.10"
 
 
+hoist-non-react-statics@^2.3.1:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.0.tgz#d2ca2dfc19c5a91c5a6615ce8e564ef0347e2a40"
+
 home-or-tmp@^2.0.0:
 home-or-tmp@^2.0.0:
   version "2.0.0"
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8"
   resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8"
@@ -3411,6 +3431,12 @@ html-comment-regex@^1.1.0:
   version "1.1.1"
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.1.tgz#668b93776eaae55ebde8f3ad464b307a4963625e"
   resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.1.tgz#668b93776eaae55ebde8f3ad464b307a4963625e"
 
 
+html-parse-stringify2@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/html-parse-stringify2/-/html-parse-stringify2-2.0.1.tgz#dc5670b7292ca158b7bc916c9a6735ac8872834a"
+  dependencies:
+    void-elements "^2.0.1"
+
 http-errors@1.6.2, http-errors@~1.6.1, http-errors@~1.6.2:
 http-errors@1.6.2, http-errors@~1.6.1, http-errors@~1.6.2:
   version "1.6.2"
   version "1.6.2"
   resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.2.tgz#0a002cc85707192a7e7946ceedc11155f60ec736"
   resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.2.tgz#0a002cc85707192a7e7946ceedc11155f60ec736"
@@ -3461,6 +3487,10 @@ humanize-ms@^1.2.1:
   dependencies:
   dependencies:
     ms "^2.0.0"
     ms "^2.0.0"
 
 
+i18next-browser-languagedetector@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-2.2.0.tgz#5f41abe61964a56dce70102ab31c3ed5d5866edc"
+
 i18next-express-middleware@^1.1.1:
 i18next-express-middleware@^1.1.1:
   version "1.1.1"
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/i18next-express-middleware/-/i18next-express-middleware-1.1.1.tgz#9204f28c8800ac3bff87fbee01945367956f349c"
   resolved "https://registry.yarnpkg.com/i18next-express-middleware/-/i18next-express-middleware-1.1.1.tgz#9204f28c8800ac3bff87fbee01945367956f349c"
@@ -3853,7 +3883,7 @@ js-yaml@3.5.4:
     argparse "^1.0.2"
     argparse "^1.0.2"
     esprima "^2.6.0"
     esprima "^2.6.0"
 
 
-js-yaml@^3.4.3, js-yaml@^3.9.1:
+js-yaml@^3.11.0, js-yaml@^3.4.3, js-yaml@^3.9.1:
   version "3.11.0"
   version "3.11.0"
   resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.11.0.tgz#597c1a8bd57152f26d622ce4117851a51f5ebaef"
   resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.11.0.tgz#597c1a8bd57152f26d622ce4117851a51f5ebaef"
   dependencies:
   dependencies:
@@ -4614,6 +4644,10 @@ minimist@0.0.8:
   version "0.0.8"
   version "0.0.8"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
 
 
+minimist@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.1.0.tgz#99df657a52574c21c9057497df742790b2b4c0de"
+
 minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0:
 minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0:
   version "1.2.0"
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
@@ -5979,6 +6013,14 @@ react-dropzone@^4.2.7:
     attr-accept "^1.0.3"
     attr-accept "^1.0.3"
     prop-types "^15.5.7"
     prop-types "^15.5.7"
 
 
+react-i18next@^7.6.1:
+  version "7.6.1"
+  resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-7.6.1.tgz#c61d8284f3c695893d51033f67c39e65f01212b6"
+  dependencies:
+    hoist-non-react-statics "^2.3.1"
+    html-parse-stringify2 "2.0.1"
+    prop-types "^15.6.0"
+
 react-onclickoutside@^6.1.1:
 react-onclickoutside@^6.1.1:
   version "6.7.0"
   version "6.7.0"
   resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.7.0.tgz#997a4d533114c9a0a104913638aa26afc084f75c"
   resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.7.0.tgz#997a4d533114c9a0a104913638aa26afc084f75c"
@@ -7423,6 +7465,10 @@ vm-browserify@0.0.4:
   dependencies:
   dependencies:
     indexof "0.0.1"
     indexof "0.0.1"
 
 
+void-elements@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec"
+
 warning@^3.0.0:
 warning@^3.0.0:
   version "3.0.0"
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/warning/-/warning-3.0.0.tgz#32e5377cb572de4ab04753bdf8821c01ed605b7c"
   resolved "https://registry.yarnpkg.com/warning/-/warning-3.0.0.tgz#32e5377cb572de4ab04753bdf8821c01ed605b7c"
@@ -7613,9 +7659,9 @@ xmlhttprequest-ssl@~1.5.4:
   version "1.5.4"
   version "1.5.4"
   resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.4.tgz#04f560915724b389088715cc0ed7813e9677bf57"
   resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.4.tgz#04f560915724b389088715cc0ed7813e9677bf57"
 
 
-xss@^0.3.5:
-  version "0.3.7"
-  resolved "https://registry.yarnpkg.com/xss/-/xss-0.3.7.tgz#1df6dc85c0240b455b5e5f0428bdeccd739ab4ee"
+xss@^0.3.7:
+  version "0.3.8"
+  resolved "https://registry.yarnpkg.com/xss/-/xss-0.3.8.tgz#d0cbe23bde490bc98c139f08de3899165a68af0e"
   dependencies:
   dependencies:
     commander "^2.9.0"
     commander "^2.9.0"
     cssfilter "0.0.10"
     cssfilter "0.0.10"
@@ -7755,6 +7801,12 @@ yargs@^8.0.2:
     y18n "^3.2.1"
     y18n "^3.2.1"
     yargs-parser "^7.0.0"
     yargs-parser "^7.0.0"
 
 
+yargs@~1.2.6:
+  version "1.2.6"
+  resolved "https://registry.yarnpkg.com/yargs/-/yargs-1.2.6.tgz#9c7b4a82fd5d595b2bf17ab6dcc43135432fe34b"
+  dependencies:
+    minimist "^0.1.0"
+
 yargs@~3.10.0:
 yargs@~3.10.0:
   version "3.10.0"
   version "3.10.0"
   resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.10.0.tgz#f7ee7bd857dd7c1d2d38c0e74efbd681d1431fd1"
   resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.10.0.tgz#f7ee7bd857dd7c1d2d38c0e74efbd681d1431fd1"