├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public └── index.html ├── relay.config.json ├── schema.graphql ├── src ├── __generated__ │ ├── storeClearCompletedQuery.graphql.js │ ├── storeCompleteAllQuery.graphql.js │ ├── storeStateQuery.graphql.js │ └── storeVisibilityFilterUpdaterQuery.graphql.js ├── actions │ └── index.js ├── components │ ├── App.js │ ├── FilterLink.js │ ├── Footer.js │ ├── Header.js │ ├── MainSection.js │ ├── TodoItem.js │ ├── TodoTextInput.js │ ├── VisibleTodoList.js │ └── __generated__ │ │ ├── AppQuery.graphql.js │ │ ├── FilterLink.graphql.js │ │ ├── Footer.graphql.js │ │ ├── MainSection.graphql.js │ │ ├── TodoItem.graphql.js │ │ └── VisibleTodoList.graphql.js ├── constants │ ├── ActionTypes.js │ └── TodoFilters.js ├── index.js ├── reducers │ ├── index.js │ ├── todos.js │ └── visibilityFilter.js ├── relay │ ├── RelayEnvironment.js │ ├── fetchGraphQL.js │ ├── resolvers │ │ ├── RootCompletedTodosCountResolver.js │ │ ├── RootTodosCountResolver.js │ │ ├── RootVisibleTodosResolver.js │ │ └── __generated__ │ │ │ ├── RootCompletedTodosCountResolver.graphql.js │ │ │ ├── RootTodosCountResolver.graphql.js │ │ │ └── RootVisibleTodosResolver.graphql.js │ └── schemaExtensions │ │ └── extensions.graphql └── store.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # production 7 | build 8 | 9 | # misc 10 | .DS_Store 11 | npm-debug.log 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Example: Migrating a Redux App to Relay using Live Resolvers 2 | 3 | [Relay Resolvers](https://relay.dev/docs/next/guides/relay-resolvers/) are a feature in [Relay](https://relay.dev) which allows you to model derived state in your Relay GraphQL schema. Live Resolvers (currently undocumented) are an experimental variant of Relay Resolvers which allow you to expose non-Relay dynamic client state in your Relay GraphQL schema. 4 | 5 | While useful on thier own, they can be combined to provide an incremental migration path from a legacy client data provider -- like Redux -- onto Relay. 6 | 7 | In this repository we aim to migrate an [example Redux app](https://github.com/reduxjs/redux/tree/master/examples/todomvc) to Relay incrementally. 8 | 9 | ## Caveats 10 | 11 | This example is a bit strained since it is only modeling client state. In most real apps you would have a combination of client and server state. 12 | 13 | **This repository is an exporatory work in progress.** 14 | 15 | # The Migration Steps 16 | 17 | Below the proposed migration strategy is broken into discrete steps with diagrams showing how data flow works at each stage. 18 | 19 | While this document and repository shows each step being completed in a single commit/step, in reality each step can be able to be split up into many incremental sub-steps where a single selector/component/resolver/action is migrated at a time. 20 | 21 | ## 0. Redux Only 22 | 23 | [Browse the code at this point](https://github.com/captbaritone/redux-to-relay-with-live-resolvers-example/tree/step-0) 24 | 25 | To set the stage, we'll begin with a diagram of how the app is originally structure using pure Redux: 26 | 27 | ```mermaid 28 | graph BT 29 | subgraph Stores 30 | REDUX[(Redux Store)] 31 | end 32 | 33 | subgraph Selectors 34 | REDUX --> GET_VISIBILITY_FILTER{{getVisibilityFilter}} 35 | REDUX --> GET_TODOS{{getTodos}} 36 | REDUX --> GET_COMPLETED_TODO_COUNT{{getCompletedTodoCount}} 37 | REDUX --> GET_TODOS_COUNT{{state.todos.length}} 38 | GET_VISIBILITY_FILTER --> GET_VISIBLE_TODOS{{getVisibileTodos}} 39 | GET_TODOS --> GET_VISIBLE_TODOS{{getVisibileTodos}} 40 | end 41 | 42 | subgraph Components 43 | GET_VISIBLE_TODOS --> VISIBLE_TODOS_LIST["#lt;VisibileTodosList />"] 44 | GET_COMPLETED_TODO_COUNT ---> MAIN_SECTION["#lt;MainSection />"] 45 | GET_TODOS_COUNT ---> MAIN_SECTION 46 | GET_VISIBILITY_FILTER ---> FILTER_LINK["#lt;FilterLink />"] 47 | end 48 | ``` 49 | 50 | ## 1. Expose Redux selectors as Live Resolvers in the Relay GraphQL Schema 51 | 52 | [Browse the code at this point](https://github.com/captbaritone/redux-to-relay-with-live-resolvers-example/tree/step-1) 53 | 54 | First we schematize the Redux state, and it's derived selector state, in a GraphQL schema using Live Resolvers. that done, we can replace all instances of `mapStateToProps` with components locally declaring their data dependenceis via `useFragment` with a single `useLazyLoadQuery` at the application root. 55 | 56 | At this point all components in the app are reading via Relay APIs. 57 | 58 | > The source of truth for buiness logic is still the selectors. Legacy parts of the app could still confidently use selectors. 59 | 60 | ```mermaid 61 | graph BT 62 | subgraph Stores 63 | REDUX[(Redux Store)] 64 | end 65 | 66 | subgraph Selectors 67 | REDUX --> GET_VISIBILITY_FILTER{{getVisibilityFilter}} 68 | REDUX --> GET_TODOS{{getTodos}} 69 | REDUX --> GET_COMPLETED_TODO_COUNT{{getCompletedTodoCount}} 70 | REDUX --> GET_TODOS_COUNT{{state.todos.length}} 71 | GET_VISIBILITY_FILTER --> GET_VISIBLE_TODOS{{getVisibileTodos}} 72 | GET_TODOS --> GET_VISIBLE_TODOS{{getVisibileTodos}} 73 | end 74 | 75 | subgraph Live Resolvers 76 | %% This resolver exists, but for a pure version 77 | %% of this strategy, it should be a live resolver 78 | %% GET_TODOS --> ALL_TODOS_RESOLVER[Query.all_todos] 79 | GET_COMPLETED_TODO_COUNT ---> COMPLETED_TODOS_COUNT_RESOLVER[Query.completed_todos_count] 80 | GET_VISIBILITY_FILTER ---> VISIBILITY_FILTER_RESOLVER[Query.visibility_filter] 81 | GET_VISIBLE_TODOS ---> VISIBLE_TODOS_RESOLVER[Query.visible_todos] 82 | GET_TODOS_COUNT --> TODOS_COUNT_RESOLVER[Query.todos_count] 83 | end 84 | 85 | subgraph Components 86 | VISIBLE_TODOS_RESOLVER --> VISIBLE_TODOS_LIST["#lt;VisibileTodosList />"] 87 | COMPLETED_TODOS_COUNT_RESOLVER ---> MAIN_SECTION["#lt;MainSection />"] 88 | TODOS_COUNT_RESOLVER ---> MAIN_SECTION 89 | VISIBILITY_FILTER_RESOLVER ---> FILTER_LINK["#lt;FilterLink />"] 90 | end 91 | ``` 92 | 93 | ## 2. Move Redux state into Local Schema Extensions 94 | 95 | [Browse the code at this point](https://github.com/captbaritone/redux-to-relay-with-live-resolvers-example/tree/step-2) 96 | 97 | The concrete state stored in the Redux store can be moved into Relay using Relay's [Client Schema Extension](https://relay.dev/docs/guides/client-schema-extensions/). In this approach the public API of the Redux store (`.getState()`, `.dispatch(action)`, `.subscribe(callback)`) are left unchanged, but the implemenation moves from Redux to Relay. 98 | 99 | This new class uses the Relay store as its source of truth. `Store.dispatch(action)` result in writing to the Relay store (and notifying all subscribers) and `Store.getState()` reads from the Relay Store and derives a backwards compatibile state object. 100 | 101 | > While this document shows these steps as linear, this migration step can actually be taken on in parallel with step 1. 102 | 103 | ```mermaid 104 | graph BT 105 | subgraph Redux Compatibility 106 | RELAY[(Relay Store)] 107 | RELAY --> VISIBILITY_FILTER[Query.FLUX_visibility_filter] 108 | RELAY --> ALL_TODO[Query.FLUX_all_todos] 109 | 110 | VISIBILITY_FILTER --> REDUX_RESOLVER["_stateFromQuery(data)"] 111 | ALL_TODO --> REDUX_RESOLVER 112 | end 113 | 114 | subgraph Selectors 115 | REDUX_RESOLVER --> GET_VISIBILITY_FILTER{{getVisibilityFilter}} 116 | REDUX_RESOLVER --> GET_TODOS{{getTodos}} 117 | REDUX_RESOLVER --> GET_COMPLETED_TODO_COUNT{{getCompletedTodoCount}} 118 | REDUX_RESOLVER --> GET_TODOS_COUNT{{state.todos.length}} 119 | GET_VISIBILITY_FILTER --> GET_VISIBLE_TODOS{{getVisibileTodos}} 120 | GET_TODOS --> GET_VISIBLE_TODOS{{getVisibileTodos}} 121 | end 122 | 123 | subgraph Live Resolvers 124 | %% This resolver exists, but for a pure version 125 | %% of this strategy, it should be a live resolver 126 | %% GET_TODOS --> ALL_TODOS_RESOLVER[Query.all_todos] 127 | GET_COMPLETED_TODO_COUNT ---> COMPLETED_TODOS_COUNT_RESOLVER[Query.completed_todos_count] 128 | GET_VISIBILITY_FILTER ---> VISIBILITY_FILTER_RESOLVER[Query.visibility_filter] 129 | GET_VISIBLE_TODOS ---> VISIBLE_TODOS_RESOLVER[Query.visible_todos] 130 | GET_TODOS_COUNT --> TODOS_COUNT_RESOLVER[Query.todos_count] 131 | end 132 | 133 | subgraph Components 134 | VISIBLE_TODOS_RESOLVER --> VISIBLE_TODOS_LIST["#lt;VisibileTodosList />"] 135 | COMPLETED_TODOS_COUNT_RESOLVER ---> MAIN_SECTION["#lt;MainSection />"] 136 | TODOS_COUNT_RESOLVER ---> MAIN_SECTION 137 | VISIBILITY_FILTER_RESOLVER ---> FILTER_LINK["#lt;FilterLink />"] 138 | end 139 | ``` 140 | 141 | ## 3. Move selector logic into Relay Resolvers 142 | 143 | [Browse the code at this point](https://github.com/captbaritone/redux-to-relay-with-live-resolvers-example/tree/step-3) 144 | 145 | Now that all calls to selectors have been replaced by GraphQL reads, selectors are now just an implementation detail of Relay Resolvers. We can now move that code directly into the Resolvers, disolving the selectors in the process. In the case of nested selectors, we read the inner selector value from Relay via the Resolver's root fragment. 146 | 147 | > **Note** In this step the Redux Compatibility class is pure overhead, but I've included it as a discrete step for clarity. 148 | 149 | ```mermaid 150 | graph BT 151 | subgraph Redux Compatibility 152 | RELAY[(Relay Store)] 153 | RELAY --> VISIBILITY_FILTER[Query.FLUX_visibility_filter] 154 | RELAY --> ALL_TODO[Query.FLUX_all_todos] 155 | 156 | VISIBILITY_FILTER --> REDUX_RESOLVER["_stateFromQuery(data)"] 157 | ALL_TODO --> REDUX_RESOLVER 158 | end 159 | 160 | subgraph Resolvers 161 | subgraph Live Resolvers 162 | REDUX_RESOLVER --> ALL_TODOS_RESOLVER[Query.all_todos] 163 | REDUX_RESOLVER ---> VISIBILITY_FILTER_RESOLVER[Query.visibility_filter] 164 | end 165 | subgraph Relay Resolvers 166 | ALL_TODOS_RESOLVER ---> COMPLETED_TODOS_COUNT_RESOLVER[Query.completed_todos_count] 167 | ALL_TODOS_RESOLVER --> TODOS_COUNT_RESOLVER[Query.todos_count] 168 | ALL_TODOS_RESOLVER ---> VISIBLE_TODOS_RESOLVER[Query.visible_todos] 169 | VISIBILITY_FILTER_RESOLVER --> VISIBLE_TODOS_RESOLVER 170 | end 171 | end 172 | 173 | subgraph Components 174 | VISIBLE_TODOS_RESOLVER --> VISIBLE_TODOS_LIST["#lt;VisibileTodosList />"] 175 | COMPLETED_TODOS_COUNT_RESOLVER ---> MAIN_SECTION["#lt;MainSection />"] 176 | TODOS_COUNT_RESOLVER ---> MAIN_SECTION 177 | VISIBILITY_FILTER_RESOLVER ---> FILTER_LINK["#lt;FilterLink />"] 178 | end 179 | ``` 180 | 181 | ## 4. Replace Live Resolvers and Redux Compatibility with direct reads into Relay 182 | 183 | [Browse the code at this point](https://github.com/captbaritone/redux-to-relay-with-live-resolvers-example/tree/step-4) 184 | 185 | Once the store implementation has moved to using the Relay store as its source of truth, Live Resolvers can bypass their subscriptions to the Redux store, and be replaced with direct reads into the Relay store. 186 | 187 | At this point `react-redux` remains in the app purely as a mechanism for dispatching and handling actions. All state is read directly through Relay and local state updates are performed through Relay APIs. 188 | 189 | An optional fifth step would be to move the action handlers into hooks which are used by the components which currently dispatch those actions. 190 | 191 | > Note how this can be implemented exclusively by deleting live resolvers which simply exposed legacy Flux state, and updating the compatibility store. 192 | 193 | ```mermaid 194 | graph BT 195 | subgraph Relay 196 | RELAY[(Relay Store)] 197 | RELAY --> VISIBILITY_FILTER[Query.visibility_filter] 198 | RELAY --> ALL_TODO[Query.all_todos] 199 | end 200 | 201 | subgraph Relay Resolvers 202 | ALL_TODO ---> COMPLETED_TODOS_COUNT_RESOLVER[Query.completed_todos_count] 203 | ALL_TODO --> TODOS_COUNT_RESOLVER[Query.todos_count] 204 | ALL_TODO ---> VISIBLE_TODOS_RESOLVER[Query.visible_todos] 205 | VISIBILITY_FILTER --> VISIBLE_TODOS_RESOLVER 206 | end 207 | 208 | subgraph Components 209 | VISIBLE_TODOS_RESOLVER --> VISIBLE_TODOS_LIST["#lt;VisibileTodosList />"] 210 | COMPLETED_TODOS_COUNT_RESOLVER ---> MAIN_SECTION["#lt;MainSection />"] 211 | TODOS_COUNT_RESOLVER ---> MAIN_SECTION 212 | VISIBILITY_FILTER ---> FILTER_LINK["#lt;FilterLink />"] 213 | end 214 | ``` 215 | 216 | # Using this Repo: 217 | 218 | If you'd like to try this repo locally, see below: 219 | 220 | ## Available Scripts 221 | 222 | In the project directory, you can run: 223 | 224 | ### `yarn start` 225 | 226 | Runs the app in the development mode.
227 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 228 | 229 | The page will reload if you make edits.
230 | You will also see any lint errors in the console. 231 | 232 | ### `yarn build` 233 | 234 | Builds the app for production to the `build` folder.
235 | It correctly bundles React in production mode and optimizes the build for the best performance. 236 | 237 | The build is minified and the filenames include the hashes.
238 | Your app is ready to be deployed! 239 | 240 | ### `yarn relay` 241 | 242 | This will run the Relay compiler. Use `yarn relay watch` to start the compiler in watch mode. 243 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todomvc", 3 | "version": "0.0.1", 4 | "private": true, 5 | "devDependencies": { 6 | "prettier": "^2.6.2", 7 | "react-scripts": "^4.0.3", 8 | "react-test-renderer": "^16.13.1", 9 | "babel-plugin-relay": "^13.2.0", 10 | "relay-compiler": "0.0.0-main-b3f6189c" 11 | }, 12 | "dependencies": { 13 | "classnames": "^2.2.6", 14 | "react": "^17.0.2", 15 | "react-dom": "^17.0.2", 16 | "react-redux": "^7.2.0", 17 | "redux": "^4.0.5", 18 | "reselect": "^4.0.0", 19 | "todomvc-app-css": "^2.3.0", 20 | "react-relay": "0.0.0-main-b3f6189c", 21 | "relay-runtime": "0.0.0-main-b3f6189c" 22 | }, 23 | "scripts": { 24 | "start": "react-scripts start", 25 | "build": "react-scripts build", 26 | "eject": "react-scripts eject", 27 | "test": "react-scripts test --env=node", 28 | "relay": "relay-compiler" 29 | }, 30 | "browserslist": [ 31 | ">0.2%", 32 | "not dead", 33 | "not ie <= 11", 34 | "not op_mini all" 35 | ], 36 | "prettier": {} 37 | } 38 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Redux TodoMVC Example 7 | 8 | 9 |
10 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /relay.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "src": "./src", 3 | "schema": "./schema.graphql", 4 | "language": "javascript", 5 | "featureFlags": { 6 | "enable_relay_resolver_transform": true, 7 | "enable_client_edges": { "kind": "enabled" } 8 | }, 9 | "schemaExtensions": ["./src/relay/schemaExtensions"] 10 | } 11 | -------------------------------------------------------------------------------- /schema.graphql: -------------------------------------------------------------------------------- 1 | # Dummy server schema. 2 | # For this example app we are only using client state. 3 | schema { 4 | query: Root 5 | } 6 | 7 | type Root { 8 | dummy_server_field: String 9 | } 10 | -------------------------------------------------------------------------------- /src/__generated__/storeClearCompletedQuery.graphql.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @generated SignedSource<<1b1784f1e1cb22d0d2d3cda9aa977a81>> 3 | * @lightSyntaxTransform 4 | * @nogrep 5 | */ 6 | 7 | /* eslint-disable */ 8 | 9 | 'use strict'; 10 | 11 | var node = { 12 | "fragment": { 13 | "argumentDefinitions": [], 14 | "kind": "Fragment", 15 | "metadata": null, 16 | "name": "storeClearCompletedQuery", 17 | "selections": [ 18 | { 19 | "kind": "ClientExtension", 20 | "selections": [ 21 | { 22 | "alias": null, 23 | "args": null, 24 | "concreteType": "Todo", 25 | "kind": "LinkedField", 26 | "name": "all_todos", 27 | "plural": true, 28 | "selections": [ 29 | { 30 | "alias": null, 31 | "args": null, 32 | "kind": "ScalarField", 33 | "name": "__id", 34 | "storageKey": null 35 | }, 36 | { 37 | "alias": null, 38 | "args": null, 39 | "kind": "ScalarField", 40 | "name": "completed", 41 | "storageKey": null 42 | } 43 | ], 44 | "storageKey": null 45 | } 46 | ] 47 | } 48 | ], 49 | "type": "Root", 50 | "abstractKey": null 51 | }, 52 | "kind": "UpdatableQuery" 53 | }; 54 | 55 | node.hash = "c830a9c219ec572a072c18bb9174a9fb"; 56 | 57 | module.exports = node; 58 | -------------------------------------------------------------------------------- /src/__generated__/storeCompleteAllQuery.graphql.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @generated SignedSource<> 3 | * @lightSyntaxTransform 4 | * @nogrep 5 | */ 6 | 7 | /* eslint-disable */ 8 | 9 | 'use strict'; 10 | 11 | var node = { 12 | "fragment": { 13 | "argumentDefinitions": [], 14 | "kind": "Fragment", 15 | "metadata": null, 16 | "name": "storeCompleteAllQuery", 17 | "selections": [ 18 | { 19 | "kind": "ClientExtension", 20 | "selections": [ 21 | { 22 | "alias": null, 23 | "args": null, 24 | "concreteType": "Todo", 25 | "kind": "LinkedField", 26 | "name": "all_todos", 27 | "plural": true, 28 | "selections": [ 29 | { 30 | "alias": null, 31 | "args": null, 32 | "kind": "ScalarField", 33 | "name": "completed", 34 | "storageKey": null 35 | } 36 | ], 37 | "storageKey": null 38 | } 39 | ] 40 | } 41 | ], 42 | "type": "Root", 43 | "abstractKey": null 44 | }, 45 | "kind": "UpdatableQuery" 46 | }; 47 | 48 | node.hash = "40f4468e11eebd1392e43d7194e94b3b"; 49 | 50 | module.exports = node; 51 | -------------------------------------------------------------------------------- /src/__generated__/storeStateQuery.graphql.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @generated SignedSource<> 3 | * @lightSyntaxTransform 4 | * @nogrep 5 | */ 6 | 7 | /* eslint-disable */ 8 | 9 | 'use strict'; 10 | 11 | var node = (function(){ 12 | var v0 = [ 13 | { 14 | "kind": "ClientExtension", 15 | "selections": [ 16 | { 17 | "alias": null, 18 | "args": null, 19 | "concreteType": "Todo", 20 | "kind": "LinkedField", 21 | "name": "all_todos", 22 | "plural": true, 23 | "selections": [ 24 | { 25 | "alias": null, 26 | "args": null, 27 | "kind": "ScalarField", 28 | "name": "id", 29 | "storageKey": null 30 | }, 31 | { 32 | "alias": null, 33 | "args": null, 34 | "kind": "ScalarField", 35 | "name": "text", 36 | "storageKey": null 37 | }, 38 | { 39 | "alias": null, 40 | "args": null, 41 | "kind": "ScalarField", 42 | "name": "completed", 43 | "storageKey": null 44 | } 45 | ], 46 | "storageKey": null 47 | }, 48 | { 49 | "alias": null, 50 | "args": null, 51 | "kind": "ScalarField", 52 | "name": "visibility_filter", 53 | "storageKey": null 54 | } 55 | ] 56 | } 57 | ]; 58 | return { 59 | "fragment": { 60 | "argumentDefinitions": [], 61 | "kind": "Fragment", 62 | "metadata": null, 63 | "name": "storeStateQuery", 64 | "selections": (v0/*: any*/), 65 | "type": "Root", 66 | "abstractKey": null 67 | }, 68 | "kind": "Request", 69 | "operation": { 70 | "argumentDefinitions": [], 71 | "kind": "Operation", 72 | "name": "storeStateQuery", 73 | "selections": (v0/*: any*/) 74 | }, 75 | "params": { 76 | "cacheID": "45b902dadb3555c469df635a325839ca", 77 | "id": null, 78 | "metadata": {}, 79 | "name": "storeStateQuery", 80 | "operationKind": "query", 81 | "text": null 82 | } 83 | }; 84 | })(); 85 | 86 | node.hash = "b4dcba6cf943125b5ab07d0ee4437bf0"; 87 | 88 | module.exports = node; 89 | -------------------------------------------------------------------------------- /src/__generated__/storeVisibilityFilterUpdaterQuery.graphql.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @generated SignedSource<<2fdb8bed91c6eb65e773fa9a9db9d9f7>> 3 | * @lightSyntaxTransform 4 | * @nogrep 5 | */ 6 | 7 | /* eslint-disable */ 8 | 9 | 'use strict'; 10 | 11 | var node = { 12 | "fragment": { 13 | "argumentDefinitions": [], 14 | "kind": "Fragment", 15 | "metadata": null, 16 | "name": "storeVisibilityFilterUpdaterQuery", 17 | "selections": [ 18 | { 19 | "kind": "ClientExtension", 20 | "selections": [ 21 | { 22 | "alias": null, 23 | "args": null, 24 | "kind": "ScalarField", 25 | "name": "visibility_filter", 26 | "storageKey": null 27 | } 28 | ] 29 | } 30 | ], 31 | "type": "Root", 32 | "abstractKey": null 33 | }, 34 | "kind": "UpdatableQuery" 35 | }; 36 | 37 | node.hash = "5dfcf78f420ec0bf84449dac98d6d5ca"; 38 | 39 | module.exports = node; 40 | -------------------------------------------------------------------------------- /src/actions/index.js: -------------------------------------------------------------------------------- 1 | import * as types from "../constants/ActionTypes"; 2 | 3 | export const addTodo = (text) => ({ type: types.ADD_TODO, text }); 4 | export const deleteTodo = (id) => ({ type: types.DELETE_TODO, id }); 5 | export const editTodo = (id, text) => ({ type: types.EDIT_TODO, id, text }); 6 | export const completeTodo = (id) => ({ type: types.COMPLETE_TODO, id }); 7 | export const completeAllTodos = () => ({ type: types.COMPLETE_ALL_TODOS }); 8 | export const clearCompleted = () => ({ type: types.CLEAR_COMPLETED }); 9 | export const setVisibilityFilter = (filter) => ({ 10 | type: types.SET_VISIBILITY_FILTER, 11 | filter, 12 | }); 13 | -------------------------------------------------------------------------------- /src/components/App.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Header from "./Header"; 3 | import MainSection from "./MainSection"; 4 | import useLazyLoadQuery from "react-relay/lib/relay-hooks/useLazyLoadQuery"; 5 | import graphql from "babel-plugin-relay/macro"; 6 | 7 | function App() { 8 | const query = useLazyLoadQuery( 9 | graphql` 10 | query AppQuery { 11 | ...MainSection 12 | } 13 | ` 14 | ); 15 | return ( 16 |
17 |
18 | 19 |
20 | ); 21 | } 22 | 23 | export default App; 24 | -------------------------------------------------------------------------------- /src/components/FilterLink.js: -------------------------------------------------------------------------------- 1 | import graphql from "babel-plugin-relay/macro"; 2 | import useFragment from "react-relay/lib/relay-hooks/useFragment"; 3 | import classnames from "classnames"; 4 | 5 | import { connect } from "react-redux"; 6 | import { setVisibilityFilter } from "../actions"; 7 | 8 | function FilterLink({ query: queryKey, filter, children, setFilter }) { 9 | const data = useFragment( 10 | graphql` 11 | fragment FilterLink on Root { 12 | visibility_filter 13 | } 14 | `, 15 | queryKey 16 | ); 17 | return ( 18 | // eslint-disable jsx-a11y/anchor-is-valid 19 | setFilter()} 23 | > 24 | {children} 25 | 26 | ); 27 | } 28 | 29 | const mapDispatchToProps = (dispatch, ownProps) => ({ 30 | setFilter: () => { 31 | dispatch(setVisibilityFilter(ownProps.filter)); 32 | }, 33 | }); 34 | 35 | export default connect(null, mapDispatchToProps)(FilterLink); 36 | -------------------------------------------------------------------------------- /src/components/Footer.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import FilterLink from "./FilterLink"; 3 | import { 4 | SHOW_ALL, 5 | SHOW_COMPLETED, 6 | SHOW_ACTIVE, 7 | } from "../constants/TodoFilters"; 8 | import graphql from "babel-plugin-relay/macro"; 9 | import useFragment from "react-relay/lib/relay-hooks/useFragment"; 10 | 11 | const FILTER_TITLES = { 12 | [SHOW_ALL]: "All", 13 | [SHOW_ACTIVE]: "Active", 14 | [SHOW_COMPLETED]: "Completed", 15 | }; 16 | 17 | const Footer = ({ onClearCompleted, query: queryKey }) => { 18 | const data = useFragment( 19 | graphql` 20 | fragment Footer on Root { 21 | todos_count 22 | completed_todos_count 23 | ...FilterLink 24 | } 25 | `, 26 | queryKey 27 | ); 28 | const activeCount = data.todos_count - data.completed_todos_count; 29 | const itemWord = activeCount === 1 ? "item" : "items"; 30 | return ( 31 |
32 | 33 | {activeCount || "No"} {itemWord} left 34 | 35 |
    36 | {Object.keys(FILTER_TITLES).map((filter) => ( 37 |
  • 38 | 39 | {FILTER_TITLES[filter]} 40 | 41 |
  • 42 | ))} 43 |
44 | {!!data.completed_todos_count && ( 45 | 48 | )} 49 |
50 | ); 51 | }; 52 | 53 | export default Footer; 54 | -------------------------------------------------------------------------------- /src/components/Header.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import TodoTextInput from "./TodoTextInput"; 3 | import { connect } from "react-redux"; 4 | import { addTodo } from "../actions"; 5 | 6 | const Header = ({ addTodo }) => { 7 | return ( 8 |
9 |

todos

10 | { 13 | if (text.length !== 0) { 14 | addTodo(text); 15 | } 16 | }} 17 | placeholder="What needs to be done?" 18 | /> 19 |
20 | ); 21 | }; 22 | 23 | export default connect(null, { addTodo })(Header); 24 | -------------------------------------------------------------------------------- /src/components/MainSection.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Footer from "./Footer"; 3 | import VisibleTodoList from "./VisibleTodoList"; 4 | import { connect } from "react-redux"; 5 | import * as TodoActions from "../actions"; 6 | import { bindActionCreators } from "redux"; 7 | import useFragment from "react-relay/lib/relay-hooks/useFragment"; 8 | import graphql from "babel-plugin-relay/macro"; 9 | 10 | const MainSection = ({ query: queryKey, actions }) => { 11 | const data = useFragment( 12 | graphql` 13 | fragment MainSection on Root { 14 | todosCount: todos_count 15 | completedCount: completed_todos_count 16 | ...VisibleTodoList 17 | ...Footer 18 | } 19 | `, 20 | queryKey 21 | ); 22 | const { todosCount, completedCount } = data; 23 | return ( 24 |
25 | {!!todosCount && ( 26 | 27 | 33 | 35 | )} 36 | 37 | {!!todosCount && ( 38 |
39 | )} 40 |
41 | ); 42 | }; 43 | 44 | const mapDispatchToProps = (dispatch) => ({ 45 | actions: bindActionCreators(TodoActions, dispatch), 46 | }); 47 | 48 | export default connect(null, mapDispatchToProps)(MainSection); 49 | -------------------------------------------------------------------------------- /src/components/TodoItem.js: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from "react"; 2 | import classnames from "classnames"; 3 | import TodoTextInput from "./TodoTextInput"; 4 | import graphql from "babel-plugin-relay/macro"; 5 | import useFragment from "react-relay/lib/relay-hooks/useFragment"; 6 | 7 | export default function TodoItem({ 8 | todo: todoKey, 9 | editTodo, 10 | deleteTodo, 11 | completeTodo, 12 | }) { 13 | const todo = useFragment( 14 | graphql` 15 | fragment TodoItem on Todo { 16 | id 17 | completed 18 | text 19 | } 20 | `, 21 | todoKey 22 | ); 23 | const [editing, setEditing] = useState(false); 24 | 25 | const handleDoubleClick = () => { 26 | setEditing(true); 27 | }; 28 | 29 | const handleSave = useCallback( 30 | (id, text) => { 31 | if (text.length === 0) { 32 | deleteTodo(id); 33 | } else { 34 | editTodo(id, text); 35 | } 36 | setEditing(false); 37 | }, 38 | [editTodo, deleteTodo] 39 | ); 40 | 41 | let element; 42 | if (editing) { 43 | element = ( 44 | handleSave(todo.id, text)} 48 | /> 49 | ); 50 | } else { 51 | element = ( 52 |
53 | completeTodo(todo.id)} 58 | /> 59 | 60 |
62 | ); 63 | } 64 | 65 | return ( 66 |
  • 72 | {element} 73 |
  • 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /src/components/TodoTextInput.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import classnames from "classnames"; 3 | 4 | export default function TodoTextInput({ 5 | text: initialText, 6 | onSave, 7 | newTodo, 8 | placeholder, 9 | editing, 10 | }) { 11 | const [text, setText] = React.useState(initialText || ""); 12 | 13 | const handleSubmit = (e) => { 14 | const text = e.target.value.trim(); 15 | if (e.which === 13) { 16 | onSave(text); 17 | if (newTodo) { 18 | setText(""); 19 | } 20 | } 21 | }; 22 | 23 | const handleChange = (e) => { 24 | setText(e.target.value); 25 | }; 26 | 27 | const handleBlur = (e) => { 28 | if (!newTodo) { 29 | onSave(e.target.value); 30 | } 31 | }; 32 | 33 | return ( 34 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/components/VisibleTodoList.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import TodoItem from "./TodoItem"; 3 | import { connect } from "react-redux"; 4 | import graphql from "babel-plugin-relay/macro"; 5 | import useFragment from "react-relay/lib/relay-hooks/useFragment"; 6 | import { bindActionCreators } from "redux"; 7 | import * as TodoActions from "../actions"; 8 | 9 | const TodoList = ({ query: queryKey, actions }) => { 10 | const { filteredTodos } = useFragment( 11 | graphql` 12 | fragment VisibleTodoList on Root { 13 | filteredTodos: visible_todos { 14 | id 15 | ...TodoItem 16 | } 17 | } 18 | `, 19 | queryKey 20 | ); 21 | return ( 22 |
      23 | {filteredTodos.map((todo) => ( 24 | 25 | ))} 26 |
    27 | ); 28 | }; 29 | 30 | const mapDispatchToProps = (dispatch) => ({ 31 | actions: bindActionCreators(TodoActions, dispatch), 32 | }); 33 | 34 | export default connect(null, mapDispatchToProps)(TodoList); 35 | -------------------------------------------------------------------------------- /src/components/__generated__/AppQuery.graphql.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @generated SignedSource<<481ef6ac0ae37c35eb00b895e2b303cb>> 3 | * @lightSyntaxTransform 4 | * @nogrep 5 | */ 6 | 7 | /* eslint-disable */ 8 | 9 | 'use strict'; 10 | 11 | var node = { 12 | "fragment": { 13 | "argumentDefinitions": [], 14 | "kind": "Fragment", 15 | "metadata": null, 16 | "name": "AppQuery", 17 | "selections": [ 18 | { 19 | "args": null, 20 | "kind": "FragmentSpread", 21 | "name": "MainSection" 22 | } 23 | ], 24 | "type": "Root", 25 | "abstractKey": null 26 | }, 27 | "kind": "Request", 28 | "operation": { 29 | "argumentDefinitions": [], 30 | "kind": "Operation", 31 | "name": "AppQuery", 32 | "selections": [ 33 | { 34 | "kind": "ClientExtension", 35 | "selections": [ 36 | { 37 | "alias": null, 38 | "args": null, 39 | "concreteType": "Todo", 40 | "kind": "LinkedField", 41 | "name": "all_todos", 42 | "plural": true, 43 | "selections": [ 44 | { 45 | "alias": null, 46 | "args": null, 47 | "kind": "ScalarField", 48 | "name": "__typename", 49 | "storageKey": null 50 | }, 51 | { 52 | "alias": null, 53 | "args": null, 54 | "kind": "ScalarField", 55 | "name": "id", 56 | "storageKey": null 57 | }, 58 | { 59 | "alias": null, 60 | "args": null, 61 | "kind": "ScalarField", 62 | "name": "completed", 63 | "storageKey": null 64 | } 65 | ], 66 | "storageKey": null 67 | }, 68 | { 69 | "alias": null, 70 | "args": null, 71 | "kind": "ScalarField", 72 | "name": "visibility_filter", 73 | "storageKey": null 74 | } 75 | ] 76 | } 77 | ] 78 | }, 79 | "params": { 80 | "cacheID": "a86e1a5d02417cf3a1573ede6b647853", 81 | "id": null, 82 | "metadata": {}, 83 | "name": "AppQuery", 84 | "operationKind": "query", 85 | "text": null 86 | } 87 | }; 88 | 89 | node.hash = "c2ffb49598436a136b1be75ca1c203a7"; 90 | 91 | module.exports = node; 92 | -------------------------------------------------------------------------------- /src/components/__generated__/FilterLink.graphql.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @generated SignedSource<> 3 | * @lightSyntaxTransform 4 | * @nogrep 5 | */ 6 | 7 | /* eslint-disable */ 8 | 9 | 'use strict'; 10 | 11 | var node = { 12 | "argumentDefinitions": [], 13 | "kind": "Fragment", 14 | "metadata": null, 15 | "name": "FilterLink", 16 | "selections": [ 17 | { 18 | "kind": "ClientExtension", 19 | "selections": [ 20 | { 21 | "alias": null, 22 | "args": null, 23 | "kind": "ScalarField", 24 | "name": "visibility_filter", 25 | "storageKey": null 26 | } 27 | ] 28 | } 29 | ], 30 | "type": "Root", 31 | "abstractKey": null 32 | }; 33 | 34 | node.hash = "9a48e52226e3ba02dead6eea6d2e723a"; 35 | 36 | module.exports = node; 37 | -------------------------------------------------------------------------------- /src/components/__generated__/Footer.graphql.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @generated SignedSource<> 3 | * @lightSyntaxTransform 4 | * @nogrep 5 | */ 6 | 7 | /* eslint-disable */ 8 | 9 | 'use strict'; 10 | 11 | var node = { 12 | "argumentDefinitions": [], 13 | "kind": "Fragment", 14 | "metadata": null, 15 | "name": "Footer", 16 | "selections": [ 17 | { 18 | "alias": null, 19 | "args": null, 20 | "fragment": { 21 | "args": null, 22 | "kind": "FragmentSpread", 23 | "name": "RootTodosCountResolver" 24 | }, 25 | "kind": "RelayResolver", 26 | "name": "todos_count", 27 | "resolverModule": require('./../../relay/resolvers/RootTodosCountResolver.js'), 28 | "path": "todos_count" 29 | }, 30 | { 31 | "alias": null, 32 | "args": null, 33 | "fragment": { 34 | "args": null, 35 | "kind": "FragmentSpread", 36 | "name": "RootCompletedTodosCountResolver" 37 | }, 38 | "kind": "RelayResolver", 39 | "name": "completed_todos_count", 40 | "resolverModule": require('./../../relay/resolvers/RootCompletedTodosCountResolver.js'), 41 | "path": "completed_todos_count" 42 | }, 43 | { 44 | "args": null, 45 | "kind": "FragmentSpread", 46 | "name": "FilterLink" 47 | } 48 | ], 49 | "type": "Root", 50 | "abstractKey": null 51 | }; 52 | 53 | node.hash = "535026557265b81bdd542813ec09aff9"; 54 | 55 | module.exports = node; 56 | -------------------------------------------------------------------------------- /src/components/__generated__/MainSection.graphql.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @generated SignedSource<<0bf596893f9ffc7a38dca79d9b05a510>> 3 | * @lightSyntaxTransform 4 | * @nogrep 5 | */ 6 | 7 | /* eslint-disable */ 8 | 9 | 'use strict'; 10 | 11 | var node = { 12 | "argumentDefinitions": [], 13 | "kind": "Fragment", 14 | "metadata": null, 15 | "name": "MainSection", 16 | "selections": [ 17 | { 18 | "alias": "todosCount", 19 | "args": null, 20 | "fragment": { 21 | "args": null, 22 | "kind": "FragmentSpread", 23 | "name": "RootTodosCountResolver" 24 | }, 25 | "kind": "RelayResolver", 26 | "name": "todos_count", 27 | "resolverModule": require('./../../relay/resolvers/RootTodosCountResolver.js'), 28 | "path": "todosCount" 29 | }, 30 | { 31 | "alias": "completedCount", 32 | "args": null, 33 | "fragment": { 34 | "args": null, 35 | "kind": "FragmentSpread", 36 | "name": "RootCompletedTodosCountResolver" 37 | }, 38 | "kind": "RelayResolver", 39 | "name": "completed_todos_count", 40 | "resolverModule": require('./../../relay/resolvers/RootCompletedTodosCountResolver.js'), 41 | "path": "completedCount" 42 | }, 43 | { 44 | "args": null, 45 | "kind": "FragmentSpread", 46 | "name": "VisibleTodoList" 47 | }, 48 | { 49 | "args": null, 50 | "kind": "FragmentSpread", 51 | "name": "Footer" 52 | } 53 | ], 54 | "type": "Root", 55 | "abstractKey": null 56 | }; 57 | 58 | node.hash = "af8d2da75165661d5831c14de2a8ce7c"; 59 | 60 | module.exports = node; 61 | -------------------------------------------------------------------------------- /src/components/__generated__/TodoItem.graphql.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @generated SignedSource<<72cd5206897579e554f7fa3923d5d10b>> 3 | * @lightSyntaxTransform 4 | * @nogrep 5 | */ 6 | 7 | /* eslint-disable */ 8 | 9 | 'use strict'; 10 | 11 | var node = { 12 | "argumentDefinitions": [], 13 | "kind": "Fragment", 14 | "metadata": null, 15 | "name": "TodoItem", 16 | "selections": [ 17 | { 18 | "kind": "ClientExtension", 19 | "selections": [ 20 | { 21 | "alias": null, 22 | "args": null, 23 | "kind": "ScalarField", 24 | "name": "id", 25 | "storageKey": null 26 | }, 27 | { 28 | "alias": null, 29 | "args": null, 30 | "kind": "ScalarField", 31 | "name": "completed", 32 | "storageKey": null 33 | }, 34 | { 35 | "alias": null, 36 | "args": null, 37 | "kind": "ScalarField", 38 | "name": "text", 39 | "storageKey": null 40 | } 41 | ] 42 | } 43 | ], 44 | "type": "Todo", 45 | "abstractKey": null 46 | }; 47 | 48 | node.hash = "8517b47725e3419758f9773054e4a69a"; 49 | 50 | module.exports = node; 51 | -------------------------------------------------------------------------------- /src/components/__generated__/VisibleTodoList.graphql.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @generated SignedSource<<8b6457304aea68456d16c194192f4a97>> 3 | * @lightSyntaxTransform 4 | * @nogrep 5 | */ 6 | 7 | /* eslint-disable */ 8 | 9 | 'use strict'; 10 | 11 | var node = { 12 | "argumentDefinitions": [], 13 | "kind": "Fragment", 14 | "metadata": { 15 | "hasClientEdges": true 16 | }, 17 | "name": "VisibleTodoList", 18 | "selections": [ 19 | { 20 | "kind": "ClientEdgeToClientObject", 21 | "concreteType": "Todo", 22 | "backingField": { 23 | "alias": "filteredTodos", 24 | "args": null, 25 | "fragment": { 26 | "args": null, 27 | "kind": "FragmentSpread", 28 | "name": "RootVisibleTodosResolver" 29 | }, 30 | "kind": "RelayResolver", 31 | "name": "visible_todos", 32 | "resolverModule": require('./../../relay/resolvers/RootVisibleTodosResolver.js'), 33 | "path": "filteredTodos" 34 | }, 35 | "linkedField": { 36 | "alias": "filteredTodos", 37 | "args": null, 38 | "concreteType": "Todo", 39 | "kind": "LinkedField", 40 | "name": "visible_todos", 41 | "plural": true, 42 | "selections": [ 43 | { 44 | "alias": null, 45 | "args": null, 46 | "kind": "ScalarField", 47 | "name": "id", 48 | "storageKey": null 49 | }, 50 | { 51 | "args": null, 52 | "kind": "FragmentSpread", 53 | "name": "TodoItem" 54 | } 55 | ], 56 | "storageKey": null 57 | } 58 | } 59 | ], 60 | "type": "Root", 61 | "abstractKey": null 62 | }; 63 | 64 | node.hash = "8c590cbeec14baf1e51f66fbce780f5f"; 65 | 66 | module.exports = node; 67 | -------------------------------------------------------------------------------- /src/constants/ActionTypes.js: -------------------------------------------------------------------------------- 1 | export const ADD_TODO = "ADD_TODO"; 2 | export const DELETE_TODO = "DELETE_TODO"; 3 | export const EDIT_TODO = "EDIT_TODO"; 4 | export const COMPLETE_TODO = "COMPLETE_TODO"; 5 | export const COMPLETE_ALL_TODOS = "COMPLETE_ALL_TODOS"; 6 | export const CLEAR_COMPLETED = "CLEAR_COMPLETED"; 7 | export const SET_VISIBILITY_FILTER = "SET_VISIBILITY_FILTER"; 8 | -------------------------------------------------------------------------------- /src/constants/TodoFilters.js: -------------------------------------------------------------------------------- 1 | export const SHOW_ALL = "show_all"; 2 | export const SHOW_COMPLETED = "show_completed"; 3 | export const SHOW_ACTIVE = "show_active"; 4 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "react-dom"; 3 | import store from "./store"; 4 | import { Provider } from "react-redux"; 5 | import App from "./components/App"; 6 | import "todomvc-app-css/index.css"; 7 | import { RelayEnvironmentProvider } from "react-relay/hooks"; 8 | import RelayEnvironment from "./relay/RelayEnvironment"; 9 | 10 | render( 11 | 12 | 13 | 14 | 15 | 16 | 17 | , 18 | document.getElementById("root") 19 | ); 20 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | import todos from "./todos"; 3 | import visibilityFilter from "./visibilityFilter"; 4 | 5 | const rootReducer = combineReducers({ 6 | todos, 7 | visibilityFilter, 8 | }); 9 | 10 | export default rootReducer; 11 | -------------------------------------------------------------------------------- /src/reducers/todos.js: -------------------------------------------------------------------------------- 1 | import { 2 | ADD_TODO, 3 | DELETE_TODO, 4 | EDIT_TODO, 5 | COMPLETE_TODO, 6 | COMPLETE_ALL_TODOS, 7 | CLEAR_COMPLETED, 8 | } from "../constants/ActionTypes"; 9 | 10 | const initialState = [ 11 | { 12 | text: "Use Redux", 13 | completed: false, 14 | id: 0, 15 | }, 16 | ]; 17 | 18 | export default function todos(state = initialState, action) { 19 | switch (action.type) { 20 | case ADD_TODO: 21 | return [ 22 | ...state, 23 | { 24 | id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1, 25 | completed: false, 26 | text: action.text, 27 | }, 28 | ]; 29 | 30 | case DELETE_TODO: 31 | return state.filter((todo) => todo.id !== action.id); 32 | 33 | case EDIT_TODO: 34 | return state.map((todo) => 35 | todo.id === action.id ? { ...todo, text: action.text } : todo 36 | ); 37 | 38 | case COMPLETE_TODO: 39 | return state.map((todo) => 40 | todo.id === action.id ? { ...todo, completed: !todo.completed } : todo 41 | ); 42 | 43 | case COMPLETE_ALL_TODOS: 44 | const areAllMarked = state.every((todo) => todo.completed); 45 | return state.map((todo) => ({ 46 | ...todo, 47 | completed: !areAllMarked, 48 | })); 49 | 50 | case CLEAR_COMPLETED: 51 | return state.filter((todo) => todo.completed === false); 52 | 53 | default: 54 | return state; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/reducers/visibilityFilter.js: -------------------------------------------------------------------------------- 1 | import { SET_VISIBILITY_FILTER } from "../constants/ActionTypes"; 2 | import { SHOW_ALL } from "../constants/TodoFilters"; 3 | 4 | const visibilityFilter = (state = SHOW_ALL, action) => { 5 | switch (action.type) { 6 | case SET_VISIBILITY_FILTER: 7 | return action.filter; 8 | default: 9 | return state; 10 | } 11 | }; 12 | 13 | export default visibilityFilter; 14 | -------------------------------------------------------------------------------- /src/relay/RelayEnvironment.js: -------------------------------------------------------------------------------- 1 | import { 2 | Environment, 3 | Network, 4 | RecordSource, 5 | RelayFeatureFlags, 6 | } from "relay-runtime"; 7 | import fetchGraphQL from "./fetchGraphQL"; 8 | import LiveResolverStore from "relay-runtime/lib/store/experimental-live-resolvers/LiveResolverStore"; 9 | 10 | RelayFeatureFlags.ENABLE_CLIENT_EDGES = true; 11 | RelayFeatureFlags.ENABLE_RELAY_RESOLVERS = true; 12 | 13 | // Relay passes a "params" object with the query name and text. So we define a helper function 14 | // to call our fetchGraphQL utility with params.text. 15 | async function fetchRelay(params, variables) { 16 | console.log( 17 | `fetching query ${params.name} with ${JSON.stringify(variables)}` 18 | ); 19 | return fetchGraphQL(params.text, variables); 20 | } 21 | 22 | // Export a singleton instance of Relay Environment configured with our network function: 23 | const env = new Environment({ 24 | network: Network.create(fetchRelay), 25 | store: new LiveResolverStore(new RecordSource()), 26 | requiredFieldLogger(e) { 27 | console.warn(e); 28 | }, 29 | log: console.log.bind(console), 30 | }); 31 | 32 | window.RelayEnvironment = env; 33 | 34 | export default env; 35 | -------------------------------------------------------------------------------- /src/relay/fetchGraphQL.js: -------------------------------------------------------------------------------- 1 | // Our app currently only uses client side data. However, Relay expects each 2 | // query to read _some_ server data. To satisfy that requirement for now, we 3 | // use an arbitrary server that returns the same data, even though that data is 4 | // ignored. 5 | async function fetchGraphQL(text, variables) { 6 | // Return dummy data for now. 7 | return { 8 | data: { 9 | dummy_server_field: null, 10 | }, 11 | errors: [], 12 | }; 13 | } 14 | 15 | export default fetchGraphQL; 16 | -------------------------------------------------------------------------------- /src/relay/resolvers/RootCompletedTodosCountResolver.js: -------------------------------------------------------------------------------- 1 | import graphql from "babel-plugin-relay/macro"; 2 | import { readFragment } from "relay-runtime/lib/store/ResolverFragments"; 3 | 4 | /** 5 | * @RelayResolver 6 | * @fieldName completed_todos_count 7 | * @rootFragment RootCompletedTodosCountResolver 8 | * @onType Root 9 | * 10 | * The total number of completed todos. 11 | */ 12 | export default function RootCompletedTodosCountResolver(key) { 13 | const data = readFragment( 14 | graphql` 15 | fragment RootCompletedTodosCountResolver on Root { 16 | all_todos { 17 | completed 18 | } 19 | } 20 | `, 21 | key 22 | ); 23 | return data.all_todos.reduce( 24 | (count, todo) => (todo.completed ? count + 1 : count), 25 | 0 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/relay/resolvers/RootTodosCountResolver.js: -------------------------------------------------------------------------------- 1 | import graphql from "babel-plugin-relay/macro"; 2 | import { readFragment } from "relay-runtime/lib/store/ResolverFragments"; 3 | 4 | /** 5 | * @RelayResolver 6 | * @fieldName todos_count 7 | * @rootFragment RootTodosCountResolver 8 | * @onType Root 9 | * 10 | * The total number of todos. 11 | */ 12 | export default function RootTodosCountResolver(key) { 13 | const data = readFragment( 14 | graphql` 15 | fragment RootTodosCountResolver on Root { 16 | all_todos { 17 | __typename 18 | } 19 | } 20 | `, 21 | key 22 | ); 23 | return data.all_todos.length; 24 | } 25 | -------------------------------------------------------------------------------- /src/relay/resolvers/RootVisibleTodosResolver.js: -------------------------------------------------------------------------------- 1 | import graphql from "babel-plugin-relay/macro"; 2 | import { readFragment } from "relay-runtime/lib/store/ResolverFragments"; 3 | import { 4 | SHOW_ALL, 5 | SHOW_COMPLETED, 6 | SHOW_ACTIVE, 7 | } from "../../constants/TodoFilters"; 8 | 9 | /** 10 | * @RelayResolver 11 | * @fieldName visible_todos 12 | * @rootFragment RootVisibleTodosResolver 13 | * @onType Root 14 | * @edgeTo [Todo] 15 | * 16 | * Read all todos from the root of the query. 17 | */ 18 | export default function RootVisibleTodosResolver(key) { 19 | const data = readFragment( 20 | graphql` 21 | fragment RootVisibleTodosResolver on Root { 22 | all_todos { 23 | id 24 | completed 25 | } 26 | visibility_filter 27 | } 28 | `, 29 | key 30 | ); 31 | return getVisibleTodos(data.visibility_filter, data.all_todos).map((todo) => 32 | relayIdToReduxId(todo.id) 33 | ); 34 | } 35 | 36 | function relayIdToReduxId(id) { 37 | // This is to work around the fact that Relay resolvers to client types 38 | // namespace their IDs. Here we un-prefix them. We need to find a fix for 39 | // this is Relay itself. 40 | const regex = /^client:Todo:(.*)$/; 41 | if (!regex.test(id)) { 42 | throw new Error("Expected special client id syntax."); 43 | } 44 | return id.replace(regex, "$1"); 45 | } 46 | 47 | const getVisibleTodos = (visibilityFilter, todos) => { 48 | switch (visibilityFilter) { 49 | case SHOW_ALL: 50 | return todos; 51 | case SHOW_COMPLETED: 52 | return todos.filter((t) => t.completed); 53 | case SHOW_ACTIVE: 54 | return todos.filter((t) => !t.completed); 55 | default: 56 | throw new Error("Unknown filter: " + visibilityFilter); 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /src/relay/resolvers/__generated__/RootCompletedTodosCountResolver.graphql.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @generated SignedSource<<18b235225ceb08174889c26ffc5e91e8>> 3 | * @lightSyntaxTransform 4 | * @nogrep 5 | */ 6 | 7 | /* eslint-disable */ 8 | 9 | 'use strict'; 10 | 11 | var node = { 12 | "argumentDefinitions": [], 13 | "kind": "Fragment", 14 | "metadata": null, 15 | "name": "RootCompletedTodosCountResolver", 16 | "selections": [ 17 | { 18 | "kind": "ClientExtension", 19 | "selections": [ 20 | { 21 | "alias": null, 22 | "args": null, 23 | "concreteType": "Todo", 24 | "kind": "LinkedField", 25 | "name": "all_todos", 26 | "plural": true, 27 | "selections": [ 28 | { 29 | "alias": null, 30 | "args": null, 31 | "kind": "ScalarField", 32 | "name": "completed", 33 | "storageKey": null 34 | } 35 | ], 36 | "storageKey": null 37 | } 38 | ] 39 | } 40 | ], 41 | "type": "Root", 42 | "abstractKey": null 43 | }; 44 | 45 | node.hash = "27e77428f6fe95c4837bf1b9f1cc87fa"; 46 | 47 | module.exports = node; 48 | -------------------------------------------------------------------------------- /src/relay/resolvers/__generated__/RootTodosCountResolver.graphql.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @generated SignedSource<<963e146c529eb6409293fa84bad83d62>> 3 | * @lightSyntaxTransform 4 | * @nogrep 5 | */ 6 | 7 | /* eslint-disable */ 8 | 9 | 'use strict'; 10 | 11 | var node = { 12 | "argumentDefinitions": [], 13 | "kind": "Fragment", 14 | "metadata": null, 15 | "name": "RootTodosCountResolver", 16 | "selections": [ 17 | { 18 | "kind": "ClientExtension", 19 | "selections": [ 20 | { 21 | "alias": null, 22 | "args": null, 23 | "concreteType": "Todo", 24 | "kind": "LinkedField", 25 | "name": "all_todos", 26 | "plural": true, 27 | "selections": [ 28 | { 29 | "alias": null, 30 | "args": null, 31 | "kind": "ScalarField", 32 | "name": "__typename", 33 | "storageKey": null 34 | } 35 | ], 36 | "storageKey": null 37 | } 38 | ] 39 | } 40 | ], 41 | "type": "Root", 42 | "abstractKey": null 43 | }; 44 | 45 | node.hash = "d659d37c20d7ba7dd8afce6e12007709"; 46 | 47 | module.exports = node; 48 | -------------------------------------------------------------------------------- /src/relay/resolvers/__generated__/RootVisibleTodosResolver.graphql.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @generated SignedSource<> 3 | * @lightSyntaxTransform 4 | * @nogrep 5 | */ 6 | 7 | /* eslint-disable */ 8 | 9 | 'use strict'; 10 | 11 | var node = { 12 | "argumentDefinitions": [], 13 | "kind": "Fragment", 14 | "metadata": null, 15 | "name": "RootVisibleTodosResolver", 16 | "selections": [ 17 | { 18 | "kind": "ClientExtension", 19 | "selections": [ 20 | { 21 | "alias": null, 22 | "args": null, 23 | "concreteType": "Todo", 24 | "kind": "LinkedField", 25 | "name": "all_todos", 26 | "plural": true, 27 | "selections": [ 28 | { 29 | "alias": null, 30 | "args": null, 31 | "kind": "ScalarField", 32 | "name": "id", 33 | "storageKey": null 34 | }, 35 | { 36 | "alias": null, 37 | "args": null, 38 | "kind": "ScalarField", 39 | "name": "completed", 40 | "storageKey": null 41 | } 42 | ], 43 | "storageKey": null 44 | }, 45 | { 46 | "alias": null, 47 | "args": null, 48 | "kind": "ScalarField", 49 | "name": "visibility_filter", 50 | "storageKey": null 51 | } 52 | ] 53 | } 54 | ], 55 | "type": "Root", 56 | "abstractKey": null 57 | }; 58 | 59 | node.hash = "036ad2306f11cb8b03d565f875040413"; 60 | 61 | module.exports = node; 62 | -------------------------------------------------------------------------------- /src/relay/schemaExtensions/extensions.graphql: -------------------------------------------------------------------------------- 1 | type Todo { 2 | id: ID! 3 | completed: Boolean 4 | text: String 5 | } 6 | 7 | extend type Root { 8 | visibility_filter: String 9 | all_todos: [Todo] 10 | } 11 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import { commitLocalUpdate } from "relay-runtime"; 2 | import graphql from "babel-plugin-relay/macro"; 3 | import RelayEnvironment from "./relay/RelayEnvironment"; 4 | import { SHOW_ALL } from "./constants/TodoFilters"; 5 | import { 6 | ADD_TODO, 7 | COMPLETE_TODO, 8 | EDIT_TODO, 9 | SET_VISIBILITY_FILTER, 10 | DELETE_TODO, 11 | COMPLETE_ALL_TODOS, 12 | CLEAR_COMPLETED, 13 | } from "./constants/ActionTypes"; 14 | import { createOperationDescriptor } from "relay-runtime"; 15 | 16 | const STATE_QUERY = graphql` 17 | query storeStateQuery { 18 | all_todos { 19 | id 20 | text 21 | completed 22 | } 23 | visibility_filter 24 | } 25 | `; 26 | 27 | // Implements the Redux store interface, but reads/writes/subscribes to the Relay 28 | // store. 29 | class CompatibilityStore { 30 | constructor() { 31 | this._callbacks = []; 32 | 33 | // Relay does not yet have an API for innitializing the default 34 | // value in client schema extensions, so we'll set the default values 35 | // using actions: 36 | this.dispatch({ type: SET_VISIBILITY_FILTER, filter: SHOW_ALL }); 37 | this.dispatch({ type: ADD_TODO, text: "Use R̵e̵d̵u̵x̵ Relay" }); 38 | 39 | const operation = createOperationDescriptor(STATE_QUERY, {}); 40 | const snapshot = RelayEnvironment.lookup(operation.fragment); 41 | 42 | // Set initial state 43 | this._state = this._stateFromQuery(snapshot.data); 44 | 45 | // Subscribe to future states 46 | this._dispose = RelayEnvironment.subscribe(snapshot, (next) => { 47 | this._state = this._stateFromQuery(next.data); 48 | for (const callback of this._callbacks) { 49 | callback(); 50 | } 51 | }); 52 | } 53 | 54 | getState = () => { 55 | return this._state; 56 | }; 57 | 58 | subscribe = (listener) => { 59 | this._callbacks.push(listener); 60 | return () => { 61 | this._callbacks = this._callbacks.filter((cb) => cb !== listener); 62 | }; 63 | }; 64 | 65 | dispatch = (action) => { 66 | switch (action.type) { 67 | case SET_VISIBILITY_FILTER: { 68 | setVisibilityFilter(action.filter); 69 | break; 70 | } 71 | case ADD_TODO: { 72 | addTodo(action.text); 73 | break; 74 | } 75 | case DELETE_TODO: { 76 | deleteTodo(action.id); 77 | break; 78 | } 79 | case EDIT_TODO: { 80 | editTodo(action.id, action.text); 81 | break; 82 | } 83 | case COMPLETE_TODO: { 84 | completeTodo(action.id); 85 | break; 86 | } 87 | case COMPLETE_ALL_TODOS: { 88 | completeAllTodos(); 89 | break; 90 | } 91 | case CLEAR_COMPLETED: { 92 | clearCompleted(); 93 | break; 94 | } 95 | } 96 | }; 97 | 98 | // Maps a Relay query to the legacy Redux state object 99 | // shape. 100 | _stateFromQuery(data) { 101 | return { 102 | todos: data.all_todos.map((todo) => ({ 103 | text: todo.text, 104 | id: todo.id, 105 | completed: todo.completed, 106 | })), 107 | visibilityFilter: data.visibility_filter, 108 | }; 109 | } 110 | } 111 | 112 | function relayIdToReduxId(id) { 113 | // This is to work around the fact that Relay resolvers to client types 114 | // namespace their IDs. Here we un-prefix them. We need to find a fix for 115 | // this is Relay itself. 116 | const regex = /^client:Todo:(.*)$/; 117 | if (!regex.test(id)) { 118 | throw new Error("Expected special client id syntax."); 119 | } 120 | return id.replace(regex, "$1"); 121 | } 122 | 123 | function setVisibilityFilter(filter) { 124 | commitLocalUpdate(RelayEnvironment, (store) => { 125 | const { updatableData } = store.readUpdatableQuery_EXPERIMENTAL( 126 | graphql` 127 | query storeVisibilityFilterUpdaterQuery @updatable { 128 | visibility_filter 129 | } 130 | `, 131 | {} 132 | ); 133 | updatableData.visibility_filter = filter; 134 | }); 135 | } 136 | 137 | let nextID = 0; 138 | 139 | function addTodo(text) { 140 | commitLocalUpdate(RelayEnvironment, (store) => { 141 | // readUpdatableQuery_EXPERIMENTAL Does not yet support creating new nodes. 142 | const root = store.getRoot(); 143 | const dataID = `client:Todo:${nextID++}`; 144 | const newTodo = store.create(dataID, "FLUXTodo"); 145 | newTodo.setValue(text, "text"); 146 | newTodo.setValue(dataID, "id"); 147 | newTodo.setValue(false, "completed"); 148 | const todos = root.getLinkedRecords("all_todos") ?? []; 149 | root.setLinkedRecords([...todos, newTodo], "all_todos"); 150 | }); 151 | } 152 | 153 | function deleteTodo(id) { 154 | commitLocalUpdate(RelayEnvironment, (store) => { 155 | const root = store.getRoot(); 156 | const todos = root.getLinkedRecords("all_todos") ?? []; 157 | const newTodos = todos.filter((todo) => todo.getDataID() !== id); 158 | root.setLinkedRecords(newTodos, "all_todos"); 159 | store.delete(id); 160 | }); 161 | } 162 | 163 | // TODO: Should we use an updatable fragment here? 164 | function editTodo(id, text) { 165 | commitLocalUpdate(RelayEnvironment, (store) => { 166 | const todo = store.get(id); 167 | if (todo == null) { 168 | throw new Error("Tried to reference a non-existent todo"); 169 | } 170 | todo.setValue(text, "text"); 171 | }); 172 | } 173 | 174 | // TODO: Should we use an updatable fragment here? 175 | function completeTodo(id) { 176 | commitLocalUpdate(RelayEnvironment, (store) => { 177 | const todo = store.get(id); 178 | if (todo == null) { 179 | throw new Error("Tried to reference a non-existent todo"); 180 | } 181 | const prev = todo.getValue("completed"); 182 | todo.setValue(!prev, "completed"); 183 | }); 184 | } 185 | 186 | function completeAllTodos(id) { 187 | commitLocalUpdate(RelayEnvironment, (store) => { 188 | const { updatableData } = store.readUpdatableQuery_EXPERIMENTAL( 189 | graphql` 190 | query storeCompleteAllQuery @updatable { 191 | all_todos { 192 | completed 193 | } 194 | } 195 | `, 196 | {} 197 | ); 198 | const areAllMarked = updatableData.all_todos.every( 199 | (todo) => todo.completed 200 | ); 201 | 202 | for (const todo of updatableData.all_todos) { 203 | todo.completed = !areAllMarked; 204 | } 205 | }); 206 | } 207 | 208 | function clearCompleted(id) { 209 | commitLocalUpdate(RelayEnvironment, (store) => { 210 | const { updatableData } = store.readUpdatableQuery_EXPERIMENTAL( 211 | graphql` 212 | query storeClearCompletedQuery @updatable { 213 | all_todos { 214 | # TODO: Bug in Relay that this is required? 215 | # without this, I get an error when I clear completed and some todos 216 | # remain. 217 | __id 218 | completed 219 | } 220 | } 221 | `, 222 | {} 223 | ); 224 | 225 | // TODO: Delete the records for the completed todos from the store. 226 | updatableData.all_todos = updatableData.all_todos.filter( 227 | (todo) => !todo.completed 228 | ); 229 | }); 230 | } 231 | 232 | const store = new CompatibilityStore(); 233 | 234 | export default store; 235 | --------------------------------------------------------------------------------