Merge branch 'master' into story-139830877-bug-local-user

This commit is contained in:
Gabriela Rodríguez Berón
2017-02-16 08:54:58 -08:00
committed by GitHub
61 changed files with 732 additions and 316 deletions
+2 -2
View File
@@ -4,22 +4,22 @@ import {Router, Route, IndexRoute, IndexRedirect, browserHistory} from 'react-ro
import Streams from 'containers/Streams/Streams';
import Configure from 'containers/Configure/Configure';
import LayoutContainer from 'containers/LayoutContainer';
import CommentStream from 'containers/CommentStream/CommentStream';
import InstallContainer from 'containers/Install/InstallContainer';
import CommunityContainer from 'containers/Community/CommunityContainer';
import ModerationLayout from 'containers/ModerationQueue/ModerationLayout';
import ModerationContainer from 'containers/ModerationQueue/ModerationContainer';
import Dashboard from 'containers/Dashboard/Dashboard';
const routes = (
<div>
<Route exact path="/admin/install" component={InstallContainer}/>
<Route path='/admin' component={LayoutContainer}>
<IndexRoute component={ModerationContainer} />
<Route path='embed' component={CommentStream} />
<Route path='community' component={CommunityContainer} />
<Route path='configure' component={Configure} />
<Route path='streams' component={Streams} />
<Route path='dashboard' component={Dashboard} />
{/* Moderation Routes */}
-103
View File
@@ -1,103 +0,0 @@
import coralApi from '../../../coral-framework/helpers/response';
import * as commentTypes from '../constants/comments';
import * as actionTypes from '../constants/actions';
function addUsersCommentsActions (dispatch, {comments, users, actions}) {
dispatch({type: commentTypes.USERS_MODERATION_QUEUE_FETCH_SUCCESS, users});
dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS, comments});
dispatch({type: actionTypes.ACTIONS_MODERATION_QUEUE_FETCH_SUCCESS, actions});
}
// Get comments to fill each of the three lists on the mod queue
export const fetchModerationQueueComments = () => {
return dispatch => {
dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST});
return Promise.all([
coralApi('/queue/comments/premod'),
coralApi('/queue/users/flagged'),
coralApi('/queue/comments/rejected'),
coralApi('/queue/comments/flagged')
])
.then(([premodComments, pendingUsers, rejected, flagged]) => {
/* Combine seperate calls into a single object */
flagged.comments.forEach(comment => comment.flagged = true);
return {
comments: [...premodComments.comments, ...rejected.comments, ...flagged.comments],
users: [...premodComments.users, ...pendingUsers.users, ...rejected.users, ...flagged.users],
actions: [...premodComments.actions, ...pendingUsers.actions, ...rejected.actions, ...flagged.actions]
};
})
.then(addUsersCommentsActions.bind(this, dispatch));
};
};
export const fetchPremodQueue = () => {
return dispatch => {
dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST});
return coralApi('/queue/comments/premod')
.then(addUsersCommentsActions.bind(this, dispatch));
};
};
export const fetchPendingUsersQueue = () => {
return dispatch => {
dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST});
return coralApi('/queue/users/flagged')
.then(addUsersCommentsActions.bind(this, dispatch));
};
};
export const fetchRejectedQueue = () => {
return dispatch => {
dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST});
return coralApi('/queue/comments/rejected')
.then(addUsersCommentsActions.bind(this, dispatch));
};
};
export const fetchFlaggedQueue = () => {
return dispatch => {
dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST});
return coralApi('/queue/comments/flagged')
.then(results => {
results.comments.forEach(comment => comment.flagged = true);
return results;
})
.then(addUsersCommentsActions.bind(this, dispatch));
};
};
// Create a new comment
export const createComment = (name, body) => {
return (dispatch) => {
const formData = {body, name};
return coralApi('/comments', {method: 'POST', body: formData})
.then(res => dispatch({type: commentTypes.COMMENT_CREATE_SUCCESS, comment: res}))
.catch(error => dispatch({type: commentTypes.COMMENT_CREATE_FAILED, error}));
};
};
/**
* Action disptacher related to comments
*/
// Update a comment. Now to update a comment we need to send back the whole object
export const updateStatus = (status, comment) => {
return dispatch => {
dispatch({type: commentTypes.COMMENT_STATUS_UPDATE_REQUEST, id: comment.id, status});
return coralApi(`/comments/${comment.id}/status`, {method: 'PUT', body: {status}})
.then(res => dispatch({type: commentTypes.COMMENT_STATUS_UPDATE_SUCCESS, res}))
.catch(error => dispatch({type: commentTypes.COMMENT_STATUS_UPDATE_FAILURE, error}));
};
};
export const flagComment = id => (dispatch, getState) => {
dispatch({type: commentTypes.COMMENT_FLAG, id});
dispatch({type: 'COMMENT_UPDATE', comment: getState().comments.get('byId').get(id)});
};
@@ -22,17 +22,17 @@
}
}
.formField {
.textField {
margin-top: 15px;
}
.formField label {
.textField label {
font-size: 1.08em;
font-weight: bold;
margin-bottom: 5px;
}
.formField input {
.textField input {
width: 100%;
display: block;
border: none;
@@ -0,0 +1,34 @@
.heading {
margin: 0;
font-size: 1.5rem;
font-weight: bold;
}
.widgetTable {
width: 100%;
border-collapse: collapse;
box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.2);
}
.widgetTable thead th {
border-bottom: 1px solid #f47e6b;
padding: 10px;
text-align: left;
}
.widgetTable tbody tr {
border-bottom: 1px solid lightgrey;
}
.widgetTable tbody tr:last-child {
border-bottom: none;
}
.widgetTable tbody td {
padding: 10px;
}
.lede {
font-size: 0.9em;
color: grey;
}
@@ -0,0 +1,66 @@
import React, {PropTypes} from 'react';
import {Link} from 'react-router';
import styles from './FlagWidget.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from 'coral-admin/src/translations';
const lang = new I18n(translations);
const FlagWidget = ({assets}) => {
return (
<table className={styles.widgetTable}>
<thead className={styles.widgetHead}>
<tr>
<th></th>{/* empty on purpose */}
<th>{lang.t('streams.article')}</th>
<th>{lang.t('modqueue.flagged')}</th>
<th>{lang.t('modqueue.likes')}</th>
<th>{lang.t('dashboard.comment_count')}</th>
</tr>
</thead>
<tbody>
{
assets.length
? assets.map((asset, index) => {
const flagCount = asset.action_summaries.find(s => s.__typename === 'FlagAssetActionSummary').actionCount;
const likeCount = asset.action_summaries.find(s => s.__typename === 'LikeAssetActionSummary').actionCount;
return (
<tr key={asset.id}>
<td>{index + 1}.</td>
<td>
<Link to={`/admin/moderate/flagged/${asset.id}`}>{asset.title}</Link>
<p className={styles.lede}>{asset.author} - Published: {new Date(asset.created_at).toLocaleDateString()}</p>
</td>
<td>{likeCount}</td>
<td>{flagCount}</td>
<td>{asset.commentCount}</td>
</tr>
);
})
: <tr><td colSpan="3">{lang.t('dashboard.no_flags')}</td></tr>
}
</tbody>
</table>
);
};
FlagWidget.propTypes = {
assets: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string,
title: PropTypes.string,
url: PropTypes.string,
commentCount: PropTypes.number,
action_summaries: PropTypes.arrayOf(
PropTypes.shape({
__typename: PropTypes.string.isRequired,
actionCount: PropTypes.number.isRequired,
actionableItemCount: PropTypes.number.isRequired
})
)
})
).isRequired
};
export default FlagWidget;
+24 -12
View File
@@ -13,21 +13,33 @@ export default ({handleLogout, restricted = false}) => (
!restricted ?
<div>
<Navigation className={styles.nav}>
<IndexLink className={styles.navLink} to="/admin/moderate"
activeClassName={styles.active}>
{lang.t('configure.moderate')}
</IndexLink>
<Link className={styles.navLink} to="/admin/streams"
activeClassName={styles.active}>
<IndexLink
className={styles.navLink}
to="/admin/moderate"
activeClassName={styles.active}>
{lang.t('configure.moderate')}
</IndexLink>
<Link className={styles.navLink}
to="/admin/streams"
activeClassName={styles.active}>
{lang.t('configure.streams')}
</Link>
<Link className={styles.navLink} to="/admin/community"
activeClassName={styles.active}>
{lang.t('configure.community')}
<Link className={styles.navLink}
to="/admin/community"
activeClassName={styles.active}>
{lang.t('configure.community')}
</Link>
<Link className={styles.navLink} to="/admin/configure"
activeClassName={styles.active}>
{lang.t('configure.configure')}
<Link
className={styles.navLink}
to="/admin/configure"
activeClassName={styles.active}>
{lang.t('configure.configure')}
</Link>
<Link
className={styles.navLink}
to="/admin/dashboard"
activeClassName={styles.active}>
{lang.t('configure.dashboard')}
</Link>
</Navigation>
<div className={styles.rightPanel}>
@@ -1,13 +0,0 @@
@custom-media --big-viewport (min-width: 780px);
.container {
max-width: 860px;
margin: 0 auto;
}
@media (--big-viewport) {
.tab {
flex: none;
}
}
@@ -1,66 +0,0 @@
import React from 'react';
import styles from './CommentStream.css';
import {Snackbar} from 'react-mdl';
import {connect} from 'react-redux';
import {createComment, flagComment} from 'actions/comments';
import ModerationList from 'components/ModerationList';
import CommentBox from 'components/CommentBox';
/**
* Renders a comment stream using a ModerationList component
* and adds a box for adding a new comment
*/
class CommentStream extends React.Component {
constructor (props) {
super(props);
this.state = {snackbar: false, snackbarMsg: ''};
this.onSubmit = this.onSubmit.bind(this);
this.onClickAction = this.onClickAction.bind(this);
}
// Fetch the comments before mounting
componentWillMount () {
this.props.dispatch({type: 'COMMENT_STREAM_FETCH'});
}
// Submit the new comment
onSubmit (comment) {
this.props.dispatch(createComment(comment.name, comment.body));
}
// The only action for now is flagging
onClickAction (action, id) {
if (action === 'flag') {
this.props.dispatch(flagComment(id));
clearTimeout(this._snackTimeout);
this.setState({snackbar: true, snackbarMsg: 'Thank you for reporting this comment. Our moderation team has been notified and will review it shortly.'});
this._snackTimeout = setTimeout(() => this.setState({snackbar: false}), 30000);
}
}
// Render the comment box along with the ModerationList
render ({comments, users}, {snackbar, snackbarMsg}) {
return (
<div className={styles.container}>
<CommentBox onSubmit={this.onSubmit} />
<ModerationList isActive hideActive
singleView={false}
commentIds={comments.ids}
comments={comments.byId}
users={users.byId}
onClickAction={this.onClickAction}
actions={['flag']}
loading={comments.loading} />
<Snackbar active={snackbar}>{snackbarMsg}</Snackbar>
</div>
);
}
}
const mapStateToProps = state => ({
comments: state.comments.toJS(),
users: state.users.toJS()
});
export default connect(mapStateToProps)(CommentStream);
@@ -0,0 +1,23 @@
.Dashboard {
display: flex;
padding: 5px;
}
.widget {
margin-top: 10px;
flex: 1;
box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.2);
margin-right: 10px;
padding: 15px;
}
.widget:last-child {
margin-right: 0;
}
.heading {
margin: 0;
font-size: 1.5rem;
font-weight: bold;
}
@@ -0,0 +1,38 @@
import React from 'react';
import {compose} from 'react-apollo';
import {mostFlags} from 'coral-admin/src/graphql/queries';
import {Spinner} from 'coral-ui';
import styles from './Dashboard.css';
import FlagWidget from '../../components/FlagWidget';
class Dashboard extends React.Component {
render () {
const {data} = this.props;
const {metrics: assets} = data;
if (data.loading) {
return <Spinner />;
}
if (data.error) {
return <code><pre>{data.error}</pre></code>;
}
return (
<div className={styles.Dashboard}>
<div className={styles.widget}>
<h2 className={styles.heading}>Top Ten Articles with the most flagged comments</h2>
<FlagWidget assets={assets} />
</div>
<div className={styles.widget}>
<h2 className={styles.heading}>Top ten comments with the most likes</h2>
</div>
</div>
);
}
}
export default compose(
mostFlags
)(Dashboard);
@@ -1,6 +1,6 @@
import React from 'react';
import styles from './style.css';
import {FormField, Button} from 'coral-ui';
import {TextField, Button} from 'coral-ui';
const AddOrganizationName = props => {
const {handleSettingsChange, handleSettingsSubmit, install} = props;
@@ -12,8 +12,8 @@ const AddOrganizationName = props => {
</p>
<div className={styles.form}>
<form onSubmit={handleSettingsSubmit}>
<FormField
className={styles.FormField}
<TextField
className={styles.TextField}
id="organizationName"
type="text"
label='Organization name'
@@ -1,6 +1,6 @@
import React from 'react';
import styles from './style.css';
import {FormField, Button, Spinner} from 'coral-ui';
import {TextField, Button, Spinner} from 'coral-ui';
const InitialStep = props => {
const {handleUserChange, handleUserSubmit, install} = props;
@@ -8,8 +8,8 @@ const InitialStep = props => {
<div className={styles.step}>
<div className={styles.form}>
<form onSubmit={handleUserSubmit}>
<FormField
className={styles.formField}
<TextField
className={styles.textField}
id="email"
type="email"
label='Email address'
@@ -19,8 +19,8 @@ const InitialStep = props => {
noValidate
/>
<FormField
className={styles.formField}
<TextField
className={styles.textField}
id="username"
type="text"
label='Username'
@@ -29,8 +29,8 @@ const InitialStep = props => {
errorMsg={install.errors.username}
/>
<FormField
className={styles.formField}
<TextField
className={styles.textField}
id="password"
type="password"
label='Password'
@@ -39,8 +39,8 @@ const InitialStep = props => {
errorMsg={install.errors.password}
/>
<FormField
className={styles.formField}
<TextField
className={styles.textField}
id="confirmPassword"
type="password"
label='Confirm Password'
@@ -1,6 +1,6 @@
import React from 'react';
import styles from './style.css';
import {Button, Select, Option, FormField} from 'coral-ui';
import {Button, Select, Option, TextField} from 'coral-ui';
const InviteTeamMembers = props => {
const {nextStep} = props;
@@ -14,19 +14,19 @@ const InviteTeamMembers = props => {
<div className={styles.form}>
<form>
<FormField
className={styles.formField}
<TextField
className={styles.textField}
id="email"
type="email"
label='Email address' required/>
<FormField
className={styles.formField}
<TextField
className={styles.textField}
id="username"
type="text"
label='Username' required/>
<div className={styles.formField}>
<div className={styles.textField}>
<label htmlFor='role'>Assing a role</label>
<Select id='role' label='Select Role'>
<Option>Admin</Option>
@@ -33,7 +33,7 @@
margin: 30px 0;
}
.formField {
.textField {
text-align: left;
label {
@@ -9,12 +9,13 @@ const ModerationQueue = ({activeTab = 'premod', ...props}) => {
<ul>
{
props.data[activeTab].map((comment, i) => {
const status = comment.action_summaries ? 'FLAGGED' : comment.status;
return <Comment
key={i}
index={i}
comment={comment}
suspectWords={props.suspectWords}
actions={actionsMap[comment.status]}
actions={actionsMap[status]}
showBanUserDialog={props.showBanUserDialog}
acceptComment={props.acceptComment}
rejectComment={props.rejectComment}
@@ -318,6 +318,7 @@ span {
}
.flagBox {
max-width: 480px;
border-top: 1px solid rgba(66, 66, 66, 0.12);
h3 {
font-size: 14px;
@@ -1,6 +1,6 @@
export const actionsMap = {
PREMOD: ['REJECT', 'APPROVE', 'BAN'],
FLAGGED: ['REJECT', 'APPROVE'],
FLAGGED: ['REJECT', 'APPROVE', 'BAN'],
REJECTED: ['APPROVE']
};
@@ -1,6 +1,24 @@
import {graphql} from 'react-apollo';
import MOST_FLAGS from './mostFlags.graphql';
import MOD_QUEUE_QUERY from './modQueueQuery.graphql';
export const mostFlags = graphql(MOST_FLAGS, {
options: () => {
// currently hard-coded per Greg's advice
const fiveMinutesAgo = new Date();
fiveMinutesAgo.setMinutes(fiveMinutesAgo.getMinutes() - 305);
return {
variables: {
sort: 'FLAG',
from: fiveMinutesAgo.toISOString(),
to: new Date().toISOString()
}
};
}
});
export const modQueueQuery = graphql(MOD_QUEUE_QUERY, {
options: ({params: {id = ''}}) => {
return {
@@ -9,7 +9,8 @@ query ModQueue ($asset_id: ID!) {
}
flagged: comments(query: {
action_type: FLAG,
asset_id: $asset_id
asset_id: $asset_id,
statuses: [NONE, PREMOD]
}) {
...commentView
action_summaries {
@@ -0,0 +1,14 @@
query Metrics ($from: Date!, $to: Date!, $sort: ACTION_TYPE!) {
metrics(from: $from, to: $to, sort: $sort) {
id
title
url
commentCount
author
created_at
action_summaries {
actionCount
actionableItemCount
}
}
}
+12
View File
@@ -16,6 +16,7 @@
"loading": "Loading results"
},
"modqueue": {
"likes": "likes",
"all": "all",
"premod": "pre-mod",
"rejected": "rejected",
@@ -50,6 +51,7 @@
"configure": {
"custom-css-url": "Custom CSS URL",
"custom-css-url-desc": "URL of a CSS stylesheet that will override default Embed Stream styles. Can be internal or external.",
"dashboard": "Dashboard",
"enable-pre-moderation": "Enable pre-moderation",
"enable-pre-moderation-text": "Moderators must approve any comment before it is published.",
"require-email-verification": "Require Email Verification",
@@ -106,6 +108,10 @@
"email": "Another member of the community recently flagged your {0} for review. Because of its content your {0} was rejected. This means you can no longer comment, like, or flag content until you rewrite your {0}. Please e-mail moderator@newsorg.com if you have any questions or concerns.",
"write_message": "Write a message"
},
"dashboard": {
"no_flags": "There have been no flags in the last 5 minutes! Hooray!",
"comment_count": "Comments"
},
"streams": {
"search": "Search",
"filter-streams": "Filter Streams",
@@ -140,6 +146,7 @@
"loading": "Cargando resultados"
},
"modqueue": {
"likes": "gustos",
"premod": "pre-mod",
"rejected": "rechazado",
"flagged": "marcado",
@@ -161,6 +168,7 @@
"configure": {
"custom-css-url": "URL CSS a medida",
"custom-css-url-desc": "URL de una hoja de estilo que va a sobrescribir los estilos por defecto de Embed Stream. Puede ser interna o externa.",
"dashboard": "Panel",
"enable-pre-moderation": "Habilitar pre-moderación",
"enable-pre-moderation-text": "Los moderadores deben aprobar cada comentario antes de que sea publicado.",
"require-email-verification": "Necesita confirmación de correo",
@@ -207,6 +215,10 @@
"cancel": "Cancelar",
"yes_ban_user": "Si, Suspendan el usuario"
},
"dashbord": {
"no_flags": "¡Nadie ha marcado nada en los últimos 5 minutos! ¡Bravo!",
"comment_count": "Comentarios"
},
"streams": {
"search": "",
"filter-streams": "",
@@ -35,3 +35,7 @@ p {
.wrapper {
margin-bottom: 20px;
}
.hidden {
display: none;
}
@@ -1,12 +1,13 @@
import React from 'react';
import {Button, Checkbox} from 'coral-ui';
import {Button, Checkbox, TextField} from 'coral-ui';
import styles from './ConfigureCommentStream.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations.json';
const lang = new I18n(translations);
export default ({handleChange, handleApply, changed, ...props}) => (
export default ({handleChange, handleApply, changed, updateQuestionBoxContent, ...props}) => (
<form onSubmit={handleApply}>
<div className={styles.wrapper}>
<div className={styles.container}>
@@ -32,25 +33,31 @@ export default ({handleChange, handleApply, changed, ...props}) => (
title: lang.t('configureCommentStream.enablePremod'),
description: lang.t('configureCommentStream.enablePremodDescription')
}} />
{/* To be implimented
<ul>
<li>
<Checkbox
className={styles.checkbox}
cStyle={changed ? 'green' : 'darkGrey'}
name="premodLinks"
onChange={handleChange}
defaultChecked={props.premodLinks}
info={{
title: lang.t('configureCommentStream.enablePremodLinks'),
description: lang.t('configureCommentStream.enablePremodDescription')
}} />
</li>
</ul>
*/}
</li>
<li>
<Checkbox
className={styles.checkbox}
cStyle={changed ? 'green' : 'darkGrey'}
name="qboxenable"
onChange={handleChange}
defaultChecked={props.questionBoxEnable}
info={{
title: lang.t('configureCommentStream.enableQuestionBox'),
description: lang.t('configureCommentStream.enableQuestionBoxDescription')
}} />
<div className={`${props.questionBoxEnable ? null : styles.hidden}`} >
<TextField
id="qboxcontent"
onChange={updateQuestionBoxContent}
rows={3}
value={props.questionBoxContent}
label={lang.t('configureCommentStream.includeQuestionHere')}
/>
</div>
</li>
</ul>
</div>
</form>
);
@@ -21,18 +21,23 @@ class ConfigureStreamContainer extends Component {
this.toggleStatus = this.toggleStatus.bind(this);
this.handleChange = this.handleChange.bind(this);
this.handleApply = this.handleApply.bind(this);
this.updateQuestionBoxContent = this.updateQuestionBoxContent.bind(this);
}
handleApply (e) {
e.preventDefault();
const {elements} = e.target;
const premod = elements.premod.checked;
const questionBoxEnable = elements.qboxenable.checked;
const questionBoxContent = elements.qboxcontent.value;
// const premodLinks = elements.premodLinks.checked;
const {changed} = this.state;
const newConfig = {
moderation: premod ? 'PRE' : 'POST'
moderation: premod ? 'PRE' : 'POST',
questionBoxEnable,
questionBoxContent
};
if (changed) {
@@ -45,12 +50,20 @@ class ConfigureStreamContainer extends Component {
}
}
handleChange () {
handleChange (e) {
if (e.target && e.target.id === 'qboxenable') {
this.props.asset.settings.questionBoxEnable = e.target.checked;
}
this.setState({
changed: true
});
}
updateQuestionBoxContent(e) {
this.props.asset.settings.questionBoxContent = e.target.value;
this.handleChange(e);
}
toggleStatus () {
this.props.updateStatus(
this.props.asset.closedAt === null ? 'closed' : 'open'
@@ -66,6 +79,8 @@ class ConfigureStreamContainer extends Component {
render () {
const status = this.props.asset.closedAt === null ? 'open' : 'closed';
const premod = this.props.asset.settings.moderation === 'PRE';
const questionBoxEnable = this.props.asset.settings.questionBoxEnable;
const questionBoxContent = this.props.asset.settings.questionBoxContent;
return (
<div>
@@ -75,6 +90,9 @@ class ConfigureStreamContainer extends Component {
changed={this.state.changed}
premodLinks={false}
premod={premod}
updateQuestionBoxContent={this.updateQuestionBoxContent}
questionBoxEnable={questionBoxEnable}
questionBoxContent={questionBoxContent}
/>
<hr />
<h3>{status === 'open' ? 'Close' : 'Open'} Comment Stream</h3>
@@ -94,7 +112,7 @@ const mapStateToProps = (state) => ({
const mapDispatchToProps = dispatch => ({
updateStatus: status => dispatch(updateOpenStatus(status)),
updateConfiguration: newConfig => dispatch(updateConfiguration(newConfig))
updateConfiguration: newConfig => dispatch(updateConfiguration(newConfig)),
});
export default compose(
+10 -4
View File
@@ -7,18 +7,24 @@
"enablePremod": "Enable Premoderation",
"enablePremodDescription": "Moderators must approve any comment before its published.",
"enablePremodLinks": "Pre-Moderate Comments Containing Links",
"enablePremodLinksDescription": "Moderators must approve any comment containing a link before its published."
"enablePremodLinksDescription": "Moderators must approve any comment containing a link before its published.",
"enableQuestionBox": "Ask readers a question",
"enableQuestionBoxDescription": "This question will appear at the top of this comment stram. Ask readers about a certain issue in the article or pose discussion questions, etc.",
"includeQuestionHere": "Write your question here."
}
},
"es": {
"configureCommentStream": {
"apply": "Aplicar",
"title": "Configurar los comentarios",
"description": "Como Administrador puedes modificar las opciones de los comentarios en este artículo",
"description": "Como Administrador/a puedes modificar las opciones de los comentarios en este artículo",
"enablePremod": "Activar Pre Moderación",
"enablePremodDescription": "Los Moderadores deben aprobar cualquier comentario antes de su publicación",
"enablePremodDescription": "Los y las Moderadoras deben aprobar cualquier comentario antes de su publicación",
"enablePremodLinks": "Pre-Moderar Commentarios que contienen Links",
"enablePremodLinksDescription": "Los Moderadores deben probar cualquier comentario que contengan links antes de su publicación."
"enablePremodLinksDescription": "Los y las Moderadoras deben probar cualquier comentario que contengan links antes de su publicación.",
"enableQuestionBox": "Hacer una pregunta a los y las lectoras.",
"enableQuestionBoxDescription": "Esta pregunta aparecera en la parte de arriba del hilo de comentarios.",
"includeQuestionHere": "Escribir la pregunta aquí."
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
export default function fetcher(query) {
return fetch(`${window.location.host}/api/v1/graph/ql`, {
return fetch(`${window.location.origin}/api/v1/graph/ql`, {
method: 'POST',
headers: {
Accept: 'application/json',
+13
View File
@@ -17,6 +17,7 @@ import PubDate from 'coral-plugin-pubdate/PubDate';
import {ReplyBox, ReplyButton} from 'coral-plugin-replies';
import FlagComment from 'coral-plugin-flags/FlagComment';
import LikeButton from 'coral-plugin-likes/LikeButton';
import LoadMore from 'coral-embed-stream/src/LoadMore';
import styles from './Comment.css';
@@ -89,6 +90,7 @@ class Comment extends React.Component {
showSignInDialog,
postLike,
postFlag,
loadMore,
setActiveReplyBox,
activeReplyBox,
deleteAction
@@ -172,6 +174,17 @@ class Comment extends React.Component {
comment={reply} />;
})
}
{
comment.replies &&
<div className='coral-load-more-replies'>
<LoadMore
id={asset.id}
comments={comment.replies}
parentId={comment.id}
moreComments={comment.replyCount > comment.replies.length}
loadMore={loadMore}/>
</div>
}
</div>
);
}
+6
View File
@@ -16,6 +16,7 @@ import {Notification, notificationActions, authActions, assetActions, pym} from
import Stream from './Stream';
import InfoBox from 'coral-plugin-infobox/InfoBox';
import QuestionBox from 'coral-plugin-questionbox/QuestionBox';
import {ModerationLink} from 'coral-plugin-moderation';
import Count from 'coral-plugin-comment-count/CommentCount';
import CommentBox from 'coral-plugin-commentbox/CommentBox';
@@ -114,6 +115,10 @@ class Embed extends Component {
content={asset.settings.infoBoxContent}
enable={asset.settings.infoBoxEnable}
/>
<QuestionBox
content={asset.settings.questionBoxContent}
enable={asset.settings.questionBoxEnable}
/>
<RestrictedContent restricted={banned} restrictedComp={
<SuspendedAccount
canEditName={user && user.canEditName}
@@ -151,6 +156,7 @@ class Embed extends Component {
currentUser={user}
postLike={this.props.postLike}
postFlag={this.props.postFlag}
loadMore={this.props.loadMore}
deleteAction={this.props.deleteAction}
showSignInDialog={this.props.showSignInDialog}
comments={asset.comments} />
+12 -6
View File
@@ -1,28 +1,34 @@
import React, {PropTypes} from 'react';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from 'coral-framework/translations.json';
import {ADDTL_COMMENTS_ON_LOAD_MORE} from 'coral-framework/constants/comments';
import {Button} from 'coral-ui';
const lang = new I18n(translations);
const loadMoreComments = (assetId, comments, loadMore) => {
const loadMoreComments = (assetId, comments, loadMore, parentId) => {
if (!comments.length) {
return;
}
const cursor = parentId
? comments[1].created_at
: comments[comments.length - 1].created_at;
loadMore({
limit: 10,
cursor: comments[comments.length - 1].created_at,
limit: ADDTL_COMMENTS_ON_LOAD_MORE,
cursor,
assetId,
sort: 'REVERSE_CHRONOLOGICAL'
parent_id: parentId,
sort: parentId ? 'CHRONOLOGICAL' : 'REVERSE_CHRONOLOGICAL'
});
};
const LoadMore = ({assetId, comments, loadMore, moreComments}) => (
const LoadMore = ({assetId, comments, loadMore, moreComments, parentId}) => (
moreComments
? <Button
className='coral-load-more'
onClick={() => loadMoreComments(assetId, comments, loadMore)}>
onClick={() => loadMoreComments(assetId, comments, loadMore, parentId)}>
{
lang.t('loadMore')
}
+2
View File
@@ -39,6 +39,7 @@ class Stream extends React.Component {
addNotification,
postFlag,
postLike,
loadMore,
deleteAction,
showSignInDialog,
refetch
@@ -59,6 +60,7 @@ class Stream extends React.Component {
currentUser={currentUser}
postLike={postLike}
postFlag={postFlag}
loadMore={loadMore}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
key={comment.id}
+26 -1
View File
@@ -62,7 +62,7 @@ hr {
.coral-plugin-infobox-info {
top: 0;
border: 0;
background: rgb(105,105,105);
background: rgb(35,118,216);
color: white;
width: 100%;
text-align: center;
@@ -72,6 +72,21 @@ hr {
display: block;
}
/* Question Box Styles */
.coral-plugin-questionbox-info {
top: 0;
border: 0;
background: rgb(105,105,105);
color: white;
width: 100%;
text-align: center;
padding: 10px;
margin-bottom: 0px;
font-weight: bold;
display: block;
}
.hidden {
visibility: hidden;
display: none;
@@ -317,3 +332,13 @@ button.coral-load-more {
button.coral-load-more:hover {
background-color: #4399FF;
}
.coral-load-more-replies {
width: 100%;
display: flex;
justify-content: center;
}
.coral-load-more-replies button.coral-load-more {
width: initial;
}
@@ -0,0 +1 @@
export const ADDTL_COMMENTS_ON_LOAD_MORE = 10;
@@ -35,13 +35,28 @@ export const queryStream = graphql(STREAM_QUERY, {
asset_id,
sort
},
updateQuery: (oldData, {fetchMoreResult:{data:{new_top_level_comments}}}) => ({
...oldData,
asset: {
...oldData.asset,
comments: [...oldData.asset.comments, ...new_top_level_comments]
updateQuery: (oldData, {fetchMoreResult:{data:{new_top_level_comments}}}) =>
// If loading more replies
parent_id ? {
...oldData,
asset: {
...oldData.asset,
comments: oldData.asset.comments.map((comment) =>
comment.id === parent_id
? {...comment, replies: [...comment.replies, ...new_top_level_comments]}
: comment)
}
}
// If loading more top-level comments
: {
...oldData,
asset: {
...oldData.asset,
comments: [...oldData.asset.comments, ...new_top_level_comments]
}
}
})
});
}
})
@@ -3,6 +3,7 @@
query LoadMoreComments($limit: Int = 5, $cursor: Date, $parent_id: ID, $asset_id: ID, $sort: SORT_ORDER) {
new_top_level_comments: comments(query: {limit: $limit, cursor: $cursor, parent_id: $parent_id, asset_id: $asset_id, sort: $sort}) {
...commentView
replyCount
replies(limit: 3) {
...commentView
}
@@ -11,6 +11,8 @@ query AssetQuery($asset_url: String!) {
moderation
infoBoxEnable
infoBoxContent
questionBoxEnable
questionBoxContent
closeTimeout
closedMessage
charCountEnable
@@ -20,6 +22,7 @@ query AssetQuery($asset_url: String!) {
commentCount
comments(limit: 10) {
...commentView
replyCount
replies(limit: 3) {
...commentView
}
@@ -0,0 +1,10 @@
import React from 'react';
const packagename = 'coral-plugin-questionbox';
const QuestionBox = ({enable, content}) =>
<div
className={`${packagename}-info ${enable ? null : 'hidden'}` }>
{content}
</div>;
export default QuestionBox;
+1 -1
View File
@@ -50,7 +50,7 @@ class Stream extends Component {
}
}
// Initialize GraphQL queries or mutations with the `gql` tag
// Initialize GraphQL queries or mutations with the gql tag
const StreamQuery = gql`fragment commentView on Comment {
id
body
@@ -1,5 +1,5 @@
import React from 'react';
import FormField from 'coral-ui/components/FormField';
import TextField from 'coral-ui/components/TextField';
import Alert from './Alert';
import Button from 'coral-ui/components/Button';
import {Dialog} from 'coral-ui';
@@ -28,7 +28,7 @@ const CreateDisplayNameDialog = ({open, handleClose, offset, formData, handleSub
<label htmlFor="username">{lang.t('createdisplay.yourusername')}</label>
{ props.auth.error && <Alert>{props.auth.error}</Alert> }
<form id="saveDisplayName" onSubmit={handleSubmitDisplayName}>
<FormField
<TextField
id="username"
type="string"
label={lang.t('createdisplay.username')}
@@ -26,7 +26,7 @@ class ForgotContent extends React.Component {
<h1>{lang.t('signIn.recoverPassword')}</h1>
</div>
<form onSubmit={this.handleSubmit}>
<div className={styles.formField}>
<div className={styles.textField}>
<label htmlFor="email">{lang.t('signIn.email')}</label>
<input
ref={input => this.emailInput = input}
@@ -1,6 +1,6 @@
import React, {PropTypes} from 'react';
import Alert from './Alert';
import {Button, FormField, Spinner, Success} from 'coral-ui';
import {Button, TextField, Spinner, Success} from 'coral-ui';
import styles from './styles.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations';
@@ -32,7 +32,7 @@ const SignInContent = ({
auth.emailVerificationFailure
? <form onSubmit={handleResendVerification}>
<p>{lang.t('signIn.requestNewVerifyEmail')}</p>
<FormField
<TextField
id="confirm-email"
type="email"
label={lang.t('signIn.email')}
@@ -54,14 +54,14 @@ const SignInContent = ({
</h1>
</div>
<form onSubmit={handleSignIn}>
<FormField
<TextField
id="email"
type="email"
label={lang.t('signIn.email')}
value={formData.email}
onChange={handleChange}
/>
<FormField
<TextField
id="password"
type="password"
label={lang.t('signIn.password')}
@@ -1,6 +1,6 @@
import React, {PropTypes} from 'react';
import Alert from './Alert';
import {Button, FormField, Spinner, Success} from 'coral-ui';
import {Button, TextField, Spinner, Success} from 'coral-ui';
import styles from './styles.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations';
@@ -79,7 +79,7 @@ class SignUpContent extends React.Component {
</h1>
</div>
<form onSubmit={handleSignUp}>
<FormField
<TextField
id="email"
type="email"
label={lang.t('signIn.email')}
@@ -88,7 +88,7 @@ class SignUpContent extends React.Component {
errorMsg={errors.email}
onChange={handleChange}
/>
<FormField
<TextField
id="username"
type="text"
label={lang.t('signIn.username')}
@@ -97,7 +97,7 @@ class SignUpContent extends React.Component {
errorMsg={errors.username}
onChange={handleChange}
/>
<FormField
<TextField
id="password"
type="password"
label={lang.t('signIn.password')}
@@ -108,7 +108,7 @@ class SignUpContent extends React.Component {
minLength="8"
/>
{ errors.password && <span className={styles.hint}> Password must be at least 8 characters. </span> }
<FormField
<TextField
id="confirmPassword"
type="password"
label={lang.t('signIn.confirmPassword')}
@@ -1,14 +1,14 @@
.formField {
.textField {
margin-top: 15px;
}
.formField label {
.textField label {
font-size: 1.08em;
font-weight: bold;
margin-bottom: 5px;
}
.formField input {
.textField input {
width: 100%;
display: block;
border: none;
@@ -1,8 +1,8 @@
import React, {PropTypes} from 'react';
import styles from './FormField.css';
import styles from './TextField.css';
const FormField = ({className, showErrors = false, errorMsg, label, ...props}) => (
<div className={`${styles.formField} ${className ? className : ''}`}>
const TextField = ({className, showErrors = false, errorMsg, label, ...props}) => (
<div className={`${styles.textField} ${className ? className : ''}`}>
<label htmlFor={props.id}>
{label}
</label>
@@ -15,7 +15,7 @@ const FormField = ({className, showErrors = false, errorMsg, label, ...props}) =
</div>
);
FormField.propTypes = {
TextField.propTypes = {
label: PropTypes.string,
value: PropTypes.string,
onChange: PropTypes.func,
@@ -23,4 +23,4 @@ FormField.propTypes = {
type: PropTypes.string
};
export default FormField;
export default TextField;
+1 -1
View File
@@ -13,7 +13,7 @@ export {default as Icon} from './components/Icon';
export {default as List} from './components/List';
export {default as Item} from './components/Item';
export {default as Card} from './components/Card';
export {default as FormField} from './components/FormField';
export {default as TextField} from './components/TextField';
export {default as Success} from './components/Success';
export {default as Pager} from './components/Pager';
export {default as Wizard} from './components/Wizard';
+10
View File
@@ -46,6 +46,15 @@ const findOrCreateAssetByURL = (context, asset_url) => {
});
};
const getAssetsForMetrics = ({loaders: {Actions, Comments}}) => {
return Actions.getByTypes({action_type: 'FLAG', item_type: 'COMMENT'})
.then((actions) => { // ALL ACTIONS :O
const ids = actions.map(({item_id}) => item_id);
return Comments.getByQuery({ids});
});
};
/**
* Creates a set of loaders based on a GraphQL context.
* @param {Object} context the context of the GraphQL request
@@ -59,6 +68,7 @@ module.exports = (context) => ({
getByURL: (url) => findOrCreateAssetByURL(context, url),
getByID: new DataLoader((ids) => genAssetsByID(context, ids)),
getForMetrics: () => getAssetsForMetrics(context),
getAll: new util.SingletonResolver(() => AssetModel.find({}))
}
});
+3 -3
View File
@@ -18,7 +18,7 @@ const getCountsByAssetID = (context, asset_ids) => {
$in: asset_ids
},
status: {
$in: [null, 'ACCEPTED']
$in: ['NONE', 'ACCEPTED']
},
parent_id: null
}
@@ -51,7 +51,7 @@ const getCountsByParentID = (context, parent_ids) => {
$in: parent_ids
},
status: {
$in: [null, 'ACCEPTED']
$in: ['NONE', 'ACCEPTED']
}
}
},
@@ -88,7 +88,7 @@ const getCommentsByQuery = ({user}, {ids, statuses, asset_id, parent_id, author_
} else {
comments = comments.where({
status: {
$in: [null, 'ACCEPTED']
$in: ['NONE', 'ACCEPTED']
}
});
}
+2
View File
@@ -3,6 +3,7 @@ const _ = require('lodash');
const Actions = require('./actions');
const Assets = require('./assets');
const Comments = require('./comments');
const Metrics = require('./metrics');
const Settings = require('./settings');
const Users = require('./users');
@@ -18,6 +19,7 @@ module.exports = (context) => {
Actions,
Assets,
Comments,
Metrics,
Settings,
Users
].map((loaders) => {
+155
View File
@@ -0,0 +1,155 @@
const _ = require('lodash');
const DataLoader = require('dataloader');
const {objectCacheKeyFn} = require('./util');
const CommentModel = require('../../models/comment');
const ActionModel = require('../../models/action');
const getMetrics = ({loaders: {Metrics, Assets}}, {from, to, sort, limit}) => {
let commentMetrics = {};
let assetMetrics = [];
return Metrics.getRecentActions.load({from, to})
.then((actionSummaries) => {
commentMetrics = actionSummaries.reduce((acc, {item_id, action_type, count}) => {
if (!(item_id in acc)) {
acc[item_id] = [];
}
acc[item_id].push({action_type, count});
return acc;
}, {});
// Collect just the comment id's.
let commentIDs = _.uniq(actionSummaries.map((as) => as.item_id));
// Find those comments.
return Metrics.getSpecificComments.loadMany(commentIDs);
})
.then((comments) => {
let commentResults = _.groupBy(comments, 'asset_id');
assetMetrics = Object.keys(commentResults).map((asset_id) => {
let ids = commentResults[asset_id].map((comment) => comment.id);
let summaries = _.groupBy(_.flatten(ids.map((id) => commentMetrics[id])), 'action_type');
let action_summaries = Object.keys(summaries).map((action_type) => ({
action_type,
actionCount: summaries[action_type].reduce((acc, {count}) => acc + count, 0),
actionableItemCount: summaries[action_type].length
}));
return {action_summaries, id: asset_id};
});
// Sort these metrics by the predefined sort order. This will ensure that
// if the action summary does not exist on the object, that it is less
// prefered over the one that does have it.
assetMetrics.sort((a, b) => {
let aActionSummary = a.action_summaries.find((({action_type}) => action_type === sort));
let bActionSummary = b.action_summaries.find((({action_type}) => action_type === sort));
// If either a or b don't have this action type, then one of them will
// automatically win.
if (aActionSummary == null || bActionSummary == null) {
if (bActionSummary != null) {
return 1;
}
if (aActionSummary != null) {
return -1;
}
return 0;
}
// Both of them had an actionCount, hence we can determine that we could
// compare the actual values directly.
return bActionSummary.actionCount - aActionSummary.actionCount;
});
// Only keep the top `limit`.
assetMetrics = assetMetrics.slice(0, limit);
// Determine the assets that we need to return.
return Assets.getByID.loadMany(assetMetrics.map((asset) => asset.id));
})
.then((assets) => {
// Join up the assets that are returned by their id.
let groupedAssets = _.groupBy(assets, 'id');
// Return from the sorted asset metrics and return their assetes.
return assetMetrics.map(({id, action_summaries}) => {
if (id in groupedAssets) {
let asset = groupedAssets[id][0];
// Add the action summaries to the asset.
asset.action_summaries = action_summaries;
return asset;
}
return null;
}).filter((asset) => asset != null);
});
};
const getRecentActions = (context, {from, to}) => {
return ActionModel.aggregate([
// Find all actions that were created in the time range.
{$match: {
item_type: 'COMMENTS',
created_at: {
$gt: from,
$lt: to
}
}},
// Count all those items.
{$group: {
_id: {
item_id: '$item_id',
action_type: '$action_type'
},
count: {
$sum: 1
}
}},
// Project the count to a better field.
{$project: {
item_id: '$_id.item_id',
action_type: '$_id.action_type',
count: '$count'
}}
]);
};
const getSpecificComments = (context, ids) => {
return CommentModel.find({
id: {
$in: ids
}
})
.select({
id: 1,
asset_id: 1
});
};
module.exports = (context) => ({
Metrics: {
getSpecificComments: new DataLoader((ids) => getSpecificComments(context, ids)),
getRecentActions: new DataLoader(([{from, to}]) => getRecentActions(context, {from, to}).then((as) => [as]), {
batch: false,
cacheKeyFn: objectCacheKeyFn('from', 'to')
}),
get: ({from, to, sort, limit}) => getMetrics(context, {from, to, sort, limit})
}
});
+10
View File
@@ -130,10 +130,20 @@ const objectCacheKeyFn = (...paths) => (obj) => {
return paths.map((path) => obj[path]).join(':');
};
/**
* Maps an object's paths to a string that can be used as a cache key.
* @param {Array} paths paths on the object to be used to generate the cache
* key
*/
const arrayCacheKeyFn = (arr) => {
return arr.sort().join(':');
};
module.exports = {
singleJoinBy,
arrayJoinBy,
objectCacheKeyFn,
arrayCacheKeyFn,
SingletonResolver,
SharedCacheDataLoader
};
-4
View File
@@ -45,10 +45,6 @@ const deleteAction = ({user}, {id}) => {
};
module.exports = (context) => {
// TODO: refactor to something that'll return an error in the event an attempt
// is made to mutate state while not logged in. There's got to be a better way
// to do this.
if (context.user && context.user.can('mutation:createAction', 'mutation:deleteAction')) {
return {
Action: {
+20 -10
View File
@@ -11,10 +11,10 @@ const Wordlist = require('../../services/wordlist');
* @param {String} body body of the comment
* @param {String} asset_id asset for the comment
* @param {String} parent_id optional parent of the comment
* @param {String} [status=null] the status of the new comment
* @param {String} [status='NONE'] the status of the new comment
* @return {Promise} resolves to the created comment
*/
const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id = null}, status = null) => {
const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id = null}, status = 'NONE') => {
return CommentsService.publicCreate({
body,
asset_id,
@@ -105,7 +105,7 @@ const resolveNewCommentStatus = (context, {asset_id, body}, wordlist = {}) => {
if (charCountEnable && body.length > charCount) {
return 'REJECTED';
}
return moderation === 'PRE' ? 'PREMOD' : null;
return moderation === 'PRE' ? 'PREMOD' : 'NONE';
});
}
@@ -169,16 +169,26 @@ const createPublicComment = (context, commentInput) => {
* @param {String} status the new status of the comment
*/
const setCommentStatus = ({comment}, {id, status}) => {
return CommentsService.setStatus(id, status)
.then(res => res);
const setCommentStatus = ({loaders: {Comments}}, {id, status}) => {
return CommentsService
.setStatus(id, status)
.then((comment) => {
// If the loaders are present, clear the caches for these values because we
// just added a new comment, hence the counts should be updated.
if (Comments && Comments.countByAssetID && Comments.countByParentID) {
if (comment.parent_id != null) {
Comments.countByParentID.clear(comment.parent_id);
} else {
Comments.countByAssetID.clear(comment.asset_id);
}
}
return comment;
});
};
module.exports = (context) => {
// TODO: refactor to something that'll return an error in the event an attempt
// is made to mutate state while not logged in. There's got to be a better way
// to do this.
let mutators = {
Comment: {
create: () => Promise.reject(errors.ErrNotAuthorized),
-4
View File
@@ -7,10 +7,6 @@ const setUserStatus = ({user}, {id, status}) => {
};
module.exports = (context) => {
// TODO: refactor to something that'll return an error in the event an attempt
// is made to mutate state while not logged in. There's got to be a better way
// to do this.
if (context.user && context.user.can('mutation:setUserStatus')) {
return {
User: {
+12
View File
@@ -0,0 +1,12 @@
const AssetActionSummary = {
__resolveType({action_type}) {
switch (action_type) {
case 'FLAG':
return 'FlagAssetActionSummary';
case 'LIKE':
return 'LikeAssetActionSummary';
}
}
};
module.exports = AssetActionSummary;
+2
View File
@@ -1,5 +1,6 @@
const ActionSummary = require('./action_summary');
const Action = require('./action');
const AssetActionSummary = require('./asset_action_summary');
const Asset = require('./asset');
const Comment = require('./comment');
const Date = require('./date');
@@ -17,6 +18,7 @@ const ValidationUserError = require('./validation_user_error');
module.exports = {
ActionSummary,
Action,
AssetActionSummary,
Asset,
Comment,
Date,
+8
View File
@@ -39,6 +39,14 @@ const RootQuery = {
return Comments.getByQuery(query);
},
metrics(_, {from, to, sort, limit = 10}, {user, loaders: {Metrics}}) {
if (user == null || !user.hasRoles('ADMIN')) {
return null;
}
return Metrics.get({from, to, sort, limit});
},
// This returns the current user, ensure that if we aren't logged in, we
// return null.
me(_, args, {user}) {
+50 -3
View File
@@ -67,6 +67,10 @@ type Tag {
# The statuses that a comment may have.
enum COMMENT_STATUS {
# The comment is not PREMOD, but was not applied a moderation status by a
# moderator.
NONE
# The comment has been accepted by a moderator.
ACCEPTED
@@ -93,7 +97,7 @@ enum ACTION_TYPE {
input CommentsQuery {
# current status of a comment.
statuses: [COMMENT_STATUS]
statuses: [COMMENT_STATUS!]
# asset that a comment is on.
asset_id: ID
@@ -152,7 +156,7 @@ type Comment {
asset: Asset
# The current status of a comment.
status: COMMENT_STATUS
status: COMMENT_STATUS!
# The time when the comment was created
created_at: Date!
@@ -188,6 +192,36 @@ interface ActionSummary {
current_user: Action
}
# A summary of actions for a specific action type on an Asset.
interface AssetActionSummary {
# Number of actions associated with actionable types on this this Asset.
actionCount: Int
# Number of unique actionable types that are referenced by the actions.
actionableItemCount: Int
}
# A summary of counts related to all the Flags on an Asset.
type FlagAssetActionSummary implements AssetActionSummary {
# Number of flags associated with actionable types on this this Asset.
actionCount: Int
# Number of unique actionable types that are referenced by the flags.
actionableItemCount: Int
}
# A summary of counts related to all the Likes on an Asset.
type LikeAssetActionSummary implements AssetActionSummary {
# Number of likes associated with actionable types on this this Asset.
actionCount: Int
# Number of unique actionable types that are referenced by the likes.
actionableItemCount: Int
}
# LikeAction is used by users who "like" a specific entity.
type LikeAction implements Action {
@@ -274,6 +308,8 @@ type Settings {
infoBoxEnable: Boolean
infoBoxContent: String
questionBoxEnable: Boolean
questionBoxContent: String
closeTimeout: Int
closedMessage: String
charCountEnable: Boolean
@@ -313,8 +349,15 @@ type Asset {
# The date that the asset was closed at.
closedAt: Date
# Summary of all Actions against all entities associated with the Asset.
# (likes, flags, etc.)
action_summaries: [AssetActionSummary]
# The date that the asset was created.
created_at: Date
# The author(s) of the asset.
author: String
}
################################################################################
@@ -349,7 +392,7 @@ type ValidationUserError implements UserError {
}
################################################################################
## Queries
## Queries;
################################################################################
# Establishes the ordering of the content by their created_at time stamp.
@@ -386,6 +429,10 @@ type RootQuery {
# The currently logged in user based on the request.
me: User
# Metrics related to user actions are saturated into the assets returned. The
# sort will affect if it will allow
metrics(from: Date!, to: Date!, sort: ACTION_TYPE!, limit: Int = 10): [Asset]
}
################################################################################
+6 -2
View File
@@ -6,7 +6,7 @@ const STATUSES = [
'ACCEPTED',
'REJECTED',
'PREMOD',
null
'NONE'
];
/**
@@ -66,7 +66,11 @@ const CommentSchema = new Schema({
asset_id: String,
author_id: String,
status_history: [StatusSchema],
status: {type: String, default: null},
status: {
type: String,
enum: STATUSES,
default: 'NONE'
},
tags: [TagSchema],
parent_id: String
}, {
+8
View File
@@ -32,6 +32,14 @@ const SettingSchema = new Schema({
type: String,
default: ''
},
questionBoxEnable: {
type: Boolean,
default: false
},
questionBoxContent: {
type: String,
default: ''
},
organizationName: {
type: String
},
+1 -1
View File
@@ -52,7 +52,7 @@ router.get('/', (req, res, next) => {
if (user_id) {
query = CommentsService.findByUserId(user_id, authorization.has(req.user, 'ADMIN'));
} else if (status) {
query = assetIDWrap(CommentsService.findByStatus(status === 'NEW' ? null : status));
query = assetIDWrap(CommentsService.findByStatus(status === 'NEW' ? 'NONE' : status));
} else if (action_type) {
query = CommentsService
.findIdsByActionType(action_type)
+5 -4
View File
@@ -11,6 +11,7 @@ const STATUSES = [
'ACCEPTED',
'REJECTED',
'PREMOD',
'NONE',
];
module.exports = class CommentsService {
@@ -31,7 +32,7 @@ module.exports = class CommentsService {
body,
asset_id,
parent_id,
status = null,
status = 'NONE',
author_id
} = comment;
@@ -146,7 +147,7 @@ module.exports = class CommentsService {
* @param {String} status status of the comment to search for
* @return {Promise} resovles to comment array
*/
static findByStatus(status = null) {
static findByStatus(status = 'NONE') {
return CommentModel.find({status});
}
@@ -155,7 +156,7 @@ module.exports = class CommentsService {
* @param {String} asset_id
* @return {Promise}
*/
static moderationQueue(status = null, asset_id = null) {
static moderationQueue(status = 'NONE', asset_id = null) {
// Fetch the comments with statuses.
let comments = CommentModel.find({status});
@@ -272,7 +273,7 @@ module.exports = class CommentsService {
return Promise.reject(new Error(`status ${status} is not supported`));
}
return CommentModel.update({id}, {
return CommentModel.findOneAndUpdate({id}, {
$set: {status}
});
}
+2 -2
View File
@@ -131,7 +131,7 @@ describe('services.CommentsService', () => {
expect(c2).to.not.be.null;
expect(c2.id).to.be.uuid;
expect(c2.status).to.be.null;
expect(c2.status).to.be.equal('NONE');
expect(c3).to.not.be.null;
expect(c3.id).to.be.uuid;
@@ -225,7 +225,7 @@ describe('services.CommentsService', () => {
return CommentsService.findById(comment_id)
.then((c) => {
expect(c.status).to.be.null;
expect(c.status).to.be.equal('NONE');
return CommentsService.pushStatus(comment_id, 'REJECTED', '123');
})