├── .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 |
46 | Clear completed
47 |
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 |
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 | {todo.text}
60 | deleteTodo(todo.id)} />
61 |
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 |
--------------------------------------------------------------------------------