diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index 13d286296..a16dc3886 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -65,7 +65,7 @@ class Comment extends React.Component { parentId: PropTypes.string, highlighted: PropTypes.string, addNotification: PropTypes.func.isRequired, - postItem: PropTypes.func.isRequired, + postComment: PropTypes.func.isRequired, depth: PropTypes.number.isRequired, asset: PropTypes.shape({ id: PropTypes.string, @@ -145,7 +145,7 @@ class Comment extends React.Component { // re-render this.setState(this.state); - }, msLeftToEdit); + }, msLeftToEdit); } } componentWillUnmount() { @@ -161,7 +161,7 @@ class Comment extends React.Component { currentUser, asset, depth, - postItem, + postComment, addNotification, showSignInDialog, highlighted, @@ -286,7 +286,7 @@ class Comment extends React.Component { } - { + { this.state.isEditing ? : null} @@ -386,7 +386,7 @@ class Comment extends React.Component { activeReplyBox={activeReplyBox} addNotification={addNotification} parentId={comment.id} - postItem={postItem} + postComment={postComment} editComment={this.props.editComment} depth={depth + 1} asset={asset} @@ -430,7 +430,7 @@ function commentIsStillEditable (comment) { if ( ! editing) {return false;} const editableUntil = getEditableUntilDate(comment); const editWindowExpired = (editableUntil - new Date) < 0; - return ! editWindowExpired; + return ! editWindowExpired; } // return number of milliseconds before edit window expires diff --git a/client/coral-embed-stream/src/components/Stream.js b/client/coral-embed-stream/src/components/Stream.js index 121aa7993..b7b325cf6 100644 --- a/client/coral-embed-stream/src/components/Stream.js +++ b/client/coral-embed-stream/src/components/Stream.js @@ -26,7 +26,7 @@ class Stream extends React.Component { render() { const { root: {asset, asset: {comments}, comment, myIgnoredUsers}, - postItem, + postComment, addNotification, postFlag, postDontAgree, @@ -84,7 +84,7 @@ class Stream extends React.Component { {user ? ({ variables: { assetId, @@ -83,11 +82,7 @@ export const withQuery = graphql(EMBED_QUERY, { hasComment: commentId !== '', excludeIgnored: Boolean(auth && auth.user && auth.user.id), }, - reducer: (previousResult, action, variables) => { - return reduceEditCommentActionsToUpdateStreamQuery(previousResult, action, variables); - }, }), - props: ({data}) => separateDataAndRoot(data) }); const mapStateToProps = state => ({ @@ -116,88 +111,6 @@ const mapDispatchToProps = dispatch => export default compose( connect(mapStateToProps, mapDispatchToProps), branch(props => !props.auth.checkedInitialLogin && props.config, renderComponent(Spinner)), - withQuery + withEmbedQuery, )(EmbedContainer); -/** - * Reduce editComment mutation actions - * producing a new queryStream result where asset.comments reflects the edit - */ -function reduceEditCommentActionsToUpdateStreamQuery(previousResult, action) { - if ( ! (action.type === 'APOLLO_MUTATION_RESULT' && action.operationName === 'editComment')) { - return previousResult; - } - const resultHasErrors = (result) => { - try { - return result.data.editComment.errors.length > 0; - } catch (error) { - - // expected if no errors; - return false; - } - }; - if (resultHasErrors(action.result)) { - return previousResult; - } - const {variables: {id, edit}, result: {data: {editComment: {comment: {status}}}}} = action; - const updateCommentWithEdit = (comment, edit) => { - const {body} = edit; - const editedComment = update(comment, { - $merge: { - body - }, - editing: {$merge:{edited:true}} - }); - return editedComment; - }; - const commentIsStillVisible = (comment) => { - return ! ((id === comment.id) && (['PREMOD', 'REJECTED'].includes(status))); - }; - const resultReflectingEdit = update(previousResult, { - asset: { - comments: { - $apply: comments => { - return comments.filter(commentIsStillVisible).map(comment => { - let replyWasEditedToBeHidden = false; - if (comment.id === id) { - return updateCommentWithEdit(comment, edit); - } - const commentWithUpdatedReplies = update(comment, { - replies: { - $apply: (comments) => { - return comments - .filter(c => { - if (commentIsStillVisible(c)) { - return true; - } - replyWasEditedToBeHidden = true; - return false; - }) - .map(comment => { - if (comment.id === id) { - return updateCommentWithEdit(comment, edit); - } - return comment; - }); - } - }, - }); - - // If a reply was edited to be hdiden, then this parent needs its replyCount to be decremented. - if (replyWasEditedToBeHidden) { - return update(commentWithUpdatedReplies, { - replyCount: { - $apply: (replyCount) => { - return replyCount - 1; - } - } - }); - } - return commentWithUpdatedReplies; - }); - } - } - } - }); - return resultReflectingEdit; -} diff --git a/client/coral-embed-stream/src/containers/Stream.js b/client/coral-embed-stream/src/containers/Stream.js index 443008913..f19cc0a34 100644 --- a/client/coral-embed-stream/src/containers/Stream.js +++ b/client/coral-embed-stream/src/containers/Stream.js @@ -6,13 +6,17 @@ import uniqBy from 'lodash/uniqBy'; import sortBy from 'lodash/sortBy'; import isNil from 'lodash/isNil'; import {NEW_COMMENT_COUNT_POLL_INTERVAL} from '../constants/stream'; -import {postComment, postFlag, postDontAgree, deleteAction, addCommentTag, removeCommentTag, ignoreUser, editComment} from 'coral-framework/graphql/mutations'; +import { + withPostComment, withPostFlag, withPostDontAgree, withDeleteAction, + withAddCommentTag, withRemoveCommentTag, withIgnoreUser, withEditComment, +} from 'coral-framework/graphql/mutations'; + import {notificationActions, authActions} from 'coral-framework'; import {editName} from 'coral-framework/actions/user'; import {setCommentCountCache, setActiveReplyBox} from '../actions/stream'; import Stream from '../components/Stream'; import Comment from './Comment'; -import withFragments from 'coral-framework/hocs/withFragments'; +import {withFragments} from 'coral-framework/hocs'; import {getDefinitionName} from 'coral-framework/utils'; const {showSignInDialog} = authActions; @@ -236,13 +240,13 @@ const mapDispatchToProps = dispatch => export default compose( withFragments(fragments), connect(mapStateToProps, mapDispatchToProps), - postComment, - postFlag, - postDontAgree, - addCommentTag, - removeCommentTag, - ignoreUser, - deleteAction, - editComment, + withPostComment, + withPostFlag, + withPostDontAgree, + withAddCommentTag, + withRemoveCommentTag, + withIgnoreUser, + withDeleteAction, + withEditComment, )(StreamContainer); diff --git a/client/coral-embed-stream/src/graphql/index.js b/client/coral-embed-stream/src/graphql/index.js new file mode 100644 index 000000000..daa8389b3 --- /dev/null +++ b/client/coral-embed-stream/src/graphql/index.js @@ -0,0 +1,270 @@ +import {gql} from 'react-apollo'; +import {add} from 'coral-framework/services/graphqlRegistry'; +import update from 'immutability-helper'; + +const extension = { + fragments: { + EditCommentResponse: gql` + fragment CoralEmbedStream_EditCommentResponse on EditCommentResponse { + comment { + status + } + errors { + translation_key + } + } + `, + StopIgnoringUserResponse: gql` + fragment CoralEmbedStream_StopIgnoringUserResponse on StopIgnoringUserResponse { + errors { + translation_key + } + } + `, + IgnoreUserResponse: gql` + fragment CoralEmbedStream_IgnoreUserResponse on IgnoreUserResponse { + errors { + translation_key + } + } + `, + RemoveCommentTagResponse: gql` + fragment CoralEmbedStream_RemoveCommentTagResponse on RemoveCommentTagResponse { + comment { + id + tags { + name + } + } + errors { + translation_key + } + } + `, + AddCommentTagResponse: gql` + fragment CoralEmbedStream_AddCommentTagResponse on AddCommentTagResponse { + comment { + id + tags { + name + } + } + errors { + translation_key + } + } + `, + DeleteActionResponse: gql` + fragment CoralEmbedStream_DeleteActionResponse on DeleteActionResponse { + errors { + translation_key + } + } + `, + CreateFlagResponse: gql` + fragment CoralEmbedStream_CreateFlagResponse on CreateFlagResponse { + flag { + id + } + errors { + translation_key + } + } + `, + CreateDontAgreeResponse : gql` + fragment CoralEmbedStream_CreateDontAgreeResponse on CreateDontAgreeResponse { + dontagree { + id + } + errors { + translation_key + } + } + `, + CreateCommentResponse: gql` + fragment CoralEmbedStream_CreateCommentResponse on CreateCommentResponse { + comment { + ...CoralEmbedStream_CreateCommentResponse_Comment + replies { + ...CoralEmbedStream_CreateCommentResponse_Comment + } + } + errors { + translation_key + } + } + + fragment CoralEmbedStream_CreateCommentResponse_Comment on Comment { + id + body + created_at + status + replyCount + tags { + name + } + user { + id + name: username + } + action_summaries { + count + current_user { + id + created_at + } + } + editing { + edited + editableUntil + } + } + `, + }, + mutations: { + IgnoreUser: () => ({ + + // TODO: don't rely on refetching. + refetchQueries: [ + 'EmbedQuery', 'myIgnoredUsers', + ], + }), + StopIgnoringUser: () => ({ + + // TODO: don't rely on refetching. + refetchQueries: [ + 'EmbedQuery', 'myIgnoredUsers', + ], + }), + PostComment: ({ + variables: {comment: {asset_id, body, parent_id, tags = []}}, + state: {auth}, + }) => ({ + optimisticResponse: { + createComment: { + comment: { + user: { + id: auth.toJS().user.id, + name: auth.toJS().user.username + }, + created_at: new Date().toISOString(), + body, + parent_id, + asset_id, + action_summaries: [], + tags, + status: null, + id: 'pending' + } + } + }, + updateQueries: { + EmbedQuery: (previousData, {mutationResult: {data: {createComment: {comment}}}}) => { + if (previousData.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED') { + return previousData; + } + + let updatedAsset; + + // If posting a reply + if (parent_id) { + updatedAsset = { + ...previousData, + asset: { + ...previousData.asset, + comments: previousData.asset.comments.map((oldComment) => { + return oldComment.id === parent_id + ? {...oldComment, replies: [...oldComment.replies, comment], replyCount: oldComment.replyCount + 1} + : oldComment; + }) + } + }; + } else { + + // If posting a top-level comment + updatedAsset = { + ...previousData, + asset: { + ...previousData.asset, + commentCount: previousData.asset.commentCount + 1, + comments: [comment, ...previousData.asset.comments] + } + }; + } + + return updatedAsset; + } + } + }), + EditComment: ({ + variables: {id, edit}, + }) => ({ + updateQueries: { + EmbedQuery: (previousData, {mutationResult: {data: {editComment: {comment: {status}}}}}) => { + const updateCommentWithEdit = (comment, edit) => { + const {body} = edit; + const editedComment = update(comment, { + $merge: { + body + }, + editing: {$merge:{edited:true}} + }); + return editedComment; + }; + const commentIsStillVisible = (comment) => { + return ! ((id === comment.id) && (['PREMOD', 'REJECTED'].includes(status))); + }; + const resultReflectingEdit = update(previousData, { + asset: { + comments: { + $apply: comments => { + return comments.filter(commentIsStillVisible).map(comment => { + let replyWasEditedToBeHidden = false; + if (comment.id === id) { + return updateCommentWithEdit(comment, edit); + } + const commentWithUpdatedReplies = update(comment, { + replies: { + $apply: (comments) => { + return comments + .filter(c => { + if (commentIsStillVisible(c)) { + return true; + } + replyWasEditedToBeHidden = true; + return false; + }) + .map(comment => { + if (comment.id === id) { + return updateCommentWithEdit(comment, edit); + } + return comment; + }); + } + }, + }); + + // If a reply was edited to be hdiden, then this parent needs its replyCount to be decremented. + if (replyWasEditedToBeHidden) { + return update(commentWithUpdatedReplies, { + replyCount: { + $apply: (replyCount) => { + return replyCount - 1; + } + } + }); + } + return commentWithUpdatedReplies; + }); + } + } + } + }); + return resultReflectingEdit; + }, + }, + }), + }, +}; + +add(extension); diff --git a/client/coral-embed-stream/src/index.js b/client/coral-embed-stream/src/index.js index 52a585fb2..0362f5644 100644 --- a/client/coral-embed-stream/src/index.js +++ b/client/coral-embed-stream/src/index.js @@ -4,6 +4,7 @@ import {ApolloProvider} from 'react-apollo'; import {client} from 'coral-framework/services/client'; import {checkLogin} from 'coral-framework/actions/auth'; +import './graphql'; import {addExternalConfig} from 'coral-embed-stream/src/actions/config'; import reducers from './reducers'; diff --git a/client/coral-framework/graphql/fragments.js b/client/coral-framework/graphql/fragments.js new file mode 100644 index 000000000..f4948ed05 --- /dev/null +++ b/client/coral-framework/graphql/fragments.js @@ -0,0 +1,2 @@ +// fragments defined here are automatically registered. +export default {}; diff --git a/client/coral-framework/graphql/fragments/actionSummaryView.graphql b/client/coral-framework/graphql/fragments/actionSummaryView.graphql deleted file mode 100644 index 4ac232bf6..000000000 --- a/client/coral-framework/graphql/fragments/actionSummaryView.graphql +++ /dev/null @@ -1,8 +0,0 @@ -fragment actionSummaryView on ActionSummary { - __typename - count - current_user { - id - created_at - } -} diff --git a/client/coral-framework/graphql/fragments/commentView.graphql b/client/coral-framework/graphql/fragments/commentView.graphql deleted file mode 100644 index 76f4c8caa..000000000 --- a/client/coral-framework/graphql/fragments/commentView.graphql +++ /dev/null @@ -1,22 +0,0 @@ -#import "../fragments/actionSummaryView.graphql" - -fragment commentView on Comment { - id - body - created_at - status - tags { - name - } - user { - id - name: username - } - action_summaries { - ...actionSummaryView - } - editing { - edited - editableUntil - } -} diff --git a/client/coral-framework/graphql/mutations.js b/client/coral-framework/graphql/mutations.js new file mode 100644 index 000000000..66e68be97 --- /dev/null +++ b/client/coral-framework/graphql/mutations.js @@ -0,0 +1,171 @@ +import {gql} from 'react-apollo'; +import withMutation from '../hocs/withMutation'; + +export const withPostComment = withMutation( + gql` + mutation PostComment($comment: CreateCommentInput!) { + createComment(comment: $comment) { + ...CreateCommentResponse + } + } + `, { + props: ({mutate}) => ({ + postComment: comment => { + return mutate({ + variables: { + comment + }, + }); + } + }), + }); + +export const withEditComment = withMutation( + gql` + mutation EditComment($id: ID!, $asset_id: ID!, $edit: EditCommentInput) { + editComment(id:$id, asset_id:$asset_id, edit:$edit) { + ...EditCommentResponse + } + } + `, { + props: ({mutate}) => ({ + editComment: (id, asset_id, edit) => { + return mutate({ + variables: { + id, + asset_id, + edit, + }, + }); + } + }), + }); + +export const withPostFlag = withMutation( + gql` + mutation PostFlag($flag: CreateFlagInput!) { + createFlag(flag: $flag) { + ...CreateFlagResponse + } + } + `, { + props: ({mutate}) => ({ + postFlag: (flag) => { + return mutate({ + variables: { + flag + } + }); + }}), + }); + +export const withPostDontAgree = withMutation( + gql` + mutation CreateDontAgree($dontagree: CreateDontAgreeInput!) { + createDontAgree(dontagree: $dontagree) { + ...CreateDontAgreeResponse + } + } + `, { + props: ({mutate}) => ({ + postDontAgree: (dontagree) => { + return mutate({ + variables: { + dontagree + } + }); + }}), + }); + +export const withDeleteAction = withMutation( + gql` + mutation DeleteAction($id: ID!) { + deleteAction(id:$id) { + ...DeleteActionResponse + } + } + `, { + props: ({mutate}) => ({ + deleteAction: (id) => { + return mutate({ + variables: { + id + } + }); + }}), + }); + +export const withAddCommentTag = withMutation( + gql` + mutation AddCommentTag($id: ID!, $tag: String!) { + addCommentTag(id:$id, tag:$tag) { + ...AddCommentTagResponse + } + } + `, { + props: ({mutate}) => ({ + addCommentTag: ({id, tag}) => { + return mutate({ + variables: { + id, + tag + } + }); + }}), + }); + +export const withRemoveCommentTag = withMutation( + gql` + mutation RemoveCommentTag($id: ID!, $tag: String!) { + removeCommentTag(id:$id, tag:$tag) { + ...RemoveCommentTagResponse + } + } + `, { + props: ({mutate}) => ({ + removeCommentTag: ({id, tag}) => { + return mutate({ + variables: { + id, + tag + } + }); + }}), + }); + +export const withIgnoreUser = withMutation( + gql` + mutation IgnoreUser($id: ID!) { + ignoreUser(id:$id) { + ...IgnoreUserResponse + } + } + `, { + props: ({mutate}) => ({ + ignoreUser: ({id}) => { + return mutate({ + variables: { + id, + }, + }); + }}), + }); + +export const withStopIgnoringUser = withMutation( + gql` + mutation StopIgnoringUser($id: ID!) { + stopIgnoringUser(id:$id) { + ...StopIgnoringUserResponse + } + } + `, { + props: ({mutate}) => ({ + stopIgnoringUser: ({id}) => { + return mutate({ + variables: { + id, + }, + }); + }}), + }); + diff --git a/client/coral-framework/graphql/mutations/addCommentTag.graphql b/client/coral-framework/graphql/mutations/addCommentTag.graphql deleted file mode 100644 index 5fd63868e..000000000 --- a/client/coral-framework/graphql/mutations/addCommentTag.graphql +++ /dev/null @@ -1,13 +0,0 @@ -mutation AddCommentTag ($id: ID!, $tag: String!) { - addCommentTag(id:$id, tag:$tag) { - comment { - id - tags { - name - } - } - errors { - translation_key - } - } -} diff --git a/client/coral-framework/graphql/mutations/deleteAction.graphql b/client/coral-framework/graphql/mutations/deleteAction.graphql deleted file mode 100644 index f8adf371c..000000000 --- a/client/coral-framework/graphql/mutations/deleteAction.graphql +++ /dev/null @@ -1,7 +0,0 @@ -mutation deleteAction ($id: ID!) { - deleteAction(id:$id) { - errors { - translation_key - } - } -} diff --git a/client/coral-framework/graphql/mutations/editComment.graphql b/client/coral-framework/graphql/mutations/editComment.graphql deleted file mode 100644 index 3cd1ec323..000000000 --- a/client/coral-framework/graphql/mutations/editComment.graphql +++ /dev/null @@ -1,10 +0,0 @@ -mutation editComment ($id: ID!, $asset_id: ID!, $edit: EditCommentInput) { - editComment(id:$id, asset_id:$asset_id, edit:$edit) { - comment { - status - } - errors { - translation_key - } - } -} diff --git a/client/coral-framework/graphql/mutations/ignoreUser.graphql b/client/coral-framework/graphql/mutations/ignoreUser.graphql deleted file mode 100644 index ad3c399f3..000000000 --- a/client/coral-framework/graphql/mutations/ignoreUser.graphql +++ /dev/null @@ -1,7 +0,0 @@ -mutation ignoreUser ($id: ID!) { - ignoreUser(id:$id) { - errors { - translation_key - } - } -} diff --git a/client/coral-framework/graphql/mutations/index.js b/client/coral-framework/graphql/mutations/index.js deleted file mode 100644 index 4fb87c42c..000000000 --- a/client/coral-framework/graphql/mutations/index.js +++ /dev/null @@ -1,191 +0,0 @@ -import {graphql} from 'react-apollo'; -import POST_COMMENT from './postComment.graphql'; -import POST_FLAG from './postFlag.graphql'; -import POST_DONT_AGREE from './postDontAgree.graphql'; -import DELETE_ACTION from './deleteAction.graphql'; -import ADD_COMMENT_TAG from './addCommentTag.graphql'; -import REMOVE_COMMENT_TAG from './removeCommentTag.graphql'; -import IGNORE_USER from './ignoreUser.graphql'; -import STOP_IGNORING_USER from './stopIgnoringUser.graphql'; -import EDIT_COMMENT from './editComment.graphql'; - -import commentView from '../fragments/commentView.graphql'; - -export const postComment = graphql(POST_COMMENT, { - options: () => ({ - fragments: commentView - }), - props: ({ownProps, mutate}) => ({ - postItem: comment => { - const {asset_id, body, parent_id, tags = []} = comment; - return mutate({ - variables: { - comment - }, - optimisticResponse: { - createComment: { - comment: { - user: { - id: ownProps.auth.user.id, - name: ownProps.auth.user.username - }, - created_at: new Date().toISOString(), - body, - parent_id, - asset_id, - action_summaries: [], - tags, - status: null, - id: 'pending' - } - } - }, - updateQueries: { - EmbedQuery: (oldData, {mutationResult: {data: {createComment: {comment}}}}) => { - - if (oldData.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED') { - return oldData; - } - - let updatedAsset; - - // If posting a reply - if (parent_id) { - updatedAsset = { - ...oldData, - asset: { - ...oldData.asset, - comments: oldData.asset.comments.map((oldComment) => { - return oldComment.id === parent_id - ? {...oldComment, replies: [...oldComment.replies, comment], replyCount: oldComment.replyCount + 1} - : oldComment; - }) - } - }; - } else { - - // If posting a top-level comment - updatedAsset = { - ...oldData, - asset: { - ...oldData.asset, - commentCount: oldData.asset.commentCount + 1, - comments: [comment, ...oldData.asset.comments] - } - }; - } - - return updatedAsset; - } - } - }); - } - }), -}); - -export const postFlag = graphql(POST_FLAG, { - props: ({mutate}) => ({ - postFlag: (flag) => { - return mutate({ - variables: { - flag - } - }); - }}), -}); - -export const postDontAgree = graphql(POST_DONT_AGREE, { - props: ({mutate}) => ({ - postDontAgree: (dontagree) => { - return mutate({ - variables: { - dontagree - } - }); - }}), -}); - -export const deleteAction = graphql(DELETE_ACTION, { - props: ({mutate}) => ({ - deleteAction: (id) => { - return mutate({ - variables: { - id - } - }); - }}), -}); - -export const addCommentTag = graphql(ADD_COMMENT_TAG, { - props: ({mutate}) => ({ - addCommentTag: ({id, tag}) => { - return mutate({ - variables: { - id, - tag - } - }); - }}), -}); - -export const removeCommentTag = graphql(REMOVE_COMMENT_TAG, { - props: ({mutate}) => ({ - removeCommentTag: ({id, tag}) => { - return mutate({ - variables: { - id, - tag - } - }); - }}), -}); - -// TODO: don't rely on refetching. -export const ignoreUser = graphql(IGNORE_USER, { - props: ({mutate}) => ({ - ignoreUser: ({id}) => { - return mutate({ - variables: { - id, - }, - refetchQueries: [ - 'EmbedQuery', 'myIgnoredUsers', - ] - }); - }}), -}); - -// TODO: don't rely on refetching. -export const stopIgnoringUser = graphql(STOP_IGNORING_USER, { - props: ({mutate}) => { - return { - stopIgnoringUser: ({id}) => { - return mutate({ - variables: { - id, - }, - refetchQueries: [ - 'EmbedQuery', 'myIgnoredUsers', - ] - }); - } - }; - } -}); - -export const editComment = graphql(EDIT_COMMENT, { - props: ({mutate}) => { - return { - editComment: (id, asset_id, edit) => { - return mutate({ - variables: { - id, - asset_id, - edit, - }, - refetchQueries: [] - }); - } - }; - } -}); diff --git a/client/coral-framework/graphql/mutations/postComment.graphql b/client/coral-framework/graphql/mutations/postComment.graphql deleted file mode 100644 index f98558804..000000000 --- a/client/coral-framework/graphql/mutations/postComment.graphql +++ /dev/null @@ -1,16 +0,0 @@ -#import "../fragments/commentView.graphql" - -mutation CreateComment ($comment: CreateCommentInput!) { - createComment(comment: $comment) { - comment { - ...commentView - replyCount - replies { - ...commentView - } - } - errors { - translation_key - } - } -} diff --git a/client/coral-framework/graphql/mutations/postDontAgree.graphql b/client/coral-framework/graphql/mutations/postDontAgree.graphql deleted file mode 100644 index 6e36d48b8..000000000 --- a/client/coral-framework/graphql/mutations/postDontAgree.graphql +++ /dev/null @@ -1,10 +0,0 @@ -mutation CreateDontAgree($dontagree: CreateDontAgreeInput!) { - createDontAgree(dontagree:$dontagree) { - dontagree { - id - } - errors { - translation_key - } - } -} diff --git a/client/coral-framework/graphql/mutations/postFlag.graphql b/client/coral-framework/graphql/mutations/postFlag.graphql deleted file mode 100644 index cabc2feef..000000000 --- a/client/coral-framework/graphql/mutations/postFlag.graphql +++ /dev/null @@ -1,10 +0,0 @@ -mutation CreateFlag($flag: CreateFlagInput!) { - createFlag(flag:$flag) { - flag { - id - } - errors { - translation_key - } - } -} diff --git a/client/coral-framework/graphql/mutations/removeCommentTag.graphql b/client/coral-framework/graphql/mutations/removeCommentTag.graphql deleted file mode 100644 index 3826b0703..000000000 --- a/client/coral-framework/graphql/mutations/removeCommentTag.graphql +++ /dev/null @@ -1,13 +0,0 @@ -mutation RemoveCommentTag ($id: ID!, $tag: String!) { - removeCommentTag(id:$id, tag:$tag) { - comment { - id - tags { - name - } - } - errors { - translation_key - } - } -} diff --git a/client/coral-framework/graphql/mutations/stopIgnoringUser.graphql b/client/coral-framework/graphql/mutations/stopIgnoringUser.graphql deleted file mode 100644 index 042452ff5..000000000 --- a/client/coral-framework/graphql/mutations/stopIgnoringUser.graphql +++ /dev/null @@ -1,7 +0,0 @@ -mutation stopIgnoringUser ($id: ID!) { - stopIgnoringUser(id:$id) { - errors { - translation_key - } - } -} diff --git a/client/coral-framework/graphql/queries/index.js b/client/coral-framework/graphql/queries/index.js deleted file mode 100644 index d76614a68..000000000 --- a/client/coral-framework/graphql/queries/index.js +++ /dev/null @@ -1,13 +0,0 @@ -import {graphql} from 'react-apollo'; -import MY_COMMENT_HISTORY from './myCommentHistory.graphql'; -import MY_IGNORED_USERS from './myIgnoredUsers.graphql'; - -export const myCommentHistory = graphql(MY_COMMENT_HISTORY, {}); - -export const myIgnoredUsers = graphql(MY_IGNORED_USERS, { - props: ({data}) => { - return ({ - myIgnoredUsersData: data - }); - } -}); diff --git a/client/coral-framework/graphql/queries/myCommentHistory.graphql b/client/coral-framework/graphql/queries/myCommentHistory.graphql deleted file mode 100644 index 0b37b192a..000000000 --- a/client/coral-framework/graphql/queries/myCommentHistory.graphql +++ /dev/null @@ -1,14 +0,0 @@ -query myCommentHistory { - me { - comments { - id - body - asset { - id - title - url - } - created_at - } - } -} diff --git a/client/coral-framework/graphql/queries/myIgnoredUsers.graphql b/client/coral-framework/graphql/queries/myIgnoredUsers.graphql deleted file mode 100644 index d81531e37..000000000 --- a/client/coral-framework/graphql/queries/myIgnoredUsers.graphql +++ /dev/null @@ -1,6 +0,0 @@ -query myIgnoredUsers { - myIgnoredUsers { - id, - username, - } -} diff --git a/client/coral-framework/helpers/plugins.js b/client/coral-framework/helpers/plugins.js index d701b933f..bde7f9d68 100644 --- a/client/coral-framework/helpers/plugins.js +++ b/client/coral-framework/helpers/plugins.js @@ -3,9 +3,9 @@ import merge from 'lodash/merge'; import flatten from 'lodash/flatten'; import flattenDeep from 'lodash/flattenDeep'; import uniq from 'lodash/uniq'; +import pick from 'lodash/pick'; import plugins from 'pluginsConfig'; -import {gql} from 'react-apollo'; -import {getDefinitionName} from 'coral-framework/utils'; +import {getDefinitionName, mergeDocuments} from 'coral-framework/utils'; export const pluginReducers = merge( ...plugins @@ -25,19 +25,28 @@ export function getSlotElements(slot, props = {}) { } function getComponentFragments(components) { - return components + const res = components .map(c => c.fragments) .filter(fragments => fragments) .reduce((res, fragments) => { Object.keys(fragments).forEach(key => { if (!(key in res)) { - res[key] = {spreads: '', definitions: ''}; + res[key] = {spreads: [], definitions: []}; } - res[key].spreads += `...${getDefinitionName(fragments[key])}\n`; - res[key].definitions = gql`${res[key].definitions}${fragments[key]}`; + res[key].spreads.push(getDefinitionName(fragments[key])); + res[key].definitions.push(fragments[key]); }); return res; }, {}); + + Object.keys(res).forEach(key => { + + // Assemble arguments for `gql` to call it directly without using template literals. + res[key].spreads = `...${res[key].spreads.join('\n...')}\n`; + res[key].definitions = mergeDocuments(res[key].definitions); + }); + + return res; } /** @@ -73,3 +82,9 @@ export function getSlotsFragments(slots) { }; } +export function getGraphQLExtensions() { + return plugins + .map(o => pick(o.module, ['mutations', 'queries', 'fragments'])) + .filter(o => o); +} + diff --git a/client/coral-framework/hocs/index.js b/client/coral-framework/hocs/index.js index 831c356d2..ef4c2ac74 100644 --- a/client/coral-framework/hocs/index.js +++ b/client/coral-framework/hocs/index.js @@ -1,7 +1,5 @@ -import withFragments from './withFragments'; -import withReaction from './withReaction'; +export {default as withFragments} from './withFragments'; +export {default as withMutation} from './withMutation'; +export {default as withQuery} from './withQuery'; +export {default as withReaction} from './withReaction'; -export default { - withFragments, - withReaction -}; diff --git a/client/coral-framework/hocs/withMutation.js b/client/coral-framework/hocs/withMutation.js new file mode 100644 index 000000000..c08a53614 --- /dev/null +++ b/client/coral-framework/hocs/withMutation.js @@ -0,0 +1,96 @@ +import * as React from 'react'; +import {graphql} from 'react-apollo'; +import merge from 'lodash/merge'; +import uniq from 'lodash/uniq'; +import flatten from 'lodash/flatten'; +import isEmpty from 'lodash/isEmpty'; +import {getMutationOptions, resolveFragments} from 'coral-framework/services/graphqlRegistry'; +import {store} from 'coral-framework/services/store'; +import {getDefinitionName} from '../utils'; + +/** + * Exports a HOC with the same signature as `graphql`, that will + * apply mutation options registered in the graphRegistry. + */ +export default (document, config) => WrappedComponent => { + config = { + ...config, + options: config.options || {}, + props: config.props || (data => ({mutate: data.mutate()})), + }; + const wrappedProps = (data) => { + const name = getDefinitionName(document); + const callbacks = getMutationOptions(name); + const mutate = (base) => { + const variables = base.variables || config.options.variables; + const configs = callbacks.map(cb => cb({variables, state: store.getState()})); + + const optimisticResponse = merge( + base.optimisticResponse || config.options.optimisticResponse, + ...configs.map(cfg => cfg.optimisticResponse), + ); + + const refetchQueries = flatten(uniq([ + base.refetchQueries || config.options.refetchQueries, + ...configs.map(cfg => cfg.refetchQueries), + ].filter(i => i))); + + const updateCallbacks = + [base.update || config.options.update] + .concat(...configs.map(cfg => cfg.update)) + .filter(i => i); + + const update = (proxy, result) => { + updateCallbacks.forEach(cb => cb(proxy, result)); + }; + + const updateQueries = + [ + base.updateQueries || config.options.updateQueries, + ...configs.map(cfg => cfg.updateQueries) + ] + .filter(i => i) + .reduce((res, map) => { + Object.keys(map).forEach(key => { + if (!(key in res)) { + res[key] = map[key]; + } else { + const existing = res[key]; + res[key] = (prev, result) => { + const next = existing(prev, result); + return map[key](next, result); + }; + } + }); + return res; + }, {}); + + const wrappedConfig = { + variables, + optimisticResponse, + refetchQueries, + updateQueries, + update, + }; + if (isEmpty(wrappedConfig.optimisticResponse)) { + delete wrappedConfig.optimisticResponse; + } + return data.mutate(wrappedConfig); + }; + return config.props({...data, mutate}); + }; + + // Lazily resolve fragments from graphRegistry to support circular dependencies. + let memoized = null; + const getWrapped = () => { + if (!memoized) { + memoized = graphql(resolveFragments(document), {...config, props: wrappedProps})(WrappedComponent); + } + return memoized; + }; + + return (props) => { + const Wrapped = getWrapped(); + return ; + }; +}; diff --git a/client/coral-framework/hocs/withQuery.js b/client/coral-framework/hocs/withQuery.js new file mode 100644 index 000000000..a29d25241 --- /dev/null +++ b/client/coral-framework/hocs/withQuery.js @@ -0,0 +1,49 @@ +import * as React from 'react'; +import {graphql} from 'react-apollo'; +import {getQueryOptions, resolveFragments} from 'coral-framework/services/graphqlRegistry'; +import {getDefinitionName, separateDataAndRoot} from '../utils'; + +/** + * Exports a HOC with the same signature as `graphql`, that will + * apply query options registered in the graphRegistry. + */ +export default (document, config) => WrappedComponent => { + config = { + ...config, + options: config.options || {}, + props: config.props || (({data}) => separateDataAndRoot(data)), + }; + + const wrappedOptions = (data) => { + const base = (typeof config.options === 'function') ? config.options(data) : config.options; + const name = getDefinitionName(document); + const configs = getQueryOptions(name); + const reducerCallbacks = + [base.reducer || (i => i)] + .concat(...configs.map(cfg => cfg.reducer)) + .filter(i => i); + + const reducer = reducerCallbacks.reduce( + (a, b) => (prev, ...rest) => + b(a(prev, ...rest), ...rest), + ); + + return { + ...base, + reducer, + }; + }; + + let memoized = null; + const getWrapped = () => { + if (!memoized) { + memoized = graphql(resolveFragments(document), {...config, options: wrappedOptions})(WrappedComponent); + } + return memoized; + }; + + return (props) => { + const Wrapped = getWrapped(); + return ; + }; +}; diff --git a/client/coral-framework/index.js b/client/coral-framework/index.js index 46ee704f4..b85b69bcd 100644 --- a/client/coral-framework/index.js +++ b/client/coral-framework/index.js @@ -1,7 +1,6 @@ import pym from './services/PymConnection'; import I18n from './modules/i18n/i18n'; import actions from './actions'; -import hocs from './hocs'; // TODO (bc): Deprecate old actions. Spreading actions is now needed. @@ -9,6 +8,5 @@ export default { pym, I18n, actions, - ...hocs, ...actions }; diff --git a/client/coral-framework/services/graphqlRegistry.js b/client/coral-framework/services/graphqlRegistry.js new file mode 100644 index 000000000..78d27d038 --- /dev/null +++ b/client/coral-framework/services/graphqlRegistry.js @@ -0,0 +1,198 @@ +import {getDefinitionName, mergeDocuments} from 'coral-framework/utils'; +import {getGraphQLExtensions} from 'coral-framework/helpers/plugins'; +import globalFragments from 'coral-framework/graphql/fragments'; +import uniq from 'lodash/uniq'; + +const fragments = {}; +const mutationOptions = {}; +const queryOptions = {}; + +const getTypeName = (ast) => ast.definitions[0].typeCondition.name.value; + +/** + * Add fragment + * + * Example: + * addFragment('MyFragment', gql` + * fragment Plugin_MyFragment on Comment { + * body + * } + * `); + */ +export function addFragment(key, document) { + const type = getTypeName(document); + const name = getDefinitionName(document); + if (!(key in fragments)) { + fragments[key] = {type, names: [name], documents: [document]}; + } else { + if (type !== fragments[key].type) { + console.error(`Type mismatch ${type} !== ${fragments[key].type}`); + } + fragments[key].names.push(name); + fragments[key].documents.push(document); + } +} + +/** + * Add mutation options. + * + * Example: + * // state is the current redux state, which is sometimes + * // necessary to fill the optimistic response. + * addMutationOptions('PostComment', ({variables, state}) => ({ + * optimisticResponse: { + * CreateComment: { + * extra: '', + * }, + * }, + * refetchQueries: [], + * updateQueries: { + * EmbedQuery: (previous, data) => { + * return previous; + * }, + * }, + * update: (proxy, result) => { + * }, + * }) + */ +export function addMutationOptions(key, config) { + if (!(key in mutationOptions)) { + mutationOptions[key] = [config]; + } else { + mutationOptions[key].push(config); + } +} + +/** + * Add query options. + * + * Example: + * addQueryOptions('EmbedQuery', { + * reducer: (previousResult, action, variables) => previousResult, + * }); + */ +export function addQueryOptions(key, config) { + if (!(key in queryOptions)) { + queryOptions[key] = [config]; + } else { + queryOptions[key].push(config); + } +} + +/** + * Add all fragments, mutation options, and query options defined in the object. + * + * Example: + * add({ + * fragments: { + * CreateCommentResponse: gql` + * fragment CoralRandomEmoji_CreateCommentResponse on CreateCommentResponse { + * [...] + * }`, + * }, + * mutations: { + * // state is the current redux state, which is sometimes + * // necessary to fill the optimistic response. + * PostComment: ({variables, state}) => ({ + * optimisticResponse: { + * [...] + * }, + * refetchQueries: [], + * updateQueries: { + * EmbedQuery: (previous, data) => { + * return previous; + * }, + * }, + * update: (proxy, result) => { + * }, + * }) + * }, + * queries: { + * EmbedQuery: { + * reducer: (previousResult, action, variables) => { + * return previousResult; + * }, + * }, + * }, + * }); + */ +export function add(extension) { + Object.keys(extension.fragments || []).forEach(key => addFragment(key, extension.fragments[key])); + Object.keys(extension.mutations || []).forEach(key => addMutationOptions(key, extension.mutations[key])); + Object.keys(extension.queries || []).forEach(key => addQueryOptions(key, extension.queries[key])); +} + +/** + * Get a list of mutation options. + */ +export function getMutationOptions(key) { + init(); + return mutationOptions[key] || []; +} + +/** + * Get a list of query options. + */ +export function getQueryOptions(key) { + init(); + return queryOptions[key] || []; +} + +/** + * Get a document with a fragment named `key`, which contains + * all fragments added under this key. + */ +export function getFragmentDocument(key) { + init(); + + if (!(key in fragments)) { + return ''; + } + + let documents = fragments[key] ? fragments[key].documents : []; + let fields = fragments[key] ? `...${fragments[key].names.join('\n...')}\n` : ' __typename'; + + // Assemble arguments for `gql` to call it directly without using template literals. + const main = ` + fragment ${key} on ${fragments[key].type} { + ${fields} + } + `; + return mergeDocuments([main, ...documents]); +} + +// The fragments and configs are lazily loaded to allow circular dependencies to work. +// TODO: We might want to change this to an explicit add after we have lazy Queries and Mutations. +let initialized = false; + +function init() { + if (initialized) { return; } + initialized = true; + + // Add fragments from framework. + [globalFragments].forEach(map => + Object.keys(map).forEach(key => addFragment(key, map[key])) + ); + + // Add configs from plugins. + getGraphQLExtensions().forEach(ext => add(ext)); +} + +export function resolveFragments(document) { + if (document.loc.source) { + + // resolve fragments from registry + const matchedSubFragments = document.loc.source.body.match(/\.\.\.(.*)/g) || []; + const subFragments = + uniq(matchedSubFragments.map(f => f.replace('...', ''))) + .map(key => getFragmentDocument(key)) + .filter(i => i); + + if (subFragments.length > 0) { + return mergeDocuments([document, ...subFragments]); + } + } else { + console.warn('Can only resolve fragments from documents definied using the gql tag.'); + } + return document; +} diff --git a/client/coral-framework/utils/index.js b/client/coral-framework/utils/index.js index 598fb59ab..70ce89bf7 100644 --- a/client/coral-framework/utils/index.js +++ b/client/coral-framework/utils/index.js @@ -1,3 +1,5 @@ +import {gql} from 'react-apollo'; + export const getTotalActionCount = (type, comment) => { return comment.action_summaries .filter(s => s.__typename === type) @@ -61,3 +63,11 @@ export function separateDataAndRoot( root, }; } + +export function mergeDocuments(documents) { + const main = typeof documents[0] === 'string' ? documents[0] : documents[0].loc.source.body; + const substitutions = documents.slice(1); + const literals = [main, ...substitutions.map(() => '\n')]; + return gql.apply(null, [literals, ...substitutions]); +} + diff --git a/client/coral-plugin-commentbox/CommentBox.js b/client/coral-plugin-commentbox/CommentBox.js index e703a3058..34c08cc57 100644 --- a/client/coral-plugin-commentbox/CommentBox.js +++ b/client/coral-plugin-commentbox/CommentBox.js @@ -43,7 +43,7 @@ class CommentBox extends React.Component { postComment = ({body}) => { const { commentPostedHandler, - postItem, + postComment, setCommentCountCache, commentCountCache, isReply, @@ -64,7 +64,7 @@ class CommentBox extends React.Component { // Execute preSubmit Hooks this.state.hooks.preSubmit.forEach(hook => hook()); - postItem(comment, 'comments') + postComment(comment, 'comments') .then(({data}) => { const postedComment = data.createComment.comment; @@ -181,7 +181,7 @@ CommentBox.propTypes = { charCountEnable: PropTypes.bool.isRequired, maxCharCount: PropTypes.number, commentPostedHandler: PropTypes.func, - postItem: PropTypes.func.isRequired, + postComment: PropTypes.func.isRequired, cancelButtonClicked: PropTypes.func, assetId: PropTypes.string.isRequired, parentId: PropTypes.string, diff --git a/client/coral-plugin-replies/ReplyBox.js b/client/coral-plugin-replies/ReplyBox.js index d6e2b5631..97667f932 100644 --- a/client/coral-plugin-replies/ReplyBox.js +++ b/client/coral-plugin-replies/ReplyBox.js @@ -12,7 +12,7 @@ class ReplyBox extends Component { render() { const { styles, - postItem, + postComment, assetId, authorId, addNotification, @@ -32,7 +32,7 @@ class ReplyBox extends Component { addNotification={addNotification} authorId={authorId} assetId={assetId} - postItem={postItem} + postComment={postComment} isReply={true} /> ; } @@ -46,7 +46,7 @@ ReplyBox.propTypes = { parentId: PropTypes.string, addNotification: PropTypes.func.isRequired, authorId: PropTypes.string.isRequired, - postItem: PropTypes.func.isRequired, + postComment: PropTypes.func.isRequired, assetId: PropTypes.string.isRequired }; diff --git a/client/coral-settings/containers/ProfileContainer.js b/client/coral-settings/containers/ProfileContainer.js index 39e615725..f9c8d892f 100644 --- a/client/coral-settings/containers/ProfileContainer.js +++ b/client/coral-settings/containers/ProfileContainer.js @@ -1,11 +1,10 @@ import {connect} from 'react-redux'; -import {compose} from 'react-apollo'; +import {compose, graphql, gql} from 'react-apollo'; import React, {Component} from 'react'; import I18n from 'coral-framework/modules/i18n/i18n'; import {bindActionCreators} from 'redux'; -import {myCommentHistory, myIgnoredUsers} from 'coral-framework/graphql/queries'; -import {stopIgnoringUser} from 'coral-framework/graphql/mutations'; +import {withStopIgnoringUser} from 'coral-framework/graphql/mutations'; import {link} from 'coral-framework/services/PymConnection'; import NotLoggedIn from '../components/NotLoggedIn'; @@ -88,6 +87,38 @@ class ProfileContainer extends Component { } } +// TODO: These currently relies on refetching (see ignoreUser and stopIgnoringUser mutations). +// +const withMyIgnoredUsersQuery = graphql(gql` + query myIgnoredUsers { + myIgnoredUsers { + id, + username, + } + }`, { + props: ({data}) => { + return ({ + myIgnoredUsersData: data + }); + } + }); + +const withMyCommentHistoryQuery = graphql(gql` + query myCommentHistory { + me { + comments { + id + body + asset { + id + title + url + } + created_at + } + } + }`); + const mapStateToProps = state => ({ user: state.user.toJS(), asset: state.asset.toJS(), @@ -99,7 +130,7 @@ const mapDispatchToProps = dispatch => export default compose( connect(mapStateToProps, mapDispatchToProps), - myCommentHistory, - myIgnoredUsers, - stopIgnoringUser, + withMyCommentHistoryQuery, + withMyIgnoredUsersQuery, + withStopIgnoringUser, )(ProfileContainer); diff --git a/plugins/coral-plugin-respect/client/containers/RespectButton.js b/plugins/coral-plugin-respect/client/containers/RespectButton.js index 38be8a978..fcb7b2a57 100644 --- a/plugins/coral-plugin-respect/client/containers/RespectButton.js +++ b/plugins/coral-plugin-respect/client/containers/RespectButton.js @@ -1,15 +1,15 @@ -import {compose, gql, graphql} from 'react-apollo'; +import {compose, gql} from 'react-apollo'; import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; import get from 'lodash/get'; -import withFragments from 'coral-framework/hocs/withFragments'; +import {withFragments, withMutation} from 'coral-framework/hocs'; import {showSignInDialog} from 'coral-framework/actions/auth'; import RespectButton from '../components/RespectButton'; const isRespectAction = (a) => a.__typename === 'RespectActionSummary'; const COMMENT_FRAGMENT = gql` - fragment RespectButton_updateFragment on Comment { + fragment CoralRespect_UpdateFragment on Comment { action_summaries { ... on RespectActionSummary { count @@ -21,8 +21,8 @@ const COMMENT_FRAGMENT = gql` } `; -const withDeleteAction = graphql(gql` - mutation deleteAction($id: ID!) { +const withDeleteAction = withMutation(gql` + mutation CoralRespect_DeleteAction($id: ID!) { deleteAction(id:$id) { errors { translation_key @@ -66,8 +66,8 @@ const withDeleteAction = graphql(gql` }), }); -const withPostRespect = graphql(gql` - mutation createRespect($respect: CreateRespectInput!) { +const withPostRespect = withMutation(gql` + mutation CoralRespect_CreateRespect($respect: CreateRespectInput!) { createRespect(respect: $respect) { respect { id @@ -137,14 +137,14 @@ const mapDispatchToProps = dispatch => const enhance = compose( withFragments({ root: gql` - fragment RespectButton_root on RootQuery { + fragment CoralRespect_RespectButton_root on RootQuery { me { status } } `, comment: gql` - fragment RespectButton_comment on Comment { + fragment CoralRespect_RespectButton_comment on Comment { action_summaries { ... on RespectActionSummary { count