Support live updates

This commit is contained in:
Chi Vinh Le
2017-06-12 23:21:27 +07:00
parent 74ad3c3a35
commit 2a283a9a5a
16 changed files with 528 additions and 339 deletions
+1 -1
View File
@@ -1 +1 @@
export {withReaction} from 'coral-framework/hocs';
export {default as withReaction} from './withReaction';
+337
View File
@@ -0,0 +1,337 @@
import React from 'react';
import get from 'lodash/get';
import uuid from 'uuid/v4';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {getDisplayName} from 'coral-framework/helpers/hoc';
import {compose, gql, graphql} from 'react-apollo';
import withFragments from 'coral-framework/hocs/withFragments';
import {showSignInDialog} from 'coral-framework/actions/auth';
import {capitalize} from 'coral-framework/helpers/strings';
import {getMyActionSummary, getTotalActionCount} from 'coral-framework/utils';
import * as PropTypes from 'prop-types';
export default (reaction) => (WrappedComponent) => {
if (typeof reaction !== 'string') {
console.error('Reaction must be a valid string');
return null;
}
let instances = 0;
let createdSubscription = null;
let deletedSubscription = null;
reaction = reaction.toLowerCase();
const Reaction = capitalize(reaction);
const COMMENT_FRAGMENT = gql`
fragment ${Reaction}Button_updateFragment on Comment {
action_summaries {
... on ${Reaction}ActionSummary {
count
current_user {
id
}
}
}
}
`;
const isReaction = (a) =>
a.__typename === `${Reaction}ActionSummary`;
const addReactionToStore = (proxy, {action, self}) => {
const fragmentId = `Comment_${action.item_id}`;
// Read the data from our cache for this query.
const data = proxy.readFragment({
fragment: COMMENT_FRAGMENT,
id: fragmentId
});
// Add our comment from the mutation to the end.
let idx = data.action_summaries.findIndex(isReaction);
// Check whether we already reactioned this comment.
if (self && idx >= 0 && data.action_summaries[idx].current_user) {
return;
}
if (idx < 0) {
// Add initial action when it doesn't exist.
data.action_summaries.push({
__typename: `${Reaction}ActionSummary`,
count: 0,
current_user: null
});
idx = data.action_summaries.length - 1;
}
data.action_summaries[idx] = {
...data.action_summaries[idx],
count: data.action_summaries[idx].count + 1,
current_user: self ? action : data.action_summaries[idx].current_user
};
// Write our data back to the cache.
proxy.writeFragment({
fragment: COMMENT_FRAGMENT,
id: fragmentId,
data
});
};
const deleteReactionFromStore = (proxy, {action, self}) => {
const fragmentId = `Comment_${action.item_id}`;
// Read the data from our cache for this query.
const data = proxy.readFragment({
fragment: COMMENT_FRAGMENT,
id: fragmentId
});
// Check whether we liked this comment.
const idx = data.action_summaries.findIndex(isReaction);
if (
self &&
(idx < 0 || get(data.action_summaries[idx], 'current_user.id') !== action.id)
) {
return;
}
data.action_summaries[idx] = {
...data.action_summaries[idx],
count: data.action_summaries[idx].count - 1,
current_user: self ? null : data.action_summaries[idx].current_user,
};
// Write our data back to the cache.
proxy.writeFragment({
fragment: COMMENT_FRAGMENT,
id: fragmentId,
data
});
};
const REACTION_CREATED_SUBSCRIPTION = gql`
subscription ${Reaction}ActionCreated($assetId: ID!) {
${reaction}ActionCreated(asset_id: $assetId) {
id
user {
id
}
item_id
}
}
`;
const REACTION_DELETED_SUBSCRIPTION = gql`
subscription ${Reaction}ActionDeleted($assetId: ID!) {
${reaction}ActionDeleted(asset_id: $assetId) {
id
user {
id
}
item_id
}
}
`;
class WithReactions extends React.Component {
static contextTypes = {
client: PropTypes.object.isRequired,
};
constructor(props, context) {
super(props, context);
if (instances === 0) {
createdSubscription = context.client.subscribe({
query: REACTION_CREATED_SUBSCRIPTION,
variables: {
assetId: this.props.root.asset.id,
},
}).subscribe({
next: this.onReactionCreated,
error(err) { console.error('err', err); },
});
deletedSubscription = context.client.subscribe({
query: REACTION_DELETED_SUBSCRIPTION,
variables: {
assetId: this.props.root.asset.id,
},
}).subscribe({
next: this.onReactionDeleted,
error(err) { console.error('err', err); },
});
}
instances++;
}
onReactionCreated = ({[`${reaction}ActionCreated`]: action}) => {
if (this.props.user && action.user && this.props.user.id === action.user.id) {
return;
}
addReactionToStore(this.context.client, {action, self: false});
};
onReactionDeleted = ({[`${reaction}ActionDeleted`]: action}) => {
if (this.props.user && action.user && this.props.user.id === action.user.id) {
return;
}
deleteReactionFromStore(this.context.client, {action, self: false});
};
componentWillUnmount() {
instances--;
if (instances === 0) {
try {
createdSubscription.unsubscribe();
deletedSubscription.unsubscribe();
}
catch(e) {
console.warn(e);
}
}
}
render() {
const {comment} = this.props;
const reactionSummary = getMyActionSummary(
`${Reaction}ActionSummary`,
comment
);
const count = getTotalActionCount(
`${Reaction}ActionSummary`,
comment
);
const alreadyReacted = !!reactionSummary;
const withReactionProps = {reactionSummary, count, alreadyReacted};
return <WrappedComponent {...this.props} {...withReactionProps} />;
}
}
const withDeleteReaction = graphql(
gql`
mutation Delete${Reaction}Action($input: Delete${Reaction}ActionInput!) {
delete${Reaction}Action(input: $input) {
errors {
translation_key
}
}
}
`,
{
props: ({mutate, ownProps}) => ({
deleteReaction: () => {
const reactionSummary = getMyActionSummary(
`${Reaction}ActionSummary`,
ownProps.comment
);
const id = reactionSummary.current_user.id;
const item_id = ownProps.comment.id;
const input = {id};
return mutate({
variables: {input},
optimisticResponse: {
[`delete${Reaction}Action`]: {
__typename: `Delete${Reaction}ActionResponse`,
errors: null
}
},
update: (proxy) => {
deleteReactionFromStore(proxy, {action: {item_id, id}, self: true});
}
});
}
})
}
);
const withPostReaction = graphql(
gql`
mutation Create${Reaction}Action($input: Create${Reaction}ActionInput!) {
create${Reaction}Action(input: $input) {
${reaction} {
id
}
errors {
translation_key
}
}
}
`,
{
props: ({mutate, ownProps}) => ({
postReaction: () => {
const input = {
item_id: ownProps.comment.id,
};
return mutate({
variables: {input},
optimisticResponse: {
[`create${Reaction}Action`]: {
__typename: `Create${Reaction}ActionResponse`,
errors: null,
[reaction]: {
__typename: `${Reaction}Action`,
id: uuid()
}
}
},
update: (proxy, {data: {[`create${Reaction}Action`]: {[reaction]: action}}}) => {
const a = {
...action,
item_id: input.item_id,
};
addReactionToStore(proxy, {action: a, self: true});
}
});
}
})
}
);
const mapStateToProps = (state) => ({
user: state.auth.toJS().user,
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators({showSignInDialog}, dispatch);
const enhance = compose(
withFragments({
comment: gql`
fragment ${Reaction}Button_comment on Comment {
action_summaries {
... on ${Reaction}ActionSummary {
count
current_user {
id
}
}
}
}`
}),
connect(mapStateToProps, mapDispatchToProps),
withDeleteReaction,
withPostReaction
);
WithReactions.displayName = `WithReactions(${getDisplayName(WrappedComponent)})`;
return enhance(WithReactions);
};
+108 -35
View File
@@ -1,4 +1,5 @@
const wrapResponse = require('../../../graph/helpers/response');
const {SEARCH_OTHER_USERS} = require('../../../perms/constants');
function getReactionConfig(reaction) {
const Reaction = reaction.charAt(0).toUpperCase() + reaction.slice(1);
@@ -6,82 +7,142 @@ function getReactionConfig(reaction) {
const typeDefs = `
enum ACTION_TYPE {
# Represents a ${Reaction}.
${REACTION}
# Represents a ${Reaction}.
${REACTION}
}
enum ASSET_METRICS_SORT {
# Represents a ${Reaction}Action.
${REACTION}
# Represents a ${Reaction}Action.
${REACTION}
}
input Create${Reaction}Input {
input Create${Reaction}ActionInput {
# The item's id for which we are to create a ${reaction}.
item_id: ID!
# The item's id for which we are to create a ${reaction}.
item_id: ID!
}
# The type of the item for which we are to create the ${reaction}.
item_type: ACTION_ITEM_TYPE!
input Delete${Reaction}ActionInput {
# The item's id for which we are deleting a ${reaction}.
id: ID!
}
# ${Reaction}Action is used by users who "${reaction}" a specific entity.
type ${Reaction}Action implements Action {
# The ID of the action.
id: ID!
# The ID of the action.
id: ID!
# The author of the action.
user: User
# The author of the action.
user: User
# The time when the Action was updated.
updated_at: Date
# The time when the Action was updated.
updated_at: Date
# The time when the Action was created.
created_at: Date
# The time when the Action was created.
created_at: Date
# The item's id for which the Action was created.
item_id: ID!
}
type ${Reaction}ActionSummary implements ActionSummary {
# The count of actions with this group.
count: Int
# The count of actions with this group.
count: Int
# The current user's action.
current_user: ${Reaction}Action
# The current user's action.
current_user: ${Reaction}Action
}
# A summary of counts related to all the ${Reaction}s on an Asset.
type ${Reaction}AssetActionSummary implements AssetActionSummary {
# Number of ${reaction}s associated with actionable types on this this Asset.
actionCount: Int
# Number of ${reaction}s associated with actionable types on this this Asset.
actionCount: Int
# Number of unique actionable types that are referenced by the ${reaction}s.
actionableItemCount: Int
# Number of unique actionable types that are referenced by the ${reaction}s.
actionableItemCount: Int
}
type Create${Reaction}Response implements Response {
type Create${Reaction}ActionResponse implements Response {
# The ${reaction} that was created.
${reaction}: ${Reaction}Action
# The ${reaction} that was created.
${reaction}: ${Reaction}Action
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
type Delete${Reaction}ActionResponse implements Response {
# The ${reaction} that was created.
${reaction}: ${Reaction}Action
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
type RootMutation {
# Creates a ${reaction} on an entity.
create${Reaction}(${reaction}: Create${Reaction}Input!): Create${Reaction}Response
# Creates a ${reaction} on an entity.
create${Reaction}Action(input: Create${Reaction}ActionInput!): Create${Reaction}ActionResponse
delete${Reaction}Action(input: Delete${Reaction}ActionInput!): Delete${Reaction}ActionResponse
}
type Subscription {
# Subscribe to ${reaction}s.
${reaction}ActionCreated(asset_id: ID!): ${Reaction}Action
# Subscribe to ${reaction} removals.
${reaction}ActionDeleted(asset_id: ID!): ${Reaction}Action
}
`;
return {
typeDefs,
resolvers: {
Subscription: {
[`${reaction}ActionCreated`]: ({action}) => {
return action;
},
[`${reaction}ActionDeleted`]: ({action}) => {
return action;
},
},
[`${Reaction}Action`]: {
// This will load the user for the specific action. We'll limit this to the
// admin users only or the current logged in user.
user({user_id}, _, {loaders: {Users}, user}) {
if (user && (user.can(SEARCH_OTHER_USERS) || user_id === user.id)) {
return Users.getByID.load(user_id);
}
}
},
RootMutation: {
[`create${Reaction}`]: (_, {[reaction]: {item_id, item_type}}, {mutators: {Action}}) => {
return wrapResponse(reaction)(Action.create({item_id, item_type, action_type: REACTION}));
[`create${Reaction}Action`]: (_, {input: {item_id}}, {mutators: {Action}, pubsub, loaders: {Comments}}) => {
const response = Comments.get.load(item_id).then((comment) => {
return Action.create({item_id, item_type: 'COMMENTS', action_type: REACTION})
.then((action) => {
pubsub.publish(`${reaction}ActionCreated`, {action, comment});
return Promise.resolve(action);
});
});
return wrapResponse(reaction)(response);
},
[`delete${Reaction}Action`]: (_, {input: {id}}, {mutators: {Action}, pubsub, loaders: {Comments}}) => {
const response = Action.delete({id})
.then((action) => {
return Comments.get.load(action.item_id).then((comment) => {
pubsub.publish(`${reaction}ActionDeleted`, {action, comment});
return Promise.resolve(action);
});
});
return wrapResponse(reaction)(response);
}
},
},
@@ -106,7 +167,19 @@ function getReactionConfig(reaction) {
}
}
}
}
},
setupFunctions: {
[`${reaction}ActionCreated`]: (options, args) => ({
[`${reaction}ActionCreated`]: {
filter: ({comment}) => comment.asset_id === args.asset_id,
},
}),
[`${reaction}ActionDeleted`]: (options, args) => ({
[`${reaction}ActionDeleted`]: {
filter: ({comment}) => comment.asset_id === args.asset_id,
},
}),
},
};
}