Merge branch 'master' into load-more-replies

This commit is contained in:
David Erwin
2017-02-16 11:31:32 -05:00
committed by GitHub
40 changed files with 621 additions and 85 deletions
+2
View File
@@ -9,6 +9,7 @@ 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>
@@ -18,6 +19,7 @@ const routes = (
<Route path='community' component={CommunityContainer} />
<Route path='configure' component={Configure} />
<Route path='streams' component={Streams} />
<Route path='dashboard' component={Dashboard} />
{/* Moderation Routes */}
@@ -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}>
@@ -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 {
@@ -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 {
@@ -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',
+5
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}
+16 -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;
@@ -11,6 +11,8 @@ query AssetQuery($asset_url: String!) {
moderation
infoBoxEnable
infoBoxContent
questionBoxEnable
questionBoxContent
closeTimeout
closedMessage
charCountEnable
@@ -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({}))
}
});
+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
};
+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}) {
+44 -1
View File
@@ -192,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 {
@@ -278,6 +308,8 @@ type Settings {
infoBoxEnable: Boolean
infoBoxContent: String
questionBoxEnable: Boolean
questionBoxContent: String
closeTimeout: Int
closedMessage: String
charCountEnable: Boolean
@@ -317,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
}
################################################################################
@@ -353,7 +392,7 @@ type ValidationUserError implements UserError {
}
################################################################################
## Queries
## Queries;
################################################################################
# Establishes the ordering of the content by their created_at time stamp.
@@ -390,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]
}
################################################################################
+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
},