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

Merge branch 'imprv/reactify-admin-user-groups-detail-2' into imprv/reactify-admin-user-groups-detail-3

mizozobu 6 лет назад
Родитель
Сommit
33bc1b7bb8

+ 3 - 1
src/client/js/app.jsx

@@ -47,6 +47,7 @@ import PageContainer from './services/PageContainer';
 import CommentContainer from './services/CommentContainer';
 import EditorContainer from './services/EditorContainer';
 import TagContainer from './services/TagContainer';
+import UserGroupDetailContainer from './services/UserGroupDetailContainer';
 import WebsocketContainer from './services/WebsocketContainer';
 
 const logger = loggerFactory('growi:app');
@@ -151,8 +152,9 @@ Object.keys(componentMappings).forEach((key) => {
 // render for admin
 const adminUserGroupDetailElem = document.getElementById('admin-user-group-detail');
 if (adminUserGroupDetailElem != null) {
+  const userGroupDetailContainer = new UserGroupDetailContainer(appContainer);
   ReactDOM.render(
-    <Provider inject={[]}>
+    <Provider inject={[userGroupDetailContainer]}>
       <I18nextProvider i18n={i18n}>
         <UserGroupDetailPage />
       </I18nextProvider>

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

@@ -73,7 +73,7 @@ class UserGroupTable extends React.Component {
                       })}
                     </ul>
                   </td>
-                  <td>{dateFnsFormat(new Date(group.createdAt), 'yyyy-MM-dd')}</td>
+                  <td>{dateFnsFormat(new Date(group.createdAt), 'YYYY-MM-DD')}</td>
                   {this.props.isAclEnabled
                     ? (
                       <td>

+ 29 - 30
src/client/js/components/Admin/UserGroupDetail/UserGroupDetailPage.jsx

@@ -1,29 +1,18 @@
 import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
 
 import UserGroupEditForm from './UserGroupEditForm';
 import UserGroupUserTable from './UserGroupUserTable';
+import UserGroupUserModal from './UserGroupUserModal';
 import UserGroupPageList from './UserGroupPageList';
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
 
 class UserGroupDetailPage extends React.Component {
 
-  constructor(props) {
-    super(props);
-
-    const elem = document.getElementById('admin-user-group-detail');
-    const userGroup = JSON.parse(elem.getAttribute('data-user-group'));
-    const userGroupRelations = JSON.parse(elem.getAttribute('data-user-group-relations'));
-    const notRelatedUsers = JSON.parse(elem.getAttribute('data-not-related-users'));
-    const relatedPages = JSON.parse(elem.getAttribute('data-related-pages'));
-
-    this.state = {
-      userGroup,
-      userGroupRelations,
-      notRelatedUsers,
-      relatedPages,
-    };
-  }
-
   render() {
+    const { t } = this.props;
 
     return (
       <div>
@@ -31,22 +20,32 @@ class UserGroupDetailPage extends React.Component {
           <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
         グループ一覧に戻る
         </a>
-        <UserGroupEditForm
-          userGroup={this.state.userGroup}
-        />
-        <UserGroupUserTable
-          userGroupRelations={this.state.userGroupRelations}
-          notRelatedUsers={this.state.notRelatedUsers}
-          userGroup={this.state.userGroup}
-        />
-        <UserGroupPageList
-          userGroup={this.state.userGroup}
-          relatedPages={this.state.relatedPages}
-        />
+        <div className="m-t-20 form-box">
+          <UserGroupEditForm />
+        </div>
+        <legend className="m-t-20">{ t('User List') }</legend>
+        <UserGroupUserTable />
+        <UserGroupUserModal />
+        <legend className="m-t-20">{ t('Page') }</legend>
+        <div className="page-list">
+          <UserGroupPageList />
+        </div>
       </div>
     );
   }
 
 }
 
-export default UserGroupDetailPage;
+UserGroupDetailPage.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupDetailPageWrapper = (props) => {
+  return createSubscribedElement(UserGroupDetailPage, props, [AppContainer]);
+};
+
+export default withTranslation()(UserGroupDetailPageWrapper);

+ 34 - 32
src/client/js/components/Admin/UserGroupDetail/UserGroupEditForm.jsx

@@ -5,6 +5,7 @@ import dateFnsFormat from 'date-fns/format';
 
 import { createSubscribedElement } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
+import UserGroupDetailContainer from '../../../services/UserGroupDetailContainer';
 import { toastSuccess, toastError } from '../../../util/apiNotification';
 
 class UserGroupEditForm extends React.Component {
@@ -13,18 +14,18 @@ class UserGroupEditForm extends React.Component {
     super(props);
 
     this.state = {
-      name: props.userGroup.name,
-      nameCache: props.userGroup.name, // cache for name. update every submit
+      name: props.userGroupDetailContainer.state.userGroup.name,
+      nameCache: props.userGroupDetailContainer.state.userGroup.name, // cache for name. update every submit
     };
 
     this.xss = window.xss;
 
-    this.handleChange = this.handleChange.bind(this);
+    this.changeUserGroupName = this.changeUserGroupName.bind(this);
     this.handleSubmit = this.handleSubmit.bind(this);
     this.validateForm = this.validateForm.bind(this);
   }
 
-  handleChange(event) {
+  changeUserGroupName(event) {
     this.setState({
       name: event.target.value,
     });
@@ -34,14 +35,12 @@ class UserGroupEditForm extends React.Component {
     e.preventDefault();
 
     try {
-      const res = await this.props.appContainer.apiv3.put(`/user-groups/${this.props.userGroup._id}`, {
+      const res = await this.props.userGroupDetailContainer.updateUserGroup({
         name: this.state.name,
       });
 
       toastSuccess(`Updated the group name to "${this.xss.process(res.data.userGroup.name)}"`);
-      this.setState({
-        nameCache: this.state.name,
-      });
+      this.setState({ nameCache: this.state.name });
     }
     catch (err) {
       toastError(new Error('Unable to update the group name'));
@@ -56,33 +55,36 @@ class UserGroupEditForm extends React.Component {
   }
 
   render() {
-    const { t } = this.props;
+    const { t, userGroupDetailContainer } = this.props;
 
     return (
-      <div className="m-t-20 form-box">
-        <form className="form-horizontal" onSubmit={this.handleSubmit}>
-          <fieldset>
-            <legend>基本情報</legend>
-            <div className="form-group">
-              <label htmlFor="name" className="col-sm-2 control-label">{ t('Name') }</label>
-              <div className="col-sm-4">
-                <input className="form-control" type="text" name="name" value={this.state.name} onChange={this.handleChange} />
-              </div>
+      <form className="form-horizontal" onSubmit={this.handleSubmit}>
+        <fieldset>
+          <legend>基本情報</legend>
+          <div className="form-group">
+            <label htmlFor="name" className="col-sm-2 control-label">{ t('Name') }</label>
+            <div className="col-sm-4">
+              <input className="form-control" type="text" name="name" value={this.state.name} onChange={this.changeUserGroupName} />
             </div>
-            <div className="form-group">
-              <label className="col-sm-2 control-label">{ t('Created') }</label>
-              <div className="col-sm-4">
-                <input className="form-control" type="text" disabled value={dateFnsFormat(new Date(this.props.userGroup.createdAt), 'yyyy-MM-dd')} />
-              </div>
+          </div>
+          <div className="form-group">
+            <label className="col-sm-2 control-label">{ t('Created') }</label>
+            <div className="col-sm-4">
+              <input
+                type="text"
+                className="form-control"
+                value={dateFnsFormat(new Date(userGroupDetailContainer.state.userGroup.createdAt), 'YYYY-MM-DD')}
+                disabled
+              />
             </div>
-            <div className="form-group">
-              <div className="col-sm-offset-2 col-sm-10">
-                <button type="submit" className="btn btn-primary" disabled={!this.validateForm()}>{ t('Update') }</button>
-              </div>
+          </div>
+          <div className="form-group">
+            <div className="col-sm-offset-2 col-sm-10">
+              <button type="submit" className="btn btn-primary" disabled={!this.validateForm()}>{ t('Update') }</button>
             </div>
-          </fieldset>
-        </form>
-      </div>
+          </div>
+        </fieldset>
+      </form>
     );
   }
 
@@ -91,14 +93,14 @@ class UserGroupEditForm extends React.Component {
 UserGroupEditForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  userGroup: PropTypes.object.isRequired,
+  userGroupDetailContainer: PropTypes.instanceOf(UserGroupDetailContainer).isRequired,
 };
 
 /**
  * Wrapper component for using unstated
  */
 const UserGroupEditFormWrapper = (props) => {
-  return createSubscribedElement(UserGroupEditForm, props, [AppContainer]);
+  return createSubscribedElement(UserGroupEditForm, props, [AppContainer, UserGroupDetailContainer]);
 };
 
 export default withTranslation()(UserGroupEditFormWrapper);

+ 9 - 72
src/client/js/components/Admin/UserGroupDetail/UserGroupPageList.jsx

@@ -2,84 +2,22 @@ import React, { Fragment } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-import UserPicture from '../../User/UserPicture';
-import PageListMeta from '../../PageList/PageListMeta';
-import PaginationWrapper from '../../PaginationWrapper';
+import Page from '../../PageList/Page';
 import { createSubscribedElement } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
-import { toastError } from '../../../util/apiNotification';
+import UserGroupDetailContainer from '../../../services/UserGroupDetailContainer';
 
 class UserGroupPageList extends React.Component {
 
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      currentPages: [],
-      activePage: 1,
-      total: props.relatedPages.length,
-      pagingLimit: Infinity,
-    };
-
-    this.handlePageChange = this.handlePageChange.bind(this);
-    this.renderPageList = this.renderPageList.bind(this);
-  }
-
-  async componentDidMount() {
-    await this.handlePageChange(this.state.activePage);
-  }
-
-  async handlePageChange(pageNum) {
-    const limit = this.state.pagingLimit;
-    const offset = (pageNum - 1) * limit;
-
-    try {
-      const res = await this.props.appContainer.apiv3.get(`/user-groups/${this.props.userGroup._id}/pages`, { limit, offset });
-
-      this.setState({
-        currentPages: res.data.pages,
-        activePage: pageNum,
-      });
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  renderPageList(page) {
-    return (
-      <li key={page._id}>
-        <UserPicture user={page.lastUpdateUser} className="picture img-circle" />
-        <a
-          href={page.path}
-          className="page-list-link"
-          data-path={page.path}
-        >
-          {decodeURIComponent(page.path)}
-        </a>
-        <PageListMeta page={page} />
-      </li>
-    );
-  }
-
   render() {
-    const { t } = this.props;
+    const { t, userGroupDetailContainer } = this.props;
 
     return (
       <Fragment>
-        <legend className="m-t-20">{ t('Page') }</legend>
-        <div className="page-list">
-          <ul className="page-list-ul page-list-ul-flat">
-            {this.state.currentPages.map((page) => { return this.renderPageList(page) })}
-          </ul>
-          {this.props.relatedPages.length === 0 ? <p>{ t('user_group_management.no_pages') }</p> : null}
-        </div>
-        <PaginationWrapper
-          activePage={this.state.activePage}
-          changePage={this.handlePageChange}
-          totalItemsCount={this.state.total}
-          pagingLimit={this.state.pagingLimit}
-        />
+        <ul className="page-list-ul page-list-ul-flat">
+          {userGroupDetailContainer.state.relatedPages.map((page) => { return <Page key={page._id} page={page} /> })}
+        </ul>
+        {userGroupDetailContainer.state.relatedPages.length === 0 ? <p>{ t('user_group_management.no_pages') }</p> : null}
       </Fragment>
     );
   }
@@ -89,15 +27,14 @@ class UserGroupPageList extends React.Component {
 UserGroupPageList.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  userGroup: PropTypes.object.isRequired,
-  relatedPages: PropTypes.arrayOf(PropTypes.object).isRequired,
+  userGroupDetailContainer: PropTypes.instanceOf(UserGroupDetailContainer).isRequired,
 };
 
 /**
  * Wrapper component for using unstated
  */
 const UserGroupPageListWrapper = (props) => {
-  return createSubscribedElement(UserGroupPageList, props, [AppContainer]);
+  return createSubscribedElement(UserGroupPageList, props, [AppContainer, UserGroupDetailContainer]);
 };
 
 export default withTranslation()(UserGroupPageListWrapper);

+ 67 - 0
src/client/js/components/Admin/UserGroupDetail/UserGroupUserFormByClick.jsx

@@ -0,0 +1,67 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import UserGroupDetailContainer from '../../../services/UserGroupDetailContainer';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class UserGroupUserFormByClick extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.xss = window.xss;
+
+    this.addUserByClick = this.addUserByClick.bind(this);
+  }
+
+  async addUserByClick(username) {
+    try {
+      await this.props.userGroupDetailContainer.addUserByUsername(username);
+      toastSuccess(`Added "${this.xss.process(username)}" to "${this.xss.process(this.props.userGroupDetailContainer.state.userGroup.name)}"`);
+    }
+    catch (err) {
+      toastError(new Error(`Unable to add "${this.xss.process(username)}" to "${this.xss.process(this.props.userGroupDetailContainer.userGroup.name)}"`));
+    }
+  }
+
+  render() {
+    // eslint-disable-next-line no-unused-vars
+    const { t, userGroupDetailContainer } = this.props;
+
+    return (
+      <Fragment>
+        <ul className="list-inline">
+          {userGroupDetailContainer.state.unrelatedUsers.map((user) => {
+              return (
+                <li key={user._id}>
+                  <button type="submit" className="btn btn-xs btn-primary" onClick={() => { return this.addUserByClick(user.username) }}>
+                    {user.username}
+                  </button>
+                </li>
+              );
+            })}
+        </ul>
+
+        {userGroupDetailContainer.state.unrelatedUsers.length === 0 ? 'No users available.' : null}
+      </Fragment>
+    );
+  }
+
+}
+
+UserGroupUserFormByClick.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  userGroupDetailContainer: PropTypes.instanceOf(UserGroupDetailContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupUserFormByClickWrapper = (props) => {
+  return createSubscribedElement(UserGroupUserFormByClick, props, [AppContainer, UserGroupDetailContainer]);
+};
+
+export default withTranslation()(UserGroupUserFormByClickWrapper);

+ 83 - 0
src/client/js/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx

@@ -0,0 +1,83 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import UserGroupDetailContainer from '../../../services/UserGroupDetailContainer';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class UserGroupUserFormByInput extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      username: '',
+    };
+
+    this.xss = window.xss;
+
+    this.changeUsername = this.changeUsername.bind(this);
+    this.addUserBySubmit = this.addUserBySubmit.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  changeUsername(e) {
+    this.setState({ username: e.target.value });
+  }
+
+  async addUserBySubmit(e) {
+    e.preventDefault();
+    const { username } = this.state;
+
+    try {
+      await this.props.userGroupDetailContainer.addUserByUsername(username);
+      toastSuccess(`Added "${this.xss.process(username)}" to "${this.xss.process(this.props.userGroupDetailContainer.state.userGroup.name)}"`);
+      this.setState({ username: '' });
+    }
+    catch (err) {
+      toastError(new Error(`Unable to add "${this.xss.process(username)}" to "${this.xss.process(this.props.userGroupDetailContainer.state.userGroup.name)}"`));
+    }
+  }
+
+  validateForm() {
+    return this.state.username !== '';
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <form className="form-inline" onSubmit={this.addUserBySubmit}>
+        <div className="form-group">
+          <input
+            type="text"
+            name="username"
+            className="form-control input-sm"
+            placeholder={t('User Name')}
+            value={this.state.username}
+            onChange={this.changeUsername}
+          />
+        </div>
+        <button type="submit" className="btn btn-sm btn-success" disabled={!this.validateForm()}>{ t('Add') }</button>
+      </form>
+    );
+  }
+
+}
+
+UserGroupUserFormByInput.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  userGroupDetailContainer: PropTypes.instanceOf(UserGroupDetailContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupUserFormByInputWrapper = (props) => {
+  return createSubscribedElement(UserGroupUserFormByInput, props, [AppContainer, UserGroupDetailContainer]);
+};
+
+export default withTranslation()(UserGroupUserFormByInputWrapper);

+ 10 - 93
src/client/js/components/Admin/UserGroupDetail/UserGroupUserModal.jsx

@@ -1,70 +1,21 @@
-import React, { Fragment } from 'react';
+import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import Modal from 'react-bootstrap/es/Modal';
 
+import UserGroupUserFormByInput from './UserGroupUserFormByInput';
+import UserGroupUserFormByClick from './UserGroupUserFormByClick';
 import { createSubscribedElement } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
-import { toastSuccess, toastError } from '../../../util/apiNotification';
+import UserGroupDetailContainer from '../../../services/UserGroupDetailContainer';
 
 class UserGroupUserModal extends React.Component {
 
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      username: '',
-    };
-
-    this.xss = window.xss;
-
-    this.handleChange = this.handleChange.bind(this);
-    this.addUserBySubmit = this.addUserBySubmit.bind(this);
-    this.addUserByClick = this.addUserByClick.bind(this);
-    this.addUser = this.addUser.bind(this);
-  }
-
-  handleChange(e) {
-    this.setState({ username: e.target.value });
-  }
-
-  async addUserBySubmit(e) {
-    e.preventDefault();
-
-    const { user, userGroup, userGroupRelation } = await this.addUser(this.state.username);
-
-    this.setState({ username: '' });
-    this.handlePostAdd(user, userGroup, userGroupRelation);
-  }
-
-  async addUserByClick(username) {
-    const { user, userGroup, userGroupRelation } = await this.addUser(username);
-
-    this.handlePostAdd(user, userGroup, userGroupRelation);
-  }
-
-  async addUser(username) {
-    try {
-      const res = await this.props.appContainer.apiv3.post(`/user-groups/${this.props.userGroup._id}/users/${username}`);
-      toastSuccess(`Added "${username}" to "${this.xss.process(this.props.userGroup.name)}"`);
-
-      return res.data;
-    }
-    catch (err) {
-      toastError(new Error(`Unable to add "${this.xss.process(username)}" to "${this.xss.process(this.props.userGroup.name)}"`));
-    }
-  }
-
-  handlePostAdd(user, userGroup, userGroupRelation) {
-    this.props.onAdd(user, userGroup, userGroupRelation);
-    this.props.onClose();
-  }
-
   render() {
-    const { t } = this.props;
+    const { t, userGroupDetailContainer } = this.props;
 
     return (
-      <Modal show={this.props.show} onHide={this.props.onClose}>
+      <Modal show={userGroupDetailContainer.state.isUserGroupUserModalOpen} onHide={userGroupDetailContainer.closeUserGroupUserModal}>
         <Modal.Header closeButton>
           <Modal.Title>{ t('user_group_management.add_user') }</Modal.Title>
         </Modal.Header>
@@ -72,42 +23,12 @@ class UserGroupUserModal extends React.Component {
           <p>
             <strong>{ t('Method') }1.</strong> { t('user_group_management.how_to_add1') }
           </p>
-          <form className="form-inline" onSubmit={this.addUserBySubmit}>
-            <div className="form-group">
-              <input
-                type="text"
-                name="username"
-                className="form-control input-sm"
-                placeholder={t('User Name')}
-                value={this.state.username}
-                onChange={this.handleChange}
-              />
-            </div>
-            <button type="submit" className="btn btn-sm btn-success">{ t('Add') }</button>
-          </form>
-
+          <UserGroupUserFormByInput />
           <hr />
           <p>
             <strong>{ t('Method') }2.</strong> { t('user_group_management.how_to_add2') }
           </p>
-
-          <ul className="list-inline">
-            {this.props.notRelatedUsers.map((user) => {
-              return (
-                <li key={user._id}>
-                  <button type="submit" className="btn btn-xs btn-primary" onClick={() => { return this.addUserByClick(user.username) }}>
-                    {user.username}
-                  </button>
-                </li>
-              );
-            })}
-          </ul>
-
-          {this.props.notRelatedUsers.length === 0 ? (
-            <Fragment>
-              No users available.
-            </Fragment>
-          ) : null}
+          <UserGroupUserFormByClick />
         </Modal.Body>
       </Modal>
     );
@@ -118,18 +39,14 @@ class UserGroupUserModal extends React.Component {
 UserGroupUserModal.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  show: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
-  onAdd: PropTypes.func.isRequired,
-  userGroup: PropTypes.object.isRequired,
-  notRelatedUsers: PropTypes.arrayOf(PropTypes.object).isRequired,
+  userGroupDetailContainer: PropTypes.instanceOf(UserGroupDetailContainer).isRequired,
 };
 
 /**
  * Wrapper component for using unstated
  */
 const UserGroupUserModalWrapper = (props) => {
-  return createSubscribedElement(UserGroupUserModal, props, [AppContainer]);
+  return createSubscribedElement(UserGroupUserModal, props, [AppContainer, UserGroupDetailContainer]);
 };
 
 export default withTranslation()(UserGroupUserModalWrapper);

+ 41 - 89
src/client/js/components/Admin/UserGroupDetail/UserGroupUserTable.jsx

@@ -1,12 +1,12 @@
-import React, { Fragment } from 'react';
+import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import dateFnsFormat from 'date-fns/format';
 
-import UserGroupUserModal from './UserGroupUserModal';
 import UserPicture from '../../User/UserPicture';
 import { createSubscribedElement } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
+import UserGroupDetailContainer from '../../../services/UserGroupDetailContainer';
 import { toastSuccess, toastError } from '../../../util/apiNotification';
 
 class UserGroupUserTable extends React.Component {
@@ -14,77 +14,41 @@ class UserGroupUserTable extends React.Component {
   constructor(props) {
     super(props);
 
-    this.state = {
-      userGroupRelations: props.userGroupRelations,
-      notRelatedUsers: props.notRelatedUsers,
-      isUserGroupUserModalOpen: false,
-    };
-
     this.xss = window.xss;
 
     this.removeUser = this.removeUser.bind(this);
-    this.openUserGroupUserModal = this.openUserGroupUserModal.bind(this);
-    this.closeUserGroupUserModal = this.closeUserGroupUserModal.bind(this);
-    this.addUser = this.addUser.bind(this);
   }
 
   async removeUser(username) {
     try {
-      const res = await this.props.appContainer.apiv3.delete(`/user-groups/${this.props.userGroup._id}/users/${username}`);
-
-      this.setState((prevState) => {
-        return {
-          userGroupRelations: prevState.userGroupRelations.filter((u) => { return u._id !== res.data.userGroupRelation._id }),
-          notRelatedUsers: [...prevState.notRelatedUsers, res.data.user],
-        };
-      });
-
-      toastSuccess(`Removed "${username}" from "${this.xss.process(this.props.userGroup.name)}"`);
+      await this.props.userGroupDetailContainer.removeUserByUsername(username);
+      toastSuccess(`Removed "${this.xss.process(username)}" from "${this.xss.process(this.props.userGroupDetailContainer.state.userGroup.name)}"`);
     }
     catch (err) {
-      toastError(new Error(`Unable to remove "${this.xss.process(username)}" from "${this.xss.process(this.props.userGroup.name)}"`));
+      // eslint-disable-next-line max-len
+      toastError(new Error(`Unable to remove "${this.xss.process(username)}" from "${this.xss.process(this.props.userGroupDetailContainer.state.userGroup.name)}"`));
     }
   }
 
-  openUserGroupUserModal() {
-    this.setState({ isUserGroupUserModalOpen: true });
-  }
-
-  closeUserGroupUserModal() {
-    this.setState({ isUserGroupUserModalOpen: false });
-  }
-
-  addUser(user, userGroup, userGroupRelation) {
-    this.setState((prevState) => {
-      return {
-        userGroupRelations: [...prevState.userGroupRelations, userGroupRelation],
-        notRelatedUsers: prevState.notRelatedUsers.filter((u) => { return u._id !== user._id }),
-      };
-    });
-  }
-
   render() {
-    const { t } = this.props;
+    const { t, userGroupDetailContainer } = this.props;
 
     return (
-      <Fragment>
-        <legend className="m-t-20">{ t('User List') }</legend>
-
-        <table className="table table-bordered table-user-list">
-          <thead>
-            <tr>
-              <th width="100px">#</th>
-              <th>
-                { t('User') }
-              </th>
-              <th>{ t('Name') }</th>
-              <th width="100px">{ t('Created') }</th>
-              <th width="160px">{ t('Last Login')}</th>
-              <th width="70px"></th>
-            </tr>
-          </thead>
-          <tbody>
-            {this.state.userGroupRelations.map((sRelation) => {
+      <table className="table table-bordered table-user-list">
+        <thead>
+          <tr>
+            <th width="100px">#</th>
+            <th>
+              { t('User') }
+            </th>
+            <th>{ t('Name') }</th>
+            <th width="100px">{ t('Created') }</th>
+            <th width="160px">{ t('Last Login')}</th>
+            <th width="70px"></th>
+          </tr>
+        </thead>
+        <tbody>
+          {userGroupDetailContainer.state.userGroupRelations.map((sRelation) => {
               const { relatedUser } = sRelation;
 
               return (
@@ -96,8 +60,8 @@ class UserGroupUserTable extends React.Component {
                     <strong>{relatedUser.username}</strong>
                   </td>
                   <td>{relatedUser.name}</td>
-                  <td>{relatedUser.createdAt ? dateFnsFormat(new Date(relatedUser.createdAt), 'yyyy-MM-dd') : ''}</td>
-                  <td>{relatedUser.lastLoginAt ? dateFnsFormat(new Date(relatedUser.lastLoginAt), 'yyyy-MM-dd HH:mm:ss') : ''}</td>
+                  <td>{relatedUser.createdAt ? dateFnsFormat(new Date(relatedUser.createdAt), 'YYYY-MM-DD') : ''}</td>
+                  <td>{relatedUser.lastLoginAt ? dateFnsFormat(new Date(relatedUser.lastLoginAt), 'YYYY-MM-DD HH:mm:ss') : ''}</td>
                   <td>
                     <div className="btn-group admin-user-menu">
                       <button type="button" className="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
@@ -116,31 +80,21 @@ class UserGroupUserTable extends React.Component {
               );
             })}
 
-            <tr>
-              <td></td>
-              <td className="text-center">
-                <button className="btn btn-default" type="button" onClick={this.openUserGroupUserModal}>
-                  <i className="ti-plus"></i>
-                </button>
-              </td>
-              <td></td>
-              <td></td>
-              <td></td>
-              <td></td>
-            </tr>
-
-          </tbody>
-        </table>
-
-        <UserGroupUserModal
-          show={this.state.isUserGroupUserModalOpen}
-          onClose={this.closeUserGroupUserModal}
-          onAdd={this.addUser}
-          notRelatedUsers={this.state.notRelatedUsers}
-          userGroup={this.props.userGroup}
-        />
-
-      </Fragment>
+          <tr>
+            <td></td>
+            <td className="text-center">
+              <button className="btn btn-default" type="button" onClick={userGroupDetailContainer.openUserGroupUserModal}>
+                <i className="ti-plus"></i>
+              </button>
+            </td>
+            <td></td>
+            <td></td>
+            <td></td>
+            <td></td>
+          </tr>
+
+        </tbody>
+      </table>
     );
   }
 
@@ -149,16 +103,14 @@ class UserGroupUserTable extends React.Component {
 UserGroupUserTable.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  userGroupRelations: PropTypes.arrayOf(PropTypes.object).isRequired,
-  notRelatedUsers: PropTypes.arrayOf(PropTypes.object).isRequired,
-  userGroup: PropTypes.object.isRequired,
+  userGroupDetailContainer: PropTypes.instanceOf(UserGroupDetailContainer).isRequired,
 };
 
 /**
  * Wrapper component for using unstated
  */
 const UserGroupUserTableWrapper = (props) => {
-  return createSubscribedElement(UserGroupUserTable, props, [AppContainer]);
+  return createSubscribedElement(UserGroupUserTable, props, [AppContainer, UserGroupDetailContainer]);
 };
 
 export default withTranslation()(UserGroupUserTableWrapper);

+ 2 - 2
src/client/js/components/Admin/Users/UserTable.jsx

@@ -94,9 +94,9 @@ class UserTable extends React.Component {
                   </td>
                   <td>{user.name}</td>
                   <td>{user.email}</td>
-                  <td>{dateFnsFormat(new Date(user.createdAt), 'yyyy-MM-DD')}</td>
+                  <td>{dateFnsFormat(new Date(user.createdAt), 'YYYY-MM-DD')}</td>
                   <td>
-                    { user.lastLoginAt && <span>{dateFnsFormat(new Date(user.lastLoginAt), 'yyyy-MM-DD HH:mm')}</span> }
+                    { user.lastLoginAt && <span>{dateFnsFormat(new Date(user.lastLoginAt), 'YYYY-MM-DD HH:mm')}</span> }
                   </td>
                   <td>
                     <UserMenu user={user} onPasswordResetClicked={this.props.onPasswordResetClicked} />

+ 112 - 0
src/client/js/services/UserGroupDetailContainer.js

@@ -0,0 +1,112 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+import { toastError } from '../util/apiNotification';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:services:UserGroupDetailContainer');
+
+/**
+ * Service container for admin user group detail page (UserGroupDetailPage.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class UserGroupDetailContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      // TODO: [SPA] get userGroup from props
+      userGroup: JSON.parse(document.getElementById('admin-user-group-detail').getAttribute('data-user-group')),
+      userGroupRelations: [],
+      unrelatedUsers: [],
+      relatedPages: [],
+      isUserGroupUserModalOpen: false,
+    };
+
+    this.init();
+
+    this.openUserGroupUserModal = this.openUserGroupUserModal.bind(this);
+    this.closeUserGroupUserModal = this.closeUserGroupUserModal.bind(this);
+    this.addUserByUsername = this.addUserByUsername.bind(this);
+    this.removeUserByUsername = this.removeUserByUsername.bind(this);
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'UserGroupDetailContainer';
+  }
+
+  /**
+   * retrieve user group data
+   */
+  async init() {
+    try {
+      const [
+        unrelatedUsers,
+        userGroupRelations,
+        relatedPages,
+      ] = await Promise.all([
+        this.appContainer.apiv3.get(`/user-groups/${this.state.userGroup._id}/unrelated-users`).then((res) => { return res.data.users }),
+        this.appContainer.apiv3.get(`/user-groups/${this.state.userGroup._id}/user-group-relations`).then((res) => { return res.data.userGroupRelations }),
+        this.appContainer.apiv3.get(`/user-groups/${this.state.userGroup._id}/pages`).then((res) => { return res.data.pages }),
+      ]);
+
+      await this.setState({
+        unrelatedUsers,
+        userGroupRelations,
+        relatedPages,
+      });
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(new Error('Failed to fetch data'));
+    }
+  }
+
+  async updateUserGroup(param) {
+    const res = await this.appContainer.apiv3.put(`/user-groups/${this.state.userGroup._id}`, param);
+    const { userGroup } = res.data;
+
+    await this.setState({ userGroup });
+
+    return res;
+  }
+
+  async openUserGroupUserModal() {
+    await this.setState({ isUserGroupUserModalOpen: true });
+  }
+
+  async closeUserGroupUserModal() {
+    await this.setState({ isUserGroupUserModalOpen: false });
+  }
+
+  async addUserByUsername(username) {
+    const res = await this.appContainer.apiv3.post(`/user-groups/${this.state.userGroup._id}/users/${username}`);
+    const { user, userGroupRelation } = res.data;
+
+    this.setState((prevState) => {
+      return {
+        userGroupRelations: [...prevState.userGroupRelations, userGroupRelation],
+        unrelatedUsers: prevState.unrelatedUsers.filter((u) => { return u._id !== user._id }),
+      };
+    });
+  }
+
+  async removeUserByUsername(username) {
+    const res = await this.appContainer.apiv3.delete(`/user-groups/${this.state.userGroup._id}/users/${username}`);
+
+    this.setState((prevState) => {
+      return {
+        userGroupRelations: prevState.userGroupRelations.filter((u) => { return u._id !== res.data.userGroupRelation._id }),
+        unrelatedUsers: [...prevState.unrelatedUsers, res.data.user],
+      };
+    });
+  }
+
+}

+ 4 - 0
src/server/middlewares/ApiV3FormValidator.js

@@ -7,6 +7,10 @@ class ApiV3FormValidator {
     const { ErrorV3 } = crowi.models;
 
     return (req, res, next) => {
+      logger.debug('req.query', req.query);
+      logger.debug('req.params', req.params);
+      logger.debug('req.body', req.body);
+
       const errObjArray = validationResult(req);
       if (errObjArray.isEmpty()) {
         return next();

+ 2 - 23
src/server/routes/admin.js

@@ -685,35 +685,14 @@ module.exports = function(crowi, app) {
   // グループ詳細
   actions.userGroup.detail = async function(req, res) {
     const userGroupId = req.params.id;
-    const renderVar = {
-      userGroup: null,
-      userGroupRelations: [],
-      notRelatedusers: [],
-      relatedPages: [],
-    };
-
     const userGroup = await UserGroup.findOne({ _id: userGroupId });
 
     if (userGroup == null) {
       logger.error('no userGroup is exists. ', userGroupId);
-      req.flash('errorMessage', 'グループがありません');
       return res.redirect('/admin/user-groups');
     }
-    renderVar.userGroup = userGroup;
-
-    const resolves = await Promise.all([
-      // get all user and group relations
-      UserGroupRelation.findAllRelationForUserGroup(userGroup),
-      // get all not related users for group
-      UserGroupRelation.findUserByNotRelatedGroup(userGroup),
-      // get all related pages
-      Page.find({ grant: Page.GRANT_USER_GROUP, grantedGroup: { $in: [userGroup] } }),
-    ]);
-    renderVar.userGroupRelations = resolves[0];
-    renderVar.notRelatedusers = resolves[1];
-    renderVar.relatedPages = resolves[2];
-
-    return res.render('admin/user-group-detail', renderVar);
+
+    return res.render('admin/user-group-detail', { userGroup });
   };
 
   // Importer management

+ 136 - 16
src/server/routes/apiv3/user-group.js

@@ -44,7 +44,7 @@ module.exports = (crowi) => {
    *    /_api/v3/user-groups:
    *      get:
    *        tags: [UserGroup]
-   *        description: Gets usergroups
+   *        description: Get usergroups
    *        produces:
    *          - application/json
    *        responses:
@@ -74,7 +74,7 @@ module.exports = (crowi) => {
   });
 
   validator.create = [
-    body('name', 'Group name is required').trim().exists(),
+    body('name', 'Group name is required').trim().exists({ checkFalsy: true }),
   ];
 
   /**
@@ -124,8 +124,8 @@ module.exports = (crowi) => {
   });
 
   validator.delete = [
-    param('id').trim().exists(),
-    query('actionName').trim().exists(),
+    param('id').trim().exists({ checkFalsy: true }),
+    query('actionName').trim().exists({ checkFalsy: true }),
     query('transferToUserGroupId').trim(),
   ];
 
@@ -187,11 +187,9 @@ module.exports = (crowi) => {
   // });
 
   validator.update = [
-    body('name', 'Group name is required').trim().exists(),
+    body('name', 'Group name is required').trim().exists({ checkFalsy: true }),
   ];
 
-  validator.users = {};
-
   /**
    * @swagger
    *
@@ -199,7 +197,7 @@ module.exports = (crowi) => {
    *    /_api/v3/user-groups/{:id}:
    *      put:
    *        tags: [UserGroup]
-   *        description: Updates userGroup
+   *        description: Update userGroup
    *        produces:
    *          - application/json
    *        parameters:
@@ -247,14 +245,16 @@ module.exports = (crowi) => {
     }
   });
 
+  validator.users = {};
+
   /**
    * @swagger
    *
    *  paths:
-   *    /_api/v3/user-groups/{:id/users}:
+   *    /_api/v3/user-groups/{:id}/users:
    *      get:
    *        tags: [UserGroup]
-   *        description: Gets the users related to the userGroup
+   *        description: Get users related to the userGroup
    *        produces:
    *          - application/json
    *        parameters:
@@ -292,7 +292,52 @@ module.exports = (crowi) => {
     catch (err) {
       const msg = `Error occurred in fetching users for group: ${id}`;
       logger.error(msg, err);
-      return res.apiv3Err(new ErrorV3(msg, 'user-group-fetch-failed'));
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-user-list-fetch-failed'));
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/user-groups/{:id}/unrelated-users:
+   *      get:
+   *        tags: [UserGroup]
+   *        description: Get users unrelated to the userGroup
+   *        produces:
+   *          - application/json
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            description: id of userGroup
+   *            schema:
+   *              type: ObjectId
+   *        responses:
+   *          200:
+   *            description: users are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    users:
+   *                      type: array
+   *                      items:
+   *                        type: object
+   *                      description: user objects
+   */
+  router.get('/:id/unrelated-users', loginRequired(), adminRequired, async(req, res) => {
+    const { id } = req.params;
+
+    try {
+      const userGroup = await UserGroup.findById(id);
+      const users = await UserGroupRelation.findUserByNotRelatedGroup(userGroup);
+
+      return res.apiv3({ users });
+    }
+    catch (err) {
+      const msg = `Error occurred in fetching unrelated users for group: ${id}`;
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-unrelated-user-list-fetch-failed'));
     }
   });
 
@@ -305,7 +350,7 @@ module.exports = (crowi) => {
    * @swagger
    *
    *  paths:
-   *    /_api/v3/user-groups/{:id/users}:
+   *    /_api/v3/user-groups/{:id}/users:
    *      post:
    *        tags: [UserGroup]
    *        description: Add a user to the userGroup
@@ -370,7 +415,7 @@ module.exports = (crowi) => {
    * @swagger
    *
    *  paths:
-   *    /_api/v3/user-groups/{:id/users}:
+   *    /_api/v3/user-groups/{:id}/users:
    *      delete:
    *        tags: [UserGroup]
    *        description: remove a user from the userGroup
@@ -430,6 +475,53 @@ module.exports = (crowi) => {
     }
   });
 
+  validator.userGroupRelations = {};
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/user-groups/{:id}/user-group-relations:
+   *      get:
+   *        tags: [UserGroup]
+   *        description: Get the user group relations for the userGroup
+   *        produces:
+   *          - application/json
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            description: id of userGroup
+   *            schema:
+   *              type: ObjectId
+   *        responses:
+   *          200:
+   *            description: user group relations are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userGroupRelations:
+   *                      type: array
+   *                      items:
+   *                        type: object
+   *                      description: userGroupRelation objects
+   */
+  router.get('/:id/user-group-relations', loginRequired(), adminRequired, async(req, res) => {
+    const { id } = req.params;
+
+    try {
+      const userGroup = await UserGroup.findById(id);
+      const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
+
+      return res.apiv3({ userGroupRelations });
+    }
+    catch (err) {
+      const msg = `Error occurred in fetching user group relations for group: ${id}`;
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-user-group-relation-list-fetch-failed'));
+    }
+  });
+
   validator.pages = {};
 
   validator.pages.get = [
@@ -438,6 +530,35 @@ module.exports = (crowi) => {
     sanitizeQuery('offset').customSanitizer(toPagingOffset),
   ];
 
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/user-groups/{:id}/pages:
+   *      get:
+   *        tags: [UserGroup]
+   *        description: Get closed pages for the userGroup
+   *        produces:
+   *          - application/json
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            description: id of userGroup
+   *            schema:
+   *              type: ObjectId
+   *        responses:
+   *          200:
+   *            description: pages are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    pages:
+   *                      type: array
+   *                      items:
+   *                        type: object
+   *                      description: page objects
+   */
   router.get('/:id/pages', loginRequired(), adminRequired, validator.pages.get, ApiV3FormValidator, async(req, res) => {
     const { id } = req.params;
     const { limit, offset } = req.query;
@@ -451,12 +572,11 @@ module.exports = (crowi) => {
       return res.apiv3({ pages });
     }
     catch (err) {
-      const msg = `Error occurred in fetching related pages for the user group "${id}"`;
+      const msg = `Error occurred in fetching pages for group: ${id}`;
       logger.error(msg, err);
-      return res.apiv3Err(new ErrorV3(msg, 'user-group-fetch-page-list-failed'));
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-page-list-fetch-failed'));
     }
   });
 
-
   return router;
 };

+ 0 - 3
src/server/views/admin/user-group-detail.html

@@ -35,9 +35,6 @@
       id="admin-user-group-detail"
       class="col-md-9"
       data-user-group="{{ userGroup|json }}"
-      data-user-group-relations="{{ userGroupRelations|json }}"
-      data-not-related-users="{{ notRelatedusers|json }}"
-      data-related-pages="{{ relatedPages|json }}"
     >
       <!-- そのまま start -->
       <a href="/admin/user-groups" class="btn btn-default">