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

Merge pull request #3761 from weseek/master

release v4.2.18
Yuki Takei 4 лет назад
Родитель
Сommit
818e8c905a
43 измененных файлов с 727 добавлено и 475 удалено
  1. 3 3
      .gitignore
  2. 11 3
      CHANGES.md
  3. 4 3
      package.json
  4. 0 0
      public/images/customize-settings/default-dark.svg
  5. 0 0
      public/images/customize-settings/default-light.svg
  6. 0 0
      public/images/customize-settings/fluid-dark.svg
  7. 0 0
      public/images/customize-settings/fluid-light.svg
  8. 5 0
      resource/locales/en_US/admin/admin.json
  9. 5 0
      resource/locales/ja_JP/admin/admin.json
  10. 5 0
      resource/locales/zh_CN/admin/admin.json
  11. 10 4
      src/client/js/components/Admin/Customize/Customize.jsx
  12. 93 0
      src/client/js/components/Admin/Customize/CustomizeLayoutSetting.jsx
  13. 1 1
      src/client/js/components/Admin/Customize/CustomizeThemeSetting.jsx
  14. 5 42
      src/client/js/components/Admin/UserGroup/UserGroupPage.jsx
  15. 31 5
      src/client/js/components/Admin/UserGroup/UserGroupTable.jsx
  16. 1 9
      src/client/js/components/Page/CopyDropdown.jsx
  17. 1 1
      src/client/js/components/PageContentFooter.jsx
  18. 2 8
      src/client/js/components/RevisionComparer/RevisionComparer.jsx
  19. 6 0
      src/client/styles/scss/_admin.scss
  20. 12 0
      src/client/styles/scss/_layout.scss
  21. 1 1
      src/client/styles/scss/_mixins.scss
  22. 14 5
      src/client/styles/scss/_on-edit.scss
  23. 9 1
      src/client/styles/scss/theme/_apply-colors.scss
  24. 15 0
      src/lib/util/path-utils.js
  25. 1 3
      src/server/models/config.js
  26. 25 0
      src/server/models/serializers/user-group-relation-serializer.js
  27. 10 0
      src/server/models/serializers/user-serializer.js
  28. 0 27
      src/server/models/user-group-relation.js
  29. 81 1
      src/server/routes/apiv3/customize-setting.js
  30. 7 21
      src/server/routes/apiv3/user-group-relation.js
  31. 12 3
      src/server/routes/apiv3/user-group.js
  32. 21 14
      src/server/service/global-notification/global-notification-slack.js
  33. 1 1
      src/server/service/global-notification/index.js
  34. 0 3
      src/server/service/user-notification/index.js
  35. 26 30
      src/server/util/slack.js
  36. 3 2
      src/server/views/layout-growi/not_found.html
  37. 1 1
      src/server/views/layout-growi/page.html
  38. 2 2
      src/server/views/layout-growi/page_list.html
  39. 1 1
      src/server/views/layout-growi/shared_page.html
  40. 1 1
      src/server/views/layout-growi/user_page.html
  41. 4 1
      src/server/views/layout/layout.html
  42. 1 1
      src/server/views/me/drafts.html
  43. 296 277
      yarn.lock

+ 3 - 3
.gitignore

@@ -1,12 +1,12 @@
 # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
 
 # dependencies
-/node_modules
+node_modules
 /.pnp
 .pnp.js
 
 # testing
-/coverage
+coverage
 
 # next.js
 /.next/
@@ -16,7 +16,7 @@
 /build
 
 # dist
-/dist/
+/packages/**/dist/
 /report/
 /public/static/js
 /public/static/styles

+ 11 - 3
CHANGES.md

@@ -1,12 +1,20 @@
 # CHANGES
 
-## v4.2.17-RC
+## v4.2.18-RC
+
+* Feature: Cobvertible page contents width.
+* Fix: Group selector of User Group Delete Modal does not show all groups
+* Fix: Global notification to Slack does not encode spaces of page path
+* Support: Upgrade libs
+    * @google-cloud/storage
+
+## v4.2.17
 
 * Improvement: Invoke garbage collection when reindex all pages by elasticsearch
 * Improvement: Hide Sidebar at shared pages
 * Fix: No unsaved alert is displayed without difference the latest markdown and editor value
 
-## v4.2.16-RC
+## v4.2.16
 
 * Fix: "Only inside the group" causes an error
     * Introduced by v4.2.15
@@ -57,7 +65,7 @@
 
 ## v4.2.10
 
-* Feature: Staff Credits for apps on GROWI.cloud 
+* Feature: Staff Credits for apps on GROWI.cloud
 * Improvement: Hackmd button behavior when disabled
 * Improvement: Layout of comparing revisions
 * Fix: Empty trash is not working

+ 4 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.2.17-RC",
+  "version": "4.2.18-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -75,10 +75,12 @@
       "check-node-version: see https://github.com/parshap/check-node-version/issues/35",
       "openid-client: Node.js 12 or higher is required for openid-client@3 and above."
     ],
-    "@google-cloud/storage": "^3.3.0",
+    "@google-cloud/storage": "^5.8.5",
     "@kobalab/socket.io-session": "^1.0.3",
     "@promster/express": "^5.0.1",
     "@promster/server": "^6.0.0",
+    "@slack/web-api": "^6.2.3",
+    "@slack/webhook": "^6.0.0",
     "JSONStream": "^1.3.5",
     "archiver": "^3.1.1",
     "array.prototype.flatmap": "^1.2.2",
@@ -148,7 +150,6 @@
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
     "rimraf": "^3.0.0",
-    "slack-node": "^0.1.8",
     "socket.io": "^2.3.0",
     "stream-to-promise": "^2.2.0",
     "string-width": "^4.1.0",

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/images/customize-settings/default-dark.svg


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/images/customize-settings/default-light.svg


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/images/customize-settings/fluid-dark.svg


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/images/customize-settings/fluid-light.svg


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

@@ -115,6 +115,11 @@
     }
   },
   "customize_setting": {
+    "layout": "Layout",
+    "layout_options": {
+      "default": "Default content width",
+      "expanded": "Content width 100%"
+    },
     "theme": "Theme",
     "theme_desc": {
       "light_and_dark": "Light and dark modes",

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

@@ -115,6 +115,11 @@
     }
   },
   "customize_setting": {
+    "layout": "レイアウト",
+    "layout_options": {
+      "default": "デフォルトのコンテンツ幅",
+      "expanded": "コンテンツ幅 100%"
+    },
     "theme": "テーマ",
     "theme_desc" : {
       "light_and_dark": "Light/Dark モード選択あり",

+ 5 - 0
resource/locales/zh_CN/admin/admin.json

@@ -114,6 +114,11 @@
 		}
 	},
 	"customize_setting": {
+    "layout": "布局",
+    "layout_options": {
+      "default": "默认内容宽度 ",
+      "expanded": "内容宽度100% "
+    },
 		"theme": "主体",
 		"behavior": "行为",
 		"behavior_desc": {

+ 10 - 4
src/client/js/components/Admin/Customize/Customize.jsx

@@ -4,13 +4,15 @@ import PropTypes from 'prop-types';
 
 import loggerFactory from '@alias/logger';
 import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
+import AppContainer from '../../../services/AppContainer';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastError } from '../../../util/apiNotification';
 import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
 import { withLoadingSppiner } from '../../SuspenseUtils';
 
-import CustomizeLayoutSetting from './CustomizeThemeSetting';
+import CustomizeLayoutSetting from './CustomizeLayoutSetting';
+import CustomizeThemeSetting from './CustomizeThemeSetting';
 import CustomizeFunctionSetting from './CustomizeFunctionSetting';
 import CustomizeHighlightSetting from './CustomizeHighlightSetting';
 import CustomizeCssSetting from './CustomizeCssSetting';
@@ -22,7 +24,7 @@ const logger = loggerFactory('growi:services:AdminCustomizePage');
 
 let retrieveErrors = null;
 function Customize(props) {
-  const { adminCustomizeContainer } = props;
+  const { appContainer, adminCustomizeContainer } = props;
 
   if (adminCustomizeContainer.state.currentTheme === adminCustomizeContainer.dummyCurrentTheme) {
     throw (async() => {
@@ -46,7 +48,10 @@ function Customize(props) {
   return (
     <Fragment>
       <div className="mb-5">
-        <CustomizeLayoutSetting />
+        <CustomizeLayoutSetting appContainer={appContainer} />
+      </div>
+      <div className="mb-5">
+        <CustomizeThemeSetting />
       </div>
       <div className="mb-5">
         <CustomizeFunctionSetting />
@@ -70,9 +75,10 @@ function Customize(props) {
   );
 }
 
-const CustomizePageWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(Customize), [AdminCustomizeContainer]);
+const CustomizePageWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(Customize), [AppContainer, AdminCustomizeContainer]);
 
 Customize.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
 };
 

+ 93 - 0
src/client/js/components/Admin/Customize/CustomizeLayoutSetting.jsx

@@ -0,0 +1,93 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import AppContainer from '../../../services/AppContainer';
+
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+import { isDarkMode as isDarkModeByUtil } from '../../../util/color-scheme';
+
+const isDarkMode = isDarkModeByUtil();
+const colorText = isDarkMode ? 'dark' : 'light';
+
+const CustomizeLayoutSetting = (props) => {
+  const { t, appContainer } = props;
+
+  const [isContainerFluid, setIsContainerFluid] = useState(false);
+  const [retrieveError, setRetrieveError] = useState();
+
+  const retrieveData = useCallback(async() => {
+    try {
+      const res = await appContainer.apiv3Get('/customize-setting/layout');
+      setIsContainerFluid(res.data.isContainerFluid);
+    }
+    catch (err) {
+      setRetrieveError(err);
+      toastError(err);
+    }
+  }, [appContainer]);
+
+  useEffect(() => {
+    retrieveData();
+  }, [retrieveData]);
+
+  const onClickSubmit = async() => {
+    try {
+      await appContainer.apiv3Put('/customize-setting/layout', { isContainerFluid });
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.layout') }));
+      retrieveData();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+  return (
+    <React.Fragment>
+      <div className="row">
+        <div className="col-12">
+          <h2 className="admin-setting-header">{t('admin:customize_setting.layout')}</h2>
+
+          <div className="d-flex justify-content-around mt-5">
+            <div id="layoutOptions" className="card-deck">
+              <div
+                className={`card customize-layout-card ${!isContainerFluid ? 'border-active' : ''}`}
+                onClick={() => setIsContainerFluid(false)}
+                role="button"
+              >
+                <img src={`/images/customize-settings/default-${colorText}.svg`} />
+                <div className="card-body text-center">
+                  {t('admin:customize_setting.layout_options.default')}
+                </div>
+              </div>
+              <div
+                className={`card customize-layout-card ${isContainerFluid ? 'border-active' : ''}`}
+                onClick={() => setIsContainerFluid(true)}
+                role="button"
+              >
+                <img src={`/images/customize-settings/fluid-${colorText}.svg`} />
+                <div className="card-body  text-center">
+                  {t('admin:customize_setting.layout_options.expanded')}
+                </div>
+              </div>
+            </div>
+          </div>
+
+          <div className="row my-3">
+            <div className="mx-auto">
+              <button type="button" className="btn btn-primary" onClick={onClickSubmit} disabled={retrieveError != null}>{ t('Update') }</button>
+            </div>
+          </div>
+        </div>
+      </div>
+    </React.Fragment>
+  );
+};
+
+CustomizeLayoutSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+export default withTranslation()(CustomizeLayoutSetting);

+ 1 - 1
src/client/js/components/Admin/Customize/CustomizeThemeSetting.jsx

@@ -35,7 +35,7 @@ class CustomizeThemeSetting extends React.Component {
     if (process.env.NODE_ENV === 'development') {
       return (
         <div className="alert alert-warning">
-          <strong>DEBUG MESSAGE:</strong> development build では、リアルタイムプレビューが無効になります
+          <strong>DEBUG MESSAGE:</strong> Live preview for theme is disabled in development mode.
         </div>
       );
     }

+ 5 - 42
src/client/js/components/Admin/UserGroup/UserGroupPage.jsx

@@ -1,7 +1,6 @@
 import React, { Fragment } from 'react';
 import PropTypes from 'prop-types';
 
-import PaginationWrapper from '../../PaginationWrapper';
 import UserGroupTable from './UserGroupTable';
 import UserGroupCreateForm from './UserGroupCreateForm';
 import UserGroupDeleteModal from './UserGroupDeleteModal';
@@ -17,17 +16,13 @@ class UserGroupPage extends React.Component {
 
     this.state = {
       userGroups: [],
-      userGroupRelations: {},
+      userGroupRelations: [],
       selectedUserGroup: undefined, // not null but undefined (to use defaultProps in UserGroupDeleteModal)
       isDeleteModalShow: false,
-      activePage: 1,
-      totalUserGroups: 0,
-      pagingLimit: Infinity,
     };
 
     this.xss = window.xss;
 
-    this.handlePage = this.handlePage.bind(this);
     this.showDeleteModal = this.showDeleteModal.bind(this);
     this.hideDeleteModal = this.hideDeleteModal.bind(this);
     this.addUserGroup = this.addUserGroup.bind(this);
@@ -101,35 +96,14 @@ class UserGroupPage extends React.Component {
     }
   }
 
-  async handlePage(selectedPage) {
-    await this.setState({ activePage: selectedPage });
-    await this.syncUserGroupAndRelations();
-  }
-
   async syncUserGroupAndRelations() {
-    let userGroups = [];
-    let userGroupRelations = {};
-    let totalUserGroups = 0;
-    let pagingLimit = Infinity;
-
     try {
-      const params = { page: this.state.activePage };
-      const responses = await Promise.all([
-        this.props.appContainer.apiv3.get('/user-groups', params),
-        this.props.appContainer.apiv3.get('/user-group-relations', params),
-      ]);
-
-      const [userGroupsRes, userGroupRelationsRes] = responses;
-      userGroups = userGroupsRes.data.userGroups;
-      totalUserGroups = userGroupsRes.data.totalUserGroups;
-      pagingLimit = userGroupsRes.data.pagingLimit;
-      userGroupRelations = userGroupRelationsRes.data.userGroupRelations;
+      const userGroupsRes = await this.props.appContainer.apiv3.get('/user-groups', { pagination: false });
+      const userGroupRelationsRes = await this.props.appContainer.apiv3.get('/user-group-relations');
 
       this.setState({
-        userGroups,
-        userGroupRelations,
-        totalUserGroups,
-        pagingLimit,
+        userGroups: userGroupsRes.data.userGroups,
+        userGroupRelations: userGroupRelationsRes.data.userGroupRelations,
       });
     }
     catch (err) {
@@ -152,17 +126,6 @@ class UserGroupPage extends React.Component {
           onDelete={this.showDeleteModal}
           userGroupRelations={this.state.userGroupRelations}
         />
-        {this.state.userGroups.length === 0
-        ? <p>No groups yet</p> : (
-          <PaginationWrapper
-            activePage={this.state.activePage}
-            changePage={this.handlePage}
-            totalItemsCount={this.state.totalUserGroups}
-            pagingLimit={this.state.pagingLimit}
-            align="center"
-            size="sm"
-          />
-        )}
         <UserGroupDeleteModal
           userGroups={this.state.userGroups}
           deleteUserGroup={this.state.selectedUserGroup}

+ 31 - 5
src/client/js/components/Admin/UserGroup/UserGroupTable.jsx

@@ -15,17 +15,41 @@ class UserGroupTable extends React.Component {
 
     this.state = {
       userGroups: this.props.userGroups,
-      userGroupRelations: this.props.userGroupRelations,
+      userGroupMap: {},
     };
 
+    this.generateUserGroupMap = this.generateUserGroupMap.bind(this);
     this.onDelete = this.onDelete.bind(this);
   }
 
+  componentWillMount() {
+    const userGroupMap = this.generateUserGroupMap(this.props.userGroups, this.props.userGroupRelations);
+    this.setState({ userGroupMap });
+  }
+
   componentWillReceiveProps(nextProps) {
+    const { userGroups, userGroupRelations } = nextProps;
+    const userGroupMap = this.generateUserGroupMap(userGroups, userGroupRelations);
+
     this.setState({
-      userGroups: nextProps.userGroups,
-      userGroupRelations: nextProps.userGroupRelations,
+      userGroups,
+      userGroupMap,
+    });
+  }
+
+  generateUserGroupMap(userGroups, userGroupRelations) {
+    const userGroupMap = {};
+    userGroupRelations.forEach((relation) => {
+      const group = relation.relatedGroup;
+
+      const users = userGroupMap[group] || [];
+      users.push(relation.relatedUser);
+
+      // register
+      userGroupMap[group] = users;
     });
+
+    return userGroupMap;
   }
 
   onDelete(e) {
@@ -56,6 +80,8 @@ class UserGroupTable extends React.Component {
           </thead>
           <tbody>
             {this.state.userGroups.map((group) => {
+              const users = this.state.userGroupMap[group._id];
+
               return (
                 <tr key={group._id}>
                   {this.props.isAclEnabled
@@ -68,7 +94,7 @@ class UserGroupTable extends React.Component {
                   }
                   <td>
                     <ul className="list-inline">
-                      {this.state.userGroupRelations[group._id].map((user) => {
+                      {users != null && users.map((user) => {
                         return <li key={user._id} className="list-inline-item badge badge-pill badge-warning">{this.xss.process(user.username)}</li>;
                       })}
                     </ul>
@@ -123,7 +149,7 @@ UserGroupTable.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
   userGroups: PropTypes.arrayOf(PropTypes.object).isRequired,
-  userGroupRelations: PropTypes.object.isRequired,
+  userGroupRelations: PropTypes.arrayOf(PropTypes.object).isRequired,
   isAclEnabled: PropTypes.bool.isRequired,
   onDelete: PropTypes.func.isRequired,
 };

+ 1 - 9
src/client/js/components/Page/CopyDropdown.jsx

@@ -12,15 +12,7 @@ import {
 
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 
-function encodeSpaces(str) {
-  if (str == null) {
-    return null;
-  }
-
-  // Encode SPACE and IDEOGRAPHIC SPACE
-  return str.replace(/ /g, '%20').replace(/\u3000/g, '%E3%80%80');
-}
-
+import { encodeSpaces } from '@commons/util/path-utils';
 
 /* eslint-disable react/prop-types */
 const DropdownItemContents = ({ title, contents }) => (

+ 1 - 1
src/client/js/components/PageContentFooter.jsx

@@ -15,7 +15,7 @@ const PageContentFooter = (props) => {
 
   return (
     <div className="page-content-footer py-4 d-edit-none d-print-none">
-      <div className="container-lg">
+      <div className="grw-container-convertible">
         <div className="page-meta">
           <AuthorInfo user={creator} date={createdAt} mode="create" locate="footer" />
           <AuthorInfo user={revisionAuthor} date={updatedAt} mode="update" locate="footer" />

+ 2 - 8
src/client/js/components/RevisionComparer/RevisionComparer.jsx

@@ -6,6 +6,8 @@ import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 
+import { encodeSpaces } from '@commons/util/path-utils';
+
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 import RevisionComparerContainer from '../../services/RevisionComparerContainer';
@@ -21,14 +23,6 @@ const DropdownItemContents = ({ title, contents }) => (
 );
 /* eslint-enable react/prop-types */
 
-function encodeSpaces(str) {
-  if (str == null) {
-    return null;
-  }
-
-  // Encode SPACE and IDEOGRAPHIC SPACE
-  return str.replace(/ /g, '%20').replace(/\u3000/g, '%E3%80%80');
-}
 
 const RevisionComparer = (props) => {
 

+ 6 - 0
src/client/styles/scss/_admin.scss

@@ -118,6 +118,12 @@
   //   }
   // }
 
+  #layoutOptions {
+    .customize-layout-card {
+      border: 4px solid $border-color;
+    }
+  }
+
   // theme selector
   #themeOptions {
     // layout

+ 12 - 0
src/client/styles/scss/_layout.scss

@@ -2,6 +2,18 @@ body {
   overflow-y: scroll !important;
 }
 
+body:not(.growi-layout-fluid) .grw-container-convertible {
+  @extend .container-lg;
+}
+
+body.not-found-page .grw-container-convertible {
+  @extend .container-lg;
+}
+
+body.growi-layout-fluid .grw-container-convertible {
+  @extend .container-fluid;
+}
+
 .grw-logo {
   svg {
     width: $grw-logo-width;

+ 1 - 1
src/client/styles/scss/_mixins.scss

@@ -27,7 +27,7 @@
     height: calc(100vh - #{$editor-margin-top});
     margin-top: 0px !important;
 
-    .container-lg {
+    .grw-container-convertible {
       max-width: unset;
       padding: 0;
       margin: 0;

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

@@ -243,11 +243,6 @@ body.on-edit {
       overflow-y: scroll;
     }
 
-    .wiki {
-      max-width: 980px;
-      margin: 0 auto;
-    }
-
     .grw-editor-configuration-dropdown {
       .icon-container {
         width: 20px;
@@ -304,6 +299,20 @@ body.on-edit {
   }
 }
 
+body.on-edit {
+  &:not(.growi-layout-fluid) .page-editor-preview-body {
+    .wiki {
+      max-width: 980px;
+      margin: 0 auto;
+    }
+  }
+  &.growi-layout-fluid .page-editor-preview-body {
+    .wiki {
+      margin: 0 auto;
+    }
+  }
+}
+
 // overwrite .CodeMirror pre
 .CodeMirror pre.CodeMirror-line {
   font-family: $font-family-monospace;

+ 9 - 1
src/client/styles/scss/theme/_apply-colors.scss

@@ -553,9 +553,17 @@ mark.rbt-highlight-text {
 }
 
 /*
- * GROWI admin page #themeOptions
+ * GROWI admin page #layoutOptions #themeOptions
  */
 .admin-page {
+  #layoutOptions {
+    .customize-layout-card {
+      &.border-active {
+        border-color: $color-theme-color-box;
+      }
+    }
+  }
+
   #themeOptions {
     .theme-option-container.active {
       .theme-option-name {

+ 15 - 0
src/lib/util/path-utils.js

@@ -65,10 +65,25 @@ const convertToNewAffiliationPath = (oldPath, newPath, childPath) => {
   return childPath.replace(pathRegExp, newPath);
 };
 
+/**
+ * Encode SPACE and IDEOGRAPHIC SPACE
+ * @param {string} path
+ * @returns {string}
+ */
+function encodeSpaces(path) {
+  if (path == null) {
+    return null;
+  }
+
+  // Encode SPACE and IDEOGRAPHIC SPACE
+  return path.replace(/ /g, '%20').replace(/\u3000/g, '%E3%80%80');
+}
+
 module.exports = {
   isTopPage,
   isTrashPage,
   isUserPage,
   userPageRoot,
   convertToNewAffiliationPath,
+  encodeSpaces,
 };

+ 1 - 3
src/server/models/config.js

@@ -22,7 +22,6 @@ module.exports = function(crowi) {
     // overwrite
     config['app:installed'] = true;
     config['app:fileUpload'] = true;
-    config['customize:layout'] = 'growi';
     config['customize:isSavedStatesOfTabChanges'] = false;
 
     return config;
@@ -107,7 +106,7 @@ module.exports = function(crowi) {
       'customize:highlightJsStyle' : 'github',
       'customize:highlightJsStyleBorder' : false,
       'customize:theme' : 'default',
-      'customize:layout' : 'growi',
+      'customize:isContainerFluid' : false,
       'customize:isEnabledTimeline' : true,
       'customize:isSavedStatesOfTabChanges' : true,
       'customize:isEnabledAttachTitleHeader' : false,
@@ -194,7 +193,6 @@ module.exports = function(crowi) {
         file: crowi.fileUploadService.getFileUploadEnabled(),
       },
       registrationWhiteList: crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
-      layoutType: crowi.configManager.getConfig('crowi', 'customize:layout'),
       themeType: crowi.configManager.getConfig('crowi', 'customize:theme'),
       isEnabledLinebreaks: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
       isEnabledLinebreaksInComments: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),

+ 25 - 0
src/server/models/serializers/user-group-relation-serializer.js

@@ -0,0 +1,25 @@
+const { serializeUserSecurely } = require('./user-serializer');
+
+function serializeInsecureUserAttributes(userGroupRelation) {
+  if (userGroupRelation.relatedUser != null && userGroupRelation.relatedUser._id != null) {
+    userGroupRelation.relatedUser = serializeUserSecurely(userGroupRelation.relatedUser);
+  }
+  return userGroupRelation;
+}
+
+function serializeUserGroupRelationSecurely(userGroupRelation) {
+  let serialized = userGroupRelation;
+
+  // invoke toObject if page is a model instance
+  if (userGroupRelation.toObject != null) {
+    serialized = userGroupRelation.toObject();
+  }
+
+  serializeInsecureUserAttributes(serialized);
+
+  return serialized;
+}
+
+module.exports = {
+  serializeUserGroupRelationSecurely,
+};

+ 10 - 0
src/server/models/serializers/user-serializer.js

@@ -1,3 +1,6 @@
+const mongoose = require('mongoose');
+
+
 function omitInsecureAttributes(user) {
   // omit password
   delete user.password;
@@ -12,6 +15,13 @@ function omitInsecureAttributes(user) {
 }
 
 function serializeUserSecurely(user) {
+  const User = mongoose.model('User');
+
+  // return when it is not a user object
+  if (user == null || !(user instanceof User)) {
+    return user;
+  }
+
   let serialized = user;
 
   // invoke toObject if page is a model instance

+ 0 - 27
src/server/models/user-group-relation.js

@@ -142,33 +142,6 @@ class UserGroupRelation {
     return relations.map((relation) => { return relation.relatedGroup });
   }
 
-  /**
-   * find all entities with pagination
-   *
-   * @see https://github.com/edwardhotchkiss/mongoose-paginate
-   *
-   * @static
-   * @param {UserGroup} userGroup
-   * @param {any} opts mongoose-paginate options object
-   * @returns {Promise<any>} mongoose-paginate result object
-   * @memberof UserGroupRelation
-   */
-  static findUserGroupRelationsWithPagination(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);
-      });
-  }
-
   /**
    * count by related group id and related user
    *

+ 81 - 1
src/server/routes/apiv3/customize-setting.js

@@ -21,6 +21,12 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *
  *  components:
  *    schemas:
+ *      CustomizeLayout:
+ *        description: CustomizeLayout
+ *        type: object
+ *        properties:
+ *          isContainerFluid:
+ *            type: boolean
  *      CustomizeTheme:
  *        description: CustomizeTheme
  *        type: object
@@ -87,6 +93,9 @@ module.exports = (crowi) => {
   const { customizeService } = crowi;
 
   const validator = {
+    layout: [
+      body('isContainerFluid').isBoolean(),
+    ],
     themeAssetPath: [
       query('themeName').isString(),
     ],
@@ -147,7 +156,6 @@ module.exports = (crowi) => {
   router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
 
     const customizeParams = {
-      layoutType: await crowi.configManager.getConfig('crowi', 'customize:layout'),
       themeType: await crowi.configManager.getConfig('crowi', 'customize:theme'),
       isEnabledTimeline: await crowi.configManager.getConfig('crowi', 'customize:isEnabledTimeline'),
       isSavedStatesOfTabChanges: await crowi.configManager.getConfig('crowi', 'customize:isSavedStatesOfTabChanges'),
@@ -169,6 +177,78 @@ module.exports = (crowi) => {
     return res.apiv3({ customizeParams });
   });
 
+  /**
+   * @swagger
+   *
+   *    /customize-setting/layout:
+   *      get:
+   *        tags: [CustomizeSetting]
+   *        operationId: getLayoutCustomizeSetting
+   *        summary: /customize-setting/layout
+   *        description: Get layout
+   *        responses:
+   *          200:
+   *            description: Succeeded to get layout
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/CustomizeLayout'
+   */
+  router.get('/layout', loginRequiredStrictly, adminRequired, async(req, res) => {
+
+    try {
+      const isContainerFluid = await crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
+      return res.apiv3({ isContainerFluid });
+    }
+    catch (err) {
+      const msg = 'Error occurred in getting layout';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'get-layout-failed'));
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /customize-setting/layout:
+   *      put:
+   *        tags: [CustomizeSetting]
+   *        operationId: updateLayoutCustomizeSetting
+   *        summary: /customize-setting/layout
+   *        description: Update layout
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/CustomizeLayout'
+   *        responses:
+   *          200:
+   *            description: Succeeded to update layout
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/CustomizeLayout'
+   */
+  router.put('/layout', loginRequiredStrictly, adminRequired, csrf, validator.layout, apiV3FormValidator, async(req, res) => {
+    const requestParams = {
+      'customize:isContainerFluid': req.body.isContainerFluid,
+    };
+
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
+      const customizedParams = {
+        isContainerFluid: await crowi.configManager.getConfig('crowi', 'customize:isContainerFluid'),
+      };
+      return res.apiv3({ customizedParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating layout';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-layout-failed'));
+    }
+  });
+
   /**
    * @swagger
    *

+ 7 - 21
src/server/routes/apiv3/user-group-relation.js

@@ -4,8 +4,8 @@ const logger = loggerFactory('growi:routes:apiv3:user-group-relation'); // eslin
 
 const express = require('express');
 
-const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 const ErrorV3 = require('../../models/vo/error-apiv3');
+const { serializeUserGroupRelationSecurely } = require('../../models/serializers/user-group-relation-serializer');
 
 const router = express.Router();
 
@@ -19,7 +19,7 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
 
-  const { UserGroup, UserGroupRelation } = crowi.models;
+  const { UserGroupRelation } = crowi.models;
 
   /**
    * @swagger
@@ -42,26 +42,12 @@ module.exports = (crowi) => {
    *                      description: contains arrays user objects related
    */
   router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
-    // TODO: filter with querystring? or body
     try {
-      const page = parseInt(req.query.page) || 1;
-      const result = await UserGroup.findUserGroupsWithPagination({ page });
-      // const pager = createPager(result.total, result.limit, result.page, result.pages, MAX_PAGE_LIST);
-      const userGroups = result.docs;
-
-      const userGroupRelationsObj = {};
-      await Promise.all(userGroups.map(async(userGroup) => {
-        const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
-        userGroupRelationsObj[userGroup._id] = userGroupRelations.map((userGroupRelation) => {
-          return serializeUserSecurely(userGroupRelation.relatedUser);
-        });
-      }));
-
-      const data = {
-        userGroupRelations: userGroupRelationsObj,
-      };
-
-      return res.apiv3(data);
+      const relations = await UserGroupRelation.find().populate('relatedUser');
+
+      const serialized = relations.map(relation => serializeUserGroupRelationSecurely(relation));
+
+      return res.apiv3({ userGroupRelations: serialized });
     }
     catch (err) {
       const msg = 'Error occurred in fetching user group relations';

+ 12 - 3
src/server/routes/apiv3/user-group.js

@@ -62,10 +62,18 @@ module.exports = (crowi) => {
    *                      description: a result of `UserGroup.find`
    */
   router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
+    const { query } = req;
+
     // TODO: filter with querystring
     try {
-      const page = parseInt(req.query.page) || 1;
-      const result = await UserGroup.findUserGroupsWithPagination({ page });
+      const page = query.page != null ? parseInt(query.page) : undefined;
+      const limit = query.limit != null ? parseInt(query.limit) : undefined;
+      const offset = query.offset != null ? parseInt(query.offset) : undefined;
+      const pagination = query.pagination != null ? query.pagination !== 'false' : undefined;
+
+      const result = await UserGroup.findUserGroupsWithPagination({
+        page, limit, offset, pagination,
+      });
       const { docs: userGroups, totalDocs: totalUserGroups, limit: pagingLimit } = result;
       return res.apiv3({ userGroups, totalUserGroups, pagingLimit });
     }
@@ -288,9 +296,10 @@ module.exports = (crowi) => {
       const userGroup = await UserGroup.findById(id);
       const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
 
-      const users = userGroupRelations.map((userGroupRelation) => {
+      const serializeUsers = userGroupRelations.map((userGroupRelation) => {
         return serializeUserSecurely(userGroupRelation.relatedUser);
       });
+      const users = serializeUsers.filter(user => user != null);
 
       return res.apiv3({ users });
     }

+ 21 - 14
src/server/service/global-notification/global-notification-slack.js

@@ -1,6 +1,8 @@
 const logger = require('@alias/logger')('growi:service:GlobalNotificationSlackService'); // eslint-disable-line no-unused-vars
 const urljoin = require('url-join');
 
+const { encodeSpaces } = require('@commons/util/path-utils');
+
 /**
  * sub service class of GlobalNotificationSetting
  */
@@ -13,22 +15,24 @@ class GlobalNotificationSlackService {
     this.event = crowi.model('GlobalNotificationSetting').EVENT;
   }
 
+
   /**
    * send slack global notification
    *
    * @memberof GlobalNotificationSlackService
    *
    * @param {string} event
+   * @param {string} id
    * @param {string} path
    * @param {User} triggeredBy user who triggered the event
    * @param {{ comment: Comment, oldPath: string }} _ event specific vars
    */
-  async fire(event, path, triggeredBy, vars) {
+  async fire(event, id, path, triggeredBy, vars) {
     const GlobalNotification = this.crowi.model('GlobalNotificationSetting');
     const notifications = await GlobalNotification.findSettingByPathAndEvent(event, path, this.type);
 
-    const messageBody = this.generateMessageBody(event, path, triggeredBy, vars);
-    const attachmentBody = this.generateAttachmentBody(event, path, triggeredBy, vars);
+    const messageBody = this.generateMessageBody(event, id, path, triggeredBy, vars);
+    const attachmentBody = this.generateAttachmentBody(event, id, path, triggeredBy, vars);
 
     await Promise.all(notifications.map((notification) => {
       return this.slack.sendGlobalNotification(messageBody, attachmentBody, notification.slackChannels);
@@ -41,26 +45,29 @@ class GlobalNotificationSlackService {
    * @memberof GlobalNotificationSlackService
    *
    * @param {string} event event name triggered
+   * @param {string} id page id
    * @param {string} path path triggered the event
    * @param {User} triggeredBy user triggered the event
    * @param {{ comment: Comment, oldPath: string }} _ event specific vars
    *
    * @return  {string} slack message body
    */
-  generateMessageBody(event, path, triggeredBy, { comment, oldPath }) {
-    const pageUrl = `<${urljoin(this.crowi.appService.getSiteUrl(), path)}|${path}>`;
-    const username = `<${urljoin(this.crowi.appService.getSiteUrl(), 'user', triggeredBy.username)}|${triggeredBy.username}>`;
+  generateMessageBody(event, id, path, triggeredBy, { comment, oldPath }) {
+    const siteUrl = this.crowi.appService.getSiteUrl();
+    const parmaLink = `<${urljoin(siteUrl, id)}|${path}>`;
+    const pathLink = `<${urljoin(siteUrl, encodeSpaces(path))}|${path}>`;
+    const username = `<${urljoin(siteUrl, 'user', triggeredBy.username)}|${triggeredBy.username}>`;
     let messageBody;
 
     switch (event) {
       case this.event.PAGE_CREATE:
-        messageBody = `:bell: ${username} created ${pageUrl}`;
+        messageBody = `:bell: ${username} created ${parmaLink}`;
         break;
       case this.event.PAGE_EDIT:
-        messageBody = `:bell: ${username} edited ${pageUrl}`;
+        messageBody = `:bell: ${username} edited ${parmaLink}`;
         break;
       case this.event.PAGE_DELETE:
-        messageBody = `:bell: ${username} deleted ${pageUrl}`;
+        messageBody = `:bell: ${username} deleted ${pathLink}`;
         break;
       case this.event.PAGE_MOVE:
         // validate for page move
@@ -68,18 +75,17 @@ class GlobalNotificationSlackService {
           throw new Error(`invalid vars supplied to GlobalNotificationSlackService.generateOption for event ${event}`);
         }
         // eslint-disable-next-line no-case-declarations
-        const oldPageUrl = `<${urljoin(this.crowi.appService.getSiteUrl(), oldPath)}|${oldPath}>`;
-        messageBody = `:bell: ${username} moved ${oldPageUrl} to ${pageUrl}`;
+        messageBody = `:bell: ${username} moved ${oldPath} to ${parmaLink}`;
         break;
       case this.event.PAGE_LIKE:
-        messageBody = `:bell: ${username} liked ${pageUrl}`;
+        messageBody = `:bell: ${username} liked ${parmaLink}`;
         break;
       case this.event.COMMENT:
         // validate for comment
         if (comment == null) {
           throw new Error(`invalid vars supplied to GlobalNotificationSlackService.generateOption for event ${event}`);
         }
-        messageBody = `:bell: ${username} commented on ${pageUrl}`;
+        messageBody = `:bell: ${username} commented on ${parmaLink}`;
         break;
       default:
         throw new Error(`unknown global notificaiton event: ${event}`);
@@ -94,13 +100,14 @@ class GlobalNotificationSlackService {
    * @memberof GlobalNotificationSlackService
    *
    * @param {string} event event name triggered
+   * @param {string} id page id
    * @param {string} path path triggered the event
    * @param {User} triggeredBy user triggered the event
    * @param {{ comment: Comment, oldPath: string }} _ event specific vars
    *
    * @return  {string} slack attachment body
    */
-  generateAttachmentBody(event, path, triggeredBy, { comment, oldPath }) {
+  generateAttachmentBody(event, id, path, triggeredBy, { comment, oldPath }) {
     const attachmentBody = '';
 
     // TODO: create attachment

+ 1 - 1
src/server/service/global-notification/index.js

@@ -44,7 +44,7 @@ class GlobalNotificationService {
 
     await Promise.all([
       this.gloabalNotificationMail.fire(event, page.path, triggeredBy, vars),
-      this.gloabalNotificationSlack.fire(event, page.path, triggeredBy, vars),
+      this.gloabalNotificationSlack.fire(event, page.id, page.path, triggeredBy, vars),
     ]);
   }
 

+ 0 - 3
src/server/service/user-notification/index.js

@@ -46,9 +46,6 @@ class UserNotificationService {
       else {
         res = await slack.postPage(page, user, chan, mode, previousRevision);
       }
-      if (res.status !== 'ok') {
-        throw new Error(`fail to send slack notification to #${chan} channel`);
-      }
       return res;
     });
 

+ 26 - 30
src/server/util/slack.js

@@ -8,42 +8,38 @@ const urljoin = require('url-join');
 /* eslint-disable no-use-before-define */
 
 module.exports = function(crowi) {
-  const Slack = require('slack-node');
+  const { IncomingWebhook } = require('@slack/webhook');
+  const { WebClient } = require('@slack/web-api');
   const { configManager } = crowi;
 
   const slack = {};
 
-  const postWithIwh = function(messageObj) {
-    return new Promise((resolve, reject) => {
-      const client = new Slack();
-      client.setWebhook(configManager.getConfig('notification', 'slack:incomingWebhookUrl'));
-      client.webhook(messageObj, (err, res) => {
-        if (err) {
-          debug('Post error', err, res);
-          debug('Sent data to slack is:', messageObj);
-          return reject(err);
-        }
-        resolve(res);
-      });
-    });
+  const postWithIwh = async(messageObj) => {
+    const webhook = new IncomingWebhook(configManager.getConfig('notification', 'slack:incomingWebhookUrl'));
+    try {
+      await webhook.send(messageObj);
+    }
+    catch (error) {
+      debug('Post error', error);
+      debug('Sent data to slack is:', messageObj);
+      throw error;
+    }
   };
 
-  const postWithWebApi = function(messageObj) {
-    return new Promise((resolve, reject) => {
-      const client = new Slack(configManager.getConfig('notification', 'slack:token'));
-      // stringify attachments
-      if (messageObj.attachments != null) {
-        messageObj.attachments = JSON.stringify(messageObj.attachments);
-      }
-      client.api('chat.postMessage', messageObj, (err, res) => {
-        if (err) {
-          debug('Post error', err, res);
-          debug('Sent data to slack is:', messageObj);
-          return reject(err);
-        }
-        resolve(res);
-      });
-    });
+  const postWithWebApi = async(messageObj) => {
+    const client = new WebClient(configManager.getConfig('notification', 'slack:token'));
+    // stringify attachments
+    if (messageObj.attachments != null) {
+      messageObj.attachments = JSON.stringify(messageObj.attachments);
+    }
+    try {
+      await client.chat.postMessage(messageObj);
+    }
+    catch (error) {
+      debug('Post error', error);
+      debug('Sent data to slack is:', messageObj);
+      throw error;
+    }
   };
 
   const convertMarkdownToMarkdown = function(body) {

+ 3 - 2
src/server/views/layout-growi/not_found.html

@@ -1,15 +1,16 @@
 {% extends 'base/layout.html' %}
 
+{% block html_base_css %}not-found-page{% endblock %}
 
 {% block content_main_before %}
-  <div class="container-lg">
+  <div class="grw-container-convertible">
     {% include '../widget/page_alerts.html' %}
   </div>
 {% endblock %}
 
 
 {% block content_main %}
-  <div class="container-lg">
+  <div class="grw-container-convertible">
     {% include '../widget/not_found_content.html' %}
   </div>
 {% endblock %}

+ 1 - 1
src/server/views/layout-growi/page.html

@@ -6,7 +6,7 @@
 
 
 {% block content_main %}
-  <div class="container-lg">
+  <div class="grw-container-convertible">
 
     {% include '../widget/page_content.html' %}
 

+ 2 - 2
src/server/views/layout-growi/page_list.html

@@ -6,7 +6,7 @@
 
 
 {% block content_main %}
-  <div class="container-lg">
+  <div class="grw-container-convertible">
     {% include '../widget/page_content.html' %}
   </div>
 {% endblock %}
@@ -14,7 +14,7 @@
 
 {% block content_main_after %}
   {% if isTrashPage() %}
-    <div class="container-lg">
+    <div class="grw-container-convertible">
       <div id="trash-page-list"></div>
     </div>
   {% endif %}

+ 1 - 1
src/server/views/layout-growi/shared_page.html

@@ -21,7 +21,7 @@
 {% endblock %}
 
 {% block content_main %}
-  <div class="container-lg">
+  <div class="grw-container-convertible">
 
     <div
       id="is-shared-page"

+ 1 - 1
src/server/views/layout-growi/user_page.html

@@ -1,7 +1,7 @@
 {% extends 'page.html' %}
 
 {% block content_main %}
-  <div class="container-lg user-page">
+  <div class="grw-container-convertible user-page">
 
     {% include '../widget/page_content.html' %}
 

+ 4 - 1
src/server/views/layout/layout.html

@@ -60,8 +60,11 @@
 {% endblock %}
 
 {% block html_body %}
+{% if getConfig('crowi', 'customize:isContainerFluid') %}
+  {% set additionalBodyClass = 'growi-layout-fluid' %}
+{% endif %}
 <body
-  class="{% block html_base_css %}{% endblock %} growi"
+  class="{% block html_base_css %}{% endblock %} growi {{ additionalBodyClass }}"
   data-is-admin="{{ user.admin }}"
   data-plugin-enabled="{{ getConfig('crowi', 'plugin:isEnabledPlugins') }}"
   {% block html_base_attr %}{% endblock %}

+ 1 - 1
src/server/views/me/drafts.html

@@ -14,7 +14,7 @@
 {% endblock %}
 
 <div id="main" class="main">
-  <div id="content-main" class="content-main container-lg">
+  <div id="content-main" class="content-main grw-container-convertible">
     <div id="my-drafts"></div>
   </div>
 </div>

Разница между файлами не показана из-за своего большого размера
+ 296 - 277
yarn.lock


Некоторые файлы не были показаны из-за большого количества измененных файлов