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

Merge branch 'reactify-admin/CustomizePage' into i18n-customize-layout

# Conflicts:
#	resource/locales/en-US/translation.json
#	resource/locales/ja/translation.json
#	src/client/js/components/Admin/Customize/CustomizeLayoutOptions.jsx
itizawa 6 лет назад
Родитель
Сommit
cd8636990b

+ 2 - 1
CHANGES.md

@@ -2,7 +2,8 @@
 
 ## 3.5.19-RC
 
-* 
+* Improvement: Drop unnecessary MongoDB collection indexes
+* Improvement: Organize MongoDB collection indexes uniqueness
 
 ## 3.5.18
 

+ 10 - 8
config/jest.config.js

@@ -1,6 +1,15 @@
 // For a detailed explanation regarding each configuration property, visit:
 // https://jestjs.io/docs/en/configuration.html
 
+const MODULE_NAME_MAPPING = {
+  '@root/(.+)': '<rootDir>/$1',
+  '@commons/(.+)': '<rootDir>/src/lib/$1',
+  '@server/(.+)': '<rootDir>/src/server/$1',
+  '@alias/logger': '<rootDir>/src/lib/service/logger',
+  // -- doesn't work with unknown error -- 2019.06.19 Yuki Takei
+  // debug: '<rootDir>/src/lib/service/logger/alias-for-debug',
+};
+
 module.exports = {
   // Indicates whether each individual test should be reported during the run
   verbose: true,
@@ -19,14 +28,7 @@ module.exports = {
       // Automatically clear mock calls and instances between every test
       clearMocks: true,
       // A map from regular expressions to module names that allow to stub out resources with a single module
-      moduleNameMapper: {
-        '@root/(.+)': '<rootDir>/$1',
-        '@commons/(.+)': '<rootDir>/src/lib/$1',
-        '@server/(.+)': '<rootDir>/src/server/$1',
-        '@alias/logger': '<rootDir>/src/lib/service/logger',
-        // -- doesn't work with unknown error -- 2019.06.19 Yuki Takei
-        // debug: '<rootDir>/src/lib/service/logger/alias-for-debug',
-      },
+      moduleNameMapper: MODULE_NAME_MAPPING,
     },
     // {
     //   displayName: 'client',

+ 27 - 0
src/client/js/components/Admin/Common/AdminUpdateButtonRow.jsx

@@ -0,0 +1,27 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+class AdminUpdateButtonRow extends React.PureComponent {
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <div className="row my-3">
+        <div className="col-xs-offset-4 col-xs-5">
+          <button type="button" className="btn btn-primary" onClick={this.props.onClick}>{ t('Update') }</button>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+AdminUpdateButtonRow.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  onClick: PropTypes.func.isRequired,
+};
+
+export default withTranslation()(AdminUpdateButtonRow);

+ 5 - 3
src/client/js/components/Admin/Customize/Customize.jsx

@@ -7,6 +7,7 @@ import AppContainer from '../../../services/AppContainer';
 
 import { createSubscribedElement } from '../../UnstatedUtils';
 import CustomizeLayoutSetting from './CustomizeLayoutSetting';
+import CustomizeBehaviorSetting from './CustomizeBehaviorSetting';
 
 class Customize extends React.Component {
 
@@ -15,11 +16,12 @@ class Customize extends React.Component {
 
     return (
       <Fragment>
-        <div className="row my-3">
+        <div className="my-3">
           <CustomizeLayoutSetting />
         </div>
-        <legend>{t('customize_page.Behavior')}</legend>
-        {/* 挙動フォームの react componentをここで呼ぶ(GW-246) */}
+        <div className="my-3">
+          <CustomizeBehaviorSetting />
+        </div>
         <legend>{t('customize_page.Function')}</legend>
         {/* 機能フォームの react componentをここで呼ぶ(GW-276) */}
         <legend>{t('customize_page.Code Highlight')}</legend>

+ 38 - 0
src/client/js/components/Admin/Customize/CustomizeBehaviorOption.jsx

@@ -0,0 +1,38 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+class CustomizeBehaviorOption extends React.PureComponent {
+
+  render() {
+
+    return (
+      <React.Fragment>
+        <h4>
+          <div className="radio radio-primary">
+            <input type="radio" id={`radioBehavior${this.props.behaviorType}`} checked={this.props.isSelected} onChange={this.props.onSelected} />
+            <label htmlFor={`radioBehavior${this.props.behaviorType}`}>
+              {/* eslint-disable-next-line react/no-danger */}
+              <span dangerouslySetInnerHTML={{ __html: this.props.labelHtml }} />
+            </label>
+          </div>
+        </h4>
+        {/* render layout description */}
+        {this.props.children}
+      </React.Fragment>
+    );
+  }
+
+}
+
+CustomizeBehaviorOption.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  behaviorType: PropTypes.string.isRequired,
+  labelHtml: PropTypes.string.isRequired,
+  isSelected: PropTypes.bool.isRequired,
+  onSelected: PropTypes.func.isRequired,
+  children: PropTypes.object.isRequired,
+};
+
+export default withTranslation()(CustomizeBehaviorOption);

+ 100 - 0
src/client/js/components/Admin/Customize/CustomizeBehaviorSetting.jsx

@@ -0,0 +1,100 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+
+import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
+import CustomizeBehaviorOption from './CustomizeBehaviorOption';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+const logger = loggerFactory('growi:importer');
+
+class CustomizeBehaviorSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async onClickSubmit() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    try {
+      await adminCustomizeContainer.updateCustomizeBehavior();
+      toastSuccess(t('customize_page.update_behavior_success'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    return (
+      <React.Fragment>
+        <h2>{t('customize_page.Behavior')}</h2>
+        <div className="row">
+          <div className="col-xs-6">
+            <CustomizeBehaviorOption
+              behaviorType="growi"
+              isSelected={adminCustomizeContainer.state.currentBehavior === 'growi'}
+              onSelected={() => adminCustomizeContainer.switchBehaviorType('growi')}
+              labelHtml='GROWI Simplified Behavior <small class="text-success">(Recommended)</small>'
+            >
+              {/* TODO i18n */}
+              <ul>
+                <li>Both of <code>/page</code> and <code>/page/</code> shows the same page</li>
+                <li><code>/nonexistent_page</code> shows editing form</li>
+                <li>All pages shows the list of sub pages <b>if using GROWI Enhanced Layout</b></li>
+              </ul>
+            </CustomizeBehaviorOption>
+          </div>
+
+          <div className="col-xs-6">
+            <CustomizeBehaviorOption
+              behaviorType="crowi-plus"
+              isSelected={adminCustomizeContainer.state.currentBehavior === 'crowi-plus'}
+              onSelected={() => adminCustomizeContainer.switchBehaviorType('crowi-plus')}
+              labelHtml="Crowi Classic Behavior"
+            >
+              {/* TODO i18n */}
+              <ul>
+                <li><code>/page</code> shows the page</li>
+                <li><code>/page/</code> shows the list of sub pages</li>
+                <ul>
+                  <li>If portal is applied to <code>/page/</code> , the portal and the list of sub pages are shown</li>
+                </ul>
+                <li><code>/nonexistent_page</code> shows editing form</li>
+                <li><code>/nonexistent_page/</code> the list of sub pages</li>
+              </ul>
+            </CustomizeBehaviorOption>
+          </div>
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.onClickSubmit} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+const CustomizeBehaviorSettingWrapper = (props) => {
+  return createSubscribedElement(CustomizeBehaviorSetting, props, [AppContainer, AdminCustomizeContainer]);
+};
+
+CustomizeBehaviorSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+};
+
+export default withTranslation()(CustomizeBehaviorSettingWrapper);

+ 2 - 2
src/client/js/components/Admin/Customize/CustomizeLayoutOption.jsx

@@ -8,7 +8,7 @@ class CustomizeLayoutOption extends React.Component {
     const { layoutType } = this.props;
 
     return (
-      <div className="col-sm-4">
+      <React.Fragment>
         <h4>
           <div className="radio radio-primary">
             <input type="radio" id={`radio-layout-${layoutType}`} checked={this.props.isSelected} onChange={this.props.onSelected} />
@@ -23,7 +23,7 @@ class CustomizeLayoutOption extends React.Component {
         </a>
         {/* render layout description */}
         {this.props.children}
-      </div>
+      </React.Fragment>
     );
   }
 

+ 47 - 41
src/client/js/components/Admin/Customize/CustomizeLayoutOptions.jsx

@@ -14,49 +14,55 @@ class CustomizeLayoutOptions extends React.Component {
     const { t, adminCustomizeContainer } = this.props;
 
     return (
-      <React.Fragment>
-        <CustomizeLayoutOption
-          layoutType="crowi-plus"
-          isSelected={adminCustomizeContainer.state.currentLayout === 'growi'}
-          onSelected={() => adminCustomizeContainer.switchLayoutType('growi')}
-          labelHtml={'GROWI Enhanced Layout <small className="text-success">(Recommended)</small>'}
-        >
-          <h4>{t('customize_page.layout_description.growi_title')}</h4>
-          <ul>
-            <li>{t('customize_page.layout_description.growi_text1')}</li>
-            <li>{t('customize_page.layout_description.growi_text2')}</li>
-            <li>{t('customize_page.layout_description.growi_text3')}</li>
-          </ul>
-        </CustomizeLayoutOption>
+      <div className="row">
+        <div className="col-sm-4">
+          <CustomizeLayoutOption
+            layoutType="crowi-plus"
+            isSelected={adminCustomizeContainer.state.currentLayout === 'growi'}
+            onSelected={() => adminCustomizeContainer.switchLayoutType('growi')}
+            labelHtml={'GROWI Enhanced Layout <small className="text-success">(Recommended)</small>'}
+          >
+            <h4>{t('customize_page.layout_description.growi_title')}</h4>
+            <ul>
+              <li>{t('customize_page.layout_description.growi_text1')}</li>
+              <li>{t('customize_page.layout_description.growi_text2')}</li>
+              <li>{t('customize_page.layout_description.growi_text3')}</li>
+            </ul>
+          </CustomizeLayoutOption>
+        </div>
 
-        <CustomizeLayoutOption
-          layoutType="kibela"
-          isSelected={adminCustomizeContainer.state.currentLayout === 'kibela'}
-          onSelected={() => adminCustomizeContainer.switchLayoutType('kibela')}
-          labelHtml="Kibela Like Layout"
-        >
-          <h4>{t('customize_page.layout_description.kibela_title')}</h4>
-          <ul>
-            <li>{t('customize_page.layout_description.kibela_text1')}</li>
-            <li>{t('customize_page.layout_description.kibela_text2')}</li>
-            <li>{t('customize_page.layout_description.kibela_text3')}</li>
-          </ul>
-        </CustomizeLayoutOption>
+        <div className="col-sm-4">
+          <CustomizeLayoutOption
+            layoutType="kibela"
+            isSelected={adminCustomizeContainer.state.currentLayout === 'kibela'}
+            onSelected={() => adminCustomizeContainer.switchLayoutType('kibela')}
+            labelHtml="Kibela Like Layout"
+          >
+            <h4>{t('customize_page.layout_description.kibela_title')}</h4>
+            <ul>
+              <li>{t('customize_page.layout_description.kibela_text1')}</li>
+              <li>{t('customize_page.layout_description.kibela_text2')}</li>
+              <li>{t('customize_page.layout_description.kibela_text3')}</li>
+            </ul>
+          </CustomizeLayoutOption>
+        </div>
 
-        <CustomizeLayoutOption
-          layoutType="classic"
-          isSelected={adminCustomizeContainer.state.currentLayout === 'crowi'}
-          onSelected={() => adminCustomizeContainer.switchLayoutType('crowi')}
-          labelHtml="Crowi Classic Layout"
-        >
-          <h4>{t('customize_page.layout_description.crowi_title')}</h4>
-          <ul>
-            <li>{t('customize_page.layout_description.crowi_text1')}</li>
-            <li>{t('customize_page.layout_description.crowi_text2')}</li>
-            <li>{t('customize_page.layout_description.crowi_text3')}</li>
-          </ul>
-        </CustomizeLayoutOption>
-      </React.Fragment>
+        <div className="col-sm-4">
+          <CustomizeLayoutOption
+            layoutType="classic"
+            isSelected={adminCustomizeContainer.state.currentLayout === 'crowi'}
+            onSelected={() => adminCustomizeContainer.switchLayoutType('crowi')}
+            labelHtml="Crowi Classic Layout"
+          >
+            <h4>{t('customize_page.layout_description.crowi_title')}</h4>
+            <ul>
+              <li>{t('customize_page.layout_description.crowi_text1')}</li>
+              <li>{t('customize_page.layout_description.crowi_text2')}</li>
+              <li>{t('customize_page.layout_description.crowi_text3')}</li>
+            </ul>
+          </CustomizeLayoutOption>
+        </div>
+      </div>
     );
   }
 

+ 3 - 6
src/client/js/components/Admin/Customize/CustomizeLayoutSetting.jsx

@@ -12,6 +12,7 @@ import AppContainer from '../../../services/AppContainer';
 import CustomizeLayoutOptions from './CustomizeLayoutOptions';
 import CustomizeThemeOptions from './CustomizeThemeOptions';
 import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 const logger = loggerFactory('growi:importer');
 
@@ -28,7 +29,7 @@ class CustomizeLayoutSetting extends React.Component {
     const { t, adminCustomizeContainer } = this.props;
 
     try {
-      await adminCustomizeContainer.updateCustomizeLayout();
+      await adminCustomizeContainer.updateCustomizeLayoutAndTheme();
       toastSuccess(t('customize_page.update_layout_success'));
     }
     catch (err) {
@@ -58,11 +59,7 @@ class CustomizeLayoutSetting extends React.Component {
         <h2>{ t('customize_page.Theme') }</h2>
         {this.renderDevAlert()}
         <CustomizeThemeOptions />
-        <div className="form-group my-3">
-          <div className="col-xs-offset-4 col-xs-5">
-            <div className="btn btn-primary" onClick={this.onClickSubmit}>{ t('Update') }</div>
-          </div>
-        </div>
+        <AdminUpdateButtonRow onClick={this.onClickSubmit} />
       </React.Fragment>
     );
   }

+ 29 - 3
src/client/js/services/AdminCustomizeContainer.js

@@ -14,9 +14,9 @@ export default class AdminCustomizeContainer extends Container {
     this.state = {
       currentTheme: appContainer.config.themeType,
       currentLayout: appContainer.config.layoutType,
+      currentBehavior: appContainer.config.behaviorType,
     };
 
-    this.switchLayoutType = this.switchLayoutType.bind(this);
   }
 
   /**
@@ -44,8 +44,34 @@ export default class AdminCustomizeContainer extends Container {
     this.setState({ currentTheme: themeName });
   }
 
-  updateCustomizeLayout() {
-    // TODO GW-479 post api
+  /**
+   * Switch behaviorType
+   */
+  switchBehaviorType(behaviorName) {
+    this.setState({ currentBehavior: behaviorName });
+  }
+
+  /**
+   * Update layout
+   * @memberOf AdminCustomizeContainer
+   * @return {Array} Appearance
+   */
+  async updateCustomizeLayoutAndTheme() {
+    const response = await this.appContainer.apiv3.put('/customize-setting/layoutTheme', {
+      layoutType: this.state.currentLayout,
+      themeType: this.state.currentTheme,
+    });
+    const { customizedParams } = response.data;
+    return customizedParams;
+  }
+
+  /**
+   * Update behavior
+   * @memberOf AdminCustomizeContainer
+   * @return {string} Behavior
+   */
+  async updateCustomizeBehavior() {
+    // TODO GW-497 create apiV3
   }
 
 }

+ 28 - 0
src/migrations/20191102223900-drop-configs-indices.js

@@ -0,0 +1,28 @@
+require('module-alias/register');
+const logger = require('@alias/logger')('growi:migrate:drop-configs-indices');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+async function dropIndexIfExists(collection, indexName) {
+  if (await collection.indexExists(indexName)) {
+    await collection.dropIndex(indexName);
+  }
+}
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const collection = db.collection('configs');
+    await dropIndexIfExists(collection, 'ns_1');
+    await dropIndexIfExists(collection, 'key_1');
+
+    logger.info('Migration has successfully applied');
+  },
+
+  down(db) {
+    // do not rollback
+  },
+};

+ 29 - 0
src/migrations/20191102223901-drop-pages-indices.js

@@ -0,0 +1,29 @@
+require('module-alias/register');
+const logger = require('@alias/logger')('growi:migrate:drop-pages-indices');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+async function dropIndexIfExists(collection, indexName) {
+  if (await collection.indexExists(indexName)) {
+    await collection.dropIndex(indexName);
+  }
+}
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const collection = db.collection('pages');
+    await dropIndexIfExists(collection, 'lastUpdateUser_1');
+    await dropIndexIfExists(collection, 'liker_1');
+    await dropIndexIfExists(collection, 'seenUsers_1');
+
+    logger.info('Migration has successfully applied');
+  },
+
+  down(db) {
+    // do not rollback
+  },
+};

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

@@ -6,6 +6,7 @@ const logger = require('@alias/logger')('growi:models:attachment');
 const path = require('path');
 
 const mongoose = require('mongoose');
+const uniqueValidator = require('mongoose-unique-validator');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
@@ -21,12 +22,13 @@ module.exports = function(crowi) {
     page: { type: ObjectId, ref: 'Page', index: true },
     creator: { type: ObjectId, ref: 'User', index: true },
     filePath: { type: String }, // DEPRECATED: remains for backward compatibility for v3.3.x or below
-    fileName: { type: String, required: true },
+    fileName: { type: String, required: true, unique: true },
     originalName: { type: String },
     fileFormat: { type: String, required: true },
     fileSize: { type: Number, default: 0 },
     createdAt: { type: Date, default: Date.now },
   });
+  attachmentSchema.plugin(uniqueValidator);
 
   attachmentSchema.virtual('filePathProxied').get(function() {
     return `/attachment/${this._id}`;

+ 7 - 4
src/server/models/bookmark.js

@@ -1,10 +1,12 @@
-// disable no-return-await for model functions
 /* eslint-disable no-return-await */
 
+const debug = require('debug')('growi:models:bookmark');
+const mongoose = require('mongoose');
+const uniqueValidator = require('mongoose-unique-validator');
+
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
 module.exports = function(crowi) {
-  const debug = require('debug')('growi:models:bookmark');
-  const mongoose = require('mongoose');
-  const ObjectId = mongoose.Schema.Types.ObjectId;
   const bookmarkEvent = crowi.event('bookmark');
 
   let bookmarkSchema = null;
@@ -16,6 +18,7 @@ module.exports = function(crowi) {
     createdAt: { type: Date, default: Date.now },
   });
   bookmarkSchema.index({ page: 1, user: 1 }, { unique: true });
+  bookmarkSchema.plugin(uniqueValidator);
 
   bookmarkSchema.statics.countByPageId = async function(pageId) {
     return await this.count({ page: pageId });

+ 8 - 7
src/server/models/config.js

@@ -1,21 +1,22 @@
-// disable no-return-await for model functions
-/* eslint-disable no-return-await */
-
-/* eslint-disable no-use-before-define */
+const mongoose = require('mongoose');
+const uniqueValidator = require('mongoose-unique-validator');
 
 module.exports = function(crowi) {
-  const mongoose = require('mongoose');
 
   const configSchema = new mongoose.Schema({
-    ns: { type: String, required: true, index: true },
-    key: { type: String, required: true, index: true },
+    ns: { type: String, required: true },
+    key: { type: String, required: true },
     value: { type: String, required: true },
   });
+  // define unique compound index
+  configSchema.index({ ns: 1, key: 1 }, { unique: true });
+  configSchema.plugin(uniqueValidator);
 
   /**
    * default values when GROWI is cleanly installed
    */
   function getConfigsForInstalling() {
+    // eslint-disable-next-line no-use-before-define
     const config = getDefaultCrowiConfigs();
 
     // overwrite

+ 5 - 0
src/server/models/page-tag-relation.js

@@ -5,6 +5,7 @@ const flatMap = require('array.prototype.flatmap');
 
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
+const uniqueValidator = require('mongoose-unique-validator');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
@@ -17,6 +18,7 @@ const schema = new mongoose.Schema({
     type: ObjectId,
     ref: 'Page',
     required: true,
+    index: true,
   },
   relatedTag: {
     type: ObjectId,
@@ -24,7 +26,10 @@ const schema = new mongoose.Schema({
     required: true,
   },
 });
+// define unique compound index
+schema.index({ page: 1, user: 1 }, { unique: true });
 schema.plugin(mongoosePaginate);
+schema.plugin(uniqueValidator);
 
 /**
  * PageTagRelation Class

+ 3 - 3
src/server/models/page.js

@@ -39,9 +39,9 @@ const pageSchema = new mongoose.Schema({
   grantedUsers: [{ type: ObjectId, ref: 'User' }],
   grantedGroup: { type: ObjectId, ref: 'UserGroup', index: true },
   creator: { type: ObjectId, ref: 'User', index: true },
-  lastUpdateUser: { type: ObjectId, ref: 'User', index: true },
-  liker: [{ type: ObjectId, ref: 'User', index: true }],
-  seenUsers: [{ type: ObjectId, ref: 'User', index: true }],
+  lastUpdateUser: { type: ObjectId, ref: 'User' },
+  liker: [{ type: ObjectId, ref: 'User' }],
+  seenUsers: [{ type: ObjectId, ref: 'User' }],
   commentCount: { type: Number, default: 0 },
   extended: {
     type: String,

+ 2 - 0
src/server/models/tag.js

@@ -3,6 +3,7 @@
 
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
+const uniqueValidator = require('mongoose-unique-validator');
 
 /*
  * define schema
@@ -15,6 +16,7 @@ const schema = new mongoose.Schema({
   },
 });
 schema.plugin(mongoosePaginate);
+schema.plugin(uniqueValidator);
 
 /**
  * Tag Class

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

@@ -1,6 +1,7 @@
 const debug = require('debug')('growi:models:userGroupRelation');
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
+const uniqueValidator = require('mongoose-unique-validator');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
@@ -14,6 +15,8 @@ const schema = new mongoose.Schema({
   createdAt: { type: Date, default: Date.now, required: true },
 });
 schema.plugin(mongoosePaginate);
+schema.plugin(uniqueValidator);
+
 
 /**
  * UserGroupRelation Class

+ 3 - 3
src/server/models/user.js

@@ -44,14 +44,14 @@ module.exports = function(crowi) {
     name: { type: String },
     username: { type: String, required: true, unique: true },
     email: { type: String, unique: true, sparse: true },
-    // === The official settings
+    // === Crowi settings
     // username: { type: String, index: true },
     // email: { type: String, required: true, index: true },
     // === crowi-plus (>= 2.1.0, <2.3.0) settings
     // email: { type: String, required: true, unique: true },
-    introduction: { type: String },
+    introduction: String,
     password: String,
-    apiToken: String,
+    apiToken: { type: String, index: true },
     lang: {
       type: String,
       // eslint-disable-next-line no-eval

+ 79 - 0
src/server/routes/apiv3/customize-setting.js

@@ -0,0 +1,79 @@
+/* eslint-disable no-unused-vars */
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:user-group');
+
+const express = require('express');
+
+const router = express.Router();
+
+const { body } = require('express-validator/check');
+const ErrorV3 = require('../../models/vo/error-apiv3');
+
+const validator = {};
+
+/**
+ * @swagger
+ *  tags:
+ *    name: CustomizeSetting
+ */
+
+module.exports = (crowi) => {
+  const loginRequiredStrictly = require('../../middleware/login-required')(crowi);
+  const adminRequired = require('../../middleware/admin-required')(crowi);
+  const csrf = require('../../middleware/csrf')(crowi);
+
+  const { ApiV3FormValidator } = crowi.middlewares;
+
+  validator.layoutTheme = [
+    body('layoutType').isString(),
+    body('themeType').isString(),
+  ];
+
+  /**
+   * @swagger
+   *
+   *    /customize-setting/layoutTheme:
+   *      put:
+   *        tags: [CustomizeSetting]
+   *        description: Update layout and theme
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schama:
+   *                type: object
+   *                properties:
+   *                  layoutType:
+   *                    description: type of layout
+   *                    type: string
+   *                  themeType:
+   *                    description: type of theme
+   *                    type: string
+   *      responses:
+   *          200:
+   *            description: Succeeded to update layout and theme
+   */
+  router.put('/layoutTheme', loginRequiredStrictly, adminRequired, csrf, validator.layoutTheme, ApiV3FormValidator, async(req, res) => {
+    const requestParams = {
+      'customize:layout': req.body.layoutType,
+      'customize:theme': req.body.themeType,
+    };
+
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
+      const customizedParams = {
+        layoutType: await crowi.configManager.getConfig('crowi', 'customize:layout'),
+        themeType: await crowi.configManager.getConfig('crowi', 'customize:theme'),
+      };
+      return res.apiv3({ customizedParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating layout and theme';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-layoutTheme-failed'));
+    }
+  });
+
+  return router;
+};

+ 2 - 0
src/server/routes/apiv3/index.js

@@ -15,6 +15,8 @@ module.exports = (crowi) => {
 
   router.use('/markdown-setting', require('./markdown-setting')(crowi));
 
+  router.use('/customize-setting', require('./customize-setting')(crowi));
+
   router.use('/users', require('./users')(crowi));
 
   router.use('/user-groups', require('./user-group')(crowi));

+ 7 - 4
src/server/routes/comment.js

@@ -121,10 +121,13 @@ module.exports = function(crowi, app) {
     }
 
     // update page
-    const page = await Page.findOneAndUpdate({ _id: pageId }, {
-      lastUpdateUser: req.user,
-      updatedAt: new Date(),
-    });
+    const page = await Page.findOneAndUpdate(
+      { _id: pageId },
+      {
+        lastUpdateUser: req.user,
+        updatedAt: new Date(),
+      },
+    );
 
     res.json(ApiResponse.success({ comment: createdComment }));
 

+ 7 - 10
src/test/crowi/crowi.test.js

@@ -1,26 +1,23 @@
-const helpers = require('@commons/util/helpers');
+const { getInstance } = require('../setup-crowi');
 
 describe('Test for Crowi application context', () => {
 
-  const Crowi = require('@server/crowi');
-
   describe('construction', () => {
-    test('initialize crowi context', () => {
-      const crowi = new Crowi(helpers.root());
-      expect(crowi).toBeInstanceOf(Crowi);
+    test('initialize crowi context', async() => {
+      const crowi = await getInstance();
       expect(crowi.version).toBe(require('@root/package.json').version);
       expect(typeof crowi.env).toBe('object');
     });
 
-    test('config getter, setter', () => {
-      const crowi = new Crowi(helpers.root());
+    test('config getter, setter', async() => {
+      const crowi = await getInstance();
       expect(crowi.getConfig()).toEqual({});
       crowi.setConfig({ test: 1 });
       expect(crowi.getConfig()).toEqual({ test: 1 });
     });
 
-    test('model getter, setter', () => {
-      const crowi = new Crowi(helpers.root());
+    test('model getter, setter', async() => {
+      const crowi = await getInstance();
       // set
       crowi.model('hoge', { fuga: 1 });
       expect(crowi.model('hoge')).toEqual({ fuga: 1 });

+ 12 - 0
src/test/global-setup.js

@@ -1,3 +1,5 @@
+require('module-alias/register');
+
 // check env
 if (process.env.NODE_ENV !== 'test') {
   throw new Error('\'process.env.NODE_ENV\' must be \'test\'');
@@ -7,8 +9,18 @@ const mongoose = require('mongoose');
 
 const { getMongoUri } = require('../lib/util/mongoose-utils');
 
+const { getInstance } = require('./setup-crowi');
+
 module.exports = async() => {
   await mongoose.connect(getMongoUri(), { useNewUrlParser: true });
+
+  // drop database
   await mongoose.connection.dropDatabase();
+
+  // init DB
+  const crowi = await getInstance();
+  const appService = crowi.appService;
+  await appService.initDB();
+
   await mongoose.disconnect();
 };

+ 0 - 4
src/test/setup-crowi.js

@@ -8,10 +8,6 @@ async function createInstance() {
   const crowi = new Crowi(helpers.root());
   await crowi.initForTest();
 
-  // init DB
-  const appService = crowi.appService;
-  await appService.initDB();
-
   return crowi;
 }
 

+ 10 - 5
src/test/util/slack.test.js

@@ -1,10 +1,15 @@
-const helpers = require('@commons/util/helpers');
-
-const Crowi = require('@server/crowi');
+const { getInstance } = require('../setup-crowi');
 
 describe('Slack Util', () => {
-  const crowi = new Crowi(helpers.root());
-  const slack = require(`${crowi.libDir}/util/slack`)(crowi);
+
+  let crowi;
+  let slack;
+
+  beforeEach(async(done) => {
+    crowi = await getInstance();
+    slack = require(`${crowi.libDir}/util/slack`)(crowi);
+    done();
+  });
 
   test('post comment method exists', () => {
     expect(slack.postComment).toBeInstanceOf(Function);