diff --git a/frontend/src/components/dialog/manage-members-dialog.js b/frontend/src/components/dialog/manage-members-dialog.js index 3885013b3d..e96d195bf5 100644 --- a/frontend/src/components/dialog/manage-members-dialog.js +++ b/frontend/src/components/dialog/manage-members-dialog.js @@ -1,21 +1,21 @@ import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; -import { Button, Modal, ModalHeader, ModalBody, ModalFooter, Table } from 'reactstrap'; -import { Utils } from '../../utils/utils'; +import { Button, Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap'; import { gettext } from '../../utils/constants'; -import { seafileAPI } from '../../utils/seafile-api'; -import RoleEditor from '../select-editor/role-editor'; -import UserSelect from '../user-select'; -import toaster from '../toast'; -import Loading from '../loading'; +import ListAndAddGroupMembers from '../list-and-add-group-members'; +import SearchGroupMembers from '../search-group-members'; import '../../css/manage-members-dialog.css'; const propTypes = { groupID: PropTypes.string.isRequired, - toggleManageMembersDialog: PropTypes.func.isRequired, - onGroupChanged: PropTypes.func.isRequired, isOwner: PropTypes.bool.isRequired, + toggleManageMembersDialog: PropTypes.func.isRequired +}; + +const MANAGEMENT_MODE= { + LIST_AND_ADD: 'list_and_add', + SEARCH: 'search' }; class ManageMembersDialog extends React.Component { @@ -23,187 +23,44 @@ class ManageMembersDialog extends React.Component { constructor(props) { super(props); this.state = { - isLoading: true, // first loading - isLoadingMore: false, - groupMembers: [], - page: 1, - perPage: 100, - hasNextPage: false, - selectedOption: null, - errMessage: [], - isItemFreezed: false + currentMode: MANAGEMENT_MODE.LIST_AND_ADD }; } - componentDidMount() { - this.listGroupMembers(this.state.page); - } - - listGroupMembers = (page) => { - const { groupID } = this.props; - const { perPage, groupMembers } = this.state; - seafileAPI.listGroupMembers(groupID, page, perPage).then((res) => { - const members = res.data; - this.setState({ - isLoading: false, - isLoadingMore: false, - page: page, - hasNextPage: members.length < perPage ? false : true, - groupMembers: groupMembers.concat(members) - }); - }).catch(error => { - let errMessage = Utils.getErrorMsg(error); - toaster.danger(errMessage); - this.setState({ - isLoading: false, - isLoadingMore: false, - hasNextPage: false - }); - }); - } - - onSelectChange = (option) => { + changeMode = () => { this.setState({ - selectedOption: option, - errMessage: [], - }); - } - - addGroupMember = () => { - let emails = []; - for (let i = 0; i < this.state.selectedOption.length; i++) { - emails.push(this.state.selectedOption[i].email); - } - seafileAPI.addGroupMembers(this.props.groupID, emails).then((res) => { - const newMembers = res.data.success; - this.setState({ - groupMembers: [].concat(newMembers, this.state.groupMembers), - selectedOption: null, - }); - this.refs.userSelect.clearSelect(); - if (res.data.failed.length > 0) { - this.setState({ - errMessage: res.data.failed - }); - } - }).catch(error => { - let errMessage = Utils.getErrorMsg(error); - toaster.danger(errMessage); - }); - } - - toggleItemFreezed = (isFreezed) => { - this.setState({ - isItemFreezed: isFreezed - }); - } - - toggle = () => { - this.props.toggleManageMembersDialog(); - } - - handleScroll = (event) => { - // isLoadingMore: to avoid repeated request - const { page, hasNextPage, isLoadingMore } = this.state; - if (hasNextPage && !isLoadingMore) { - const clientHeight = event.target.clientHeight; - const scrollHeight = event.target.scrollHeight; - const scrollTop = event.target.scrollTop; - const isBottom = (clientHeight + scrollTop + 1 >= scrollHeight); - if (isBottom) { // scroll to the bottom - this.setState({isLoadingMore: true}, () => { - this.listGroupMembers(page + 1); - }); - } - } - } - - changeMember = (targetMember) => { - this.setState({ - groupMembers: this.state.groupMembers.map((item) => { - if (item.email == targetMember.email) { - item = targetMember; - } - return item; - }) - }); - } - - deleteMember = (targetMember) => { - const groupMembers = this.state.groupMembers; - groupMembers.splice(groupMembers.indexOf(targetMember), 1); - this.setState({ - groupMembers: groupMembers + currentMode: this.state.currentMode == MANAGEMENT_MODE.LIST_AND_ADD ? + MANAGEMENT_MODE.SEARCH : MANAGEMENT_MODE.LIST_AND_ADD }); } render() { - const { isLoading, hasNextPage } = this.state; + const { currentMode } = this.state; + const { groupID, isOwner, toggleManageMembersDialog: toggle } = this.props; return ( - - {gettext('Manage group members')} - -

{gettext('Add group member')}

-
- - {this.state.selectedOption ? - : - - } -
- { - this.state.errMessage.length > 0 && - this.state.errMessage.map((item, index = 0) => { - return ( -
{item.error_msg}
- ); - }) - } -
- {isLoading ? : ( + + + {currentMode == MANAGEMENT_MODE.LIST_AND_ADD ? + gettext('Manage group members') : ( - - - - - - - - - - - { - this.state.groupMembers.length > 0 && - this.state.groupMembers.map((item, index) => { - return ( - - ); - }) - } - -
{gettext('Name')}{gettext('Role')}
- {hasNextPage && } + + {gettext('Search group members')}
- )} -
+ ) + } + + + {currentMode == MANAGEMENT_MODE.LIST_AND_ADD ? + : + + } - +
); @@ -212,107 +69,4 @@ class ManageMembersDialog extends React.Component { ManageMembersDialog.propTypes = propTypes; -const MemberPropTypes = { - item: PropTypes.object.isRequired, - changeMember: PropTypes.func.isRequired, - deleteMember: PropTypes.func.isRequired, - groupID: PropTypes.string.isRequired, - isOwner: PropTypes.bool.isRequired, -}; - -class Member extends React.PureComponent { - - constructor(props) { - super(props); - this.roles = ['Admin', 'Member']; - this.state = ({ - highlight: false, - }); - } - - onChangeUserRole = (role) => { - let isAdmin = role === 'Admin' ? 'True' : 'False'; - seafileAPI.setGroupAdmin(this.props.groupID, this.props.item.email, isAdmin).then((res) => { - this.props.changeMember(res.data); - }).catch(error => { - let errMessage = Utils.getErrorMsg(error); - toaster.danger(errMessage); - }); - } - - deleteMember = () => { - const { item } = this.props; - seafileAPI.deleteGroupMember(this.props.groupID, item.email).then((res) => { - this.props.deleteMember(item); - toaster.success(gettext('Successfully deleted {name}.').replace('{name}', item.name)); - }).catch(error => { - let errMessage = Utils.getErrorMsg(error); - toaster.danger(errMessage); - }); - } - - handleMouseOver = () => { - if (this.props.isItemFreezed) return; - this.setState({ - highlight: true, - }); - } - - handleMouseLeave = () => { - if (this.props.isItemFreezed) return; - this.setState({ - highlight: false, - }); - } - - translateRole = (role) => { - if (role === 'Admin') { - return gettext('Admin'); - } - else if (role === 'Member') { - return gettext('Member'); - } - else if (role === 'Owner') { - return gettext('Owner'); - } - } - - render() { - const { item, isOwner } = this.props; - const deleteAuthority = (item.role !== 'Owner' && isOwner === true) || (item.role === 'Member' && isOwner === false); - return( - - - {item.name} - - {((isOwner === false) || (isOwner === true && item.role === 'Owner')) && - {this.translateRole(item.role)} - } - {(isOwner === true && item.role !== 'Owner') && - - } - - - {(deleteAuthority && !this.props.isItemFreezed) && - - - } - - - ); - } -} - -Member.propTypes = MemberPropTypes; - - export default ManageMembersDialog; diff --git a/frontend/src/components/group-members.js b/frontend/src/components/group-members.js new file mode 100644 index 0000000000..883ba6bb40 --- /dev/null +++ b/frontend/src/components/group-members.js @@ -0,0 +1,165 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Table } from 'reactstrap'; +import { Utils } from '../utils/utils'; +import { gettext } from '../utils/constants'; +import { seafileAPI } from '../utils/seafile-api'; +import RoleEditor from './select-editor/role-editor'; +import toaster from './toast'; +import OpIcon from './op-icon'; + +const propTypes = { + groupMembers: PropTypes.array.isRequired, + groupID: PropTypes.string.isRequired, + isOwner: PropTypes.bool.isRequired, + isItemFreezed: PropTypes.bool.isRequired, + toggleItemFreezed: PropTypes.func.isRequired, + changeMember: PropTypes.func.isRequired, + deleteMember: PropTypes.func.isRequired +}; + +class GroupMembers extends React.Component { + + render() { + const { groupMembers, changeMember, deleteMember, groupID, isOwner, isItemFreezed, toggleItemFreezed } = this.props; + return ( + + + + + + + + + + + {groupMembers.map((item, index) => { + return ( + + ); + }) + } + +
{gettext('Name')}{gettext('Role')}
+ ); + } +} + +GroupMembers.propTypes = propTypes; + +const MemberPropTypes = { + item: PropTypes.object.isRequired, + changeMember: PropTypes.func.isRequired, + deleteMember: PropTypes.func.isRequired, + toggleItemFreezed: PropTypes.func.isRequired, + groupID: PropTypes.string.isRequired, + isOwner: PropTypes.bool.isRequired, + isItemFreezed: PropTypes.bool.isRequired +}; + +class Member extends React.PureComponent { + + constructor(props) { + super(props); + this.roles = ['Admin', 'Member']; + this.state = ({ + highlight: false, + }); + } + + onChangeUserRole = (role) => { + let isAdmin = role === 'Admin' ? 'True' : 'False'; + seafileAPI.setGroupAdmin(this.props.groupID, this.props.item.email, isAdmin).then((res) => { + this.props.changeMember(res.data); + }).catch(error => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + + deleteMember = () => { + const { item } = this.props; + seafileAPI.deleteGroupMember(this.props.groupID, item.email).then((res) => { + this.props.deleteMember(item); + toaster.success(gettext('Successfully deleted {name}.').replace('{name}', item.name)); + }).catch(error => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + + handleMouseOver = () => { + if (this.props.isItemFreezed) return; + this.setState({ + highlight: true, + }); + } + + handleMouseLeave = () => { + if (this.props.isItemFreezed) return; + this.setState({ + highlight: false, + }); + } + + translateRole = (role) => { + if (role === 'Admin') { + return gettext('Admin'); + } + else if (role === 'Member') { + return gettext('Member'); + } + else if (role === 'Owner') { + return gettext('Owner'); + } + } + + render() { + const { item, isOwner } = this.props; + const deleteAuthority = (item.role !== 'Owner' && isOwner === true) || (item.role === 'Member' && isOwner === false); + return( + + + {item.name} + + {((isOwner === false) || (isOwner === true && item.role === 'Owner')) && + {this.translateRole(item.role)} + } + {(isOwner === true && item.role !== 'Owner') && + + } + + + {(deleteAuthority && this.state.highlight) && + + } + + + ); + } +} + +Member.propTypes = MemberPropTypes; + + +export default GroupMembers; diff --git a/frontend/src/components/list-and-add-group-members.js b/frontend/src/components/list-and-add-group-members.js new file mode 100644 index 0000000000..ee607f201f --- /dev/null +++ b/frontend/src/components/list-and-add-group-members.js @@ -0,0 +1,188 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { Button } from 'reactstrap'; +import { Utils } from '../utils/utils'; +import { gettext } from '../utils/constants'; +import { seafileAPI } from '../utils/seafile-api'; +import UserSelect from './user-select'; +import toaster from './toast'; +import Loading from './loading'; +import GroupMembers from './group-members'; + +const propTypes = { + groupID: PropTypes.string.isRequired, + isOwner: PropTypes.bool.isRequired, + changeMode: PropTypes.func.isRequired +}; + +class ManageMembersDialog extends React.Component { + + constructor(props) { + super(props); + this.state = { + isLoading: true, // first loading + isLoadingMore: false, + groupMembers: [], + page: 1, + perPage: 100, + hasNextPage: false, + selectedOption: null, + errMessage: [], + isItemFreezed: false + }; + } + + componentDidMount() { + this.listGroupMembers(this.state.page); + } + + listGroupMembers = (page) => { + const { groupID } = this.props; + const { perPage, groupMembers } = this.state; + seafileAPI.listGroupMembers(groupID, page, perPage).then((res) => { + const members = res.data; + this.setState({ + isLoading: false, + isLoadingMore: false, + page: page, + hasNextPage: members.length < perPage ? false : true, + groupMembers: groupMembers.concat(members) + }); + }).catch(error => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + this.setState({ + isLoading: false, + isLoadingMore: false, + hasNextPage: false + }); + }); + } + + onSelectChange = (option) => { + this.setState({ + selectedOption: option, + errMessage: [], + }); + } + + addGroupMember = () => { + let emails = []; + for (let i = 0; i < this.state.selectedOption.length; i++) { + emails.push(this.state.selectedOption[i].email); + } + seafileAPI.addGroupMembers(this.props.groupID, emails).then((res) => { + const newMembers = res.data.success; + this.setState({ + groupMembers: [].concat(newMembers, this.state.groupMembers), + selectedOption: null, + }); + this.refs.userSelect.clearSelect(); + if (res.data.failed.length > 0) { + this.setState({ + errMessage: res.data.failed + }); + } + }).catch(error => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + + toggleItemFreezed = (isFreezed) => { + this.setState({ + isItemFreezed: isFreezed + }); + } + + handleScroll = (event) => { + // isLoadingMore: to avoid repeated request + const { page, hasNextPage, isLoadingMore } = this.state; + if (hasNextPage && !isLoadingMore) { + const clientHeight = event.target.clientHeight; + const scrollHeight = event.target.scrollHeight; + const scrollTop = event.target.scrollTop; + const isBottom = (clientHeight + scrollTop + 1 >= scrollHeight); + if (isBottom) { // scroll to the bottom + this.setState({isLoadingMore: true}, () => { + this.listGroupMembers(page + 1); + }); + } + } + } + + changeMember = (targetMember) => { + this.setState({ + groupMembers: this.state.groupMembers.map((item) => { + if (item.email == targetMember.email) { + item = targetMember; + } + return item; + }) + }); + } + + deleteMember = (targetMember) => { + const groupMembers = this.state.groupMembers; + groupMembers.splice(groupMembers.indexOf(targetMember), 1); + this.setState({ + groupMembers: groupMembers + }); + } + + render() { + const { isLoading, hasNextPage, groupMembers } = this.state; + return ( + +

{gettext('Add group member')}

+
+ + {this.state.selectedOption ? + : + + } +
+ { + this.state.errMessage.length > 0 && + this.state.errMessage.map((item, index = 0) => { + return ( +
{item.error_msg}
+ ); + }) + } + {groupMembers.length > 10 && + + } +
+ {isLoading ? : ( + + + {hasNextPage && } + + )} +
+
+ ); + } +} + +ManageMembersDialog.propTypes = propTypes; + +export default ManageMembersDialog; diff --git a/frontend/src/components/op-icon.js b/frontend/src/components/op-icon.js new file mode 100644 index 0000000000..b486bc81dc --- /dev/null +++ b/frontend/src/components/op-icon.js @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Utils } from '../utils/utils'; + +const propTypes = { + className: PropTypes.string.isRequired, + op: PropTypes.func, + title: PropTypes.string.isRequired +}; + +class OpIcon extends React.Component { + + render() { + const { className, op, title } = this.props; + return (); + } +} + +OpIcon.propTypes = propTypes; + +export default OpIcon; diff --git a/frontend/src/components/search-group-members.js b/frontend/src/components/search-group-members.js new file mode 100644 index 0000000000..fb8bf3b6f0 --- /dev/null +++ b/frontend/src/components/search-group-members.js @@ -0,0 +1,130 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { Input, Button } from 'reactstrap'; +import { Utils } from '../utils/utils'; +import { gettext } from '../utils/constants'; +import { seafileAPI } from '../utils/seafile-api'; +import Loading from './loading'; +import GroupMembers from './group-members'; + +const propTypes = { + groupID: PropTypes.string.isRequired, + isOwner: PropTypes.bool.isRequired +}; + +class SearchGroupMembers extends React.Component { + + // pagination is not needed + constructor(props) { + super(props); + this.state = { + isLoading: false, + q: '', // query + groupMembers: [], + errMessage: [], + isItemFreezed: false + }; + } + + toggleItemFreezed = (isFreezed) => { + this.setState({ + isItemFreezed: isFreezed + }); + } + + changeMember = (targetMember) => { + this.setState({ + groupMembers: this.state.groupMembers.map((item) => { + if (item.email == targetMember.email) { + item = targetMember; + } + return item; + }) + }); + } + + deleteMember = (targetMember) => { + const groupMembers = this.state.groupMembers; + groupMembers.splice(groupMembers.indexOf(targetMember), 1); + this.setState({ + groupMembers: groupMembers + }); + } + + submit = () => { + let { q } = this.state; + q = q.trim(); + if (!q) { + return; + } + this.setState({ + isLoading: true + }); + seafileAPI.searchGroupMember(this.props.groupID, q).then((res) => { + this.setState({ + isLoading: false, + groupMembers: res.data, + errorMsg: '' + }); + }).catch(error => { + let errMessage = Utils.getErrorMsg(error); + this.setState({ + isLoading: false, + errorMsg: errMessage + }); + }); + } + + onInputChange = (e) => { + this.setState({ + q: e.target.value + }); + } + + onInputKeyDown = (e) => { + if (e.key == 'Enter') { + this.submit(); + } + } + + render() { + const { isLoading, q, errorMsg, groupMembers } = this.state; + return ( + +
+ + +
+ {errorMsg &&

{errorMsg}

} +
+ {isLoading ? : ( + + {groupMembers.length > 0 && ( + + )} + + )} +
+
+ ); + } +} + +SearchGroupMembers.propTypes = propTypes; + +export default SearchGroupMembers; diff --git a/frontend/src/css/manage-members-dialog.css b/frontend/src/css/manage-members-dialog.css index 265f31bdd2..1220f5ff71 100644 --- a/frontend/src/css/manage-members-dialog.css +++ b/frontend/src/css/manage-members-dialog.css @@ -1,33 +1,13 @@ .manage-members { max-height: 300px; overflow-y: scroll; - margin: 1rem 0 2rem; + margin: 10px 0 2rem; } .manage-members-table th, .manage-members-table td { vertical-align: middle; text-align: left; } -.toggle-group-admin-icon, -.delete-group-member-icon { - opacity: 0; - cursor: pointer; - color: #888; - margin-left: 5px; -} -.manage-members-table tr:hover .toggle-group-admin-icon, -.manage-members-table tr:hover .delete-group-member-icon { - opacity: 1; -} - -.manage-members-table .editing { - background-color: rgba(0, 0, 0, 0.04); -} - -.toggle-group-admin-icon:hover, -.delete-group-member-icon:hover { - color: #333; -} .add-members-select .true__indicator-separator { display: none; @@ -46,4 +26,20 @@ } .group-error { margin-top: 10px; -} \ No newline at end of file +} + +.group-manage-members-dialog .back-icon { + color: #999; + cursor: pointer; +} + +.group-manage-members-dialog .search-group-members-btn { + font-size: 14px; + font-weight: normal; + background: #f1f1f1; + margin: 12px 0 0; +} + +.group-manage-members-dialog .search-group-members-btn:hover { + background: #dbdbdb; +} diff --git a/frontend/src/utils/utils.js b/frontend/src/utils/utils.js index 25ff7edda8..443e5b9855 100644 --- a/frontend/src/utils/utils.js +++ b/frontend/src/utils/utils.js @@ -1382,4 +1382,11 @@ export const Utils = { return level; }, + // for a11y + onKeyDown: function(e) { + if (e.key == 'Enter' || e.key == 'Space') { + e.target.click(); + } + } + }; diff --git a/seahub/api2/endpoints/group_members.py b/seahub/api2/endpoints/group_members.py index 725a1fe4d5..b5e0726a30 100644 --- a/seahub/api2/endpoints/group_members.py +++ b/seahub/api2/endpoints/group_members.py @@ -133,10 +133,42 @@ class GroupMembers(APIView): return Response(member_info, status=status.HTTP_201_CREATED) +class GroupSearchMember(APIView): + + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + throttle_classes = (UserRateThrottle,) + + @api_check_group + def get(self, request, group_id, format=None): + """ + Search group member by email. + """ + + q = request.GET.get('q', '') + if not q: + error_msg = 'q invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if not is_group_member(group_id, request.user.username): + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + group_members = [] + members = ccnet_api.search_group_members(group_id, q) + for member in members: + + member_info = get_group_member_info(request, group_id, member.user_name) + + group_members.append(member_info) + + return Response(group_members) + + class GroupMember(APIView): authentication_classes = (TokenAuthentication, SessionAuthentication) permission_classes = (IsAuthenticated,) - throttle_classes = (UserRateThrottle, ) + throttle_classes = (UserRateThrottle,) @api_check_group def get(self, request, group_id, email): diff --git a/seahub/urls.py b/seahub/urls.py index ac687a49a1..fbe6f7b747 100644 --- a/seahub/urls.py +++ b/seahub/urls.py @@ -36,7 +36,7 @@ from seahub.api2.endpoints.group_owned_libraries import GroupOwnedLibraries, \ from seahub.api2.endpoints.address_book.groups import AddressBookGroupsSubGroups from seahub.api2.endpoints.address_book.members import AddressBookGroupsSearchMember -from seahub.api2.endpoints.group_members import GroupMembers, GroupMember, \ +from seahub.api2.endpoints.group_members import GroupMembers, GroupSearchMember, GroupMember, \ GroupMembersBulk, GroupMembersImport, GroupMembersImportExample from seahub.api2.endpoints.search_group import SearchGroup from seahub.api2.endpoints.share_links import ShareLinks, ShareLink, \ @@ -308,6 +308,7 @@ urlpatterns = [ url(r'^api/v2.1/groups/(?P\d+)/group-owned-libraries/$', GroupOwnedLibraries.as_view(), name='api-v2.1-group-owned-libraries'), url(r'^api/v2.1/groups/(?P\d+)/group-owned-libraries/(?P[-0-9a-f]{36})/$', GroupOwnedLibrary.as_view(), name='api-v2.1-owned-group-library'), url(r'^api/v2.1/groups/(?P\d+)/members/$', GroupMembers.as_view(), name='api-v2.1-group-members'), + url(r'^api/v2.1/groups/(?P\d+)/search-member/$', GroupSearchMember.as_view(), name='api-v2.1-group-search-member'), url(r'^api/v2.1/groups/(?P\d+)/members/bulk/$', GroupMembersBulk.as_view(), name='api-v2.1-group-members-bulk'), url(r'^api/v2.1/groups/(?P\d+)/members/import/$', GroupMembersImport.as_view(), name='api-v2.1-group-members-import'), url(r'^api/v2.1/group-members-import-example/$', GroupMembersImportExample.as_view(), name='api-v2.1-group-members-import-example'),