├── LICENSE
├── README.md
├── client
├── .gitignore
├── package.json
├── public
│ ├── favicon.ico
│ ├── index.html
│ └── manifest.json
├── src
│ ├── App.js
│ ├── App.test.js
│ ├── Header.js
│ ├── HomePage.js
│ ├── Layout.js
│ ├── NewTweetPage.js
│ ├── Notification.js
│ ├── Tweet.js
│ ├── TweetPage.js
│ ├── currentUserQuery.js
│ ├── fragments.js
│ ├── index.css
│ ├── index.js
│ ├── logo.svg
│ ├── notifications.js
│ ├── registerServiceWorker.js
│ └── withCurrentUser.js
└── yarn.lock
├── makefile
├── schema.graphql
└── server
├── .babelrc
├── .gitignore
├── package-lock.json
├── package.json
└── src
├── base.js
├── context.js
├── data.js
├── index.js
├── resolvers.js
├── scalar
└── Date.js
├── schema.js
├── schema.spec.js
├── stat
├── resolvers.js
└── schema.js
├── tweet
├── resolvers.js
├── resolvers.spec.js
└── schema.js
└── user
├── resolvers.js
├── resolvers.spec.js
└── schema.js
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 marmelab
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Archived Repository
5 | The code of this repository was written during a Hack Day by a Marmelab developer . It's part of the distributed R&D effort at Marmelab, where each developer spends 2 days a month for learning and experimentation.
6 | This code is not intended to be used in production, and is not maintained.
7 |
8 |
9 |
10 |
11 | # GraphQL-example
12 |
13 | An example code structure for a GraphQL-powered mobile app in full-stack JavaScript. Contains client and server code. Architecture and code explained in the [Dive into Graphql series](https://marmelab.com/blog/2017/09/03/dive-into-graphql.html).
14 |
15 | ## Installation
16 |
17 | ```sh
18 | npm install
19 | ```
20 |
21 | ## Run the server
22 |
23 | ```sh
24 | npm start
25 | ```
26 |
27 | ## Run tests
28 |
29 | ```sh
30 | npm test
31 | ```
32 |
--------------------------------------------------------------------------------
/client/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env.local
15 | .env.development.local
16 | .env.test.local
17 | .env.production.local
18 |
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "apollo-link": "^0.5.0",
7 | "date-fns": "^1.28.5",
8 | "material-ui": "^1.0.0-beta.8",
9 | "material-ui-icons": "^1.0.0-alpha.19",
10 | "react": "^15.6.1",
11 | "react-apollo": "^1.4.14",
12 | "react-dom": "^15.6.1",
13 | "react-redux": "^5.0.6",
14 | "react-router-dom": "^4.1.2",
15 | "react-scripts": "1.0.11",
16 | "redux": "^3.7.2"
17 | },
18 | "scripts": {
19 | "start": "react-scripts start",
20 | "build": "react-scripts build",
21 | "test": "react-scripts test --env=jsdom",
22 | "eject": "react-scripts eject"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/client/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marmelab/GraphQL-example/6f6b561e9da0d185e147e9bfdd92920748f486f5/client/public/favicon.ico
--------------------------------------------------------------------------------
/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
13 |
22 | React App
23 |
24 |
25 |
26 | You need to enable JavaScript to run this app.
27 |
28 |
29 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/client/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/client/src/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
3 | import { gql, graphql } from 'react-apollo';
4 | import { userFragment } from './fragments';
5 |
6 | import HomePage from './HomePage';
7 | import Header from './Header';
8 | import TweetPage from './TweetPage';
9 | import NewTweetPage from './NewTweetPage';
10 |
11 | const App = ({ data: { currentUser } }) =>
12 |
13 |
14 |
15 |
16 |
17 |
18 | {/* This route must come before the one with a parameter to be matched */}
19 | } />
20 |
21 |
22 |
23 | ;
24 |
25 | export const currentUserQuery = gql`
26 | query headerQuery {
27 | currentUser: User {
28 | ...UserFields
29 | }
30 | }
31 |
32 | ${userFragment}
33 | `;
34 |
35 | export default graphql(currentUserQuery)(App);
36 |
--------------------------------------------------------------------------------
/client/src/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 |
5 | it('renders without crashing', () => {
6 | const div = document.createElement('div');
7 | ReactDOM.render( , div);
8 | });
9 |
--------------------------------------------------------------------------------
/client/src/Header.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import AppBar from 'material-ui/AppBar';
4 | import Avatar from 'material-ui/Avatar';
5 | import IconButton from 'material-ui/IconButton';
6 | import ModeEditIcon from 'material-ui-icons/ModeEdit';
7 | import Toolbar from 'material-ui/Toolbar';
8 | import Typography from 'material-ui/Typography';
9 | import { withStyles, createMuiTheme } from 'material-ui/styles';
10 | import { Link } from 'react-router-dom';
11 |
12 | const styleSheet = createMuiTheme(theme => ({
13 | title: {
14 | margin: '0 auto',
15 | }
16 | }));
17 |
18 | const Header = ({ classes, currentUser }) => (
19 |
20 |
21 | {currentUser &&
22 |
23 | }
24 |
25 | Home
26 |
27 |
28 |
29 |
30 |
31 |
32 | );
33 |
34 | Header.propTypes = {
35 | classes: PropTypes.object.isRequired,
36 | currentUser: PropTypes.shape({
37 | id: PropTypes.string.isRequired,
38 | username: PropTypes.string.isRequired,
39 | full_name: PropTypes.string.isRequired,
40 | avatar_url: PropTypes.string.isRequired,
41 | }),
42 | };
43 |
44 | export default withStyles(styleSheet)(Header);
45 |
--------------------------------------------------------------------------------
/client/src/HomePage.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { connect } from 'react-redux';
4 | import { compose, gql, graphql } from 'react-apollo';
5 | import { LinearProgress } from 'material-ui/Progress';
6 | import Button from 'material-ui/Button';
7 | import { withStyles, createMuiTheme } from 'material-ui/styles';
8 |
9 | import Notification from './Notification';
10 | import Tweet from './Tweet';
11 | import { tweetFragment, userFragment } from './fragments';
12 | import { removeNotification as removeNotificationAction } from './notifications';
13 |
14 | const styleSheet = createMuiTheme(theme => ({
15 | button: {
16 | textAlign: 'center',
17 | width: '100%',
18 | }
19 | }));
20 |
21 | class HomePage extends Component {
22 | state = { skip: 5 };
23 |
24 | handleClick = () => {
25 | this.props.loadMore(this.state.skip).then(() => {
26 | this.setState(({ skip }) => ({ skip: skip + 5 }));
27 | });
28 | }
29 |
30 | handleNotificationClose = notificationId => {
31 | this.props.removeNotification(notificationId);
32 | }
33 |
34 | render() {
35 | const { classes, loading, notifications, tweets } = this.props;
36 | return (
37 |
38 | {loading && }
39 | {!loading && tweets.map(tweet =>
40 |
41 | )}
42 |
43 |
44 | Load more
45 |
46 |
47 | {notifications.map(notification =>
48 |
53 | )}
54 |
55 | );
56 | }
57 | }
58 |
59 | HomePage.propTypes = {
60 | data: PropTypes.shape({
61 | loading: PropTypes.bool.isRequired,
62 | tweets: PropTypes.arrayOf(
63 | PropTypes.shape({
64 | id: PropTypes.string.isRequired,
65 | body: PropTypes.string.isRequired,
66 | date: PropTypes.string.isRequired,
67 | Author: PropTypes.shape({
68 | id: PropTypes.string.isRequired,
69 | username: PropTypes.string.isRequired,
70 | full_name: PropTypes.string.isRequired,
71 | avatar_url: PropTypes.string.isRequired,
72 | }).isRequired,
73 | Stats: PropTypes.shape({
74 | views: PropTypes.number.isRequired,
75 | likes: PropTypes.number.isRequired,
76 | retweets: PropTypes.number.isRequired,
77 | responses: PropTypes.number.isRequired,
78 | }).isRequired,
79 | }).isRequired,
80 | ),
81 | }),
82 | };
83 |
84 | HomePage.defaultProps = {
85 | tweets: [],
86 | };
87 |
88 | export const homePageQuery = gql`
89 | query homePageQuery($limit: Int!, $skip: Int!) {
90 | tweets: Tweets(limit:$limit, skip:$skip, sort_field:"date", sort_order:"desc") {
91 | ...TweetFields
92 | }
93 | }
94 |
95 | ${userFragment}
96 | ${tweetFragment}
97 | `;
98 |
99 | export const homePageQueryVariables = { limit: 5, skip: 0 };
100 |
101 | const mapStateToProps = state => ({ notifications: state.notifications });
102 |
103 | export default compose(
104 | connect(mapStateToProps, { removeNotification: removeNotificationAction }),
105 | graphql(homePageQuery, {
106 | options: {
107 | variables: homePageQueryVariables,
108 | },
109 | props: ({ data: { loading, tweets, fetchMore } }) => ({
110 | loading,
111 | tweets,
112 | loadMore: skip =>
113 | fetchMore({
114 | variables: { limit: 5, skip },
115 | updateQuery: (previousResult, { fetchMoreResult }) => ({
116 | ...previousResult,
117 | tweets: [
118 | ...previousResult.tweets,
119 | ...fetchMoreResult.tweets,
120 | ],
121 | }),
122 | })
123 | }),
124 | }),
125 | withStyles(styleSheet),
126 | )(HomePage);
127 |
--------------------------------------------------------------------------------
/client/src/Layout.js:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marmelab/GraphQL-example/6f6b561e9da0d185e147e9bfdd92920748f486f5/client/src/Layout.js
--------------------------------------------------------------------------------
/client/src/NewTweetPage.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import { withStyles, createMuiTheme } from 'material-ui/styles';
4 | import Button from 'material-ui/Button';
5 | import { LinearProgress } from 'material-ui/Progress';
6 | import { compose, gql, graphql } from 'react-apollo';
7 | import { withRouter } from 'react-router';
8 | import { userFragment, tweetFragment } from './fragments';
9 | import { homePageQuery, homePageQueryVariables } from './HomePage';
10 | import { notify as notifyAction } from './notifications';
11 |
12 | const styleSheet = createMuiTheme(theme => ({
13 | container: {
14 | margin: '1rem',
15 | },
16 | button: {
17 | marginTop: '1rem',
18 | marginBottom: '1rem',
19 | },
20 | textarea: {
21 | width: '100%',
22 | resize: 'none',
23 | boxSizing: 'border-box',
24 | }
25 | }));
26 |
27 | class NewTweetPage extends Component {
28 | state = { body: '', saving: false };
29 |
30 | handleChange = event => {
31 | this.setState({ body: event.target.value });
32 | }
33 |
34 | handleSubmit = () => {
35 | this.setState({ saving: true }, () => {
36 | this.props.submit(this.state.body);
37 | // The submit promise will only resolve once the server has anwsered.
38 | // As we have an optimistic response setup, we don't wait for the promise
39 | // to be resolved and redirect the user to the list immediatly
40 | this.props.redirectToHome();
41 | });
42 | }
43 |
44 | render() {
45 | const { classes, saving } = this.props;
46 | const { body } = this.state;
47 |
48 | return (
49 |
50 | Enter your message
51 |
58 |
59 |
66 | Send
67 | {saving && }
68 |
69 |
70 | );
71 | }
72 | }
73 |
74 | const mutation = gql`
75 | mutation createTweet($body: String!) {
76 | createTweet(body: $body) {
77 | ...TweetFields
78 | }
79 | }
80 |
81 | ${userFragment}
82 | ${tweetFragment}
83 | `;
84 |
85 | export default compose(
86 | withRouter,
87 | connect(null, { notify: notifyAction }),
88 | graphql(mutation, {
89 | name: 'createTweet',
90 | props: ({ createTweet, ownProps: { currentUser, history, notify } }) => ({
91 | redirectToHome: () => history.push('/'),
92 | submit: body => {
93 | createTweet({
94 | variables: { body },
95 | optimisticResponse: {
96 | __typename: 'Mutation',
97 | createTweet: {
98 | __typename: 'Tweet',
99 | id: 'newTweet',
100 | date: new Date().toISOString(),
101 | body,
102 | Author: {
103 | __typename: 'User',
104 | id: currentUser.id,
105 | avatar_url: currentUser.avatar_url,
106 | username: currentUser.username,
107 | full_name: currentUser.full_name,
108 | },
109 | Stats: {
110 | __typename: 'Stats',
111 | tweet_id: 'newTweet',
112 | views: 0,
113 | likes: 0,
114 | retweets: 0,
115 | responses: 0,
116 | },
117 | },
118 | },
119 | update: (store, { data: { createTweet } }) => {
120 | // Read the data from our cache for this query.
121 | const data = store.readQuery({ query: homePageQuery, variables: homePageQueryVariables });
122 | // Add our new tweet from the mutation to the beginning.
123 | data.tweets.unshift(createTweet);
124 | // Write our data back to the cache.
125 | store.writeQuery({ query: homePageQuery, variables: homePageQueryVariables, data });
126 | }
127 | }).catch(error => {
128 | console.error(error);
129 | notify(`Sorry, we weren't able to send your tweet. Please check your network connection and retry.`);
130 | });
131 | }
132 | }),
133 | }),
134 | withStyles(styleSheet),
135 | )(NewTweetPage);
136 |
--------------------------------------------------------------------------------
/client/src/Notification.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import Snackbar from 'material-ui/Snackbar';
4 |
5 | class Notification extends Component {
6 | handleClose = () => {
7 | this.props.onClose(this.props.notification.id);
8 | }
9 |
10 | render() {
11 | const { notification: { message } } = this.props;
12 |
13 | return (
14 | {message}}
18 | onClick={this.handleClose}
19 | onRequestClose={this.handleClose}
20 | open
21 | />
22 | );
23 | }
24 | }
25 |
26 | Notification.propTypes = {
27 | notification: PropTypes.shape({
28 | id: PropTypes.string.isRequired,
29 | message: PropTypes.string.isRequired,
30 | }).isRequired,
31 | onClose: PropTypes.func.isRequired,
32 | }
33 |
34 | export default Notification;
35 |
--------------------------------------------------------------------------------
/client/src/Tweet.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import Card, { CardContent } from 'material-ui/Card';
4 | import Avatar from 'material-ui/Avatar';
5 | import Typography from 'material-ui/Typography';
6 | import CachedIcon from 'material-ui-icons/Cached';
7 | import FavoriteBorderIcon from 'material-ui-icons/FavoriteBorder';
8 | import ChatBubbleOutlineIcon from 'material-ui-icons/ChatBubbleOutline';
9 | import { withStyles, createMuiTheme } from 'material-ui/styles';
10 | import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
11 | import { Link } from 'react-router-dom';
12 |
13 | const styleSheet = createMuiTheme(theme => ({
14 | link: {
15 | textDecoration: 'none',
16 | },
17 | container: {
18 | display: 'flex',
19 | flexDirection: 'row',
20 | },
21 | avatar: {
22 | marginRight: '0.5rem',
23 | },
24 | fullName: {
25 | color: theme.palette.text.secondary,
26 | fontWeight: 'bold',
27 | marginRight: '0.5rem',
28 | },
29 | userName: {
30 | color: theme.palette.text.secondary,
31 | marginRight: '0.5rem',
32 | },
33 | separator: {
34 | color: theme.palette.text.secondary,
35 | marginRight: '0.5rem',
36 | },
37 | date: {
38 | color: theme.palette.text.secondary,
39 | },
40 | buttons: {
41 | display: 'flex',
42 | justifyContent: 'space-between',
43 | marginTop: '1rem',
44 | },
45 | icon: {
46 | marginRight: '0.5rem',
47 | },
48 | stats: {
49 | color: theme.palette.text.secondary,
50 | display: 'flex',
51 | alignItems: 'center',
52 | justifyContent: 'space-between',
53 | }
54 | }));
55 |
56 | const Tweet = ({ classes, tweet, showDetailsLink }) => (
57 |
58 |
59 |
60 |
61 |
62 |
63 | {tweet.Author.full_name}
64 |
65 |
66 | {tweet.Author.username}
67 |
68 |
-
69 |
70 | {distanceInWordsToNow(tweet.date)}
71 |
72 |
73 | {tweet.body}
74 |
75 |
76 |
77 |
78 | {tweet.Stats.responses}
79 |
80 |
81 |
82 | {tweet.Stats.retweets}
83 |
84 |
85 |
86 | {tweet.Stats.likes}
87 |
88 |
89 |
90 |
91 |
92 |
93 | );
94 |
95 | Tweet.propTypes = {
96 | classes: PropTypes.object.isRequired,
97 | showDetailsLink: PropTypes.bool,
98 | tweet: PropTypes.shape({
99 | id: PropTypes.string.isRequired,
100 | body: PropTypes.string.isRequired,
101 | date: PropTypes.string.isRequired,
102 | Author: PropTypes.shape({
103 | id: PropTypes.string.isRequired,
104 | username: PropTypes.string.isRequired,
105 | full_name: PropTypes.string.isRequired,
106 | avatar_url: PropTypes.string.isRequired,
107 | }).isRequired,
108 | Stats: PropTypes.shape({
109 | views: PropTypes.number.isRequired,
110 | likes: PropTypes.number.isRequired,
111 | retweets: PropTypes.number.isRequired,
112 | responses: PropTypes.number.isRequired,
113 | }).isRequired,
114 | }).isRequired,
115 | };
116 |
117 | export default withStyles(styleSheet)(Tweet);
118 |
--------------------------------------------------------------------------------
/client/src/TweetPage.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { gql, graphql } from 'react-apollo';
4 | import { LinearProgress } from 'material-ui/Progress';
5 |
6 | import Tweet from './Tweet';
7 | import { userFragment, tweetFragment } from './fragments';
8 |
9 | const TweetPage = ({ data: { loading, tweet } }) => (
10 |
11 | {loading && }
12 | {!loading && }
13 |
14 | );
15 |
16 | TweetPage.propTypes = {
17 | data: PropTypes.shape({
18 | loading: PropTypes.bool.isRequired,
19 | tweet: PropTypes.shape({
20 | id: PropTypes.string.isRequired,
21 | body: PropTypes.string.isRequired,
22 | date: PropTypes.string.isRequired,
23 | Author: PropTypes.shape({
24 | id: PropTypes.string.isRequired,
25 | username: PropTypes.string.isRequired,
26 | full_name: PropTypes.string.isRequired,
27 | avatar_url: PropTypes.string.isRequired,
28 | }).isRequired,
29 | Stats: PropTypes.shape({
30 | views: PropTypes.number.isRequired,
31 | likes: PropTypes.number.isRequired,
32 | retweets: PropTypes.number.isRequired,
33 | responses: PropTypes.number.isRequired,
34 | }).isRequired,
35 | }),
36 | }),
37 | };
38 |
39 | export const tweetPageQuery = gql`
40 | query tweetPageQuery($id: ID!) {
41 | tweet: Tweet(id: $id) {
42 | ...TweetFields
43 | }
44 | }
45 |
46 | ${userFragment}
47 | ${tweetFragment}
48 | `;
49 |
50 | export default graphql(tweetPageQuery, {
51 | options: ({ match }) => ({ variables: { id: match.params.id } })
52 | })(TweetPage);
53 |
--------------------------------------------------------------------------------
/client/src/currentUserQuery.js:
--------------------------------------------------------------------------------
1 | const query = `
2 | currentUser: User {
3 | ...UserFields
4 | }
5 | `;
6 |
7 | export default query;
8 |
--------------------------------------------------------------------------------
/client/src/fragments.js:
--------------------------------------------------------------------------------
1 | import { gql } from 'react-apollo';
2 |
3 | export const userFragment = gql`
4 | fragment UserFields on User {
5 | id
6 | username
7 | full_name
8 | avatar_url
9 | }
10 | `;
11 |
12 | export const tweetFragment = gql`
13 | fragment TweetFields on Tweet {
14 | id
15 | body
16 | date
17 | Author {
18 | ...UserFields
19 | }
20 | Stats {
21 | tweet_id
22 | views
23 | likes
24 | retweets
25 | responses
26 | }
27 | }
28 | `;
29 |
--------------------------------------------------------------------------------
/client/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | font-family: sans-serif;
5 | }
6 |
--------------------------------------------------------------------------------
/client/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { createStore, combineReducers, applyMiddleware, compose } from 'redux';
4 | import { ApolloClient, ApolloProvider, createNetworkInterface, toIdValue } from 'react-apollo';
5 |
6 | import './index.css';
7 | import App from './App';
8 | import registerServiceWorker from './registerServiceWorker';
9 | import notificationsReducer from './notifications';
10 |
11 |
12 | const networkInterface = createNetworkInterface({
13 | uri: 'http://localhost:4000/graphql',
14 | });
15 |
16 | const dataIdFromObject = object => `${object.__typename}__${object.id || object.tweet_id}`;
17 |
18 | const client = new ApolloClient({
19 | networkInterface,
20 | dataIdFromObject,
21 | customResolvers: {
22 | Query: {
23 | Tweet: (_, { id }) => toIdValue(dataIdFromObject({ __typename: 'Tweet', id })),
24 | },
25 | },
26 | });
27 |
28 | const store = createStore(
29 | combineReducers({
30 | notifications: notificationsReducer,
31 | apollo: client.reducer(),
32 | }),
33 | {}, // initial state
34 | compose(
35 | applyMiddleware(client.middleware()),
36 | // If you are using the devToolsExtension, you can add it here also
37 | (typeof window.__REDUX_DEVTOOLS_EXTENSION__ !== 'undefined') ? window.__REDUX_DEVTOOLS_EXTENSION__() : f => f,
38 | )
39 | );
40 |
41 | ReactDOM.render(
42 |
43 |
44 | ,
45 | document.getElementById('root'),
46 | );
47 |
48 |
49 | registerServiceWorker();
50 |
--------------------------------------------------------------------------------
/client/src/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/client/src/notifications.js:
--------------------------------------------------------------------------------
1 | import uuid from 'uuid';
2 | export const NOTIFY = 'NOTIFY';
3 | export const REMOVE_NOTIFICATION = 'REMOVE_NOTIFICATION';
4 |
5 | export const notify = message => ({
6 | type: NOTIFY,
7 | payload: message,
8 | });
9 |
10 | export const removeNotification = id => ({
11 | type: REMOVE_NOTIFICATION,
12 | payload: id,
13 | });
14 |
15 | const initialState = [];
16 |
17 | export default (previousState = initialState, action) => {
18 | if (action.type === NOTIFY) {
19 | return [
20 | ...previousState,
21 | { id: uuid.v1(), message: action.payload },
22 | ];
23 | }
24 |
25 | if (action.type === REMOVE_NOTIFICATION) {
26 | return [
27 | ...previousState.slice(0, previousState.findIndex(n => n.id === action.payload)),
28 | ...previousState.slice(previousState.findIndex(n => n.id === action.payload) + 1),
29 | ]
30 | }
31 |
32 | return previousState;
33 | }
34 |
--------------------------------------------------------------------------------
/client/src/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | // In production, we register a service worker to serve assets from local cache.
2 |
3 | // This lets the app load faster on subsequent visits in production, and gives
4 | // it offline capabilities. However, it also means that developers (and users)
5 | // will only see deployed updates on the "N+1" visit to a page, since previously
6 | // cached resources are updated in the background.
7 |
8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
9 | // This link also includes instructions on opting out of this behavior.
10 |
11 | const isLocalhost = Boolean(
12 | window.location.hostname === 'localhost' ||
13 | // [::1] is the IPv6 localhost address.
14 | window.location.hostname === '[::1]' ||
15 | // 127.0.0.1/8 is considered localhost for IPv4.
16 | window.location.hostname.match(
17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
18 | )
19 | );
20 |
21 | export default function register() {
22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
23 | // The URL constructor is available in all browsers that support SW.
24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
25 | if (publicUrl.origin !== window.location.origin) {
26 | // Our service worker won't work if PUBLIC_URL is on a different origin
27 | // from what our page is served on. This might happen if a CDN is used to
28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
29 | return;
30 | }
31 |
32 | window.addEventListener('load', () => {
33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
34 |
35 | if (!isLocalhost) {
36 | // Is not local host. Just register service worker
37 | registerValidSW(swUrl);
38 | } else {
39 | // This is running on localhost. Lets check if a service worker still exists or not.
40 | checkValidServiceWorker(swUrl);
41 | }
42 | });
43 | }
44 | }
45 |
46 | function registerValidSW(swUrl) {
47 | navigator.serviceWorker
48 | .register(swUrl)
49 | .then(registration => {
50 | registration.onupdatefound = () => {
51 | const installingWorker = registration.installing;
52 | installingWorker.onstatechange = () => {
53 | if (installingWorker.state === 'installed') {
54 | if (navigator.serviceWorker.controller) {
55 | // At this point, the old content will have been purged and
56 | // the fresh content will have been added to the cache.
57 | // It's the perfect time to display a "New content is
58 | // available; please refresh." message in your web app.
59 | console.log('New content is available; please refresh.');
60 | } else {
61 | // At this point, everything has been precached.
62 | // It's the perfect time to display a
63 | // "Content is cached for offline use." message.
64 | console.log('Content is cached for offline use.');
65 | }
66 | }
67 | };
68 | };
69 | })
70 | .catch(error => {
71 | console.error('Error during service worker registration:', error);
72 | });
73 | }
74 |
75 | function checkValidServiceWorker(swUrl) {
76 | // Check if the service worker can be found. If it can't reload the page.
77 | fetch(swUrl)
78 | .then(response => {
79 | // Ensure service worker exists, and that we really are getting a JS file.
80 | if (
81 | response.status === 404 ||
82 | response.headers.get('content-type').indexOf('javascript') === -1
83 | ) {
84 | // No service worker found. Probably a different app. Reload the page.
85 | navigator.serviceWorker.ready.then(registration => {
86 | registration.unregister().then(() => {
87 | window.location.reload();
88 | });
89 | });
90 | } else {
91 | // Service worker found. Proceed as normal.
92 | registerValidSW(swUrl);
93 | }
94 | })
95 | .catch(() => {
96 | console.log(
97 | 'No internet connection found. App is running in offline mode.'
98 | );
99 | });
100 | }
101 |
102 | export function unregister() {
103 | if ('serviceWorker' in navigator) {
104 | navigator.serviceWorker.ready.then(registration => {
105 | registration.unregister();
106 | });
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/client/src/withCurrentUser.js:
--------------------------------------------------------------------------------
1 | import { gql, graphql } from 'react-apollo';
2 | import { userFragment } from './fragments';
3 |
4 | const query = gql`
5 | query headerQuery {
6 | currentUser: User {
7 | ...UserFields
8 | }
9 | }
10 |
11 | ${userFragment}
12 | `
13 |
14 | export default graphql(query);
15 |
--------------------------------------------------------------------------------
/makefile:
--------------------------------------------------------------------------------
1 | install:
2 | cd server && npm install
3 | cd client && npm install
4 |
5 | run-server:
6 | cd server && npm start
7 |
8 | run-client:
9 | cd client && npm start
10 |
11 | test:
12 | cd server && npm run test
13 | cd client && npm run test
14 |
--------------------------------------------------------------------------------
/schema.graphql:
--------------------------------------------------------------------------------
1 | type Tweet {
2 | id: ID!
3 | # The tweet text. No more than 140 characters!
4 | body: String
5 | # When the tweet was published
6 | date: Date
7 | # Who published the tweet
8 | Author: User
9 | # Views, retweets, likes, etc
10 | Stats: Stat
11 | }
12 |
13 | type User {
14 | id: ID!
15 | username: String
16 | first_name: String
17 | last_name: String
18 | full_name: String
19 | name: String @deprecated
20 | avatar_url: Url
21 | }
22 |
23 | type Stat {
24 | views: Int
25 | likes: Int
26 | retweets: Int
27 | responses: Int
28 | }
29 |
30 | type Notification {
31 | id: ID
32 | date: Date
33 | type: String
34 | }
35 |
36 | type Meta {
37 | count: Int
38 | }
39 |
40 | scalar Url
41 | scalar Date
42 |
43 | type Query {
44 | Tweet(id: ID!): Tweet
45 | Tweets(limit: Int, skip: Int, sort_field: String, sort_order: String): [Tweet]
46 | TweetsMeta: Meta
47 | User(id: ID!): User
48 | Notifications(limit: Int): [Notification]
49 | NotificationsMeta: Meta
50 | }
51 |
52 | type Mutation {
53 | createTweet (
54 | body: String
55 | ): Tweet
56 | deleteTweet(id: ID!): Tweet
57 | markTweetRead(id: ID!): Boolean
58 | }
59 |
--------------------------------------------------------------------------------
/server/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [["env", { "targets": { "node": "current" } }], "stage-3"],
3 | "plugins": ["add-module-exports"]
4 | }
5 |
--------------------------------------------------------------------------------
/server/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "gqlserver",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "./node_modules/.bin/jest --watch",
8 | "start": "./node_modules/.bin/babel-node src/index.js"
9 | },
10 | "author": "",
11 | "license": "ISC",
12 | "dependencies": {
13 | "cors": "^2.8.4",
14 | "dataloader": "^1.3.0",
15 | "date-fns": "^1.28.5",
16 | "express": "^4.15.3",
17 | "express-graphql": "^0.6.6",
18 | "graphql": "^0.10.5",
19 | "graphql-tools": "^1.1.0",
20 | "nodemon": "^1.11.0"
21 | },
22 | "devDependencies": {
23 | "babel-cli": "^6.24.1",
24 | "babel-plugin-add-module-exports": "^0.2.1",
25 | "babel-preset-env": "^1.6.0",
26 | "babel-preset-stage-3": "^6.24.1",
27 | "jest": "^20.0.4"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/server/src/base.js:
--------------------------------------------------------------------------------
1 | const Base = `
2 | type Query {
3 | dummy: Boolean
4 | }
5 |
6 | type Mutation {
7 | dummy: Boolean
8 | }
9 |
10 | type Meta {
11 | count: Int
12 | }
13 |
14 | scalar Url
15 | scalar Date
16 | `;
17 |
18 | export default () => [Base];
19 |
--------------------------------------------------------------------------------
/server/src/context.js:
--------------------------------------------------------------------------------
1 | import data from './data';
2 | import { dataloaders as userDataloaders } from './user/resolvers';
3 | import { dataloaders as statDataloaders } from './stat/resolvers';
4 |
5 | export default request => ({
6 | author_id: 10, // should come from the request for an authentified user
7 | datastore: data,
8 | dataloaders: {
9 | ...userDataloaders(data),
10 | ...statDataloaders(data),
11 | },
12 | });
13 |
--------------------------------------------------------------------------------
/server/src/data.js:
--------------------------------------------------------------------------------
1 | import subMinutes from 'date-fns/sub_minutes';
2 | import subHours from 'date-fns/sub_hours';
3 | import subDays from 'date-fns/sub_days';
4 | import subMonths from 'date-fns/sub_months';
5 |
6 | const today = new Date();
7 |
8 | export default {
9 | tweets: [
10 | { id: 1, body: 'Lorem Ipsum', date: subMinutes(today, 1), author_id: 10 },
11 | { id: 2, body: 'Sic dolor amet', date: subMinutes(today, 25), author_id: 11 },
12 | { id: 3, body: 'Lorem Ipsum', date: subMinutes(today, 45), author_id: 10 },
13 | { id: 4, body: 'Sic dolor amet', date: subHours(today, 3), author_id: 11 },
14 | { id: 5, body: 'Lorem Ipsum', date: subHours(today, 7), author_id: 10 },
15 | { id: 6, body: 'Sic dolor amet', date: subDays(today, 1), author_id: 11 },
16 | { id: 7, body: 'Lorem Ipsum', date: subDays(today, 5), author_id: 10 },
17 | { id: 8, body: 'Sic dolor amet', date: subDays(today, 11), author_id: 11 },
18 | { id: 9, body: 'Lorem Ipsum', date: subDays(today, 23), author_id: 10 },
19 | { id: 10, body: 'Sic dolor amet', date: subMonths(today, 3), author_id: 11 },
20 | ],
21 | users: [
22 | {
23 | id: 10,
24 | username: 'johndoe',
25 | first_name: 'John',
26 | last_name: 'Doe',
27 | avatar_url: 'https://material-ui-1dab0.firebaseapp.com/static/images/remy.jpg',
28 | },
29 | {
30 | id: 11,
31 | username: 'janedoe',
32 | first_name: 'Jane',
33 | last_name: 'Doe',
34 | avatar_url: 'https://material-ui-1dab0.firebaseapp.com/static/images/uxceo-128.jpg',
35 | },
36 | ],
37 | stats: [
38 | { tweet_id: 1, views: 123, likes: 4, retweets: 1, responses: 0 },
39 | { tweet_id: 2, views: 567, likes: 45, retweets: 63, responses: 6 },
40 | { tweet_id: 3, views: 45, likes: 24, retweets: 18, responses: 1 },
41 | { tweet_id: 4, views: 87, likes: 34, retweets: 31, responses: 2 },
42 | { tweet_id: 5, views: 93, likes: 53, retweets: 14, responses: 4 },
43 | { tweet_id: 6, views: 10, likes: 2, retweets: 1, responses: 0 },
44 | { tweet_id: 7, views: 243, likes: 145, retweets: 121, responses: 128 },
45 | { tweet_id: 8, views: 73, likes: 12, retweets: 2, responses: 3 },
46 | { tweet_id: 9, views: 187, likes: 139, retweets: 167, responses: 98 },
47 | { tweet_id: 10, views: 435, likes: 389, retweets: 348, responses: 310 },
48 | ],
49 | };
50 |
--------------------------------------------------------------------------------
/server/src/index.js:
--------------------------------------------------------------------------------
1 | // in src/index.js
2 | import express from 'express';
3 | import cors from 'cors';
4 | import graphqlHTTP from 'express-graphql';
5 |
6 | import schema from './schema';
7 | import context from './context';
8 |
9 | var app = express();
10 |
11 | app.use(cors());
12 |
13 | app.use(function (req, res, next) {
14 | res.header("Access-Control-Allow-Origin", "*");
15 | res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
16 | next();
17 | });
18 |
19 | app.use(
20 | '/graphql',
21 | graphqlHTTP(request => ({
22 | schema: schema,
23 | context: context(request),
24 | graphiql: true,
25 | })),
26 | );
27 | app.listen(4000);
28 | console.log('Running a GraphQL API server at http://localhost:4000/graphql');
29 |
--------------------------------------------------------------------------------
/server/src/resolvers.js:
--------------------------------------------------------------------------------
1 | import {
2 | Query as TweetQuery,
3 | Mutation as TweetMutation,
4 | Tweet,
5 | } from './tweet/resolvers';
6 | import { Query as UserQuery, User } from './user/resolvers';
7 | import Date from './scalar/Date';
8 |
9 | export default {
10 | Query: {
11 | ...TweetQuery,
12 | ...UserQuery,
13 | },
14 | Mutation: {
15 | ...TweetMutation,
16 | },
17 | Tweet,
18 | User,
19 | Date,
20 | };
21 |
--------------------------------------------------------------------------------
/server/src/scalar/Date.js:
--------------------------------------------------------------------------------
1 | import { GraphQLScalarType } from 'graphql';
2 | import { Kind } from 'graphql/language';
3 |
4 | export default new GraphQLScalarType({
5 | name: 'Date',
6 | description: 'Date type',
7 | parseValue(value) {
8 | // value comes from the client
9 | return new Date(value); // sent to resolvers
10 | },
11 | serialize(value) {
12 | // value comes from resolvers
13 | return value.toISOString(); // sent to the client
14 | },
15 | parseLiteral(ast) {
16 | // ast comes from parsing the query
17 | // this is where you can validate and transform
18 | if (ast.kind !== Kind.STRING) {
19 | throw new GraphQLError(
20 | `Query error: Can only parse dates strings, got a: ${ast.kind}`,
21 | [ast],
22 | );
23 | }
24 | if (isNaN(Date.parse(ast.value))) {
25 | throw new GraphQLError(`Query error: not a valid date`, [ast]);
26 | }
27 | return new Date(ast.value);
28 | },
29 | });
30 |
--------------------------------------------------------------------------------
/server/src/schema.js:
--------------------------------------------------------------------------------
1 | import { makeExecutableSchema } from 'graphql-tools';
2 | import Base from './base';
3 | import Tweet from './tweet/schema';
4 | import User from './user/schema';
5 | import Stat from './stat/schema';
6 | import resolvers from './resolvers';
7 |
8 | export default makeExecutableSchema({
9 | typeDefs: [Base, Tweet, User, Stat],
10 | resolvers,
11 | logger: { log: e => console.log(e) },
12 | });
13 |
--------------------------------------------------------------------------------
/server/src/schema.spec.js:
--------------------------------------------------------------------------------
1 | import { graphql } from 'graphql';
2 | import schema from './schema';
3 |
4 | it('responds to the Tweets query', () => {
5 | // stubs
6 | const datastore = {
7 | tweets: [
8 | { id: 1, body: 'hello', author_id: 10 },
9 | { id: 2, body: 'world', author_id: 11 },
10 | ],
11 | users: [
12 | {
13 | id: 10,
14 | username: 'johndoe',
15 | first_name: 'John',
16 | last_name: 'Doe',
17 | avatar_url: 'acme.com/avatars/10',
18 | },
19 | {
20 | id: 11,
21 | username: 'janedoe',
22 | first_name: 'Jane',
23 | last_name: 'Doe',
24 | avatar_url: 'acme.com/avatars/11',
25 | },
26 | ],
27 | };
28 | const context = {
29 | datastore,
30 | dataloaders: {
31 | userById: {
32 | load: id =>
33 | Promise.resolve(
34 | datastore.users.find(user => user.id == id),
35 | ),
36 | },
37 | },
38 | };
39 | // now onto the test itself
40 | const query = '{ Tweets { id body Author { username } }}';
41 | return graphql(schema, query, null, context).then(results => {
42 | expect(results).toEqual({
43 | data: {
44 | Tweets: [
45 | { id: '1', body: 'hello', Author: { username: 'johndoe' } },
46 | { id: '2', body: 'world', Author: { username: 'janedoe' } },
47 | ],
48 | },
49 | });
50 | });
51 | });
52 |
--------------------------------------------------------------------------------
/server/src/stat/resolvers.js:
--------------------------------------------------------------------------------
1 | import DataLoader from 'dataloader';
2 |
3 | export const getStatsForTweet = datastore => ids =>
4 | Promise.resolve(
5 | ids.map(id => datastore.stats.find(stat => stat.tweet_id == id)),
6 | );
7 | export const dataloaders = datastore => ({
8 | statForTweet: new DataLoader(getStatsForTweet(datastore)),
9 | });
10 |
--------------------------------------------------------------------------------
/server/src/stat/schema.js:
--------------------------------------------------------------------------------
1 | const Stat = `
2 | type Stat {
3 | views: Int
4 | likes: Int
5 | retweets: Int
6 | responses: Int
7 | tweet_id: Int
8 | }
9 | `;
10 |
11 | export default () => [Stat];
12 |
--------------------------------------------------------------------------------
/server/src/tweet/resolvers.js:
--------------------------------------------------------------------------------
1 | export const Query = {
2 | Tweets: (_, { limit = 5, skip = 0 }, context) =>
3 | Promise.resolve(
4 | context.datastore.tweets
5 | .slice()
6 | .sort((a, b) => b.date - a.date)
7 | .slice(skip, skip + limit)
8 | ),
9 | Tweet: (_, { id }, context) =>
10 | Promise.resolve(context.datastore.tweets.find(tweet => tweet.id == id)),
11 | };
12 | export const Mutation = {
13 | createTweet: (_, { body }, context) => {
14 | const nextTweetId =
15 | context.datastore.tweets.reduce((id, tweet) => {
16 | return Math.max(id, tweet.id);
17 | }, -1) + 1;
18 | const newTweetStats = {
19 | tweet_id: nextTweetId,
20 | views: 0,
21 | likes: 0,
22 | retweets: 0,
23 | responses: 0,
24 | };
25 | const newTweet = {
26 | id: nextTweetId,
27 | date: new Date(),
28 | author_id: context.author_id,
29 | body,
30 | Stats: newTweetStats,
31 | };
32 |
33 | context.datastore.tweets.push(newTweet);
34 | context.datastore.stats.push(newTweetStats);
35 | return Promise.resolve(newTweet);
36 | },
37 | };
38 | export const Tweet = {
39 | Author: (tweet, _, context) =>
40 | context.dataloaders.userById.load(tweet.author_id),
41 | Stats: (tweet, _, context) =>
42 | context.dataloaders.statForTweet.load(tweet.id),
43 | };
44 |
--------------------------------------------------------------------------------
/server/src/tweet/resolvers.spec.js:
--------------------------------------------------------------------------------
1 | import { Query } from './resolvers';
2 |
3 | describe('Query', () => {
4 | describe('Tweets', () => {
5 | it('returns all tweets', () => {
6 | const context = {
7 | datastore: {
8 | tweets: [
9 | { id: 1, body: 'hello' },
10 | { id: 2, body: 'world' },
11 | ],
12 | },
13 | };
14 | return Query.Tweets(null, null, context).then(results => {
15 | expect(results).toEqual([
16 | { id: 1, body: 'hello' },
17 | { id: 2, body: 'world' },
18 | ]);
19 | });
20 | });
21 | });
22 | describe('Tweet', () => {
23 | it('returns undefined when not found', () => {
24 | const context = { datastore: { tweets: [] } };
25 | return Query.Tweet(null, { id: 3 }, context).then(result => {
26 | expect(result).toBeUndefined();
27 | });
28 | });
29 | it('returns tweet by id', () => {
30 | const context = {
31 | datastore: {
32 | tweets: [
33 | { id: 1, body: 'hello' },
34 | { id: 2, body: 'world' },
35 | ],
36 | },
37 | };
38 | return Query.Tweet(null, { id: 2 }, context).then(result => {
39 | expect(result).toEqual({ id: 2, body: 'world' });
40 | });
41 | });
42 | });
43 | });
44 |
--------------------------------------------------------------------------------
/server/src/tweet/schema.js:
--------------------------------------------------------------------------------
1 | import User from '../user/schema';
2 | import Stat from '../stat/schema';
3 | import Base from '../base';
4 |
5 | const Tweet = `
6 | extend type Query {
7 | Tweet(id: ID!): Tweet
8 | Tweets(limit: Int, skip: Int, sort_field: String, sort_order: String): [Tweet]
9 | TweetsMeta: Meta
10 | }
11 | extend type Mutation {
12 | createTweet (body: String): Tweet
13 | deleteTweet(id: ID!): Tweet
14 | markTweetRead(id: ID!): Boolean
15 | }
16 | type Tweet {
17 | id: ID!
18 | # The tweet text. No more than 140 characters!
19 | body: String
20 | # When the tweet was published
21 | date: Date
22 | # Who published the tweet
23 | Author: User
24 | # Views, retweets, likes, etc
25 | Stats: Stat
26 | }
27 | `;
28 |
29 | export default () => [Tweet, User, Stat, Base];
30 |
--------------------------------------------------------------------------------
/server/src/user/resolvers.js:
--------------------------------------------------------------------------------
1 | import DataLoader from 'dataloader';
2 |
3 | export const Query = {
4 | User: (_, __, context) =>
5 | Promise.resolve(context.datastore.users.find(user => user.id === context.author_id)),
6 | };
7 | export const User = {
8 | full_name: author =>
9 | Promise.resolve(`${author.first_name} ${author.last_name}`),
10 | };
11 | export const getUsersById = datastore => ids =>
12 | Promise.resolve(
13 | ids.map(id => datastore.users.find(user => user.id == id)),
14 | datastore.users.filter(user => ids.includes(user.id)),
15 | );
16 | export const dataloaders = datastore => ({
17 | userById: new DataLoader(getUsersById(datastore)),
18 | });
19 |
--------------------------------------------------------------------------------
/server/src/user/resolvers.spec.js:
--------------------------------------------------------------------------------
1 | import { User, Query } from './resolvers';
2 |
3 | describe('Query', () => {
4 | describe('User', () => {
5 | it('returns user by id', () => {
6 | const context = {
7 | datastore: {
8 | users: [{ id: 1, name: 'john' }, { id: 2, name: 'jane' }],
9 | },
10 | };
11 | return Query.User(null, { id: 2 }, context).then(result => {
12 | expect(result).toEqual({ id: 2, name: 'jane' });
13 | });
14 | });
15 | });
16 | });
17 |
18 | describe('User', () => {
19 | describe('full_name', () => {
20 | it('concatenates first and last name', () => {
21 | const user = { first_name: 'John', last_name: 'Doe' };
22 | User.full_name(user).then(result =>
23 | expect(result).toEqual('John Doe'),
24 | );
25 | });
26 | });
27 | });
28 |
--------------------------------------------------------------------------------
/server/src/user/schema.js:
--------------------------------------------------------------------------------
1 | import Base from '../base';
2 |
3 | const User = `
4 | extend type Query {
5 | User: User
6 | }
7 | type User {
8 | id: ID!
9 | username: String
10 | first_name: String
11 | last_name: String
12 | full_name: String
13 | name: String @deprecated
14 | avatar_url: String
15 | }
16 | `;
17 |
18 | export default () => [User, Base];
19 |
--------------------------------------------------------------------------------