Kaynağa Gözat

Merge pull request #1807 from weseek/feat/user-management

Feat/user management
Yuki Takei 6 yıl önce
ebeveyn
işleme
c6f1db0f46

+ 1 - 0
resource/locales/en-US/admin/admin.json

@@ -221,6 +221,7 @@
   },
   },
   "user_management": {
   "user_management": {
     "invite_users": "Invite New Users",
     "invite_users": "Invite New Users",
+    "click_twice_same_checkbox": "You should check at least one checkbox.",
     "invite_modal": {
     "invite_modal": {
       "emails": "Emails",
       "emails": "Emails",
       "invite_thru_email": "Send Invitation Email",
       "invite_thru_email": "Send Invitation Email",

+ 1 - 0
resource/locales/ja/admin/admin.json

@@ -221,6 +221,7 @@
   },
   },
   "user_management": {
   "user_management": {
     "invite_users": "新規ユーザーの招待",
     "invite_users": "新規ユーザーの招待",
+    "click_twice_same_checkbox": "少なくとも一つはチェックしてください。",
     "invite_modal": {
     "invite_modal": {
       "emails": "メールアドレス (複数行入力で複数人招待可能)",
       "emails": "メールアドレス (複数行入力で複数人招待可能)",
       "invite_thru_email": "招待をメールで送信",
       "invite_thru_email": "招待をメールで送信",

+ 168 - 0
src/client/js/components/Admin/UserManagement.jsx

@@ -20,7 +20,12 @@ class UserManagement extends React.Component {
   constructor(props) {
   constructor(props) {
     super();
     super();
 
 
+    this.state = {
+      isNotifyCommentShow: false,
+    };
+
     this.handlePage = this.handlePage.bind(this);
     this.handlePage = this.handlePage.bind(this);
+    this.handleChangeSearchText = this.handleChangeSearchText.bind(this);
   }
   }
 
 
   componentWillMount() {
   componentWillMount() {
@@ -36,6 +41,56 @@ class UserManagement extends React.Component {
     }
     }
   }
   }
 
 
+  /**
+   * For checking same check box twice
+   * @param {string} statusType
+   */
+  async handleClick(statusType) {
+    const { adminUsersContainer } = this.props;
+    if (!this.validateToggleStatus(statusType)) {
+      return this.setState({ isNotifyCommentShow: true });
+    }
+
+    if (this.state.isNotifyCommentShow) {
+      await this.setState({ isNotifyCommentShow: false });
+    }
+    adminUsersContainer.handleClick(statusType);
+  }
+
+  /**
+   * Workaround user status check box
+   * @param {string} statusType
+   */
+  validateToggleStatus(statusType) {
+    if (this.props.adminUsersContainer.isSelected(statusType)) {
+      return this.props.adminUsersContainer.state.selectedStatusList.size > 1;
+    }
+    return true;
+  }
+
+  /**
+   * Reset button
+   */
+  resetButtonClickHandler() {
+    const { adminUsersContainer } = this.props;
+    try {
+      adminUsersContainer.resetAllChanges();
+      this.searchUserElement.value = '';
+      this.state.isNotifyCommentShow = false;
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  /**
+   * Workaround increamental search
+   * @param {string} event
+   */
+  handleChangeSearchText(event) {
+    this.props.adminUsersContainer.handleChangeSearchText(event.target.value);
+  }
+
   render() {
   render() {
     const { t, adminUsersContainer } = this.props;
     const { t, adminUsersContainer } = this.props;
 
 
@@ -50,6 +105,20 @@ class UserManagement extends React.Component {
       </div>
       </div>
     );
     );
 
 
+    const clearButton = (
+      adminUsersContainer.state.searchText.length > 0
+        ? (
+          <i
+            className="icon-close search-clear"
+            onClick={() => {
+              adminUsersContainer.clearSearchText();
+              this.searchUserElement.value = '';
+            }}
+          />
+        )
+        : ''
+    );
+
     return (
     return (
       <Fragment>
       <Fragment>
         {adminUsersContainer.state.userForPasswordResetModal && <PasswordResetModal />}
         {adminUsersContainer.state.userForPasswordResetModal && <PasswordResetModal />}
@@ -63,6 +132,105 @@ class UserManagement extends React.Component {
 
 
         <h2>{t('User_Management')}</h2>
         <h2>{t('User_Management')}</h2>
 
 
+        <div className="border-top border-bottom">
+
+          <div className="d-flex justify-content-start align-items-center my-2">
+            <div>
+              <i className="icon-magnifier mr-1"></i>
+              <span className="search-typeahead">
+                <input
+                  type="text"
+                  ref={(searchUserElement) => { this.searchUserElement = searchUserElement }}
+                  onChange={this.handleChangeSearchText}
+                />
+                { clearButton }
+              </span>
+            </div>
+
+            <div className="mx-5 form-inline">
+              <div className="checkbox checkbox-primary pl-0">
+                <input
+                  type="checkbox"
+                  id="c1"
+                  checked={adminUsersContainer.isSelected('all')}
+                  onClick={() => { this.handleClick('all') }}
+                />
+                <label htmlFor="c1">
+                  <span className="label label-primary d-inline-block vt mt-1">All</span>
+                </label>
+              </div>
+
+              <div className="checkbox checkbox-info">
+                <input
+                  type="checkbox"
+                  id="c2"
+                  checked={adminUsersContainer.isSelected('registered')}
+                  onClick={() => { this.handleClick('registered') }}
+                />
+                <label htmlFor="c2">
+                  <span className="label label-info d-inline-block vt mt-1">Approval Pending</span>
+                </label>
+              </div>
+
+              <div className="checkbox checkbox-success">
+                <input
+                  type="checkbox"
+                  id="c3"
+                  checked={adminUsersContainer.isSelected('active')}
+                  onClick={() => { this.handleClick('active') }}
+                />
+                <label htmlFor="c3">
+                  <span className="label label-success d-inline-block vt mt-1">Active</span>
+                </label>
+              </div>
+
+              <div className="checkbox checkbox-warning">
+                <input
+                  type="checkbox"
+                  id="c4"
+                  checked={adminUsersContainer.isSelected('suspended')}
+                  onClick={() => { this.handleClick('suspended') }}
+                />
+                <label htmlFor="c4">
+                  <span className="label label-warning d-inline-block vt mt-1">Suspended</span>
+                </label>
+              </div>
+
+              <div className="checkbox checkbox-info">
+                <input
+                  type="checkbox"
+                  id="c5"
+                  checked={adminUsersContainer.isSelected('invited')}
+                  onClick={() => { this.handleClick('invited') }}
+                />
+                <label htmlFor="c5">
+                  <span className="label label-info d-inline-block vt mt-1">Invited</span>
+                </label>
+              </div>
+            </div>
+
+            <div>
+              <button
+                type="button"
+                className="btn btn-default btn-outline btn-sm"
+                onClick={() => { this.resetButtonClickHandler() }}
+              >
+                <span
+                  className="icon-refresh mr-1"
+                >
+                </span>
+                Reset
+              </button>
+            </div>
+
+            <div className="ml-5">
+              {this.state.isNotifyCommentShow && <span className="text-warning">{t('admin:user_management.click_twice_same_checkbox')}</span>}
+            </div>
+
+          </div>
+        </div>
+
+
         {pager}
         {pager}
         <UserTable />
         <UserTable />
         {pager}
         {pager}

+ 32 - 0
src/client/js/components/Admin/Users/SortIcons.jsx

@@ -0,0 +1,32 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+const SortIcons = (props) => {
+
+  const { isSelected, isAsc } = props;
+
+  return (
+    <div className="d-flex flex-column text-center">
+      <a
+        className={`fa ${isSelected && isAsc ? 'fa-chevron-up' : 'fa-angle-up'}`}
+        aria-hidden="true"
+        onClick={() => props.onClick('asc')}
+      />
+      <a
+        className={`fa ${isSelected && !isAsc ? 'fa-chevron-down' : 'fa-angle-down'}`}
+        aria-hidden="true"
+        onClick={() => props.onClick('desc')}
+      />
+    </div>
+  );
+};
+
+SortIcons.propTypes = {
+  onClick: PropTypes.func.isRequired,
+  isSelected: PropTypes.bool.isRequired,
+  isAsc: PropTypes.bool.isRequired,
+};
+
+
+export default withTranslation()(SortIcons);

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

@@ -9,6 +9,7 @@ import UserMenu from './UserMenu';
 import { createSubscribedElement } from '../../UnstatedUtils';
 import { createSubscribedElement } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
 import AppContainer from '../../../services/AppContainer';
 import AdminUsersContainer from '../../../services/AdminUsersContainer';
 import AdminUsersContainer from '../../../services/AdminUsersContainer';
+import SortIcons from './SortIcons';
 
 
 class UserTable extends React.Component {
 class UserTable extends React.Component {
 
 
@@ -74,21 +75,108 @@ class UserTable extends React.Component {
     }
     }
   }
   }
 
 
+  sortIconsClickedHandler(sort, sortOrder) {
+    const isAsc = sortOrder === 'asc';
+
+    const { adminUsersContainer } = this.props;
+    adminUsersContainer.sort(sort, isAsc);
+  }
+
   render() {
   render() {
     const { t, adminUsersContainer } = this.props;
     const { t, adminUsersContainer } = this.props;
 
 
+    const isCurrentSortOrderAsc = adminUsersContainer.state.sortOrder === 'asc';
+
     return (
     return (
       <Fragment>
       <Fragment>
         <table className="table table-default table-bordered table-user-list">
         <table className="table table-default table-bordered table-user-list">
           <thead>
           <thead>
             <tr>
             <tr>
               <th width="100px">#</th>
               <th width="100px">#</th>
-              <th>{t('status')}</th>
-              <th><code>username</code></th>
-              <th>{t('Name')}</th>
-              <th>{t('Email')}</th>
-              <th width="100px">{t('Created')}</th>
-              <th width="150px">{t('Last_Login')}</th>
+              <th>
+                <div className="d-flex align-items-center">
+                  <div className="mr-3">
+                    {t('status')}
+                  </div>
+                  <SortIcons
+                    isSelected={adminUsersContainer.state.sort === 'status'}
+                    isAsc={isCurrentSortOrderAsc}
+                    onClick={(sortOrder) => {
+                      this.sortIconsClickedHandler('status', sortOrder);
+                    }}
+                  />
+                </div>
+              </th>
+              <th>
+                <div className="d-flex align-items-center">
+                  <div className="mr-3">
+                    <code>username</code>
+                  </div>
+                  <SortIcons
+                    isSelected={adminUsersContainer.state.sort === 'username'}
+                    isAsc={isCurrentSortOrderAsc}
+                    onClick={(sortOrder) => {
+                      this.sortIconsClickedHandler('username', sortOrder);
+                    }}
+                  />
+                </div>
+              </th>
+              <th>
+                <div className="d-flex align-items-center">
+                  <div className="mr-3">
+                    {t('Name')}
+                  </div>
+                  <SortIcons
+                    isSelected={adminUsersContainer.state.sort === 'name'}
+                    isAsc={isCurrentSortOrderAsc}
+                    onClick={(sortOrder) => {
+                      this.sortIconsClickedHandler('name', sortOrder);
+                    }}
+                  />
+                </div>
+              </th>
+              <th>
+                <div className="d-flex align-items-center">
+                  <div className="mr-3">
+                    {t('Email')}
+                  </div>
+                  <SortIcons
+                    isSelected={adminUsersContainer.state.sort === 'email'}
+                    isAsc={isCurrentSortOrderAsc}
+                    onClick={(sortOrder) => {
+                      this.sortIconsClickedHandler('email', sortOrder);
+                    }}
+                  />
+                </div>
+              </th>
+              <th width="100px">
+                <div className="d-flex align-items-center">
+                  <div className="mr-3">
+                    {t('Created')}
+                  </div>
+                  <SortIcons
+                    isSelected={adminUsersContainer.state.sort === 'createdAt'}
+                    isAsc={isCurrentSortOrderAsc}
+                    onClick={(sortOrder) => {
+                      this.sortIconsClickedHandler('createdAt', sortOrder);
+                    }}
+                  />
+                </div>
+              </th>
+              <th width="150px">
+                <div className="d-flex align-items-center">
+                  <div className="mr-3">
+                    {t('Last_Login')}
+                  </div>
+                  <SortIcons
+                    isSelected={adminUsersContainer.state.sort === 'lastLoginAt'}
+                    isAsc={isCurrentSortOrderAsc}
+                    onClick={(sortOrder) => {
+                      this.sortIconsClickedHandler('lastLoginAt', sortOrder);
+                    }}
+                  />
+                </div>
+              </th>
               <th width="70px"></th>
               <th width="70px"></th>
             </tr>
             </tr>
           </thead>
           </thead>
@@ -123,9 +211,6 @@ class UserTable extends React.Component {
 
 
 }
 }
 
 
-const UserTableWrapper = (props) => {
-  return createSubscribedElement(UserTable, props, [AppContainer, AdminUsersContainer]);
-};
 
 
 UserTable.propTypes = {
 UserTable.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
@@ -134,4 +219,8 @@ UserTable.propTypes = {
 
 
 };
 };
 
 
+const UserTableWrapper = (props) => {
+  return createSubscribedElement(UserTable, props, [AppContainer, AdminUsersContainer]);
+};
+
 export default withTranslation()(UserTableWrapper);
 export default withTranslation()(UserTableWrapper);

+ 89 - 2
src/client/js/services/AdminUsersContainer.js

@@ -1,6 +1,6 @@
 import { Container } from 'unstated';
 import { Container } from 'unstated';
-
 import loggerFactory from '@alias/logger';
 import loggerFactory from '@alias/logger';
+import { debounce } from 'throttle-debounce';
 
 
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:services:AdminUserGroupDetailContainer');
 const logger = loggerFactory('growi:services:AdminUserGroupDetailContainer');
@@ -18,17 +18,23 @@ export default class AdminUsersContainer extends Container {
 
 
     this.state = {
     this.state = {
       users: [],
       users: [],
+      sort: 'id',
+      sortOrder: 'asc',
       isPasswordResetModalShown: false,
       isPasswordResetModalShown: false,
       isUserInviteModalShown: false,
       isUserInviteModalShown: false,
       userForPasswordResetModal: null,
       userForPasswordResetModal: null,
       totalUsers: 0,
       totalUsers: 0,
       activePage: 1,
       activePage: 1,
       pagingLimit: Infinity,
       pagingLimit: Infinity,
+      selectedStatusList: new Set(['all']),
+      searchText: '',
     };
     };
 
 
     this.showPasswordResetModal = this.showPasswordResetModal.bind(this);
     this.showPasswordResetModal = this.showPasswordResetModal.bind(this);
     this.hidePasswordResetModal = this.hidePasswordResetModal.bind(this);
     this.hidePasswordResetModal = this.hidePasswordResetModal.bind(this);
     this.toggleUserInviteModal = this.toggleUserInviteModal.bind(this);
     this.toggleUserInviteModal = this.toggleUserInviteModal.bind(this);
+
+    this.handleChangeSearchTextDebouce = debounce(3000, () => this.retrieveUsersByPagingNum(1));
   }
   }
 
 
   /**
   /**
@@ -38,6 +44,81 @@ export default class AdminUsersContainer extends Container {
     return 'AdminUsersContainer';
     return 'AdminUsersContainer';
   }
   }
 
 
+  /**
+   * Workaround for status list
+   */
+  isSelected(statusType) {
+    return this.state.selectedStatusList.has(statusType);
+  }
+
+  handleClick(statusType) {
+    const all = 'all';
+    if (this.isSelected(statusType)) {
+      this.deleteStatusFromList(statusType);
+    }
+    else {
+      if (statusType === all) {
+        this.clearStatusList();
+      }
+      else {
+        this.deleteStatusFromList(all);
+      }
+      this.addStatusToList(statusType);
+    }
+  }
+
+  async clearStatusList() {
+    const { selectedStatusList } = this.state;
+    selectedStatusList.clear();
+    await this.setState({ selectedStatusList });
+  }
+
+  async addStatusToList(statusType) {
+    const { selectedStatusList } = this.state;
+    selectedStatusList.add(statusType);
+    await this.setState({ selectedStatusList });
+    this.retrieveUsersByPagingNum(1);
+  }
+
+  async deleteStatusFromList(statusType) {
+    const { selectedStatusList } = this.state;
+    selectedStatusList.delete(statusType);
+    await this.setState({ selectedStatusList });
+    this.retrieveUsersByPagingNum(1);
+  }
+
+  /**
+   * Workaround for Increment Search Text Input
+   */
+  async handleChangeSearchText(searchText) {
+    await this.setState({ searchText });
+    this.handleChangeSearchTextDebouce();
+  }
+
+  async clearSearchText() {
+    await this.setState({ searchText: '' });
+    this.retrieveUsersByPagingNum(1);
+  }
+
+  /**
+   * Workaround for Sorting
+   */
+  async sort(sort, isAsc) {
+    const sortOrder = isAsc ? 'asc' : 'desc';
+    await this.setState({ sort, sortOrder });
+    this.retrieveUsersByPagingNum(1);
+  }
+
+  async resetAllChanges() {
+    await this.setState({
+      sort: 'id',
+      sortOrder: 'asc',
+      searchText: '',
+      selectedStatusList: new Set(['all']),
+    });
+    this.retrieveUsersByPagingNum(1);
+  }
+
   /**
   /**
    * syncUsers of selectedPage
    * syncUsers of selectedPage
    * @memberOf AdminUsersContainer
    * @memberOf AdminUsersContainer
@@ -45,7 +126,13 @@ export default class AdminUsersContainer extends Container {
    */
    */
   async retrieveUsersByPagingNum(selectedPage) {
   async retrieveUsersByPagingNum(selectedPage) {
 
 
-    const params = { page: selectedPage };
+    const params = {
+      page: selectedPage,
+      sort: this.state.sort,
+      sortOrder: this.state.sortOrder,
+      selectedStatusList: Array.from(this.state.selectedStatusList),
+      searchText: this.state.searchText,
+    };
     const { data } = await this.appContainer.apiv3.get('/users', params);
     const { data } = await this.appContainer.apiv3.get('/users', params);
 
 
     if (data.paginateResult == null) {
     if (data.paginateResult == null) {

+ 4 - 3
src/client/styles/scss/_user.scss

@@ -34,9 +34,11 @@
         font-weight: bold;
         font-weight: bold;
       }
       }
 
 
-      .user-page-email {}
+      .user-page-email {
+      }
 
 
-      .user-page-introduction {}
+      .user-page-introduction {
+      }
     }
     }
 
 
     .btn-like,
     .btn-like,
@@ -79,7 +81,6 @@
         }
         }
 
 
         a {
         a {
-
           .icon-copy,
           .icon-copy,
           .draft-delete,
           .draft-delete,
           .icon-edit {
           .icon-edit {

+ 81 - 6
src/server/routes/apiv3/users.js

@@ -6,7 +6,7 @@ const express = require('express');
 
 
 const router = express.Router();
 const router = express.Router();
 
 
-const { body } = require('express-validator/check');
+const { body, query } = require('express-validator/check');
 const { isEmail } = require('validator');
 const { isEmail } = require('validator');
 
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const ErrorV3 = require('../../models/vo/error-apiv3');
@@ -77,6 +77,31 @@ module.exports = (crowi) => {
 
 
   const { ApiV3FormValidator } = crowi.middlewares;
   const { ApiV3FormValidator } = crowi.middlewares;
 
 
+  const statusNo = {
+    registered: User.STATUS_REGISTERED,
+    active: User.STATUS_ACTIVE,
+    suspended: User.STATUS_SUSPENDED,
+    invited: User.STATUS_INVITED,
+  };
+
+  validator.statusList = [
+    // validate status list status array match to statusNo
+    query('selectedStatusList').custom((value) => {
+      const error = [];
+      value.forEach((status) => {
+        if (!Object.keys(statusNo)) {
+          error.push(status);
+        }
+      });
+      return (error.length === 0);
+    }),
+    // validate sortOrder : asc or desc
+    query('sortOrder').isIn(['asc', 'desc']),
+    // validate sort : what column you will sort
+    query('sort').isIn(['id', 'status', 'username', 'name', 'email', 'createdAt', 'lastLoginAt']),
+    query('page').isInt({ min: 1 }),
+  ];
+
   /**
   /**
    * @swagger
    * @swagger
    *
    *
@@ -86,7 +111,33 @@ module.exports = (crowi) => {
    *        tags: [Users]
    *        tags: [Users]
    *        operationId: listUsers
    *        operationId: listUsers
    *        summary: /users
    *        summary: /users
-   *        description: Get users
+   *        description: Select selected columns from users order by asc or desc
+   *        parameters:
+   *          - name: page
+   *            in: query
+   *            description: page number
+   *            schema:
+   *              type: number
+   *          - name:  selectedStatusList
+   *            in: query
+   *            description: status list
+   *            schema:
+   *              type: string
+   *          - name: searchText
+   *            in: query
+   *            description: For incremental search value from input box
+   *            schema:
+   *              type: string
+   *          - name: sortOrder
+   *            in: query
+   *            description: asc or desc
+   *            schema:
+   *              type: string
+   *          - name: sort
+   *            in: query
+   *            description: sorting column
+   *            schema:
+   *              type: string
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: users are fetched
    *            description: users are fetched
@@ -97,14 +148,39 @@ module.exports = (crowi) => {
    *                    paginateResult:
    *                    paginateResult:
    *                      $ref: '#/components/schemas/PaginateResult'
    *                      $ref: '#/components/schemas/PaginateResult'
    */
    */
-  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
+
+  router.get('/', validator.statusList, ApiV3FormValidator, async(req, res) => {
+
     const page = parseInt(req.query.page) || 1;
     const page = parseInt(req.query.page) || 1;
+    // status
+    const { selectedStatusList } = req.query;
+    const statusNoList = (selectedStatusList.includes('all')) ? Object.values(statusNo) : selectedStatusList.map(element => statusNo[element]);
+
+    // Search from input
+    const searchText = req.query.searchText || '';
+    const searchWord = new RegExp(`${searchText}`);
+    // Sort
+    const { sort, sortOrder } = req.query;
+    const sortOutput = {
+      [sort]: (sortOrder === 'desc') ? -1 : 1,
+    };
 
 
     try {
     try {
       const paginateResult = await User.paginate(
       const paginateResult = await User.paginate(
-        { status: { $ne: User.STATUS_DELETED } },
         {
         {
-          sort: { status: 1, username: 1, createdAt: 1 },
+          $and: [
+            { status: { $in: statusNoList } },
+            {
+              $or: [
+                { name: { $in: searchWord } },
+                { username: { $in: searchWord } },
+                { email: { $in: searchWord } },
+              ],
+            },
+          ],
+        },
+        {
+          sort: sortOutput,
           page,
           page,
           limit: PAGE_ITEMS,
           limit: PAGE_ITEMS,
         },
         },
@@ -424,7 +500,6 @@ module.exports = (crowi) => {
     }
     }
   });
   });
 
 
-
   /**
   /**
    * @swagger
    * @swagger
    *
    *