├── .eslintrc ├── .gitignore ├── LICENSE ├── README.md ├── backend ├── .babelrc ├── .gitignore ├── config.json ├── package.json ├── scripts │ └── updateSchema.js └── src │ ├── data │ └── Todo.js │ ├── graphql │ ├── Todo │ │ ├── index.js │ │ └── mutations │ │ │ ├── AddTodoMutation.js │ │ │ ├── ChangeTodoStatusMutation.js │ │ │ ├── MarkAllTodosMutation.js │ │ │ ├── RemoveCompletedTodosMutation.js │ │ │ ├── RemoveTodoMutation.js │ │ │ └── RenameTodoMutation.js │ ├── Viewer.js │ ├── node.js │ └── schema.js │ ├── index.js │ ├── middlewares │ ├── errorHandler.js │ └── graphql.js │ └── utils │ └── logger.js ├── frontend ├── .babelrc ├── .gitignore ├── config.json ├── package.json ├── scripts │ └── babelRelayPlugin.js ├── src │ ├── app.js │ ├── routes.js │ ├── screens │ │ └── TodoApp │ │ │ ├── components │ │ │ └── TodoListFooter.js │ │ │ ├── index.js │ │ │ ├── mutations │ │ │ ├── AddTodoMutation.js │ │ │ └── RemoveCompletedTodosMutation.js │ │ │ ├── screens │ │ │ └── TodoList │ │ │ │ ├── components │ │ │ │ └── Todo.js │ │ │ │ ├── images │ │ │ │ ├── checked.svg │ │ │ │ └── unchecked.svg │ │ │ │ ├── index.js │ │ │ │ ├── mutations │ │ │ │ ├── ChangeTodoStatusMutation.js │ │ │ │ ├── MarkAllTodosMutation.js │ │ │ │ ├── RemoveTodoMutation.js │ │ │ │ └── RenameTodoMutation.js │ │ │ │ ├── styles │ │ │ │ ├── Todo.css │ │ │ │ └── TodoList.css │ │ │ │ └── views │ │ │ │ ├── TodoListView.js │ │ │ │ └── TodoView.js │ │ │ ├── shared │ │ │ ├── components │ │ │ │ └── TodoTextInput.js │ │ │ └── styles │ │ │ │ └── edit.css │ │ │ ├── styles │ │ │ ├── TodoApp.css │ │ │ └── TodoListFooter.css │ │ │ └── views │ │ │ ├── TodoAppView.js │ │ │ └── TodoListFooterView.js │ ├── server │ │ ├── components │ │ │ └── Page.js │ │ ├── index.js │ │ ├── middlewares │ │ │ ├── errorHandler.js │ │ │ ├── page.js │ │ │ ├── webpackDev.js │ │ │ └── webpackHot.js │ │ └── utils │ │ │ ├── logger.js │ │ │ └── webpackCompiler.js │ ├── shared │ │ └── utils │ │ │ └── history.js │ └── styles │ │ └── app.css ├── webpack-client.config.js └── webpack-server.config.js ├── package.json ├── proxy ├── .babelrc ├── .gitignore ├── config.json ├── package.json └── src │ ├── index.js │ ├── middlewares │ ├── errorHandler.js │ └── proxy.js │ └── utils │ └── logger.js └── server.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | }, 5 | "extends": "airbnb", 6 | "parser": "babel-eslint", 7 | "rules": { 8 | // TODO: remove that line when eslint-plugin-react is fixed (now it produces false positives) 9 | "react/prefer-stateless-function": 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-present Denis Nedelyaev 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Isomorphic Relay Boilerplate (Starter Kit) 2 | ========================================== 3 | 4 | Features 5 | -------- 6 | - Single page app with **isomorphic rendering** (i.e. supports server-side rendering). 7 | - Uses GraphQL and Relay. 8 | - Search engine indexable. 9 | - CSS Modules. 10 | - Cache boosting. 11 | - Hot Module Replacement for browser side code (in development mode). 12 | - Clear separation between the back-end and front-end. 13 | - Example based on TodoMVC. 14 | 15 | The directory structure for the front-end follows recommendations by 16 | [@ryanflorence](https://github.com/ryanflorence) presented 17 | [here](https://gist.github.com/ryanflorence/daafb1e3cb8ad740b346). 18 | 19 | Todo 20 | ---- 21 | - User sessions. 22 | - Flow annotations. 23 | - Infrastructure for unit testing. 24 | - Infrastructure for E2E testing. 25 | -------------------------------------------------------------------------------- /backend/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "stage-0" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | npm-debug.log 3 | node_modules/ 4 | schema.graphql 5 | schema.json 6 | -------------------------------------------------------------------------------- /backend/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 4444 3 | } 4 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "dependencies": { 4 | "express-graphql": "^0.4.9", 5 | "graphql": "^0.4.18", 6 | "graphql-relay": "^0.3.6" 7 | }, 8 | "scripts": { 9 | "build": "babel src -d lib", 10 | "postinstall": "npm run update-schema && npm run build", 11 | "start": "node lib", 12 | "update-schema": "babel-node scripts/updateSchema.js" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /backend/scripts/updateSchema.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env babel-node 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import { graphql } from 'graphql'; 5 | import { introspectionQuery, printSchema } from 'graphql/utilities'; 6 | 7 | import logger from '../src/utils/logger'; 8 | import schema from '../src/graphql/schema'; 9 | 10 | // Save JSON of full schema introspection for Babel Relay Plugin to use 11 | (async () => { 12 | const result = await graphql(schema, introspectionQuery); 13 | if (result.errors) { 14 | logger.error( 15 | 'ERROR introspecting schema: ', 16 | JSON.stringify(result.errors, null, 2) 17 | ); 18 | } else { 19 | fs.writeFileSync( 20 | path.resolve(__dirname, '..', 'schema.json'), 21 | JSON.stringify(result, null, 2) 22 | ); 23 | } 24 | })(); 25 | 26 | // Save user readable type system shorthand of schema 27 | fs.writeFileSync( 28 | path.resolve(__dirname, '..', 'schema.graphql'), 29 | printSchema(schema) 30 | ); 31 | -------------------------------------------------------------------------------- /backend/src/data/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 | export default class Todo {} 14 | 15 | const todosById = new Map; 16 | const todoIds = []; 17 | let nextTodoId = 0; 18 | 19 | export function addTodo(text, complete) { 20 | const todo = new Todo(); 21 | todo.complete = !!complete; 22 | todo.id = String(nextTodoId++); 23 | todo.text = text; 24 | todosById.set(todo.id, todo); 25 | todoIds.push(todo.id); 26 | return todo.id; 27 | } 28 | 29 | export function getTodo(id) { 30 | return todosById.get(id); 31 | } 32 | 33 | export function changeTodoStatus(id, complete) { 34 | const todo = getTodo(id); 35 | todo.complete = complete; 36 | } 37 | 38 | export function getTodos(status = 'any') { 39 | const todos = todoIds.map(id => todosById.get(id)); 40 | return status === 'any' ? 41 | todos : 42 | todos.filter(todo => todo.complete === (status === 'completed')); 43 | } 44 | 45 | export function markAllTodos(complete) { 46 | return getTodos().filter(todo => todo.complete !== complete).map(todo => { 47 | /* eslint-disable no-param-reassign */ 48 | todo.complete = complete; 49 | /* eslint-enable no-param-reassign */ 50 | return todo.id; 51 | }); 52 | } 53 | 54 | export function removeTodo(id) { 55 | const todoIndex = todoIds.indexOf(id); 56 | if (todoIndex !== -1) { 57 | todoIds.splice(todoIndex, 1); 58 | } 59 | 60 | delete todosById[id]; 61 | } 62 | 63 | export function removeCompletedTodos() { 64 | return getTodos().filter(todo => todo.complete).map(todo => { 65 | removeTodo(todo.id); 66 | return todo.id; 67 | }); 68 | } 69 | 70 | export function renameTodo(id, text) { 71 | const todo = getTodo(id); 72 | todo.text = text; 73 | } 74 | 75 | // Mock data 76 | addTodo('Taste JavaScript', true); 77 | addTodo('Buy a unicorn', false); 78 | -------------------------------------------------------------------------------- /backend/src/graphql/Todo/index.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 { GraphQLBoolean, GraphQLObjectType, GraphQLString } from 'graphql'; 14 | import { connectionDefinitions, globalIdField } from 'graphql-relay'; 15 | import Todo, { getTodo } from '../../data/Todo'; 16 | import { nodeInterface, registerNodeFetcher } from '../node'; 17 | 18 | const TODO_TYPE_NAME = 'Todo'; 19 | 20 | registerNodeFetcher(TODO_TYPE_NAME, id => getTodo(id)); 21 | 22 | const GraphQLTodo = new GraphQLObjectType({ 23 | name: TODO_TYPE_NAME, 24 | fields: () => ({ 25 | id: globalIdField(), 26 | text: { 27 | type: GraphQLString, 28 | resolve: obj => obj.text, 29 | }, 30 | complete: { 31 | type: GraphQLBoolean, 32 | resolve: obj => obj.complete, 33 | }, 34 | }), 35 | interfaces: () => [nodeInterface], 36 | isTypeOf: obj => obj instanceof Todo, 37 | }); 38 | 39 | export const { 40 | connectionType: GraphQLTodoConnection, 41 | edgeType: GraphQLTodoEdge, 42 | } = connectionDefinitions({ nodeType: GraphQLTodo }); 43 | 44 | export default GraphQLTodo; 45 | -------------------------------------------------------------------------------- /backend/src/graphql/Todo/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 { GraphQLNonNull, GraphQLString } from 'graphql'; 14 | import { cursorForObjectInConnection, mutationWithClientMutationId } from 'graphql-relay'; 15 | 16 | import { addTodo, getTodo, getTodos } from '../../../data/Todo'; 17 | import { GraphQLTodoEdge } from '../'; 18 | import GraphQLViewer, { viewer } from '../../Viewer'; 19 | 20 | export default mutationWithClientMutationId({ 21 | name: 'AddTodo', 22 | inputFields: () => ({ 23 | text: { type: new GraphQLNonNull(GraphQLString) }, 24 | }), 25 | outputFields: () => ({ 26 | todoEdge: { 27 | type: GraphQLTodoEdge, 28 | resolve: ({ localTodoId }) => { 29 | const todo = getTodo(localTodoId); 30 | return { 31 | cursor: cursorForObjectInConnection(getTodos(), todo), 32 | node: todo, 33 | }; 34 | }, 35 | }, 36 | viewer: { 37 | type: GraphQLViewer, 38 | resolve: () => viewer, 39 | }, 40 | }), 41 | mutateAndGetPayload: ({ text }) => ({ localTodoId: addTodo(text) }), 42 | }); 43 | -------------------------------------------------------------------------------- /backend/src/graphql/Todo/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 { GraphQLBoolean, GraphQLID, GraphQLNonNull } from 'graphql'; 14 | import { fromGlobalId, mutationWithClientMutationId } from 'graphql-relay'; 15 | 16 | import { changeTodoStatus, getTodo } from '../../../data/Todo'; 17 | import GraphQLTodo from '../'; 18 | import GraphQLViewer, { viewer } from '../../Viewer'; 19 | 20 | export default mutationWithClientMutationId({ 21 | name: 'ChangeTodoStatus', 22 | inputFields: () => ({ 23 | complete: { type: new GraphQLNonNull(GraphQLBoolean) }, 24 | id: { type: new GraphQLNonNull(GraphQLID) }, 25 | }), 26 | outputFields: () => ({ 27 | todo: { 28 | type: GraphQLTodo, 29 | resolve: ({ localTodoId }) => getTodo(localTodoId), 30 | }, 31 | viewer: { 32 | type: GraphQLViewer, 33 | resolve: () => viewer, 34 | }, 35 | }), 36 | 37 | mutateAndGetPayload: ({ id, complete }) => { 38 | const localTodoId = fromGlobalId(id).id; 39 | changeTodoStatus(localTodoId, complete); 40 | return { localTodoId }; 41 | }, 42 | }); 43 | -------------------------------------------------------------------------------- /backend/src/graphql/Todo/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 { GraphQLBoolean, GraphQLList, GraphQLNonNull } from 'graphql'; 14 | import { mutationWithClientMutationId } from 'graphql-relay'; 15 | 16 | import { getTodo, markAllTodos } from '../../../data/Todo'; 17 | import GraphQLTodo from '../'; 18 | import GraphQLViewer, { viewer } from '../../Viewer'; 19 | 20 | export default mutationWithClientMutationId({ 21 | name: 'MarkAllTodos', 22 | inputFields: () => ({ 23 | complete: { type: new GraphQLNonNull(GraphQLBoolean) }, 24 | }), 25 | outputFields: () => ({ 26 | changedTodos: { 27 | type: new GraphQLList(GraphQLTodo), 28 | resolve: ({ changedTodoLocalIds }) => changedTodoLocalIds.map(getTodo), 29 | }, 30 | viewer: { 31 | type: GraphQLViewer, 32 | resolve: () => viewer, 33 | }, 34 | }), 35 | 36 | mutateAndGetPayload: ({ complete }) => { 37 | const changedTodoLocalIds = markAllTodos(complete); 38 | return { changedTodoLocalIds }; 39 | }, 40 | }); 41 | -------------------------------------------------------------------------------- /backend/src/graphql/Todo/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 { GraphQLList, GraphQLString } from 'graphql'; 14 | import { mutationWithClientMutationId, toGlobalId } from 'graphql-relay'; 15 | 16 | import { removeCompletedTodos } from '../../../data/Todo'; 17 | import GraphQLViewer, { viewer } from '../../Viewer'; 18 | 19 | // TODO: Support plural deletes 20 | export default mutationWithClientMutationId({ 21 | name: 'RemoveCompletedTodos', 22 | outputFields: () => ({ 23 | deletedTodoIds: { 24 | type: new GraphQLList(GraphQLString), 25 | resolve: ({ deletedTodoIds }) => deletedTodoIds, 26 | }, 27 | viewer: { 28 | type: GraphQLViewer, 29 | resolve: () => viewer, 30 | }, 31 | }), 32 | 33 | mutateAndGetPayload: () => { 34 | const deletedTodoLocalIds = removeCompletedTodos(); 35 | const deletedTodoIds = deletedTodoLocalIds.map(toGlobalId.bind(null, 'Todo')); 36 | return { deletedTodoIds }; 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /backend/src/graphql/Todo/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 { GraphQLID, GraphQLNonNull } from 'graphql'; 14 | import { fromGlobalId, mutationWithClientMutationId } from 'graphql-relay'; 15 | 16 | import { removeTodo } from '../../../data/Todo'; 17 | import GraphQLViewer, { viewer } from '../../Viewer'; 18 | 19 | export default mutationWithClientMutationId({ 20 | name: 'RemoveTodo', 21 | inputFields: () => ({ 22 | id: { type: new GraphQLNonNull(GraphQLID) }, 23 | }), 24 | outputFields: () => ({ 25 | deletedTodoId: { 26 | type: GraphQLID, 27 | resolve: ({ id }) => id, 28 | }, 29 | viewer: { 30 | type: GraphQLViewer, 31 | resolve: () => viewer, 32 | }, 33 | }), 34 | 35 | mutateAndGetPayload: ({ id }) => { 36 | const localTodoId = fromGlobalId(id).id; 37 | removeTodo(localTodoId); 38 | return { id }; 39 | }, 40 | }); 41 | -------------------------------------------------------------------------------- /backend/src/graphql/Todo/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 { GraphQLID, GraphQLNonNull, GraphQLString } from 'graphql'; 14 | import { fromGlobalId, mutationWithClientMutationId } from 'graphql-relay'; 15 | 16 | import { getTodo, renameTodo } from '../../../data/Todo'; 17 | import GraphQLTodo from '../'; 18 | 19 | export default mutationWithClientMutationId({ 20 | name: 'RenameTodo', 21 | inputFields: () => ({ 22 | id: { type: new GraphQLNonNull(GraphQLID) }, 23 | text: { type: new GraphQLNonNull(GraphQLString) }, 24 | }), 25 | outputFields: () => ({ 26 | todo: { 27 | type: GraphQLTodo, 28 | resolve: ({ localTodoId }) => getTodo(localTodoId), 29 | }, 30 | }), 31 | 32 | mutateAndGetPayload: ({ id, text }) => { 33 | const localTodoId = fromGlobalId(id).id; 34 | renameTodo(localTodoId, text); 35 | return { localTodoId }; 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /backend/src/graphql/Viewer.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 { GraphQLInt, GraphQLObjectType, GraphQLString } from 'graphql'; 14 | import { connectionArgs, connectionFromArray, globalIdField } from 'graphql-relay'; 15 | 16 | import { getTodos } from '../data/Todo'; 17 | import { nodeInterface, registerNodeFetcher } from './node'; 18 | import { GraphQLTodoConnection } from './Todo'; 19 | 20 | const VIEWER_TYPE_NAME = 'Viewer'; 21 | 22 | export const viewer = {}; 23 | 24 | registerNodeFetcher(VIEWER_TYPE_NAME, () => viewer); 25 | 26 | export default new GraphQLObjectType({ 27 | name: VIEWER_TYPE_NAME, 28 | fields: () => ({ 29 | id: globalIdField(), 30 | todos: { 31 | type: GraphQLTodoConnection, 32 | args: { 33 | status: { 34 | type: GraphQLString, 35 | defaultValue: 'any', 36 | }, 37 | ...connectionArgs, 38 | }, 39 | resolve: (obj, { status, ...args }) => connectionFromArray(getTodos(status), args), 40 | }, 41 | totalCount: { 42 | type: GraphQLInt, 43 | resolve: () => getTodos().length, 44 | }, 45 | completedCount: { 46 | type: GraphQLInt, 47 | resolve: () => getTodos('completed').length, 48 | }, 49 | }), 50 | interfaces: () => [nodeInterface], 51 | isTypeOf: obj => (obj === viewer), 52 | }); 53 | -------------------------------------------------------------------------------- /backend/src/graphql/node.js: -------------------------------------------------------------------------------- 1 | import { fromGlobalId, nodeDefinitions } from 'graphql-relay'; 2 | 3 | const nodeFetchers = new Map; 4 | 5 | export function registerNodeFetcher(type, fetcher) { 6 | if (nodeFetchers.has(type)) { 7 | throw new Error(`Node fetcher for type "${type}" have already been registered.`); 8 | } 9 | 10 | nodeFetchers.set(type, fetcher); 11 | } 12 | 13 | const { nodeInterface, nodeField } = nodeDefinitions( 14 | (globalId, info) => { 15 | const { type, id } = fromGlobalId(globalId); 16 | 17 | const fetcher = nodeFetchers.get(type); 18 | if (!fetcher) { 19 | throw new Error(`Node fetcher for type "${type}" is not registered.`); 20 | } 21 | 22 | return fetcher(id, info); 23 | } 24 | ); 25 | 26 | export { nodeInterface, nodeField }; 27 | -------------------------------------------------------------------------------- /backend/src/graphql/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 { GraphQLObjectType, GraphQLSchema } from 'graphql'; 14 | 15 | import { nodeField } from './node'; 16 | import GraphQLAddTodoMutation from './Todo/mutations/AddTodoMutation'; 17 | import GraphQLChangeTodoStatusMutation from './Todo/mutations/ChangeTodoStatusMutation'; 18 | import GraphQLMarkAllTodosMutation from './Todo/mutations/MarkAllTodosMutation'; 19 | import GraphQLRemoveCompletedTodosMutation from './Todo/mutations/RemoveCompletedTodosMutation'; 20 | import GraphQLRemoveTodoMutation from './Todo/mutations/RemoveTodoMutation'; 21 | import GraphQLRenameTodoMutation from './Todo/mutations/RenameTodoMutation'; 22 | import GraphQLViewer, { viewer } from './Viewer'; 23 | 24 | const Query = new GraphQLObjectType({ 25 | name: 'Query', 26 | fields: () => ({ 27 | viewer: { 28 | type: GraphQLViewer, 29 | resolve: () => viewer, 30 | }, 31 | node: nodeField, 32 | }), 33 | }); 34 | 35 | const Mutation = new GraphQLObjectType({ 36 | name: 'Mutation', 37 | fields: { 38 | addTodo: GraphQLAddTodoMutation, 39 | changeTodoStatus: GraphQLChangeTodoStatusMutation, 40 | markAllTodos: GraphQLMarkAllTodosMutation, 41 | removeCompletedTodos: GraphQLRemoveCompletedTodosMutation, 42 | removeTodo: GraphQLRemoveTodoMutation, 43 | renameTodo: GraphQLRenameTodoMutation, 44 | }, 45 | }); 46 | 47 | export default new GraphQLSchema({ 48 | query: Query, 49 | mutation: Mutation, 50 | }); 51 | -------------------------------------------------------------------------------- /backend/src/index.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import express from 'express'; 3 | 4 | import config from '../config.json'; 5 | import errorHandler from './middlewares/errorHandler'; 6 | import logger from './utils/logger'; 7 | import graphql from './middlewares/graphql'; 8 | 9 | const app = express(); 10 | 11 | app.use('/', graphql); 12 | app.use(errorHandler); 13 | 14 | const server = app.listen(config.port, err => { 15 | if (err) { 16 | logger.error(err); 17 | } else { 18 | const { address, port } = server.address(); 19 | logger.info(`Backend is listening at http://${address}:${port}`); 20 | 21 | if (process.send) { 22 | process.send('ready'); 23 | } 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /backend/src/middlewares/errorHandler.js: -------------------------------------------------------------------------------- 1 | import logger from '../utils/logger'; 2 | 3 | export default function errorHandler(err, {}, res, {}) { 4 | logger.error(err); 5 | 6 | if (res.headersSent) { 7 | res.end(); 8 | } else { 9 | res.send(500, 'Internal server error.'); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /backend/src/middlewares/graphql.js: -------------------------------------------------------------------------------- 1 | import graphQLHTTP from 'express-graphql'; 2 | 3 | import logger from '../utils/logger'; 4 | import schema from '../graphql/schema'; 5 | 6 | export default graphQLHTTP({ 7 | schema, 8 | graphiql: process.env.NODE_ENV !== 'production', 9 | pretty: process.env.NODE_ENV !== 'production', 10 | 11 | formatError: error => { 12 | logger.error(error); 13 | return { message: 'Internal server error.' }; 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /backend/src/utils/logger.js: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | 3 | export default new winston.Logger({ 4 | exitOnError: false, 5 | transports: [ 6 | new winston.transports.Console({ 7 | handleExceptions: true, 8 | humanReadableUnhandledException: true, 9 | }), 10 | ], 11 | }); 12 | -------------------------------------------------------------------------------- /frontend/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "passPerPreset": true, 3 | "presets": [ 4 | { 5 | "plugins": [ 6 | "./scripts/babelRelayPlugin" 7 | ] 8 | }, 9 | "react", 10 | "es2015", 11 | "stage-0" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | assets/ 2 | assets.json 3 | lib/ 4 | npm-debug.log 5 | node_modules/ 6 | -------------------------------------------------------------------------------- /frontend/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "graphQLAddress": "http://localhost:8080/graphql", 3 | "port": 8888 4 | } 5 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "devDependencies": { 4 | "autoprefixer": "^6.3.3", 5 | "babel-core": "^6.6.4", 6 | "babel-loader": "^6.2.4", 7 | "babel-preset-react": "^6.5.0", 8 | "babel-relay-plugin": "^0.7.2", 9 | "css-loader": "^0.23.1", 10 | "extract-text-webpack-plugin": "^1.0.1", 11 | "file-loader": "^0.8.5", 12 | "json-loader": "^0.5.4", 13 | "postcss-loader": "^0.8.1", 14 | "postcss-nested": "^1.0.0", 15 | "style-loader": "^0.13.0", 16 | "url-loader": "^0.5.7", 17 | "webpack": "^1.12.14", 18 | "webpack-dev-middleware": "^1.5.1", 19 | "webpack-hot-middleware": "^2.10.0" 20 | }, 21 | "dependencies": { 22 | "classnames": "^2.2.3", 23 | "isomorphic-relay": "^0.5.4", 24 | "isomorphic-relay-router": "^0.6.2", 25 | "react": "^0.14.7", 26 | "react-dom": "^0.14.7", 27 | "react-relay": "^0.7.2", 28 | "react-router": "^2.0.0" 29 | }, 30 | "scripts": { 31 | "build": "NODE_ENV=production npm run build-client && npm run build-server", 32 | "build-dev": "NODE_ENV=development npm run build-server", 33 | "build-client": "NODE_ENV=production webpack --config webpack-client.config.js", 34 | "build-server": "babel src/server -d lib/server && webpack --config webpack-server.config.js", 35 | "postinstall": "npm run build", 36 | "start": "NODE_ENV=production node lib/server", 37 | "start-dev": "node lib/server" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /frontend/scripts/babelRelayPlugin.js: -------------------------------------------------------------------------------- 1 | const getBabelRelayPlugin = require('babel-relay-plugin'); 2 | const schema = require('../../backend/schema.json'); 3 | 4 | module.exports = getBabelRelayPlugin(schema.data); 5 | -------------------------------------------------------------------------------- /frontend/src/app.js: -------------------------------------------------------------------------------- 1 | import history from 'shared/utils/history'; 2 | import IsomorphicRelay from 'isomorphic-relay'; 3 | import IsomorphicRouter from 'isomorphic-relay-router'; 4 | import React from 'react'; 5 | import ReactDOM from 'react-dom'; 6 | 7 | import './styles/app.css'; 8 | 9 | const data = JSON.parse(document.getElementById('preloadedData').textContent); 10 | 11 | IsomorphicRelay.injectPreparedData(data); 12 | 13 | const rootElement = document.getElementById('root'); 14 | 15 | function render() { 16 | const routes = require('./routes').default; 17 | 18 | ReactDOM.render( 19 | , 20 | rootElement 21 | ); 22 | } 23 | 24 | render(); 25 | 26 | if (module.hot) { 27 | module.hot.accept('./routes', () => { 28 | setTimeout(() => { 29 | ReactDOM.unmountComponentAtNode(rootElement); 30 | render(); 31 | }); 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoutes, IndexRoute, Route } from 'react-router'; 3 | import Relay from 'react-relay'; 4 | 5 | import TodoApp from './screens/TodoApp'; 6 | import TodoList from './screens/TodoApp/screens/TodoList'; 7 | 8 | const prepareParams = ({ status }) => ({ 9 | status: ['active', 'completed'].includes(status) ? status : 'any', 10 | }); 11 | 12 | const queries = { 13 | viewer: () => Relay.QL`query { viewer }`, 14 | }; 15 | 16 | export default createRoutes( 17 | 18 | 19 | 20 | 21 | ); 22 | -------------------------------------------------------------------------------- /frontend/src/screens/TodoApp/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 React from 'react'; 14 | import Relay from 'react-relay'; 15 | 16 | import RemoveCompletedTodosMutation from '../mutations/RemoveCompletedTodosMutation'; 17 | import TodoListFooterView from '../views/TodoListFooterView'; 18 | 19 | class TodoListFooter extends React.Component { 20 | static propTypes = { 21 | viewer: React.PropTypes.shape({ 22 | completedCount: React.PropTypes.number.isRequired, 23 | totalCount: React.PropTypes.number.isRequired, 24 | }).isRequired, 25 | }; 26 | 27 | _handleRemoveCompletedTodosClick = () => { 28 | Relay.Store.commitUpdate(new RemoveCompletedTodosMutation({ viewer: this.props.viewer })); 29 | }; 30 | 31 | render() { 32 | const { completedCount, totalCount } = this.props.viewer; 33 | return ( 34 | 39 | ); 40 | } 41 | } 42 | 43 | export default Relay.createContainer(TodoListFooter, { 44 | fragments: { 45 | viewer: () => Relay.QL` 46 | fragment on Viewer { 47 | completedCount, 48 | totalCount, 49 | ${RemoveCompletedTodosMutation.getFragment('viewer')}, 50 | } 51 | `, 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /frontend/src/screens/TodoApp/index.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 Relay from 'react-relay'; 15 | 16 | import AddTodoMutation from './mutations/AddTodoMutation'; 17 | import TodoAppView from './views/TodoAppView'; 18 | import TodoListFooter from './components/TodoListFooter'; 19 | 20 | class TodoApp extends React.Component { 21 | static propTypes = { 22 | children: React.PropTypes.node, 23 | viewer: React.PropTypes.shape({ 24 | totalCount: React.PropTypes.number.isRequired, 25 | }).isRequired, 26 | }; 27 | 28 | _handleTextInputSave = text => { 29 | Relay.Store.commitUpdate( 30 | new AddTodoMutation({ text, viewer: this.props.viewer }) 31 | ); 32 | }; 33 | 34 | render() { 35 | return ( 36 | 37 | {this.props.children} 38 | {this.props.viewer.totalCount > 0 && } 39 | 40 | ); 41 | } 42 | } 43 | 44 | export default Relay.createContainer(TodoApp, { 45 | fragments: { 46 | viewer: () => Relay.QL` 47 | fragment on Viewer { 48 | totalCount, 49 | ${AddTodoMutation.getFragment('viewer')}, 50 | ${TodoListFooter.getFragment('viewer')}, 51 | } 52 | `, 53 | }, 54 | }); 55 | -------------------------------------------------------------------------------- /frontend/src/screens/TodoApp/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 Relay from 'react-relay'; 14 | 15 | export default class AddTodoMutation extends Relay.Mutation { 16 | static fragments = { 17 | viewer: () => Relay.QL` 18 | fragment on Viewer { 19 | id, 20 | totalCount, 21 | } 22 | `, 23 | }; 24 | 25 | getMutation() { 26 | return Relay.QL`mutation{addTodo}`; 27 | } 28 | 29 | getFatQuery() { 30 | return Relay.QL` 31 | fragment on AddTodoPayload @relay(pattern: true) { 32 | todoEdge, 33 | viewer { 34 | todos, 35 | totalCount, 36 | }, 37 | } 38 | `; 39 | } 40 | 41 | getConfigs() { 42 | return [{ 43 | type: 'RANGE_ADD', 44 | parentName: 'viewer', 45 | parentID: this.props.viewer.id, 46 | connectionName: 'todos', 47 | edgeName: 'todoEdge', 48 | rangeBehaviors: { 49 | '': 'append', 50 | 'status(any)': 'append', 51 | 'status(active)': 'append', 52 | 'status(completed)': null, 53 | }, 54 | }]; 55 | } 56 | 57 | getVariables() { 58 | return { 59 | text: this.props.text, 60 | }; 61 | } 62 | 63 | getOptimisticResponse() { 64 | return { 65 | // FIXME: totalCount gets updated optimistically, but this edge does not 66 | // get added until the server responds 67 | todoEdge: { 68 | node: { 69 | complete: false, 70 | text: this.props.text, 71 | }, 72 | }, 73 | viewer: { 74 | id: this.props.viewer.id, 75 | totalCount: this.props.viewer.totalCount + 1, 76 | }, 77 | }; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /frontend/src/screens/TodoApp/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 Relay from 'react-relay'; 14 | 15 | export default class RemoveCompletedTodosMutation extends Relay.Mutation { 16 | static fragments = { 17 | // TODO: Make completedCount, edges, and totalCount optional 18 | viewer: () => Relay.QL` 19 | fragment on Viewer { 20 | completedCount, 21 | id, 22 | todos(status: "completed", first: 2147483647) { 23 | edges { 24 | node { 25 | complete, 26 | id, 27 | }, 28 | }, 29 | }, 30 | totalCount, 31 | } 32 | `, 33 | }; 34 | 35 | getMutation() { 36 | return Relay.QL`mutation{removeCompletedTodos}`; 37 | } 38 | 39 | getFatQuery() { 40 | return Relay.QL` 41 | fragment on RemoveCompletedTodosPayload @relay(pattern: true) { 42 | deletedTodoIds, 43 | viewer { 44 | completedCount, 45 | totalCount, 46 | }, 47 | } 48 | `; 49 | } 50 | 51 | getConfigs() { 52 | return [{ 53 | type: 'NODE_DELETE', 54 | parentName: 'viewer', 55 | parentID: this.props.viewer.id, 56 | connectionName: 'todos', 57 | deletedIDFieldName: 'deletedTodoIds', 58 | }]; 59 | } 60 | 61 | getVariables() { 62 | return {}; 63 | } 64 | 65 | getOptimisticResponse() { 66 | const { todos } = this.props.viewer; 67 | let deletedTodoIds; 68 | if (todos && todos.edges) { 69 | deletedTodoIds = todos.edges.filter(edge => edge.node.complete).map(edge => edge.node.id); 70 | } 71 | 72 | const { completedCount, totalCount } = this.props.viewer; 73 | let newTotalCount; 74 | if (completedCount !== null && totalCount !== null) { 75 | newTotalCount = totalCount - completedCount; 76 | } 77 | 78 | return { 79 | deletedTodoIds, 80 | viewer: { 81 | completedCount: 0, 82 | id: this.props.viewer.id, 83 | totalCount: newTotalCount, 84 | }, 85 | }; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /frontend/src/screens/TodoApp/screens/TodoList/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 React from 'react'; 14 | import Relay from 'react-relay'; 15 | 16 | import ChangeTodoStatusMutation from '../mutations/ChangeTodoStatusMutation'; 17 | import RemoveTodoMutation from '../mutations/RemoveTodoMutation'; 18 | import RenameTodoMutation from '../mutations/RenameTodoMutation'; 19 | import TodoView from '../views/TodoView'; 20 | 21 | class Todo extends React.Component { 22 | static propTypes = { 23 | todo: React.PropTypes.shape({ 24 | complete: React.PropTypes.bool.isRequired, 25 | id: React.PropTypes.string.isRequired, 26 | text: React.PropTypes.string.isRequired, 27 | }).isRequired, 28 | viewer: React.PropTypes.object.isRequired, 29 | }; 30 | state = { 31 | isEditing: false, 32 | }; 33 | 34 | _handleCompleteChange = event => { 35 | Relay.Store.commitUpdate( 36 | new ChangeTodoStatusMutation({ 37 | complete: event.target.checked, 38 | todo: this.props.todo, 39 | viewer: this.props.viewer, 40 | }) 41 | ); 42 | }; 43 | 44 | _handleDestroyClick = () => { 45 | this._removeTodo(); 46 | }; 47 | 48 | _handleLabelDoubleClick = () => { 49 | this._setEditMode(true); 50 | }; 51 | 52 | _handleTextInputCancel = () => { 53 | this._setEditMode(false); 54 | }; 55 | 56 | _handleTextInputDelete = () => { 57 | this._setEditMode(false); 58 | this._removeTodo(); 59 | }; 60 | 61 | _handleTextInputSave = text => { 62 | this._setEditMode(false); 63 | Relay.Store.commitUpdate( 64 | new RenameTodoMutation({ todo: this.props.todo, text }) 65 | ); 66 | }; 67 | 68 | _removeTodo() { 69 | Relay.Store.commitUpdate( 70 | new RemoveTodoMutation({ todo: this.props.todo, viewer: this.props.viewer }) 71 | ); 72 | } 73 | 74 | _setEditMode = shouldEdit => { 75 | this.setState({ isEditing: shouldEdit }); 76 | }; 77 | 78 | render() { 79 | return ( 80 | 90 | ); 91 | } 92 | } 93 | 94 | export default Relay.createContainer(Todo, { 95 | fragments: { 96 | todo: () => Relay.QL` 97 | fragment on Todo { 98 | complete, 99 | id, 100 | text, 101 | ${ChangeTodoStatusMutation.getFragment('todo')}, 102 | ${RemoveTodoMutation.getFragment('todo')}, 103 | ${RenameTodoMutation.getFragment('todo')}, 104 | } 105 | `, 106 | viewer: () => Relay.QL` 107 | fragment on Viewer { 108 | ${ChangeTodoStatusMutation.getFragment('viewer')}, 109 | ${RemoveTodoMutation.getFragment('viewer')}, 110 | } 111 | `, 112 | }, 113 | }); 114 | -------------------------------------------------------------------------------- /frontend/src/screens/TodoApp/screens/TodoList/images/checked.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/screens/TodoApp/screens/TodoList/images/unchecked.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /frontend/src/screens/TodoApp/screens/TodoList/index.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 Relay from 'react-relay'; 15 | 16 | import MarkAllTodosMutation from './mutations/MarkAllTodosMutation'; 17 | import Todo from './components/Todo'; 18 | import TodoListView from './views/TodoListView'; 19 | 20 | class TodoList extends React.Component { 21 | static propTypes = { 22 | viewer: React.PropTypes.shape({ 23 | completedCount: React.PropTypes.number.isRequired, 24 | todos: React.PropTypes.shape({ 25 | edges: React.PropTypes.arrayOf( 26 | React.PropTypes.shape({ 27 | node: React.PropTypes.shape({ 28 | id: React.PropTypes.string.isRequired, 29 | }).isRequired, 30 | }).isRequired 31 | ).isRequired, 32 | }).isRequired, 33 | totalCount: React.PropTypes.number.isRequired, 34 | }), 35 | }; 36 | 37 | _handleMarkAllChange = event => { 38 | Relay.Store.commitUpdate( 39 | new MarkAllTodosMutation({ 40 | complete: event.target.checked, 41 | todos: this.props.viewer.todos, 42 | viewer: this.props.viewer, 43 | }) 44 | ); 45 | }; 46 | 47 | render() { 48 | const { viewer } = this.props; 49 | return ( 50 | 55 | {viewer.todos.edges.map(edge => edge.node).map(todo => 56 | 57 | )} 58 | 59 | ); 60 | } 61 | } 62 | 63 | export default Relay.createContainer(TodoList, { 64 | initialVariables: { 65 | status: null, 66 | }, 67 | fragments: { 68 | viewer: () => Relay.QL` 69 | fragment on Viewer { 70 | completedCount, 71 | todos(status: $status, first: 2147483647) { 72 | edges { 73 | node { 74 | id, 75 | ${Todo.getFragment('todo')}, 76 | }, 77 | }, 78 | ${MarkAllTodosMutation.getFragment('todos')}, 79 | }, 80 | totalCount, 81 | ${MarkAllTodosMutation.getFragment('viewer')}, 82 | ${Todo.getFragment('viewer')}, 83 | } 84 | `, 85 | }, 86 | }); 87 | -------------------------------------------------------------------------------- /frontend/src/screens/TodoApp/screens/TodoList/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 Relay from 'react-relay'; 14 | 15 | export default class ChangeTodoStatusMutation extends Relay.Mutation { 16 | static fragments = { 17 | todo: () => Relay.QL` 18 | fragment on Todo { 19 | id, 20 | } 21 | `, 22 | // TODO: Mark completedCount optional 23 | viewer: () => Relay.QL` 24 | fragment on Viewer { 25 | id, 26 | completedCount, 27 | } 28 | `, 29 | }; 30 | 31 | getMutation() { 32 | return Relay.QL`mutation{changeTodoStatus}`; 33 | } 34 | 35 | getFatQuery() { 36 | return Relay.QL` 37 | fragment on ChangeTodoStatusPayload @relay(pattern: true) { 38 | todo { 39 | complete, 40 | }, 41 | viewer { 42 | completedCount, 43 | todos, 44 | }, 45 | } 46 | `; 47 | } 48 | 49 | getConfigs() { 50 | return [{ 51 | type: 'FIELDS_CHANGE', 52 | fieldIDs: { 53 | todo: this.props.todo.id, 54 | viewer: this.props.viewer.id, 55 | }, 56 | }]; 57 | } 58 | 59 | getVariables() { 60 | return { 61 | complete: this.props.complete, 62 | id: this.props.todo.id, 63 | }; 64 | } 65 | 66 | getOptimisticResponse() { 67 | const viewerPayload = { id: this.props.viewer.id }; 68 | if (this.props.viewer.completedCount !== null) { 69 | viewerPayload.completedCount = this.props.complete ? 70 | this.props.viewer.completedCount + 1 : 71 | this.props.viewer.completedCount - 1; 72 | } 73 | 74 | return { 75 | todo: { 76 | complete: this.props.complete, 77 | id: this.props.todo.id, 78 | }, 79 | viewer: viewerPayload, 80 | }; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /frontend/src/screens/TodoApp/screens/TodoList/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 Relay from 'react-relay'; 14 | 15 | export default class MarkAllTodosMutation extends Relay.Mutation { 16 | static fragments = { 17 | // TODO: Mark edges and totalCount optional 18 | todos: () => Relay.QL` 19 | fragment on TodoConnection { 20 | edges { 21 | node { 22 | complete, 23 | id, 24 | }, 25 | }, 26 | } 27 | `, 28 | viewer: () => Relay.QL` 29 | fragment on Viewer { 30 | id, 31 | totalCount, 32 | } 33 | `, 34 | }; 35 | 36 | getMutation() { 37 | return Relay.QL`mutation{markAllTodos}`; 38 | } 39 | 40 | getFatQuery() { 41 | return Relay.QL` 42 | fragment on MarkAllTodosPayload @relay(pattern: true) { 43 | viewer { 44 | completedCount, 45 | todos, 46 | }, 47 | } 48 | `; 49 | } 50 | 51 | getConfigs() { 52 | return [{ 53 | type: 'FIELDS_CHANGE', 54 | fieldIDs: { 55 | viewer: this.props.viewer.id, 56 | }, 57 | }]; 58 | } 59 | 60 | getVariables() { 61 | return { 62 | complete: this.props.complete, 63 | }; 64 | } 65 | 66 | getOptimisticResponse() { 67 | const viewerPayload = { id: this.props.viewer.id }; 68 | if (this.props.todos && this.props.todos.edges) { 69 | viewerPayload.todos = { 70 | edges: this.props.todos.edges 71 | .filter(edge => edge.node.complete !== this.props.complete) 72 | .map(edge => ({ 73 | node: { 74 | complete: this.props.complete, 75 | id: edge.node.id, 76 | }, 77 | })), 78 | }; 79 | } 80 | 81 | if (this.props.viewer.totalCount !== null) { 82 | viewerPayload.completedCount = this.props.complete ? 83 | this.props.viewer.totalCount : 84 | 0; 85 | } 86 | 87 | return { 88 | viewer: viewerPayload, 89 | }; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /frontend/src/screens/TodoApp/screens/TodoList/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 Relay from 'react-relay'; 14 | 15 | export default class RemoveTodoMutation extends Relay.Mutation { 16 | static fragments = { 17 | // TODO: Mark complete as optional 18 | todo: () => Relay.QL` 19 | fragment on Todo { 20 | complete, 21 | id, 22 | } 23 | `, 24 | // TODO: Mark completedCount and totalCount as optional 25 | viewer: () => Relay.QL` 26 | fragment on Viewer { 27 | completedCount, 28 | id, 29 | totalCount, 30 | } 31 | `, 32 | }; 33 | 34 | getMutation() { 35 | return Relay.QL`mutation{removeTodo}`; 36 | } 37 | 38 | getFatQuery() { 39 | return Relay.QL` 40 | fragment on RemoveTodoPayload @relay(pattern: true) { 41 | deletedTodoId, 42 | viewer { 43 | completedCount, 44 | totalCount, 45 | }, 46 | } 47 | `; 48 | } 49 | 50 | getConfigs() { 51 | return [{ 52 | type: 'NODE_DELETE', 53 | parentName: 'viewer', 54 | parentID: this.props.viewer.id, 55 | connectionName: 'todos', 56 | deletedIDFieldName: 'deletedTodoId', 57 | }]; 58 | } 59 | 60 | getVariables() { 61 | return { 62 | id: this.props.todo.id, 63 | }; 64 | } 65 | 66 | getOptimisticResponse() { 67 | const viewerPayload = { id: this.props.viewer.id }; 68 | if (this.props.viewer.completedCount !== null) { 69 | viewerPayload.completedCount = this.props.todo.complete === true ? 70 | this.props.viewer.completedCount - 1 : 71 | this.props.viewer.completedCount; 72 | } 73 | 74 | if (this.props.viewer.totalCount !== null) { 75 | viewerPayload.totalCount = this.props.viewer.totalCount - 1; 76 | } 77 | 78 | return { 79 | deletedTodoId: this.props.todo.id, 80 | viewer: viewerPayload, 81 | }; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /frontend/src/screens/TodoApp/screens/TodoList/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 Relay from 'react-relay'; 14 | 15 | export default class RenameTodoMutation extends Relay.Mutation { 16 | static fragments = { 17 | todo: () => Relay.QL` 18 | fragment on Todo { 19 | id, 20 | } 21 | `, 22 | }; 23 | 24 | getMutation() { 25 | return Relay.QL`mutation{renameTodo}`; 26 | } 27 | 28 | getFatQuery() { 29 | return Relay.QL` 30 | fragment on RenameTodoPayload @relay(pattern: true) { 31 | todo { 32 | text, 33 | } 34 | } 35 | `; 36 | } 37 | 38 | getConfigs() { 39 | return [{ 40 | type: 'FIELDS_CHANGE', 41 | fieldIDs: { 42 | todo: this.props.todo.id, 43 | }, 44 | }]; 45 | } 46 | 47 | getVariables() { 48 | return { 49 | id: this.props.todo.id, 50 | text: this.props.text, 51 | }; 52 | } 53 | 54 | getOptimisticResponse() { 55 | return { 56 | todo: { 57 | id: this.props.todo.id, 58 | text: this.props.text, 59 | }, 60 | }; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /frontend/src/screens/TodoApp/screens/TodoList/styles/Todo.css: -------------------------------------------------------------------------------- 1 | .todo { 2 | position: relative; 3 | font-size: 24px; 4 | border-bottom: 1px solid #ededed; 5 | 6 | &:last-child { 7 | border-bottom: none; 8 | 9 | &.editing { 10 | margin-bottom: -1px; 11 | } 12 | } 13 | 14 | &.editing { 15 | border-bottom: none; 16 | padding: 0; 17 | } 18 | } 19 | 20 | .view { 21 | .editing & { 22 | display: none; 23 | } 24 | } 25 | 26 | .toggle { 27 | text-align: center; 28 | width: 40px; 29 | /* auto, since non-WebKit browsers doesn't support input styling */ 30 | height: auto; 31 | position: absolute; 32 | top: 0; 33 | bottom: 0; 34 | margin: auto 0; 35 | border: none; /* Mobile Safari */ 36 | -webkit-appearance: none; 37 | appearance: none; 38 | 39 | &::after { 40 | content: url("../images/unchecked.svg"); 41 | } 42 | 43 | &:checked::after { 44 | content: url("../images/checked.svg"); 45 | } 46 | 47 | .todo:hover & { 48 | display: block; 49 | } 50 | 51 | /* 52 | Hack to remove background from Mobile Safari. 53 | Can't use it globally since it destroys checkboxes in Firefox 54 | */ 55 | @media screen and(-webkit-min-device-pixel-ratio:0) { 56 | background: none; 57 | height: 40px; 58 | } 59 | } 60 | 61 | .label { 62 | white-space: pre-line; 63 | word-break: break-all; 64 | padding: 15px 60px 15px 15px; 65 | margin-left: 45px; 66 | display: block; 67 | line-height: 1.2; 68 | transition: color 0.4s; 69 | 70 | .completed & { 71 | color: #d9d9d9; 72 | text-decoration: line-through; 73 | } 74 | } 75 | 76 | .destroy { 77 | display: none; 78 | position: absolute; 79 | top: 0; 80 | right: 10px; 81 | bottom: 0; 82 | width: 40px; 83 | height: 40px; 84 | margin: auto 0; 85 | font-size: 30px; 86 | color: #cc9a9a; 87 | margin-bottom: 11px; 88 | transition: color 0.2s ease-out; 89 | 90 | &:hover { 91 | color: #af5b5e; 92 | } 93 | 94 | &::after { 95 | content: '×'; 96 | } 97 | 98 | .todo:hover & { 99 | display: block; 100 | } 101 | } 102 | 103 | .edit { 104 | composes: edit from "shared/styles/edit.css"; 105 | display: none; 106 | 107 | .editing & { 108 | display: block; 109 | width: 506px; 110 | padding: 13px 17px 12px 17px; 111 | margin: 0 0 0 43px; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /frontend/src/screens/TodoApp/screens/TodoList/styles/TodoList.css: -------------------------------------------------------------------------------- 1 | .main { 2 | position: relative; 3 | z-index: 2; 4 | border-top: 1px solid #e6e6e6; 5 | } 6 | 7 | label[for='toggle-all'] { 8 | display: none; 9 | } 10 | 11 | .toggleAll { 12 | position: absolute; 13 | top: -55px; 14 | left: -12px; 15 | width: 60px; 16 | height: 34px; 17 | text-align: center; 18 | border: none; /* Mobile Safari */ 19 | 20 | &::before { 21 | content: '❯'; 22 | font-size: 22px; 23 | color: #e6e6e6; 24 | padding: 10px 27px 10px 27px; 25 | 26 | &:checked { 27 | color: #737373; 28 | } 29 | } 30 | 31 | /* 32 | Hack to remove background from Mobile Safari. 33 | Can't use it globally since it destroys checkboxes in Firefox 34 | */ 35 | @media screen and(-webkit-min-device-pixel-ratio:0) { 36 | background: none; 37 | transform: rotate(90deg); 38 | appearance: none; 39 | } 40 | } 41 | 42 | .todoList { 43 | margin: 0; 44 | padding: 0; 45 | list-style: none; 46 | } 47 | -------------------------------------------------------------------------------- /frontend/src/screens/TodoApp/screens/TodoList/views/TodoListView.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 | 15 | import styles from '../styles/TodoList.css'; 16 | 17 | const TodoListView = ({ children, completedCount, onMarkAllChange, totalCount }) => ( 18 |
19 | 25 | 26 |
    {children}
27 |
28 | ); 29 | 30 | TodoListView.propTypes = { 31 | children: React.PropTypes.node, 32 | completedCount: React.PropTypes.number.isRequired, 33 | onMarkAllChange: React.PropTypes.func.isRequired, 34 | totalCount: React.PropTypes.number.isRequired, 35 | }; 36 | 37 | export default TodoListView; 38 | -------------------------------------------------------------------------------- /frontend/src/screens/TodoApp/screens/TodoList/views/TodoView.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 classnames from 'classnames'; 14 | import React from 'react'; 15 | 16 | import styles from '../styles/Todo.css'; 17 | import TodoTextInput from 'shared/components/TodoTextInput'; 18 | 19 | function TodoView({ 20 | isEditing, 21 | onCompleteChange, 22 | onDestroyClick, 23 | onLabelDoubleClick, 24 | onTextInputCancel, 25 | onTextInputDelete, 26 | onTextInputSave, 27 | todo, 28 | }) { 29 | const renderTextInput = () => ( 30 | 38 | ); 39 | return ( 40 |
  • 47 |
    48 | 54 | {todo.text} 55 |
    57 | {isEditing && renderTextInput()} 58 |
  • 59 | ); 60 | } 61 | 62 | TodoView.propTypes = { 63 | todo: React.PropTypes.shape({ 64 | complete: React.PropTypes.bool.isRequired, 65 | text: React.PropTypes.string.isRequired, 66 | }).isRequired, 67 | isEditing: React.PropTypes.bool.isRequired, 68 | onCompleteChange: React.PropTypes.func.isRequired, 69 | onDestroyClick: React.PropTypes.func.isRequired, 70 | onLabelDoubleClick: React.PropTypes.func.isRequired, 71 | onTextInputCancel: React.PropTypes.func.isRequired, 72 | onTextInputDelete: React.PropTypes.func.isRequired, 73 | onTextInputSave: React.PropTypes.func.isRequired, 74 | }; 75 | 76 | export default TodoView; 77 | -------------------------------------------------------------------------------- /frontend/src/screens/TodoApp/shared/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 ENTER_KEY_CODE = 13; 17 | const ESC_KEY_CODE = 27; 18 | 19 | export default class TodoTextInput extends React.Component { 20 | static defaultProps = { 21 | commitOnBlur: false, 22 | onCancel: () => {}, 23 | onDelete: () => {}, 24 | }; 25 | static propTypes = { 26 | className: React.PropTypes.string, 27 | commitOnBlur: React.PropTypes.bool.isRequired, 28 | initialValue: React.PropTypes.string, 29 | onCancel: React.PropTypes.func, 30 | onDelete: React.PropTypes.func, 31 | onSave: React.PropTypes.func.isRequired, 32 | placeholder: React.PropTypes.string, 33 | }; 34 | state = { 35 | isEditing: false, 36 | text: this.props.initialValue || '', 37 | }; 38 | 39 | componentDidMount() { 40 | ReactDOM.findDOMNode(this).focus(); 41 | } 42 | 43 | _commitChanges = () => { 44 | const newText = this.state.text.trim(); 45 | if (newText === '') { 46 | this.props.onDelete(); 47 | } else if (newText === this.props.initialValue) { 48 | this.props.onCancel(); 49 | } else if (newText !== '') { 50 | this.props.onSave(newText); 51 | this.setState({ text: '' }); 52 | } 53 | }; 54 | 55 | _handleBlur = () => { 56 | if (this.props.commitOnBlur) { 57 | this._commitChanges(); 58 | } 59 | }; 60 | 61 | _handleChange = event => { 62 | this.setState({ text: event.target.value }); 63 | }; 64 | 65 | _handleKeyDown = event => { 66 | if (event.keyCode === ESC_KEY_CODE) { 67 | this.props.onCancel(); 68 | } else if (event.keyCode === ENTER_KEY_CODE) { 69 | this._commitChanges(); 70 | } 71 | }; 72 | 73 | render() { 74 | return ( 75 | 83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /frontend/src/screens/TodoApp/shared/styles/edit.css: -------------------------------------------------------------------------------- 1 | .edit { 2 | position: relative; 3 | margin: 0; 4 | width: 100%; 5 | font-size: 24px; 6 | font-family: inherit; 7 | font-weight: inherit; 8 | line-height: 1.4em; 9 | outline: none; 10 | color: inherit; 11 | padding: 6px; 12 | border: 1px solid #999; 13 | box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); 14 | box-sizing: border-box; 15 | -webkit-font-smoothing: antialiased; 16 | -moz-osx-font-smoothing: grayscale; 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/screens/TodoApp/styles/TodoApp.css: -------------------------------------------------------------------------------- 1 | .todoapp { 2 | background: #fff; 3 | margin: 130px 0 40px 0; 4 | position: relative; 5 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 6 | 0 25px 50px 0 rgba(0, 0, 0, 0.1); 7 | 8 | input::placeholder { 9 | font-style: italic; 10 | font-weight: 300; 11 | color: #e6e6e6; 12 | } 13 | 14 | h1 { 15 | position: absolute; 16 | top: -155px; 17 | width: 100%; 18 | font-size: 100px; 19 | font-weight: 100; 20 | text-align: center; 21 | color: rgba(175, 47, 47, 0.15); 22 | -webkit-text-rendering: optimizeLegibility; 23 | -moz-text-rendering: optimizeLegibility; 24 | text-rendering: optimizeLegibility; 25 | } 26 | } 27 | 28 | .newTodo { 29 | composes: edit from "shared/styles/edit.css"; 30 | padding: 16px 16px 16px 60px; 31 | border: none; 32 | background: rgba(0, 0, 0, 0.003); 33 | box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); 34 | } 35 | 36 | .info { 37 | margin: 65px auto 0; 38 | color: #bfbfbf; 39 | font-size: 10px; 40 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); 41 | text-align: center; 42 | 43 | p { 44 | line-height: 1; 45 | } 46 | 47 | a { 48 | color: inherit; 49 | text-decoration: none; 50 | font-weight: 400; 51 | 52 | &:hover { 53 | text-decoration: underline; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /frontend/src/screens/TodoApp/styles/TodoListFooter.css: -------------------------------------------------------------------------------- 1 | .footer { 2 | color: #777; 3 | padding: 10px 15px; 4 | height: 20px; 5 | text-align: center; 6 | border-top: 1px solid #e6e6e6; 7 | 8 | &::before { 9 | content: ''; 10 | position: absolute; 11 | right: 0; 12 | bottom: 0; 13 | left: 0; 14 | height: 50px; 15 | overflow: hidden; 16 | box-shadow: 17 | 0 1px 1px rgba(0, 0, 0, 0.2), 18 | 0 8px 0 -3px #f6f6f6, 19 | 0 9px 1px -3px rgba(0, 0, 0, 0.2), 20 | 0 16px 0 -6px #f6f6f6, 21 | 0 17px 2px -6px rgba(0, 0, 0, 0.2); 22 | } 23 | 24 | @media(max-width: 430px) { 25 | height: 50px; 26 | } 27 | } 28 | 29 | .todoCount { 30 | float: left; 31 | text-align: left; 32 | 33 | strong { 34 | font-weight: 300; 35 | } 36 | } 37 | 38 | .filters { 39 | margin: 0; 40 | padding: 0; 41 | list-style: none; 42 | position: absolute; 43 | right: 0; 44 | left: 0; 45 | 46 | li { 47 | display: inline; 48 | 49 | a { 50 | color: inherit; 51 | margin: 3px; 52 | padding: 3px 7px; 53 | text-decoration: none; 54 | border: 1px solid transparent; 55 | border-radius: 3px; 56 | 57 | &:hover { 58 | border-color: rgba(175, 47, 47, 0.1); 59 | } 60 | 61 | &.selected { 62 | border-color: rgba(175, 47, 47, 0.2); 63 | } 64 | } 65 | } 66 | 67 | @media(max-width: 430px) { 68 | bottom: 10px; 69 | } 70 | } 71 | 72 | .clearCompleted { 73 | &, 74 | &:active { 75 | float: right; 76 | position: relative; 77 | line-height: 20px; 78 | text-decoration: none; 79 | cursor: pointer; 80 | } 81 | 82 | &:hover { 83 | text-decoration: underline; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /frontend/src/screens/TodoApp/views/TodoAppView.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 | 15 | import styles from '../styles/TodoApp.css'; 16 | import TodoTextInput from 'shared/components/TodoTextInput'; 17 | 18 | const TodoAppView = ({ children, onTextInputSave }) => ( 19 |
    20 |
    21 |
    22 |

    todos

    23 | 29 |
    30 | {children} 31 |
    32 | 42 |
    43 | ); 44 | 45 | TodoAppView.propTypes = { 46 | children: React.PropTypes.node, 47 | onTextInputSave: React.PropTypes.func.isRequired, 48 | }; 49 | 50 | export default TodoAppView; 51 | -------------------------------------------------------------------------------- /frontend/src/screens/TodoApp/views/TodoListFooterView.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 { IndexLink, Link } from 'react-router'; 15 | 16 | import styles from '../styles/TodoListFooter.css'; 17 | 18 | const TodoListFooterView = ({ completedCount, onRemoveCompletedTodosClick, remainingTodos }) => ( 19 |
    20 | 21 | {remainingTodos} item{remainingTodos === 1 ? '' : 's'} left 22 | 23 |
      24 |
    • 25 | All 26 |
    • 27 |
    • 28 | Active 29 |
    • 30 |
    • 31 | Completed 32 |
    • 33 |
    34 | {completedCount > 0 && 35 | 38 | } 39 |
    40 | ); 41 | 42 | TodoListFooterView.propTypes = { 43 | completedCount: React.PropTypes.number.isRequired, 44 | onRemoveCompletedTodosClick: React.PropTypes.func.isRequired, 45 | remainingTodos: React.PropTypes.number.isRequired, 46 | }; 47 | 48 | export default TodoListFooterView; 49 | -------------------------------------------------------------------------------- /frontend/src/server/components/Page.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Page = ({ cssUri, data, jsUri, markup }) => ( 4 | 5 | 6 | 7 | Isomorphic Relay • TodoMVC 8 | 9 | 10 | {cssUri && } 11 | 12 | 13 |
    14 | 19 | 20 | 21 | 22 | ); 23 | 24 | Page.propTypes = { 25 | cssUri: React.PropTypes.string, 26 | data: React.PropTypes.array, 27 | jsUri: React.PropTypes.string.isRequired, 28 | markup: React.PropTypes.string, 29 | }; 30 | 31 | export default Page; 32 | -------------------------------------------------------------------------------- /frontend/src/server/index.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import express from 'express'; 3 | import path from 'path'; 4 | 5 | import config from '../../config.json'; 6 | import errorHandler from './middlewares/errorHandler'; 7 | import logger from './utils/logger'; 8 | import page from './middlewares/page'; 9 | 10 | const app = express(); 11 | 12 | if (process.env.NODE_ENV === 'production') { 13 | app.use('/assets', express.static(path.resolve(__dirname, '..', '..', 'assets'))); 14 | } else { 15 | app.use(require('./middlewares/webpackDev').default); 16 | app.use(require('./middlewares/webpackHot').default); 17 | } 18 | 19 | app.get('*', page); 20 | app.use(errorHandler); 21 | 22 | const server = app.listen(config.port, err => { 23 | if (err) { 24 | logger.error(err); 25 | } else { 26 | const { address, port } = server.address(); 27 | logger.info(`Frontend is listening at http://${address}:${port}`); 28 | 29 | if (process.send) { 30 | process.send('ready'); 31 | } 32 | } 33 | }); 34 | -------------------------------------------------------------------------------- /frontend/src/server/middlewares/errorHandler.js: -------------------------------------------------------------------------------- 1 | import logger from '../utils/logger'; 2 | 3 | export default function errorHandler(err, {}, res, {}) { 4 | logger.error(err); 5 | 6 | if (res.headersSent) { 7 | res.end(); 8 | } else { 9 | res.send(500, 'Internal server error.'); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/server/middlewares/page.js: -------------------------------------------------------------------------------- 1 | import IsomorphicRelayRouter from 'isomorphic-relay-router'; 2 | import React from 'react'; 3 | import ReactDOMServer from 'react-dom/server'; 4 | import Relay from 'react-relay'; 5 | import { match } from 'react-router'; 6 | 7 | import config from '../../../config.json'; 8 | import Page from '../components/Page'; 9 | import routes from '../../routes'; 10 | 11 | Relay.injectNetworkLayer(new Relay.DefaultNetworkLayer(config.graphQLAddress)); 12 | 13 | export default (req, res, next) => { 14 | match({ routes, location: req.url }, (error, redirectLocation, renderProps) => { 15 | function render({ data, props }) { 16 | let cssUri; 17 | let jsUri; 18 | if (process.env.NODE_ENV === 'production') { 19 | const assets = require('../../../assets.json'); 20 | cssUri = `/assets/${assets.app.find(path => path.endsWith('.css'))}`; 21 | jsUri = `/assets/${assets.app.find(path => path.endsWith('.js'))}`; 22 | } else { 23 | cssUri = null; 24 | jsUri = '/assets/app.js'; 25 | } 26 | 27 | const markup = ReactDOMServer.renderToString( 28 | 29 | ); 30 | res.send(`\n${ReactDOMServer.renderToStaticMarkup( 31 | 37 | )}`); 38 | } 39 | 40 | if (error) { 41 | next(error); 42 | } else if (redirectLocation) { 43 | res.redirect(302, redirectLocation.pathname + redirectLocation.search); 44 | } else if (renderProps) { 45 | IsomorphicRelayRouter.prepareData(renderProps).then(render, next); 46 | } else { 47 | res.status(404).send('Not Found'); 48 | } 49 | }); 50 | }; 51 | -------------------------------------------------------------------------------- /frontend/src/server/middlewares/webpackDev.js: -------------------------------------------------------------------------------- 1 | import webpackDevMiddleware from 'webpack-dev-middleware'; 2 | 3 | import webpackCompiler from '../utils/webpackCompiler'; 4 | import webpackConfig from '../../../webpack-client.config'; 5 | 6 | export default webpackDevMiddleware( 7 | webpackCompiler, 8 | { noInfo: true, publicPath: webpackConfig.output.publicPath } 9 | ); 10 | -------------------------------------------------------------------------------- /frontend/src/server/middlewares/webpackHot.js: -------------------------------------------------------------------------------- 1 | import webpackHotMiddleware from 'webpack-hot-middleware'; 2 | 3 | import webpackCompiler from '../utils/webpackCompiler'; 4 | 5 | export default webpackHotMiddleware(webpackCompiler); 6 | -------------------------------------------------------------------------------- /frontend/src/server/utils/logger.js: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | 3 | export default new winston.Logger({ 4 | exitOnError: false, 5 | transports: [ 6 | new winston.transports.Console({ 7 | handleExceptions: true, 8 | humanReadableUnhandledException: true, 9 | }), 10 | ], 11 | }); 12 | -------------------------------------------------------------------------------- /frontend/src/server/utils/webpackCompiler.js: -------------------------------------------------------------------------------- 1 | import webpack from 'webpack'; 2 | 3 | import webpackConfig from '../../../webpack-client.config'; 4 | 5 | export default webpack(webpackConfig); 6 | -------------------------------------------------------------------------------- /frontend/src/shared/utils/history.js: -------------------------------------------------------------------------------- 1 | import { browserHistory } from 'react-router'; 2 | 3 | export default browserHistory; 4 | -------------------------------------------------------------------------------- /frontend/src/styles/app.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 | appearance: none; 18 | -webkit-font-smoothing: antialiased; 19 | -moz-osx-font-smoothing: grayscale; 20 | } 21 | 22 | body { 23 | font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; 24 | line-height: 1.4em; 25 | background: #f5f5f5; 26 | color: #4d4d4d; 27 | min-width: 230px; 28 | max-width: 550px; 29 | margin: 0 auto; 30 | -webkit-font-smoothing: antialiased; 31 | -moz-osx-font-smoothing: grayscale; 32 | font-weight: 300; 33 | } 34 | 35 | button, 36 | input[type="checkbox"] { 37 | outline: none; 38 | } 39 | -------------------------------------------------------------------------------- /frontend/webpack-client.config.js: -------------------------------------------------------------------------------- 1 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const webpack = require('webpack'); 5 | 6 | const development = process.env.NODE_ENV !== 'production'; 7 | 8 | module.exports = { 9 | context: path.join(__dirname, 'src'), 10 | entry: { 11 | app: ['babel-polyfill', './app'].concat(development ? ['webpack-hot-middleware/client'] : []), 12 | }, 13 | module: { 14 | loaders: [ 15 | { test: /\.js$/, exclude: /node_modules/, loader: 'babel' }, 16 | { test: /\.json$/, loader: 'json' }, 17 | { 18 | test: /\.css$/, 19 | loader: ExtractTextPlugin.extract( 20 | 'style', 21 | ['css?modules&importLoaders=1&localIdentName=[local]-[hash:base64]', 'postcss'] 22 | ), 23 | }, 24 | { test: /\.eot$/, loader: 'url?limit=8192&mimetype=application/vnd.ms-fontobject' }, 25 | { test: /\.jpg$/, loader: 'url?limit=8192&mimetype=image/jpeg' }, 26 | { test: /\.png$/, loader: 'url?limit=8192&mimetype=image/png' }, 27 | { test: /\.svg$/, loader: 'url?limit=8192&mimetype=image/svg+xml' }, 28 | { test: /\.ttf$/, loader: 'url?limit=8192&mimetype=application/font-sfnt' }, 29 | { test: /\.woff$/, loader: 'url?limit=8192&mimetype=application/font-woff' }, 30 | ], 31 | }, 32 | output: { 33 | filename: process.env.NODE_ENV === 'production' ? '[name].[chunkhash].js' : '[name].js', 34 | path: path.join(__dirname, 'assets'), 35 | publicPath: '/assets/', 36 | }, 37 | plugins: [ 38 | new ExtractTextPlugin( 39 | '[name].[contenthash].css', 40 | { 41 | allChunks: true, 42 | disable: development, 43 | } 44 | ), 45 | new webpack.optimize.OccurrenceOrderPlugin(), 46 | ].concat(development ? [ 47 | new webpack.HotModuleReplacementPlugin(), 48 | ] : [ 49 | new webpack.optimize.UglifyJsPlugin(), 50 | function writeAssetList() { 51 | this.plugin('done', stats => { 52 | fs.writeFileSync( 53 | path.join(__dirname, 'assets.json'), 54 | JSON.stringify(stats.toJson().assetsByChunkName, null, ' ') 55 | ); 56 | }); 57 | }, 58 | ]), 59 | postcss: () => [ 60 | require('autoprefixer'), 61 | require('postcss-nested'), 62 | ], 63 | resolve: { 64 | alias: { 65 | 'shared/components': 'components', 66 | 'shared/styles': 'styles', 67 | 'shared/utils': 'utils', 68 | }, 69 | modulesDirectories: ['shared', 'node_modules'], 70 | }, 71 | }; 72 | -------------------------------------------------------------------------------- /frontend/webpack-server.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | context: path.join(__dirname, 'src'), 5 | entry: './routes', 6 | externals: [ 7 | (context, request, callback) => { 8 | callback(null, !/^((|.|..|shared)\/|!|-!)/.test(request)); 9 | }, 10 | ], 11 | module: { 12 | loaders: [ 13 | { 14 | test: /\.js$/, 15 | loader: 'babel', 16 | exclude: /node_modules/, 17 | }, 18 | { 19 | test: /\.json$/, 20 | loader: 'json', 21 | }, 22 | { 23 | test: /\.css$/, 24 | loaders: 25 | ['css/locals?modules&importLoaders=1&localIdentName=[local]-[hash:base64]', 'postcss'], 26 | }, 27 | ], 28 | }, 29 | output: { 30 | filename: 'routes.js', 31 | libraryTarget: 'commonjs2', 32 | path: path.join(__dirname, 'lib'), 33 | publicPath: '/assets/', 34 | }, 35 | postcss: () => [ 36 | require('postcss-nested'), 37 | ], 38 | resolve: { 39 | alias: { 40 | 'shared/components': 'components', 41 | 'shared/styles': 'styles', 42 | 'shared/utils': 'utils', 43 | }, 44 | modulesDirectories: ['shared', 'node_modules'], 45 | }, 46 | target: 'node', 47 | }; 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "dependencies": { 4 | "express": "^4.13.4", 5 | "winston": "^2.2.0" 6 | }, 7 | "devDependencies": { 8 | "babel-cli": "^6.6.4", 9 | "babel-eslint": "^5.0.0", 10 | "babel-polyfill": "^6.6.1", 11 | "babel-preset-es2015": "^6.6.0", 12 | "babel-preset-stage-0": "^6.5.0", 13 | "eslint": "~2.2.0", 14 | "eslint-config-airbnb": "^6.0.2", 15 | "eslint-plugin-react": "^4.2.1" 16 | }, 17 | "engines": { 18 | "node": "^4.2.4", 19 | "npm": "^3.7.5" 20 | }, 21 | "scripts": { 22 | "build-backend": "cd backend && npm run build", 23 | "build-frontend": "cd frontend && npm run build", 24 | "build-frontend-dev": "cd frontend && npm run build-dev", 25 | "build-frontend-client": "cd frontend && npm run build-client", 26 | "build-frontend-server": "cd frontend && NODE_ENV=production npm run build-server", 27 | "build-proxy": "cd proxy && npm run build", 28 | "lint": "eslint backend/scripts backend/src frontend/scripts frontend/src proxy/src", 29 | "postinstall": "cd backend && npm install && cd ../frontend && npm install && cd ../proxy && npm install", 30 | "start": "NODE_ENV=production node server", 31 | "start-dev": "NODE_ENV=development node server", 32 | "update-schema": "cd backend && npm run update-schema" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /proxy/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "stage-0" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /proxy/.gitignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | npm-debug.log 3 | node_modules/ -------------------------------------------------------------------------------- /proxy/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "backendAddress": "http://localhost:4444", 3 | "frontendAddress": "http://localhost:8888", 4 | "graphQLPath": "/graphql", 5 | "port": 8080 6 | } 7 | -------------------------------------------------------------------------------- /proxy/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "dependencies": { 4 | "compression": "^1.6.1", 5 | "http-proxy": "^1.13.2" 6 | }, 7 | "scripts": { 8 | "build": "babel src -d lib", 9 | "postinstall": "npm run build", 10 | "start": "node lib" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /proxy/src/index.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import compression from 'compression'; 3 | import express from 'express'; 4 | 5 | import config from '../config.json'; 6 | import errorHandler from './middlewares/errorHandler'; 7 | import logger from './utils/logger'; 8 | import proxy from './middlewares/proxy'; 9 | 10 | const app = express(); 11 | 12 | app.use(compression({ level: 2 })); 13 | app.use(config.graphQLPath, proxy(config.backendAddress)); 14 | app.use(proxy(config.frontendAddress)); 15 | app.use(errorHandler); 16 | 17 | const server = app.listen(config.port, err => { 18 | if (err) { 19 | logger.error(err); 20 | } else { 21 | const { address, port } = server.address(); 22 | logger.info(`Proxy is listening at http://${address}:${port}`); 23 | 24 | if (process.send) { 25 | process.send('ready'); 26 | } 27 | } 28 | }); 29 | -------------------------------------------------------------------------------- /proxy/src/middlewares/errorHandler.js: -------------------------------------------------------------------------------- 1 | import logger from '../utils/logger'; 2 | 3 | export default function errorHandler(err, {}, res, {}) { 4 | logger.error(err); 5 | 6 | if (res.headersSent) { 7 | res.end(); 8 | } else { 9 | res.send(500, 'Internal server error.'); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /proxy/src/middlewares/proxy.js: -------------------------------------------------------------------------------- 1 | import httpProxy from 'http-proxy'; 2 | 3 | const proxyServer = httpProxy.createProxyServer({}); 4 | 5 | export default function proxy(target) { 6 | return (req, res, next) => proxyServer.web(req, res, { target }, err => { 7 | next(err); 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /proxy/src/utils/logger.js: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | 3 | export default new winston.Logger({ 4 | exitOnError: false, 5 | transports: [ 6 | new winston.transports.Console({ 7 | handleExceptions: true, 8 | humanReadableUnhandledException: true, 9 | }), 10 | ], 11 | }); 12 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | "use strict"; 3 | const fork = require('child_process').fork; 4 | const path = require('path'); 5 | 6 | const BACKEND_DIR = path.join(__dirname, 'backend'); 7 | const FRONTEND_DIR = path.join(__dirname, 'frontend'); 8 | const PROXY_DIR = path.join(__dirname, 'proxy'); 9 | 10 | const BACKEND_SCRIPT = path.join(BACKEND_DIR, 'lib'); 11 | const FRONTEND_SCRIPT = path.join(FRONTEND_DIR, 'lib/server'); 12 | const PROXY_SCRIPT = path.join(PROXY_DIR, 'lib'); 13 | 14 | const backend = fork(BACKEND_SCRIPT, { cwd: BACKEND_DIR, env: process.env }); 15 | const frontend = fork(FRONTEND_SCRIPT, { cwd: FRONTEND_DIR, env: process.env }); 16 | let proxy; 17 | 18 | function ready(proc) { 19 | return new Promise(resolve => { 20 | proc.on('message', message => { 21 | if (message === 'ready') { 22 | resolve(); 23 | } 24 | }); 25 | }); 26 | } 27 | 28 | Promise.all([ 29 | ready(backend), 30 | ready(frontend), 31 | ]).then(() => { 32 | proxy = fork(PROXY_SCRIPT, { cwd: PROXY_DIR }); 33 | }); 34 | 35 | function exit() { 36 | backend.kill(); 37 | frontend.kill(); 38 | if (proxy) { 39 | proxy.kill(); 40 | } 41 | 42 | process.exit(); 43 | } 44 | 45 | process.on('SIGINT', exit); 46 | process.on('SIGTERM', exit); 47 | --------------------------------------------------------------------------------