Repository: edvinerikson/relay-subscriptions Branch: master Commit: a26869ba2e26 Files: 51 Total size: 100.4 KB Directory structure: gitextract_qz37ndd9/ ├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── docs/ │ ├── API/ │ │ └── Subscription.md │ └── API.md ├── examples/ │ ├── .eslintrc │ └── todo/ │ ├── .babelrc │ ├── README.md │ ├── data/ │ │ ├── database.js │ │ ├── schema.graphql │ │ └── schema.js │ ├── js/ │ │ ├── NetworkLayer.js │ │ ├── app.js │ │ ├── components/ │ │ │ ├── Todo.js │ │ │ ├── TodoApp.js │ │ │ ├── TodoList.js │ │ │ ├── TodoListFooter.js │ │ │ └── TodoTextInput.js │ │ ├── mutations/ │ │ │ ├── AddTodoMutation.js │ │ │ ├── ChangeTodoStatusMutation.js │ │ │ ├── MarkAllTodosMutation.js │ │ │ ├── RemoveCompletedTodosMutation.js │ │ │ ├── RemoveTodoMutation.js │ │ │ └── RenameTodoMutation.js │ │ ├── queries/ │ │ │ └── ViewerQueries.js │ │ └── subscriptions/ │ │ ├── AddTodoSubscription.js │ │ ├── RemoveTodoSubscription.js │ │ └── UpdateTodoSubscription.js │ ├── package.json │ ├── public/ │ │ ├── base.css │ │ ├── index.css │ │ ├── index.html │ │ └── learn.json │ ├── server.js │ └── tools/ │ ├── .eslintrc │ └── updateSchema.js ├── package.json └── src/ ├── .flowconfig ├── Environment.js ├── Subscription.js ├── SubscriptionRequest.js ├── __tests__/ │ └── createContainer.js ├── createContainer.js ├── createSubscriptionQuery.js ├── index.js ├── types.js └── updateStoreData.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .babelrc ================================================ { "presets": [ ["env", { "loose": true }], "stage-2", "react" ], "plugins": ["dev-expression", "add-module-exports"] } ================================================ FILE: .eslintignore ================================================ /examples/*/node_modules/* ================================================ FILE: .eslintrc ================================================ { "extends": "airbnb", "env": { "jest": true, "jasmine": true }, "parser": "babel-eslint", "plugins": [ "flowtype" ], "rules": { "no-underscore-dangle": 0, "flowtype/define-flow-type": 2 } } ================================================ FILE: .gitignore ================================================ # Custom lib/ # Logs logs *.log npm-debug.log* # Runtime data pids *.pid *.seed # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage # nyc test coverage .nyc_output # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt # node-waf configuration .lock-wscript # Compiled binary addons (http://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules jspm_packages # Optional npm cache directory .npm # Optional REPL history .node_repl_history ================================================ FILE: .travis.yml ================================================ sudo: false language: node_js node_js: - stable cache: yarn branches: only: - master ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2016 Edvin Erikson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Relay Subscriptions [![npm][npm-badge]][npm] Subscription support for [Relay Classic](http://facebook.github.io/relay/). ![PoC](http://g.recordit.co/zZfGNmYJTr.gif) [![Discord][discord-badge]][discord] ## Documentation - [Guide](#guide) - [TodoMVC example](examples/todo) - [API reference](docs/API.md) ## Guide ### Installation ```sh $ npm i -S react react-relay babel-relay-plugin $ npm i -S relay-subscriptions ``` ### Network layer ([API](docs/API.md#network-layer)) To use Relay Subscriptions, you need to provide a network layer with subscription support. This network layer needs to implement a `sendSubscription` method that takes a subscription request, calls the observer methods on the request when the subscription updates, and returns a disposable for tearing down the subscription. A simple network layer that uses [Socket.IO](http://socket.io/) as the underlying transport looks like: ```js import Relay from 'react-relay/classic'; import io from 'socket.io-client'; export default class NetworkLayer extends Relay.DefaultNetworkLayer { constructor(...args) { super(...args); this.socket = io(); this.requests = Object.create(null); this.socket.on('subscription update', ({ id, data, errors }) => { const request = this.requests[id]; if (errors) { request.onError(errors); } else { request.onNext(data); } }); } sendSubscription(request) { const id = request.getClientSubscriptionId(); this.requests[id] = request; this.socket.emit('subscribe', { id, query: request.getQueryString(), variables: request.getVariables(), }); return { dispose: () => { this.socket.emit('unsubscribe', id); }, }; } } ``` For a full example, see [the network layer](examples/todo/js/NetworkLayer.js) in the TodoMVC example. If your server uses [GraphQL.js](https://github.com/graphql/graphql-js), [graphql-relay-subscription](https://github.com/taion/graphql-relay-subscription) provides helpers for implementing subscriptions. For a basic example, see [the server](examples/todo/server.js) and [the schema](examples/todo/data/schema.js) in the TodoMVC example. ### Environment ([API](docs/API.md#relaysubscriptionsenvironment)) Instead of using a standard `Relay.Environment`, use a `RelaySubscriptions.Environment`. This environment class adds subscription support to the standard Relay environment. ```js import RelaySubscriptions from 'relay-subscriptions'; import NetworkLayer from './NetworkLayer'; const environment = new RelaySubscriptions.Environment(); environment.injectNetworkLayer(new NetworkLayer()); ``` ### Subscriptions ([API](docs/API.md#subscription)) Subclass the `Subscription` class to define subscriptions. This base class is similar to `Relay.Mutation`. A basic subscription looks like: ```js import Relay from 'react-relay/classic'; import { Subscription } from 'relay-subscriptions'; import Widget from '../components/Widget'; export default class WidgetSubscription extends Subscription { static fragments = { widget: () => Relay.QL` fragment on Widget { id } `, }; getSubscription() { return Relay.QL` subscription { updateWidget(input: $input) { widget { ${Widget.getFragment('widget')} } } } `; } getConfigs() { return [{ type: 'FIELDS_CHANGE', fieldIDs: { widget: this.props.widget.id, }, }]; } getVariables() { return { id: this.props.widget.id, }; } } ``` Due to an open issue ([#12]), for a `RANGE_ADD` subscription, you must manually request the `__typename` field on the edge in the payload. For full examples, see [the subscriptions](examples/todo/js/subscriptions) in the TodoMVC example. ### Containers ([API](docs/API.md#relaysubscriptionscreatecontainer)) For components with subscriptions, use `RelaySubscriptions.createContainer` instead of `Relay.createContainer`. Define your Relay fragments normally, including the fragments for any subscriptions you need, then define a `subscriptions` array of functions that create the desired subscriptions from the component's props. ```js import React from 'react'; import Relay from 'react-relay/classic'; import RelaySubscriptions from 'relay-subscriptions'; import WidgetSubscription from '../subscriptions/WidgetSubscription'; class Widget extends React.Component { /* ... */ } export default RelaySubscriptions.createContainer(Widget, { fragments: { widget: () => Relay.QL` fragment on Widget { # ... ${WidgetSubscription.getFragment('widget')} } `, }, subscriptions: [ ({ widget }) => new WidgetSubscription({ widget }), ], }) ``` If you want to manually manage your subscription, the container also adds a `subscribe` method on `props.relay`, which takes a `Subscription` and an optional observer, and returns a disposable for tearing down the subscription. ## TODO - [ ] Add tests ([#1]) - [ ] Automatically add `__typename` to query for `RANGE_ADD` subscriptions ([#12]) ## Credits Big thanks to [@taion](https://github.com/taion) for cleaning up my mess, creating a really nice API and these amazing docs :tada: [#1]: https://github.com/edvinerikson/relay-subscriptions/issues/1 [#12]: https://github.com/edvinerikson/relay-subscriptions/issues/12 [npm-badge]: https://img.shields.io/npm/v/relay-subscriptions.svg [npm]: https://www.npmjs.org/package/relay-subscriptions [discord-badge]: https://img.shields.io/badge/Discord-join%20chat%20%E2%86%92-738bd7.svg [discord]: https://discord.gg/0ZcbPKXt5bX40xsQ ================================================ FILE: docs/API/Subscription.md ================================================ # Subscription RelaySubscriptions makes use of the `Relay.Mutation` API. If you are familiar with the mutation api this shouldn't be any new things. Except the `getSubscription` method which replaced `getMutation`. # Overview ### Properties `static fragments` _Declare this subscription's data dependencies here_ `static initialVariables` _A default set of variables to make available to this subscription's fragment builders_ `static prepareVariables` _A method to modify the variables based on the runtime environment, previous variables, or the meta route_ ### Methods `constructor(props)` `abstract getConfigs()` `abstract getSubscription()` `abstract getVariables()` `static getFragment(fragmentName[, variableMapping])` # Properties ## fragments (static property) ```js static fragments: RelayMutationFragments<$Keys> // Type of RelayMutationFragments type RelayMutationFragments = { [key: Tk]: FragmentBuilder; }; // Type of FragmentBuilder type FragmentBuilder = (variables: Variables) => RelayConcreteNode; ``` We declare our subscription' data dependencies here, just as we would with a container. ### Example ```js class UpdateTodoSubscription extends RelaySubscriptions.Subscription { static fragments = { todo: () => Relay.QL` fragment on Todo { id text complete } `, }; } ``` ## initialVariables (static property) `static initialVariables: {[name: string]: mixed};` The defaults we specify here will become available to our fragment builders: ### Example ```js class AddTodoSubscription extends RelaySubscriptions.Subscription { static initialVariables = {orderby: 'priority'}; static fragments = { todos: () => Relay.QL` # The variable defined above is available here as $orderby fragment on Viewer { todos(orderby: $orderby) { ... } } `, }; /* ... */ } ``` ## prepareVariables (static property) ```js static prepareVariables: ?( prevVariables: {[name: string]: mixed}, route: RelayMetaRoute, ) => {[name: string]: mixed} // Type of `route` argument type RelayMetaRoute = { name: string; } ``` If we provide to a subscription a method that conforms to the signature described above, it will be given the opportunity to modify the fragment builders' variables, based on the previous variables (or the initialVariables if no previous ones exist), the meta route, and the runtime environment. Whatever variables this method returns will become available to this subscription's fragment builders. ### Example ```js class BuySongSubscription extends RelaySubscriptions.Subscription { static initialVariables = {format: 'mp3'}; static prepareVariables = (prevVariables) => { var overrideVariables = {}; var formatPreference = localStorage.getItem('formatPreference'); if (formatPreference) { overrideVariables.format = formatPreference; // Lossless, hopefully } return {...prevVariables, overrideVariables}; }; /* ... */ } ``` # Methods ## constructor Create a subscription instance using the `new` keyword, optionally passing it some props. Note that `this.props` is not available inside the constructor function, but are set for all the methods mentioned below (getConfigs, getVariables, etc). This restriction is due to the fact that subscription props may depend on data from the RelayEnvironment, which isn't known until the subscription is applied with `subscribe` provided by `SubscriptionProvider` and `SubscriptionContainer`. ### Example ```js const flightsUpdateSub = new FlightsUpdateSubscription({airport: 'yvr'}); this.props.subscriptions.subscribe(flightsUpdateSub); ``` ## getConfigs (abstract method) `abstract getConfigs(): Array<{[key: string]: mixed}>` Implement this required method to give Relay instructions on how to use the response payload from each subscription to update the client-side store. ### Example ```js class LikeStorySubscription extends Subscription { getConfigs() { return [{ type: 'FIELDS_CHANGE', fieldIDs: { story: this.props.story.id, }, }]; } } ``` ## getSubscription (abstract method) `abstract getSubscription(): GraphQL.Subscription` Implement this required method to return a GraphQL subscription operation that represents the subscription to subscribe to. ### Example ```js class LikeStorySubscription extends Subscription { getSubscription() { return Relay.QL`subscription { likeStorySubscribe { story { likes { likeSentence count } } } }`; } } ``` ## getVariables (abstract method) `abstract getVariables(): {[name: string]: mixed}` Implement this required method to prepare variables to be used as input to the subscription. ## Example ```js class DestroyShipSubscription extends RelaySubscriptions.Subscription { getVariables() { return { factionId: this.props.faction.id, }; } } ``` ## getFragment (static method) ```js static getFragment( fragmentName: $Keys, variableMapping?: Variables ): RelayFragmentReference // Type of the variableMapping argument type Variables = {[name: string]: mixed}; ``` Gets a fragment reference for use in a parent's query fragment. ### Example ```js class StoryComponent extends React.Component { /* ... */ static fragments = { story: () => Relay.QL` fragment on Story { id, text, ${LikeStorySubscription.getFragment('story')}, } `, }; } ``` You can also pass variables to the subscription's fragment builder from the outer fragment that contains it. ```js class Movie extends React.Component { /* ... */ static fragments = { movie: () => Relay.QL` fragment on Movie { posterImage(lang: $lang) { url }, trailerVideo(format: $format, lang: $lang) { url }, ${MovieUpdateSubscription.getFragment('movie', { format: variables.format, lang: variables.lang, })}, } `, }; } ``` ================================================ FILE: docs/API.md ================================================ # API Reference - [Network layer](#network-layer) - [RelaySubscriptions.Environment](#relaysubscriptionsenvironment) - [Subscription](#subscription) - [RelaySubscriptions.createContainer](#relaysubscriptionscreatecontainer) ## Network layer You must implement a network layer that connects to a backend with subscription support. This network layer must implement the following additional method: ```js sendSubscription: (request: SubscriptionRequest) => Disposable ``` The `SubscriptionRequest` object supports: ```js type SubscriptionRequest { getQueryString: () => string; getVariables: () => Variables; getClientSubscriptionId: () => string; onNext: (payload: SubscriptionResult) => void; onError: (error: any) => void; onCompleted: (value: any) => void; getDebugName: () => string; } ``` The `getQueryString` method returns the GraphQL query string. The `getVariables` method returns the variables for the query. The `getClientSubscriptionId` method returns a client-side ID for the subscription. Call the `onNext`, `onError`, and `onCompleted` methods when the subscription updates. The return value is expected to conform to: ```js type Disposable = { dispose: () => void; } ``` The `dispose` method should tear down the subscription. ## `RelaySubscriptions.Environment` `RelaySubscriptions.Environment` extends `Relay.Environment` and provides subscription support. ### `subscribe` This method has the signature: ```js subscribe: (subscription: Subscription, observer?: Observer) => Disposable ``` This method will make the subscription. The observer, if provided, is expected to conform to: ```js type Observer = { onNext?: (value: SubscriptionResult) => void; onError?: (error: any) => void; onCompleted?: (value: any) => void; } ``` The specified callbacks will be invoked when the subscription updates. The `onNext` callback fires after the store update. ## `Subscription` Subclass the `Subscription` class to define a subscription. This base class is similar to `Relay.Mutation`, except that you need to implement `getSubscription` instead of `getMutation` and `getFatQuery`. ```js import { Subscription } from 'relay-subscriptions'; export default class WidgetSubscription extends Subscription { /* ... */ } ``` ### `constructor` As with `Relay.Mutation`, you can construct an instance of a subclass of `Subscription` with the `new` keyword and optional props. ```js new WidgetSubscription({ widget }) ``` ### Static properties Define these properties to specify the input data dependencies for the subscription. #### `fragments` This static property defines the subscription's data requirements as a object of fragment builders, as with the `fragments` static property on `Relay.Mutation`. These fragments can then be composed elsewhere with `MySubscription.getFragment(fragmentName)`. ```js static fragments = { widget: () => Relay.QL` fragment on Widget { id } }, }; ``` #### `initialVariables` If provided, this specifies the default variables for the fragment builders, as with the `initialVariables` static property on `Relay.Mutation`. #### `prepareVariables` If provided, this method modifies variables for the fragment builders, as with the `prepareVariables` static method on `Relay.Mutation`. ### Abstract methods Implement these methods to define the subscription's behavior. #### `getSubscription` This method should return the concrete subscription query. The query should use the `$input` variable for the subscription input. Unlike with mutations, this is not a fat query, so it must specify all desired fields. You can compose in fragments from container components here, which can help manage code duplication. ```js getSubscription() { return Relay.QL` subscription { updateWidget(input: $input) { ${Widget.getFragment('widget')} } } `; } ``` #### `getConfigs` This method should return the mutation configs, as with the `getConfigs` method on `Relay.Mutation`. ```js getConfigs() { return [{ type: 'FIELDS_CHANGE', fieldIDs: { widget: this.props.widget.id, }, }]; } ``` #### `getVariables` This method should return the subscription input variables, as with the `getVariables` method on `Relay.Mutation`. ```js getVariables() { return { id: this.props.widget.id, }; } ``` ## `RelaySubscriptions.createContainer` `RelaySubscriptions.createContainer` behaves like `Relay.createContainer`. It provides additional functionality for subscription support. ### Container specification #### `subscriptions` The specification for a Relay Subscriptions container accepts an optional `subscriptions` property: ```js subscriptions?: subscriptionFn[] ``` These subscription functions are expected to have the signature: ```js type subscriptionFn = (props: Object) => ?Subscription; ``` This function can return a falsy value to indicate that no subscription is desired. The Relay Subscriptions container will manage these subscriptions. It will establish the subscription after the component mounts, replace any subscriptions that have changed type or variables, and tear down these subscriptions when the component unmounts. ```js import RelaySubscriptions from 'relay-subscriptions'; /* ... */ export default RelaySubscriptions.createContainer(Widget, { fragments: { widget: () => Relay.QL` fragment on Widget { # ... ${WidgetSubscription.getFragment('widget')} } `, }, subscriptions: [ ({ pending, widget }) => !pending && new WidgetSubscription({ widget }), ], }); ``` ### `props.relay` The Relay Subscriptions container injects an augmented `props.relay` to the component with subscription functionality. #### `subscribe` This method invokes the `subscribe` method on the Relay Subscriptions environment. It has the same signature of: ```js subscribe: (subscription: Subscription, observer?: Observer) => Disposable ``` You can use this to manually manage the subscription. ```js import RelaySubscriptions from 'relay-subscriptions'; /* ... */ class Widget extends React.Component { componentDidMount() { const { relay, widget } = this.props; this.subscription = relay.subscribe( new WidgetSubscription({ widget }), ); } componentWillUnmount() { this.subscription.dispose(); } /* ... */ } export default RelaySubscriptions.createContainer(Widget, { fragments: { widget: () => Relay.QL` fragment on Widget { # ... ${WidgetSubscription.getFragment('widget')} } `, }, }); ``` ================================================ FILE: examples/.eslintrc ================================================ { "rules": { // Don't fail linting if example dependencies aren't installed. "import/no-unresolved": "off" } } ================================================ FILE: examples/todo/.babelrc ================================================ { "presets": [ ["env", { "loose": true }], "stage-2", "react" ], "plugins": [ ["relay", { "schema": "data/schema.graphql" }] ] } ================================================ FILE: examples/todo/README.md ================================================ # Relay TodoMVC ## Installation ``` npm install ``` ## Running Start a local server: ``` npm start ``` ## Developing Any changes you make to files in the `js/` directory will cause the server to automatically rebuild the app and refresh your browser. If at any time you make changes to `data/schema.js`, stop the server, regenerate `data/schema.json`, and restart the server: ``` npm run update-schema npm start ``` ## License This file provided by Facebook is for non-commercial testing and evaluation purposes only. Facebook reserves all rights not expressly granted. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: examples/todo/data/database.js ================================================ /** * This file provided by Facebook is for non-commercial testing and evaluation * purposes only. Facebook reserves all rights not expressly granted. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ export class Todo {} export class User {} // Mock authenticated ID export const VIEWER_ID = 'me'; // Mock user data const viewer = new User(); viewer.id = VIEWER_ID; const usersById = { [VIEWER_ID]: viewer, }; // Mock todo data const todosById = {}; const todoIdsByUser = { [VIEWER_ID]: [], }; const notifiers = []; function notifyChange(topic, data) { // Delay the change notification to avoid the subscription update hitting the // client before the mutation response. setTimeout(() => { notifiers.forEach(notifier => notifier({ topic, data })); }, 100); } export function addNotifier(cb) { notifiers.push(cb); return () => { const index = notifiers.indexOf(cb); if (index !== -1) { notifiers.splice(index, 1); } }; } let nextTodoId = 0; export function addTodo(text, complete) { const todo = new Todo(); todo.complete = !!complete; todo.id = `${nextTodoId++}`; todo.text = text; todosById[todo.id] = todo; todoIdsByUser[VIEWER_ID].push(todo.id); notifyChange('add_todo', todo); return todo.id; } addTodo('Taste JavaScript', true); addTodo('Buy a unicorn', false); export function getTodo(id) { return todosById[id]; } export function getTodos(status = 'any') { const todos = todoIdsByUser[VIEWER_ID].map(id => todosById[id]); if (status === 'any') { return todos; } return todos.filter(todo => todo.complete === (status === 'completed')); } export function changeTodoStatus(id, complete) { const todo = getTodo(id); todo.complete = complete; notifyChange(`update_todo_${id}`, todo); } export function getUser(id) { return usersById[id]; } export function getViewer() { return getUser(VIEWER_ID); } export function markAllTodos(complete) { const changedTodos = []; getTodos().forEach(todo => { if (todo.complete !== complete) { todo.complete = complete; // eslint-disable-line no-param-reassign changedTodos.push(todo); notifyChange(`update_todo_${todo.id}`, todo); } }); return changedTodos.map(todo => todo.id); } export function removeTodo(id) { const todoIndex = todoIdsByUser[VIEWER_ID].indexOf(id); if (todoIndex !== -1) { todoIdsByUser[VIEWER_ID].splice(todoIndex, 1); } notifyChange('delete_todo', { id }); delete todosById[id]; } export function removeCompletedTodos() { const todosToRemove = getTodos().filter(todo => todo.complete); todosToRemove.forEach(todo => removeTodo(todo.id)); return todosToRemove.map(todo => todo.id); } export function renameTodo(id, text) { const todo = getTodo(id); todo.text = text; notifyChange(`update_todo_${id}`, todo); } ================================================ FILE: examples/todo/data/schema.graphql ================================================ schema { query: Root mutation: Mutation subscription: Subscription } input AddTodoInput { text: String! clientMutationId: String } type AddTodoPayload { todoEdge: TodoEdge viewer: User clientMutationId: String } input AddTodoSubscriptionInput { clientSubscriptionId: String } type AddTodoSubscriptionPayload { todo: Todo todoEdge: TodoEdge viewer: User clientSubscriptionId: String } input ChangeTodoStatusInput { complete: Boolean! id: ID! clientMutationId: String } type ChangeTodoStatusPayload { todo: Todo viewer: User clientMutationId: String } input MarkAllTodosInput { complete: Boolean! clientMutationId: String } type MarkAllTodosPayload { changedTodos: [Todo] viewer: User clientMutationId: String } type Mutation { addTodo(input: AddTodoInput!): AddTodoPayload changeTodoStatus(input: ChangeTodoStatusInput!): ChangeTodoStatusPayload markAllTodos(input: MarkAllTodosInput!): MarkAllTodosPayload removeCompletedTodos(input: RemoveCompletedTodosInput!): RemoveCompletedTodosPayload removeTodo(input: RemoveTodoInput!): RemoveTodoPayload renameTodo(input: RenameTodoInput!): RenameTodoPayload } # An object with an ID interface Node { # The id of the object. id: ID! } # Information about pagination in a connection. type PageInfo { # When paginating forwards, are there more items? hasNextPage: Boolean! # When paginating backwards, are there more items? hasPreviousPage: Boolean! # When paginating backwards, the cursor to continue. startCursor: String # When paginating forwards, the cursor to continue. endCursor: String } input RemoveCompletedTodosInput { clientMutationId: String } type RemoveCompletedTodosPayload { deletedTodoIds: [String] viewer: User clientMutationId: String } input RemoveTodoInput { id: ID! clientMutationId: String } type RemoveTodoPayload { deletedTodoId: ID viewer: User clientMutationId: String } input RemoveTodoSubscriptionInput { clientSubscriptionId: String } type RemoveTodoSubscriptionPayload { deletedTodoId: ID viewer: User clientSubscriptionId: String } input RenameTodoInput { id: ID! text: String! clientMutationId: String } type RenameTodoPayload { todo: Todo clientMutationId: String } type Root { viewer: User # Fetches an object given its ID node( # The ID of an object id: ID! ): Node } type Subscription { addTodoSubscription(input: AddTodoSubscriptionInput!): AddTodoSubscriptionPayload removeTodoSubscription(input: RemoveTodoSubscriptionInput!): RemoveTodoSubscriptionPayload updateTodoSubscription(input: UpdateTodoSubscriptionInput!): UpdateTodoSubscriptionPayload } type Todo implements Node { # The ID of an object id: ID! text: String complete: Boolean } # A connection to a list of items. type TodoConnection { # Information to aid in pagination. pageInfo: PageInfo! # A list of edges. edges: [TodoEdge] } # An edge in a connection. type TodoEdge { # The item at the end of the edge node: Todo # A cursor for use in pagination cursor: String! } input UpdateTodoSubscriptionInput { id: ID! clientSubscriptionId: String } type UpdateTodoSubscriptionPayload { todo: Todo viewer: User clientSubscriptionId: String } type User implements Node { # The ID of an object id: ID! todos(status: String = "any", after: String, first: Int, before: String, last: Int): TodoConnection totalCount: Int completedCount: Int } ================================================ FILE: examples/todo/data/schema.js ================================================ /** * This file provided by Facebook is for non-commercial testing and evaluation * purposes only. Facebook reserves all rights not expressly granted. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import { GraphQLBoolean, GraphQLID, GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLSchema, GraphQLString, } from 'graphql'; import { connectionArgs, connectionDefinitions, connectionFromArray, cursorForObjectInConnection, fromGlobalId, globalIdField, mutationWithClientMutationId, nodeDefinitions, toGlobalId, } from 'graphql-relay'; import { subscriptionWithClientId } from 'graphql-relay-subscription'; import { Todo, User, addTodo, changeTodoStatus, getTodo, getTodos, getUser, getViewer, markAllTodos, removeCompletedTodos, removeTodo, renameTodo, } from './database'; const { nodeInterface, nodeField } = nodeDefinitions( (globalId) => { const { type, id } = fromGlobalId(globalId); if (type === 'Todo') { return getTodo(id); } else if (type === 'User') { return getUser(id); } return null; }, (obj) => { /* eslint-disable no-use-before-define */ if (obj instanceof Todo) { return GraphQLTodo; } else if (obj instanceof User) { return GraphQLUser; } /* eslint-enable no-use-before-define */ return null; } ); const GraphQLTodo = new GraphQLObjectType({ name: 'Todo', fields: { id: globalIdField('Todo'), text: { type: GraphQLString, resolve: (obj) => obj.text, }, complete: { type: GraphQLBoolean, resolve: (obj) => obj.complete, }, }, interfaces: [nodeInterface], }); const { connectionType: TodosConnection, edgeType: GraphQLTodoEdge, } = connectionDefinitions({ name: 'Todo', nodeType: GraphQLTodo, }); const GraphQLUser = new GraphQLObjectType({ name: 'User', fields: { id: globalIdField('User'), todos: { type: TodosConnection, args: { status: { type: GraphQLString, defaultValue: 'any', }, ...connectionArgs, }, resolve: (obj, { status, ...args }) => connectionFromArray(getTodos(status), args), }, totalCount: { type: GraphQLInt, resolve: () => getTodos().length, }, completedCount: { type: GraphQLInt, resolve: () => getTodos('completed').length, }, }, interfaces: [nodeInterface], }); const Root = new GraphQLObjectType({ name: 'Root', fields: { viewer: { type: GraphQLUser, resolve: () => getViewer(), }, node: nodeField, }, }); const GraphQLAddTodoMutation = mutationWithClientMutationId({ name: 'AddTodo', inputFields: { text: { type: new GraphQLNonNull(GraphQLString) }, }, outputFields: { todoEdge: { type: GraphQLTodoEdge, resolve: ({ localTodoId }) => { const todo = getTodo(localTodoId); return { cursor: cursorForObjectInConnection(getTodos(), todo), node: todo, }; }, }, viewer: { type: GraphQLUser, resolve: () => getViewer(), }, }, mutateAndGetPayload: ({ text }) => { const localTodoId = addTodo(text); return { localTodoId }; }, }); const GraphQLChangeTodoStatusMutation = mutationWithClientMutationId({ name: 'ChangeTodoStatus', inputFields: { complete: { type: new GraphQLNonNull(GraphQLBoolean) }, id: { type: new GraphQLNonNull(GraphQLID) }, }, outputFields: { todo: { type: GraphQLTodo, resolve: ({ localTodoId }) => getTodo(localTodoId), }, viewer: { type: GraphQLUser, resolve: () => getViewer(), }, }, mutateAndGetPayload: ({ id, complete }) => { const localTodoId = fromGlobalId(id).id; changeTodoStatus(localTodoId, complete); return { localTodoId }; }, }); const GraphQLMarkAllTodosMutation = mutationWithClientMutationId({ name: 'MarkAllTodos', inputFields: { complete: { type: new GraphQLNonNull(GraphQLBoolean) }, }, outputFields: { changedTodos: { type: new GraphQLList(GraphQLTodo), resolve: ({ changedTodoLocalIds }) => changedTodoLocalIds.map(getTodo), }, viewer: { type: GraphQLUser, resolve: () => getViewer(), }, }, mutateAndGetPayload: ({ complete }) => { const changedTodoLocalIds = markAllTodos(complete); return { changedTodoLocalIds }; }, }); // TODO: Support plural deletes const GraphQLRemoveCompletedTodosMutation = mutationWithClientMutationId({ name: 'RemoveCompletedTodos', outputFields: { deletedTodoIds: { type: new GraphQLList(GraphQLString), resolve: ({ deletedTodoIds }) => deletedTodoIds, }, viewer: { type: GraphQLUser, resolve: () => getViewer(), }, }, mutateAndGetPayload: () => { const deletedTodoLocalIds = removeCompletedTodos(); const deletedTodoIds = deletedTodoLocalIds.map(toGlobalId.bind(null, 'Todo')); return { deletedTodoIds }; }, }); const GraphQLRemoveTodoMutation = mutationWithClientMutationId({ name: 'RemoveTodo', inputFields: { id: { type: new GraphQLNonNull(GraphQLID) }, }, outputFields: { deletedTodoId: { type: GraphQLID, resolve: ({ id }) => id, }, viewer: { type: GraphQLUser, resolve: () => getViewer(), }, }, mutateAndGetPayload: ({ id }) => { const localTodoId = fromGlobalId(id).id; removeTodo(localTodoId); return { id }; }, }); const GraphQLRenameTodoMutation = mutationWithClientMutationId({ name: 'RenameTodo', inputFields: { id: { type: new GraphQLNonNull(GraphQLID) }, text: { type: new GraphQLNonNull(GraphQLString) }, }, outputFields: { todo: { type: GraphQLTodo, resolve: ({ localTodoId }) => getTodo(localTodoId), }, }, mutateAndGetPayload: ({ id, text }) => { const localTodoId = fromGlobalId(id).id; renameTodo(localTodoId, text); return { localTodoId }; }, }); const GraphQLAddTodoSubscription = subscriptionWithClientId({ name: 'AddTodoSubscription', outputFields: { todo: { type: GraphQLTodo, resolve: obj => obj, }, todoEdge: { type: GraphQLTodoEdge, resolve: obj => ({ cursor: cursorForObjectInConnection(getTodos(), getTodo(obj.id)), node: obj, }), }, viewer: { type: GraphQLUser, resolve: () => getViewer(), }, }, subscribe: (input, context) => ( context.subscribe('add_todo') ), }); const GraphQLRemoveTodoSubscription = subscriptionWithClientId({ name: 'RemoveTodoSubscription', outputFields: { deletedTodoId: { type: GraphQLID, resolve: ({ id }) => toGlobalId('Todo', id), }, viewer: { type: GraphQLUser, resolve: () => getViewer(), }, }, subscribe: (input, context) => ( context.subscribe('delete_todo') ), }); const GraphQLUpdateTodoSubscription = subscriptionWithClientId({ name: 'UpdateTodoSubscription', inputFields: { id: { type: new GraphQLNonNull(GraphQLID) }, }, outputFields: { todo: { type: GraphQLTodo, resolve: obj => obj, }, viewer: { type: GraphQLUser, resolve: () => getViewer(), }, }, subscribe: ({ id }, context) => ( context.subscribe(`update_todo_${fromGlobalId(id).id}`) ), }); const Mutation = new GraphQLObjectType({ name: 'Mutation', fields: { addTodo: GraphQLAddTodoMutation, changeTodoStatus: GraphQLChangeTodoStatusMutation, markAllTodos: GraphQLMarkAllTodosMutation, removeCompletedTodos: GraphQLRemoveCompletedTodosMutation, removeTodo: GraphQLRemoveTodoMutation, renameTodo: GraphQLRenameTodoMutation, }, }); const Subscription = new GraphQLObjectType({ name: 'Subscription', fields: { addTodoSubscription: GraphQLAddTodoSubscription, removeTodoSubscription: GraphQLRemoveTodoSubscription, updateTodoSubscription: GraphQLUpdateTodoSubscription, }, }); export const schema = new GraphQLSchema({ query: Root, mutation: Mutation, subscription: Subscription, }); ================================================ FILE: examples/todo/js/NetworkLayer.js ================================================ /* eslint-disable no-console */ import Relay from 'react-relay/classic'; import { SubscriptionClient } from 'subscriptions-transport-ws'; export default class NetworkLayer extends Relay.DefaultNetworkLayer { constructor(...args) { super(...args); this._subscriptionClient = new SubscriptionClient( `ws://${global.location.host}/graphql`, { reconnect: true }, ); } sendSubscription(request) { const { unsubscribe } = this._subscriptionClient.request({ query: request.getQueryString(), variables: request.getVariables(), }).subscribe({ next: ({ errors, data }) => { if (errors) { request.onError(errors); } else { request.onNext(data); } }, error: request.onError, complete: request.onCompleted, }); return { dispose: unsubscribe }; } disconnect() { this._subscriptionClient.close(); } } ================================================ FILE: examples/todo/js/app.js ================================================ /** * This file provided by Facebook is for non-commercial testing and evaluation * purposes only. Facebook reserves all rights not expressly granted. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import 'todomvc-common'; import { createHashHistory } from 'history'; import React from 'react'; import ReactDOM from 'react-dom'; import { applyRouterMiddleware, IndexRoute, Route, Router, useRouterHistory, } from 'react-router'; import useRelay from 'react-router-relay'; import RelaySubscriptions from 'relay-subscriptions'; import NetworkLayer from './NetworkLayer'; import TodoApp from './components/TodoApp'; import TodoList from './components/TodoList'; import ViewerQueries from './queries/ViewerQueries'; const history = useRouterHistory(createHashHistory)({ queryKey: false }); const environment = new RelaySubscriptions.Environment(); environment.injectNetworkLayer(new NetworkLayer('/graphql')); const mountNode = document.getElementById('root'); ReactDOM.render( ({ status: 'any' })} /> , mountNode ); ================================================ FILE: examples/todo/js/components/Todo.js ================================================ /** * This file provided by Facebook is for non-commercial testing and evaluation * purposes only. Facebook reserves all rights not expressly granted. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import classNames from 'classnames'; import React from 'react'; import Relay from 'react-relay/classic'; import RelaySubscriptions from 'relay-subscriptions'; import ChangeTodoStatusMutation from '../mutations/ChangeTodoStatusMutation'; import RemoveTodoMutation from '../mutations/RemoveTodoMutation'; import RenameTodoMutation from '../mutations/RenameTodoMutation'; import UpdateTodoSubscription from '../subscriptions/UpdateTodoSubscription'; import TodoTextInput from './TodoTextInput'; class Todo extends React.Component { static propTypes = { viewer: React.PropTypes.object.isRequired, todo: React.PropTypes.object.isRequired, relay: React.PropTypes.object.isRequired, }; state = { isEditing: false, }; _handleCompleteChange = (e) => { const { relay, todo, viewer } = this.props; relay.commitUpdate( new ChangeTodoStatusMutation({ todo, viewer, complete: e.target.checked, }), ); }; _handleDestroyClick = () => { this._removeTodo(); }; _handleLabelDoubleClick = () => { this._setEditMode(true); }; _handleTextInputCancel = () => { this._setEditMode(false); }; _handleTextInputDelete = () => { this._setEditMode(false); this._removeTodo(); }; _handleTextInputSave = (text) => { this._setEditMode(false); const { relay, todo } = this.props; relay.commitUpdate( new RenameTodoMutation({ todo, text }), ); }; _removeTodo() { const { relay, todo, viewer } = this.props; relay.commitUpdate( new RemoveTodoMutation({ todo, viewer }), ); } _setEditMode = (shouldEdit) => { this.setState({ isEditing: shouldEdit }); }; renderTextInput() { return ( ); } render() { return (
  • {this.state.isEditing && this.renderTextInput()}
  • ); } } export default RelaySubscriptions.createContainer(Todo, { fragments: { todo: () => Relay.QL` fragment on Todo { id complete text ${ChangeTodoStatusMutation.getFragment('todo')} ${RemoveTodoMutation.getFragment('todo')} ${RenameTodoMutation.getFragment('todo')} ${UpdateTodoSubscription.getFragment('todo')} } `, viewer: () => Relay.QL` fragment on User { ${ChangeTodoStatusMutation.getFragment('viewer')} ${RemoveTodoMutation.getFragment('viewer')} } `, }, subscriptions: [ ({ pending, todo }) => !pending && new UpdateTodoSubscription({ todo }), ], }); ================================================ FILE: examples/todo/js/components/TodoApp.js ================================================ /** * This file provided by Facebook is for non-commercial testing and evaluation * purposes only. Facebook reserves all rights not expressly granted. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import React from 'react'; import Relay from 'react-relay/classic'; import RelaySubscriptions from 'relay-subscriptions'; import AddTodoMutation from '../mutations/AddTodoMutation'; import AddTodoSubscription from '../subscriptions/AddTodoSubscription'; import RemoveTodoSubscription from '../subscriptions/RemoveTodoSubscription'; import TodoListFooter from './TodoListFooter'; import TodoTextInput from './TodoTextInput'; class TodoApp extends React.Component { static propTypes = { viewer: React.PropTypes.object.isRequired, relay: React.PropTypes.object.isRequired, children: React.PropTypes.node.isRequired, }; _handleTextInputSave = (text) => { const { relay, viewer } = this.props; relay.commitUpdate( new AddTodoMutation({ viewer, text }), ); }; render() { const { viewer, children } = this.props; const hasTodos = viewer.totalCount > 0; return (

    todos

    {children} {hasTodos && ( )}
    ); } } export default RelaySubscriptions.createContainer(TodoApp, { fragments: { viewer: () => Relay.QL` fragment on User { totalCount ${AddTodoMutation.getFragment('viewer')} ${TodoListFooter.getFragment('viewer')} ${AddTodoSubscription.getFragment('viewer')} ${RemoveTodoSubscription.getFragment('viewer')} } `, }, subscriptions: [ ({ viewer }) => new AddTodoSubscription({ viewer }), ({ viewer }) => new RemoveTodoSubscription({ viewer }), ], }); ================================================ FILE: examples/todo/js/components/TodoList.js ================================================ /** * This file provided by Facebook is for non-commercial testing and evaluation * purposes only. Facebook reserves all rights not expressly granted. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import React from 'react'; import Relay from 'react-relay/classic'; import MarkAllTodosMutation from '../mutations/MarkAllTodosMutation'; import Todo from './Todo'; class TodoList extends React.Component { static propTypes = { viewer: React.PropTypes.object.isRequired, relay: React.PropTypes.object.isRequired, }; _handleMarkAllChange = (e) => { const { relay, viewer } = this.props; relay.commitUpdate( new MarkAllTodosMutation({ todos: viewer.todos, viewer, complete: e.target.checked, }), ); }; render() { const { viewer, relay } = this.props; const numTodos = viewer.totalCount; const numCompletedTodos = viewer.completedCount; return (
      {viewer.todos.edges.map(edge => ( ))}
    ); } } export default Relay.createContainer(TodoList, { initialVariables: { status: null, }, prepareVariables({ status }) { let nextStatus; if (status === 'active' || status === 'completed') { nextStatus = status; } else { // This matches the Backbone example, which displays all todos on an // invalid route. nextStatus = 'any'; } return { status: nextStatus, }; }, fragments: { viewer: () => Relay.QL` fragment on User { completedCount todos( status: $status first: 2147483647 # max GraphQLInt ) { edges { node { id ${Todo.getFragment('todo')} } } ${MarkAllTodosMutation.getFragment('todos')} } totalCount ${MarkAllTodosMutation.getFragment('viewer')} ${Todo.getFragment('viewer')} } `, }, }); ================================================ FILE: examples/todo/js/components/TodoListFooter.js ================================================ /** * This file provided by Facebook is for non-commercial testing and evaluation * purposes only. Facebook reserves all rights not expressly granted. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import React from 'react'; import Relay from 'react-relay/classic'; import { IndexLink, Link } from 'react-router'; import RemoveCompletedTodosMutation from '../mutations/RemoveCompletedTodosMutation'; class TodoListFooter extends React.Component { static propTypes = { viewer: React.PropTypes.object.isRequired, relay: React.PropTypes.object.isRequired, }; _handleRemoveCompletedTodosClick = () => { const { relay, viewer } = this.props; relay.commitUpdate( new RemoveCompletedTodosMutation({ todos: viewer.todos, viewer, }), ); }; render() { const { viewer } = this.props; const numCompletedTodos = viewer.completedCount; const numRemainingTodos = viewer.totalCount - numCompletedTodos; return (
    {numRemainingTodos} item{numRemainingTodos === 1 ? '' : 's'} left
    • All
    • Active
    • Completed
    {numCompletedTodos > 0 && ( )}
    ); } } export default Relay.createContainer(TodoListFooter, { fragments: { viewer: () => Relay.QL` fragment on User { completedCount todos( status: "completed" first: 2147483647 # max GraphQLInt ) { ${RemoveCompletedTodosMutation.getFragment('todos')} } totalCount ${RemoveCompletedTodosMutation.getFragment('viewer')} } `, }, }); ================================================ FILE: examples/todo/js/components/TodoTextInput.js ================================================ /** * This file provided by Facebook is for non-commercial testing and evaluation * purposes only. Facebook reserves all rights not expressly granted. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import React, { PropTypes } from 'react'; import ReactDOM from 'react-dom'; const ENTER_KEY_CODE = 13; const ESC_KEY_CODE = 27; export default class TodoTextInput extends React.Component { static propTypes = { className: PropTypes.string, commitOnBlur: PropTypes.bool.isRequired, initialValue: PropTypes.string, onCancel: PropTypes.func, onDelete: PropTypes.func, onSave: PropTypes.func.isRequired, placeholder: PropTypes.string, }; static defaultProps = { commitOnBlur: false, }; state = { isEditing: false, text: this.props.initialValue || '', }; componentDidMount() { ReactDOM.findDOMNode(this).focus(); } _commitChanges = () => { const newText = this.state.text.trim(); if (this.props.onDelete && newText === '') { this.props.onDelete(); } else if (this.props.onCancel && newText === this.props.initialValue) { this.props.onCancel(); } else if (newText !== '') { this.props.onSave(newText); this.setState({ text: '' }); } }; _handleBlur = () => { if (this.props.commitOnBlur) { this._commitChanges(); } }; _handleChange = (e) => { this.setState({ text: e.target.value }); }; _handleKeyDown = (e) => { if (this.props.onCancel && e.keyCode === ESC_KEY_CODE) { this.props.onCancel(); } else if (e.keyCode === ENTER_KEY_CODE) { this._commitChanges(); } }; render() { return ( ); } } ================================================ FILE: examples/todo/js/mutations/AddTodoMutation.js ================================================ /** * This file provided by Facebook is for non-commercial testing and evaluation * purposes only. Facebook reserves all rights not expressly granted. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import Relay from 'react-relay/classic'; export default class AddTodoMutation extends Relay.Mutation { static fragments = { viewer: () => Relay.QL` fragment on User { id totalCount } `, }; getMutation() { return Relay.QL`mutation { addTodo }`; } getFatQuery() { return Relay.QL` fragment on AddTodoPayload @relay(pattern: true) { todoEdge viewer { todos totalCount } } `; } getConfigs() { return [{ type: 'RANGE_ADD', parentName: 'viewer', parentID: this.props.viewer.id, connectionName: 'todos', edgeName: 'todoEdge', rangeBehaviors: ({ status }) => ( status === 'completed' ? 'ignore' : 'append' ), }]; } getVariables() { return { text: this.props.text, }; } getOptimisticResponse() { return { // FIXME: totalCount gets updated optimistically, but this edge does not // get added until the server responds todoEdge: { node: { complete: false, text: this.props.text, }, }, viewer: { id: this.props.viewer.id, totalCount: this.props.viewer.totalCount + 1, }, }; } } ================================================ FILE: examples/todo/js/mutations/ChangeTodoStatusMutation.js ================================================ /** * This file provided by Facebook is for non-commercial testing and evaluation * purposes only. Facebook reserves all rights not expressly granted. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import Relay from 'react-relay/classic'; export default class ChangeTodoStatusMutation extends Relay.Mutation { static fragments = { todo: () => Relay.QL` fragment on Todo { id, } `, // TODO: Mark completedCount optional viewer: () => Relay.QL` fragment on User { id, completedCount, } `, }; getMutation() { return Relay.QL`mutation{changeTodoStatus}`; } getFatQuery() { return Relay.QL` fragment on ChangeTodoStatusPayload @relay(pattern: true) { todo { complete, }, viewer { completedCount, todos, }, } `; } getConfigs() { return [{ type: 'FIELDS_CHANGE', fieldIDs: { todo: this.props.todo.id, viewer: this.props.viewer.id, }, }]; } getVariables() { return { complete: this.props.complete, id: this.props.todo.id, }; } getOptimisticResponse() { const viewerPayload = { id: this.props.viewer.id }; if (this.props.viewer.completedCount != null) { viewerPayload.completedCount = this.props.complete ? this.props.viewer.completedCount + 1 : this.props.viewer.completedCount - 1; } return { todo: { complete: this.props.complete, id: this.props.todo.id, }, viewer: viewerPayload, }; } } ================================================ FILE: examples/todo/js/mutations/MarkAllTodosMutation.js ================================================ /** * This file provided by Facebook is for non-commercial testing and evaluation * purposes only. Facebook reserves all rights not expressly granted. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import Relay from 'react-relay/classic'; export default class MarkAllTodosMutation extends Relay.Mutation { static fragments = { // TODO: Mark edges and totalCount optional todos: () => Relay.QL` fragment on TodoConnection { edges { node { complete, id, }, }, } `, viewer: () => Relay.QL` fragment on User { id, totalCount, } `, }; getMutation() { return Relay.QL`mutation{markAllTodos}`; } getFatQuery() { return Relay.QL` fragment on MarkAllTodosPayload @relay(pattern: true) { viewer { completedCount, todos, }, } `; } getConfigs() { return [{ type: 'FIELDS_CHANGE', fieldIDs: { viewer: this.props.viewer.id, }, }]; } getVariables() { return { complete: this.props.complete, }; } getOptimisticResponse() { const viewerPayload = { id: this.props.viewer.id }; if (this.props.todos && this.props.todos.edges) { viewerPayload.todos = { edges: this.props.todos.edges .filter(edge => edge.node.complete !== this.props.complete) .map(edge => ({ node: { complete: this.props.complete, id: edge.node.id, }, })), }; } if (this.props.viewer.totalCount != null) { viewerPayload.completedCount = this.props.complete ? this.props.viewer.totalCount : 0; } return { viewer: viewerPayload, }; } } ================================================ FILE: examples/todo/js/mutations/RemoveCompletedTodosMutation.js ================================================ /** * This file provided by Facebook is for non-commercial testing and evaluation * purposes only. Facebook reserves all rights not expressly granted. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import Relay from 'react-relay/classic'; export default class RemoveCompletedTodosMutation extends Relay.Mutation { static fragments = { // TODO: Make completedCount, edges, and totalCount optional todos: () => Relay.QL` fragment on TodoConnection { edges { node { complete, id, }, }, } `, viewer: () => Relay.QL` fragment on User { completedCount, id, totalCount, } `, }; getMutation() { return Relay.QL`mutation{removeCompletedTodos}`; } getFatQuery() { return Relay.QL` fragment on RemoveCompletedTodosPayload @relay(pattern: true) { deletedTodoIds, viewer { completedCount, totalCount, }, } `; } getConfigs() { return [{ type: 'NODE_DELETE', parentName: 'viewer', parentID: this.props.viewer.id, connectionName: 'todos', deletedIDFieldName: 'deletedTodoIds', }]; } getVariables() { return {}; } getOptimisticResponse() { let deletedTodoIds; let newTotalCount; if (this.props.todos && this.props.todos.edges) { deletedTodoIds = this.props.todos.edges .filter(edge => edge.node.complete) .map(edge => edge.node.id); } const { completedCount, totalCount } = this.props.viewer; if (completedCount != null && totalCount != null) { newTotalCount = totalCount - completedCount; } return { deletedTodoIds, viewer: { completedCount: 0, id: this.props.viewer.id, totalCount: newTotalCount, }, }; } } ================================================ FILE: examples/todo/js/mutations/RemoveTodoMutation.js ================================================ /** * This file provided by Facebook is for non-commercial testing and evaluation * purposes only. Facebook reserves all rights not expressly granted. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import Relay from 'react-relay/classic'; export default class RemoveTodoMutation extends Relay.Mutation { static fragments = { // TODO: Mark complete as optional todo: () => Relay.QL` fragment on Todo { complete, id, } `, // TODO: Mark completedCount and totalCount as optional viewer: () => Relay.QL` fragment on User { completedCount, id, totalCount, } `, }; getMutation() { return Relay.QL`mutation{removeTodo}`; } getFatQuery() { return Relay.QL` fragment on RemoveTodoPayload @relay(pattern: true) { deletedTodoId, viewer { completedCount, totalCount, }, } `; } getConfigs() { return [{ type: 'NODE_DELETE', parentName: 'viewer', parentID: this.props.viewer.id, connectionName: 'todos', deletedIDFieldName: 'deletedTodoId', }]; } getVariables() { return { id: this.props.todo.id, }; } getOptimisticResponse() { const viewerPayload = { id: this.props.viewer.id }; if (this.props.viewer.completedCount != null) { viewerPayload.completedCount = this.props.todo.complete === true ? this.props.viewer.completedCount - 1 : this.props.viewer.completedCount; } if (this.props.viewer.totalCount != null) { viewerPayload.totalCount = this.props.viewer.totalCount - 1; } return { deletedTodoId: this.props.todo.id, viewer: viewerPayload, }; } } ================================================ FILE: examples/todo/js/mutations/RenameTodoMutation.js ================================================ /** * This file provided by Facebook is for non-commercial testing and evaluation * purposes only. Facebook reserves all rights not expressly granted. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import Relay from 'react-relay/classic'; export default class RenameTodoMutation extends Relay.Mutation { static fragments = { todo: () => Relay.QL` fragment on Todo { id, } `, }; getMutation() { return Relay.QL`mutation{renameTodo}`; } getFatQuery() { return Relay.QL` fragment on RenameTodoPayload @relay(pattern: true) { todo { text, } } `; } getConfigs() { return [{ type: 'FIELDS_CHANGE', fieldIDs: { todo: this.props.todo.id, }, }]; } getVariables() { return { id: this.props.todo.id, text: this.props.text, }; } getOptimisticResponse() { return { todo: { id: this.props.todo.id, text: this.props.text, }, }; } } ================================================ FILE: examples/todo/js/queries/ViewerQueries.js ================================================ /** * This file provided by Facebook is for non-commercial testing and evaluation * purposes only. Facebook reserves all rights not expressly granted. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import Relay from 'react-relay/classic'; export default { viewer: () => Relay.QL`query { viewer }`, }; ================================================ FILE: examples/todo/js/subscriptions/AddTodoSubscription.js ================================================ import Relay from 'react-relay/classic'; import { Subscription } from 'relay-subscriptions'; import Todo from '../components/Todo'; export default class AddTodoSubscription extends Subscription { static fragments = { viewer: () => Relay.QL` fragment on User { id } `, }; getSubscription() { return Relay.QL` subscription { addTodoSubscription(input: $input) { todoEdge { __typename node { ${Todo.getFragment('todo')} } } viewer { id totalCount } } } `; } getConfigs() { return [{ type: 'RANGE_ADD', parentName: 'viewer', parentID: this.props.viewer.id, connectionName: 'todos', edgeName: 'todoEdge', rangeBehaviors: () => 'append', }]; } getVariables() { return {}; } } ================================================ FILE: examples/todo/js/subscriptions/RemoveTodoSubscription.js ================================================ import Relay from 'react-relay/classic'; import { Subscription } from 'relay-subscriptions'; export default class RemoveTodoSubscription extends Subscription { static fragments = { viewer: () => Relay.QL` fragment on User { id } `, }; getSubscription() { return Relay.QL` subscription { removeTodoSubscription(input: $input) { deletedTodoId viewer { completedCount totalCount } } } `; } getConfigs() { return [{ type: 'NODE_DELETE', parentName: 'viewer', parentID: this.props.viewer.id, connectionName: 'todos', deletedIDFieldName: 'deletedTodoId', }]; } getVariables() { return {}; } } ================================================ FILE: examples/todo/js/subscriptions/UpdateTodoSubscription.js ================================================ import Relay from 'react-relay/classic'; import { Subscription } from 'relay-subscriptions'; import Todo from '../components/Todo'; export default class UpdateTodoSubscription extends Subscription { static fragments = { todo: () => Relay.QL` fragment on Todo { id } `, }; getSubscription() { return Relay.QL` subscription { updateTodoSubscription(input: $input) { todo { ${Todo.getFragment('todo')} } viewer { id completedCount } } } `; } getConfigs() { return [{ type: 'FIELDS_CHANGE', fieldIDs: { todo: this.props.todo.id, }, }]; } getVariables() { return { id: this.props.todo.id, }; } } ================================================ FILE: examples/todo/package.json ================================================ { "private": true, "scripts": { "start": "babel-node server.js", "update-schema": "babel-node tools/updateSchema.js" }, "dependencies": { "babel-cli": "^6.26.0", "babel-core": "^6.26.0", "babel-loader": "^7.1.2", "babel-plugin-relay": "^1.4.1", "babel-polyfill": "^6.26.0", "babel-preset-env": "^1.6.1", "babel-preset-react": "^6.24.1", "babel-preset-stage-2": "^6.24.1", "classnames": "^2.2.5", "express": "^4.16.2", "express-graphql": "^0.6.11", "graphql": "^0.11.7", "graphql-relay": "^0.5.3", "graphql-relay-subscription": "^0.2.0", "history": "^2.1.2", "react": "^15.5.4", "react-dom": "^15.5.4", "react-relay": "^1.4.1", "react-router": "^2.8.1", "react-router-relay": "^0.14.0", "relay-subscriptions": "^2.0.2", "subscriptions-transport-ws": "^0.9.4", "todomvc-app-css": "^2.1.0", "todomvc-common": "^1.0.4", "webpack": "^3.8.1", "webpack-dev-server": "^2.9.4" } } ================================================ FILE: examples/todo/public/base.css ================================================ hr { margin: 20px 0; border: 0; border-top: 1px dashed #c5c5c5; border-bottom: 1px dashed #f7f7f7; } .learn a { font-weight: normal; text-decoration: none; color: #b83f45; } .learn a:hover { text-decoration: underline; color: #787e7e; } .learn h3, .learn h4, .learn h5 { margin: 10px 0; font-weight: 500; line-height: 1.2; color: #000; } .learn h3 { font-size: 24px; } .learn h4 { font-size: 18px; } .learn h5 { margin-bottom: 0; font-size: 14px; } .learn ul { padding: 0; margin: 0 0 30px 25px; } .learn li { line-height: 20px; } .learn p { font-size: 15px; font-weight: 300; line-height: 1.3; margin-top: 0; margin-bottom: 0; } #issue-count { display: none; } .quote { border: none; margin: 20px 0 60px 0; } .quote p { font-style: italic; } .quote p:before { content: '“'; font-size: 50px; opacity: .15; position: absolute; top: -20px; left: 3px; } .quote p:after { content: '”'; font-size: 50px; opacity: .15; position: absolute; bottom: -42px; right: 3px; } .quote footer { position: absolute; bottom: -40px; right: 0; } .quote footer img { border-radius: 3px; } .quote footer a { margin-left: 5px; vertical-align: middle; } .speech-bubble { position: relative; padding: 10px; background: rgba(0, 0, 0, .04); border-radius: 5px; } .speech-bubble:after { content: ''; position: absolute; top: 100%; right: 30px; border: 13px solid transparent; border-top-color: rgba(0, 0, 0, .04); } .learn-bar > .learn { position: absolute; width: 272px; top: 8px; left: -300px; padding: 10px; border-radius: 5px; background-color: rgba(255, 255, 255, .6); transition-property: left; transition-duration: 500ms; } @media (min-width: 899px) { .learn-bar { width: auto; padding-left: 300px; } .learn-bar > .learn { left: 8px; } } ================================================ FILE: examples/todo/public/index.css ================================================ html, body { margin: 0; padding: 0; } button { margin: 0; padding: 0; border: 0; background: none; font-size: 100%; vertical-align: baseline; font-family: inherit; font-weight: inherit; color: inherit; -webkit-appearance: none; appearance: none; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } body { font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; line-height: 1.4em; background: #f5f5f5; color: #4d4d4d; min-width: 230px; max-width: 550px; margin: 0 auto; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; font-weight: 300; } :focus { outline: 0; } .hidden { display: none; } .todoapp { background: #fff; margin: 130px 0 40px 0; position: relative; box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1); } .todoapp input::-webkit-input-placeholder { font-style: italic; font-weight: 300; color: #e6e6e6; } .todoapp input::-moz-placeholder { font-style: italic; font-weight: 300; color: #e6e6e6; } .todoapp input::input-placeholder { font-style: italic; font-weight: 300; color: #e6e6e6; } .todoapp h1 { position: absolute; top: -155px; width: 100%; font-size: 100px; font-weight: 100; text-align: center; color: rgba(175, 47, 47, 0.15); -webkit-text-rendering: optimizeLegibility; -moz-text-rendering: optimizeLegibility; text-rendering: optimizeLegibility; } .new-todo, .edit { position: relative; margin: 0; width: 100%; font-size: 24px; font-family: inherit; font-weight: inherit; line-height: 1.4em; border: 0; color: inherit; padding: 6px; border: 1px solid #999; box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); box-sizing: border-box; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .new-todo { padding: 16px 16px 16px 60px; border: none; background: rgba(0, 0, 0, 0.003); box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); } .main { position: relative; z-index: 2; border-top: 1px solid #e6e6e6; } label[for='toggle-all'] { display: none; } .toggle-all { position: absolute; top: -55px; left: -12px; width: 60px; height: 34px; text-align: center; border: none; /* Mobile Safari */ } .toggle-all:before { content: '❯'; font-size: 22px; color: #e6e6e6; padding: 10px 27px 10px 27px; } .toggle-all:checked:before { color: #737373; } .todo-list { margin: 0; padding: 0; list-style: none; } .todo-list li { position: relative; font-size: 24px; border-bottom: 1px solid #ededed; } .todo-list li:last-child { border-bottom: none; } .todo-list li.editing { border-bottom: none; padding: 0; } .todo-list li.editing .edit { display: block; width: 506px; padding: 12px 16px; margin: 0 0 0 43px; } .todo-list li.editing .view { display: none; } .todo-list li .toggle { text-align: center; width: 40px; /* auto, since non-WebKit browsers doesn't support input styling */ height: auto; position: absolute; top: 0; bottom: 0; margin: auto 0; border: none; /* Mobile Safari */ -webkit-appearance: none; appearance: none; } .todo-list li .toggle:after { content: url('data:image/svg+xml;utf8,'); } .todo-list li .toggle:checked:after { content: url('data:image/svg+xml;utf8,'); } .todo-list li label { word-break: break-all; padding: 15px 60px 15px 15px; margin-left: 45px; display: block; line-height: 1.2; transition: color 0.4s; } .todo-list li.completed label { color: #d9d9d9; text-decoration: line-through; } .todo-list li .destroy { display: none; position: absolute; top: 0; right: 10px; bottom: 0; width: 40px; height: 40px; margin: auto 0; font-size: 30px; color: #cc9a9a; margin-bottom: 11px; transition: color 0.2s ease-out; } .todo-list li .destroy:hover { color: #af5b5e; } .todo-list li .destroy:after { content: '×'; } .todo-list li:hover .destroy { display: block; } .todo-list li .edit { display: none; } .todo-list li.editing:last-child { margin-bottom: -1px; } .footer { color: #777; padding: 10px 15px; height: 20px; text-align: center; border-top: 1px solid #e6e6e6; } .footer:before { content: ''; position: absolute; right: 0; bottom: 0; left: 0; height: 50px; overflow: hidden; box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 0 8px 0 -3px #f6f6f6, 0 9px 1px -3px rgba(0, 0, 0, 0.2), 0 16px 0 -6px #f6f6f6, 0 17px 2px -6px rgba(0, 0, 0, 0.2); } .todo-count { float: left; text-align: left; } .todo-count strong { font-weight: 300; } .filters { margin: 0; padding: 0; list-style: none; position: absolute; right: 0; left: 0; } .filters li { display: inline; } .filters li a { color: inherit; margin: 3px; padding: 3px 7px; text-decoration: none; border: 1px solid transparent; border-radius: 3px; } .filters li a:hover { border-color: rgba(175, 47, 47, 0.1); } .filters li a.selected { border-color: rgba(175, 47, 47, 0.2); } .clear-completed, html .clear-completed:active { float: right; position: relative; line-height: 20px; text-decoration: none; cursor: pointer; } .clear-completed:hover { text-decoration: underline; } .info { margin: 65px auto 0; color: #bfbfbf; font-size: 10px; text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); text-align: center; } .info p { line-height: 1; } .info a { color: inherit; text-decoration: none; font-weight: 400; } .info a:hover { text-decoration: underline; } /* Hack to remove background from Mobile Safari. Can't use it globally since it destroys checkboxes in Firefox */ @media screen and (-webkit-min-device-pixel-ratio:0) { .toggle-all, .todo-list li .toggle { background: none; } .todo-list li .toggle { height: 40px; } .toggle-all { -webkit-transform: rotate(90deg); transform: rotate(90deg); -webkit-appearance: none; appearance: none; } } @media (max-width: 430px) { .footer { height: 50px; } .filters { bottom: 10px; } } ================================================ FILE: examples/todo/public/index.html ================================================ Relay • TodoMVC
    ================================================ FILE: examples/todo/public/learn.json ================================================ { "relay": { "name": "Relay", "description": "A JavaScript framework for building data-driven React applications", "homepage": "facebook.github.io/relay/", "examples": [{ "name": "Relay + express-graphql Example", "url": "", "source_url": "https://github.com/facebook/relay/tree/master/examples/todo", "type": "backend" }], "link_groups": [{ "heading": "Official Resources", "links": [{ "name": "Documentation", "url": "https://facebook.github.io/relay/docs/getting-started.html" }, { "name": "API Reference", "url": "https://facebook.github.io/relay/docs/api-reference-relay.html" }, { "name": "Relay on GitHub", "url": "https://github.com/facebook/relay" }] }, { "heading": "Community", "links": [{ "name": "Relay on StackOverflow", "url": "https://stackoverflow.com/questions/tagged/relayjs" }] }] }, "templates": { "todomvc": "

    <%= name %>

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

    <%= description %>

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

    <%= link_group.heading %>

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

    If you have other helpful links to share, or find any of the links above no longer work, please let us know.
    " } } ================================================ FILE: examples/todo/server.js ================================================ /** * This file provided by Facebook is for non-commercial testing and evaluation * purposes only. Facebook reserves all rights not expressly granted. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ /* eslint-disable no-console */ import express from 'express'; import graphQLHTTP from 'express-graphql'; import { execute, subscribe } from 'graphql'; import path from 'path'; import { SubscriptionServer } from 'subscriptions-transport-ws'; import webpack from 'webpack'; import WebpackDevServer from 'webpack-dev-server'; import { addNotifier } from './data/database'; import { schema } from './data/schema'; const APP_PORT = 3000; const GRAPHQL_PORT = 8080; // Expose a GraphQL endpoint const graphQLApp = express(); graphQLApp.use('/', graphQLHTTP({ schema, pretty: true, graphiql: true })); const graphQLServer = graphQLApp.listen(GRAPHQL_PORT, () => { console.log( `GraphQL Server is now running on http://localhost:${GRAPHQL_PORT}` ); }); class AsyncQueue { constructor(unsubscribe) { this.unsubscribe = unsubscribe; this.values = []; this.createPromise(); this.iterable = this.createIterable(); } createPromise() { this.promise = new Promise((resolve) => { this.resolvePromise = resolve; }); } async * createIterable() { try { while (true) { // eslint-disable-line no-constant-condition await this.promise; // eslint-disable-line no-unused-expressions for (const value of this.values) { yield value; } this.values.length = 0; this.createPromise(); } } finally { this.unsubscribe(); } } push(value) { this.values.push(value); this.resolvePromise(); } } SubscriptionServer.create( { schema, execute, subscribe, onConnect: (payload, socket) => { const topics = Object.create(null); // eslint-disable-next-line no-param-reassign socket.removeNotifier = addNotifier(({ topic, data }) => { const topicQueues = topics[topic]; if (!topicQueues) { return; } topicQueues.forEach((queue) => { queue.push(data); }); }); function unsubscribe(topic, queue) { const topicQueues = topics[topic]; const index = topicQueues.indexOf(queue); if (index === -1) { return; } topicQueues.splice(index, 1); console.log('removed subscription for %s', topic); } return { subscribe: (topic) => { if (!topics[topic]) { topics[topic] = []; } const queue = new AsyncQueue(() => { unsubscribe(topic, queue); }); topics[topic].push(queue); console.log('added subscription for %s', topic); return queue.iterable; }, }; }, onDisconnect: (socket) => { console.log('socket disconnect'); socket.removeNotifier(); }, }, { server: graphQLServer, }, ); // Serve the Relay app. const compiler = webpack({ entry: [ 'babel-polyfill', './js/app.js', ], output: { path: '/', filename: 'app.js', }, module: { rules: [ { test: /\.js$/, exclude: /node_modules/, use: 'babel-loader' }, ], }, devtool: 'sourcemap', }); const app = new WebpackDevServer(compiler, { contentBase: '/public/', proxy: { '/graphql': { target: `http://localhost:${GRAPHQL_PORT}`, ws: true, }, }, publicPath: '/js/', stats: { colors: true }, }); // Serve static resources app.use('/', express.static(path.join(__dirname, 'public'))); app.listen(APP_PORT, () => { console.log(`App is now running on http://localhost:${APP_PORT}`); }); ================================================ FILE: examples/todo/tools/.eslintrc ================================================ { "rules": { "no-console": 0 } } ================================================ FILE: examples/todo/tools/updateSchema.js ================================================ #!/usr/bin/env babel-node /** * This file provided by Facebook is for non-commercial testing and evaluation * purposes only. Facebook reserves all rights not expressly granted. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import fs from 'fs'; import path from 'path'; import { schema } from '../data/schema'; import { printSchema } from 'graphql/utilities'; fs.writeFileSync( path.join(__dirname, '../data/schema.graphql'), printSchema(schema) ); ================================================ FILE: package.json ================================================ { "name": "relay-subscriptions", "version": "2.0.2", "description": "Subscription support for Relay", "main": "lib/index.js", "scripts": { "build": "rimraf lib && babel src --ignore __tests__ --out-dir lib", "check": "flow check src", "lint": "eslint examples src", "prepublish": "npm run build", "tdd": "jest --watch", "test": "npm run lint && npm run testonly", "testonly": "jest --runInBand --verbose", "watch": "npm run build -- --watch" }, "repository": { "type": "git", "url": "git+https://github.com/edvinerikson/relay-subscriptions.git" }, "files": [ "lib" ], "keywords": [ "relay", "react", "subscriptions" ], "author": "Edvin Eriksson ", "license": "MIT", "bugs": { "url": "https://github.com/edvinerikson/relay-subscriptions/issues" }, "homepage": "https://github.com/edvinerikson/relay-subscriptions#readme", "dependencies": { "invariant": "^2.2.2", "lodash": "^4.17.4", "prop-types": "^15.5.10", "warning": "^3.0.0" }, "peerDependencies": { "react": "^0.14.9 || >=15.3.0", "react-relay": ">=1.0.0" }, "devDependencies": { "babel-cli": "^6.24.1", "babel-eslint": "^7.2.3", "babel-jest": "^20.0.3", "babel-plugin-add-module-exports": "^0.2.1", "babel-plugin-dev-expression": "^0.2.1", "babel-preset-env": "^1.5.1", "babel-preset-react": "^6.24.1", "babel-preset-stage-2": "^6.24.1", "enzyme": "^2.8.2", "eslint": "^2.13.1", "eslint-config-airbnb": "^9.0.1", "eslint-plugin-flowtype": "^2.34.0", "eslint-plugin-import": "^1.14.0", "eslint-plugin-jsx-a11y": "^1.5.5", "eslint-plugin-react": "^5.2.2", "flow-bin": "^0.47.0", "jest": "^20.0.4", "react": "^15.5.4", "react-dom": "^15.5.4", "react-relay": "^1.0.0", "react-test-renderer": "^15.5.4", "rimraf": "^2.6.1" } } ================================================ FILE: src/.flowconfig ================================================ [ignore] [include] ../node_modules/invariant ../node_modules/lodash ../node_modules/react ../node_modules/react-relay ../node_modules/warning [libs] [options] esproposal.class_static_fields=enable ================================================ FILE: src/Environment.js ================================================ /* @flow */ import invariant from 'invariant'; import Relay from 'react-relay/classic'; import RelayNetworkLayer from 'react-relay/lib/RelayNetworkLayer'; import RelayStoreData from 'react-relay/lib/RelayStoreData'; import createSubscriptionQuery from './createSubscriptionQuery'; import type Subscription from './Subscription'; import SubscriptionRequest from './SubscriptionRequest'; import type { SubscriptionDisposable, SubscriptionObserver } from './types'; import updateStoreData from './updateStoreData'; // Override a few Relay classes to use our own network layer proxy that // supports sendSubscription. class NetworkLayer extends RelayNetworkLayer { sendSubscription( subscriptionRequest: SubscriptionRequest, ): SubscriptionDisposable { const implementation = this._getImplementation(); invariant( implementation.sendSubscription, 'NetworkLayer: Network layer implementation does not support ' + 'subscriptions.', ); return implementation.sendSubscription(subscriptionRequest); } } class StoreData extends RelayStoreData { _networkLayer: NetworkLayer; constructor(...args) { super(...args); this._networkLayer = new NetworkLayer(); } } export default class Environment extends Relay.Environment { startSubscription: ( subscription: Subscription, observer?: SubscriptionObserver, ) => SubscriptionDisposable; constructor(storeData?: StoreData) { super(storeData || new StoreData()); this.startSubscription = this.startSubscription.bind(this); this._nextClientSubscriptionId = 0; } startSubscription( subscription: Subscription, observer?: SubscriptionObserver, ): SubscriptionDisposable { const clientSubscriptionId = this._nextClientSubscriptionId.toString(36); ++this._nextClientSubscriptionId; subscription.bindEnvironment(this); const query = createSubscriptionQuery(subscription.getSubscription(), { input: { ...subscription.getVariables(), clientSubscriptionId, }, }); const onPayload = (payload) => { updateStoreData(this, subscription.getConfigs(), query, payload); }; let requestObserver; if (observer) { const definedObserver = observer; // Placate Flow. requestObserver = { onNext: (payload) => { onPayload(payload); if (definedObserver.onNext) { definedObserver.onNext(payload); } }, onError: (error) => { if (definedObserver.onError) { definedObserver.onError(error); } }, onCompleted: (value) => { if (definedObserver.onCompleted) { definedObserver.onCompleted(value); } }, }; } else { requestObserver = { onNext: onPayload, onError: () => {}, onCompleted: () => {}, }; } return this._storeData.getNetworkLayer().sendSubscription( new SubscriptionRequest(query, requestObserver), ); } } ================================================ FILE: src/Subscription.js ================================================ /* @flow */ import invariant from 'invariant'; import Relay from 'react-relay/classic'; import RelayMetaRoute from 'react-relay/lib/RelayMetaRoute'; import type { MutationConfig, RelayConcreteNode, Variables, } from './types'; export default class Subscription { static name: string; static initialVariables: Variables; static prepareVariables: ?( prevVariables: Variables, route: RelayMetaRoute ) => Variables; props: Tp; _unresolvedProps: Tp; _environment: Relay.Environment; _didShowFakeDataWarning: boolean; _didValidateConfig: boolean; constructor(props: Tp) { this._didShowFakeDataWarning = false; this._didValidateConfig = false; this._unresolvedProps = props; } static getFragment(fragmentName: string, variableMapping): any { return Relay.Mutation.getFragment.call( this, fragmentName, variableMapping ); } bindEnvironment(environment: Relay.Environment): void { Relay.Mutation.prototype.bindEnvironment.call(this, environment); } getSubscription(): RelayConcreteNode { invariant( false, '%s: Expected abstract method `getSubscription` to be implemented', this.constructor.name ); } getConfigs(): Array { invariant( false, '%s: Expected abstract method `getConfigs` to be implemented', this.constructor.name ); } getVariables(): Variables { invariant( false, '%s: Expected abstract method `getVariables` to be implemented', this.constructor.name ); } _resolveProps(): void { Relay.Mutation.prototype._resolveProps.call(this); } } ================================================ FILE: src/SubscriptionRequest.js ================================================ /* @flow */ import printQuery from 'react-relay/lib/printRelayQuery'; import RelayQuery from 'react-relay/lib/RelayQuery'; import type { PrintedQuery, SubscriptionRequestObserver, SubscriptionResult, Variables, } from './types'; export default class SubscriptionRequest { _printedQuery: ?PrintedQuery; _subscription: RelayQuery.Subscription; _observer: SubscriptionRequestObserver; constructor( subscription: RelayQuery.Subscription, observer: SubscriptionRequestObserver, ) { this._printedQuery = null; this._subscription = subscription; this._observer = observer; } getDebugName(): string { return this._subscription.getName(); } getVariables(): Variables { return this._getPrintedQuery().variables; } getQueryString(): string { return this._getPrintedQuery().text; } _getPrintedQuery(): PrintedQuery { if (!this._printedQuery) { this._printedQuery = printQuery(this._subscription); } return this._printedQuery; } getClientSubscriptionId(): string { return this._subscription.getVariables().input.clientSubscriptionId; } onNext(payload: SubscriptionResult) { this._observer.onNext(payload); } onError(error: any) { this._observer.onError(error); } onCompleted(value: any) { this._observer.onCompleted(value); } } ================================================ FILE: src/__tests__/createContainer.js ================================================ jest.mock('react-relay/lib/RelayContainer', () => ({ create: (Component) => { Component.isRelayContainer = true; // eslint-disable-line no-param-reassign return Component; }, })); import { mount } from 'enzyme'; import PropTypes from 'prop-types'; import React from 'react'; import RelaySubscriptions from '..'; describe('createContainer', () => { it('should support relay.subscribe', () => { const environment = new RelaySubscriptions.Environment(); spyOn(environment, 'startSubscription'); const dummySubscription = new RelaySubscriptions.Subscription(); class Widget extends React.Component { static propTypes = { relay: PropTypes.object.isRequired, }; componentDidMount() { this.props.relay.subscribe(dummySubscription); } render() { return null; } } const WidgetContainer = RelaySubscriptions.createContainer(Widget, {}); expect(WidgetContainer.isRelayContainer).toBe(true); mount(, { context: { relay: { environment, variables: {}, }, }, }); expect(environment.startSubscription).toHaveBeenCalledWith(dummySubscription); }); }); ================================================ FILE: src/createContainer.js ================================================ /* @flow */ import isEqual from 'lodash/isEqual'; import PropTypes from 'prop-types'; import React from 'react'; import Relay from 'react-relay/classic'; import type { RelayContainerSpec } from 'react-relay/lib/RelayContainer'; import type Subscription from './Subscription'; import type { SubscriptionDisposable } from './types'; type subscriptionFn = (props: Object) => ?Subscription; type ActiveSubscription = { subscription: Subscription, disposable: SubscriptionDisposable, } function disposeActiveSubscription(activeSubscription) { if (!activeSubscription) { return; } activeSubscription.disposable.dispose(); } function subscribe( Component: ReactClass, subscriptionsSpec: ?Array, ) { const componentName = Component.displayName || Component.name || 'Component'; return class Subscribe extends React.Component { static displayName = `Subscribe(${componentName})`; static propTypes = { relay: PropTypes.object.isRequired, }; static contextTypes = { relay: Relay.PropTypes.ClassicRelay, }; relayProp: mixed; activeSubscriptions: Array; constructor(props, context) { super(props, context); this.relayProp = this.makeRelayProp(props); this.activeSubscriptions = []; } componentDidMount() { if (subscriptionsSpec) { subscriptionsSpec.forEach((createSubscription) => { this.activeSubscriptions.push( this.makeActiveSubscription(createSubscription(this.props)), ); }); } } componentWillReceiveProps(nextProps) { if (nextProps.relay !== this.props.relay) { this.relayProp = this.makeRelayProp(nextProps); } if (subscriptionsSpec) { subscriptionsSpec.forEach((createSubscription, index) => { const activeSubscription = this.activeSubscriptions[index]; const nextSubscription = createSubscription(nextProps); if (!this.areSubscriptionsEqual( activeSubscription, nextSubscription, )) { disposeActiveSubscription(activeSubscription); this.activeSubscriptions[index] = this.makeActiveSubscription(nextSubscription); } }); } } componentWillUnmount() { if (subscriptionsSpec) { this.activeSubscriptions.forEach(disposeActiveSubscription); } } makeRelayProp(props) { return { ...props.relay, subscribe: this.context.relay.environment.startSubscription, }; } makeActiveSubscription(subscription) { if (!subscription) { return null; } return { subscription, disposable: this.context.relay.environment.startSubscription(subscription), }; } areSubscriptionsEqual(activeSubscription, nextSubscription) { if (!nextSubscription && !activeSubscription) { // Both old and new are falsy. return true; } if (!nextSubscription || !activeSubscription) { // Only one of the pair is falsy. return false; } const subscription = activeSubscription.subscription; if (nextSubscription.constructor !== subscription.constructor) { // Subscriptions are of different types. return false; } // Need to bind subscription to Relay environment to get variables. nextSubscription.bindEnvironment(this.context.relay.environment); // Check if variables match. return isEqual( nextSubscription.getVariables(), subscription.getVariables(), ); } render() { return ( ); } }; } export default function createContainer( Component: ReactClass, spec: RelayContainerSpec & { subscriptions?: subscriptionFn[] }, ) { return Relay.createContainer( subscribe(Component, spec.subscriptions), spec ); } ================================================ FILE: src/createSubscriptionQuery.js ================================================ /* @flow */ import RelayMetaRoute from 'react-relay/lib/RelayMetaRoute'; import RelayQuery from 'react-relay/lib/RelayQuery'; import type { RelayConcreteNode, Variables } from './types'; export default function createSubscriptionQuery( concreteNode: RelayConcreteNode, variables: Variables ): RelayQuery.Subscription { return RelayQuery.Subscription.create( concreteNode, RelayMetaRoute.get('$createSubscriptionQuery'), variables ); } ================================================ FILE: src/index.js ================================================ /* @flow */ import createContainer from './createContainer'; import Environment from './Environment'; import Subscription from './Subscription'; export default { createContainer, Environment, Subscription }; export { createContainer, Environment, Subscription }; ================================================ FILE: src/types.js ================================================ /* @flow */ export type Variables = {[name: string]: mixed}; export type RelayConcreteNode = {[name: string]: mixed}; export type MutationConfig = { type: string; [name: string]: mixed; }; export type PrintedQuery = { text: string; variables: Variables; }; export type SubscriptionResult = {[name: string]: mixed}; export type SubscriptionObserver = { onNext?: (value: SubscriptionResult) => void; onError?: (error: any) => void; onCompleted?: (value: any) => void; } export type SubscriptionRequestObserver = { onNext: (value: SubscriptionResult) => void; onError: (error: any) => void; onCompleted: (value: any) => void; } export type SubscriptionDisposable = { dispose: () => void; } ================================================ FILE: src/updateStoreData.js ================================================ /* @flow */ import type Relay from 'react-relay/classic'; import type RelayQuery from 'react-relay/lib/RelayQuery'; import type { MutationConfig, SubscriptionResult, } from './types'; export default function updateStoreData( environment: Relay.Environment, configs: Array, query: RelayQuery.Operation, payload: SubscriptionResult ) { const storeData = environment.getStoreData(); const payloadName = query.getCall().name; // FIXME: Applying a RANGE_ADD update requires a clientMutationId. This is a // nonce that won't collide with any actual mutation IDs. const clientMutationId = Math.random().toString(36); storeData.handleUpdatePayload( query, { ...payload[payloadName], clientMutationId }, { configs } ); }