├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── README.md ├── data ├── database.js ├── schema.graphql └── schema.js ├── js ├── app.js ├── components │ ├── Todo.js │ ├── TodoApp.js │ ├── TodoList.js │ ├── TodoListFooter.js │ └── TodoTextInput.js ├── getRelayEnvironment.js ├── mutations │ ├── AddTodoMutation.js │ ├── ChangeTodoStatusMutation.js │ ├── MarkAllTodosMutation.js │ ├── RemoveCompletedTodosMutation.js │ ├── RemoveTodoMutation.js │ └── RenameTodoMutation.js ├── network.js ├── renderServer.js └── root.js ├── package.json ├── public ├── base.css ├── index.css └── learn.json ├── scripts └── updateSchema.js ├── server.js ├── views └── index.html └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "passPerPreset": true, 3 | "plugins": [ 4 | "@babel/plugin-proposal-class-properties", 5 | "relay" 6 | ], 7 | "presets": [ 8 | "@babel/preset-react", 9 | [ 10 | "@babel/preset-env", 11 | { 12 | "modules": "commonjs", 13 | "targets": "> 0.25%, not dead" 14 | } 15 | ] 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | __generated__/ -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | extends: 4 | - fbjs 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | __generated__/ 3 | .idea 4 | node_modules 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Isomorphic Relay Modern TodoMVC 2 | 3 | This is the [todo-modern example](https://github.com/relayjs/relay-examples/tree/master/todo-modern) updated to use universal/isomorphic/server side renderering. 4 | 5 | How? [See the diff](https://github.com/robrichard/relay-modern-isomorphic-example/compare/4a1b2ca08d5bd841dbc935eabcf1614f9272d303...master) 6 | 7 | ## Approach 8 | On the server (renderServer.js), we fetch the data needed directly by using the `fetchQuery` function that is exported by `react-relay`. This function returns a promise that resolves with the result of the query. We then use `ReactRelayContext` to synchronously render the components to string. The Relay store is then serialized to JSON and passed to the client. 9 | 10 | On the client (app.js), a new environment is created with the data sent from the server. Then the components are rendered using relay-query-lookup-renderer. With the lookup prop, the component will be rendered immediately with the data from the store. In a more complex app you may change the variables passed to the QueryLookupRenderer and it will fetch the data if needed. 11 | 12 | ## Installation 13 | 14 | ``` 15 | yarn install 16 | ``` 17 | 18 | ## Running 19 | 20 | Set up generated files: 21 | 22 | ``` 23 | yarn run update-schema 24 | yarn run build 25 | ``` 26 | 27 | Start a local server: 28 | 29 | ``` 30 | yarn start 31 | ``` 32 | 33 | ## Developing 34 | 35 | Any changes you make to files in the `js/` directory will cause the server to 36 | automatically rebuild the app and refresh your browser. 37 | 38 | If at any time you make changes to `data/schema.js`, stop the server, 39 | regenerate `data/schema.graphql`, and restart the server: 40 | 41 | ``` 42 | yarn run update-schema 43 | yarn run build 44 | yarn start 45 | ``` 46 | -------------------------------------------------------------------------------- /data/database.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file provided by Facebook is for non-commercial testing and evaluation 3 | * purposes only. Facebook reserves all rights not expressly granted. 4 | * 5 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 6 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 7 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 8 | * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 9 | * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 10 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | */ 12 | 13 | export class Todo {} 14 | export class User {} 15 | 16 | // Mock authenticated ID 17 | const VIEWER_ID = 'me'; 18 | 19 | // Mock user data 20 | const viewer = new User(); 21 | viewer.id = VIEWER_ID; 22 | const usersById = { 23 | [VIEWER_ID]: viewer, 24 | }; 25 | 26 | // Mock todo data 27 | const todosById = {}; 28 | const todoIdsByUser = { 29 | [VIEWER_ID]: [], 30 | }; 31 | let nextTodoId = 0; 32 | addTodo('Taste JavaScript', true); 33 | addTodo('Buy a unicorn', false); 34 | 35 | export function addTodo(text, complete) { 36 | const todo = new Todo(); 37 | todo.complete = !!complete; 38 | todo.id = `${nextTodoId++}`; 39 | todo.text = text; 40 | todosById[todo.id] = todo; 41 | todoIdsByUser[VIEWER_ID].push(todo.id); 42 | return todo.id; 43 | } 44 | 45 | export function changeTodoStatus(id, complete) { 46 | const todo = getTodo(id); 47 | todo.complete = complete; 48 | } 49 | 50 | export function getTodo(id) { 51 | return todosById[id]; 52 | } 53 | 54 | export function getTodos(status = 'any') { 55 | const todos = todoIdsByUser[VIEWER_ID].map(id => todosById[id]); 56 | if (status === 'any') { 57 | return todos; 58 | } 59 | return todos.filter(todo => todo.complete === (status === 'completed')); 60 | } 61 | 62 | export function getUser(id) { 63 | return usersById[id]; 64 | } 65 | 66 | export function getViewer() { 67 | return getUser(VIEWER_ID); 68 | } 69 | 70 | export function markAllTodos(complete) { 71 | const changedTodos = []; 72 | getTodos().forEach(todo => { 73 | if (todo.complete !== complete) { 74 | todo.complete = complete; 75 | changedTodos.push(todo); 76 | } 77 | }); 78 | return changedTodos.map(todo => todo.id); 79 | } 80 | 81 | export function removeTodo(id) { 82 | const todoIndex = todoIdsByUser[VIEWER_ID].indexOf(id); 83 | if (todoIndex !== -1) { 84 | todoIdsByUser[VIEWER_ID].splice(todoIndex, 1); 85 | } 86 | delete todosById[id]; 87 | } 88 | 89 | export function removeCompletedTodos() { 90 | const todosToRemove = getTodos().filter(todo => todo.complete); 91 | todosToRemove.forEach(todo => removeTodo(todo.id)); 92 | return todosToRemove.map(todo => todo.id); 93 | } 94 | 95 | export function renameTodo(id, text) { 96 | const todo = getTodo(id); 97 | todo.text = text; 98 | } 99 | -------------------------------------------------------------------------------- /data/schema.graphql: -------------------------------------------------------------------------------- 1 | input AddTodoInput { 2 | text: String! 3 | clientMutationId: String 4 | } 5 | 6 | type AddTodoPayload { 7 | todoEdge: TodoEdge 8 | viewer: User 9 | clientMutationId: String 10 | } 11 | 12 | input ChangeTodoStatusInput { 13 | complete: Boolean! 14 | id: ID! 15 | clientMutationId: String 16 | } 17 | 18 | type ChangeTodoStatusPayload { 19 | todo: Todo 20 | viewer: User 21 | clientMutationId: String 22 | } 23 | 24 | input MarkAllTodosInput { 25 | complete: Boolean! 26 | clientMutationId: String 27 | } 28 | 29 | type MarkAllTodosPayload { 30 | changedTodos: [Todo] 31 | viewer: User 32 | clientMutationId: String 33 | } 34 | 35 | type Mutation { 36 | addTodo(input: AddTodoInput!): AddTodoPayload 37 | changeTodoStatus(input: ChangeTodoStatusInput!): ChangeTodoStatusPayload 38 | markAllTodos(input: MarkAllTodosInput!): MarkAllTodosPayload 39 | removeCompletedTodos(input: RemoveCompletedTodosInput!): RemoveCompletedTodosPayload 40 | removeTodo(input: RemoveTodoInput!): RemoveTodoPayload 41 | renameTodo(input: RenameTodoInput!): RenameTodoPayload 42 | } 43 | 44 | """An object with an ID""" 45 | interface Node { 46 | """The id of the object.""" 47 | id: ID! 48 | } 49 | 50 | """Information about pagination in a connection.""" 51 | type PageInfo { 52 | """When paginating forwards, are there more items?""" 53 | hasNextPage: Boolean! 54 | 55 | """When paginating backwards, are there more items?""" 56 | hasPreviousPage: Boolean! 57 | 58 | """When paginating backwards, the cursor to continue.""" 59 | startCursor: String 60 | 61 | """When paginating forwards, the cursor to continue.""" 62 | endCursor: String 63 | } 64 | 65 | type Query { 66 | viewer: User 67 | 68 | """Fetches an object given its ID""" 69 | node( 70 | """The ID of an object""" 71 | id: ID! 72 | ): Node 73 | } 74 | 75 | input RemoveCompletedTodosInput { 76 | clientMutationId: String 77 | } 78 | 79 | type RemoveCompletedTodosPayload { 80 | deletedTodoIds: [String] 81 | viewer: User 82 | clientMutationId: String 83 | } 84 | 85 | input RemoveTodoInput { 86 | id: ID! 87 | clientMutationId: String 88 | } 89 | 90 | type RemoveTodoPayload { 91 | deletedTodoId: ID 92 | viewer: User 93 | clientMutationId: String 94 | } 95 | 96 | input RenameTodoInput { 97 | id: ID! 98 | text: String! 99 | clientMutationId: String 100 | } 101 | 102 | type RenameTodoPayload { 103 | todo: Todo 104 | clientMutationId: String 105 | } 106 | 107 | type Todo implements Node { 108 | """The ID of an object""" 109 | id: ID! 110 | text: String 111 | complete: Boolean 112 | } 113 | 114 | """A connection to a list of items.""" 115 | type TodoConnection { 116 | """Information to aid in pagination.""" 117 | pageInfo: PageInfo! 118 | 119 | """A list of edges.""" 120 | edges: [TodoEdge] 121 | } 122 | 123 | """An edge in a connection.""" 124 | type TodoEdge { 125 | """The item at the end of the edge""" 126 | node: Todo 127 | 128 | """A cursor for use in pagination""" 129 | cursor: String! 130 | } 131 | 132 | type User implements Node { 133 | """The ID of an object""" 134 | id: ID! 135 | todos(status: String = "any", after: String, first: Int, before: String, last: Int): TodoConnection 136 | totalCount: Int 137 | completedCount: Int 138 | } 139 | -------------------------------------------------------------------------------- /data/schema.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file provided by Facebook is for non-commercial testing and evaluation 3 | * purposes only. Facebook reserves all rights not expressly granted. 4 | * 5 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 6 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 7 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 8 | * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 9 | * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 10 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | */ 12 | 13 | import { 14 | GraphQLBoolean, 15 | GraphQLID, 16 | GraphQLInt, 17 | GraphQLList, 18 | GraphQLNonNull, 19 | GraphQLObjectType, 20 | GraphQLSchema, 21 | GraphQLString, 22 | } from 'graphql'; 23 | 24 | import { 25 | connectionArgs, 26 | connectionDefinitions, 27 | connectionFromArray, 28 | cursorForObjectInConnection, 29 | fromGlobalId, 30 | globalIdField, 31 | mutationWithClientMutationId, 32 | nodeDefinitions, 33 | toGlobalId, 34 | } from 'graphql-relay'; 35 | 36 | import { 37 | Todo, 38 | User, 39 | addTodo, 40 | changeTodoStatus, 41 | getTodo, 42 | getTodos, 43 | getUser, 44 | getViewer, 45 | markAllTodos, 46 | removeCompletedTodos, 47 | removeTodo, 48 | renameTodo, 49 | } from './database'; 50 | 51 | const {nodeInterface, nodeField} = nodeDefinitions( 52 | (globalId) => { 53 | const {type, id} = fromGlobalId(globalId); 54 | if (type === 'Todo') { 55 | return getTodo(id); 56 | } else if (type === 'User') { 57 | return getUser(id); 58 | } 59 | return null; 60 | }, 61 | (obj) => { 62 | if (obj instanceof Todo) { 63 | return GraphQLTodo; 64 | } else if (obj instanceof User) { 65 | return GraphQLUser; 66 | } 67 | return null; 68 | } 69 | ); 70 | 71 | const GraphQLTodo = new GraphQLObjectType({ 72 | name: 'Todo', 73 | fields: { 74 | id: globalIdField('Todo'), 75 | text: { 76 | type: GraphQLString, 77 | resolve: (obj) => obj.text, 78 | }, 79 | complete: { 80 | type: GraphQLBoolean, 81 | resolve: (obj) => obj.complete, 82 | }, 83 | }, 84 | interfaces: [nodeInterface], 85 | }); 86 | 87 | const { 88 | connectionType: TodosConnection, 89 | edgeType: GraphQLTodoEdge, 90 | } = connectionDefinitions({ 91 | name: 'Todo', 92 | nodeType: GraphQLTodo, 93 | }); 94 | 95 | const GraphQLUser = new GraphQLObjectType({ 96 | name: 'User', 97 | fields: { 98 | id: globalIdField('User'), 99 | todos: { 100 | type: TodosConnection, 101 | args: { 102 | status: { 103 | type: GraphQLString, 104 | defaultValue: 'any', 105 | }, 106 | ...connectionArgs, 107 | }, 108 | resolve: (obj, {status, ...args}) => 109 | connectionFromArray(getTodos(status), args), 110 | }, 111 | totalCount: { 112 | type: GraphQLInt, 113 | resolve: () => getTodos().length, 114 | }, 115 | completedCount: { 116 | type: GraphQLInt, 117 | resolve: () => getTodos('completed').length, 118 | }, 119 | }, 120 | interfaces: [nodeInterface], 121 | }); 122 | 123 | const Query = new GraphQLObjectType({ 124 | name: 'Query', 125 | fields: { 126 | viewer: { 127 | type: GraphQLUser, 128 | resolve: () => getViewer(), 129 | }, 130 | node: nodeField, 131 | }, 132 | }); 133 | 134 | const GraphQLAddTodoMutation = mutationWithClientMutationId({ 135 | name: 'AddTodo', 136 | inputFields: { 137 | text: { type: new GraphQLNonNull(GraphQLString) }, 138 | }, 139 | outputFields: { 140 | todoEdge: { 141 | type: GraphQLTodoEdge, 142 | resolve: ({localTodoId}) => { 143 | const todo = getTodo(localTodoId); 144 | return { 145 | cursor: cursorForObjectInConnection(getTodos(), todo), 146 | node: todo, 147 | }; 148 | }, 149 | }, 150 | viewer: { 151 | type: GraphQLUser, 152 | resolve: () => getViewer(), 153 | }, 154 | }, 155 | mutateAndGetPayload: ({text}) => { 156 | const localTodoId = addTodo(text); 157 | return {localTodoId}; 158 | }, 159 | }); 160 | 161 | const GraphQLChangeTodoStatusMutation = mutationWithClientMutationId({ 162 | name: 'ChangeTodoStatus', 163 | inputFields: { 164 | complete: { type: new GraphQLNonNull(GraphQLBoolean) }, 165 | id: { type: new GraphQLNonNull(GraphQLID) }, 166 | }, 167 | outputFields: { 168 | todo: { 169 | type: GraphQLTodo, 170 | resolve: ({localTodoId}) => getTodo(localTodoId), 171 | }, 172 | viewer: { 173 | type: GraphQLUser, 174 | resolve: () => getViewer(), 175 | }, 176 | }, 177 | mutateAndGetPayload: ({id, complete}) => { 178 | const localTodoId = fromGlobalId(id).id; 179 | changeTodoStatus(localTodoId, complete); 180 | return {localTodoId}; 181 | }, 182 | }); 183 | 184 | const GraphQLMarkAllTodosMutation = mutationWithClientMutationId({ 185 | name: 'MarkAllTodos', 186 | inputFields: { 187 | complete: { type: new GraphQLNonNull(GraphQLBoolean) }, 188 | }, 189 | outputFields: { 190 | changedTodos: { 191 | type: new GraphQLList(GraphQLTodo), 192 | resolve: ({changedTodoLocalIds}) => changedTodoLocalIds.map(getTodo), 193 | }, 194 | viewer: { 195 | type: GraphQLUser, 196 | resolve: () => getViewer(), 197 | }, 198 | }, 199 | mutateAndGetPayload: ({complete}) => { 200 | const changedTodoLocalIds = markAllTodos(complete); 201 | return {changedTodoLocalIds}; 202 | }, 203 | }); 204 | 205 | // TODO: Support plural deletes 206 | const GraphQLRemoveCompletedTodosMutation = mutationWithClientMutationId({ 207 | name: 'RemoveCompletedTodos', 208 | outputFields: { 209 | deletedTodoIds: { 210 | type: new GraphQLList(GraphQLString), 211 | resolve: ({deletedTodoIds}) => deletedTodoIds, 212 | }, 213 | viewer: { 214 | type: GraphQLUser, 215 | resolve: () => getViewer(), 216 | }, 217 | }, 218 | mutateAndGetPayload: () => { 219 | const deletedTodoLocalIds = removeCompletedTodos(); 220 | const deletedTodoIds = deletedTodoLocalIds.map(toGlobalId.bind(null, 'Todo')); 221 | return {deletedTodoIds}; 222 | }, 223 | }); 224 | 225 | const GraphQLRemoveTodoMutation = mutationWithClientMutationId({ 226 | name: 'RemoveTodo', 227 | inputFields: { 228 | id: { type: new GraphQLNonNull(GraphQLID) }, 229 | }, 230 | outputFields: { 231 | deletedTodoId: { 232 | type: GraphQLID, 233 | resolve: ({id}) => id, 234 | }, 235 | viewer: { 236 | type: GraphQLUser, 237 | resolve: () => getViewer(), 238 | }, 239 | }, 240 | mutateAndGetPayload: ({id}) => { 241 | const localTodoId = fromGlobalId(id).id; 242 | removeTodo(localTodoId); 243 | return {id}; 244 | }, 245 | }); 246 | 247 | const GraphQLRenameTodoMutation = mutationWithClientMutationId({ 248 | name: 'RenameTodo', 249 | inputFields: { 250 | id: { type: new GraphQLNonNull(GraphQLID) }, 251 | text: { type: new GraphQLNonNull(GraphQLString) }, 252 | }, 253 | outputFields: { 254 | todo: { 255 | type: GraphQLTodo, 256 | resolve: ({localTodoId}) => getTodo(localTodoId), 257 | }, 258 | }, 259 | mutateAndGetPayload: ({id, text}) => { 260 | const localTodoId = fromGlobalId(id).id; 261 | renameTodo(localTodoId, text); 262 | return {localTodoId}; 263 | }, 264 | }); 265 | 266 | const Mutation = new GraphQLObjectType({ 267 | name: 'Mutation', 268 | fields: { 269 | addTodo: GraphQLAddTodoMutation, 270 | changeTodoStatus: GraphQLChangeTodoStatusMutation, 271 | markAllTodos: GraphQLMarkAllTodosMutation, 272 | removeCompletedTodos: GraphQLRemoveCompletedTodosMutation, 273 | removeTodo: GraphQLRemoveTodoMutation, 274 | renameTodo: GraphQLRenameTodoMutation, 275 | }, 276 | }); 277 | 278 | export const schema = new GraphQLSchema({ 279 | query: Query, 280 | mutation: Mutation, 281 | }); 282 | -------------------------------------------------------------------------------- /js/app.js: -------------------------------------------------------------------------------- 1 | import 'todomvc-common'; 2 | 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import {ReactRelayContext} from 'react-relay'; 6 | import QueryRenderer from 'relay-query-lookup-renderer'; 7 | import TodoApp from './components/TodoApp'; 8 | import rootQuery from './root'; 9 | import getRelayEnvironment from './getRelayEnvironment'; 10 | 11 | const environment = getRelayEnvironment(window.records); 12 | 13 | ReactDOM.render( 14 | { 21 | if (!props) { 22 | return null; 23 | } 24 | return 25 | }} 26 | />, 27 | document.getElementById('root') 28 | ); 29 | -------------------------------------------------------------------------------- /js/components/Todo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file provided by Facebook is for non-commercial testing and evaluation 3 | * purposes only. Facebook reserves all rights not expressly granted. 4 | * 5 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 6 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 7 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 8 | * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 9 | * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 10 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | */ 12 | 13 | import ChangeTodoStatusMutation from '../mutations/ChangeTodoStatusMutation'; 14 | import RemoveTodoMutation from '../mutations/RemoveTodoMutation'; 15 | import RenameTodoMutation from '../mutations/RenameTodoMutation'; 16 | import TodoTextInput from './TodoTextInput'; 17 | 18 | import React from 'react'; 19 | import { 20 | createFragmentContainer, 21 | graphql, 22 | } from 'react-relay'; 23 | import classnames from 'classnames'; 24 | 25 | class Todo extends React.Component { 26 | state = { 27 | isEditing: false, 28 | }; 29 | _handleCompleteChange = (e) => { 30 | const complete = e.target.checked; 31 | ChangeTodoStatusMutation.commit( 32 | this.props.relay.environment, 33 | complete, 34 | this.props.todo, 35 | this.props.viewer, 36 | ); 37 | }; 38 | _handleDestroyClick = () => { 39 | this._removeTodo(); 40 | }; 41 | _handleLabelDoubleClick = () => { 42 | this._setEditMode(true); 43 | }; 44 | _handleTextInputCancel = () => { 45 | this._setEditMode(false); 46 | }; 47 | _handleTextInputDelete = () => { 48 | this._setEditMode(false); 49 | this._removeTodo(); 50 | }; 51 | _handleTextInputSave = (text) => { 52 | this._setEditMode(false); 53 | RenameTodoMutation.commit( 54 | this.props.relay.environment, 55 | text, 56 | this.props.todo, 57 | ); 58 | }; 59 | _removeTodo() { 60 | RemoveTodoMutation.commit( 61 | this.props.relay.environment, 62 | this.props.todo, 63 | this.props.viewer, 64 | ); 65 | } 66 | _setEditMode = (shouldEdit) => { 67 | this.setState({isEditing: shouldEdit}); 68 | }; 69 | renderTextInput() { 70 | return ( 71 | 79 | ); 80 | } 81 | render() { 82 | return ( 83 |
  • 88 |
    89 | 95 | 98 |
    103 | {this.state.isEditing && this.renderTextInput()} 104 |
  • 105 | ); 106 | } 107 | } 108 | 109 | export default createFragmentContainer(Todo, { 110 | todo: graphql` 111 | fragment Todo_todo on Todo { 112 | complete, 113 | id, 114 | text, 115 | } 116 | `, 117 | viewer: graphql` 118 | fragment Todo_viewer on User { 119 | id, 120 | totalCount, 121 | completedCount, 122 | } 123 | `, 124 | }); 125 | -------------------------------------------------------------------------------- /js/components/TodoApp.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file provided by Facebook is for non-commercial testing and evaluation 3 | * purposes only. Facebook reserves all rights not expressly granted. 4 | * 5 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 6 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 7 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 8 | * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 9 | * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 10 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | */ 12 | 13 | import AddTodoMutation from '../mutations/AddTodoMutation'; 14 | import TodoList from './TodoList'; 15 | import TodoListFooter from './TodoListFooter'; 16 | import TodoTextInput from './TodoTextInput'; 17 | 18 | import React from 'react'; 19 | import { 20 | createFragmentContainer, 21 | graphql, 22 | } from 'react-relay'; 23 | 24 | class TodoApp extends React.Component { 25 | _handleTextInputSave = (text) => { 26 | AddTodoMutation.commit( 27 | this.props.relay.environment, 28 | text, 29 | this.props.viewer, 30 | ); 31 | }; 32 | render() { 33 | const hasTodos = this.props.viewer.totalCount > 0; 34 | return ( 35 |
    36 |
    37 |
    38 |

    39 | todos 40 |

    41 | 47 |
    48 | 49 | {hasTodos && 50 | 54 | } 55 |
    56 | 69 |
    70 | ); 71 | } 72 | } 73 | 74 | export default createFragmentContainer(TodoApp, { 75 | viewer: graphql` 76 | fragment TodoApp_viewer on User { 77 | id, 78 | totalCount, 79 | ...TodoListFooter_viewer, 80 | ...TodoList_viewer, 81 | } 82 | `, 83 | }); 84 | -------------------------------------------------------------------------------- /js/components/TodoList.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file provided by Facebook is for non-commercial testing and evaluation 3 | * purposes only. Facebook reserves all rights not expressly granted. 4 | * 5 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 6 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 7 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 8 | * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 9 | * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 10 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | */ 12 | 13 | import MarkAllTodosMutation from '../mutations/MarkAllTodosMutation'; 14 | import Todo from './Todo'; 15 | 16 | import React from 'react'; 17 | import { 18 | createFragmentContainer, 19 | graphql, 20 | } from 'react-relay'; 21 | 22 | class TodoList extends React.Component { 23 | _handleMarkAllChange = (e) => { 24 | const complete = e.target.checked; 25 | MarkAllTodosMutation.commit( 26 | this.props.relay.environment, 27 | complete, 28 | this.props.viewer.todos, 29 | this.props.viewer, 30 | ); 31 | }; 32 | renderTodos() { 33 | return this.props.viewer.todos.edges.map(edge => 34 | 39 | ); 40 | } 41 | render() { 42 | const numTodos = this.props.viewer.totalCount; 43 | const numCompletedTodos = this.props.viewer.completedCount; 44 | return ( 45 |
    46 | 52 | 55 |
      56 | {this.renderTodos()} 57 |
    58 |
    59 | ); 60 | } 61 | } 62 | 63 | export default createFragmentContainer(TodoList, { 64 | viewer: graphql` 65 | fragment TodoList_viewer on User { 66 | todos( 67 | first: 2147483647 # max GraphQLInt 68 | ) @connection(key: "TodoList_todos") { 69 | edges { 70 | node { 71 | id, 72 | complete, 73 | ...Todo_todo, 74 | }, 75 | }, 76 | }, 77 | id, 78 | totalCount, 79 | completedCount, 80 | ...Todo_viewer, 81 | } 82 | `, 83 | }); 84 | -------------------------------------------------------------------------------- /js/components/TodoListFooter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file provided by Facebook is for non-commercial testing and evaluation 3 | * purposes only. Facebook reserves all rights not expressly granted. 4 | * 5 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 6 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 7 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 8 | * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 9 | * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 10 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | */ 12 | 13 | import RemoveCompletedTodosMutation from '../mutations/RemoveCompletedTodosMutation'; 14 | 15 | import React from 'react'; 16 | import { 17 | graphql, 18 | createFragmentContainer, 19 | } from 'react-relay'; 20 | 21 | class TodoListFooter extends React.Component { 22 | _handleRemoveCompletedTodosClick = () => { 23 | RemoveCompletedTodosMutation.commit( 24 | this.props.relay.environment, 25 | this.props.viewer.completedTodos, 26 | this.props.viewer, 27 | ); 28 | }; 29 | render() { 30 | const numCompletedTodos = this.props.viewer.completedCount; 31 | const numRemainingTodos = this.props.viewer.totalCount - numCompletedTodos; 32 | return ( 33 |
    34 | 35 | {numRemainingTodos} item{numRemainingTodos === 1 ? '' : 's'} left 36 | 37 | {numCompletedTodos > 0 && 38 | 43 | } 44 |
    45 | ); 46 | } 47 | } 48 | 49 | export default createFragmentContainer( 50 | TodoListFooter, 51 | { 52 | viewer: graphql` 53 | fragment TodoListFooter_viewer on User { 54 | id, 55 | completedCount, 56 | completedTodos: todos( 57 | status: "completed", 58 | first: 2147483647 # max GraphQLInt 59 | ) { 60 | edges { 61 | node { 62 | id 63 | complete 64 | } 65 | } 66 | }, 67 | totalCount, 68 | } 69 | ` 70 | } 71 | ); 72 | -------------------------------------------------------------------------------- /js/components/TodoTextInput.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file provided by Facebook is for non-commercial testing and evaluation 3 | * purposes only. Facebook reserves all rights not expressly granted. 4 | * 5 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 6 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 7 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 8 | * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 9 | * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 10 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | */ 12 | 13 | import React from 'react'; 14 | import ReactDOM from 'react-dom'; 15 | 16 | const PropTypes = require('prop-types'); 17 | 18 | const ENTER_KEY_CODE = 13; 19 | const ESC_KEY_CODE = 27; 20 | 21 | export default class TodoTextInput extends React.Component { 22 | static defaultProps = { 23 | commitOnBlur: false, 24 | }; 25 | static propTypes = { 26 | className: PropTypes.string, 27 | commitOnBlur: PropTypes.bool.isRequired, 28 | initialValue: PropTypes.string, 29 | onCancel: PropTypes.func, 30 | onDelete: PropTypes.func, 31 | onSave: PropTypes.func.isRequired, 32 | placeholder: PropTypes.string, 33 | }; 34 | state = { 35 | isEditing: false, 36 | text: this.props.initialValue || '', 37 | }; 38 | componentDidMount() { 39 | ReactDOM.findDOMNode(this).focus(); 40 | } 41 | _commitChanges = () => { 42 | const newText = this.state.text.trim(); 43 | if (this.props.onDelete && newText === '') { 44 | this.props.onDelete(); 45 | } else if (this.props.onCancel && newText === this.props.initialValue) { 46 | this.props.onCancel(); 47 | } else if (newText !== '') { 48 | this.props.onSave(newText); 49 | this.setState({text: ''}); 50 | } 51 | }; 52 | _handleBlur = () => { 53 | if (this.props.commitOnBlur) { 54 | this._commitChanges(); 55 | } 56 | }; 57 | _handleChange = (e) => { 58 | this.setState({text: e.target.value}); 59 | }; 60 | _handleKeyDown = (e) => { 61 | if (this.props.onCancel && e.keyCode === ESC_KEY_CODE) { 62 | this.props.onCancel(); 63 | } else if (e.keyCode === ENTER_KEY_CODE) { 64 | this._commitChanges(); 65 | } 66 | }; 67 | render() { 68 | return ( 69 | 77 | ); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /js/getRelayEnvironment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import 'isomorphic-fetch'; 4 | import { 5 | Environment, 6 | RecordSource, 7 | Store, 8 | } from 'relay-runtime'; 9 | import network from './network'; 10 | 11 | export default function getRelayEnvironment(records) { 12 | const source = new RecordSource(records); 13 | const store = new Store(source); 14 | 15 | // Create a network layer from the fetch function 16 | return new Environment({network, store}); 17 | } 18 | 19 | -------------------------------------------------------------------------------- /js/mutations/AddTodoMutation.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file provided by Facebook is for non-commercial testing and evaluation 3 | * purposes only. Facebook reserves all rights not expressly granted. 4 | * 5 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 6 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 7 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 8 | * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 9 | * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 10 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | */ 12 | 13 | import { 14 | commitMutation, 15 | graphql, 16 | } from 'react-relay'; 17 | import {ConnectionHandler} from 'relay-runtime'; 18 | 19 | const mutation = graphql` 20 | mutation AddTodoMutation($input: AddTodoInput!) { 21 | addTodo(input:$input) { 22 | todoEdge { 23 | __typename 24 | cursor 25 | node { 26 | complete 27 | id 28 | text 29 | } 30 | } 31 | viewer { 32 | id 33 | totalCount 34 | } 35 | } 36 | } 37 | `; 38 | 39 | function sharedUpdater(store, user, newEdge) { 40 | const userProxy = store.get(user.id); 41 | const conn = ConnectionHandler.getConnection( 42 | userProxy, 43 | 'TodoList_todos', 44 | ); 45 | ConnectionHandler.insertEdgeAfter(conn, newEdge); 46 | } 47 | 48 | let tempID = 0; 49 | 50 | function commit( 51 | environment, 52 | text, 53 | user 54 | ) { 55 | return commitMutation( 56 | environment, 57 | { 58 | mutation, 59 | variables: { 60 | input: {text}, 61 | }, 62 | updater: (store) => { 63 | const payload = store.getRootField('addTodo'); 64 | const newEdge = payload.getLinkedRecord('todoEdge'); 65 | sharedUpdater(store, user, newEdge); 66 | }, 67 | optimisticUpdater: (store) => { 68 | const id = 'client:newTodo:' + tempID++; 69 | const node = store.create(id, 'Todo'); 70 | node.setValue(text, 'text'); 71 | node.setValue(id, 'id'); 72 | const newEdge = store.create( 73 | 'client:newEdge:' + tempID++, 74 | 'TodoEdge', 75 | ); 76 | newEdge.setLinkedRecord(node, 'node'); 77 | sharedUpdater(store, user, newEdge); 78 | const userProxy = store.get(user.id); 79 | userProxy.setValue( 80 | userProxy.getValue('totalCount') + 1, 81 | 'totalCount', 82 | ); 83 | }, 84 | } 85 | ); 86 | } 87 | 88 | export default {commit}; 89 | -------------------------------------------------------------------------------- /js/mutations/ChangeTodoStatusMutation.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file provided by Facebook is for non-commercial testing and evaluation 3 | * purposes only. Facebook reserves all rights not expressly granted. 4 | * 5 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 6 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 7 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 8 | * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 9 | * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 10 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | */ 12 | 13 | import { 14 | commitMutation, 15 | graphql, 16 | } from 'react-relay'; 17 | 18 | const mutation = graphql` 19 | mutation ChangeTodoStatusMutation($input: ChangeTodoStatusInput!) { 20 | changeTodoStatus(input: $input) { 21 | todo { 22 | id 23 | complete 24 | } 25 | viewer { 26 | id 27 | completedCount 28 | } 29 | } 30 | } 31 | `; 32 | 33 | function getOptimisticResponse(complete, todo, user) { 34 | const viewerPayload = {id: user.id}; 35 | if (user.completedCount != null) { 36 | viewerPayload.completedCount = complete ? 37 | user.completedCount + 1 : 38 | user.completedCount - 1; 39 | } 40 | return { 41 | changeTodoStatus: { 42 | todo: { 43 | complete: complete, 44 | id: todo.id, 45 | }, 46 | viewer: viewerPayload, 47 | }, 48 | }; 49 | } 50 | 51 | function commit( 52 | environment, 53 | complete, 54 | todo, 55 | user, 56 | ) { 57 | return commitMutation( 58 | environment, 59 | { 60 | mutation, 61 | variables: { 62 | input: {complete, id: todo.id}, 63 | }, 64 | optimisticResponse: getOptimisticResponse(complete, todo, user), 65 | } 66 | ); 67 | } 68 | 69 | export default {commit}; 70 | -------------------------------------------------------------------------------- /js/mutations/MarkAllTodosMutation.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file provided by Facebook is for non-commercial testing and evaluation 3 | * purposes only. Facebook reserves all rights not expressly granted. 4 | * 5 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 6 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 7 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 8 | * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 9 | * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 10 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | */ 12 | 13 | import { 14 | commitMutation, 15 | graphql, 16 | } from 'react-relay'; 17 | 18 | const mutation = graphql` 19 | mutation MarkAllTodosMutation($input: MarkAllTodosInput!) { 20 | markAllTodos(input: $input) { 21 | changedTodos { 22 | id 23 | complete 24 | } 25 | viewer { 26 | id 27 | completedCount 28 | } 29 | } 30 | } 31 | `; 32 | 33 | function getOptimisticResponse(complete, todos, user) { 34 | const payload = {viewer: {id: user.id}}; 35 | if (todos && todos.edges) { 36 | payload.changedTodos = todos.edges 37 | .filter(edge => edge.node.complete !== complete) 38 | .map(edge => ({ 39 | complete: complete, 40 | id: edge.node.id, 41 | })); 42 | } 43 | if (user.totalCount != null) { 44 | payload.viewer.completedCount = complete ? 45 | user.totalCount : 46 | 0; 47 | } 48 | return { 49 | markAllTodos: payload, 50 | }; 51 | } 52 | 53 | function commit( 54 | environment, 55 | complete, 56 | todos, 57 | user, 58 | ) { 59 | return commitMutation( 60 | environment, 61 | { 62 | mutation, 63 | variables: { 64 | input: {complete}, 65 | }, 66 | optimisticResponse: getOptimisticResponse(complete, todos, user), 67 | } 68 | ); 69 | } 70 | 71 | export default {commit}; 72 | -------------------------------------------------------------------------------- /js/mutations/RemoveCompletedTodosMutation.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file provided by Facebook is for non-commercial testing and evaluation 3 | * purposes only. Facebook reserves all rights not expressly granted. 4 | * 5 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 6 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 7 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 8 | * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 9 | * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 10 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | */ 12 | 13 | import { 14 | commitMutation, 15 | graphql, 16 | } from 'react-relay'; 17 | import {ConnectionHandler} from 'relay-runtime'; 18 | 19 | const mutation = graphql` 20 | mutation RemoveCompletedTodosMutation($input: RemoveCompletedTodosInput!) { 21 | removeCompletedTodos(input: $input) { 22 | deletedTodoIds, 23 | viewer { 24 | completedCount, 25 | totalCount, 26 | }, 27 | } 28 | } 29 | `; 30 | 31 | function sharedUpdater(store, user, deletedIDs) { 32 | const userProxy = store.get(user.id); 33 | const conn = ConnectionHandler.getConnection( 34 | userProxy, 35 | 'TodoList_todos', 36 | ); 37 | deletedIDs.forEach((deletedID) => 38 | ConnectionHandler.deleteNode(conn, deletedID) 39 | ); 40 | } 41 | 42 | function commit( 43 | environment, 44 | todos, 45 | user, 46 | ) { 47 | return commitMutation( 48 | environment, 49 | { 50 | mutation, 51 | variables: { 52 | input: {}, 53 | }, 54 | updater: (store) => { 55 | const payload = store.getRootField('removeCompletedTodos'); 56 | sharedUpdater(store, user, payload.getValue('deletedTodoIds')); 57 | }, 58 | optimisticUpdater: (store) => { 59 | if (todos && todos.edges) { 60 | const deletedIDs = todos.edges 61 | .filter(edge => edge.node.complete) 62 | .map(edge => edge.node.id); 63 | sharedUpdater(store, user, deletedIDs); 64 | } 65 | }, 66 | } 67 | ); 68 | } 69 | 70 | export default {commit}; 71 | -------------------------------------------------------------------------------- /js/mutations/RemoveTodoMutation.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file provided by Facebook is for non-commercial testing and evaluation 3 | * purposes only. Facebook reserves all rights not expressly granted. 4 | * 5 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 6 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 7 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 8 | * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 9 | * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 10 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | */ 12 | 13 | import { 14 | commitMutation, 15 | graphql, 16 | } from 'react-relay'; 17 | import {ConnectionHandler} from 'relay-runtime'; 18 | 19 | const mutation = graphql` 20 | mutation RemoveTodoMutation($input: RemoveTodoInput!) { 21 | removeTodo(input: $input) { 22 | deletedTodoId, 23 | viewer { 24 | completedCount, 25 | totalCount, 26 | }, 27 | } 28 | } 29 | `; 30 | 31 | function sharedUpdater(store, user, deletedID) { 32 | const userProxy = store.get(user.id); 33 | const conn = ConnectionHandler.getConnection( 34 | userProxy, 35 | 'TodoList_todos', 36 | ); 37 | ConnectionHandler.deleteNode( 38 | conn, 39 | deletedID, 40 | ); 41 | } 42 | 43 | function commit( 44 | environment, 45 | todo, 46 | user, 47 | ) { 48 | return commitMutation( 49 | environment, 50 | { 51 | mutation, 52 | variables: { 53 | input: {id: todo.id}, 54 | }, 55 | updater: (store) => { 56 | const payload = store.getRootField('removeTodo'); 57 | sharedUpdater(store, user, payload.getValue('deletedTodoId')); 58 | }, 59 | optimisticUpdater: (store) => { 60 | sharedUpdater(store, user, todo.id); 61 | }, 62 | } 63 | ); 64 | } 65 | 66 | export default {commit}; 67 | -------------------------------------------------------------------------------- /js/mutations/RenameTodoMutation.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file provided by Facebook is for non-commercial testing and evaluation 3 | * purposes only. Facebook reserves all rights not expressly granted. 4 | * 5 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 6 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 7 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 8 | * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 9 | * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 10 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | */ 12 | 13 | import { 14 | commitMutation, 15 | graphql, 16 | } from 'react-relay'; 17 | 18 | const mutation = graphql` 19 | mutation RenameTodoMutation($input: RenameTodoInput!) { 20 | renameTodo(input:$input) { 21 | todo { 22 | id 23 | text 24 | } 25 | } 26 | } 27 | `; 28 | 29 | function getOptimisticResponse(text, todo) { 30 | return { 31 | renameTodo: { 32 | todo: { 33 | id: todo.id, 34 | text: text, 35 | }, 36 | }, 37 | }; 38 | } 39 | 40 | function commit( 41 | environment, 42 | text, 43 | todo 44 | ) { 45 | return commitMutation( 46 | environment, 47 | { 48 | mutation, 49 | variables: { 50 | input: {text, id: todo.id}, 51 | }, 52 | optimisticResponse: getOptimisticResponse(text, todo), 53 | } 54 | ); 55 | } 56 | 57 | export default {commit}; 58 | -------------------------------------------------------------------------------- /js/network.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import 'isomorphic-fetch'; 4 | import {Network} from 'relay-runtime'; 5 | 6 | function fetchQuery( 7 | operation, 8 | variables, 9 | ) { 10 | return fetch('http://localhost:3000/graphql', { 11 | method: 'POST', 12 | headers: { 13 | 'content-type': 'application/json', 14 | }, 15 | body: JSON.stringify({ 16 | query: operation.text, // GraphQL text from input 17 | variables, 18 | }), 19 | }).then(response => { 20 | return response.json(); 21 | }); 22 | } 23 | 24 | export default Network.create(fetchQuery); 25 | -------------------------------------------------------------------------------- /js/renderServer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import {fetchQuery, ReactRelayContext} from 'react-relay'; 4 | import nunjucks from 'nunjucks'; 5 | import React from 'react'; 6 | import {renderToString} from 'react-dom/server'; 7 | import getRelayEnvironment from './getRelayEnvironment'; 8 | import PropTypes from 'prop-types'; 9 | import TodoApp from './components/TodoApp'; 10 | import rootQuery from './root'; 11 | 12 | const variables = {}; 13 | 14 | export default async function(req, res, next) { 15 | const environment = getRelayEnvironment(); 16 | const data = await fetchQuery(environment, rootQuery, variables); 17 | const renderedComponent = renderToString( 18 | 19 | 20 | 21 | ); 22 | 23 | 24 | res.send(nunjucks.render('index.html', { 25 | renderedComponent: renderedComponent, 26 | records: JSON.stringify(environment.getStore().getSource()), 27 | })); 28 | } 29 | -------------------------------------------------------------------------------- /js/root.js: -------------------------------------------------------------------------------- 1 | import {graphql} from 'react-relay'; 2 | 3 | export default graphql` 4 | query rootQuery { 5 | viewer { 6 | ...TodoApp_viewer 7 | } 8 | } 9 | `; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "start": "babel-node ./server.js", 5 | "build": "relay-compiler --src ./js/ --schema ./data/schema.graphql", 6 | "update-schema": "babel-node ./scripts/updateSchema.js", 7 | "lint": "eslint ./js" 8 | }, 9 | "dependencies": { 10 | "classnames": "2.2.5", 11 | "deep-freeze": "^0.0.1", 12 | "express": "^4.15.2", 13 | "express-graphql": "^0.6.4", 14 | "fbjs": "^0.8.1", 15 | "graphql": "^14.0.0", 16 | "graphql-relay": "^0.5.1", 17 | "nunjucks": "^3.0.0", 18 | "prop-types": "^15.5.8", 19 | "react": "^16.7.0", 20 | "react-dom": "^16.7.0", 21 | "react-relay": "5.0.0", 22 | "relay-query-lookup-renderer": "^4.0.0", 23 | "todomvc-app-css": "^2.1.0", 24 | "todomvc-common": "^1.0.3", 25 | "webpack": "1.13.2", 26 | "webpack-dev-server": "^3.1.11", 27 | "whatwg-fetch": "2.0.3" 28 | }, 29 | "devDependencies": { 30 | "@babel/node": "^7.4.5", 31 | "@babel/plugin-proposal-class-properties": "^7.4.4", 32 | "@babel/preset-env": "^7.4.5", 33 | "@babel/preset-react": "^7.0.0", 34 | "babel-eslint": "6.1.2", 35 | "babel-loader": "^8.0.6", 36 | "babel-plugin-relay": "5.0.0", 37 | "eslint": "4.18.2", 38 | "eslint-config-fbjs": "1.1.1", 39 | "eslint-plugin-babel": "3.3.0", 40 | "eslint-plugin-flowtype": "2.15.0", 41 | "eslint-plugin-react": "5.2.2", 42 | "relay-compiler": "5.0.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /public/base.css: -------------------------------------------------------------------------------- 1 | hr { 2 | margin: 20px 0; 3 | border: 0; 4 | border-top: 1px dashed #c5c5c5; 5 | border-bottom: 1px dashed #f7f7f7; 6 | } 7 | 8 | .learn a { 9 | font-weight: normal; 10 | text-decoration: none; 11 | color: #b83f45; 12 | } 13 | 14 | .learn a:hover { 15 | text-decoration: underline; 16 | color: #787e7e; 17 | } 18 | 19 | .learn h3, 20 | .learn h4, 21 | .learn h5 { 22 | margin: 10px 0; 23 | font-weight: 500; 24 | line-height: 1.2; 25 | color: #000; 26 | } 27 | 28 | .learn h3 { 29 | font-size: 24px; 30 | } 31 | 32 | .learn h4 { 33 | font-size: 18px; 34 | } 35 | 36 | .learn h5 { 37 | margin-bottom: 0; 38 | font-size: 14px; 39 | } 40 | 41 | .learn ul { 42 | padding: 0; 43 | margin: 0 0 30px 25px; 44 | } 45 | 46 | .learn li { 47 | line-height: 20px; 48 | } 49 | 50 | .learn p { 51 | font-size: 15px; 52 | font-weight: 300; 53 | line-height: 1.3; 54 | margin-top: 0; 55 | margin-bottom: 0; 56 | } 57 | 58 | #issue-count { 59 | display: none; 60 | } 61 | 62 | .quote { 63 | border: none; 64 | margin: 20px 0 60px 0; 65 | } 66 | 67 | .quote p { 68 | font-style: italic; 69 | } 70 | 71 | .quote p:before { 72 | content: '“'; 73 | font-size: 50px; 74 | opacity: .15; 75 | position: absolute; 76 | top: -20px; 77 | left: 3px; 78 | } 79 | 80 | .quote p:after { 81 | content: '”'; 82 | font-size: 50px; 83 | opacity: .15; 84 | position: absolute; 85 | bottom: -42px; 86 | right: 3px; 87 | } 88 | 89 | .quote footer { 90 | position: absolute; 91 | bottom: -40px; 92 | right: 0; 93 | } 94 | 95 | .quote footer img { 96 | border-radius: 3px; 97 | } 98 | 99 | .quote footer a { 100 | margin-left: 5px; 101 | vertical-align: middle; 102 | } 103 | 104 | .speech-bubble { 105 | position: relative; 106 | padding: 10px; 107 | background: rgba(0, 0, 0, .04); 108 | border-radius: 5px; 109 | } 110 | 111 | .speech-bubble:after { 112 | content: ''; 113 | position: absolute; 114 | top: 100%; 115 | right: 30px; 116 | border: 13px solid transparent; 117 | border-top-color: rgba(0, 0, 0, .04); 118 | } 119 | 120 | .learn-bar > .learn { 121 | position: absolute; 122 | width: 272px; 123 | top: 8px; 124 | left: -300px; 125 | padding: 10px; 126 | border-radius: 5px; 127 | background-color: rgba(255, 255, 255, .6); 128 | transition-property: left; 129 | transition-duration: 500ms; 130 | } 131 | 132 | @media (min-width: 899px) { 133 | .learn-bar { 134 | width: auto; 135 | padding-left: 300px; 136 | } 137 | 138 | .learn-bar > .learn { 139 | left: 8px; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /public/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | button { 8 | margin: 0; 9 | padding: 0; 10 | border: 0; 11 | background: none; 12 | font-size: 100%; 13 | vertical-align: baseline; 14 | font-family: inherit; 15 | font-weight: inherit; 16 | color: inherit; 17 | -webkit-appearance: none; 18 | appearance: none; 19 | -webkit-font-smoothing: antialiased; 20 | -moz-osx-font-smoothing: grayscale; 21 | } 22 | 23 | body { 24 | font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; 25 | line-height: 1.4em; 26 | background: #f5f5f5; 27 | color: #4d4d4d; 28 | min-width: 230px; 29 | max-width: 550px; 30 | margin: 0 auto; 31 | -webkit-font-smoothing: antialiased; 32 | -moz-osx-font-smoothing: grayscale; 33 | font-weight: 300; 34 | } 35 | 36 | :focus { 37 | outline: 0; 38 | } 39 | 40 | .hidden { 41 | display: none; 42 | } 43 | 44 | .todoapp { 45 | background: #fff; 46 | margin: 130px 0 40px 0; 47 | position: relative; 48 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 49 | 0 25px 50px 0 rgba(0, 0, 0, 0.1); 50 | } 51 | 52 | .todoapp input::-webkit-input-placeholder { 53 | font-style: italic; 54 | font-weight: 300; 55 | color: #e6e6e6; 56 | } 57 | 58 | .todoapp input::-moz-placeholder { 59 | font-style: italic; 60 | font-weight: 300; 61 | color: #e6e6e6; 62 | } 63 | 64 | .todoapp input::input-placeholder { 65 | font-style: italic; 66 | font-weight: 300; 67 | color: #e6e6e6; 68 | } 69 | 70 | .todoapp h1 { 71 | position: absolute; 72 | top: -155px; 73 | width: 100%; 74 | font-size: 100px; 75 | font-weight: 100; 76 | text-align: center; 77 | color: rgba(175, 47, 47, 0.15); 78 | -webkit-text-rendering: optimizeLegibility; 79 | -moz-text-rendering: optimizeLegibility; 80 | text-rendering: optimizeLegibility; 81 | } 82 | 83 | .new-todo, 84 | .edit { 85 | position: relative; 86 | margin: 0; 87 | width: 100%; 88 | font-size: 24px; 89 | font-family: inherit; 90 | font-weight: inherit; 91 | line-height: 1.4em; 92 | border: 0; 93 | color: inherit; 94 | padding: 6px; 95 | border: 1px solid #999; 96 | box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); 97 | box-sizing: border-box; 98 | -webkit-font-smoothing: antialiased; 99 | -moz-osx-font-smoothing: grayscale; 100 | } 101 | 102 | .new-todo { 103 | padding: 16px 16px 16px 60px; 104 | border: none; 105 | background: rgba(0, 0, 0, 0.003); 106 | box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); 107 | } 108 | 109 | .main { 110 | position: relative; 111 | z-index: 2; 112 | border-top: 1px solid #e6e6e6; 113 | } 114 | 115 | label[for='toggle-all'] { 116 | display: none; 117 | } 118 | 119 | .toggle-all { 120 | position: absolute; 121 | top: -55px; 122 | left: -12px; 123 | width: 60px; 124 | height: 34px; 125 | text-align: center; 126 | border: none; /* Mobile Safari */ 127 | } 128 | 129 | .toggle-all:before { 130 | content: '❯'; 131 | font-size: 22px; 132 | color: #e6e6e6; 133 | padding: 10px 27px 10px 27px; 134 | } 135 | 136 | .toggle-all:checked:before { 137 | color: #737373; 138 | } 139 | 140 | .todo-list { 141 | margin: 0; 142 | padding: 0; 143 | list-style: none; 144 | } 145 | 146 | .todo-list li { 147 | position: relative; 148 | font-size: 24px; 149 | border-bottom: 1px solid #ededed; 150 | } 151 | 152 | .todo-list li:last-child { 153 | border-bottom: none; 154 | } 155 | 156 | .todo-list li.editing { 157 | border-bottom: none; 158 | padding: 0; 159 | } 160 | 161 | .todo-list li.editing .edit { 162 | display: block; 163 | width: 506px; 164 | padding: 12px 16px; 165 | margin: 0 0 0 43px; 166 | } 167 | 168 | .todo-list li.editing .view { 169 | display: none; 170 | } 171 | 172 | .todo-list li .toggle { 173 | text-align: center; 174 | width: 40px; 175 | /* auto, since non-WebKit browsers doesn't support input styling */ 176 | height: auto; 177 | position: absolute; 178 | top: 0; 179 | bottom: 0; 180 | margin: auto 0; 181 | border: none; /* Mobile Safari */ 182 | -webkit-appearance: none; 183 | appearance: none; 184 | } 185 | 186 | .todo-list li .toggle:after { 187 | content: url('data:image/svg+xml;utf8,'); 188 | } 189 | 190 | .todo-list li .toggle:checked:after { 191 | content: url('data:image/svg+xml;utf8,'); 192 | } 193 | 194 | .todo-list li label { 195 | word-break: break-all; 196 | padding: 15px 60px 15px 15px; 197 | margin-left: 45px; 198 | display: block; 199 | line-height: 1.2; 200 | transition: color 0.4s; 201 | } 202 | 203 | .todo-list li.completed label { 204 | color: #d9d9d9; 205 | text-decoration: line-through; 206 | } 207 | 208 | .todo-list li .destroy { 209 | display: none; 210 | position: absolute; 211 | top: 0; 212 | right: 10px; 213 | bottom: 0; 214 | width: 40px; 215 | height: 40px; 216 | margin: auto 0; 217 | font-size: 30px; 218 | color: #cc9a9a; 219 | margin-bottom: 11px; 220 | transition: color 0.2s ease-out; 221 | } 222 | 223 | .todo-list li .destroy:hover { 224 | color: #af5b5e; 225 | } 226 | 227 | .todo-list li .destroy:after { 228 | content: '×'; 229 | } 230 | 231 | .todo-list li:hover .destroy { 232 | display: block; 233 | } 234 | 235 | .todo-list li .edit { 236 | display: none; 237 | } 238 | 239 | .todo-list li.editing:last-child { 240 | margin-bottom: -1px; 241 | } 242 | 243 | .footer { 244 | color: #777; 245 | padding: 10px 15px; 246 | height: 20px; 247 | text-align: center; 248 | border-top: 1px solid #e6e6e6; 249 | } 250 | 251 | .footer:before { 252 | content: ''; 253 | position: absolute; 254 | right: 0; 255 | bottom: 0; 256 | left: 0; 257 | height: 50px; 258 | overflow: hidden; 259 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 260 | 0 8px 0 -3px #f6f6f6, 261 | 0 9px 1px -3px rgba(0, 0, 0, 0.2), 262 | 0 16px 0 -6px #f6f6f6, 263 | 0 17px 2px -6px rgba(0, 0, 0, 0.2); 264 | } 265 | 266 | .todo-count { 267 | float: left; 268 | text-align: left; 269 | } 270 | 271 | .todo-count strong { 272 | font-weight: 300; 273 | } 274 | 275 | .filters { 276 | margin: 0; 277 | padding: 0; 278 | list-style: none; 279 | position: absolute; 280 | right: 0; 281 | left: 0; 282 | } 283 | 284 | .filters li { 285 | display: inline; 286 | } 287 | 288 | .filters li a { 289 | color: inherit; 290 | margin: 3px; 291 | padding: 3px 7px; 292 | text-decoration: none; 293 | border: 1px solid transparent; 294 | border-radius: 3px; 295 | } 296 | 297 | .filters li a:hover { 298 | border-color: rgba(175, 47, 47, 0.1); 299 | } 300 | 301 | .filters li a.selected { 302 | border-color: rgba(175, 47, 47, 0.2); 303 | } 304 | 305 | .clear-completed, 306 | html .clear-completed:active { 307 | float: right; 308 | position: relative; 309 | line-height: 20px; 310 | text-decoration: none; 311 | cursor: pointer; 312 | } 313 | 314 | .clear-completed:hover { 315 | text-decoration: underline; 316 | } 317 | 318 | .info { 319 | margin: 65px auto 0; 320 | color: #bfbfbf; 321 | font-size: 10px; 322 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); 323 | text-align: center; 324 | } 325 | 326 | .info p { 327 | line-height: 1; 328 | } 329 | 330 | .info a { 331 | color: inherit; 332 | text-decoration: none; 333 | font-weight: 400; 334 | } 335 | 336 | .info a:hover { 337 | text-decoration: underline; 338 | } 339 | 340 | /* 341 | Hack to remove background from Mobile Safari. 342 | Can't use it globally since it destroys checkboxes in Firefox 343 | */ 344 | @media screen and (-webkit-min-device-pixel-ratio:0) { 345 | .toggle-all, 346 | .todo-list li .toggle { 347 | background: none; 348 | } 349 | 350 | .todo-list li .toggle { 351 | height: 40px; 352 | } 353 | 354 | .toggle-all { 355 | -webkit-transform: rotate(90deg); 356 | transform: rotate(90deg); 357 | -webkit-appearance: none; 358 | appearance: none; 359 | } 360 | } 361 | 362 | @media (max-width: 430px) { 363 | .footer { 364 | height: 50px; 365 | } 366 | 367 | .filters { 368 | bottom: 10px; 369 | } 370 | } 371 | -------------------------------------------------------------------------------- /public/learn.json: -------------------------------------------------------------------------------- 1 | { 2 | "relay": { 3 | "name": "Relay", 4 | "description": "A JavaScript framework for building data-driven React applications", 5 | "homepage": "facebook.github.io/relay/", 6 | "examples": [{ 7 | "name": "Relay + express-graphql Example", 8 | "url": "", 9 | "source_url": "https://github.com/facebook/relay/tree/master/examples/todo", 10 | "type": "backend" 11 | }], 12 | "link_groups": [{ 13 | "heading": "Official Resources", 14 | "links": [{ 15 | "name": "Documentation", 16 | "url": "https://facebook.github.io/relay/docs/getting-started.html" 17 | }, { 18 | "name": "API Reference", 19 | "url": "https://facebook.github.io/relay/docs/api-reference-relay.html" 20 | }, { 21 | "name": "Relay on GitHub", 22 | "url": "https://github.com/facebook/relay" 23 | }] 24 | }, { 25 | "heading": "Community", 26 | "links": [{ 27 | "name": "Relay on StackOverflow", 28 | "url": "https://stackoverflow.com/questions/tagged/relayjs" 29 | }] 30 | }] 31 | }, 32 | "templates": { 33 | "todomvc": "

    <%= name %>

    <% if (typeof examples !== 'undefined') { %> <% examples.forEach(function (example) { %>
    <%= example.name %>
    <% if (!location.href.match(example.url + '/')) { %> \" href=\"<%= example.url %>\">Demo, <% } if (example.type === 'backend') { %>\"><% } else { %>\"><% } %>Source <% }); %> <% } %>

    <%= description %>

    <% if (typeof link_groups !== 'undefined') { %>
    <% link_groups.forEach(function (link_group) { %>

    <%= link_group.heading %>

    <% }); %> <% } %>

    If you have other helpful links to share, or find any of the links above no longer work, please let us know.
    " 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /scripts/updateSchema.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env babel-node 2 | /** 3 | * This file provided by Facebook is for non-commercial testing and evaluation 4 | * purposes only. Facebook reserves all rights not expressly granted. 5 | * 6 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 7 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 8 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 9 | * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 10 | * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 11 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 12 | */ 13 | 14 | import fs from 'fs'; 15 | import path from 'path'; 16 | import { schema } from '../data/schema'; 17 | import { printSchema } from 'graphql'; 18 | 19 | const schemaPath = path.resolve(__dirname, '../data/schema.graphql'); 20 | 21 | fs.writeFileSync(schemaPath, printSchema(schema)); 22 | 23 | console.log('Wrote ' + schemaPath); 24 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file provided by Facebook is for non-commercial testing and evaluation 3 | * purposes only. Facebook reserves all rights not expressly granted. 4 | * 5 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 6 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 7 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 8 | * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 9 | * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 10 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | */ 12 | 13 | import express from 'express'; 14 | import graphQLHTTP from 'express-graphql'; 15 | import nunjucks from 'nunjucks'; 16 | import path from 'path'; 17 | import webpack from 'webpack'; 18 | import WebpackDevServer from 'webpack-dev-server'; 19 | import {schema} from './data/schema'; 20 | import renderServer from './js/renderServer'; 21 | const APP_PORT = 3000; 22 | const GRAPHQL_PORT = 8080; 23 | 24 | // Expose a GraphQL endpoint 25 | const graphQLServer = express(); 26 | graphQLServer.use('/', graphQLHTTP({schema, pretty: true})); 27 | graphQLServer.listen(GRAPHQL_PORT, () => console.log( 28 | `GraphQL Server is now running on http://localhost:${GRAPHQL_PORT}` 29 | )); 30 | 31 | // Serve the Relay app 32 | const compiler = webpack({ 33 | entry: path.resolve(__dirname, 'js', 'app.js'), 34 | module: { 35 | loaders: [ 36 | { 37 | exclude: /node_modules/, 38 | loader: 'babel', 39 | test: /\.js$/, 40 | }, 41 | ], 42 | }, 43 | output: {filename: 'app.js', path: '/'}, 44 | devtool: 'source-map' 45 | }); 46 | const app = new WebpackDevServer(compiler, { 47 | contentBase: '/public/', 48 | proxy: {'/graphql': `http://localhost:${GRAPHQL_PORT}`}, 49 | publicPath: '/js/', 50 | stats: {colors: true}, 51 | }); 52 | 53 | nunjucks.configure('views', {autoescape: true}); 54 | 55 | // Serve static resources 56 | app.use('/public', express.static(path.resolve(__dirname, 'public'))); 57 | app.use('/', renderServer); 58 | app.listen(APP_PORT, () => { 59 | console.log(`App is now running on http://localhost:${APP_PORT}`); 60 | }); 61 | -------------------------------------------------------------------------------- /views/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Relay • TodoMVC 7 | 8 | 9 | 10 | 11 |
    {{renderedComponent | safe}}
    12 | 18 | 19 | 20 | 21 | 22 | --------------------------------------------------------------------------------