Merge branch 'master' into fix-ignore-user

This commit is contained in:
Benjamin Goering
2017-05-17 13:57:23 -07:00
committed by GitHub
26 changed files with 740 additions and 48 deletions
@@ -18,3 +18,6 @@ export const hideShortcutsNote = () => {
return {type: actions.HIDE_SHORTCUTS_NOTE};
};
export const viewUserDetail = (userId) => ({type: actions.VIEW_USER_DETAIL, userId});
export const hideUserDetail = () => ({type: actions.HIDE_USER_DETAIL});
@@ -3,3 +3,5 @@ export const SINGLE_VIEW = 'SINGLE_VIEW';
export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG';
export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG';
export const HIDE_SHORTCUTS_NOTE = 'HIDE_SHORTCUTS_NOTE';
export const VIEW_USER_DETAIL = 'VIEW_USER_DETAIL';
export const HIDE_USER_DETAIL = 'HIDE_USER_DETAIL';
@@ -10,7 +10,15 @@ import {banUser, setCommentStatus} from '../../graphql/mutations';
import {fetchSettings} from 'actions/settings';
import {updateAssets} from 'actions/assets';
import {toggleModal, singleView, showBanUserDialog, hideBanUserDialog, hideShortcutsNote} from 'actions/moderation';
import {
toggleModal,
singleView,
showBanUserDialog,
hideBanUserDialog,
hideShortcutsNote,
viewUserDetail,
hideUserDetail
} from 'actions/moderation';
import {Spinner} from 'coral-ui';
import BanUserDialog from '../../components/BanUserDialog';
@@ -19,6 +27,7 @@ import ModerationMenu from './components/ModerationMenu';
import ModerationHeader from './components/ModerationHeader';
import NotFoundAsset from './components/NotFoundAsset';
import ModerationKeysModal from '../../components/ModerationKeysModal';
import UserDetail from './UserDetail';
class ModerationContainer extends Component {
state = {
@@ -111,7 +120,7 @@ class ModerationContainer extends Component {
}
render () {
const {data, moderation, settings, assets, onClose, ...props} = this.props;
const {data, moderation, settings, assets, onClose, viewUserDetail, hideUserDetail, ...props} = this.props;
const providedAssetId = this.props.params.id;
const activeTab = this.props.route.path === ':id' ? 'premod' : this.props.route.path;
@@ -181,6 +190,8 @@ class ModerationContainer extends Component {
assetId={providedAssetId}
sort={this.state.sort}
commentCount={activeTabCount}
viewUserDetail={viewUserDetail}
hideUserDetail={hideUserDetail}
/>
<BanUserDialog
open={moderation.banDialog}
@@ -192,11 +203,16 @@ class ModerationContainer extends Component {
showRejectedNote={moderation.showRejectedNote}
rejectComment={props.rejectComment}
/>
<ModerationKeysModal
<ModerationKeysModal
hideShortcutsNote={props.hideShortcutsNote}
shortcutsNoteVisible={moderation.shortcutsNoteVisible}
open={moderation.modalOpen}
onClose={onClose}/>
{moderation.userDetailId && (
<UserDetail
id={moderation.userDetailId}
hideUserDetail={hideUserDetail} />
)}
</div>
);
}
@@ -214,6 +230,8 @@ const mapDispatchToProps = (dispatch) => ({
singleView: () => dispatch(singleView()),
updateAssets: (assets) => dispatch(updateAssets(assets)),
fetchSettings: () => dispatch(fetchSettings()),
viewUserDetail: (id) => dispatch(viewUserDetail(id)),
hideUserDetail: () => dispatch(hideUserDetail()),
showBanUserDialog: (user, commentId, commentStatus, showRejectedNote) => dispatch(showBanUserDialog(user, commentId, commentStatus, showRejectedNote)),
hideBanUserDialog: () => dispatch(hideBanUserDialog(false)),
hideShortcutsNote: () => dispatch(hideShortcutsNote()),
@@ -12,6 +12,7 @@ const lang = new I18n(translations);
class ModerationQueue extends React.Component {
static propTypes = {
viewUserDetail: PropTypes.func.isRequired,
bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired,
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired,
currentAsset: PropTypes.object,
@@ -33,7 +34,17 @@ class ModerationQueue extends React.Component {
}
render () {
const {comments, selectedIndex, commentCount, singleView, loadMore, activeTab, sort, ...props} = this.props;
const {
comments,
selectedIndex,
commentCount,
singleView,
loadMore,
activeTab,
sort,
viewUserDetail,
...props
} = this.props;
return (
<div id="moderationList" className={`${styles.list} ${singleView ? styles.singleView : ''}`}>
@@ -49,6 +60,7 @@ class ModerationQueue extends React.Component {
selected={i === selectedIndex}
suspectWords={props.suspectWords}
bannedWords={props.bannedWords}
viewUserDetail={viewUserDetail}
actions={actionsMap[status]}
showBanUserDialog={props.showBanUserDialog}
acceptComment={props.acceptComment}
@@ -0,0 +1,32 @@
.copyButton {
float: right;
top: -10px;
}
.memberSince {
clear: both;
}
.small {
color: #aaa;
}
.stats {
display: flex;
.stat {
margin: 0 4px 12px;
}
.stat:last-child {
margin-right: 0;
}
p {
margin: 0;
}
.stat p:first-child {
font-weight: bold;
}
}
@@ -0,0 +1,74 @@
import React, {PropTypes} from 'react';
import {Button, Drawer} from 'coral-ui';
import styles from './UserDetail.css';
import {compose} from 'react-apollo';
import {getUserDetail} from 'coral-admin/src/graphql/queries';
import Slot from 'coral-framework/components/Slot';
class UserDetail extends React.Component {
static propTypes = {
id: PropTypes.string.isRequired,
hideUserDetail: PropTypes.func.isRequired
}
copyPermalink () {
this.profile.select();
try {
document.execCommand('copy');
} catch (e) {
/* nothing */
}
}
render () {
const {data, hideUserDetail} = this.props;
if (!('user' in data)) {
return null;
}
const {user, totalComments, rejectedComments} = data;
const localProfile = user.profiles.find((p) => p.provider === 'local');
let profile;
if (localProfile) {
profile = localProfile.id;
}
let rejectedPercent = rejectedComments / totalComments;
if (rejectedPercent === Infinity || isNaN(rejectedPercent)) {
// if totalComments is 0, you're dividing by zero, which is naughty
rejectedPercent = 0;
}
return (
<Drawer handleClickOutside={hideUserDetail}>
<h3>{user.username}</h3>
<Button className={styles.copyButton}>Copy</Button>
{profile && <p ref={(ref) => this.profile = ref} contentEditable="true">{profile}</p>}
<Slot fill="userProfile" user={user} />
<p className={styles.memberSince}><strong>Member since</strong> {new Date(user.created_at).toLocaleString()}</p>
<hr/>
<p>
<strong>Account summary</strong>
<br/><small className={styles.small}>Data represents the last six months of activity</small>
</p>
<div className={styles.stats}>
<div className={styles.stat}>
<p>Total Comments</p>
<p>{totalComments}</p>
</div>
<div className={styles.stat}>
<p>Reject Rate</p>
<p>{`${(rejectedPercent).toFixed(1)}%`}</p>
</div>
</div>
</Drawer>
);
}
}
export default compose(
getUserDetail
)(UserDetail);
@@ -22,6 +22,7 @@ const lang = new I18n(translations);
const Comment = ({
actions = [],
comment,
viewUserDetail,
suspectWords,
bannedWords,
...props
@@ -56,7 +57,7 @@ const Comment = ({
<div className={styles.container}>
<div className={styles.itemHeader}>
<div className={styles.author}>
<span>
<span className={styles.username} onClick={() => viewUserDetail(comment.user.id)}>
{comment.user.name}
</span>
<span className={styles.created}>
@@ -154,6 +155,7 @@ const Comment = ({
};
Comment.propTypes = {
viewUserDetail: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired,
@@ -165,8 +167,9 @@ Comment.propTypes = {
actions: PropTypes.array,
created_at: PropTypes.string.isRequired,
user: PropTypes.shape({
id: PropTypes.string,
status: PropTypes.string
}),
}).isRequired,
asset: PropTypes.shape({
title: PropTypes.string,
url: PropTypes.string,
@@ -424,6 +424,17 @@ span {
top: 7px;
}
.username {
color: blue;
text-decoration: underline;
padding: 5px;
cursor: pointer;
&:hover {
background-color: rgba(255, 0, 0, .1);
}
}
.external {
font-size: .7em;
text-decoration: none;
@@ -4,6 +4,7 @@ import MOD_QUEUE_QUERY from './modQueueQuery.graphql';
import MOD_QUEUE_LOAD_MORE from './loadMore.graphql';
import MOD_USER_FLAGGED_QUERY from './modUserFlaggedQuery.graphql';
import METRICS from './metricsQuery.graphql';
import USER_DETAIL from './userDetail.graphql';
import GET_QUEUE_COUNTS from './getQueueCounts.graphql';
export const modQueueQuery = graphql(MOD_QUEUE_QUERY, {
@@ -95,6 +96,14 @@ export const modQueueResort = (id, fetchMore) => (sort) => {
});
};
export const getUserDetail = graphql(USER_DETAIL, {
options: ({id}) => {
return {
variables: {author_id: id}
};
}
});
export const getQueueCounts = graphql(GET_QUEUE_COUNTS, {
options: ({params: {id = null}}) => {
return {
@@ -0,0 +1,13 @@
query UserDetail ($author_id: ID!) {
user(id: $author_id) {
id
username
created_at
profiles {
id
provider
}
}
totalComments: commentCount(query: {author_id: $author_id})
rejectedComments: commentCount(query: {author_id: $author_id, statuses: [REJECTED]})
}
@@ -7,6 +7,7 @@ const initialState = Map({
user: Map({}),
commentId: null,
commentStatus: null,
userDetailId: null,
banDialog: false,
shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show'
});
@@ -38,6 +39,10 @@ export default function moderation (state = initialState, action) {
case actions.HIDE_SHORTCUTS_NOTE:
return state
.set('shortcutsNoteVisible', 'hide');
case actions.VIEW_USER_DETAIL:
return state.set('userDetailId', action.userId);
case actions.HIDE_USER_DETAIL:
return state.set('userDetailId', null);
default :
return state;
}
+33
View File
@@ -0,0 +1,33 @@
.drawer {
max-width: 700px;
min-width: 400px;
padding: 20px;
position: fixed;
top: 0;
right: 0;
bottom: 0;
background-color: white;
transition: transform 500ms ease-in-out;
box-shadow: -3px 0px 4px 0px rgba(0,0,0,0.15);
z-index: 10000;
}
.closeButton {
position: absolute;
width: 40px;
height: 40px;
left: -40px;
background-color: white;
border-radius: 4px 0 0 4px;
box-sizing: border-box;
font-size: 32px;
top: 60px;
box-shadow: -1px 3px 4px 0px rgba(0,0,0,0.15);
text-align: center;
padding-top: 10px;
cursor: pointer;
&:hover {
color: #ccc;
}
}
+19
View File
@@ -0,0 +1,19 @@
import React, {PropTypes} from 'react';
import styles from './Drawer.css';
import onClickOutside from 'react-onclickoutside';
const Drawer = ({children, handleClickOutside}) => {
return (
<div className={styles.drawer}>
<div className={styles.closeButton} onClick={handleClickOutside}>×</div>
{children}
</div>
);
};
Drawer.propTypes = {
active: PropTypes.bool,
handleClickOutside: PropTypes.func.isRequired
};
export default onClickOutside(Drawer);
+1
View File
@@ -23,3 +23,4 @@ export {default as Select} from './components/Select';
export {default as Option} from './components/Option';
export {default as SnackBar} from './components/SnackBar';
export {default as TextArea} from './components/TextArea';
export {default as Drawer} from './components/Drawer';