diff --git a/client/coral-admin/src/actions/moderation.js b/client/coral-admin/src/actions/moderation.js index 3f8d975df..37e09590d 100644 --- a/client/coral-admin/src/actions/moderation.js +++ b/client/coral-admin/src/actions/moderation.js @@ -42,3 +42,12 @@ export const changeUserDetailStatuses = (tab) => { } return {type: actions.CHANGE_USER_DETAIL_STATUSES, tab, statuses}; }; + +export const clearUserDetailSelections = () => ({type: actions.CLEAR_USER_DETAIL_SELECTIONS}); + +export const toggleSelectCommentInUserDetail = (id, active) => { + return { + type: active ? actions.SELECT_USER_DETAIL_COMMENT : actions.UNSELECT_USER_DETAIL_COMMENT, + id + }; +}; diff --git a/client/coral-admin/src/constants/moderation.js b/client/coral-admin/src/constants/moderation.js index aa8a51a24..374de616a 100644 --- a/client/coral-admin/src/constants/moderation.js +++ b/client/coral-admin/src/constants/moderation.js @@ -9,3 +9,6 @@ export const VIEW_USER_DETAIL = 'VIEW_USER_DETAIL'; export const HIDE_USER_DETAIL = 'HIDE_USER_DETAIL'; export const SET_SORT_ORDER = 'MODERATION_SET_SORT_ORDER'; export const CHANGE_USER_DETAIL_STATUSES = 'CHANGE_USER_DETAIL_STATUSES'; +export const SELECT_USER_DETAIL_COMMENT = 'SELECT_USER_DETAIL_COMMENT'; +export const UNSELECT_USER_DETAIL_COMMENT = 'UNSELECT_USER_DETAIL_COMMENT'; +export const CLEAR_USER_DETAIL_SELECTIONS = 'CLEAR_USER_DETAIL_SELECTIONS'; diff --git a/client/coral-admin/src/reducers/moderation.js b/client/coral-admin/src/reducers/moderation.js index 4201ffb06..1b95bdb90 100644 --- a/client/coral-admin/src/reducers/moderation.js +++ b/client/coral-admin/src/reducers/moderation.js @@ -1,4 +1,4 @@ -import {fromJS, Map} from 'immutable'; +import {fromJS, Map, Set} from 'immutable'; import * as actions from '../constants/moderation'; const initialState = fromJS({ @@ -10,6 +10,7 @@ const initialState = fromJS({ userDetailId: null, userDetailActiveTab: 'all', userDetailStatuses: ['NONE', 'ACCEPTED', 'REJECTED', 'PREMOD'], + userDetailSelectedIds: new Set(), banDialog: false, shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show', sortOrder: 'REVERSE_CHRONOLOGICAL', @@ -66,11 +67,19 @@ export default function moderation (state = initialState, action) { case actions.VIEW_USER_DETAIL: return state.set('userDetailId', action.userId); case actions.HIDE_USER_DETAIL: - return state.set('userDetailId', null); + return state + .set('userDetailId', null) + .update('userDetailSelectedIds', (set) => set.clear()); + case actions.CLEAR_USER_DETAIL_SELECTIONS: + return state.update('userDetailSelectedIds', (set) => set.clear()); case actions.CHANGE_USER_DETAIL_STATUSES: return state .set('userDetailActiveTab', action.tab) .set('userDetailStatuses', action.statuses); + case actions.SELECT_USER_DETAIL_COMMENT: + return state.update('userDetailSelectedIds', (set) => set.add(action.id)); + case actions.UNSELECT_USER_DETAIL_COMMENT: + return state.update('userDetailSelectedIds', (set) => set.delete(action.id)); case actions.SET_SORT_ORDER: return state.set('sortOrder', action.order); default : diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.js b/client/coral-admin/src/routes/Moderation/components/Comment.js index 1bc2b50d7..9d1019b5e 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.js +++ b/client/coral-admin/src/routes/Moderation/components/Comment.js @@ -24,6 +24,8 @@ const Comment = ({ suspectWords, bannedWords, minimal, + selected, + toggleSelect, ...props }) => { const links = linkify.getMatches(comment.body); @@ -48,10 +50,17 @@ const Comment = ({ }) .concat(linkText); + let selectionStateCSS; + if (minimal) { + selectionStateCSS = selected ? styles.minimalSelection : ''; + } else { + selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp'; + } + return (
  • @@ -63,6 +72,16 @@ const Comment = ({ ) } + { + minimal && typeof selected === 'boolean' && typeof toggleSelect === 'function' && ( + toggleSelect(e.target.value, e.target.checked)} /> + ) + } {timeago(comment.created_at || Date.now() - props.index * 60 * 1000)} @@ -187,6 +206,7 @@ Comment.propTypes = { showBanUserDialog: PropTypes.func.isRequired, showSuspendUserDialog: PropTypes.func.isRequired, currentUserId: PropTypes.string.isRequired, + toggleSelect: PropTypes.func, comment: PropTypes.shape({ body: PropTypes.string.isRequired, action_summaries: PropTypes.array, diff --git a/client/coral-admin/src/routes/Moderation/components/UserDetail.css b/client/coral-admin/src/routes/Moderation/components/UserDetail.css index 1119be8c4..d4b5f0db7 100644 --- a/client/coral-admin/src/routes/Moderation/components/UserDetail.css +++ b/client/coral-admin/src/routes/Moderation/components/UserDetail.css @@ -41,8 +41,11 @@ } .commentStatuses { - padding: 0; + padding: 10px 0 0 0; + margin: 0; + height: 52px; list-style: none; + box-sizing: border-box; li { display: inline-block; @@ -56,3 +59,24 @@ font-weight: bold; border-bottom: 3px solid #F36451; } + +.bulkActionGroup { + height: 52px; + background-color: #efefef; + + i { + margin-right: 0; + } + + .bulkAction { + display: inline-block; + width: 48px; + height: 48px; + transform: scale(.7); + min-width: 0; + } + + .bulkAction:last-child { + margin-left: -10px; + } +} diff --git a/client/coral-admin/src/routes/Moderation/components/UserDetail.js b/client/coral-admin/src/routes/Moderation/components/UserDetail.js index c461220c8..0fb355486 100644 --- a/client/coral-admin/src/routes/Moderation/components/UserDetail.js +++ b/client/coral-admin/src/routes/Moderation/components/UserDetail.js @@ -16,6 +16,10 @@ export default class UserDetail extends React.Component { showSuspendUserDialog: PropTypes.func.isRequired, acceptComment: PropTypes.func.isRequired, rejectComment: PropTypes.func.isRequired, + changeStatus: PropTypes.func.isRequired, + toggleSelect: PropTypes.func.isRequired, + bulkAccept: PropTypes.func.isRequired, + bulkReject: PropTypes.func.isRequired, } copyPermalink = () => { @@ -28,12 +32,24 @@ export default class UserDetail extends React.Component { } } - changeStatus = (tab) => { - if (tab === 'all') { - this.props.changeStatus('all'); - } else if (tab === 'rejected') { - this.props.changeStatus('rejected'); - } + rejectThenReload = (info) => { + this.props.rejectComment(info).then(() => { + this.props.data.refetch(); + }); + } + + acceptThenReload = (info) => { + this.props.acceptComment(info).then(() => { + this.props.data.refetch(); + }); + } + + showAll = () => { + this.props.changeStatus('all'); + } + + showRejected = () => { + this.props.changeStatus('rejected'); } render () { @@ -44,13 +60,17 @@ export default class UserDetail extends React.Component { rejectedComments, comments: {nodes} }, - moderation: {userDetailActiveTab: tab}, + moderation: { + userDetailActiveTab: tab, + userDetailSelectedIds: selectedIds + }, bannedWords, suspectWords, + toggleSelect, + bulkAccept, + bulkReject, showBanUserDialog, showSuspendUserDialog, - acceptComment, - rejectComment, hideUserDetail } = this.props; const localProfile = user.profiles.find((p) => p.provider === 'local'); @@ -94,14 +114,38 @@ export default class UserDetail extends React.Component {

    {`${(rejectedPercent).toFixed(1)}%`}

    - + { + selectedIds.length === 0 + ? ( + + ) + : ( +
    + + + {`${selectedIds.length} comments selected`} +
    + ) + } +
    { nodes.map((comment, i) => { const status = comment.action_summaries ? 'FLAGGED' : comment.status; + const selected = selectedIds.indexOf(comment.id) !== -1; return ; diff --git a/client/coral-admin/src/routes/Moderation/components/styles.css b/client/coral-admin/src/routes/Moderation/components/styles.css index e7bea9430..d10311f74 100644 --- a/client/coral-admin/src/routes/Moderation/components/styles.css +++ b/client/coral-admin/src/routes/Moderation/components/styles.css @@ -185,10 +185,6 @@ span { padding: 0 14px; } - &:hover { - box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); - } - &:last-child { border-bottom: none; } @@ -291,7 +287,6 @@ span { @media (--big-viewport) { .listItem { - border: 1px solid #e0e0e0; margin-bottom: 30px; &:last-child { @@ -460,3 +455,15 @@ span { position: relative; } } + +.minimal { + margin: 0; +} + +.minimalSelection { + background-color: #ecf4ff; +} + +.bulkSelectInput { + cursor: pointer; +} diff --git a/client/coral-admin/src/routes/Moderation/containers/UserDetail.js b/client/coral-admin/src/routes/Moderation/containers/UserDetail.js index 9afd10efb..0e36ed51a 100644 --- a/client/coral-admin/src/routes/Moderation/containers/UserDetail.js +++ b/client/coral-admin/src/routes/Moderation/containers/UserDetail.js @@ -6,7 +6,12 @@ import UserDetail from '../components/UserDetail'; import withQuery from 'coral-framework/hocs/withQuery'; import {getSlotsFragments} from 'coral-framework/helpers/plugins'; import {getDefinitionName} from 'coral-framework/utils'; -import {changeUserDetailStatuses} from 'coral-admin/src/actions/moderation'; +import { + changeUserDetailStatuses, + clearUserDetailSelections, + toggleSelectCommentInUserDetail +} from 'coral-admin/src/actions/moderation'; +import {withSetCommentStatus} from 'coral-framework/graphql/mutations'; import Comment from './Comment'; const commentConnectionFragment = gql` @@ -31,12 +36,37 @@ class UserDetailContainer extends React.Component { hideUserDetail: PropTypes.func.isRequired } + // status can be 'ACCEPTED' or 'REJECTED' + bulkSetCommentStatus = (status) => { + const changes = this.props.moderation.userDetailSelectedIds.map((commentId) => { + return this.props.setCommentStatus({commentId, status}); + }); + + Promise.all(changes).then(() => { + this.props.data.refetch(); // some comments may have moved out of this tab + this.props.clearUserDetailSelections(); // un-select everything + }); + } + + bulkReject = () => { + this.bulkSetCommentStatus('REJECTED'); + } + + bulkAccept = () => { + this.bulkSetCommentStatus('ACCEPTED'); + } + render () { if (!('user' in this.props.root)) { return null; } - return ; + return ; } } @@ -79,10 +109,15 @@ const mapStateToProps = (state) => ({ }); const mapDispatchToProps = (dispatch) => ({ - ...bindActionCreators({changeUserDetailStatuses}, dispatch) + ...bindActionCreators({ + changeUserDetailStatuses, + clearUserDetailSelections, + toggleSelectCommentInUserDetail + }, dispatch) }); export default compose( connect(mapStateToProps, mapDispatchToProps), withUserDetailQuery, + withSetCommentStatus, )(UserDetailContainer);