diff --git a/client/coral-admin/src/AppRouter.js b/client/coral-admin/src/AppRouter.js index 9721123fa..a00c17c04 100644 --- a/client/coral-admin/src/AppRouter.js +++ b/client/coral-admin/src/AppRouter.js @@ -18,6 +18,9 @@ const routes = ( + + + ); diff --git a/client/coral-admin/src/actions/assets.js b/client/coral-admin/src/actions/assets.js index f431f1ad6..a4a3513a8 100644 --- a/client/coral-admin/src/actions/assets.js +++ b/client/coral-admin/src/actions/assets.js @@ -4,8 +4,10 @@ import { FETCH_ASSETS_FAILURE, UPDATE_ASSET_STATE_REQUEST, UPDATE_ASSET_STATE_SUCCESS, - UPDATE_ASSET_STATE_FAILURE + UPDATE_ASSET_STATE_FAILURE, + UPDATE_ASSETS } from '../constants/assets'; + import coralApi from '../../../coral-framework/helpers/response'; /** @@ -34,3 +36,7 @@ export const updateAssetState = (id, closedAt) => (dispatch) => { dispatch({type: UPDATE_ASSET_STATE_SUCCESS})) .catch(error => dispatch({type: UPDATE_ASSET_STATE_FAILURE, error})); }; + +export const updateAssets = assets => dispatch => { + dispatch({type: UPDATE_ASSETS, assets}); +}; diff --git a/client/coral-admin/src/actions/comments.js b/client/coral-admin/src/actions/comments.js index ac7af12fd..14f33bf36 100644 --- a/client/coral-admin/src/actions/comments.js +++ b/client/coral-admin/src/actions/comments.js @@ -101,12 +101,3 @@ export const flagComment = id => (dispatch, getState) => { dispatch({type: commentTypes.COMMENT_FLAG, id}); dispatch({type: 'COMMENT_UPDATE', comment: getState().comments.get('byId').get(id)}); }; - -// Dialog Actions -export const showBanUserDialog = (userId, userName, commentId) => { - return {type: commentTypes.SHOW_BANUSER_DIALOG, userId, userName, commentId}; -}; - -export const hideBanUserDialog = (showDialog) => { - return {type: commentTypes.HIDE_BANUSER_DIALOG, showDialog}; -}; diff --git a/client/coral-admin/src/actions/moderation.js b/client/coral-admin/src/actions/moderation.js new file mode 100644 index 000000000..b332b9086 --- /dev/null +++ b/client/coral-admin/src/actions/moderation.js @@ -0,0 +1,9 @@ +import * as actions from 'constants/moderation'; + +export const setActiveTab = activeTab => ({type: actions.SET_ACTIVE_TAB, activeTab}); +export const toggleModal = open => ({type: actions.TOGGLE_MODAL, open}); +export const singleView = () => ({type: actions.SINGLE_VIEW}); + +// Ban User Dialog +export const showBanUserDialog = (user, commentId) => ({type: actions.SHOW_BANUSER_DIALOG, user, commentId}); +export const hideBanUserDialog = (showDialog) => ({type: actions.HIDE_BANUSER_DIALOG, showDialog}); diff --git a/client/coral-admin/src/components/ActionButton.js b/client/coral-admin/src/components/ActionButton.js index 14b7fe95d..1d1d4d9d0 100644 --- a/client/coral-admin/src/components/ActionButton.js +++ b/client/coral-admin/src/components/ActionButton.js @@ -1,48 +1,22 @@ import React from 'react'; import styles from './ModerationList.css'; -import I18n from 'coral-framework/modules/i18n/i18n'; -import translations from '../translations.json'; -import {FabButton, Button, Icon} from 'coral-ui'; +import BanUserButton from './BanUserButton'; +import {FabButton} from 'coral-ui'; +import {menuActionsMap} from '../containers/ModerationQueue/helpers/moderationQueueActionsMap'; -const ActionButton = ({option, type, comment = {}, user, menuOptionsMap, onClickAction, onClickShowBanDialog}) => -{ - const banned = user.status === 'BANNED'; +const ActionButton = ({type = '', user, ...props}) => { + if (type === 'BAN') { + return props.showBanUserDialog(props.user, props.id)} />; + } - if (option === 'flag' && (type === 'USERS' || comment.status || comment.flagged === true)) { - return null; - } - if (option === 'ban') { - return ( -
- -
- ); - } - const menuOption = menuOptionsMap[option]; - const action = { - item_type: type, - item_id: type === 'COMMENTS' ? comment.id : user.id - }; return ( onClickAction(menuOption.status, type === 'COMMENTS' ? comment : user, action)} + className={`${type.toLowerCase()} ${styles.actionButton}`} + cStyle={type.toLowerCase()} + icon={menuActionsMap[type].icon} + onClick={type === 'APPROVE' ? props.acceptComment : props.rejectComment} /> ); }; export default ActionButton; - -const lang = new I18n(translations); diff --git a/client/coral-admin/src/components/BanUserButton.css b/client/coral-admin/src/components/BanUserButton.css new file mode 100644 index 000000000..79b805c30 --- /dev/null +++ b/client/coral-admin/src/components/BanUserButton.css @@ -0,0 +1,10 @@ +.banButton { + width: 114px; + letter-spacing: 1px; + + i { + vertical-align: middle; + margin-right: 10px; + font-size: 14px; + } +} diff --git a/client/coral-admin/src/components/BanUserButton.js b/client/coral-admin/src/components/BanUserButton.js new file mode 100644 index 000000000..191164ca5 --- /dev/null +++ b/client/coral-admin/src/components/BanUserButton.js @@ -0,0 +1,26 @@ +import React, {PropTypes} from 'react'; +import {Button, Icon} from 'coral-ui'; +import styles from './BanUserButton.css'; + +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from 'coral-admin/src/translations.json'; +const lang = new I18n(translations); + +const BanUserButton = ({user, ...props}) => ( +
+ +
+); + +BanUserButton.propTypes = { + onClick: PropTypes.func.isRequired +}; + +export default BanUserButton; diff --git a/client/coral-admin/src/components/BanUserDialog.js b/client/coral-admin/src/components/BanUserDialog.js index 0ad149fbe..b23d8f9d1 100644 --- a/client/coral-admin/src/components/BanUserDialog.js +++ b/client/coral-admin/src/components/BanUserDialog.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {PropTypes} from 'react'; import {Dialog} from 'coral-ui'; import styles from './BanUserDialog.css'; @@ -8,34 +8,28 @@ import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../translations'; const lang = new I18n(translations); -const BanUserDialog = ({open, handleClose, onClickBanUser, user = {}}) => ( +const BanUserDialog = ({open, handleClose, handleBanUser, user}) => ( handleClose()} - onCancel={() => handleClose()} + onClose={handleClose} + onCancel={handleClose} title={lang.t('bandialog.ban_user')}> ×
-

- {lang.t('bandialog.ban_user')} -

+

{lang.t('bandialog.ban_user')}

-

- {lang.t('bandialog.are_you_sure', user.userName)} -

- - {lang.t('bandialog.note')} - +

{lang.t('bandialog.are_you_sure', user.name)}

+ {lang.t('bandialog.note')}
- -
@@ -43,4 +37,10 @@ const BanUserDialog = ({open, handleClose, onClickBanUser, user = {}}) => (
); +BanUserDialog.propTypes = { + handleBanUser: PropTypes.func.isRequired, + handleClose: PropTypes.func.isRequired, + user: PropTypes.object.isRequired, +}; + export default BanUserDialog; diff --git a/client/coral-admin/src/components/ModerationList.css b/client/coral-admin/src/components/ModerationList.css index fbcbce7d7..140f4bc8f 100644 --- a/client/coral-admin/src/components/ModerationList.css +++ b/client/coral-admin/src/components/ModerationList.css @@ -96,11 +96,6 @@ margin-left: 40px; } - .actionButton { - transform: scale(.8); - margin: 0; - } - .body { margin-top: 20px; flex: 1; @@ -182,3 +177,9 @@ font-size: 14px; } } + + +.actionButton { + transform: scale(.8); + margin: 0; +} diff --git a/client/coral-admin/src/components/User.js b/client/coral-admin/src/components/User.js index 6b08ed76a..129524993 100644 --- a/client/coral-admin/src/components/User.js +++ b/client/coral-admin/src/components/User.js @@ -25,9 +25,8 @@ const User = props => { {props.modActions.map( (action, i) => ( !restricted ?
- {lang.t('configure.moderate')} + + {lang.t('configure.streams')} + {lang.t('configure.community')} @@ -25,10 +29,6 @@ export default ({handleLogout, restricted = false}) => ( activeClassName={styles.active}> {lang.t('configure.configure')} - - {lang.t('configure.streams')} -
    diff --git a/client/coral-admin/src/constants/assets.js b/client/coral-admin/src/constants/assets.js index 0a2ecf73c..20ec0a9c3 100644 --- a/client/coral-admin/src/constants/assets.js +++ b/client/coral-admin/src/constants/assets.js @@ -1,6 +1,9 @@ export const FETCH_ASSETS_REQUEST = 'FETCH_ASSETS_REQUEST'; export const FETCH_ASSETS_SUCCESS = 'FETCH_ASSETS_SUCCESS'; export const FETCH_ASSETS_FAILURE = 'FETCH_ASSETS_FAILURE'; + export const UPDATE_ASSET_STATE_REQUEST = 'UPDATE_ASSET_STATE_REQUEST'; export const UPDATE_ASSET_STATE_SUCCESS = 'UPDATE_ASSET_STATE_SUCCESS'; export const UPDATE_ASSET_STATE_FAILURE = 'UPDATE_ASSET_STATE_FAILURE'; + +export const UPDATE_ASSETS = 'UPDATE_ASSETS'; diff --git a/client/coral-admin/src/constants/moderation.js b/client/coral-admin/src/constants/moderation.js new file mode 100644 index 000000000..10c6a7c4c --- /dev/null +++ b/client/coral-admin/src/constants/moderation.js @@ -0,0 +1,5 @@ +export const SET_ACTIVE_TAB = 'SET_ACTIVE_TAB'; +export const TOGGLE_MODAL = 'TOGGLE_MODAL'; +export const SINGLE_VIEW = 'SINGLE_VIEW'; +export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG'; +export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG'; diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js index 327913927..5a11ef425 100644 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js @@ -1,42 +1,33 @@ -import React from 'react'; +import React, {Component} from 'react'; import {connect} from 'react-redux'; +import {compose} from 'react-apollo'; import key from 'keymaster'; +import isEqual from 'lodash/isEqual'; + +import {modQueueQuery} from '../../graphql/queries'; +import {banUser, setCommentStatus} from '../../graphql/mutations'; -import { - updateStatus, - showBanUserDialog, - hideBanUserDialog, - fetchPremodQueue, - fetchRejectedQueue, - fetchFlaggedQueue, - fetchModerationQueueComments, -} from 'actions/comments'; -import {userStatusUpdate, sendNotificationEmail, enableUsernameEdit} from 'actions/users'; import {fetchSettings} from 'actions/settings'; +import {updateAssets} from 'actions/assets'; +import {setActiveTab, toggleModal, singleView, showBanUserDialog, hideBanUserDialog} from 'actions/moderation'; +import {Spinner} from 'coral-ui'; +import BanUserDialog from '../../components/BanUserDialog'; import ModerationQueue from './ModerationQueue'; +import ModerationMenu from './components/ModerationMenu'; +import ModerationHeader from './components/ModerationHeader'; +import NotFoundAsset from './components/NotFoundAsset'; -class ModerationContainer extends React.Component { - - constructor(props) { - super(props); - - this.state = { - activeTab: 'all', - singleView: false, - modalOpen: false - }; - - this.onClose = this.onClose.bind(this); - this.onTabClick = this.onTabClick.bind(this); - } +class ModerationContainer extends Component { componentWillMount() { - this.props.fetchModerationQueueComments(); + const {toggleModal, singleView} = this.props; + this.props.fetchSettings(); - key('s', () => this.setState({singleView: !this.state.singleView})); - key('shift+/', () => this.setState({modalOpen: true})); - key('esc', () => this.setState({modalOpen: false})); + + key('s', () => singleView()); + key('shift+/', () => toggleModal(true)); + key('esc', () => toggleModal(false)); } componentWillUnmount() { @@ -45,90 +36,85 @@ class ModerationContainer extends React.Component { key.unbind('esc'); } - componentDidMount() { - - // Hack for dynamic mdl tabs - if (typeof componentHandler !== 'undefined') { - - // FIXME: fix this hack - componentHandler.upgradeAllRegistered(); // eslint-disable-line no-undef + componentWillReceiveProps(nextProps) { + const {updateAssets} = this.props; + if(!isEqual(nextProps.data.assets, this.props.data.assets)) { + updateAssets(nextProps.data.assets); } } - onTabClick(activeTab) { - this.setState({activeTab}); - - if (activeTab === 'premod') { - this.props.fetchPremodQueue(); - } else if (activeTab === 'rejected') { - this.props.fetchRejectedQueue(); - } else if (activeTab === 'flagged') { - this.props.fetchFlaggedQueue(); - } else { - this.props.fetchModerationQueueComments(); - } - } - - onClose() { - this.setState({modalOpen: false}); - } - render () { - const {comments, actions, settings} = this.props; - const premodIds = comments.ids.filter(id => comments.byId[id].status === 'PREMOD'); - const rejectedIds = comments.ids.filter(id => comments.byId[id].status === 'REJECTED'); - const flaggedIds = comments.ids.filter(id => - comments.byId[id].flagged === true && - comments.byId[id].status !== 'REJECTED' && - comments.byId[id].status !== 'ACCEPTED' - ); - const userActionIds = actions.ids.filter(id => actions.byId[id].item_type === 'USERS'); + const {data, moderation, settings, assets, ...props} = this.props; + const providedAssetId = this.props.params.id; + let asset; - // show the Pre-Mod tab if premod is enabled globally OR there are pre-mod comments in the db. - let enablePremodTab = (settings.settings && settings.settings.moderation === 'PRE') || premodIds.length; + if (data.loading) { + return
    ; + } + if (data.error) { + console.log(data); + return
    Error
    ; + } + + if (providedAssetId) { + asset = assets.find(asset => asset.id === this.props.params.id); + + if (!asset) { + return ; + } + } + + const enablePremodTab = !!data.premod.length; return ( - +
    + + + + +
    ); } } const mapStateToProps = state => ({ - comments: state.comments.toJS(), + moderation: state.moderation.toJS(), settings: state.settings.toJS(), - users: state.users.toJS(), - actions: state.actions.toJS(), + assets: state.assets.get('assets') }); -const mapDispatchToProps = dispatch => { - return { - fetchSettings: () => dispatch(fetchSettings()), - fetchModerationQueueComments: () => dispatch(fetchModerationQueueComments()), - fetchPremodQueue: () => dispatch(fetchPremodQueue()), - fetchRejectedQueue: () => dispatch(fetchRejectedQueue()), - fetchFlaggedQueue: () => dispatch(fetchFlaggedQueue()), - showBanUserDialog: (userId, userName, commentId) => dispatch(showBanUserDialog(userId, userName, commentId)), - hideBanUserDialog: () => dispatch(hideBanUserDialog(false)), - userStatusUpdate: (status, userId, commentId) => dispatch(userStatusUpdate(status, userId, commentId)).then(() => { - dispatch(fetchModerationQueueComments()); - }), - suspendUser: (userId, subject, text) => dispatch(userStatusUpdate('BANNED', userId)) - .then(() => dispatch(enableUsernameEdit(userId))) - .then(() => dispatch(sendNotificationEmail(userId, subject, text))) - .then(() => dispatch(fetchModerationQueueComments())) - , - updateStatus: (action, comment) => dispatch(updateStatus(action, comment)) - }; -}; +const mapDispatchToProps = dispatch => ({ + onTabClick: activeTab => dispatch(setActiveTab(activeTab)), + toggleModal: toggle => dispatch(toggleModal(toggle)), + onClose: () => dispatch(toggleModal(false)), + singleView: () => dispatch(singleView()), + updateAssets: assets => dispatch(updateAssets(assets)), + fetchSettings: () => dispatch(fetchSettings()), + showBanUserDialog: (user, commentId) => dispatch(showBanUserDialog(user, commentId)), + hideBanUserDialog: () => dispatch(hideBanUserDialog(false)), +}); -export default connect(mapStateToProps, mapDispatchToProps)(ModerationContainer); +export default compose( + connect(mapStateToProps, mapDispatchToProps), + setCommentStatus, + modQueueQuery, + banUser +)(ModerationContainer); diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.css b/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.css deleted file mode 100644 index 84ee8b923..000000000 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.css +++ /dev/null @@ -1,53 +0,0 @@ -@custom-media --big-viewport (min-width: 780px); - -.listContainer { - max-width: 860px; - margin: 0 auto; -} - -.tabBar { - background: #262626; - z-index: 5; -} - -.tab { - flex: 1; - color: white; - text-transform: capitalize; - font-weight: 500; - font-size: 15px; - letter-spacing: 1px; - transition: border-bottom 200ms; -} - -.active { - color: white; - box-sizing: border-box; - border-bottom: solid 5px #F36451; -} - -.active > span { - color: white; -} - -.active:after { - background: transparent !important; -} - -.showShortcuts { - position: absolute; - right: 130px; - display: flex; - align-items: center; - font-size: 13px; - - span { - margin-left: 7px; - } -} - -@media (--big-viewport) { - .tab { - flex: none; - } -} diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js b/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js index 09b02daed..052138590 100644 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js @@ -1,212 +1,34 @@ import React, {PropTypes} from 'react'; -import styles from './ModerationQueue.css'; -import ModerationKeysModal from 'components/ModerationKeysModal'; -import ModerationList from 'components/ModerationList'; -import BanUserDialog from 'components/BanUserDialog'; +import Comment from './components/Comment'; +import {actionsMap} from './helpers/moderationQueueActionsMap'; -import I18n from 'coral-framework/modules/i18n/i18n'; -import translations from '../../translations.json'; - -const lang = new I18n(translations); - -const ModerationQueue = (props) => ( -
    -
    -
    - { - e.preventDefault(); - props.onTabClick('all'); - }} - className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'all' ? styles.active : ''}`} - > - {lang.t('modqueue.all')} - - { - props.enablePremodTab - ? { - e.preventDefault(); - props.onTabClick('premod'); - }} - className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'premod' ? styles.active : ''}`}> - {lang.t('modqueue.premod')} - - : null - } - { - e.preventDefault(); - props.onTabClick('account'); - }} - className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'account' ? styles.active : ''}`}> - {lang.t('modqueue.account')} - - { - e.preventDefault(); - props.onTabClick('rejected'); - }} - className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'rejected' ? styles.active : ''}`} - > - {lang.t('modqueue.rejected')} - - { - e.preventDefault(); - props.onTabClick('flagged'); - }} - className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'flagged' ? styles.active : ''}`} - > - {lang.t('modqueue.flagged')} - -
    -
    - { - props.activeTab === 'all' && -
    - - -
    - } -
    +const ModerationQueue = props => { + return ( +
    +
      { - props.enablePremodTab - ?
      - { - props.activeTab === 'premod' && -
      - - -
      - } -
      - : null + props.data[props.activeTab].map((comment, i) => { + return ; + }) } - -
      - { - props.activeTab === 'account' && -
      - - -
      - } -
      -
      - { - props.activeTab === 'flagged' && -
      - - -
      - } -
      -
      - { - props.activeTab === 'rejected' && -
      - - -
      - } -
      - - +
    -
    -); + ); +}; ModerationQueue.propTypes = { - enablePremodTab: PropTypes.bool.isRequired + data: PropTypes.object.isRequired }; export default ModerationQueue; diff --git a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js new file mode 100644 index 000000000..ae70b303a --- /dev/null +++ b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js @@ -0,0 +1,79 @@ +import React from 'react'; +import timeago from 'timeago.js'; +import Linkify from 'react-linkify'; +import Highlighter from 'react-highlight-words'; +import {Link} from 'react-router'; + +import styles from './styles.css'; +import {Icon} from 'coral-ui'; +import ActionButton from '../../../components/ActionButton'; +import FlagBox from './FlagBox'; + +const linkify = new Linkify(); + +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from 'coral-admin/src/translations.json'; +const lang = new I18n(translations); + +const Comment = ({actions = [], ...props}) => { + const links = linkify.getMatches(props.comment.body); + const actionSumaries = props.comment.action_summaries; + return ( +
  • +
    +
    + {props.comment.user.name} + + {timeago().format(props.comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))} + + {props.comment.action_summaries ?

    {lang.t('comment.flagged')}

    : null} +
    +
    + {links ? Contains Link : null} +
    + {actions.map((action, i) => + props.acceptComment({commentId: props.comment.id})} + rejectComment={() => props.rejectComment({commentId: props.comment.id})} + showBanUserDialog={() => props.showBanUserDialog(props.comment.user, props.comment.id)} + /> + )} +
    + {props.comment.user.banned === 'banned' ? + + + {lang.t('comment.banned_user')} + + : null} +
    +
    + {!props.currentAsset && ( +
    + Article: {props.comment.asset.title} Moderate Article +
    + )} +
    +

    + + + +

    +
    + {actionSumaries && } + + {/* */} + {/* View context*/} + {/* */} +
  • + ); +}; + +const linkStyles = { + backgroundColor: 'rgb(255, 219, 135)', + padding: '1px 2px' +}; + +export default Comment; diff --git a/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.js b/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.js new file mode 100644 index 000000000..bf5a34f29 --- /dev/null +++ b/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.js @@ -0,0 +1,19 @@ +import React, {PropTypes} from 'react'; +import styles from './styles.css'; + +const FlagBox = props => ( +
    +

    Flags:

    +
      + {props.actionSumaries.map((action, i) => +
    • {!action.reason ? No reason provided : action.reason} ({action.count})
    • + )} +
    +
    +); + +FlagBox.propTypes = { + actionSumaries: PropTypes.array.isRequired +}; + +export default FlagBox; diff --git a/client/coral-admin/src/containers/ModerationQueue/components/ModerationHeader.js b/client/coral-admin/src/containers/ModerationQueue/components/ModerationHeader.js new file mode 100644 index 000000000..474d8fd27 --- /dev/null +++ b/client/coral-admin/src/containers/ModerationQueue/components/ModerationHeader.js @@ -0,0 +1,25 @@ +import React from 'react'; +import {Link} from 'react-router'; +import styles from './styles.css'; + +const ModerationHeader = props => ( +
    +
    + { + props.asset ? +
    + All Streams + {props.asset.title} + Select Stream +
    + : +
    + + All Streams + Select Stream +
    + } +
    +
    +); +export default ModerationHeader; diff --git a/client/coral-admin/src/containers/ModerationQueue/components/ModerationMenu.js b/client/coral-admin/src/containers/ModerationQueue/components/ModerationMenu.js new file mode 100644 index 000000000..51605c443 --- /dev/null +++ b/client/coral-admin/src/containers/ModerationQueue/components/ModerationMenu.js @@ -0,0 +1,59 @@ +import React, {PropTypes} from 'react'; +import styles from './styles.css'; +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from 'coral-admin/src/translations.json'; + +const lang = new I18n(translations); + +const ModerationMenu = (props) => ( +
    +
    + { + e.preventDefault(); + props.onTabClick('all'); + }} + className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'all' ? styles.active : ''}`} + > + {lang.t('modqueue.all')} + + { + props.enablePremodTab + ? { + e.preventDefault(); + props.onTabClick('premod'); + }} + className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'premod' ? styles.active : ''}`}> + {lang.t('modqueue.premod')} + + : null + } + { + e.preventDefault(); + props.onTabClick('rejected'); + }} + className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'rejected' ? styles.active : ''}`} + > + {lang.t('modqueue.rejected')} + + { + e.preventDefault(); + props.onTabClick('flagged'); + }} + className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'flagged' ? styles.active : ''}`} + > + {lang.t('modqueue.flagged')} + +
    +
    +); + +ModerationMenu.propTypes = { + activeTab: PropTypes.string.isRequired, + enablePremodTab: PropTypes.bool +}; + +export default ModerationMenu; diff --git a/client/coral-admin/src/containers/ModerationQueue/components/NotFoundAsset.js b/client/coral-admin/src/containers/ModerationQueue/components/NotFoundAsset.js new file mode 100644 index 000000000..ffa1adfcd --- /dev/null +++ b/client/coral-admin/src/containers/ModerationQueue/components/NotFoundAsset.js @@ -0,0 +1,14 @@ +import React from 'react'; +import {Link} from 'react-router'; +import styles from './styles.css'; + +const NotFound = props => ( +
    +

    + The provided asset id {props.assetId} does not exist. + Go to Streams +

    +
    +); + +export default NotFound; diff --git a/client/coral-admin/src/containers/ModerationQueue/components/styles.css b/client/coral-admin/src/containers/ModerationQueue/components/styles.css new file mode 100644 index 000000000..658c9a059 --- /dev/null +++ b/client/coral-admin/src/containers/ModerationQueue/components/styles.css @@ -0,0 +1,327 @@ +@custom-media --big-viewport (min-width: 780px); + +.listContainer { + max-width: 860px; + margin: 0 auto; +} + +.tabBar { + background-color: rgba(44, 44, 44, 0.89); + z-index: 5; +} + +.tab { + flex: 1; + color: white; + text-transform: capitalize; + font-weight: 500; + font-size: 15px; + letter-spacing: 1px; + transition: border-bottom 200ms; +} + +.active { + color: white; + box-sizing: border-box; + border-bottom: solid 5px #F36451; +} + +.active > span { + color: white; +} + +.active:after { + background: transparent !important; +} + +.showShortcuts { + position: absolute; + right: 130px; + display: flex; + align-items: center; + font-size: 13px; + +span { + margin-left: 7px; +} +} + +@media (--big-viewport) { + .tab { + flex: none; + } +} + +.notFound { + position: relative; + margin: 20px auto; + text-align: center; + padding: 68px 45px; + vertical-align: middle; + min-width: 500px; + + a { + color: rgb(244, 126, 107); + font-weight: 500; + + &.goToStreams { + position: absolute; + right: 10px; + bottom: 10px; + } + } +} + +.header { + background-color: #2c2c2c; + color: white; + margin-bottom: -1px; + + .moderateAsset { + a { + -webkit-box-flex: 1; + -ms-flex: 1; + flex: 1; + color: white; + text-transform: capitalize; + font-weight: 500; + font-size: 15px; + letter-spacing: 1px; + transition: opacity 200ms; + opacity: 1; + + &:hover { + opacity: .8; + cursor: pointer; + } + + &:first-child { + text-align: left; + } + + &:nth-child(2) { + text-align: center; + } + + &:last-child { + text-align: right; + } + } + } +} + + +@custom-media --big-viewport (min-width: 780px); + +.list { + padding: 8px 0; + list-style: none; + display: block; + + &.singleView .listItem { + display: none; + } + + &.singleView .listItem.activeItem { + display: block; + height: 100%; + font-size: 1.5em; + line-height: 1.5em; + border: none; + + .actions { + position: fixed; + bottom: 60px; + left: 25%; + margin: 0 auto; + display: flex; + justify-content: space-around; + width: 50%; + margin: 0; + } + + .actionButton { + transform: scale(1.4); + } + } +} + +.listItem { + border-bottom: 1px solid #e0e0e0; + font-size: 16px; + width: 100%; + max-width: 660px; + min-width: 400px; + margin: 0 auto; + padding: 16px 14px; + position: relative; + transition: box-shadow 200ms; + margin-top: 0; + + + &: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; + } + + .context { + a { + color: #f36451; + text-decoration: underline; + float: right; + } + } + + .sideActions { + position: absolute; + right: 0; + height: 100%; + top: 0; + padding: 40px 18px; + box-sizing: border-box; + } + + .itemHeader { + display: flex; + align-items: center; + justify-content: space-between; + + .author { + min-width: 230px; + display: flex; + align-items: center; + } + } + + .itemBody { + display: flex; + justify-content: space-between; + } + + .avatar { + margin-right: 16px; + height: 40px; + width: 40px; + border-radius: 50%; + background-color: #757575; + font-size: 40px; + color: #fff; + } + + .created { + color: #666; + font-size: 13px; + margin-left: 40px; + } + + .actionButton { + transform: scale(.8); + margin: 0; + } + + .body { + margin-top: 0px; + flex: 1; + font-size: 0.88em; + color: black; + max-width: 500px; + word-wrap: break-word; + } + + .flagged { + color: rgba(255, 0, 0, .5); + padding-top: 15px; + padding-left: 10px; + } + + .flagCount{ + font-size: 12px; + color: #d32f2f; + } + +} + +.empty { + color: #444; + margin-top: 50px; + text-align: center; +} + + +@media (--big-viewport) { + .listItem { + border: 1px solid #e0e0e0; + margin-bottom: 30px; + + &:last-child { + border-bottom: 1px solid #e0e0e0; + } + + &.activeItem { + border: 2px solid #333; + } + } + +} + +.hasLinks { + color: #f00; + text-align: right; + display: flex; + align-items: center; + + i { + margin-right: 5px; + } +} + +.banned { + color: #f00; + text-align: left; + display: flex; + align-items: center; + + i { + margin-right: 5px; + } +} + +.ban { + display: block; + text-align: center; + margin-top: 5px; +} + +.Comment { + .moderateArticle { + font-size: 12px; + a { + display: inline-block; + color: #679af3; + text-decoration: none; + font-size: 1em; + font-weight: 400; + letter-spacing: .5px; + font-size: 12px; + margin-left: 10px; + + &:hover { + text-decoration: underline; + opacity: .9; + cursor: pointer; + } + } + } +} + +.flagBox { + border-top: 1px solid rgba(66, 66, 66, 0.12); + h3 { + font-size: 14px; + margin: 0; + font-weight: 500; + } +} diff --git a/client/coral-admin/src/containers/ModerationQueue/helpers/moderationQueueActionsMap.js b/client/coral-admin/src/containers/ModerationQueue/helpers/moderationQueueActionsMap.js new file mode 100644 index 000000000..0da93e898 --- /dev/null +++ b/client/coral-admin/src/containers/ModerationQueue/helpers/moderationQueueActionsMap.js @@ -0,0 +1,13 @@ +export const actionsMap = { + PREMOD: ['REJECT', 'APPROVE', 'BAN'], + FLAGGED: ['REJECT', 'APPROVE'], + REJECTED: ['APPROVE'] +}; + +export const menuActionsMap = { + 'REJECT': {status: 'REJECTED', icon: 'close', key: 'r'}, + 'APPROVE': {status: 'ACCEPTED', icon: 'done', key: 't'}, + 'FLAGGED': {status: 'FLAGGED', icon: 'flag', filter: 'Untouched'}, + 'BAN': {status: 'BANNED', icon: 'not interested'}, + '': {icon: 'done'} +}; diff --git a/client/coral-admin/src/containers/Streams/Streams.css b/client/coral-admin/src/containers/Streams/Streams.css index 26fb4f74f..d8a75a4ce 100644 --- a/client/coral-admin/src/containers/Streams/Streams.css +++ b/client/coral-admin/src/containers/Streams/Streams.css @@ -55,6 +55,12 @@ border-left: none; border-right: none; + a { + color: rgb(44, 44, 44); + font-weight: 500; + text-decoration: none; + } + th { font-size: 1.1em; } diff --git a/client/coral-admin/src/containers/Streams/Streams.js b/client/coral-admin/src/containers/Streams/Streams.js index 1937f82c0..62f2f009e 100644 --- a/client/coral-admin/src/containers/Streams/Streams.js +++ b/client/coral-admin/src/containers/Streams/Streams.js @@ -4,14 +4,10 @@ import {connect} from 'react-redux'; import I18n from 'coral-framework/modules/i18n/i18n'; import {fetchAssets, updateAssetState} from '../../actions/assets'; import translations from '../../translations.json'; -import { - RadioGroup, - Radio, - Icon, - DataTable, - TableHeader -} from 'react-mdl'; -import Pager from 'coral-ui/components/Pager'; +import {Link} from 'react-router'; + +import {Pager, Icon} from 'coral-ui'; +import {DataTable, TableHeader, RadioGroup, Radio} from 'react-mdl'; const limit = 25; @@ -74,6 +70,8 @@ class Streams extends Component { } } + renderTitle = (title, {id}) => {title} + renderStatus = (closedAt, {id}) => { const closed = closedAt && new Date(closedAt).getTime() < Date.now(); const statusMenuOpen = this.state.statusMenus[id]; @@ -104,6 +102,9 @@ class Streams extends Component { render () { const {search, sort, filter} = this.state; const {assets} = this.props; + + const assetsIds = assets.ids.map((id) => assets.byId[id]); + return (
    @@ -142,16 +143,14 @@ class Streams extends Component {
    - assets.byId[id])}> - {lang.t('streams.article')} - - {lang.t('streams.pubdate')} - - - {lang.t('streams.status')} - + + {lang.t('streams.article')} + + {lang.t('streams.pubdate')} + + + {lang.t('streams.status')} + { assets: assets.toJS() }; }; + const mapDispatchToProps = (dispatch) => { return { fetchAssets: (...args) => { diff --git a/client/coral-admin/src/graphql/fragments/commentView.graphql b/client/coral-admin/src/graphql/fragments/commentView.graphql new file mode 100644 index 000000000..e78c28a28 --- /dev/null +++ b/client/coral-admin/src/graphql/fragments/commentView.graphql @@ -0,0 +1,15 @@ +fragment commentView on Comment { + id + body + created_at + status + user { + id + name: username + status + } + asset { + id + title + } +} diff --git a/client/coral-admin/src/graphql/mutations/index.js b/client/coral-admin/src/graphql/mutations/index.js new file mode 100644 index 000000000..fe3a1faf9 --- /dev/null +++ b/client/coral-admin/src/graphql/mutations/index.js @@ -0,0 +1,38 @@ +import {graphql} from 'react-apollo'; +import SET_USER_STATUS from './setUserStatus.graphql'; +import SET_COMMENT_STATUS from './setCommentStatus.graphql'; + +export const banUser = graphql(SET_USER_STATUS, { + props: ({mutate}) => ({ + banUser: ({userId}) => { + return mutate({ + variables: { + userId, + status: 'BANNED' + } + }); + }}), +}); + +export const setCommentStatus = graphql(SET_COMMENT_STATUS, { + props: ({mutate}) => ({ + acceptComment: ({commentId}) => { + return mutate({ + variables: { + commentId, + status: 'ACCEPTED' + }, + refetchQueries: ['ModQueue'] + }); + }, + rejectComment: ({commentId}) => { + return mutate({ + variables: { + commentId, + status: 'REJECTED' + }, + refetchQueries: ['ModQueue'] + }); + } + }) +}); diff --git a/client/coral-admin/src/graphql/mutations/setCommentStatus.graphql b/client/coral-admin/src/graphql/mutations/setCommentStatus.graphql new file mode 100644 index 000000000..7ff6173a8 --- /dev/null +++ b/client/coral-admin/src/graphql/mutations/setCommentStatus.graphql @@ -0,0 +1,7 @@ +mutation setCommentStatus($commentId: ID!, $status: COMMENT_STATUS!){ + setCommentStatus(id: $commentId, status: $status) { + errors { + translation_key + } + } +} diff --git a/client/coral-admin/src/graphql/mutations/setUserStatus.graphql b/client/coral-admin/src/graphql/mutations/setUserStatus.graphql new file mode 100644 index 000000000..32fcf7e20 --- /dev/null +++ b/client/coral-admin/src/graphql/mutations/setUserStatus.graphql @@ -0,0 +1,7 @@ +mutation setUserStatus($userId: ID!, $status: USER_STATUS!) { + setUserStatus(id: $userId, status: $status) { + errors { + translation_key + } + } +} diff --git a/client/coral-admin/src/graphql/queries/assetsQuery.graphql b/client/coral-admin/src/graphql/queries/assetsQuery.graphql new file mode 100644 index 000000000..37950692d --- /dev/null +++ b/client/coral-admin/src/graphql/queries/assetsQuery.graphql @@ -0,0 +1,6 @@ +query Assets { + assets { + id + title + } +} diff --git a/client/coral-admin/src/graphql/queries/index.js b/client/coral-admin/src/graphql/queries/index.js new file mode 100644 index 000000000..fc59f8f84 --- /dev/null +++ b/client/coral-admin/src/graphql/queries/index.js @@ -0,0 +1,12 @@ +import {graphql} from 'react-apollo'; +import MOD_QUEUE_QUERY from './modQueueQuery.graphql'; + +export const modQueueQuery = graphql(MOD_QUEUE_QUERY, { + options: ({params: {id = ''}}) => { + return { + variables: { + asset_id: id + } + }; + } +}); diff --git a/client/coral-admin/src/graphql/queries/modQueueQuery.graphql b/client/coral-admin/src/graphql/queries/modQueueQuery.graphql new file mode 100644 index 000000000..9eba7a971 --- /dev/null +++ b/client/coral-admin/src/graphql/queries/modQueueQuery.graphql @@ -0,0 +1,38 @@ +#import "../fragments/commentView.graphql" + +query ModQueue ($asset_id: ID!) { + all: comments(query: { + statuses: [REJECTED, PREMOD], + asset_id: $asset_id + }) { + ...commentView + } + premod: comments(query: { + statuses: [PREMOD], + asset_id: $asset_id + }) { + ...commentView + } + flagged: comments(query: { + action_type: FLAG, + asset_id: $asset_id + }) { + ...commentView + action_summaries { + count + ... on FlagActionSummary { + reason + } + } + } + rejected: comments(query: { + statuses: [REJECTED], + asset_id: $asset_id + }) { + ...commentView + } + assets: assets { + id + title + } +} diff --git a/client/coral-admin/src/reducers/actions.js b/client/coral-admin/src/reducers/actions.js deleted file mode 100644 index 284e41c72..000000000 --- a/client/coral-admin/src/reducers/actions.js +++ /dev/null @@ -1,27 +0,0 @@ -import {Map, Set, fromJS} from 'immutable'; -import * as types from '../constants/actions'; - -const initialState = Map({ - ids: Set(), - byId: Map() -}); - -export default (state = initialState, action) => { - switch (action.type) { - case types.ACTIONS_MODERATION_QUEUE_FETCH_SUCCESS: return addActions(state, action); - default: - return state; - } -}; - -const addActions = (state, action) => { - - // Make ids that are unique by item_id and by action type - const typeId = (action) => `${action.action_type}_${action.item_id}`; - const ids = action.actions.map(action => typeId(action)); - const map = action.actions.reduce((memo, action) => { - memo[typeId(action)] = action; - return memo; - }, {}); - return state.set('byId', fromJS(map)).set('ids', new Set(ids)); -}; diff --git a/client/coral-admin/src/reducers/assets.js b/client/coral-admin/src/reducers/assets.js index 77d0bf081..c9a82f1c5 100644 --- a/client/coral-admin/src/reducers/assets.js +++ b/client/coral-admin/src/reducers/assets.js @@ -1,20 +1,26 @@ import {Map, List, fromJS} from 'immutable'; -import {FETCH_ASSETS_SUCCESS, UPDATE_ASSET_STATE_REQUEST} from '../constants/assets'; +import * as actions from '../constants/assets'; const initialState = Map({ byId: Map(), - ids: List() + ids: List(), + assets: List() }); -export default (state = initialState, action) => { +export default function assets (state = initialState, action) { switch (action.type) { - case FETCH_ASSETS_SUCCESS: + case actions.FETCH_ASSETS_SUCCESS: return replaceAssets(action, state); - case UPDATE_ASSET_STATE_REQUEST: - return state.setIn(['byId', action.id, 'closedAt'], action.closedAt); - default: return state; + case actions.UPDATE_ASSET_STATE_REQUEST: + return state + .setIn(['byId', action.id, 'closedAt'], action.closedAt); + case actions.UPDATE_ASSETS: + return state + .set('assets', List(action.assets)); + default: + return state; } -}; +} const replaceAssets = (action, state) => { const assets = fromJS(action.assets.reduce((prev, curr) => { prev[curr.id] = curr; return prev; }, {})); diff --git a/client/coral-admin/src/reducers/comments.js b/client/coral-admin/src/reducers/comments.js deleted file mode 100644 index 034016088..000000000 --- a/client/coral-admin/src/reducers/comments.js +++ /dev/null @@ -1,77 +0,0 @@ -import * as actions from '../constants/comments'; -import * as userActions from '../constants/users'; -import {Map, List, fromJS} from 'immutable'; - -/** - * Comments state is stored using 2 structures: - * - byId is a Map holding the comments using the item_id property as keys - * - ids is a List of item_id, this allows us to order and iterate easily - * since maps are unordered and some times we just need a list of things - */ - -const initialState = Map({ - byId: Map(), - ids: List(), - loading: false, - showBanUserDialog: false, - banUser: { - 'userName': '', - 'userId': '', - 'commentId': '' - } -}); - -// Handle the comment actions -export default (state = initialState, action) => { - switch (action.type) { - case actions.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST: return state.set('loading', true); - case actions.COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS: return replaceComments(action, state); - case actions.COMMENTS_MODERATION_QUEUE_FAILED: return state.set('loading', false); - case actions.COMMENT_STATUS_UPDATE_REQUEST: return updateStatus(state, action); - case actions.COMMENT_FLAG: return flag(state, action); - case actions.COMMENT_CREATE_SUCCESS: return addComment(state, action); - case actions.COMMENT_STREAM_FETCH_SUCCESS: return replaceComments(action, state); - case actions.SHOW_BANUSER_DIALOG: return setBanUser(state, true, action); - case actions.HIDE_BANUSER_DIALOG: return setBanUser(state, false, action); - case actions.USER_BAN_SUCCESS: return setBanUser(state, false, action); - case userActions.UPDATE_STATUS_SUCCESS: return setBanUser(state, false, action); - default: return state; - } -}; - -// hide or show the UI for the dialog confirming the ban -// set the user that is going to set and the comment that is the reason -const setBanUser = (state, showBanUser, action) => { - const banUser = {'userName': action.userName, 'userId': action.userId, 'commentId': action.commentId}; - return state.set('showBanUserDialog', showBanUser) - .set('banUser', banUser); -}; - -// Update a comment status -const updateStatus = (state, action) => { - const byId = state.get('byId'); - const data = byId.get(action.id).set('status', action.status.toLowerCase()); - return state.set('byId', byId.set(action.id, data)); -}; - -// Flag a comment -const flag = (state, action) => { - const byId = state.get('byId'); - const data = byId.get(action.id).set('flagged', true); - const comment = byId.get(action.id).set('data', data); - return state.set('byId', byId.set(action.id, comment)); -}; - -// Replace the comment list with a new one -const replaceComments = (action, state) => { - const comments = fromJS(action.comments.reduce((prev, curr) => { prev[curr.id] = curr; return prev; }, {})); - return state.set('byId', comments).set('loading', false) - .set('ids', List(comments.keys())); -}; - -// Add a new comment -const addComment = (state, action) => { - const comment = fromJS(action.comment); - return state.set('byId', state.get('byId').set(comment.get('item_id'), comment)) - .set('ids', state.get('ids').unshift(comment.get('item_id'))); -}; diff --git a/client/coral-admin/src/reducers/index.js b/client/coral-admin/src/reducers/index.js index f12de96ba..837ad507d 100644 --- a/client/coral-admin/src/reducers/index.js +++ b/client/coral-admin/src/reducers/index.js @@ -1,19 +1,15 @@ -import auth from 'reducers/auth'; -import users from 'reducers/users'; -import assets from 'reducers/assets'; -import actions from 'reducers/actions'; -import install from 'reducers/install'; -import comments from 'reducers/comments'; -import settings from 'reducers/settings'; -import community from 'reducers/community'; +import auth from './auth'; +import assets from './assets'; +import settings from './settings'; +import community from './community'; +import moderation from './moderation'; +import install from './install'; export default { - settings, - comments, - community, auth, - actions, assets, - users, + settings, + community, + moderation, install }; diff --git a/client/coral-admin/src/reducers/moderation.js b/client/coral-admin/src/reducers/moderation.js new file mode 100644 index 000000000..3131f6096 --- /dev/null +++ b/client/coral-admin/src/reducers/moderation.js @@ -0,0 +1,37 @@ +import {Map} from 'immutable'; +import * as actions from '../constants/moderation'; + +const initialState = Map({ + activeTab: 'all', + singleView: false, + modalOpen: false, + user: Map({}), + commentId: null, + banDialog: false +}); + +export default function moderation (state = initialState, action) { + switch (action.type) { + case actions.HIDE_BANUSER_DIALOG: + return state + .set('banDialog', false); + case actions.SHOW_BANUSER_DIALOG: + return state + .merge({ + user: Map(action.user), + commentId: action.commentId, + banDialog: true + }); + case actions.SET_ACTIVE_TAB: + return state + .set('activeTab', action.activeTab); + case actions.TOGGLE_MODAL: + return state + .set('modalOpen', action.open); + case actions.SINGLE_VIEW: + return state + .set('singleView', !state.get('singleView')); + default : + return state; + } +} diff --git a/client/coral-admin/src/reducers/settings.js b/client/coral-admin/src/reducers/settings.js index 4f743bc0a..70c52f028 100644 --- a/client/coral-admin/src/reducers/settings.js +++ b/client/coral-admin/src/reducers/settings.js @@ -1,5 +1,5 @@ import {Map, List} from 'immutable'; -import * as types from '../actions/settings'; +import * as actions from '../actions/settings'; const initialState = Map({ settings: Map({ @@ -16,48 +16,49 @@ const initialState = Map({ fetchingSettings: false }); -// Handle the comment actions -export default (state = initialState, action) => { +export default function settings (state = initialState, action) { switch (action.type) { - case types.SETTINGS_LOADING: return state.set('fetchingSettings', true).set('fetchSettingsError', null); - case types.SETTINGS_RECEIVED: return updateSettings(state, action); - case types.SETTINGS_FETCH_ERROR: return settingsFetchFailed(state, action); - case types.SETTINGS_UPDATED: return updateSettings(state, action); - case types.SAVE_SETTINGS_LOADING: return state.set('fetchingSettings', true).set('saveSettingsError', null); - case types.SAVE_SETTINGS_SUCCESS: return saveComplete(state, action); - case types.SAVE_SETTINGS_FAILED: return settingsSaveFailed(state, action); - case types.WORDLIST_UPDATED: return updateWordlist(state, action); - case types.DOMAINLIST_UPDATED: return updateDomainlist(state, action); - default: return state; + case actions.SETTINGS_LOADING: + return state + .set('fetchingSettings', true) + .set('fetchSettingsError', null); + case actions.SETTINGS_RECEIVED: + return state.merge({ + fetchingSettings: null, + fetchSettingsError: null, + ...action.settings + }); + case actions.SETTINGS_FETCH_ERROR: + return state + .set('fetchingSettings', false) + .set('fetchSettingsError', action.error); + case actions.SETTINGS_UPDATED: + return state.merge({ + fetchingSettings: null, + fetchSettingsError: null, + ...action.settings + }); + case actions.SAVE_SETTINGS_LOADING: + return state + .set('fetchingSettings', true) + .set('saveSettingsError', null); + case actions.SAVE_SETTINGS_SUCCESS: + return state.merge({ + fetchingSettings: false, + fetchSettingsError: null, + ...action.settings + }); + case actions.SAVE_SETTINGS_FAILED: + return state + .set('fetchingSettings', false) + .set('fetchSettingsError', action.error); + case actions.WORDLIST_UPDATED: + return state + .setIn(['settings', 'wordlist', action.listName], action.list); + case actions.DOMAINLIST_UPDATED: + return state + .setIn(['settings', 'domains', action.listName], action.list); + default: + return state; } -}; - -// only for updating top-level settings -const updateSettings = (state, action) => { - const s = state.set('fetchingSettings', false).set('fetchSettingsError', null); - const settings = s.get('settings').merge(action.settings); - return s.set('settings', settings); -}; - -// any nested settings must have a specialized setter -const updateWordlist = (state, action) => { - return state.setIn(['settings', 'wordlist', action.listName], action.list); -}; - -const updateDomainlist = (state, action) => { - return state.setIn(['settings', 'domains', action.listName], action.list); -}; - -const saveComplete = (state, action) => { - const s = state.set('fetchingSettings', false).set('saveSettingsError', null); - const settings = s.get('settings').merge(action.settings); - return s.set('settings', settings); -}; - -const settingsFetchFailed = (state, action) => { - return state.set('fetchingSettings', false).set('fetchSettingsError', action.error); -}; - -const settingsSaveFailed = (state, action) => { - return state.set('fetchingSettings', false).set('fetchSettingsError', action.error); -}; +} diff --git a/client/coral-admin/src/reducers/users.js b/client/coral-admin/src/reducers/users.js deleted file mode 100644 index ef589c155..000000000 --- a/client/coral-admin/src/reducers/users.js +++ /dev/null @@ -1,28 +0,0 @@ -import {Map, List, fromJS} from 'immutable'; - -const initialState = Map({ - byId: Map(), - ids: List() -}); - -export default (state = initialState, action) => { - switch (action.type) { - case 'USERS_MODERATION_QUEUE_FETCH_SUCCESS': return replaceUsers(action, state); - case 'USER_STATUS_UPDATE': return updateUserStatus(state, action); - default: return state; - } -}; - -// Replace the comment list with a new one -const replaceUsers = (action, state) => { - const users = fromJS(action.users.reduce((prev, curr) => { prev[curr.id] = curr; return prev; }, {})); - return state.set('byId', users) - .set('ids', List(users.keys())); -}; - -// Update a user status -const updateUserStatus = (state, action) => { - const byId = state.get('byId'); - const data = byId.get(action.author_id).set('status', action.status.toLowerCase()); - return state.set('byId', byId.set(action.author_id, data)); -}; diff --git a/client/coral-admin/src/services/PymConnection.js b/client/coral-admin/src/services/PymConnection.js new file mode 100644 index 000000000..ca592b824 --- /dev/null +++ b/client/coral-admin/src/services/PymConnection.js @@ -0,0 +1,9 @@ +import Pym from '../../node_modules/pym.js'; + +const pym = new Pym.Child({polling: 100}); +export default pym; + +export const link = (url) => (e) => { + e.preventDefault(); + pym.sendMessage('navigate', url); +}; diff --git a/client/coral-admin/src/services/client.js b/client/coral-admin/src/services/client.js index b4a7a38df..40a539634 100644 --- a/client/coral-admin/src/services/client.js +++ b/client/coral-admin/src/services/client.js @@ -2,7 +2,6 @@ import ApolloClient, {addTypename} from 'apollo-client'; import getNetworkInterface from './transport'; export const client = new ApolloClient({ - connectToDevTools: true, queryTransformer: addTypename, dataIdFromObject: (result) => { if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle diff --git a/client/coral-embed-stream/src/Embed.js b/client/coral-embed-stream/src/Embed.js index 405c75c5b..9fa4fc77c 100644 --- a/client/coral-embed-stream/src/Embed.js +++ b/client/coral-embed-stream/src/Embed.js @@ -17,6 +17,7 @@ import {Notification, notificationActions, authActions, assetActions, pym} from import Stream from './Stream'; import InfoBox from 'coral-plugin-infobox/InfoBox'; import QuestionBox from 'coral-plugin-questionbox/QuestionBox'; +import {ModerationLink} from 'coral-plugin-moderation'; import Count from 'coral-plugin-comment-count/CommentCount'; import CommentBox from 'coral-plugin-commentbox/CommentBox'; import UserBox from 'coral-sign-in/components/UserBox'; @@ -139,6 +140,7 @@ class Embed extends Component { charCount={asset.settings.charCountEnable && asset.settings.charCount} /> : null } +
    :

    {asset.settings.closedMessage}

    diff --git a/client/coral-plugin-author-name/styles.css b/client/coral-plugin-author-name/styles.css index 608db203c..b7870a862 100644 --- a/client/coral-plugin-author-name/styles.css +++ b/client/coral-plugin-author-name/styles.css @@ -1,7 +1,7 @@ .authorName { color: black; display: inline-block; - margin: 10px 5px 10px 0; + margin: 10px 8px 10px 0; } .hasBio { diff --git a/client/coral-plugin-likes/LikeButton.js b/client/coral-plugin-likes/LikeButton.js index 145080469..9b3abfb00 100644 --- a/client/coral-plugin-likes/LikeButton.js +++ b/client/coral-plugin-likes/LikeButton.js @@ -8,7 +8,7 @@ class LikeButton extends Component { static propTypes = { like: PropTypes.shape({ - current: PropTypes.obect, + current: PropTypes.object, count: PropTypes.number }), id: PropTypes.string, diff --git a/client/coral-plugin-moderation/ModerationLink.js b/client/coral-plugin-moderation/ModerationLink.js new file mode 100644 index 000000000..808f79839 --- /dev/null +++ b/client/coral-plugin-moderation/ModerationLink.js @@ -0,0 +1,22 @@ +import React, {PropTypes} from 'react'; +import styles from './styles.css'; + +import {I18n} from '../coral-framework'; +import translations from './translations.json'; + +const ModerationLink = props => props.isAdmin ? ( + + ) : null; + +ModerationLink.propTypes = { + assetId: PropTypes.string.isRequired, + isAdmin: PropTypes.bool.isRequired +}; + +const lang = new I18n(translations); + +export default ModerationLink; diff --git a/client/coral-plugin-moderation/index.js b/client/coral-plugin-moderation/index.js new file mode 100644 index 000000000..4543750c4 --- /dev/null +++ b/client/coral-plugin-moderation/index.js @@ -0,0 +1 @@ +export {default as ModerationLink} from './ModerationLink'; diff --git a/client/coral-plugin-moderation/styles.css b/client/coral-plugin-moderation/styles.css new file mode 100644 index 000000000..2e928d3df --- /dev/null +++ b/client/coral-plugin-moderation/styles.css @@ -0,0 +1,9 @@ +.moderationLink { + a { + color: #679af3; + text-decoration: none; + font-size: 1em; + font-weight: 600; + letter-spacing: .3px; + } +} diff --git a/client/coral-plugin-moderation/translations.json b/client/coral-plugin-moderation/translations.json new file mode 100644 index 000000000..331c6e392 --- /dev/null +++ b/client/coral-plugin-moderation/translations.json @@ -0,0 +1,8 @@ +{ + "en": { + "MODERATE_THIS_STREAM": "Moderate this stream" + }, + "es": { + "MODERATE_THIS_STREAM": "Modera este stream" + } +} diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index 6214c9df8..98891953c 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -162,22 +162,37 @@ const createPublicComment = (context, commentInput) => { })); }; +/** + * Sets the status of a comment + * @param {String} comment comment in graphql context + * @param {String} id identifier of the comment (uuid) + * @param {String} status the new status of the comment + */ + +const setCommentStatus = ({comment}, {id, status}) => { + return CommentsService.setStatus(id, status) + .then(res => res); +}; + module.exports = (context) => { // TODO: refactor to something that'll return an error in the event an attempt // is made to mutate state while not logged in. There's got to be a better way // to do this. - if (context.user && context.user.can('mutation:createComment')) { - return { - Comment: { - create: (comment) => createPublicComment(context, comment) - } - }; - } - - return { + let mutators = { Comment: { - create: () => Promise.reject(errors.ErrNotAuthorized) + create: () => Promise.reject(errors.ErrNotAuthorized), + setCommentStatus: () => Promise.reject(errors.ErrNotAuthorized) } }; + + if (context.user && context.user.can('mutation:createComment')) { + mutators.Comment.create = (comment) => createPublicComment(context, comment); + } + + if (context.user && context.user.can('mutation:setCommentStatus')) { + mutators.Comment.setCommentStatus = (action) => setCommentStatus(context, action); + } + + return mutators; }; diff --git a/graph/mutators/index.js b/graph/mutators/index.js index 58d0ed62c..b799cf83d 100644 --- a/graph/mutators/index.js +++ b/graph/mutators/index.js @@ -2,6 +2,7 @@ const _ = require('lodash'); const Comment = require('./comment'); const Action = require('./action'); +const User = require('./user'); module.exports = (context) => { @@ -9,6 +10,7 @@ module.exports = (context) => { return _.merge(...[ Comment, Action, + User, ].map((mutators) => { // Each set of mutators is a function which takes the context. diff --git a/graph/mutators/user.js b/graph/mutators/user.js new file mode 100644 index 000000000..2c43f11be --- /dev/null +++ b/graph/mutators/user.js @@ -0,0 +1,27 @@ +const errors = require('../../errors'); +const UsersService = require('../../services/users'); + +const setUserStatus = ({user}, {id, status}) => { + return UsersService.setStatus(id, status) + .then(res => res); +}; + +module.exports = (context) => { + + // TODO: refactor to something that'll return an error in the event an attempt + // is made to mutate state while not logged in. There's got to be a better way + // to do this. + if (context.user && context.user.can('mutation:setUserStatus')) { + return { + User: { + setUserStatus: (action) => setUserStatus(context, action) + } + }; + } + + return { + User: { + setUserStatus: () => Promise.reject(errors.ErrNotAuthorized) + } + }; +}; diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js index 8369b7281..4285f900d 100644 --- a/graph/resolvers/root_mutation.js +++ b/graph/resolvers/root_mutation.js @@ -27,6 +27,12 @@ const RootMutation = { deleteAction(_, {id}, {mutators: {Action}}) { return wrapResponse(null)(Action.delete({id})); }, + setUserStatus(_, {id, status}, {mutators: {User}}) { + return wrapResponse(null)(User.setUserStatus({id, status})); + }, + setCommentStatus(_, {id, status}, {mutators: {Comment}}) { + return wrapResponse(null)(Comment.setCommentStatus({id, status})); + } }; module.exports = RootMutation; diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js index 8aa14d5c6..ec3ac5b08 100644 --- a/graph/resolvers/root_query.js +++ b/graph/resolvers/root_query.js @@ -29,12 +29,7 @@ const RootQuery = { if (user != null && user.hasRoles('ADMIN') && action_type) { return Actions.getByTypes({action_type, item_type: 'COMMENTS'}) - .then((actions) => { - - // Map the actions from the items referenced byt this query. The actions - // returned by this query are explicitly going to be distinct by their - // `item_id`'s. - let ids = actions.map((action) => action.item_id); + .then((ids) => { // Perform the query using the available resolver. return Comments.getByQuery({ids, statuses, asset_id, parent_id, limit, cursor, sort}); diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 13a1698ec..175541f80 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -44,6 +44,9 @@ type User { # returns all comments based on a query. comments(query: CommentsQuery): [Comment] + + # returns user status + status: USER_STATUS } type Tag { @@ -362,6 +365,13 @@ enum SORT_ORDER { } # All queries that can be executed. +enum USER_STATUS { + ACTIVE + BANNED + PENDING + APPROVED +} + type RootQuery { # Site wide settings and defaults. @@ -468,6 +478,22 @@ type DeleteActionResponse implements Response { errors: [UserError] } +# SetUserStatusResponse is the response returned with possibly some errors +# relating to the delete action attempt. +type SetUserStatusResponse implements Response { + + # An array of errors relating to the mutation that occured. + errors: [UserError] +} + +# SetCommentStatusResponse is the response returned with possibly some errors +# relating to the delete action attempt. +type SetCommentStatusResponse implements Response { + + # An array of errors relating to the mutation that occured. + errors: [UserError] +} + # All mutations for the application are defined on this object. type RootMutation { @@ -482,6 +508,12 @@ type RootMutation { # Delete an action based on the action id. deleteAction(id: ID!): DeleteActionResponse + + # Sets User status + setUserStatus(id: ID!, status: USER_STATUS!): SetUserStatusResponse + + # Sets Comment status + setCommentStatus(id: ID!, status: COMMENT_STATUS!): SetCommentStatusResponse } ################################################################################ diff --git a/models/user.js b/models/user.js index 43a34a565..d56f030f3 100644 --- a/models/user.js +++ b/models/user.js @@ -154,7 +154,9 @@ const USER_GRAPH_OPERATIONS = [ 'mutation:createComment', 'mutation:createAction', 'mutation:deleteAction', - 'mutation:editName' + 'mutation:editName', + 'mutation:setUserStatus', + 'mutation:setCommentStatus' ]; /** @@ -170,6 +172,10 @@ UserSchema.method('can', function(...actions) { return false; } + if (actions.some((action) => action === 'mutation:setUserStatus' || action === 'mutation:setCommentStatus') && !this.hasRoles('ADMIN')) { + return false; + } + return true; }); diff --git a/services/comments.js b/services/comments.js index fc05341cb..bcc534c51 100644 --- a/services/comments.js +++ b/services/comments.js @@ -7,6 +7,12 @@ const ALLOWED_TAGS = [ {name: 'STAFF'} ]; +const STATUSES = [ + 'ACCEPTED', + 'REJECTED', + 'PREMOD', +]; + module.exports = class CommentsService { /** @@ -249,4 +255,25 @@ module.exports = class CommentsService { return CommentModel.find(query); } + + /** + * Sets Comment Status + * @param {String} id identifier of the comment (uuid) + * @param {String} status the new status of the comment + * @return {Promise} + */ + + static setStatus(id, status) { + + // Check to see if the comment status is in the allowable set of statuses. + if (STATUSES.indexOf(status) === -1) { + + // Comment status is not supported! Error out here. + return Promise.reject(new Error(`status ${status} is not supported`)); + } + + return CommentModel.update({id}, { + $set: {status} + }); + } };