Implement live updates for mod actions

This commit is contained in:
Chi Vinh Le
2017-06-15 01:08:25 +07:00
parent 687b135adf
commit 3658a804b0
22 changed files with 432 additions and 255 deletions
@@ -133,7 +133,7 @@
animation-fill-mode: both; }
.toastify {
z-index: 999;
z-index: 99999;
position: fixed;
padding: 4px;
width: 350px;
@@ -197,7 +197,7 @@
border-radius: 2px;
box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.1), 0 3px 20px 0 rgba(0, 0, 0, 0.05); }
.toastify-content--info {
background: #2488cb; }
background: #404040; }
.toastify-content--success {
background: #008577; }
.toastify-content--warning {
-54
View File
@@ -1,6 +1,4 @@
import {add} from 'coral-framework/services/graphqlRegistry';
import update from 'immutability-helper';
const queues = ['all', 'premod', 'flagged', 'accepted', 'rejected'];
const extension = {
mutations: {
@@ -10,58 +8,6 @@ const extension = {
RejectUsername: () => ({
refetchQueries: ['CoralAdmin_Community'],
}),
SetCommentStatus: ({variables: {commentId, status}}) => ({
updateQueries: {
CoralAdmin_Moderation: (prev) => {
const comment = queues.reduce((comment, queue) => {
return comment ? comment : prev[queue].nodes.find((c) => c.id === commentId);
}, null);
let acceptedNodes = prev.accepted.nodes;
let acceptedCount = prev.acceptedCount;
let rejectedNodes = prev.rejected.nodes;
let rejectedCount = prev.rejectedCount;
if (status !== comment.status) {
if (status === 'ACCEPTED') {
comment.status = 'ACCEPTED';
acceptedCount++;
acceptedNodes = [comment, ...acceptedNodes];
}
else if (status === 'REJECTED') {
comment.status = 'REJECTED';
rejectedCount++;
rejectedNodes = [comment, ...rejectedNodes];
}
}
const premodNodes = prev.premod.nodes.filter((c) => c.id !== commentId);
const flaggedNodes = prev.flagged.nodes.filter((c) => c.id !== commentId);
const premodCount = premodNodes.length < prev.premod.nodes.length ? prev.premodCount - 1 : prev.premodCount;
const flaggedCount = flaggedNodes.length < prev.flagged.nodes.length ? prev.flaggedCount - 1 : prev.flaggedCount;
if (status === 'REJECTED') {
acceptedNodes = prev.accepted.nodes.filter((c) => c.id !== commentId);
acceptedCount = acceptedNodes.length < prev.accepted.nodes.length ? prev.acceptedCount - 1 : prev.acceptedCount;
}
else if (status === 'ACCEPTED') {
rejectedNodes = prev.rejected.nodes.filter((c) => c.id !== commentId);
rejectedCount = rejectedNodes.length < prev.rejected.nodes.length ? prev.rejectedCount - 1 : prev.rejectedCount;
}
return update(prev, {
premodCount: {$set: Math.max(0, premodCount)},
flaggedCount: {$set: Math.max(0, flaggedCount)},
acceptedCount: {$set: Math.max(0, acceptedCount)},
rejectedCount: {$set: Math.max(0, rejectedCount)},
premod: {nodes: {$set: premodNodes}},
flagged: {nodes: {$set: flaggedNodes}},
accepted: {nodes: {$set: acceptedNodes}},
rejected: {nodes: {$set: rejectedNodes}},
});
}
}
}),
},
};
+62
View File
@@ -0,0 +1,62 @@
import update from 'immutability-helper';
export function findCommentInModQueues(root, id, queues = ['all', 'premod', 'flagged', 'accepted', 'rejected']) {
return queues.reduce((comment, queue) => {
return comment ? comment : root[queue].nodes.find((c) => c.id === id);
}, null);
}
export function handleCommentStatusChange(root, {id, status}, previousStatus) {
const comment = findCommentInModQueues(root, id);
if (!previousStatus && comment) {
previousStatus = comment.status;
}
if (status === previousStatus) {
return root;
}
let acceptedNodes = root.accepted.nodes;
let acceptedCount = root.acceptedCount;
let rejectedNodes = root.rejected.nodes;
let rejectedCount = root.rejectedCount;
if (status === 'ACCEPTED') {
acceptedCount++;
if (comment) {
acceptedNodes = [{...comment, status}, ...acceptedNodes];
}
}
else if (status === 'REJECTED') {
rejectedCount++;
if (comment) {
rejectedNodes = [{...comment, status}, ...rejectedNodes];
}
}
const premodNodes = root.premod.nodes.filter((c) => c.id !== id);
const flaggedNodes = root.flagged.nodes.filter((c) => c.id !== id);
const premodCount = premodNodes.length < root.premod.nodes.length ? root.premodCount - 1 : root.premodCount;
const flaggedCount = flaggedNodes.length < root.flagged.nodes.length ? root.flaggedCount - 1 : root.flaggedCount;
if (status === 'REJECTED') {
acceptedNodes = root.accepted.nodes.filter((c) => c.id !== id);
acceptedCount = acceptedNodes.length < root.accepted.nodes.length ? root.acceptedCount - 1 : root.acceptedCount;
}
else if (status === 'ACCEPTED') {
rejectedNodes = root.rejected.nodes.filter((c) => c.id !== id);
rejectedCount = rejectedNodes.length < root.rejected.nodes.length ? root.rejectedCount - 1 : root.rejectedCount;
}
return update(root, {
premodCount: {$set: Math.max(0, premodCount)},
flaggedCount: {$set: Math.max(0, flaggedCount)},
acceptedCount: {$set: Math.max(0, acceptedCount)},
rejectedCount: {$set: Math.max(0, rejectedCount)},
premod: {nodes: {$set: premodNodes}},
flagged: {nodes: {$set: flaggedNodes}},
accepted: {nodes: {$set: acceptedNodes}},
rejected: {nodes: {$set: rejectedNodes}},
});
}
+2 -2
View File
@@ -2,7 +2,7 @@ import React from 'react';
import {render} from 'react-dom';
import {ApolloProvider} from 'react-apollo';
import {client} from './services/client';
import {getClient} from './services/client';
import store from './services/store';
import App from './components/App';
@@ -15,7 +15,7 @@ loadPluginsTranslations();
injectPluginsReducers();
render(
<ApolloProvider client={client} store={store}>
<ApolloProvider client={getClient()} store={store}>
<App />
</ApolloProvider>
, document.querySelector('#root')
@@ -12,194 +12,202 @@ import {getActionSummary} from 'coral-framework/utils';
import ActionButton from 'coral-admin/src/components/ActionButton';
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
import cn from 'classnames';
const linkify = new Linkify();
import t, {timeago} from 'coral-framework/services/i18n';
const Comment = ({
actions = [],
comment,
viewUserDetail,
suspectWords,
bannedWords,
minimal,
selected,
toggleSelect,
...props
}) => {
const links = linkify.getMatches(comment.body);
const linkText = links ? links.map((link) => link.raw) : [];
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
const flagActions =
comment.actions &&
comment.actions.filter((a) => a.__typename === 'FlagAction');
let commentType = '';
if (comment.status === 'PREMOD') {
commentType = 'premod';
} else if (flagActions && flagActions.length) {
commentType = 'flagged';
}
class Comment extends React.Component {
// since words are checked against word boundaries on the backend,
// should be the behavior on the front end as well.
// currently the highlighter plugin does not support out of the box.
const searchWords = [...suspectWords, ...bannedWords]
.filter((w) => {
return new RegExp(`(^|\\s)${w}(\\s|$)`).test(comment.body);
})
.concat(linkText);
render() {
const {
actions = [],
comment,
viewUserDetail,
suspectWords,
bannedWords,
minimal,
selected,
toggleSelect,
className,
...props
} = this.props;
let selectionStateCSS;
if (minimal) {
selectionStateCSS = selected ? styles.minimalSelection : '';
} else {
selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
}
const links = linkify.getMatches(comment.body);
const linkText = links ? links.map((link) => link.raw) : [];
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
const flagActions =
comment.actions &&
comment.actions.filter((a) => a.__typename === 'FlagAction');
let commentType = '';
if (comment.status === 'PREMOD') {
commentType = 'premod';
} else if (flagActions && flagActions.length) {
commentType = 'flagged';
}
return (
<li
tabIndex={props.index}
className={`mdl-card ${selectionStateCSS} ${styles.Comment} ${styles.listItem} ${minimal ? styles.minimal : ''}`}
>
<div className={styles.container}>
<div className={styles.itemHeader}>
<div className={styles.author}>
{
!minimal && (
<span className={styles.username} onClick={() => viewUserDetail(comment.user.id)}>
{comment.user.name}
</span>
)
}
{
minimal && typeof selected === 'boolean' && typeof toggleSelect === 'function' && (
<input
className={styles.bulkSelectInput}
type='checkbox'
value={comment.id}
checked={selected}
onChange={(e) => toggleSelect(e.target.value, e.target.checked)} />
)
}
<span className={styles.created}>
{timeago(comment.created_at || Date.now() - props.index * 60 * 1000)}
</span>
{props.currentUserId !== comment.user.id &&
<ActionsMenu icon="not_interested">
<ActionsMenuItem
disabled={comment.user.status === 'BANNED'}
onClick={() => props.showSuspendUserDialog(comment.user.id, comment.user.name, comment.id, comment.status)}>
Suspend User</ActionsMenuItem>
<ActionsMenuItem
disabled={comment.user.status === 'BANNED'}
onClick={() => props.showBanUserDialog(comment.user, comment.id, comment.status, comment.status !== 'REJECTED')}>
Ban User
</ActionsMenuItem>
</ActionsMenu>
}
<CommentType type={commentType} />
</div>
{comment.user.status === 'banned'
? <span className={styles.banned}>
<Icon name="error_outline" />
{t('comment.banned_user')}
// since words are checked against word boundaries on the backend,
// should be the behavior on the front end as well.
// currently the highlighter plugin does not support out of the box.
const searchWords = [...suspectWords, ...bannedWords]
.filter((w) => {
return new RegExp(`(^|\\s)${w}(\\s|$)`).test(comment.body);
})
.concat(linkText);
let selectionStateCSS;
if (minimal) {
selectionStateCSS = selected ? styles.minimalSelection : '';
} else {
selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
}
return (
<li
tabIndex={props.index}
className={cn(className, 'mdl-card', selectionStateCSS, styles.Comment, styles.listItem, {[styles.minimal] : minimal})}
>
<div className={styles.container}>
<div className={styles.itemHeader}>
<div className={styles.author}>
{
!minimal && (
<span className={styles.username} onClick={() => viewUserDetail(comment.user.id)}>
{comment.user.name}
</span>
)
}
{
minimal && typeof selected === 'boolean' && typeof toggleSelect === 'function' && (
<input
className={styles.bulkSelectInput}
type='checkbox'
value={comment.id}
checked={selected}
onChange={(e) => toggleSelect(e.target.value, e.target.checked)} />
)
}
<span className={styles.created}>
{timeago(comment.created_at || Date.now() - props.index * 60 * 1000)}
</span>
: null}
<Slot
data={props.data}
root={props.root}
fill="adminCommentInfoBar"
comment={comment}
/>
</div>
<div className={styles.moderateArticle}>
Story: {comment.asset.title}
{!props.currentAsset &&
<Link to={`/admin/moderate/${comment.asset.id}`}>{t('modqueue.moderate')}</Link>}
</div>
<div className={styles.itemBody}>
<p className={styles.body}>
<Highlighter
searchWords={searchWords}
textToHighlight={comment.body}
/>
{' '}
<a
className={styles.external}
href={`${comment.asset.url}#${comment.id}`}
target="_blank"
>
<Icon name="open_in_new" /> {t('comment.view_context')}
</a>
</p>
<Slot
data={props.data}
root={props.root}
fill="adminCommentContent"
comment={comment}
/>
<div className={styles.sideActions}>
{links
? <span className={styles.hasLinks}>
<Icon name="error_outline" /> Contains Link
{props.currentUserId !== comment.user.id &&
<ActionsMenu icon="not_interested">
<ActionsMenuItem
disabled={comment.user.status === 'BANNED'}
onClick={() => props.showSuspendUserDialog(comment.user.id, comment.user.name, comment.id, comment.status)}>
Suspend User</ActionsMenuItem>
<ActionsMenuItem
disabled={comment.user.status === 'BANNED'}
onClick={() => props.showBanUserDialog(comment.user, comment.id, comment.status, comment.status !== 'REJECTED')}>
Ban User
</ActionsMenuItem>
</ActionsMenu>
}
<CommentType type={commentType} />
</div>
{comment.user.status === 'banned'
? <span className={styles.banned}>
<Icon name="error_outline" />
{t('comment.banned_user')}
</span>
: null}
<div className={`actions ${styles.actions}`}>
{actions.map((action, i) => {
const active =
(action === 'REJECT' && comment.status === 'REJECTED') ||
(action === 'APPROVE' && comment.status === 'ACCEPTED');
return (
<ActionButton
minimal={minimal}
key={i}
type={action}
user={comment.user}
status={comment.status}
active={active}
acceptComment={() =>
(comment.status === 'ACCEPTED'
? null
: props.acceptComment({commentId: comment.id}))}
rejectComment={() =>
(comment.status === 'REJECTED'
? null
: props.rejectComment({commentId: comment.id}))}
/>
);
})}
</div>
<Slot
data={props.data}
root={props.root}
fill="adminCommentInfoBar"
comment={comment}
/>
</div>
<div className={styles.moderateArticle}>
Story: {comment.asset.title}
{!props.currentAsset &&
<Link to={`/admin/moderate/${comment.asset.id}`}>{t('modqueue.moderate')}</Link>}
</div>
<div className={styles.itemBody}>
<p className={styles.body}>
<Highlighter
searchWords={searchWords}
textToHighlight={comment.body}
/>
{' '}
<a
className={styles.external}
href={`${comment.asset.url}#${comment.id}`}
target="_blank"
>
<Icon name="open_in_new" /> {t('comment.view_context')}
</a>
</p>
<Slot
data={props.data}
root={props.root}
fill="adminSideActions"
fill="adminCommentContent"
comment={comment}
/>
<div className={styles.sideActions}>
{links
? <span className={styles.hasLinks}>
<Icon name="error_outline" /> Contains Link
</span>
: null}
<div className={`actions ${styles.actions}`}>
{actions.map((action, i) => {
const active =
(action === 'REJECT' && comment.status === 'REJECTED') ||
(action === 'APPROVE' && comment.status === 'ACCEPTED');
return (
<ActionButton
minimal={minimal}
key={i}
type={action}
user={comment.user}
status={comment.status}
active={active}
acceptComment={() =>
(comment.status === 'ACCEPTED'
? null
: props.acceptComment({commentId: comment.id}))}
rejectComment={() =>
(comment.status === 'REJECTED'
? null
: props.rejectComment({commentId: comment.id}))}
/>
);
})}
</div>
<Slot
data={props.data}
root={props.root}
fill="adminSideActions"
comment={comment}
/>
</div>
</div>
</div>
</div>
<Slot
data={props.data}
root={props.root}
fill="adminCommentDetailArea"
comment={comment}
/>
{flagActions && flagActions.length
? <FlagBox
actions={flagActions}
actionSummaries={flagActionSummaries}
/>
: null}
</li>
);
};
<Slot
data={props.data}
root={props.root}
fill="adminCommentDetailArea"
comment={comment}
/>
{flagActions && flagActions.length
? <FlagBox
actions={flagActions}
actionSummaries={flagActionSummaries}
/>
: null}
</li>
);
}
}
Comment.propTypes = {
minimal: PropTypes.bool,
viewUserDetail: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
className: PropTypes.string,
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired,
bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired,
currentAsset: PropTypes.object,
@@ -92,9 +92,8 @@ export default class Moderation extends Component {
}
render () {
const {root, moderation, settings, assets, viewUserDetail, hideUserDetail, ...props} = this.props;
const {root, moderation, settings, assets, viewUserDetail, hideUserDetail, activeTab, ...props} = this.props;
const providedAssetId = this.props.params.id;
const activeTab = this.props.route.path === ':id' ? 'premod' : this.props.route.path;
let asset;
@@ -6,6 +6,7 @@ import EmptyCard from '../../../components/EmptyCard';
import {actionsMap} from '../helpers/moderationQueueActionsMap';
import LoadMore from './LoadMore';
import t from 'coral-framework/services/i18n';
import {CSSTransitionGroup} from 'react-transition-group';
class ModerationQueue extends React.Component {
@@ -43,12 +44,27 @@ class ModerationQueue extends React.Component {
commentCount,
singleView,
viewUserDetail,
activeTab,
...props
} = this.props;
return (
<div id="moderationList" className={`${styles.list} ${singleView ? styles.singleView : ''}`}>
<ul style={{paddingLeft: 0}}>
<CSSTransitionGroup
key={activeTab}
component={'ul'}
style={{paddingLeft: 0}}
transitionName={{
enter: styles.commentEnter,
enterActive: styles.commentEnterActive,
leave: styles.commentLeave,
leaveActive: styles.commentLeaveActive,
}}
transitionEnter={true}
transitionLeave={true}
transitionEnterTimeout={1000}
transitionLeaveTimeout={1000}
>
{
comments.length
? comments.map((comment, i) => {
@@ -56,7 +72,7 @@ class ModerationQueue extends React.Component {
return <Comment
data={this.props.data}
root={this.props.root}
key={i}
key={comment.id}
index={i}
comment={comment}
selected={i === selectedIndex}
@@ -74,7 +90,7 @@ class ModerationQueue extends React.Component {
})
: <EmptyCard>{t('modqueue.empty_queue')}</EmptyCard>
}
</ul>
</CSSTransitionGroup>
<LoadMore
loadMore={this.loadMore}
showLoadMore={comments.length < commentCount}
@@ -479,3 +479,21 @@ span {
.bulkSelectInput {
cursor: pointer;
}
.commentLeave {
opacity: 1.0;
transition: opacity 800ms;
}
.commentLeaveActive {
opacity: 0;
}
.commentEnter {
opacity: 0;
transition: opacity 800ms;
}
.commentEnterActive {
opacity: 1.0;
}
@@ -10,6 +10,7 @@ import t, {timeago} from 'coral-framework/services/i18n';
import update from 'immutability-helper';
import {withSetUserStatus, withSuspendUser, withSetCommentStatus} from 'coral-framework/graphql/mutations';
import {handleCommentStatusChange, findCommentInModQueues} from '../../../graphql/utils';
import {fetchSettings} from 'actions/settings';
import {updateAssets} from 'actions/assets';
@@ -30,9 +31,65 @@ import {Spinner} from 'coral-ui';
import Moderation from '../components/Moderation';
import Comment from './Comment';
function truncate(s, length = 10) {
return (s.length > length) ? `${s.substring(0, length)}...` : s;
}
class ModerationContainer extends Component {
unsubscribe = null;
get activeTab() { return this.props.route.path === ':id' ? 'premod' : this.props.route.path; }
subscribeToUpdates() {
this.unsubscribe = this.props.data.subscribeToMore({
document: STATUS_CHANGED_SUBSCRIPTION,
variables: {
asset_id: this.props.data.variables.asset_id,
},
updateQuery: (prev, {subscriptionData: {data: {commentStatusChanged: {user, comment, previous}}}}) => {
const activeTab = this.activeTab;
// Status changed was caused by a different user.
if (user && user.id !== this.props.auth.user.id) {
if (findCommentInModQueues(prev, comment.id) && (
activeTab === 'all' && findCommentInModQueues(prev, comment.id, ['all'])
|| activeTab === 'premod' && previous.status === 'PREMOD'
|| activeTab === 'flagged' && findCommentInModQueues(prev, comment.id, ['flagged'])
|| comment.status === 'ACCEPTED' && activeTab === 'accepted'
|| comment.status !== 'ACCEPTED' && previous.status === 'ACCEPTED' && activeTab === 'accepted'
|| comment.status === 'REJECTED' && activeTab === 'rejected'
|| comment.status !== 'REJECTED' && previous.status === 'REJECTED' && activeTab === 'rejected'
)
) {
const text = `${user.username} ${comment.status.toLowerCase()} comment "${truncate(comment.body, 50)}"`;
notification.info(text);
}
}
return handleCommentStatusChange(prev, comment, previous.status, user);
},
});
}
unsubscribe() {
if (!this.unsubscribe) {
return;
}
this.unsubscribe();
this.unsubscribe = null;
}
resubscribe() {
this.unsubscribe();
this.subscribeToUpdates();
}
componentWillMount() {
this.props.fetchSettings();
this.subscribeToUpdates();
}
componentWillUnmount() {
this.unsubscribe();
}
componentWillReceiveProps(nextProps) {
@@ -40,6 +97,11 @@ class ModerationContainer extends Component {
if(!isEqual(nextProps.root.assets, this.props.root.assets)) {
updateAssets(nextProps.root.assets);
}
// Resubscribe when we change between assets.
if(this.props.data.variables.asset_id !== nextProps.data.variables.asset_id) {
this.resubscribe();
}
}
suspendUser = async (args) => {
@@ -113,6 +175,9 @@ class ModerationContainer extends Component {
return update(prev, {
[tab]: {
nodes: {$push: comments.nodes},
hasNextPage: {$set: comments.hasNextPage},
startCursor: {$set: comments.startCursor},
endCursor: {$set: comments.endCursor},
},
});
}
@@ -137,10 +202,30 @@ class ModerationContainer extends Component {
acceptComment={this.acceptComment}
rejectComment={this.rejectComment}
suspendUser={this.suspendUser}
activeTab={this.activeTab}
/>;
}
}
const STATUS_CHANGED_SUBSCRIPTION = gql`
subscription CommentStatusChanged($asset_id: ID){
commentStatusChanged(asset_id: $asset_id){
user {
id
username
}
comment {
id
status
body
}
previous {
status
}
}
}
`;
const LOAD_MORE_QUERY = gql`
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Date, $sort: SORT_ORDER, $asset_id: ID, $statuses:[COMMENT_STATUS!], $action_type: ACTION_TYPE) {
comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sort: $sort, action_type: $action_type}) {
@@ -153,6 +238,9 @@ const LOAD_MORE_QUERY = gql`
}
}
}
hasNextPage
startCursor
endCursor
}
}
${Comment.fragments.comment}
+4 -18
View File
@@ -1,20 +1,6 @@
import ApolloClient, {addTypename} from 'apollo-client';
import {networkInterface} from 'coral-framework/services/transport';
import {getClient as getFrameworkClient} from 'coral-framework/services/client';
import fragmentMatcher from './fragmentMatcher';
export const client = new ApolloClient({
fragmentMatcher,
connectToDevTools: true,
addTypename: true,
queryTransformer: addTypename,
dataIdFromObject: (result) => {
if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle
return `${result.__typename}_${result.id}`; // eslint-disable-line no-underscore-dangle
}
return null;
},
networkInterface
});
export default client;
export function getClient() {
return getFrameworkClient({fragmentMatcher});
}
+3 -3
View File
@@ -1,10 +1,10 @@
import {createStore, combineReducers, applyMiddleware, compose} from 'redux';
import thunk from 'redux-thunk';
import mainReducer from '../reducers';
import {client} from './client';
import {getClient} from './client';
const middlewares = [
applyMiddleware(client.middleware()),
applyMiddleware(getClient().middleware()),
applyMiddleware(thunk)
];
@@ -16,7 +16,7 @@ if (window.devToolsExtension) {
const coralReducers = {
...mainReducer,
apollo: client.reducer()
apollo: getClient().reducer()
};
const store = createStore(
+2 -1
View File
@@ -26,7 +26,7 @@ export function resetWebsocket() {
});
}
export function getClient() {
export function getClient(options = {}) {
if (client) {
return client;
}
@@ -56,6 +56,7 @@ export function getClient() {
);
client = new ApolloClient({
...options,
connectToDevTools: true,
addTypename: true,
queryTransformer: addTypename,
+4 -1
View File
@@ -326,7 +326,7 @@ 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}) => {
const setStatus = async ({user, loaders: {Comments}, pubsub}, {id, status}) => {
let comment = await CommentsService.pushStatus(id, status, user ? user.id : null);
// If the loaders are present, clear the caches for these values because we
@@ -370,6 +370,9 @@ const edit = async (context, {id, asset_id, edit: {body}}) => {
// Publish the edited comment via the subscription.
context.pubsub.publish('commentEdited', comment);
// Publish the comment status change via the subscription.
context.pubsub.publish('commentStatusChanged', comment);
}
return comment;
+7 -1
View File
@@ -31,7 +31,13 @@ const RootMutation = {
stopIgnoringUser(_, {id}, {mutators: {User}}) {
return wrapResponse(null)(User.stopIgnoringUser({id}));
},
setCommentStatus(_, {id, status}, {mutators: {Comment}}) {
setCommentStatus: async (_, {id, status}, {loaders: {Comments}, mutators: {Comment}, user, pubsub}) => {
const previous = await Comments.get.load(id);
const comment = await Comment.setStatus({id, status});
// Publish the comment status change via the subscription.
pubsub.publish('commentStatusChanged', {user, comment, previous});
return wrapResponse(null)(Comment.setStatus({id, status}));
},
addTag(_, {tag}, {mutators: {Tag}}) {
+4 -1
View File
@@ -4,7 +4,10 @@ const Subscription = {
},
commentEdited(comment) {
return comment;
}
},
commentStatusChanged(data) {
return data;
},
};
module.exports = Subscription;
+14
View File
@@ -10,6 +10,10 @@ const plugins = require('../services/plugins');
const {deserializeUser} = require('../services/subscriptions');
const {
SUBSCRIBE_COMMENT_STATUS,
} = require('../perms/constants');
/**
* Plugin support requires that we merge in existing setupFunctions with our new
* plugin based ones. This allows plugins to extend existing setupFunctions as well
@@ -30,6 +34,16 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu
filter: (comment) => comment.asset_id === args.asset_id
},
}),
commentStatusChanged: (options, args) => ({
commentStatusChanged: {
filter: ({comment}, context) => {
if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_STATUS)) {
return false;
}
return !args.asset_id || comment.asset_id === args.asset_id;
}
},
}),
});
/**
+8
View File
@@ -936,9 +936,17 @@ type RootMutation {
## Subscriptions
################################################################################
# Response to ignoreUser mutation
type CommentStatusChangedUpdate {
user: User
comment: Comment
previous: Comment
}
type Subscription {
commentAdded(asset_id: ID!): Comment
commentEdited(asset_id: ID!): Comment
commentStatusChanged(asset_id: ID): CommentStatusChangedUpdate
}
################################################################################
-1
View File
@@ -201,7 +201,6 @@
"regenerator": "^0.8.46",
"selenium-standalone": "^5.11.2",
"style-loader": "^0.16.0",
"subscriptions-transport-ws": "^0.5.5-alpha.0",
"supertest": "^2.0.1",
"timeago.js": "^2.0.3",
"webpack": "^2.3.1"
+4 -1
View File
@@ -21,5 +21,8 @@ module.exports = {
SEARCH_ACTIONS: 'SEARCH_ACTIONS',
SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS: 'SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS',
SEARCH_OTHERS_COMMENTS: 'SEARCH_OTHERS_COMMENTS',
SEARCH_COMMENT_METRICS: 'SEARCH_COMMENT_METRICS'
SEARCH_COMMENT_METRICS: 'SEARCH_COMMENT_METRICS',
// subscriptions
SUBSCRIBE_COMMENT_STATUS: 'SUBSCRIBE_COMMENT_STATUS',
};
+3 -1
View File
@@ -2,11 +2,13 @@ const constants = require('./constants');
const root = require('./rootReducer');
const queries = require('./queryReducer');
const mutations = require('./mutationReducer');
const subscriptions = require('./subscriptionReducer');
const reducers = [
root,
queries,
mutations
mutations,
subscriptions,
];
// this will make 'reducer' a key in this array. hm.
+11
View File
@@ -0,0 +1,11 @@
const {check} = require('./utils');
const types = require('./constants');
module.exports = (user, perm) => {
switch (perm) {
case types.SUBSCRIBE_COMMENT_STATUS:
return check(user, ['ADMIN', 'MODERATOR']);
default:
break;
}
};
+4
View File
@@ -215,6 +215,10 @@ module.exports = class CommentsService {
}
},
$set: {status}
}, {
// return modified comment.
new: true,
});
}