From 9e1b470cb52647cadaaee27623187f4fba9f849c Mon Sep 17 00:00:00 2001 From: riley Date: Wed, 3 May 2017 09:32:05 -0600 Subject: [PATCH 01/11] saving my place --- .../coral-admin/src/components/UserDetail.css | 0 .../coral-admin/src/components/UserDetail.js | 39 +++++++++++++++++++ .../ModerationQueue/ModerationContainer.js | 16 +++++++- .../ModerationQueue/ModerationQueue.js | 13 ++++++- .../ModerationQueue/components/Comment.js | 11 ++++-- .../ModerationQueue/components/styles.css | 11 ++++++ .../coral-admin/src/graphql/queries/index.js | 15 +++++++ .../src/graphql/queries/userDetail.graphql | 6 +++ client/coral-ui/components/Drawer.css | 12 ++++++ client/coral-ui/components/Drawer.js | 17 ++++++++ client/coral-ui/index.js | 1 + graph/resolvers/root_query.js | 10 +++++ graph/typeDefs.graphql | 3 ++ 13 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 client/coral-admin/src/components/UserDetail.css create mode 100644 client/coral-admin/src/components/UserDetail.js create mode 100644 client/coral-admin/src/graphql/queries/userDetail.graphql create mode 100644 client/coral-ui/components/Drawer.css create mode 100644 client/coral-ui/components/Drawer.js diff --git a/client/coral-admin/src/components/UserDetail.css b/client/coral-admin/src/components/UserDetail.css new file mode 100644 index 000000000..e69de29bb diff --git a/client/coral-admin/src/components/UserDetail.js b/client/coral-admin/src/components/UserDetail.js new file mode 100644 index 000000000..aa6997dc4 --- /dev/null +++ b/client/coral-admin/src/components/UserDetail.js @@ -0,0 +1,39 @@ +import React, {PropTypes} from 'react'; +import {Button, Drawer} from 'coral-ui'; +import styles from './UserDetail.js'; + +const UserDetail = ({user}) => { + const localProfile = user.profiles.find(p => p.provider === 'local'); + const facebookProfile = user.profiles.find(p => p.provider === 'facebook'); + let profile; + if (localProfile) { + profile = profile.id; + } else if (facebookProfile) { + profile = {facebookProfile.id}; + } + + return ( + +

{user.username}

+

{profile}

+ Member since {user.created_at} +
+ Account summary +
Data represents the last six months of activity +
+
+
+ ); +}; + +UserDetail.propTypes = { + user: PropTypes.shape({ + username: PropTypes.string.isRequired, + profiles: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string, + provider: PropTypes.string + })) + }) +}; + +export default UserDetail; diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js index eb59f369e..fac092a77 100644 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js @@ -5,7 +5,7 @@ import key from 'keymaster'; import isEqual from 'lodash/isEqual'; import styles from './components/styles.css'; -import {modQueueQuery} from '../../graphql/queries'; +import {modQueueQuery, getUserDetail} from '../../graphql/queries'; import {banUser, setCommentStatus} from '../../graphql/mutations'; import {fetchSettings} from 'actions/settings'; @@ -19,6 +19,7 @@ import ModerationMenu from './components/ModerationMenu'; import ModerationHeader from './components/ModerationHeader'; import NotFoundAsset from './components/NotFoundAsset'; import ModerationKeysModal from '../../components/ModerationKeysModal'; +import UserDetail from '../../components/UserDetail'; class ModerationContainer extends Component { state = { @@ -76,6 +77,15 @@ class ModerationContainer extends Component { } } + viewUserDetail = (id) => { + console.log('getting user detail', id, typeof getUserDetail); + try { + getUserDetail({id}); + } catch (e) { + console.log(e); + } + } + selectSort = (sort) => { this.setState({sort}); this.props.modQueueResort(sort); @@ -180,6 +190,7 @@ class ModerationContainer extends Component { assetId={providedAssetId} sort={this.state.sort} commentCount={activeTabCount} + viewUserDetail={this.viewUserDetail} /> - + {data.user && } ); } diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js b/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js index f986b947b..093c2f5dd 100644 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js @@ -9,7 +9,16 @@ import translations from 'coral-admin/src/translations'; import LoadMore from './components/LoadMore'; const lang = new I18n(translations); -const ModerationQueue = ({comments, selectedIndex, commentCount, singleView, loadMore, activeTab, sort, ...props}) => { +const ModerationQueue = ({ + comments, + selectedIndex, + commentCount, + singleView, + loadMore, + activeTab, + sort, + viewUserDetail, + ...props}) => { return (
    @@ -26,6 +35,7 @@ const ModerationQueue = ({comments, selectedIndex, commentCount, singleView, loa bannedWords={props.bannedWords} actions={actionsMap[status]} showBanUserDialog={props.showBanUserDialog} + viewUserDetail={viewUserDetail} acceptComment={props.acceptComment} rejectComment={props.rejectComment} currentAsset={props.currentAsset} @@ -47,6 +57,7 @@ const ModerationQueue = ({comments, selectedIndex, commentCount, singleView, loa }; ModerationQueue.propTypes = { + viewUserDetail: PropTypes.func.isRequired, bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired, suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired, currentAsset: PropTypes.object, diff --git a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js index 0fdf435bd..b35c688cd 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js @@ -18,7 +18,7 @@ import I18n from 'coral-framework/modules/i18n/i18n'; import translations from 'coral-admin/src/translations.json'; const lang = new I18n(translations); -const Comment = ({actions = [], comment, ...props}) => { +const Comment = ({actions = [], comment, viewUserDetail, ...props}) => { const links = linkify.getMatches(comment.body); const linkText = links ? links.map(link => link.raw) : []; const flagActionSummaries = getActionSummary('FlagActionSummary', comment); @@ -35,7 +35,10 @@ const Comment = ({actions = [], comment, ...props}) => {
    - + { + console.log('clickt', comment.user.id); + viewUserDetail(comment.user.id); + }}> {comment.user.name} @@ -89,6 +92,7 @@ const Comment = ({actions = [], comment, ...props}) => { }; Comment.propTypes = { + viewUserDetail: PropTypes.func.isRequired, acceptComment: PropTypes.func.isRequired, rejectComment: PropTypes.func.isRequired, suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired, @@ -100,8 +104,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, id: PropTypes.string diff --git a/client/coral-admin/src/containers/ModerationQueue/components/styles.css b/client/coral-admin/src/containers/ModerationQueue/components/styles.css index a22fbbc64..baa098d91 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/styles.css +++ b/client/coral-admin/src/containers/ModerationQueue/components/styles.css @@ -423,3 +423,14 @@ span { position: relative; top: 7px; } + +.username { + color: blue; + text-decoration: underline; + padding: 5px; + cursor: pointer; + + &:hover { + background-color: rgba(255, 0, 0, .1); + } +} diff --git a/client/coral-admin/src/graphql/queries/index.js b/client/coral-admin/src/graphql/queries/index.js index 9d2357ce7..aa5ffa505 100644 --- a/client/coral-admin/src/graphql/queries/index.js +++ b/client/coral-admin/src/graphql/queries/index.js @@ -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'; export const modQueueQuery = graphql(MOD_QUEUE_QUERY, { options: ({params: {id = null}}) => { @@ -93,3 +94,17 @@ export const modQueueResort = (id, fetchMore) => (sort) => { updateQuery: (oldData, {fetchMoreResult:{data}}) => data }); }; + +export const getUserDetail = ({id}) => { + console.log('close', id); + return graphql(USER_DETAIL, { + options: () => { + console.log('so close!', id); + return { + variables: { + id + } + }; + } + }); +}; diff --git a/client/coral-admin/src/graphql/queries/userDetail.graphql b/client/coral-admin/src/graphql/queries/userDetail.graphql new file mode 100644 index 000000000..f489e16d9 --- /dev/null +++ b/client/coral-admin/src/graphql/queries/userDetail.graphql @@ -0,0 +1,6 @@ +query UserDetail ($id: ID!) { + user(id: $id) { + id + username + } +} diff --git a/client/coral-ui/components/Drawer.css b/client/coral-ui/components/Drawer.css new file mode 100644 index 000000000..57a44075e --- /dev/null +++ b/client/coral-ui/components/Drawer.css @@ -0,0 +1,12 @@ +.wrapper { + transition: opacity 250ms; + position: fixed; + z-index: 10000; + background-color: rgba(0, 0, 0, .2); + height: 100%; + width: 100%; +} + +.drawer { + transition: transform 500ms ease-in-out; +} diff --git a/client/coral-ui/components/Drawer.js b/client/coral-ui/components/Drawer.js new file mode 100644 index 000000000..d56677d76 --- /dev/null +++ b/client/coral-ui/components/Drawer.js @@ -0,0 +1,17 @@ +import React, {PropTypes} from 'react'; +import styles from './Drawer.css'; + +const Drawer = (props) => { + return ( +
    +
    {props.children}
    +
    ×
    +
    + ); +}; + +Drawer.propTypes = { + active: PropTypes.bool +}; + +export default Drawer; diff --git a/client/coral-ui/index.js b/client/coral-ui/index.js index 148da0383..3e9456727 100644 --- a/client/coral-ui/index.js +++ b/client/coral-ui/index.js @@ -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'; diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js index 4dcb7ea11..622584310 100644 --- a/graph/resolvers/root_query.js +++ b/graph/resolvers/root_query.js @@ -96,6 +96,16 @@ const RootQuery = { return await Users.getByQuery({ids: currentUser.ignoresUsers}); }, + // this returns an arbitrary user + user(_, {id}, {user, loaders: {Users}}) { + console.log("user id!", id); + if (user == null || !user.hasRoles('ADMIN')) { + return null; + } + + return Users.getByID.load(id); + }, + // This endpoint is used for loading the user moderation queues (users whose username has been flagged), // so hide it in the event that we aren't an admin. users(_, {query: {action_type, limit, cursor, sort}}, {user, loaders: {Users, Actions}}) { diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 81105ff58..4fa7a1531 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -585,6 +585,9 @@ type RootQuery { # Users returned based on a query. users(query: UsersQuery): [User] + # a single User by id + user(id: ID!): User + # Asset metrics related to user actions are saturated into the assets # returned. Parameters `from` and `to` are related to the action created_at field. assetMetrics(from: Date!, to: Date!, sort: ASSET_METRICS_SORT!, limit: Int = 10): [Asset!] From 4a42436b7b4b41921f28bc580a842faa8f250313 Mon Sep 17 00:00:00 2001 From: riley Date: Wed, 3 May 2017 12:54:02 -0600 Subject: [PATCH 02/11] load user via HOC UserDetail --- client/coral-admin/src/actions/moderation.js | 2 + .../coral-admin/src/components/UserDetail.js | 39 ---------------- .../coral-admin/src/constants/moderation.js | 2 + .../ModerationQueue/ModerationContainer.js | 29 ++++++------ .../containers/ModerationQueue/UserDetail.js | 46 +++++++++++++++++++ .../ModerationQueue/components/Comment.js | 5 +- .../coral-admin/src/graphql/queries/index.js | 20 +++----- client/coral-admin/src/reducers/moderation.js | 5 ++ graph/resolvers/root_query.js | 1 - graph/resolvers/user.js | 9 ++++ graph/typeDefs.graphql | 11 +++++ 11 files changed, 97 insertions(+), 72 deletions(-) delete mode 100644 client/coral-admin/src/components/UserDetail.js create mode 100644 client/coral-admin/src/containers/ModerationQueue/UserDetail.js diff --git a/client/coral-admin/src/actions/moderation.js b/client/coral-admin/src/actions/moderation.js index b7117181e..557954b70 100644 --- a/client/coral-admin/src/actions/moderation.js +++ b/client/coral-admin/src/actions/moderation.js @@ -18,3 +18,5 @@ export const hideShortcutsNote = () => { return {type: actions.HIDE_SHORTCUTS_NOTE}; }; + +export const viewUserDetail = userId => ({type: actions.VIEW_USER_DETAIL, userId}); diff --git a/client/coral-admin/src/components/UserDetail.js b/client/coral-admin/src/components/UserDetail.js deleted file mode 100644 index aa6997dc4..000000000 --- a/client/coral-admin/src/components/UserDetail.js +++ /dev/null @@ -1,39 +0,0 @@ -import React, {PropTypes} from 'react'; -import {Button, Drawer} from 'coral-ui'; -import styles from './UserDetail.js'; - -const UserDetail = ({user}) => { - const localProfile = user.profiles.find(p => p.provider === 'local'); - const facebookProfile = user.profiles.find(p => p.provider === 'facebook'); - let profile; - if (localProfile) { - profile = profile.id; - } else if (facebookProfile) { - profile = {facebookProfile.id}; - } - - return ( - -

    {user.username}

    -

    {profile}

    - Member since {user.created_at} -
    - Account summary -
    Data represents the last six months of activity -
    -
    -
    - ); -}; - -UserDetail.propTypes = { - user: PropTypes.shape({ - username: PropTypes.string.isRequired, - profiles: PropTypes.arrayOf(PropTypes.shape({ - id: PropTypes.string, - provider: PropTypes.string - })) - }) -}; - -export default UserDetail; diff --git a/client/coral-admin/src/constants/moderation.js b/client/coral-admin/src/constants/moderation.js index 14672146e..1d09b0e1a 100644 --- a/client/coral-admin/src/constants/moderation.js +++ b/client/coral-admin/src/constants/moderation.js @@ -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'; diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js index fac092a77..b76de1d05 100644 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js @@ -5,12 +5,19 @@ import key from 'keymaster'; import isEqual from 'lodash/isEqual'; import styles from './components/styles.css'; -import {modQueueQuery, getUserDetail} from '../../graphql/queries'; +import {modQueueQuery} from '../../graphql/queries'; 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 +} from 'actions/moderation'; import {Spinner} from 'coral-ui'; import BanUserDialog from '../../components/BanUserDialog'; @@ -19,7 +26,7 @@ import ModerationMenu from './components/ModerationMenu'; import ModerationHeader from './components/ModerationHeader'; import NotFoundAsset from './components/NotFoundAsset'; import ModerationKeysModal from '../../components/ModerationKeysModal'; -import UserDetail from '../../components/UserDetail'; +import UserDetail from './UserDetail'; class ModerationContainer extends Component { state = { @@ -77,15 +84,6 @@ class ModerationContainer extends Component { } } - viewUserDetail = (id) => { - console.log('getting user detail', id, typeof getUserDetail); - try { - getUserDetail({id}); - } catch (e) { - console.log(e); - } - } - selectSort = (sort) => { this.setState({sort}); this.props.modQueueResort(sort); @@ -120,7 +118,7 @@ class ModerationContainer extends Component { } render () { - const {data, moderation, settings, assets, onClose, ...props} = this.props; + const {data, moderation, settings, assets, onClose, viewUserDetail, ...props} = this.props; const providedAssetId = this.props.params.id; const activeTab = this.props.route.path === ':id' ? 'premod' : this.props.route.path; @@ -190,7 +188,7 @@ class ModerationContainer extends Component { assetId={providedAssetId} sort={this.state.sort} commentCount={activeTabCount} - viewUserDetail={this.viewUserDetail} + viewUserDetail={viewUserDetail} /> - {data.user && } + {moderation.userDetailId && }
    ); } @@ -224,6 +222,7 @@ const mapDispatchToProps = dispatch => ({ singleView: () => dispatch(singleView()), updateAssets: assets => dispatch(updateAssets(assets)), fetchSettings: () => dispatch(fetchSettings()), + viewUserDetail: id => dispatch(viewUserDetail(id)), showBanUserDialog: (user, commentId, showRejectedNote) => dispatch(showBanUserDialog(user, commentId, showRejectedNote)), hideBanUserDialog: () => dispatch(hideBanUserDialog(false)), hideShortcutsNote: () => dispatch(hideShortcutsNote()), diff --git a/client/coral-admin/src/containers/ModerationQueue/UserDetail.js b/client/coral-admin/src/containers/ModerationQueue/UserDetail.js new file mode 100644 index 000000000..45d98c56c --- /dev/null +++ b/client/coral-admin/src/containers/ModerationQueue/UserDetail.js @@ -0,0 +1,46 @@ +import React, {PropTypes} from 'react'; +import {Button, Drawer} from 'coral-ui'; +import styles from './UserDetail.js'; +import {compose} from 'react-apollo'; +import {getUserDetail} from 'coral-admin/src/graphql/queries'; + +class UserDetail extends React.Component { + static propTypes = { + id: PropTypes.string.isRequired + } + + render () { + const {data} = this.props; + + if (!('user' in data)) { + return null; + } + + const {user} = data; + const localProfile = user.profiles.find(p => p.provider === 'local'); + const facebookProfile = user.profiles.find(p => p.provider === 'facebook'); + let profile; + if (localProfile) { + profile = localProfile.id; + } else if (facebookProfile) { + profile = {facebookProfile.id}; + } + + return ( + +

    {user.username}

    +

    {profile}

    + Member since {user.created_at} +
    + Account summary +
    Data represents the last six months of activity +
    +
    +
    + ); + } +} + +export default compose( + getUserDetail +)(UserDetail); diff --git a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js index b35c688cd..8956e6e03 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js @@ -35,10 +35,7 @@ const Comment = ({actions = [], comment, viewUserDetail, ...props}) => {
    - { - console.log('clickt', comment.user.id); - viewUserDetail(comment.user.id); - }}> + viewUserDetail(comment.user.id)}> {comment.user.name} diff --git a/client/coral-admin/src/graphql/queries/index.js b/client/coral-admin/src/graphql/queries/index.js index aa5ffa505..8ab3c7292 100644 --- a/client/coral-admin/src/graphql/queries/index.js +++ b/client/coral-admin/src/graphql/queries/index.js @@ -95,16 +95,10 @@ export const modQueueResort = (id, fetchMore) => (sort) => { }); }; -export const getUserDetail = ({id}) => { - console.log('close', id); - return graphql(USER_DETAIL, { - options: () => { - console.log('so close!', id); - return { - variables: { - id - } - }; - } - }); -}; +export const getUserDetail = graphql(USER_DETAIL, { + options: ({id}) => { + return { + variables: {id} + }; + } +}); diff --git a/client/coral-admin/src/reducers/moderation.js b/client/coral-admin/src/reducers/moderation.js index a40ac865f..d8b9c59bb 100644 --- a/client/coral-admin/src/reducers/moderation.js +++ b/client/coral-admin/src/reducers/moderation.js @@ -6,6 +6,7 @@ const initialState = Map({ modalOpen: false, user: Map({}), commentId: null, + userDetailId: null, banDialog: false, shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show' }); @@ -35,6 +36,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; } diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js index 622584310..9dd78c2fc 100644 --- a/graph/resolvers/root_query.js +++ b/graph/resolvers/root_query.js @@ -98,7 +98,6 @@ const RootQuery = { // this returns an arbitrary user user(_, {id}, {user, loaders: {Users}}) { - console.log("user id!", id); if (user == null || !user.hasRoles('ADMIN')) { return null; } diff --git a/graph/resolvers/user.js b/graph/resolvers/user.js index d8ed7ee15..4f6528454 100644 --- a/graph/resolvers/user.js +++ b/graph/resolvers/user.js @@ -20,6 +20,15 @@ const User = { return null; }, + profiles({id, profiles}, _, {user}) { + + // if the user is not an admin, do not return the profiles + if (user && (user.hasRoles('ADMIN') || user.id === id)) { + return profiles; + } + + return null; + }, roles({id, roles}, _, {user}) { // If the user is not an admin, only return the current user's roles. diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 4fa7a1531..f1d0acac4 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -20,6 +20,14 @@ enum USER_ROLES { MODERATOR } +type UserProfile { + # the id is an identifier for the user profile (email, facebook id, etc) + id: String! + + # name of the provider attached to the authentication mode + provider: String! +} + # Any person who can author comments, create actions, and view comments on a # stream. type User { @@ -39,6 +47,9 @@ type User { # the current roles of the user. roles: [USER_ROLES] + # the current profiles of the user. + profiles: [UserProfile] + # determines whether the user can edit their username canEditName: Boolean From 57b0b5ec67b71a2d2cc0d269a0459f98d13020aa Mon Sep 17 00:00:00 2001 From: riley Date: Thu, 4 May 2017 13:58:43 -0600 Subject: [PATCH 03/11] add a drawer for the user details --- client/coral-admin/src/actions/moderation.js | 1 + .../coral-admin/src/components/UserDetail.css | 0 .../ModerationQueue/ModerationContainer.js | 9 +++-- .../containers/ModerationQueue/UserDetail.css | 12 +++++++ .../containers/ModerationQueue/UserDetail.js | 30 +++++++++++----- .../src/graphql/queries/userDetail.graphql | 5 +++ client/coral-ui/components/Drawer.css | 35 ++++++++++++++----- client/coral-ui/components/Drawer.js | 14 ++++---- graph/resolvers/user.js | 11 ++++-- graph/typeDefs.graphql | 3 ++ package.json | 6 ++-- yarn.lock | 2 +- 12 files changed, 96 insertions(+), 32 deletions(-) delete mode 100644 client/coral-admin/src/components/UserDetail.css create mode 100644 client/coral-admin/src/containers/ModerationQueue/UserDetail.css diff --git a/client/coral-admin/src/actions/moderation.js b/client/coral-admin/src/actions/moderation.js index 557954b70..5107669cf 100644 --- a/client/coral-admin/src/actions/moderation.js +++ b/client/coral-admin/src/actions/moderation.js @@ -20,3 +20,4 @@ export const hideShortcutsNote = () => { }; export const viewUserDetail = userId => ({type: actions.VIEW_USER_DETAIL, userId}); +export const hideUserDetail = () => ({type: actions.HIDE_USER_DETAIL}); diff --git a/client/coral-admin/src/components/UserDetail.css b/client/coral-admin/src/components/UserDetail.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js index 29612b0a3..480666637 100644 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js @@ -16,7 +16,8 @@ import { showBanUserDialog, hideBanUserDialog, hideShortcutsNote, - viewUserDetail + viewUserDetail, + hideUserDetail } from 'actions/moderation'; import {Spinner} from 'coral-ui'; @@ -118,7 +119,7 @@ class ModerationContainer extends Component { } render () { - const {data, moderation, settings, assets, onClose, viewUserDetail, ...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; @@ -189,6 +190,7 @@ class ModerationContainer extends Component { sort={this.state.sort} commentCount={activeTabCount} viewUserDetail={viewUserDetail} + hideUserDetail={hideUserDetail} /> - {moderation.userDetailId && } + {moderation.userDetailId && }
    ); } @@ -223,6 +225,7 @@ const mapDispatchToProps = dispatch => ({ updateAssets: assets => dispatch(updateAssets(assets)), fetchSettings: () => dispatch(fetchSettings()), viewUserDetail: id => dispatch(viewUserDetail(id)), + hideUserDetail: () => dispatch(hideUserDetail()), showBanUserDialog: (user, commentId, showRejectedNote) => dispatch(showBanUserDialog(user, commentId, showRejectedNote)), hideBanUserDialog: () => dispatch(hideBanUserDialog(false)), hideShortcutsNote: () => dispatch(hideShortcutsNote()), diff --git a/client/coral-admin/src/containers/ModerationQueue/UserDetail.css b/client/coral-admin/src/containers/ModerationQueue/UserDetail.css new file mode 100644 index 000000000..9f41a9d8d --- /dev/null +++ b/client/coral-admin/src/containers/ModerationQueue/UserDetail.css @@ -0,0 +1,12 @@ +.copyButton { + float: right; + top: -10px; +} + +.memberSince { + clear: both; +} + +.small { + color: #aaa; +} diff --git a/client/coral-admin/src/containers/ModerationQueue/UserDetail.js b/client/coral-admin/src/containers/ModerationQueue/UserDetail.js index 45d98c56c..06587065e 100644 --- a/client/coral-admin/src/containers/ModerationQueue/UserDetail.js +++ b/client/coral-admin/src/containers/ModerationQueue/UserDetail.js @@ -1,16 +1,27 @@ import React, {PropTypes} from 'react'; import {Button, Drawer} from 'coral-ui'; -import styles from './UserDetail.js'; +import styles from './UserDetail.css'; import {compose} from 'react-apollo'; import {getUserDetail} from 'coral-admin/src/graphql/queries'; class UserDetail extends React.Component { static propTypes = { - id: PropTypes.string.isRequired + id: PropTypes.string.isRequired, + hideUserDetail: PropTypes.func.isRequired + } + + copyPermalink () { + this.profile.select(); + try { + document.execCommand('copy'); + } catch (e) { + + /* nothing */ + } } render () { - const {data} = this.props; + const {data, hideUserDetail} = this.props; if (!('user' in data)) { return null; @@ -27,13 +38,16 @@ class UserDetail extends React.Component { } return ( - +

    {user.username}

    -

    {profile}

    - Member since {user.created_at} + +

    this.profile = ref} contentEditable="true">{profile}

    +

    Member since {new Date(user.created_at).toLocaleString()}


    - Account summary -
    Data represents the last six months of activity +

    + Account summary +
    Data represents the last six months of activity +

    diff --git a/client/coral-admin/src/graphql/queries/userDetail.graphql b/client/coral-admin/src/graphql/queries/userDetail.graphql index f489e16d9..6d42682ea 100644 --- a/client/coral-admin/src/graphql/queries/userDetail.graphql +++ b/client/coral-admin/src/graphql/queries/userDetail.graphql @@ -2,5 +2,10 @@ query UserDetail ($id: ID!) { user(id: $id) { id username + created_at + profiles { + id + provider + } } } diff --git a/client/coral-ui/components/Drawer.css b/client/coral-ui/components/Drawer.css index 57a44075e..b967b20ab 100644 --- a/client/coral-ui/components/Drawer.css +++ b/client/coral-ui/components/Drawer.css @@ -1,12 +1,31 @@ -.wrapper { - transition: opacity 250ms; +.drawer { + max-width: 700px; + padding: 20px; position: fixed; - z-index: 10000; - background-color: rgba(0, 0, 0, .2); - height: 100%; - width: 100%; + 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); } -.drawer { - transition: transform 500ms ease-in-out; +.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; + } } diff --git a/client/coral-ui/components/Drawer.js b/client/coral-ui/components/Drawer.js index d56677d76..d896203a8 100644 --- a/client/coral-ui/components/Drawer.js +++ b/client/coral-ui/components/Drawer.js @@ -1,17 +1,19 @@ import React, {PropTypes} from 'react'; import styles from './Drawer.css'; +import onClickOutside from 'react-onclickoutside'; -const Drawer = (props) => { +const Drawer = ({children, handleClickOutside}) => { return ( -
    -
    {props.children}
    -
    ×
    +
    +
    ×
    + {children}
    ); }; Drawer.propTypes = { - active: PropTypes.bool + active: PropTypes.bool, + handleClickOutside: PropTypes.func.isRequired }; -export default Drawer; +export default onClickOutside(Drawer); diff --git a/graph/resolvers/user.js b/graph/resolvers/user.js index 4f6528454..4dbdc43c4 100644 --- a/graph/resolvers/user.js +++ b/graph/resolvers/user.js @@ -10,6 +10,13 @@ const User = { } }, + created_at({roles, created_at}, _, {user}) { + if (user && user.hasRoles('ADMIN')) { + return created_at; + } + + return null; + }, comments({id}, _, {loaders: {Comments}, user}) { // If the user is not an admin, only return comment list for the owner of @@ -20,10 +27,10 @@ const User = { return null; }, - profiles({id, profiles}, _, {user}) { + profiles({profiles}, _, {user}) { // if the user is not an admin, do not return the profiles - if (user && (user.hasRoles('ADMIN') || user.id === id)) { + if (user && user.hasRoles('ADMIN')) { return profiles; } diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index a505f558e..7a5f0b76f 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -38,6 +38,9 @@ type User { # Username of a user. username: String! + # creation date of user + created_at: String! + # Action summaries against the user. action_summaries: [ActionSummary]! diff --git a/package.json b/package.json index bfca21643..d61431b3a 100644 --- a/package.json +++ b/package.json @@ -98,12 +98,10 @@ "react-recaptcha": "^2.2.6", "recompose": "^0.23.1", "redis": "^2.7.1", - "uuid": "^3.0.1", - "simplemde": "^1.11.2", - "subscriptions-transport-ws": "^0.5.5-alpha.0", "resolve": "^1.3.2", "semver": "^5.3.0", "simplemde": "^1.11.2", + "subscriptions-transport-ws": "^0.5.5-alpha.0", "uuid": "^3.0.1" }, "devDependencies": { @@ -174,7 +172,7 @@ "react-linkify": "^0.1.3", "react-mdl": "^1.7.2", "react-mdl-selectfield": "^0.2.0", - "react-onclickoutside": "^5.7.1", + "react-onclickoutside": "^5.11.1", "react-redux": "^4.4.5", "react-router": "^3.0.0", "react-tagsinput": "^3.14.0", diff --git a/yarn.lock b/yarn.lock index 99703bdf3..7a5163706 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6780,7 +6780,7 @@ react-mdl@^1.7.1, react-mdl@^1.7.2: lodash.isequal "^4.4.0" prop-types "^15.5.0" -react-onclickoutside@^5.7.1: +react-onclickoutside@^5.11.1: version "5.11.1" resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-5.11.1.tgz#00314e52567cf55faba94cabbacd119619070623" dependencies: From 084f6b28ed0120fab1abed1a5c3092857b5bfe09 Mon Sep 17 00:00:00 2001 From: Riley Davis Date: Tue, 9 May 2017 10:06:41 -0600 Subject: [PATCH 04/11] merge master --- README.md | 38 +++- client/coral-admin/src/AppRouter.js | 4 +- .../ModerationQueue/components/Comment.js | 14 +- .../components/ModerationMenu.js | 12 +- .../ModerationQueue/components/styles.css | 21 ++ .../src/graphql/fragments/commentView.graphql | 1 + client/coral-admin/src/translations.json | 2 + .../components/ConfigureCommentStream.css | 6 +- .../components/ConfigureCommentStream.js | 2 +- docs/frontend/PLUGINS-experimental.md | 102 ++++++++++ docs/frontend/PLUGINS.md | 186 ++++++++++++++++++ graph/pubsub.js | 2 +- 12 files changed, 371 insertions(+), 19 deletions(-) create mode 100644 docs/frontend/PLUGINS-experimental.md create mode 100644 docs/frontend/PLUGINS.md diff --git a/README.md b/README.md index ef621b59c..74097d48c 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,43 @@ available in the format: `://` without the path. Refer to the wiki page on [Configuration Loading](https://github.com/coralproject/talk/wiki/Configuration-Loading) for alternative methods of loading configuration during development. -### License +## Supported Browsers - Copyright 2016 Mozilla Foundation +### Web + +- Chrome: latest 2 versions +- Firefox: latest 2 versions, and most recent extended support version, if any +- Safari: latest 2 versions +- Internet Explorer: IE Edge, 11 + +### iOS Devices + +- iPad +- iPad Pro +- iPhone 6 Plus +- iPhone 6 +- iPhone 5 + +### iOS Browsers + +- Chrome for iOS: latest version +- Firefox for iOS: latest version +- Safari for iOS: latest version + +### Android Devices + +- Galaxy S5 +- Nexus 5X +- Nexus 6P + +### Android Browsers + +- Chrome for Android: latest version +- Firefox for Android: latest version + +## License + + Copyright 2017 Mozilla Foundation Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client/coral-admin/src/AppRouter.js b/client/coral-admin/src/AppRouter.js index af3423acc..989316dd9 100644 --- a/client/coral-admin/src/AppRouter.js +++ b/client/coral-admin/src/AppRouter.js @@ -1,5 +1,5 @@ import React from 'react'; -import {Router, Route, IndexRoute, IndexRedirect, browserHistory} from 'react-router'; +import {Router, Route, IndexRedirect, browserHistory} from 'react-router'; import Stories from 'containers/Stories/Stories'; import Configure from 'containers/Configure/Configure'; @@ -18,7 +18,7 @@ const routes = (
    - + diff --git a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js index 8294e8c43..155d0c551 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js @@ -18,7 +18,7 @@ import I18n from 'coral-framework/modules/i18n/i18n'; import translations from 'coral-admin/src/translations.json'; const lang = new I18n(translations); -const Comment = ({actions = [], comment, viewUserDetail, ...props}) => { +const Comment = ({actions = [], comment, viewUserDetail, suspectWords, bannedWords, ...props}) => { const links = linkify.getMatches(comment.body); const linkText = links ? links.map(link => link.raw) : []; const flagActionSummaries = getActionSummary('FlagActionSummary', comment); @@ -30,6 +30,13 @@ const Comment = ({actions = [], comment, viewUserDetail, ...props}) => { commentType = 'flagged'; } + // since words are checked against word boundaries on the backend, + // this should be the behavior on the front end as well. + // currently the highlighter plugin does not support this out of the box. + const searchWords = [...suspectWords, ...bannedWords].filter(w => { + return new RegExp(`(^|\\s)${w}(\\s|$)`).test(comment.body); + }).concat(linkText); + return (
  • @@ -60,8 +67,8 @@ const Comment = ({actions = [], comment, viewUserDetail, ...props}) => {

    + searchWords={searchWords} + textToHighlight={comment.body} /> {lang.t('comment.view_context')}

    {links ? Contains Link : null} @@ -108,6 +115,7 @@ Comment.propTypes = { }).isRequired, asset: PropTypes.shape({ title: PropTypes.string, + url: PropTypes.string, id: PropTypes.string }) }) diff --git a/client/coral-admin/src/containers/ModerationQueue/components/ModerationMenu.js b/client/coral-admin/src/containers/ModerationQueue/components/ModerationMenu.js index 39594274e..7a50e6270 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/ModerationMenu.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/ModerationMenu.js @@ -28,12 +28,6 @@ const ModerationMenu = ( activeClassName={styles.active}> {lang.t('modqueue.all')} - - {lang.t('modqueue.approved')} - {lang.t('modqueue.flagged')} + + {lang.t('modqueue.approved')} + (

    {lang.t('configureCommentStream.title')}

    -

    {lang.t('configureCommentStream.description')}

    +

    {lang.t('configureCommentStream.description')}

    • diff --git a/docs/frontend/PLUGINS-experimental.md b/docs/frontend/PLUGINS-experimental.md new file mode 100644 index 000000000..895be5900 --- /dev/null +++ b/docs/frontend/PLUGINS-experimental.md @@ -0,0 +1,102 @@ +# Experimental plugins + +Talk plugins are, in essence, small programs that hook into the core application in a variety of ways. Ultimately, this code can do anything that javascript is capable of. In addition, plugins can import any core code to hook into talk at any level. + +If you want to write plugins that integrate with core code beyond the api described in [PLUGINS.md](PLUGINS.md), please keep the following things in mind: + +* core code may change and break your plugin +* you may introduce inefficiencies with your plugin that could hurt performance/crash Talk +* you may cause bugs in other areas of Talk + +If you'd like to build a supported plugin but don't have the hooks you need, please file an issue on this repo and we can discuss deepening the supported plugin api! + +With that said, here's some of the prime experimental integration points: + +## Reducers and Actions : Redux + +Talk is powered by Redux and our plugins can too! Our plugins can have their own reducers and actions. + +```js +import MyButton from './MyButton'; +import reducer from './reducer'; + +export default { + slots: { + commentDetail: [MyButton], + }, + reducer +}; +``` + +## Import Actions from Talk +We can easily trigger `Talk` actions in our plugin Components. + +```js +import React, {Component} from 'react'; +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import {addTag, removeTag} from 'coral-plugin-commentbox/actions'; + +class MyButton extends Component { + render() { + return ; + } +} + +const mapStateToProps = ({commentBox}) => ({commentBox}); + +const mapDispatchToProps = dispatch => + bindActionCreators({addTag, removeTag}, dispatch); + +export default connect(mapStateToProps, mapDispatchToProps)(OffTopicCheckbox); +``` + +## ESlint and Babel +In talk we use `eslint:recommended` and Babel with the latest ECMAScript Features. But you can use your own! +While building your plugin you need to specify a `.eslintrc.json` file and a`.babelrc` file. + +#### `.eslintrc.json` +```json +{ + "env": { + "browser": true, + "es6": true, + "mocha": true + }, + "parserOptions": { + "sourceType": "module", + "ecmaFeatures": { + "experimentalObjectRestSpread": true, + "jsx": true + } + }, + "parser": "babel-eslint", + "plugins": [ + "react" + ], + "rules": { + "react/jsx-uses-react": "error", + "react/jsx-uses-vars": "error", + "no-console": ["warn", { "allow": ["warn", "error"] }] + } +} +```` + + +#### `. babelrc ` +```json +{ + "presets": [ + "es2015" + ], + "plugins": [ + "add-module-exports", + "transform-class-properties", + "transform-decorators-legacy", + "transform-object-assign", + "transform-object-rest-spread", + "transform-async-to-generator", + "transform-react-jsx" + ] +} +```` diff --git a/docs/frontend/PLUGINS.md b/docs/frontend/PLUGINS.md new file mode 100644 index 000000000..fa582877c --- /dev/null +++ b/docs/frontend/PLUGINS.md @@ -0,0 +1,186 @@ +# Plugins +We can build plugins to extend the functionality of Talk. + +This guide is a walkthrough of our plugin architecture and components that we provide that allow you to build on top of Core coral components without having to understand the concepts there in. It is organized into three sections: + +* Plugin architecture +* Using our building block components +* Styling + +Advanced users will quickly realize that our plugins have complete access to core code. If you would like to write advanced plugins that reach outside of our published API as described in this document, please see [our notes on experimental pluginss](PLUGINS-experimental.md). + +Under the hood our plugins are powered by *React*, *Redux* and *GraphQL*. We can also build them with simple vanilla javascript. + +## Plugin Architecture + +The plugins live in the `/plugins` folder. Each plugin must have an `index.js` file and two folders `client` and `server`. + +### The Client Folder +The frontend of our plugin lives inside the `client` folder. The `client` folder must have an `index.js` file that exports the configuration of our plugin. + +``` +my-plugin/ + ├── client/ + │ └── index.js <-- index for client side functionality + ├── server/ + └── index.js <-- base plugin index +``` + +For now our base plugin `index.js` file should look like this: + +```js +export default { + // We will add more here later. +}; +``` + +### Creating a Component + +We can add our components (or any other javascript code) within the `client` folder. + +``` +my-plugin/ + ├── client/ + │ ├── MyComponent.js + │ └── index.js + ├── server/ + └── index.js +``` + +Our component could look like this: + +```js +import React, {Component} from 'react'; + +class MyButton extends Component { + render() { + return ; + } +} + +export default MyButton; +``` + +Here we create a component that renders a `button`. Now that we created our component we need to specify where it should get injected within Talk! + +To tell Talk where that Component should get injected we need to specify which *Slots* to insert it into. + +```js +import React from 'react'; +export default = () => ; +``` + +### Slots +In Talk we have defined specific *Slots* where we can inject components. + +Here is how we specify our slots config in `my-plugin/index.js` + +```js +import MyButton from './MyButton'; + +export default { + slots: { + commentDetail: [MyButton] + } +}; +``` + +Here I’m specifying that the MyComponent Component will take place within the `commentDetail` in Talk. + +`commentDetail` it’s a specific slot in the CommentStream. It means that it will be embedded inside de comment detail. + +Slots properties take an`Array` so we can add as many components as we want. + +## Building Blocks (TBD) + +`Note: the concepts in this section are still to be implemented. Code samples are for discussion and may change.` + +In order to allow you to build more complex plugins, we have wrapped some of our functionality in higher order componenets that expose a simple api. + +### Reactions + +Reactions provide users the ability to 'like', 'respect', etc... comments. + +``` + + + +``` + +Note: some server side work will need to accompany this client side component. See the like and respect plugins as examples. + +### Comment Stream + +Comment streams may be created with filtering and ordering in place: + +* filter by user +* filter by tag +* sort by date ascending / descrnding + +``` + +``` + +### Comment Commit hooks + +// docs for the pre/post comment submit commit hooks + +### Mod Queues + +Moderation queues can be added via configuration objects passed in through plugins. + +Basic mod queues will resemble the current moderation queues but can be generated from different lists of comments. + +* filter by user tag +* filter by comment tag +* filter by comment status +* Custom queries (paired with back end plugins that provide queries to get the data) + +#### Advanced mod queues + +Advanced mod queues can be created giving plugin authors the power to create the cards that appear in the queue, create actions and custom buttons, etc... + +### Custom Configuration + +Plugins may rely on configuration options that admins/moderators can set in the Configuration section. + +Basic settings can be added via json configuration in a plugin. + +* Setting headline +* Setting description +* Setting input type +* Default value +* Variable name + +#### Advanced Custom Configuration (low prioritiy) + +Users can inject configuration interfaces that they create into the configuration allowing for more advanced configuration. + + +## Styling Plugins +Talk uses CSS Modules. This basically means that you can also add your CSS Module to your plugin without colliding with the rest of Talk! + +##### My Component +```js +import styles from './style.css'; + +class MyCoralButton extends Component { + render() { + return ; + } +} +```` + +Our `style.css` should could look like this. +```css + +.button { + background: coral; + border-radius: 3px; +} +``` + + +### The server folder and the index file +Read more about the `/server` and how to extend Talk here. +[talk/PLUGINS.md at master · coralproject/talk · GitHub](https://github.com/coralproject/talk/blob/master/PLUGINS.md) diff --git a/graph/pubsub.js b/graph/pubsub.js index a5ee95b80..704f83725 100644 --- a/graph/pubsub.js +++ b/graph/pubsub.js @@ -2,4 +2,4 @@ const {RedisPubSub} = require('graphql-redis-subscriptions'); const {connectionOptions} = require('../services/redis'); -module.exports = new RedisPubSub(connectionOptions); +module.exports = new RedisPubSub({connection: connectionOptions}); From 40ff265ab1b4bbc4781358dc1eb7d08b7b45523b Mon Sep 17 00:00:00 2001 From: Riley Davis Date: Wed, 10 May 2017 11:07:48 -0600 Subject: [PATCH 05/11] merge master --- client/coral-admin/src/actions/moderation.js | 2 +- client/coral-admin/src/components/BanUserDialog.js | 8 ++++---- .../coral-admin/src/components/ModerationList.css | 2 ++ .../src/containers/Dashboard/Dashboard.js | 8 +------- .../ModerationQueue/ModerationContainer.js | 10 ++++++---- .../ModerationQueue/components/Comment.js | 6 +++--- client/coral-admin/src/reducers/moderation.js | 5 ++++- docs/frontend/PLUGINS.md | 14 ++------------ 8 files changed, 23 insertions(+), 32 deletions(-) diff --git a/client/coral-admin/src/actions/moderation.js b/client/coral-admin/src/actions/moderation.js index 5107669cf..a65b8c704 100644 --- a/client/coral-admin/src/actions/moderation.js +++ b/client/coral-admin/src/actions/moderation.js @@ -4,7 +4,7 @@ export const toggleModal = open => ({type: actions.TOGGLE_MODAL, open}); export const singleView = () => ({type: actions.SINGLE_VIEW}); // Ban User Dialog -export const showBanUserDialog = (user, commentId, showRejectedNote) => ({type: actions.SHOW_BANUSER_DIALOG, user, commentId, showRejectedNote}); +export const showBanUserDialog = (user, commentId, commentStatus, showRejectedNote) => ({type: actions.SHOW_BANUSER_DIALOG, user, commentId, commentStatus, showRejectedNote}); export const hideBanUserDialog = (showDialog) => ({type: actions.HIDE_BANUSER_DIALOG, showDialog}); // hide shortcuts note diff --git a/client/coral-admin/src/components/BanUserDialog.js b/client/coral-admin/src/components/BanUserDialog.js index ee5830249..b259012f3 100644 --- a/client/coral-admin/src/components/BanUserDialog.js +++ b/client/coral-admin/src/components/BanUserDialog.js @@ -8,14 +8,14 @@ import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../translations'; const lang = new I18n(translations); -const onBanClick = (userId, commentId, handleBanUser, rejectComment, handleClose) => (e) => { +const onBanClick = (userId, commentId, commentStatus, handleBanUser, rejectComment, handleClose) => (e) => { e.preventDefault(); handleBanUser({userId}) .then(handleClose) - .then(() => rejectComment({commentId})); + .then(() => commentStatus === 'REJECTED' ? null : rejectComment({commentId})); }; -const BanUserDialog = ({open, handleClose, handleBanUser, rejectComment, user, commentId, showRejectedNote}) => ( +const BanUserDialog = ({open, handleClose, handleBanUser, rejectComment, user, commentId, commentStatus, showRejectedNote}) => ( {lang.t('bandialog.cancel')} -
    diff --git a/client/coral-admin/src/components/ModerationList.css b/client/coral-admin/src/components/ModerationList.css index 1e871f97c..4d4f37c9a 100644 --- a/client/coral-admin/src/components/ModerationList.css +++ b/client/coral-admin/src/components/ModerationList.css @@ -193,10 +193,12 @@ box-shadow: none; color: white; background-color: #519954; + cursor: not-allowed; } .reject__active, .rejected__active { color: white; background-color: #D03235; box-shadow: none; + cursor: not-allowed; } diff --git a/client/coral-admin/src/containers/Dashboard/Dashboard.js b/client/coral-admin/src/containers/Dashboard/Dashboard.js index f5d0aa39b..e6cfa27cd 100644 --- a/client/coral-admin/src/containers/Dashboard/Dashboard.js +++ b/client/coral-admin/src/containers/Dashboard/Dashboard.js @@ -6,7 +6,6 @@ import {getMetrics} from 'coral-admin/src/graphql/queries'; import FlagWidget from './FlagWidget'; import ActivityWidget from './ActivityWidget'; import CountdownTimer from 'coral-admin/src/components/CountdownTimer'; -import {showBanUserDialog, hideBanUserDialog} from 'coral-admin/src/actions/moderation'; import {Spinner} from 'coral-ui'; @@ -43,12 +42,7 @@ const mapStateToProps = state => { }; }; -const mapDispatchToProps = dispatch => ({ - showBanUserDialog: (user, commentId) => dispatch(showBanUserDialog(user, commentId)), - hideBanUserDialog: () => dispatch(hideBanUserDialog(false)) -}); - export default compose( - connect(mapStateToProps, mapDispatchToProps), + connect(mapStateToProps), getMetrics )(Dashboard); diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js index 480666637..121871de7 100644 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js @@ -52,12 +52,13 @@ class ModerationContainer extends Component { const {acceptComment, rejectComment} = this.props; const {selectedIndex} = this.state; const comments = this.getComments(); - const commentId = {commentId: comments[selectedIndex].id}; + const comment = comments[selectedIndex]; + const commentId = {commentId: comment.id}; if (accept) { - acceptComment(commentId); + comment.status !== 'ACCEPTED' && acceptComment(commentId); } else { - rejectComment(commentId); + comment.status !== 'REJECTED' && rejectComment(commentId); } } @@ -196,6 +197,7 @@ class ModerationContainer extends Component { open={moderation.banDialog} user={moderation.user} commentId={moderation.commentId} + commentStatus={moderation.commentStatus} handleClose={props.hideBanUserDialog} handleBanUser={props.banUser} showRejectedNote={moderation.showRejectedNote} @@ -226,7 +228,7 @@ const mapDispatchToProps = dispatch => ({ fetchSettings: () => dispatch(fetchSettings()), viewUserDetail: id => dispatch(viewUserDetail(id)), hideUserDetail: () => dispatch(hideUserDetail()), - showBanUserDialog: (user, commentId, showRejectedNote) => dispatch(showBanUserDialog(user, commentId, showRejectedNote)), + showBanUserDialog: (user, commentId, commentStatus, showRejectedNote) => dispatch(showBanUserDialog(user, commentId, commentStatus, showRejectedNote)), hideBanUserDialog: () => dispatch(hideBanUserDialog(false)), hideShortcutsNote: () => dispatch(hideShortcutsNote()), }); diff --git a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js index 155d0c551..b38e0c4f0 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js @@ -48,7 +48,7 @@ const Comment = ({actions = [], comment, viewUserDetail, suspectWords, bannedWor {timeago().format(comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))} - props.showBanUserDialog(comment.user, comment.id, comment.status !== 'REJECTED')} /> + props.showBanUserDialog(comment.user, comment.id, comment.status, comment.status !== 'REJECTED')} />
    {comment.user.status === 'banned' ? @@ -81,8 +81,8 @@ const Comment = ({actions = [], comment, viewUserDetail, suspectWords, bannedWor user={comment.user} status={comment.status} active={active} - acceptComment={() => props.acceptComment({commentId: comment.id})} - rejectComment={() => props.rejectComment({commentId: comment.id})} />; + acceptComment={() => comment.status === 'ACCEPTED' ? null : props.acceptComment({commentId: comment.id})} + rejectComment={() => comment.status === 'REJECTED' ? null : props.rejectComment({commentId: comment.id})} />; })}
    diff --git a/client/coral-admin/src/reducers/moderation.js b/client/coral-admin/src/reducers/moderation.js index d8b9c59bb..b0ff99617 100644 --- a/client/coral-admin/src/reducers/moderation.js +++ b/client/coral-admin/src/reducers/moderation.js @@ -6,6 +6,7 @@ const initialState = Map({ modalOpen: false, user: Map({}), commentId: null, + commentStatus: null, userDetailId: null, banDialog: false, shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show' @@ -15,12 +16,14 @@ export default function moderation (state = initialState, action) { switch (action.type) { case actions.HIDE_BANUSER_DIALOG: return state - .set('banDialog', false); + .set('banDialog', false) + .set('commentStatus', null); case actions.SHOW_BANUSER_DIALOG: return state .merge({ user: Map(action.user), commentId: action.commentId, + commentStatus: action.commentStatus, showRejectedNote: action.showRejectedNote, banDialog: true }); diff --git a/docs/frontend/PLUGINS.md b/docs/frontend/PLUGINS.md index fa582877c..8377d0359 100644 --- a/docs/frontend/PLUGINS.md +++ b/docs/frontend/PLUGINS.md @@ -95,18 +95,12 @@ Slots properties take an`Array` so we can add as many components as we want. `Note: the concepts in this section are still to be implemented. Code samples are for discussion and may change.` -In order to allow you to build more complex plugins, we have wrapped some of our functionality in higher order componenets that expose a simple api. +In order to allow you to build more complex plugins, we have wrapped some of our functionality in higher order components that expose a simple api. ### Reactions Reactions provide users the ability to 'like', 'respect', etc... comments. -``` - - - -``` - Note: some server side work will need to accompany this client side component. See the like and respect plugins as examples. ### Comment Stream @@ -115,11 +109,7 @@ Comment streams may be created with filtering and ordering in place: * filter by user * filter by tag -* sort by date ascending / descrnding - -``` - -``` +* sort by date ascending / descending ### Comment Commit hooks From 602c3d0be286e617007f0f2b092992074818075f Mon Sep 17 00:00:00 2001 From: riley Date: Wed, 10 May 2017 12:28:33 -0600 Subject: [PATCH 06/11] use a slot instead of trying to use facebook directly --- .../src/containers/ModerationQueue/UserDetail.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/client/coral-admin/src/containers/ModerationQueue/UserDetail.js b/client/coral-admin/src/containers/ModerationQueue/UserDetail.js index 06587065e..a305118a7 100644 --- a/client/coral-admin/src/containers/ModerationQueue/UserDetail.js +++ b/client/coral-admin/src/containers/ModerationQueue/UserDetail.js @@ -3,6 +3,7 @@ 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 = { @@ -29,19 +30,17 @@ class UserDetail extends React.Component { const {user} = data; const localProfile = user.profiles.find(p => p.provider === 'local'); - const facebookProfile = user.profiles.find(p => p.provider === 'facebook'); let profile; if (localProfile) { profile = localProfile.id; - } else if (facebookProfile) { - profile = {facebookProfile.id}; } return (

    {user.username}

    -

    this.profile = ref} contentEditable="true">{profile}

    + {profile &&

    this.profile = ref} contentEditable="true">{profile}

    } +

    Member since {new Date(user.created_at).toLocaleString()}


    From 50db2d1dda44c0c89e35792a53155f7795bb56ca Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 15 May 2017 15:28:57 -0600 Subject: [PATCH 07/11] Initial merge of trust functionality --- graph/mutators/comment.js | 101 ++++++++++++++++++++++++- graph/resolvers/user.js | 9 +++ graph/typeDefs.graphql | 21 ++++++ services/karma.js | 155 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 services/karma.js diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index 52440593d..621494db0 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -1,12 +1,91 @@ +const debug = require('debug')('talk:graph:mutators:comment'); const errors = require('../../errors'); +const ActionModel = require('../../models/action'); const AssetsService = require('../../services/assets'); const ActionsService = require('../../services/actions'); const CommentsService = require('../../services/comments'); +const KarmaService = require('../../services/karma'); const linkify = require('linkify-it')(); const Wordlist = require('../../services/wordlist'); +/** + * adjustKarma will adjust the affected user's karma depending on the moderators + * action. + */ +const adjustKarma = (Comments, id, status) => async () => { + try { + + // Use the dataloader to get the comment that was just moderated and + // get the flag user's id's so we can adjust their karma too. + let [ + comment, + flagUserIDs + ] = await Promise.all([ + + // Load the comment that was just made/updated by the setCommentStatus + // operation. + Comments.get.load(id), + + // Find all the flag actions that were referenced by this comment + // at this point in time. + ActionModel.find({ + item_id: id, + item_type: 'COMMENTS', + action_type: 'FLAG' + }).then((actions) => { + + // This is to ensure that this is always an array. + if (!actions) { + return []; + } + + return actions.map(({user_id}) => user_id); + }) + ]); + + debug(`Comment[${id}] by User[${comment.author_id}] was Status[${status}]`); + + switch (status) { + case 'REJECTED': + + // Reduce the user's karma. + debug(`CommentUser[${comment.author_id}] had their karma reduced`); + + // Decrease the flag user's karma, the moderator disagreed with this + // action. + debug(`FlaggingUser[${flagUserIDs.join(', ')}] had their karma increased`); + await Promise.all([ + KarmaService.modifyUser(comment.author_id, -1, 'comment'), + KarmaService.modifyUser(flagUserIDs, 1, 'flag', true) + ]); + + break; + + case 'ACCEPTED': + + // Increase the user's karma. + debug(`CommentUser[${comment.author_id}] had their karma increased`); + + // Increase the flag user's karma, the moderator agreed with this + // action. + debug(`FlaggingUser[${flagUserIDs.join(', ')}] had their karma reduced`); + await Promise.all([ + KarmaService.modifyUser(comment.author_id, 1, 'comment'), + KarmaService.modifyUser(flagUserIDs, -1, 'flag', true) + ]); + + break; + + } + + return; + } catch (e) { + console.error(e); + } +}; + /** * Creates a new comment. * @param {Object} user the user performing the request @@ -86,6 +165,7 @@ const filterNewComment = (context, {body, asset_id}) => { * @return {Promise} resolves to the comment's status */ const resolveNewCommentStatus = async (context, {asset_id, body}, wordlist = {}, settings = {}) => { + let {user} = context; // Check to see if the body is too short, if it is, then complain about it! if (body.length < 2) { @@ -123,6 +203,22 @@ const resolveNewCommentStatus = async (context, {asset_id, body}, wordlist = {}, return 'REJECTED'; } + if (user && user.metadata) { + + // If the user is not a reliable commenter (passed the unreliability + // threshold by having too many rejected comments) then we can change the + // status of the comment to `PREMOD`, therefore pushing the user's comments + // away from the public eye until a moderator can manage them. This of + // course can only be applied if the comment's current status is `NONE`, + // we don't want to interfere if the comment was rejected. + if (KarmaService.isReliable('comment', user.metadata.trust) === false) { + + // Update the response from the comment creation to add the PREMOD so that + // that user's UI will reflect the fact that their comment is in pre-mod. + return 'PREMOD'; + } + } + return moderation === 'PRE' ? 'PREMOD' : 'NONE'; }; @@ -179,7 +275,6 @@ const createPublicComment = async (context, commentInput) => { * @param {String} id identifier of the comment (uuid) * @param {String} status the new status of the comment */ - const setStatus = async ({user, loaders: {Comments}}, {id, status}) => { let comment = await CommentsService.pushStatus(id, status, user ? user.id : null); @@ -196,6 +291,10 @@ const setStatus = async ({user, loaders: {Comments}}, {id, status}) => { Comments.countByAssetID.clear(comment.asset_id); + // postSetCommentStatus will use the arguments from the mutation and + // adjust the affected user's karma in the next tick. + process.nextTick(adjustKarma(Comments, id, status)); + return comment; }; diff --git a/graph/resolvers/user.js b/graph/resolvers/user.js index 9b25c812f..b9b72501e 100644 --- a/graph/resolvers/user.js +++ b/graph/resolvers/user.js @@ -1,3 +1,5 @@ +const KarmaService = require('../../services/karma'); + const User = { action_summaries({id}, _, {loaders: {Actions}}) { return Actions.getSummariesByItemID.load(id); @@ -43,6 +45,13 @@ const User = { } return null; + }, + + // Extract the reliability from the user metadata if they have permission. + reliable(user, _, {user: requestingUser}) { + if (requestingUser && requestingUser.hasRoles('ADMIN')) { + return KarmaService.model(user); + } } }; diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index f51258eb1..a9348f706 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -5,6 +5,22 @@ # Date represented as an ISO8601 string. scalar Date +################################################################################ +## Reliability +################################################################################ + +# Reliability defines how a given user should be considered reliable for their +# comment or flag activity. +type Reliability { + + # flagger will be `true` when the flagger is reliable, `false` if not, or + # `null` if the reliability cannot be determined. + flagger: Boolean + + # commenter will be `true` when the commenter is reliable, `false` if not, or + # `null` if the reliability cannot be determined. + commenter: Boolean +} ################################################################################ ## Users @@ -48,6 +64,11 @@ type User { # returns all comments based on a query. comments(query: CommentsQuery): [Comment!] + # reliable is the reference to a given user's Reliability. If the requesting + # user does not have permission to access the reliability, null will be + # returned. + reliable: Reliability + # returns user status status: USER_STATUS } diff --git a/services/karma.js b/services/karma.js new file mode 100644 index 000000000..ea932c86a --- /dev/null +++ b/services/karma.js @@ -0,0 +1,155 @@ +const debug = require('debug')('talk:trust'); +const UserModel = require('../models/user'); + +/** + * This will create an object with the property name of the action type as the + * key and an object as it's value. This will contain a RELIABLE, and UNRELIABLE + * property with the number of karma points associated with their particular + * state. + * + * If only the RELIABLE variable is provided, then it will also be used as the + * UNRELIABLE variable. + * + * The form of the environment variable is: + * + * :,;:,;... + * + * The default used is: + * + * comment:1,1;flag:-1,-1 + */ +const parseThresholds = (thresholds) => thresholds + .split(';') + .filter((threshold) => threshold && threshold.length > 0) + .reduce((acc, threshold) => { + const thresholds = threshold.split(':'); + if (thresholds.length < 2) { + return acc; + } + + let [name, values] = thresholds; + let [RELIABLE, UNRELIABLE] = values.split(',').map((value) => parseInt(value)); + + if (!(name in acc)) { + acc[name] = {}; + } + + if (isNaN(UNRELIABLE) && !isNaN(RELIABLE)) { + acc[name].RELIABLE = RELIABLE; + acc[name].UNRELIABLE = RELIABLE; + } else { + if (!isNaN(UNRELIABLE)) { + acc[name].UNRELIABLE = UNRELIABLE; + } + + if (!isNaN(RELIABLE)) { + acc[name].RELIABLE = RELIABLE; + } + } + + return acc; + }, { + comment: { + RELIABLE: -1, + UNRELIABLE: -1 + }, + flag: { + RELIABLE: -1, + UNRELIABLE: -1 + } + }); + +const THRESHOLDS = parseThresholds(process.env.TRUST_THRESHOLDS || ''); + +debug('using thresholds: ', THRESHOLDS); + +/** + * KarmaModel represents the checkable properties of a user and wrapps the + * KarmaService function `isReliable` to work flexibly with the graph. + */ +class KarmaModel { + constructor(model) { + this.model = model; + } + + get flagger() { + return KarmaService.isReliable('flag', this.model); + } + + get commenter() { + return KarmaService.isReliable('comment', this.model); + } +} + +/** + * KarmaService provides interfaces for editing a user's karma. + */ +class KarmaService { + + /** + * Model returns a KarmaModel based on the passed in user. + */ + static model(user) { + if (user === null || !user.metadata || !user.metadata.trust) { + return new KarmaModel({}); + } + + return new KarmaModel(user.metadata.trust); + } + + /** + * Inspects the reliability of a property and returns it if known. + * @param {String} name - name of the property + * @param {Object} trust - object possibly containing the propertys + */ + static isReliable(name, trust) { + if (trust && trust[name]) { + if (trust[name].karma > THRESHOLDS[name].RELIABLE) { + return true; + } else if (trust[name].karma < THRESHOLDS[name].UNRELIABLE) { + return false; + } + } else if (THRESHOLDS[name].RELIABLE < 0) { + return true; + } else if (THRESHOLDS[name].UNRELIABLE > 0) { + return false; + } + + return null; + } + + /** + * modifyUserKarma updates the user to adjust their karma, for either the `type` + * of 'comment' or 'flag'. If `multi` is true, then it assumes that `id` is an + * array of id's. + */ + static async modifyUser(id, direction = 1, type = 'comment', multi = false) { + const key = `metadata.trust.${type}.karma`; + + let update = { + $inc: { + [key]: direction + } + }; + + if (multi) { + + // If it was in multi-mode but there was no user's to adjust, bail. + if (id.length <= 0) { + return; + } + + return UserModel.update({ + id: { + $in: id + } + }, update, { + multi: true + }); + } + + return UserModel.update({id}, update); + } +} + +module.exports = KarmaService; From e90ce7f4c3265cbc052470680d79bf2e6be4915c Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 16 May 2017 10:20:16 -0600 Subject: [PATCH 08/11] fixed merge issue --- graph/resolvers/root_query.js | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js index 34928ee52..7fd072854 100644 --- a/graph/resolvers/root_query.js +++ b/graph/resolvers/root_query.js @@ -79,19 +79,6 @@ const RootQuery = { return user; }, - myIgnoredUsers: async (_, args, {user, loaders: {Users}}) => { - if (!user) { - return null; - } - - // get currentUser again since context.user was out of date when running test/graph/mutations/ignoreUser - const currentUser = (await Users.getByQuery({ids: [user.id], limit: 1}))[0]; - if (!(currentUser && Array.isArray(currentUser.ignoresUsers) && currentUser.ignoresUsers.length)) { - return []; - } - return await Users.getByQuery({ids: currentUser.ignoresUsers}); - }, - // this returns an arbitrary user user(_, {id}, {user, loaders: {Users}}) { if (user == null || !user.hasRoles('ADMIN')) { From f4b91914767588fa6c3810ad80160140f1dbd185 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 16 May 2017 14:19:40 -0600 Subject: [PATCH 09/11] Added author_id to comment count query --- app.js | 8 ++- graph/loaders/comments.js | 8 ++- graph/resolvers/root_query.js | 6 +- graph/subscriptions.js | 21 +++--- graph/typeDefs.graphql | 6 +- views/graphiql.ejs | 119 ++++++++++++++++++++++++++++++++++ 6 files changed, 148 insertions(+), 20 deletions(-) create mode 100644 views/graphiql.ejs diff --git a/app.js b/app.js index f5243f81a..9aca8b0ef 100644 --- a/app.js +++ b/app.js @@ -69,9 +69,11 @@ app.use('/api/v1/graph/ql', apollo.graphqlExpress(createGraphOptions)); if (app.get('env') !== 'production') { // Interactive graphiql interface. - app.use('/api/v1/graph/iql', apollo.graphiqlExpress({ - endpointURL: '/api/v1/graph/ql' - })); + app.use('/api/v1/graph/iql', (req, res) => { + res.render('graphiql', { + endpointURL: '/api/v1/graph/ql' + }); + }); // GraphQL documention. app.get('/admin/docs', (req, res) => { diff --git a/graph/loaders/comments.js b/graph/loaders/comments.js index ccee16ca7..96f0e8f73 100644 --- a/graph/loaders/comments.js +++ b/graph/loaders/comments.js @@ -120,7 +120,7 @@ const getParentCountByAssetIDPersonalized = async (context, {assetId, excludeIgn const ignoredUsers = freshUser.ignoresUsers; query.author_id = {$nin: ignoredUsers}; } - + return CommentModel.where(query).count(); }; @@ -191,7 +191,7 @@ const getCountByParentIDPersonalized = async (context, {id, excludeIgnored}) => * @return {Promise} resolves to the counts of the comments from the * query */ -const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id}) => { +const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id, author_id}) => { let query = CommentModel.find(); if (ids) { @@ -210,6 +210,10 @@ const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id}) = query = query.where({parent_id}); } + if (author_id) { + query = query.where({author_id}); + } + return CommentModel .find(query) .count(); diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js index 7fd072854..4f79b1c18 100644 --- a/graph/resolvers/root_query.js +++ b/graph/resolvers/root_query.js @@ -34,7 +34,7 @@ const RootQuery = { comment(_, {id}, {loaders: {Comments}}) { return Comments.get.load(id); }, - async commentCount(_, {query: {action_type, statuses, asset_id, parent_id}}, {user, loaders: {Actions, Comments}}) { + async commentCount(_, {query: {action_type, statuses, asset_id, parent_id, author_id}}, {user, loaders: {Actions, Comments}}) { if (user == null || !user.hasRoles('ADMIN')) { return null; } @@ -43,10 +43,10 @@ const RootQuery = { let ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'}); // Perform the query using the available resolver. - return Comments.getCountByQuery({ids, statuses, asset_id, parent_id}); + return Comments.getCountByQuery({ids, statuses, asset_id, parent_id, author_id}); } - return Comments.getCountByQuery({statuses, asset_id, parent_id}); + return Comments.getCountByQuery({statuses, asset_id, parent_id, author_id}); }, assetMetrics(_, {from, to, sort, limit = 10}, {user, loaders: {Metrics: {Assets}}}) { diff --git a/graph/subscriptions.js b/graph/subscriptions.js index 369b5c058..2eb676cc4 100644 --- a/graph/subscriptions.js +++ b/graph/subscriptions.js @@ -1,6 +1,7 @@ const {SubscriptionManager} = require('graphql-subscriptions'); const {SubscriptionServer} = require('subscriptions-transport-ws'); const _ = require('lodash'); +const debug = require('debug')('talk:graph:subscriptions'); const pubsub = require('./pubsub'); const schema = require('./schema'); @@ -9,24 +10,22 @@ const plugins = require('../services/plugins'); const {deserializeUser} = require('../services/subscriptions'); -// Core setup functions -let setupFunctions = { - commentAdded: (options, args) => ({ - commentAdded: { - filter: (comment) => comment.asset_id === args.asset_id - }, - }), -}; - /** * Plugin support requires that we merge in existing setupFunctions with our new * plugin based ones. This allows plugins to extend existing setupFunctions as well * as provide new ones. */ -setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {setupFunctions}) => { +const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plugin, setupFunctions}) => { + debug(`added plugin '${plugin.name}'`); return _.merge(acc, setupFunctions); -}, setupFunctions); +}, { + commentAdded: (options, args) => ({ + commentAdded: { + filter: (comment) => comment.asset_id === args.asset_id + }, + }), +}); /** * This creates a new subscription manager. diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index d4f035da6..b11889865 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -194,6 +194,10 @@ input CommentCountQuery { # type. action_type: ACTION_TYPE + # author_id allows the querying of comment counts based on the author of the + # comments. + author_id: ID + # Filter by a specific tag name. tag: [String] } @@ -779,7 +783,7 @@ type EditCommentResponse implements Response { comment: Comment # An array of errors relating to the mutation that occured. - errors: [UserError] + errors: [UserError] } # All mutations for the application are defined on this object. diff --git a/views/graphiql.ejs b/views/graphiql.ejs new file mode 100644 index 000000000..5f1759c0d --- /dev/null +++ b/views/graphiql.ejs @@ -0,0 +1,119 @@ + + + + + + GraphiQL + + + + + + + + + + + + \ No newline at end of file From fde4dee52f595023431344abf678cafa8103003c Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 16 May 2017 17:26:34 -0600 Subject: [PATCH 10/11] Resolver cleanup --- graph/loaders/users.js | 10 +++++++++- graph/resolvers/root_query.js | 33 ++++++++++++++------------------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/graph/loaders/users.js b/graph/loaders/users.js index 145b0dfdb..c4850eaf0 100644 --- a/graph/loaders/users.js +++ b/graph/loaders/users.js @@ -15,7 +15,7 @@ const genUserByIDs = (context, ids) => UsersService * @param {Object} context graph context * @param {Object} query query terms to apply to the users query */ -const getUsersByQuery = ({user}, {ids, limit, cursor, sort}) => { +const getUsersByQuery = ({user}, {ids, limit, cursor, statuses = null, sort}) => { let users = UserModel.find(); @@ -27,6 +27,14 @@ const getUsersByQuery = ({user}, {ids, limit, cursor, sort}) => { }); } + if (statuses != null) { + users = users.where({ + status: { + $in: statuses + } + }); + } + if (cursor) { if (sort === 'REVERSE_CHRONOLOGICAL') { users = users.where({ diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js index 4f79b1c18..8c1552b5f 100644 --- a/graph/resolvers/root_query.js +++ b/graph/resolvers/root_query.js @@ -19,34 +19,32 @@ const RootQuery = { // This endpoint is used for loading moderation queues, so hide it in the // event that we aren't an admin. - async comments(_, {query: {action_type, statuses, asset_id, parent_id, limit, cursor, sort, excludeIgnored}}, {user, loaders: {Comments, Actions}}) { - let query = {statuses, asset_id, parent_id, limit, cursor, sort, excludeIgnored}; + async comments(_, {query}, {user, loaders: {Comments, Actions}}) { + let {action_type} = query; if (user != null && user.hasRoles('ADMIN') && action_type) { - let ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'}); - - // Perform the query using the available resolver. - return Comments.getByQuery({ids, statuses, asset_id, parent_id, limit, cursor, sort, excludeIgnored}); + query.ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'}); } return Comments.getByQuery(query); }, + comment(_, {id}, {loaders: {Comments}}) { return Comments.get.load(id); }, - async commentCount(_, {query: {action_type, statuses, asset_id, parent_id, author_id}}, {user, loaders: {Actions, Comments}}) { + + async commentCount(_, {query}, {user, loaders: {Actions, Comments}}) { if (user == null || !user.hasRoles('ADMIN')) { return null; } - if (action_type) { - let ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'}); + const {action_type} = query; - // Perform the query using the available resolver. - return Comments.getCountByQuery({ids, statuses, asset_id, parent_id, author_id}); + if (action_type) { + query.ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'}); } - return Comments.getCountByQuery({statuses, asset_id, parent_id, author_id}); + return Comments.getCountByQuery(query); }, assetMetrics(_, {from, to, sort, limit = 10}, {user, loaders: {Metrics: {Assets}}}) { @@ -90,19 +88,16 @@ const RootQuery = { // This endpoint is used for loading the user moderation queues (users whose username has been flagged), // so hide it in the event that we aren't an admin. - async users(_, {query: {action_type, limit, cursor, sort}}, {user, loaders: {Users, Actions}}) { - + async users(_, {query}, {user, loaders: {Users, Actions}}) { if (user == null || !user.hasRoles('ADMIN')) { return null; } - const query = {limit, cursor, sort}; + const {action_type} = query; if (action_type) { - let ids = await Actions.getByTypes({action_type, item_type: 'USERS'}); - - // Perform the query using the available resolver. - return Users.getByQuery({ids, limit, cursor, sort}).find({status: 'PENDING'}); + query.ids = await Actions.getByTypes({action_type, item_type: 'USERS'}); + query.statuses = ['PENDING']; } return Users.getByQuery(query); From a0f41bb19dd29c69b336799484a7d2925b3ce577 Mon Sep 17 00:00:00 2001 From: Riley Davis Date: Wed, 17 May 2017 10:54:14 -0600 Subject: [PATCH 11/11] show total comments and rejected % --- .../ModerationQueue/ModerationContainer.js | 6 +++++- .../containers/ModerationQueue/UserDetail.css | 20 +++++++++++++++++++ .../containers/ModerationQueue/UserDetail.js | 17 +++++++++++++++- .../coral-admin/src/graphql/queries/index.js | 2 +- .../src/graphql/queries/userDetail.graphql | 6 ++++-- client/coral-ui/components/Drawer.css | 2 ++ 6 files changed, 48 insertions(+), 5 deletions(-) diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js index 52282f630..e6062674d 100644 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js @@ -208,7 +208,11 @@ class ModerationContainer extends Component { shortcutsNoteVisible={moderation.shortcutsNoteVisible} open={moderation.modalOpen} onClose={onClose}/> - {moderation.userDetailId && } + {moderation.userDetailId && ( + + )}

  • ); } diff --git a/client/coral-admin/src/containers/ModerationQueue/UserDetail.css b/client/coral-admin/src/containers/ModerationQueue/UserDetail.css index 9f41a9d8d..eb3f2e4c7 100644 --- a/client/coral-admin/src/containers/ModerationQueue/UserDetail.css +++ b/client/coral-admin/src/containers/ModerationQueue/UserDetail.css @@ -10,3 +10,23 @@ .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; + } +} diff --git a/client/coral-admin/src/containers/ModerationQueue/UserDetail.js b/client/coral-admin/src/containers/ModerationQueue/UserDetail.js index 02e28dd84..200b8537d 100644 --- a/client/coral-admin/src/containers/ModerationQueue/UserDetail.js +++ b/client/coral-admin/src/containers/ModerationQueue/UserDetail.js @@ -28,13 +28,20 @@ class UserDetail extends React.Component { return null; } - const {user} = data; + 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 (

    {user.username}

    @@ -48,6 +55,14 @@ class UserDetail extends React.Component {
    Data represents the last six months of activity

    +
    +

    Total Comments

    +

    {totalComments}

    +
    +
    +

    Reject Rate

    +

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

    +
    ); diff --git a/client/coral-admin/src/graphql/queries/index.js b/client/coral-admin/src/graphql/queries/index.js index 9caa90ad0..ae94d70c3 100644 --- a/client/coral-admin/src/graphql/queries/index.js +++ b/client/coral-admin/src/graphql/queries/index.js @@ -99,7 +99,7 @@ export const modQueueResort = (id, fetchMore) => (sort) => { export const getUserDetail = graphql(USER_DETAIL, { options: ({id}) => { return { - variables: {id} + variables: {author_id: id} }; } }); diff --git a/client/coral-admin/src/graphql/queries/userDetail.graphql b/client/coral-admin/src/graphql/queries/userDetail.graphql index 6d42682ea..c375b80ff 100644 --- a/client/coral-admin/src/graphql/queries/userDetail.graphql +++ b/client/coral-admin/src/graphql/queries/userDetail.graphql @@ -1,5 +1,5 @@ -query UserDetail ($id: ID!) { - user(id: $id) { +query UserDetail ($author_id: ID!) { + user(id: $author_id) { id username created_at @@ -8,4 +8,6 @@ query UserDetail ($id: ID!) { provider } } + totalComments: commentCount(query: {author_id: $author_id}) + rejectedComments: commentCount(query: {author_id: $author_id, statuses: [REJECTED]}) } diff --git a/client/coral-ui/components/Drawer.css b/client/coral-ui/components/Drawer.css index b967b20ab..d22512a3a 100644 --- a/client/coral-ui/components/Drawer.css +++ b/client/coral-ui/components/Drawer.css @@ -1,5 +1,6 @@ .drawer { max-width: 700px; + min-width: 400px; padding: 20px; position: fixed; top: 0; @@ -8,6 +9,7 @@ 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 {