Implement extendable GraphQL framework

This commit is contained in:
Chi Vinh Le
2017-05-05 22:51:19 +07:00
parent 8b3f69d964
commit 1b7eedfd8d
18 changed files with 463 additions and 127 deletions
@@ -54,7 +54,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,
@@ -107,7 +107,7 @@ class Comment extends React.Component {
currentUser,
asset,
depth,
postItem,
postComment,
addNotification,
showSignInDialog,
postLike,
@@ -268,7 +268,7 @@ class Comment extends React.Component {
parentId={parentId || comment.id}
addNotification={addNotification}
authorId={currentUser.id}
postItem={postItem}
postComment={postComment}
assetId={asset.id} />
: null
}
@@ -285,7 +285,7 @@ class Comment extends React.Component {
activeReplyBox={activeReplyBox}
addNotification={addNotification}
parentId={comment.id}
postItem={postItem}
postComment={postComment}
depth={depth + 1}
asset={asset}
highlighted={highlighted}
@@ -26,7 +26,7 @@ class Stream extends React.Component {
render() {
const {
root: {asset, asset: {comments}, comment, myIgnoredUsers},
postItem,
postComment,
addNotification,
postFlag,
postLike,
@@ -85,7 +85,7 @@ class Stream extends React.Component {
{user
? <CommentBox
addNotification={this.props.addNotification}
postItem={this.props.postItem}
postComment={this.props.postComment}
appendItemArray={this.props.appendItemArray}
updateItem={this.props.updateItem}
setCommentCountCache={this.props.setCommentCountCache}
@@ -123,7 +123,7 @@ class Stream extends React.Component {
activeReplyBox={this.props.activeReplyBox}
addNotification={addNotification}
depth={0}
postItem={this.props.postItem}
postComment={this.props.postComment}
asset={asset}
currentUser={user}
highlighted={comment.id}
@@ -162,7 +162,7 @@ class Stream extends React.Component {
activeReplyBox={this.props.activeReplyBox}
addNotification={addNotification}
depth={0}
postItem={postItem}
postComment={postComment}
asset={asset}
currentUser={user}
postLike={postLike}
@@ -199,7 +199,7 @@ class Stream extends React.Component {
Stream.propTypes = {
addNotification: PropTypes.func.isRequired,
postItem: PropTypes.func.isRequired,
postComment: PropTypes.func.isRequired,
// dispatch action to add a tag to a comment
addCommentTag: PropTypes.func,
@@ -1,6 +1,6 @@
import {gql} from 'react-apollo';
import Comment from '../components/Comment';
import withFragments from 'coral-framework/hocs/withFragments';
import {withFragments} from 'coral-framework/hocs';
import {getSlotsFragments} from 'coral-framework/helpers/plugins';
const pluginFragments = getSlotsFragments([
@@ -1,5 +1,5 @@
import React from 'react';
import {compose, gql, graphql} from 'react-apollo';
import {compose, gql} from 'react-apollo';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import isEqual from 'lodash/isEqual';
@@ -8,7 +8,8 @@ import renderComponent from 'recompose/renderComponent';
import {Spinner} from 'coral-ui';
import {authActions, assetActions, pym} from 'coral-framework';
import {getDefinitionName, separateDataAndRoot} from 'coral-framework/utils';
import {getDefinitionName} from 'coral-framework/utils';
import {withQuery} from 'coral-framework/hocs';
import Embed from '../components/Embed';
import {setCommentCountCache, viewAllComments} from '../actions/stream';
import {setActiveTab} from '../actions/embed';
@@ -75,7 +76,7 @@ const EMBED_QUERY = gql`
${Stream.fragments.root}
`;
export const withQuery = graphql(EMBED_QUERY, {
export const withEmbedQuery = withQuery(EMBED_QUERY, {
options: ({auth, commentId, assetId, assetUrl}) => ({
variables: {
assetId,
@@ -85,7 +86,6 @@ export const withQuery = graphql(EMBED_QUERY, {
excludeIgnored: Boolean(auth && auth.user && auth.user.id),
},
}),
props: ({data}) => separateDataAndRoot(data),
});
const mapStateToProps = state => ({
@@ -113,6 +113,6 @@ export default compose(
props => !props.auth.checkedInitialLogin,
renderComponent(Spinner),
),
withQuery,
withEmbedQuery,
)(EmbedContainer);
@@ -6,13 +6,13 @@ 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, postLike, postDontAgree, deleteAction, addCommentTag, removeCommentTag, ignoreUser} from 'coral-framework/graphql/mutations';
import {withPostComment, postFlag, postLike, postDontAgree, deleteAction, addCommentTag, removeCommentTag, ignoreUser} 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;
@@ -237,7 +237,7 @@ const mapDispatchToProps = dispatch =>
export default compose(
withFragments(fragments),
connect(mapStateToProps, mapDispatchToProps),
postComment,
withPostComment,
postFlag,
postLike,
postDontAgree,
@@ -0,0 +1,105 @@
import {gql} from 'react-apollo';
import {registerConfig} from 'coral-framework/services/registry';
const config = {
fragments: {
CreateCommentResponse: gql`
fragment Coral_CreateCommentResponse on CreateCommentResponse {
comment {
...Coral_CreateCommentResponse_Comment
replies {
...Coral_CreateCommentResponse_Comment
}
}
errors {
translation_key
}
}
fragment Coral_CreateCommentResponse_Comment on Comment {
id
body
created_at
status
replyCount
tags {
name
}
user {
id
name: username
}
action_summaries {
count
current_user {
id
created_at
}
}
}`,
},
mutations: {
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: (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;
}
}
})
},
};
registerConfig(config);
+1
View File
@@ -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 reducers from './reducers';
import localStore, {injectReducers} from 'coral-framework/services/store';
@@ -0,0 +1,2 @@
// fragments defined here are automatically registered.
export default {};
@@ -1,5 +1,4 @@
import {graphql} from 'react-apollo';
import POST_COMMENT from './postComment.graphql';
import {graphql, gql} from 'react-apollo';
import POST_FLAG from './postFlag.graphql';
import POST_LIKE from './postLike.graphql';
import POST_DONT_AGREE from './postDontAgree.graphql';
@@ -8,80 +7,28 @@ 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 withMutation from '../../hocs/withMutation';
import {getFragmentDocument} from '../../services/registry';
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 withPostComment = withMutation(
gql`
mutation PostComment($comment: CreateCommentInput!) {
createComment(comment: $comment) {
...CreateCommentResponse
}
}
}),
});
${getFragmentDocument('CreateCommentResponse')}
`, {
props: ({mutate}) => ({
postComment: comment => {
return mutate({
variables: {
comment
},
});
}
}),
});
export const postLike = graphql(POST_LIKE, {
props: ({mutate}) => ({
@@ -1,16 +0,0 @@
#import "../fragments/commentView.graphql"
mutation CreateComment ($comment: CreateCommentInput!) {
createComment(comment: $comment) {
comment {
...commentView
replyCount
replies {
...commentView
}
}
errors {
translation_key
}
}
}
+19 -4
View File
@@ -3,6 +3,7 @@ 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';
@@ -25,19 +26,27 @@ 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 => {
res[key].spreads = `...${res[key].spreads.join('\n...')}\n`;
const literals = ['', ...res[key].definitions.map(() => '\n')];
res[key].definitions = gql.apply(null, [literals, ...res[key].definitions]);
});
return res;
}
/**
@@ -73,3 +82,9 @@ export function getSlotsFragments(slots) {
};
}
export function getGraphQLConfigs() {
return plugins
.map(o => o.module.mutations && pick(o.module, ['mutations', 'queries', 'fragments']))
.filter(o => o);
}
+3
View File
@@ -0,0 +1,3 @@
export {default as withFragments} from './withFragments';
export {default as withMutation} from './withMutation';
export {default as withQuery} from './withQuery';
@@ -0,0 +1,74 @@
import {graphql} from 'react-apollo';
import merge from 'lodash/merge';
import uniq from 'lodash/uniq';
import {getMutationOptions} from 'coral-framework/services/registry';
import {store} from 'coral-framework/services/store';
import {getDefinitionName} from '../utils';
export default (definitions, config) => WrappedComponent => {
config = {
...config,
options: config.options || {},
props: config.props || (data => ({mutate: data.mutate()})),
};
const wrappedProps = (data) => {
const name = getDefinitionName(definitions);
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 = uniq(
base.refetchQueries || config.options.refetchQueries,
...configs.map(cfg => cfg.refetchQueries),
);
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,
};
return data.mutate(wrappedConfig);
};
return config.props({...data, mutate});
};
const wrapped = graphql(definitions, {...config, props: wrappedProps})(WrappedComponent);
return wrapped;
};
+34
View File
@@ -0,0 +1,34 @@
import {graphql} from 'react-apollo';
import {getQueryOptions} from 'coral-framework/services/registry';
import {getDefinitionName, separateDataAndRoot} from '../utils';
export default (definitions, 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(definitions);
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,
};
};
const wrapped = graphql(definitions, {...config, options: wrappedOptions})(WrappedComponent);
return wrapped;
};
+171
View File
@@ -0,0 +1,171 @@
import {gql} from 'react-apollo';
import {getDefinitionName} from 'coral-framework/utils';
import {getGraphQLConfigs} from 'coral-framework/helpers/plugins';
import globalFragments from 'coral-framework/graphql/fragments';
const fragments = {};
const mutationOptions = {};
const queryOptions = {};
const getTypeName = (ast) => ast.definitions[0].typeCondition.name.value;
/**
* Register fragment
*
* Example:
* registerFragment('MyFragment', gql`
* fragment Plugin_MyFragment on Comment {
* body
* }
* `);
*/
export function registerFragment(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);
}
}
/**
* Register mutation options.
*
* Example:
* registerMutationOptions('PostComment', ({variables, state}) => ({
* optimisticResponse: {
* CreateComment: {
* extra: '',
* },
* },
* refetchQueries: [],
* updateQueries: {
* EmbedQuery: (previous, data) => {
* return previous;
* },
* },
* update: (proxy, result) => {
* },
* })
*/
export function registerMutationOptions(key, config) {
if (!(key in mutationOptions)) {
mutationOptions[key] = [config];
} else {
mutationOptions[key].push(config);
}
}
/**
* Register query options.
*
* Example:
* registerQueryOptions('EmbedQuery', {
* reducer: (previousResult, action, variables) => previousResult,
* });
*/
export function registerQueryOptions(key, config) {
if (!(key in queryOptions)) {
queryOptions[key] = [config];
} else {
queryOptions[key].push(config);
}
}
/**
* Register all fragments, mutation options, and query options defined in the object.
*
* Example:
* registerConfig({
* fragments: {
* CreateCommentResponse: gql`
* fragment CoralRandomEmoji_CreateCommentResponse on CreateCommentResponse {
* [...]
* }`,
* },
* mutations: {
* PostComment: ({variables, state}) => ({
* optimisticResponse: {
* [...]
* },
* refetchQueries: [],
* updateQueries: {
* EmbedQuery: (previous, data) => {
* return previous;
* },
* },
* update: (proxy, result) => {
* },
* })
* },
* queries: {
* EmbedQuery: {
* reducer: (previousResult, action, variables) => {
* return previousResult;
* },
* },
* },
* });
*/
export function registerConfig(cfg) {
Object.keys(cfg.fragments || []).forEach(key => registerFragment(key, cfg.fragments[key]));
Object.keys(cfg.mutations || []).forEach(key => registerMutationOptions(key, cfg.mutations[key]));
Object.keys(cfg.queries || []).forEach(key => registerQueryOptions(key, cfg.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 registered under this key.
*/
export function getFragmentDocument(key) {
init();
let documents = fragments[key] ? fragments[key].documents : [];
let fields = fragments[key] ? `...${fragments[key].names.join('\n...')}\n` : ' __typename';
const main = `
fragment ${key} on ${fragments[key].type} {
${fields}
}
`;
const literals = [main, ...documents.map(() => '\n')];
return gql.apply(null, [literals, ...documents]);
}
// The fragments and configs are lazily loaded to allow circular dependencies to work.
// TODO: We might want to change this to an explicit register after we have lazy Queries and Mutations.
let initialized = false;
function init() {
if (initialized) { return; }
initialized = true;
// Register fragments from framework.
[globalFragments].forEach(map =>
Object.keys(map).forEach(key => registerFragment(key, map[key]))
);
// Register configs from plugins.
getGraphQLConfigs().forEach(cfg => registerConfig(cfg));
}
+3 -3
View File
@@ -25,7 +25,7 @@ class CommentBox extends React.Component {
postComment = () => {
const {
commentPostedHandler,
postItem,
postComment,
setCommentCountCache,
commentCountCache,
isReply,
@@ -46,7 +46,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;
@@ -192,7 +192,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,
+3 -3
View File
@@ -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} />
</div>;
}
@@ -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
};
@@ -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