├── .gitignore ├── .gitmodules ├── .tortilla ├── assets │ ├── chat.png │ ├── chat_components.png │ ├── chats.png │ ├── chats_components.png │ ├── new_chat.png │ ├── new_chat_components.png │ ├── new_group.png │ ├── new_group_components.png │ ├── new_group_details.png │ └── new_group_details_components.png └── manuals │ ├── templates │ ├── root.tmpl │ ├── step1.tmpl │ ├── step10.tmpl │ ├── step11.tmpl │ ├── step12.tmpl │ ├── step13.tmpl │ ├── step2.tmpl │ ├── step3.tmpl │ ├── step4.tmpl │ ├── step5.tmpl │ ├── step6.tmpl │ ├── step7.tmpl │ ├── step8.tmpl │ └── step9.tmpl │ └── views │ ├── root.md │ ├── step1.md │ ├── step10.md │ ├── step11.md │ ├── step12.md │ ├── step13.md │ ├── step2.md │ ├── step3.md │ ├── step4.md │ ├── step5.md │ ├── step6.md │ ├── step7.md │ ├── step8.md │ └── step9.md ├── README.md └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .idea 4 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "server"] 2 | path = server 3 | url = ../whatsapp-server-express 4 | [submodule "client"] 5 | path = client 6 | url = ../whatsapp-client-angularcli-material 7 | -------------------------------------------------------------------------------- /.tortilla/assets/chat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Urigo/whatsapp-textrepo-angularcli-express/7884ea519f1340cb9bfc3d6304043cb363258c48/.tortilla/assets/chat.png -------------------------------------------------------------------------------- /.tortilla/assets/chat_components.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Urigo/whatsapp-textrepo-angularcli-express/7884ea519f1340cb9bfc3d6304043cb363258c48/.tortilla/assets/chat_components.png -------------------------------------------------------------------------------- /.tortilla/assets/chats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Urigo/whatsapp-textrepo-angularcli-express/7884ea519f1340cb9bfc3d6304043cb363258c48/.tortilla/assets/chats.png -------------------------------------------------------------------------------- /.tortilla/assets/chats_components.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Urigo/whatsapp-textrepo-angularcli-express/7884ea519f1340cb9bfc3d6304043cb363258c48/.tortilla/assets/chats_components.png -------------------------------------------------------------------------------- /.tortilla/assets/new_chat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Urigo/whatsapp-textrepo-angularcli-express/7884ea519f1340cb9bfc3d6304043cb363258c48/.tortilla/assets/new_chat.png -------------------------------------------------------------------------------- /.tortilla/assets/new_chat_components.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Urigo/whatsapp-textrepo-angularcli-express/7884ea519f1340cb9bfc3d6304043cb363258c48/.tortilla/assets/new_chat_components.png -------------------------------------------------------------------------------- /.tortilla/assets/new_group.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Urigo/whatsapp-textrepo-angularcli-express/7884ea519f1340cb9bfc3d6304043cb363258c48/.tortilla/assets/new_group.png -------------------------------------------------------------------------------- /.tortilla/assets/new_group_components.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Urigo/whatsapp-textrepo-angularcli-express/7884ea519f1340cb9bfc3d6304043cb363258c48/.tortilla/assets/new_group_components.png -------------------------------------------------------------------------------- /.tortilla/assets/new_group_details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Urigo/whatsapp-textrepo-angularcli-express/7884ea519f1340cb9bfc3d6304043cb363258c48/.tortilla/assets/new_group_details.png -------------------------------------------------------------------------------- /.tortilla/assets/new_group_details_components.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Urigo/whatsapp-textrepo-angularcli-express/7884ea519f1340cb9bfc3d6304043cb363258c48/.tortilla/assets/new_group_details_components.png -------------------------------------------------------------------------------- /.tortilla/manuals/templates/root.tmpl: -------------------------------------------------------------------------------- 1 | ## Startup instructions 2 | 3 | This project is made out of 2 sub-projects - the one is client and the other is server. We will go through the initialization for each of these individually. We will start with the server since the client is dependent on it. 4 | 5 | ### Server 6 | 7 | First we need to clone the server project: 8 | 9 | $ git clone git@github.com:Urigo/whatsapp-server-express.git 10 | 11 | Then we need to install the NPM dependencies: 12 | 13 | $ npm install 14 | 15 | Before starting the server make sure that postgresql is installed: 16 | 17 | $ sudo apt-get update 18 | $ sudo apt-get install postgresql postgresql-contrib 19 | 20 | Setup a user named "test" with no password by first switching into "postgres" account: 21 | 22 | $ sudo -u postgres psql 23 | 24 | And running the following command: 25 | 26 | postgres=# ALTER USER test WITH PASSWORD ''; 27 | 28 | Try to run the server: 29 | 30 | $ npm start 31 | 32 | If logs show that connection refused, kill the process and set a random password for the "test" user (e.g. "test"): 33 | 34 | postgres=# ALTER USER test WITH PASSWORD 'test'; 35 | 36 | Be sure to set the password in the `ormconfig.json` as well: 37 | 38 | ```js 39 | { 40 | // ... 41 | "password": "test", 42 | // ... 43 | } 44 | ``` 45 | 46 | Run the start command again: 47 | 48 | $ npm start 49 | 50 | ### Client 51 | 52 | **Be sure to go through server initialization first** 53 | 54 | First we need to clone the server project: 55 | 56 | $ git clone git@github.com:Urigo/whatsapp-client-angularcli-material.git 57 | 58 | Then we need to install the NPM dependencies: 59 | 60 | $ npm install 61 | 62 | Start the app: 63 | 64 | $ npm start 65 | -------------------------------------------------------------------------------- /.tortilla/manuals/templates/step1.tmpl: -------------------------------------------------------------------------------- 1 | Our goal for this chapter will be very simple: we want to simply retrieve and display a list of chats from our own server. 2 | 3 | ## Server 4 | 5 | We'll start with the server, so let's install a couple of packages first: 6 | 7 | yarn add apollo-server-express body-parser cors express graphql 8 | yarn add -D @types/body-parser @types/cors @types/express @types/graphql 9 | 10 | Express is a fast, unopinionated, minimalist web framework for node. 11 | Cross-Origin Resource Sharing (CORS) is a mechanism that uses additional HTTP headers to let a user agent gain permission to access selected resources from a server on a different origin (domain) than the site currently in use. A user agent makes a cross-origin HTTP request when it requests a resource from a different domain, protocol, or port than the one from which the current document originated. 12 | We will need CORS because Webpack's development server used in the client will make use of a different port than the Express server, thus configuring a different origin. 13 | GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data. GraphQL provides a complete and understandable description of the data in your API, gives clients the power to ask for exactly what they need and nothing more, makes it easier to evolve APIs over time, and enables powerful developer tools. 14 | Apollo Server is a community-maintained open-source GraphQL server. It works with pretty much all Node.js HTTP server frameworks. Apollo Server works with any GraphQL schema built with GraphQL.js or with a convenience library such as graphql-tools. 15 | 16 | The GraphQL query language is basically about selecting fields on objects. Because the shape of a GraphQL query closely matches the result, you can predict what the query will return without knowing that much about the server. But it's useful to have an exact description of the data we can ask for - what fields can we select? What kinds of objects might they return? What fields are available on those sub-objects? That's where the schema comes in. 17 | Every GraphQL service defines a set of types which completely describe the set of possible data you can query on that service. Then, when queries come in, they are validated and executed against that schema. 18 | 19 | For the moment let's create some empty schemas and resolvers: 20 | 21 | {{{ diffStep "1.1" module="server" files="schema/*" }}} 22 | 23 | Time to create our index: 24 | 25 | {{{ diffStep "1.1" module="server" files="^index.ts" }}} 26 | 27 | Now we want to feed our graphql server with some data. Soon we will need `moment`, so let's install it: 28 | 29 | yarn add moment 30 | 31 | Now we can create a fake db: 32 | 33 | {{{ diffStep "1.2" module="server" files="db.ts" }}} 34 | 35 | Its' finally time to create our schema 36 | 37 | {{{ diffStep "1.3" module="server" files="schema/typeDefs.ts" }}} 38 | 39 | and our resolvers 40 | 41 | {{{ diffStep "1.3" module="server" files="schema/resolvers.ts" }}} 42 | 43 | Out basic server is already done and working. We still have no way to do any kind of mutation, but we already set up several queries to return a list of users or chats. 44 | In particular we can choose if we want to return all the chats (and how many messages we want to return for each chat) or if we want to return a single chat. We can also choose which and how many properties we want to return for each query. 45 | We can start the server by simply running: 46 | 47 | yarn start 48 | 49 | ## Client 50 | 51 | Now we can concentrate on the client and bootstrap it using Angular-CLI. 52 | First you will need to install it globally with: 53 | 54 | yarn global add @angular/cli 55 | 56 | Then we can create a new project from scratch: 57 | 58 | ng new client --style scss 59 | 60 | Time to install a couple of packages: 61 | 62 | ng add apollo-angular 63 | yarn add -D @types/graphql 64 | 65 | Apollo's Schematics sets everything for us. The only thing missing is to fill the `url`. 66 | 67 | {{{ diffStep "1.1" module="client" files="src/app/graphql.module.ts" }}} 68 | 69 | Let's take a closer look what Schematics prepared for us. 70 | 71 | First, it created `graphql.module.ts` and used `ApolloModule` and `HttpLinkModule`. 72 | 73 | - `ApolloModule` is the center of using GraphQL in your app! It includes all needed services that allows to use ApolloClient’s features. 74 | - `HttpLinkModule` makes it easy to fetch data in Angular. 75 | 76 | `HttpLinkModule` is optional, you can replace it with any other Link. 77 | Its biggest advantage of all is that it uses Angular's `HttpClient` internally so it's possible to use it in `NativeScript` or in combination with any other `HttpClient` provider. By using `HttpLinkModule` you get Server-Side Rendering for free, without any additional work. 78 | 79 | Next, we got `APOLLO_OPTIONS` provided to Angular. 80 | 81 | We're using the `InMemoryCache` cache, but there are several options like `Redux`, `Hermes` and even `ngrx`. 82 | 83 | - `apollo-cache-inmemory` is an official and recommended Apollo Cache. 84 | 85 | ### Cache and Links 86 | 87 | These are two main parts of Apollo Stack, both are pluggable and customizable. 88 | 89 | - **Cache** is responsible for storing data and keeping it consistent. 90 | - **Links** describe how to access the source of data (usually a GraphQL endpoint). 91 | 92 | Let's take a closer look on what cache means for Apollo, we're going to base it on the official `apollo-cache-inmemory`, one of many implementations. 93 | 94 | But first, imagine, we write our own amazing Apollo Cache implementation. 95 | 96 | ```graphql 97 | query getMessages { 98 | messages { 99 | id 100 | content 101 | likes 102 | } 103 | } 104 | ``` 105 | 106 | When we execute a query like one above what we get in return is an array of all messages of logged in user: 107 | 108 | ```json 109 | { 110 | "data": { 111 | "messages": [ 112 | { 113 | "id": 0, 114 | "content": "Hi", 115 | "likes": 0 116 | }, 117 | { 118 | "id": 1, 119 | "content": "Hello", 120 | "likes": 0 121 | } 122 | ] 123 | } 124 | } 125 | ``` 126 | 127 | Our cache stores the result in exact shape as above. 128 | 129 | Later on we add more queries and one of them returns a list of messages from one conversation. 130 | 131 | Now, a short story. We've got two users, Niccolo and Dotan. 132 | 133 | 1. Niccolo opens the app and Apollo fetches all of his messages (I know, it's stupid but stay with me) 134 | 1. One message is to Dotan, Niccolo said "Hi" to him. 135 | 1. Now Dotan opens the app and reacts to the message with a like. 136 | 1. After few minutes Niccolo asks for messages from a conversation with Dotan. 137 | 1. Apollo fetches those messages and there is one in which Niccolo says "Hi" and it's liked by Dotan. 138 | 1. Now we have the same message twice, one has different state that the other, in one place there is no like, in other, we have +1. 139 | 140 | As you see, our cache is not smart enough to figure out one query might be a subset of another. 141 | That's because it doesn't get enough informations. 142 | 143 | Let's go back to InMemoryCache and see how it describes itself: 144 | 145 | > Thanks to InMemoryCache, it's possible for the results of a query or mutation to update the UI in all the right places. In many cases it's possible for that to happen automatically, whereas in others you need to help the client out a little in doing so. 146 | 147 | It means it could solve the issue we have with duplicated messages with different state. 148 | 149 | It's very important to understand one concept that InMemoryCache is heavily based on, it's called normalization. 150 | 151 | The InMemoryCache splits the result into individual objects, creates a unique identifier for each of them and flattens everything up before saving it to the store. 152 | Might seem complicated at first, but as InMemoryCache, let's break everything into smaller parts. 153 | 154 | First, "breaking the result into pieces" part. The InMemoryCache tells ApolloClient to add `__typename` field to your query before passing it to the server. I used server for simplicity, it actually passes it to Apollo Links and they are responsible to make a HTTP request. Every Apollo Cache is able to transform a document, that's as a side note. 155 | 156 | ```graphql 157 | query getMessages { 158 | messages { 159 | id 160 | content 161 | likes 162 | __typename 163 | } 164 | } 165 | ``` 166 | 167 | The `__typename` field tells about the type of the object. It's compatible with every GraphQL server as it's part of the specification. 168 | 169 | ```graphql 170 | type Message { 171 | id: ID 172 | content: String 173 | likes: Int 174 | } 175 | 176 | type Query { 177 | messages: [Message] 178 | } 179 | ``` 180 | 181 | In our case, `__typename` returns `Message`. So now InMemoryCache knows that a message is a Message. Computers are stupid, we have to tell them everything! 182 | 183 | Next, let's take a look at "creates a unique identifier" part. 184 | 185 | The InMemoryCache iterates through every object and looks for the `__typename` and two fields, `id` and `_id` that usually are used as identifiers. Taking our example, it turns every Message object into `Message:1`, `Message:2`, `Message:3` and so on. You get the idea. 186 | 187 | It's the default behaviour but you can change it as you wish: 188 | 189 | ```typescript 190 | const cache = new InMemoryCache({ 191 | dataIdFromObject(result) { 192 | if (result.__typename) { 193 | const id = result.uid || result.id || result._id; 194 | 195 | if (id !== undefined) { 196 | return `${result.__typename}:${id}`; 197 | } 198 | } 199 | return null; 200 | }, 201 | }); 202 | ``` 203 | 204 | Great, we covered two out of three things InMemoryCache does. Now we're going to explore "flattens everything up". 205 | 206 | We're able to understand the type of each individual object and it's unique identifier. It still has to be stored. 207 | 208 | What InMemoryCache does is it's saves every element on root level in a plain object where the key contains a unique identifier and the value has a body. 209 | 210 | ```json 211 | { 212 | "Message:1": { 213 | "id": 1, 214 | "content": "Hi", 215 | "likes": 1, 216 | "__typename": "Message" 217 | }, 218 | "Message:2": { 219 | "id": 2, 220 | "content": "Hello", 221 | "likes": 0, 222 | "__typename": "Message" 223 | }, 224 | "ROOT_QUERY": { 225 | "messages": [ 226 | { 227 | "id": "Message:1", 228 | "typename": "Message" 229 | }, 230 | { 231 | "id": "Message:2", 232 | "typename": "Message" 233 | } 234 | ] 235 | } 236 | } 237 | ``` 238 | 239 | Wait, `ROOT_QUERY`? Yes, it has to somehow store the actual result of every executed query so next time you ask for the same thing, it matches it with the query in store and resolves right away. 240 | 241 | > You should be aware that the InMemoryCache stringifies variables of a query too, which means the same queries but with different variables are stored separately. Even the order of those variables matters. 242 | 243 | As you can see, each element in `ROOT_QUERY.message` is kind of a reference to a message record. 244 | 245 | Let's bring back our short story, a scenario. 246 | When Niccolo asks for all messages, the whole three part process is started, each message is saved to the store. 247 | When he asks for history of a conversation with Dotan, InMemoryCache starts the process again and if it sees that there is already a massage with the same unique identifier, it knows they both are the same so it merged them. 248 | The InMemoryCache can tell what changed and emits the new object to the UI. 249 | Thanks to that we no longer have the same message in two places where one has a like and other doesn't. 250 | 251 | Uff, Apollo Cache part is done. Thanks for being with us that long, I hope you didn't get bored because there's a lot ahead of us. 252 | 253 | Let's talk Apollo Link. 254 | 255 | As I mentioned it's kind of a network stack of Apollo. You execute a document with some metadata like variables or a context, Apollo Client receives it and passes it to the chain of Links. 256 | 257 | The ApolloLink is based on an Observable (not RxJS) and if you know `RxJS` you're familiar with `pipe`. Each Link is basically a function that receives a requested operation and a function that runs the next Link. Just like a pipe! 258 | 259 | ```typescript 260 | import { ApolloLink } from 'apollo-link'; 261 | 262 | const log = new ApolloLink((operation, next) => { 263 | console.log('Name', operation.operationName); 264 | return next(operation); 265 | }); 266 | ``` 267 | 268 | Now when we add it in `graphql.module.ts` we see the name of each requested operation. 269 | 270 | We can even intercept the result. 271 | 272 | ```typescript 273 | const intercept = new ApolloLink((operation, next) => { 274 | return forward(operation).map((data) => { 275 | // data from a previous link 276 | console.log('data', data); 277 | return data; 278 | }); 279 | }); 280 | ``` 281 | 282 | As you can see below, by network we don't always mean making HTTP requests. It could be Http Link at the end of Links chain or WebSocket Link or even something that works on in-memory data. 283 | 284 | ```typescript 285 | import { ApolloLink, Observable } from 'apollo-link'; 286 | 287 | const inmemory = new ApolloLink(operation => { 288 | return Observable(observer => { 289 | // function that executes the operation and returns data 290 | const result = resolveOperation(operation); 291 | 292 | observer.next(result); 293 | observer.complete(); 294 | }); 295 | }); 296 | ``` 297 | 298 | You can find tons of helpful Links [on npm](https://www.npmjs.com/search?q=apollo-link-) and [on Apollo Link documentation](https://www.apollographql.com/docs/link/links/community.html). 299 | 300 | We highly recommend to take a look at Angular related Links [on Apollo Angular documentation](https://www.apollographql.com/docs/angular/guides/tools-and-packages.html). 301 | 302 | ### GQL Tag 303 | 304 | The `gql` template tag is what you use to define GraphQL queries in your Apollo apps. It parses your GraphQL query into the `GraphQL.js AST format` which may then be consumed by Apollo methods. Whenever Apollo is asking for a GraphQL query you will always want to wrap it in a `gql` template tag. 305 | 306 | You can embed a GraphQL document containing only fragments inside of another GraphQL document using template string interpolation. This allows you to use fragments defined in one part of your codebase inside of a query defined in a completely different file. 307 | 308 | {{{ diffStep "1.2" module="client" file="src/graphql/fragment.ts" }}} 309 | 310 | ### Use Apollo service 311 | 312 | Let's create a simple service to query the chats from our just created server: 313 | 314 | {{{ diffStep "1.2" module="client" files="src/app/services" }}} 315 | 316 | We just learned how to use Apollo to attach GraphQL query results to the Angular UI. 317 | 318 | The `watchQuery` method returns a `QueryRef` object which has the `valueChanges` property that is an `Observable`. 319 | 320 | Every fetched data is stored in Apollo Client’s cache, so if some other query fetches new information about the chats, this component will update to remain consistent. 321 | 322 | It’s also possible to fetch data only once. You simply use `query` method instead of `watchQuery`. The query method of Apollo service returns an Observable too that also resolves with the same result as above. 323 | 324 | #### But what is a `QueryRef`? 325 | 326 | As you know, `query` method returns an Observable that emits a result, just once, then the observable completes. `watchQuery` works a bit different. It fetches data then stays open and listens to the Cache for updates. 327 | 328 | So why `watchQuery` can not simply expose an Observable and it serves `QueryRef` instead? 329 | 330 | Because Apollo Angular is a bridge between Apollo Client (core library) and Angular framework it has to operate on RxJS based Observables. RxJS Observable has a simple API that cannot be easily extended and it's in general a bad practice. 331 | 332 | Why we're talking about those things? Apollo Client exposes an Observable that has bunch of custom method on it. They are super useful and their purpose is to talk to Apollo through them. 333 | 334 | Since we have to turn Apollo's Observable to something that is compatible with RxJS we decided to expose an object that has all those methods and `valueChanges` property that is a regular RxJS Observable. 335 | 336 | #### Make our app pretty 337 | 338 | We will use Materials for the UI, so let's install it: 339 | 340 | yarn add @angular/cdk @angular/material hammerjs ng2-truncate 341 | 342 | Let's configure Material: 343 | 344 | {{{ diffStep "1.3" module="client" files="src/index.ts, src/main.ts, src/styles.scss" }}} 345 | 346 | We're now creating a `shared` module where we will define our header component where we're going to project a different content from each component: 347 | 348 | {{{ diffStep "1.3" module="client" files="src/app/shared/*" }}} 349 | 350 | Now we want to create the `chats-lister` module, with a container component called `ChatsComponent` and a couple of presentational components. Each and every chat item would be presented with a default profile picture as a placeholder, so before we proceed we will have to download it to the `src/assets` directory: 351 | 352 | $ wget https://raw.githubusercontent.com/Urigo/whatsapp-client-angularcli-material/4f4497df6187c6cf42bb01d7c516db7f9f08e32c/src/assets/default-profile-pic.jpg src/assets 353 | 354 | And now we can proceed to creating the relevant components and their style sheets: 355 | 356 | {{{ diffStep "1.3" module="client" files="src/app/chats-lister/*" }}} 357 | 358 | Finally let's wire everything up to the main module: 359 | 360 | {{{ diffStep "1.3" module="client" files="src/app/app.component.ts, src/app/app.module.ts" }}} 361 | 362 | If you will try to run the frontend you will notice that several messages seems like duplicated, why does it happen? 363 | 364 | Since we use NoSQL-like structure in our backend, messages are stored as an array inside each chat so their incremental identifiers are not unique across different chats. We need to normalize them in a way that takes into account both the message id and the chat id: 365 | 366 | {{{ diffStep "1.4" module="client" }}} 367 | 368 | That way our application will work even if the backend is a NoSQL. What's even more interesting is that our application will keep working as well even when we will switch our backend to PostgreSQL. 369 | -------------------------------------------------------------------------------- /.tortilla/manuals/templates/step10.tmpl: -------------------------------------------------------------------------------- 1 | ## Authentication on server 2 | 3 | Authentication is an hot topic in the GraphQL world and there are some projects which aim at authenticating through GraphQL. 4 | Since often you will be required to use a specific auth framework (because of a feature you need or because of an existing authorization infrastructure) I will show you how to use a classic REST API framework within your GraphQL application. 5 | This approach is completely fine and in line with the official GraphQL best practices. 6 | We will use `Passport` for the authentication and `BasicAuth` as the auth mechanism: 7 | 8 | yarn add bcrypt-nodejs passport passport-http 9 | yarn add -D @types/bcrypt-nodejs @types/passport @types/passport-http 10 | 11 | `BasicAuth` basically involves to send username e password in an Authorization Header together with each request and is fully supported by any browser (meaning that we will be able to use `Graphiql` simply by proving username and password in the login window provided by the browser itself). 12 | It's the most simple auth mechanism but it's completely fine for our needs. Later we could decide to use something more complicated like `JWT`, but it's outside of the scope of this tutorial. 13 | 14 | {{{ diffStep "5.1" module="server" files="index.ts" }}} 15 | 16 | We are going to store hashes instead of plain passwords, that's why we're using `bcrypt-nodejs`. 17 | With `passport.use('basic-signin')` and `passport.use('basic-signup')` we define how the auth framework deals with our database (well, our JSON file for the moment). 18 | `app.post('/signup')` is the endpoint for creating new accounts, so we left it out of the authentication middleware (`app.use(passport.authenticate('basic-signin')`). 19 | What's of particular interest is that we're passing the user object to the GraphQL context. 20 | 21 | {{{ diffStep "5.1" module="server" files="schema/types.ts" }}} 22 | {{{ diffStep "5.1" module="server" files="codegen.yml" }}} 23 | 24 | As you can see, we defined the `contextType` and `AppContext`. Let me explain it. 25 | 26 | ```yaml 27 | # contextType: ./relative/path/to/module#Interface 28 | contextType: ./schema/types#AppContext 29 | ``` 30 | 31 | It means we want to use `AppContext` interface from `./schema/types.ts` module as a Context of every resolver. This is very helpful to not repeat the same interface over and over again, in each resolver function. The path should be relative to the output file. 32 | 33 | {{{ diffStep "5.1" module="server" files="schema/resolvers.ts" }}} 34 | 35 | In the resolvers we're basically making use of the user object taken from the context. 36 | 37 | ## Authentication on client 38 | 39 | Let's start installing `@angular/flex-layout`, because we will use it later: 40 | 41 | yarn add @angular/flex-layout 42 | 43 | 44 | First of all we need to create an `HTTP Interceptor`, which will intercept every HTTP request and will add authentication headers. 45 | For the moment we still don't have those headers, but we are going to store them in the `LocalStorage` later. 46 | We are also creating an `AuthGuard`, which we will use to stop the user from reaching unauthorized Routes. 47 | 48 | The `AuthGuard` will simply look for the presence of the `Authentication` Header, but will not guarantee that the header is authentic. 49 | This is no problem, because client side AuthGuards are not safe by design and the real authentication will be done server side anyway. 50 | AuthGuards are here just to redirect the user to the Login page. 51 | 52 | The service we are going to create will contain some auth methods we are going to use across the app. 53 | 54 | {{{ diffStep "10.1" module="client" files="src/app/login/services" }}} 55 | 56 | Now it's time to create a `SignIn`/`SignUp` component. Since we use Passport in the server we are going to make REST calls for the authentication, instead of using GraphQL. 57 | Since we use `Basic Auth` we will simply combine the username and the password together to create the authentication header. 58 | We will also store the response from the server, which will contain the user information like the ID, etc. which we are going to need later. 59 | 60 | {{{ diffStep "10.1" module="client" files="src/app/login/containers, src/app/login/login.module.ts" }}} 61 | 62 | Now it's time use the Interceptor we just created: 63 | 64 | {{{ diffStep "10.1" module="client" files="src/app/app.module.ts" }}} 65 | 66 | As well as the `AuthGuard`: 67 | 68 | {{{ diffStep "10.1" module="client" files="src/app/chat-viewer/chat-viewer.module.ts, src/app/chats-creation/chats-creation.module.ts, src/app/chats-lister/chats-lister.module.ts" }}} 69 | 70 | Last but not the least we need to fix our main service in order to not use the hardcoded user anymore. Instead we will use our Login service to read the user info from the `LocalStorage`. 71 | 72 | {{{ diffStep "10.1" module="client" files="src/app/services/chats.service.ts" }}} 73 | 74 | We also need to fix our tests: 75 | 76 | {{{ diffStep "10.1" module="client" files="src/app/chat-viewer/containers/chat/chat.component.spec.ts, src/app/chats-lister/containers/chats/chats.component.spec.ts, src/app/services/chats.service.spec.ts" }}} 77 | 78 | 79 | ### GraphQL Server behind a firewall 80 | 81 | There's still one thing remaining. Our GraphQL Code Generator setup is broken now. That's because every made request to the server has to have proper rights and currently the codegen's request introspection query is not authenticated. In short, we need to attach an `Authorization` header. 82 | 83 | We're going to implement the following scenario: 84 | 85 | 1. User runs `yarn generator` 86 | 1. He is asked for a _username_ and a _password_ 87 | 1. Our script generates an _Authorization_ header 88 | 1. GraphQL Code Generator get the GraphQL Schema of our server and outputs the types 89 | 90 | To achieve the goal, we need to change the way we interact with the codegen. 91 | Right now we use GraphQL Code Generator's CLI but the tool allows to use it programatically. So one issue solved. 92 | 93 | What about getting user's name and password part? We will use in-terminal prompt so user can type those. There's a package for that: 94 | 95 | yarn add -D prompt 96 | 97 | Next, create a `src/codegen.ts` file and write our script there: 98 | 99 | {{{ diffStep "10.1" module="client" files="src/codegen.ts" }}} 100 | 101 | Let's take a closer look what we did there: 102 | 103 | - We asks user for a _username_ and a _password_ with `prompt` 104 | - Based on that we generate a header 105 | - We put the header in `schema` option 106 | - there's a `true` value as the second argument, it tells codegen to write the output to a file 107 | 108 | Finally we need to change our `generator` script in `package.json` to `"ts-node src/codegen.ts"` and to tweak a couple of configs: 109 | 110 | {{{ diffStep "10.1" module="client" files="tsconfig.json, src/tsconfig.app.json" }}} 111 | 112 | With all of that, whenever we run `yarn generator`, we access the GraphQL server as an authenticated user. 113 | -------------------------------------------------------------------------------- /.tortilla/manuals/templates/step11.tmpl: -------------------------------------------------------------------------------- 1 | ## Server 2 | 3 | In order to use WebSockets we don't need to install any package, it comes for free with Apollo Server 2.0. 4 | 5 | Our GraphQL server will use WebSockets only for subscriptions, while using HTTP for everything else. That means that we will have to add subscriptions on a specific path. 6 | We're using `connectionParams` for the authentication over WebSockets, that means that we won't be using the `Passport` framework at all. Instead we will use the `onConnect` hook to manually validate the parameters provided by the user to either validate the WebSocket connection or throw an error. 7 | We will also return the user object we retrieved from the db, to let the resolvers know who is the current user. 8 | 9 | {{{ diffStep "6.1" module="server" files="index.ts" }}} 10 | 11 | We will use the `PubSub` implementation from `apollo-server-express`, and publish the data using with Apollo Server. 12 | 13 | The process of setting up a GraphQL subscriptions server consist of the following steps: 14 | 15 | 1. Declaring subscriptions in the GraphQL schema 16 | 2. Setup a PubSub instance that our server will publish new events to 17 | 3. Hook together `PubSub` event and GraphQL subscription. 18 | 4. Setting up `ApolloServer` 19 | 20 | {{{ diffStep "6.1" module="server" files="schema/typeDefs.ts" }}} 21 | 22 | We created two subscriptions: one to notify for new chats and one to notify for new messages. 23 | 24 | {{{ diffStep "6.1" module="server" files="schema/resolvers.ts" }}} 25 | 26 | We will publish a message to the `messageAdded` subscription every time that a user sends a message, then we will filter them according to the current user (we don't want to send someone else's messages). 27 | The `chatAdded` subscription is similar: we will publish each time that a group gets created, but not when chats get created. This is because when a user creates a chat the chat doesn't appear to the other peer until he writes the first message. That's why we also publish when new messages get added (we first look if the other peer already gets the chat listed). 28 | 29 | ## Client 30 | 31 | In order to use WebSockets we will need to install a couple of dependencies: 32 | 33 | yarn add apollo-link-ws apollo-utilities subscriptions-transport-ws 34 | 35 | First let's create the queries for the GraphQL Subscriptions: 36 | 37 | {{{ diffStep "11.1" module="client" files="src/graphql" }}} 38 | 39 | Then we need to run `graphql-code-generator` to generate the types: 40 | 41 | yarn generator 42 | 43 | Now we can update the chats service to update the getChats query every time that we receive a new chat from the subscription. 44 | With GraphQL subscriptions your client will be alerted on push from the server and you should choose the pattern that fits your application the most: 45 | 46 | - Use it as a notification and run any logic you want when it fires, for example alerting the user or refetching data 47 | - Use the data sent along with the notification and merge it directly into the store (existing queries are automatically notified) 48 | 49 | With subscribeToMore, you can easily do the latter. We will manipulate the store to add the newly created chat. 50 | 51 | We will do to do the same for the newMessage subscription, but this time we will have to update two different queries in the store: getChats and getChat. 52 | 53 | {{{ diffStep "11.1" module="client" files="src/app/services/chats.service.ts" }}} 54 | 55 | We can finally configure the WebSocket in the GraphQL Module. Please notice that the WebSocket has its own authentication instead of using the `HttpInterceptor`, in fact we use `connectionParams` to send the authorization. 56 | All queries will go through HTTP except the Subscriptions, which will use the WebSocket. 57 | 58 | {{{ diffStep "11.1" module="client" files="src/app/graphql.module.ts" }}} 59 | 60 | We used `split` of ApolloLink to decide which path should a request get, through WebSocket or HTTP. We decided to push subscriptions over the WebSocket and the rest normally, just like before. 61 | 62 | Finally, let's fix the tests: 63 | 64 | {{{ diffStep "11.1" module="client" files="src/app/chat-viewer/containers/chat/chat.component.spec.ts, src/app/chats-lister/containers/chats/chats.component.spec.ts, src/app/services/chats.service.spec.ts" }}} 65 | -------------------------------------------------------------------------------- /.tortilla/manuals/templates/step12.tmpl: -------------------------------------------------------------------------------- 1 | ## Server 2 | 3 | First of all you will have to install PostgreSQL on your operating system. Since there so many options (different Linux distributions, MacOS X, Windows...) I will assume that you already know how to install a software in your OS and take that part for granted. 4 | 5 | Then you will have to install a couple of packages: 6 | 7 | yarn add pg reflect-metadata typeorm 8 | yarn add -D @types/pg 9 | 10 | We aren't going to use plain SQL, instead we will use an Object-relational mapping framework (ORM) called `TypeORM`. 11 | `TypeORM` takes advantage of Typescript classes and type declarations in order to infer the db structure. 12 | 13 | We will need to enable support for experimental decorators, emit type metadata for decorators and disable strict property initialization: 14 | 15 | {{{ diffStep "7.1" module="server" files="tsconfig.json" }}} 16 | 17 | The next step is to create Entities. An Entity is a class that maps to a database table. You can create a entity by defining a new class and mark it with @Entity(): 18 | 19 | {{{ diffStep "7.1" module="server" files="entity" }}} 20 | 21 | Basic entities consist of columns and relations. Each entity MUST have a primary column. 22 | 23 | Each entity must be registered in your connection options: 24 | 25 | {{{ diffStep "7.1" module="server" files="ormconfig.json" }}} 26 | 27 | Since database table consist of columns your entities must consist of columns too. Each entity class property you marked with @Column will be mapped to a database table column. 28 | Each entity must have at least one primary column. There are several types of primary columns, but in our case `@PrimaryGeneratedColumn()` creates a primary column which value will be automatically generated with an auto-increment value. 29 | `@CreateDateColumn` is a special column that is automatically set to the entity's insertion date. You don't need set this column - it will be automatically set. 30 | For the Recipient Entity we use a composite primary key that consists of two foreign keys. 31 | 32 | The next thing to do is to create a connection with the database before firing up the web server: 33 | 34 | {{{ diffStep "7.1" module="server" files="index.ts" }}} 35 | 36 | We will also remove our fake db and replace it with some real data: 37 | 38 | {{{ diffStep "7.1" module="server" files="db.ts" }}} 39 | 40 | It's time to deal with resolvers: 41 | 42 | {{{ diffStep "7.1" module="server" files="schema/resolvers.ts" }}} 43 | 44 | `QueryBuilder` is one of the most powerful features of `TypeORM` - it allows you to build SQL queries using elegant and convenient syntax, execute them and get automatically transformed entities. 45 | 46 | You can find more informations on `TypeORM` on http://typeorm.io 47 | 48 | The best part is that you won't have to do anything on the client, everything will be completely transparent to it, even if migrated from NoSQL-like db structure to a relational one! 49 | Of course, you could remove the custom normalization for the messages because now they have their own table and they are no longer embedded (so they have unique IDs), but we could leave it as well in order to be free to use any kind of backend. 50 | -------------------------------------------------------------------------------- /.tortilla/manuals/templates/step13.tmpl: -------------------------------------------------------------------------------- 1 | So far we've built a fully functional application, with a database and lots of cool features. 2 | But it we look at our codebase we feel that we could separate the GraphQL server into smaller, reusable, feature based parts. 3 | As our application grew, its code and schematic relationship became bigger and more complex, making the schema maintenance harder and harder to work with. 4 | Some old school MVC frameworks solve this issue adding layers after layer, but most of them just implement separation based technical layers: controllers, models, etc. 5 | There is a better approach: you should separate your GraphQL schema by modules, or features, and include everything related to a specific part of the app under a module. 6 | This is where `GraphQL Modules` comes into our help, providing: 7 | - Reusable modules: Modules are defined by their GraphQL schema (Schema first design). They're completely independent and can be shared between apps. 8 | - Scalable structure: Allows to manage multiple teams and features, multiple micro-services and servers. 9 | - Gradual growth: A clear, gradual path from a very simple and fast, single-file modules, to scalable multi-file, multi-teams, multi-repo, multi-server modules. 10 | - Testable architecture: A rich toolset around testing, mocking and separation. 11 | 12 | First things first, let's install it: 13 | 14 | yarn add @graphql-modules/core @graphql-modules/sonar 15 | 16 | The first question we should ask ourselves is: how do we divide the app into modules? 17 | 18 | First, let's tackle the most obvious ones: the `App Module` and the `Auth Module`. 19 | 20 | ## App Module and Auth Module 21 | 22 | We obviously need authentication, so we need to create a module to handle it. 23 | But we also need to create an "`App Module`", which allows us to import every other module. This is main module of our app. 24 | First, let's delete the whole `/schema` directory, we won't need it anymore. 25 | Instead, let's create a `/modules` directory and create `app.module.ts`: 26 | 27 | {{{ diffStep "8.2" module="server" files="modules/app.module.ts" }}} 28 | 29 | `imports` allows to import other modules (like the `Auth Module` which we are going to create soon), but it also allows us to access the `config` property which contains all the options we are going to pass to the `App Module` when we instantiate it. 30 | We are going to pass those options to the child modules, as configuration parameters. 31 | 32 | Since we are going to move all the middleware for authentication into the `Auth Module` itself, it's time to clean up our index file: 33 | 34 | {{{ diffStep "8.2" module="server" files="^index.ts" }}} 35 | 36 | We also need to adjust our `GraphQL Code Generator` configuration in order to get the schema from our main `App Module`: 37 | 38 | 39 | {{{ diffStep "8.2" module="server" files="codegen.yml, schema.ts" }}} 40 | 41 | Now it's finally time to create the `Auth Module`: 42 | 43 | {{{ diffStep "8.2" module="server" files="modules/auth/index.ts, modules/app.symbols.ts" }}} 44 | 45 | Notice the `middleware` section, where we put all our Express middlewares. 46 | 47 | Also note `providers`, where we define all our `Providers` (they're basically services) where we store all the logic. 48 | It gets `config` as parameter, allowing to access the Module configuration: we created a `Connection` provider out of the `connection` configuration option and the same happened for `app`. 49 | Once a provider gets defined it can be injected almost everywhere. 50 | `GraphQL Modules` features a strong encapsulation, so if you want to access another Module's provider you will need to import that Module first. 51 | 52 | {{{ diffStep "8.2" module="server" files="modules/auth/providers/auth.provider.ts" }}} 53 | 54 | Take a look at the `Auth Provider` and you will notice the `ProviderScope.Session` scope. 55 | That means that a new instance of the `Auth Provider` gets created for each session. 56 | That allows us to get rid of the GraphQL context and simply save the `currentUser` into its own class property. 57 | Every time we want to access the `currentUser` we simply need to access the `Auth Provider` and look at the `currentUser` property. 58 | This is very useful because each Module's context will be different depending on which modules it imports, thus we will need to generate per-module typings. 59 | Instead we preferred getting rid of the context altogether and simply use session-scoped providers instead (the default is application-scoped). 60 | 61 | {{{ diffStep "8.2" module="server" files="modules/auth/resolvers, modules/auth/schema" }}} 62 | 63 | Notice that the `Auth Module` doesn't implement any GraphQL schema except for the scalar `Date` and its resolver. 64 | This is because the authentication happens on the REST endpoints and not through GraphQL (yet, in a later chapter we will cover `Accounts JS`). 65 | Yet for simplicity we decided to put `GraphQLDateTime` into the `Auth Module` instead of creating its own. 66 | Ideally you would want to create a `GraphQLDateTime Module` which takes care of it and you will have to import it in all the modules which makes use of it. 67 | 68 | ## User Module 69 | 70 | {{{ diffStep "8.3" module="server" files="^(?!types.d.ts$).*" }}} 71 | 72 | As you can notice we still have some middleware inside the `User Module`, because the image upload is done through a REST endpoint instead of a GraphQL one. 73 | This will change with a future update. 74 | 75 | Also notice how tidy the resolvers look: this is because all of our logic gone into Providers. 76 | 77 | ## Chat, Message and Recipient Modules 78 | 79 | Now this is where the hard part begins, because we have to figure out how to divide the rest of our applications into Modules. 80 | The first and most important concept to realize is that each Module should be able to work on its own (the module itself plus all of its dependencies). 81 | That means that if we are going to remove all the other modules it should still keep working. 82 | `GraphQL Modules` has strong encapsulation (it forbids access to other modules unless dependencies are explicitly stated), which forces us into this kind of behaviour. 83 | This is great for team development and testability because each module can be worked and tested on its own (we could even import Modules directly from `npm`). 84 | On the other side, sometimes figuring out relationships between Modules can be hard and we will soon realize why. 85 | For this app we are going to create three more modules: `Chat Module`, `Message Module` and `Recipient Module`. 86 | Let's focus on the first two for the moment. 87 | It's obvious that the `Message Module` will have to depend on the `Chat Module`, because to create a new message you will need the chat id. 88 | That means that `Chat Module` cannot depend on `Message Module`, because otherwise we will have circular dependencies. 89 | Circular dependencies basically mean that those modules won't be able to work on their own: each one of them will require the other. 90 | They are basically no modules at all: it's basically like splitting the code in two different files, but you still won't be able to develop and test them separately. 91 | To avoid abuse of circular dependencies we recently removed that feature from `GraphQL Modules`. 92 | At first look it doesn't look like there is any way to avoid a dependency on `Message Module`, because of the `messages` field inside `Chat`. 93 | That's not a problem at all, because we are going to implement that field inside the `Message Module` itself (it depends on `Chat Module`, so it can extend it). 94 | Now if you think there are no more dependencies towards `Message Module` you would be wrong, because in the `chats` resolver we order the chats based on the latest message date. 95 | The fix is very easy: just return them in the natural order and override the `chats` resolver from the `Message Module` to order them according to messages. 96 | That means that if someone is going to import the `Chat Module` as standalone the chats will be returned in no particular order, while if he imports the `Message Module` the order will be according to messages. 97 | But that's not finished yet, because if you take a closer look at the `removeChat` mutation you will notice that it also removes all related messages. 98 | That's a big problem, because if we are going to override the whole mutation we will end up duplicating lots of code. 99 | To avoid that, we will get access to the `Chat Module` `removeChat` mutation from the `Message Module` (it's as easy as injecting its provider and calling it). 100 | Once called we will further expand it to also remove the related messages. 101 | It's done! But you could ask yourself, what's the point of a standalone `Chat Module` if we don't have messages? How can it be useful at all? 102 | What's misleading here is the name: a better name would be the `Group Module`. 103 | In fact what it does is simply creating groups of two or more users and it could be used for basically anything. 104 | 105 | {{{ diffStep "8.4" module="server" files="^(?!types.d.ts$).*" }}} 106 | 107 | Notice tha inside the `Chat Provider` we injected the `User Provider` in order to access the `currentUser`. 108 | 109 | {{{ diffStep "8.5" module="server" files="^(?!types.d.ts$).*" }}} 110 | 111 | Next is the `Recipient Module`. What does it do? It extends the `Message Module` in order to implement the infamous Whatsapp ticks (single, double and blue). 112 | Right now our client doesn't support this functionality, but soon we will update it in order to make use of it. 113 | Once again notice that the Recipient module extends both the `Chat` and the `Message` types, while also extending several mutations like `removeChat`, `addMessage` and `removeMessages`. 114 | 115 | {{{ diffStep "8.6" module="server" files="^(?!types.d.ts$).*" }}} 116 | -------------------------------------------------------------------------------- /.tortilla/manuals/templates/step2.tmpl: -------------------------------------------------------------------------------- 1 | ## Code Generation 2 | 3 | GraphQL entities are defined as static and typed, which means they can be analyzed and use as a base for generating everything. 4 | 5 | There are many tools related to that topic, but we're going to focus on The **GraphQL Coge Generator**. 6 | 7 | The GraphQL Coge Generator can generate any code for any language  —  including type definitions, data models, query builder, resolvers, ORM code, complete full stack platforms. 8 | 9 | The tool is based on the concept of plugins. It has few official plugins to solve most required features. The GraphQL Code Gen allows to create your own custom codegen plugin in 10 minutes, that fit exactly your needs. 10 | 11 | ### Generating types for server-side code 12 | 13 | First, let's install `graphql-code-generator`: 14 | 15 | yarn add -D graphql-code-generator 16 | 17 | GraphQL Code Generator lets you setup everything by simply running the following command: 18 | 19 | yarn gql-gen init 20 | 21 | Question by question, it will guide you through the whole process of setting up a schema, selecting and intalling plugins, picking a destination of a generated file and a lot more. 22 | 23 | What type of application are you building? Angular 24 | Where is your schema? http://localhost:3000/graphql/ 25 | What are your operations and fragments? ./src/graphql/**/*.ts 26 | Pick plugins: common, client, server 27 | Where to write the output? ./src/graphql.ts 28 | Do you want to generate an introspection file? n 29 | What script in package.json should run the codegen? generator 30 | 31 | 32 | First, it will ask you what is the type of application you're going to build, pick "Backend". 33 | When it asks you for the schema, point it to `./schema/typeDefs.ts`. 34 | The output path should be: `./types.d.ts` 35 | 36 | Plugins that you want to have selected are: 37 | 38 | - TypeScript Common 39 | - TypeScript Server 40 | - TypeScript Resolvers 41 | 42 | The goal is to have a config file under `codegen.yml` and an npm script called `generator`. 43 | 44 | > You can read more about GraphQL Code Generator [on its website](https://graphql-code-generator.com/docs/getting-started/). 45 | 46 | {{{ diffStep "2.1" module="server" files="^(?!yarn.lock$).*" }}} 47 | 48 | Few things here: 49 | 50 | - `require: ts-node/register` - makes the ts-node compile TypeScript files 51 | - `schema` - points to a file that exports the GraphQL Schema object or a string (it also accepts an url) 52 | - `generates` - is an object where key is the filepath of an output 53 | - `generates.plugins` - tells about the plugins we want to use 54 | 55 | Let's modify the `codegen.yml` a bit and tell GraphQL Code Generator that `ID` scalar matches primitive `number` type in TypeScript. 56 | 57 | We're also going to use Mappers feature. 58 | 59 | ```yaml 60 | mappers: 61 | Chat: ./db#Chat 62 | Message: ./db#Message 63 | Recipient: ./db#Recipient 64 | ``` 65 | 66 | What it means is that every resolver that is expected to resolve Chat, Message or Recipient type of our GraphQL Schema will use an according interface from `./db` module. Why this is helpful? What if an object returned by a parent resolver has `_id` property instead of `id`, it doesn't match a GraphQL Type then. That's why we implemented mappers. In our case, everything should match by this allow us to make sure it really does. 67 | 68 | The idea behind Mappers is to map an interface to a GraphQL Type so you overwrite that default logic. 69 | 70 | > Read more about [Mappers feature](https://graphql-code-generator.com/docs/plugins/typescript-resolvers#mappers-overwrite-parents-and-resolved-values) 71 | 72 | Now let's run the generator: 73 | 74 | yarn generator 75 | 76 | Please note that the server doesn't have to be running in background because we import schema through a file. 77 | 78 | Next, let's use the generated types: 79 | 80 | {{{ diffStep "2.3" module="server" }}} 81 | 82 | Don't worry, they will be much more useful when we will write our first mutation. 83 | 84 | ### Generating types for client-side code 85 | 86 | Let's do the same on the client: 87 | 88 | yarn add graphql-code-generator 89 | 90 | and also to prepare everything: 91 | 92 | yarn gql-gen init 93 | 94 | Exactly as with the server, you will need to answer few questions. 95 | 96 | First, it will ask you what is the type of application you're going to build, pick "Vanilla JS application" (there's an Angular option but we will introduce it in the next chapter). 97 | When it asks you for the schema, point it to our GraphQL Server `http://localhost:3000/graphql`. 98 | Documents are available under `./src/graphql/**/*.ts`. 99 | The output path should be: `./src/graphql.ts` 100 | 101 | Plugins that you want to have selected are: 102 | 103 | - TypeScript Common 104 | - TypeScript Client 105 | 106 | The goal again, is to have a config file under `codegen.yml` and an npm script called `generate`. 107 | 108 | Please note that in this case, the server must be started before running the generator. 109 | 110 | {{{ diffStep "2.1" module="client" files="^(?!yarn.lock$).*" }}} 111 | 112 | We saw how to use them on the server but let's see how easy it is to take advantage of them in Apollo. 113 | 114 | First thing you should know is most of the methods of Apollo service accepts generic types. 115 | 116 | #### What are Generic Types 117 | 118 | A major part of software engineering is building components that not only have well-defined and consistent APIs, but are also reusable. Components that are capable of working on the data of today as well as the data of tomorrow will give you the most flexible capabilities for building up large software systems. 119 | 120 | We like to work on an code samples so let's do some functional programming and create the `map` method that accepts a transform function and return a new result. 121 | 122 | Without generics, we would have to use a specific type or `any`: 123 | 124 | ```typescript 125 | function map(mapFn: (value: any) => any) { 126 | return function(source: any): any { 127 | return mapFn(source); 128 | }; 129 | } 130 | ``` 131 | 132 | Imagine, we want to use `map` function to pick user's id from an object: 133 | 134 | ```typescript 135 | interface User { 136 | id: number; 137 | } 138 | const me = { 139 | id: 1, 140 | }; 141 | 142 | const pickUserId = map(user => user.id); 143 | 144 | console.log(pickUserId(me)); // outputs: 1 145 | ``` 146 | 147 | Great, it works, we get the expected result but it could be done way better. 148 | 149 | What is the main issue here? 150 | It accepts an object of any type and get `any` in return. It's not very useful because we know it return a `number` and receive a `User` but TypeScript doesn't know that and later on, in other part of the code, we end up with no information about the result. 151 | 152 | That's where Generic Types jumps in! 153 | 154 | With generics, it could look like this: 155 | 156 | ```typescript 157 | function map(mapFn: (value: T) => R) { 158 | return function(source: T): R { 159 | return mapFn(source); 160 | }; 161 | } 162 | ``` 163 | 164 | There's... a lot! So let's break it: 165 | 166 | - `map` - the map function accepts two generic types 167 | - `(mapFn: (value: T) => R)` - the only argument is a function that accepts an object of type `T` and returns value of type `R` 168 | - `function (source: T): R => {...}` - it's a higher order function that accepts a source of type `T` and transforms to be `R`. 169 | 170 | Back to our example, now with generic types: 171 | 172 | ```typescript 173 | interface User { 174 | id: number; 175 | } 176 | const me = { 177 | id: 1, 178 | }; 179 | 180 | const pickUserId = map(user => user.id); 181 | 182 | console.log(pickUserId(me)); // outputs: 1 183 | ``` 184 | 185 | Our `map` and `pickUserId` function are strongly typed now so when you try to provide an object that has no `id` field or the field is a `string` you'll receive a proper information from TypeScript (and your IDE). 186 | 187 | #### Make client-side code strongly typed 188 | 189 | With all that knowledge let's use how to use those generated typed with `Apollo` service: 190 | 191 | {{{ diffStep "2.3" module="client" }}} 192 | 193 | As you can probably tell by now, methods like `watchQuery` and `mutate` (and others) accepts two generic types, first one describes the result and the second one the variables. 194 | 195 | ```typescript 196 | export class ChatService { 197 | constructor(private apollo: Apollo) {} 198 | 199 | getChat(chatId: string, amount: number): GetChat.Chat { 200 | return this.apollo 201 | .watchQuery({ 202 | query: getChatQuery, 203 | variables: { 204 | chatId, 205 | amount, 206 | }, 207 | }) 208 | .pipe(map(result => result.data.chat)); 209 | } 210 | } 211 | ``` 212 | 213 | Few things here. 214 | 215 | - An object of `GetChat.Query` shape lives under the `result.data` because `watchQuery` returns an object of type `ApolloQueryResult` where `T` is our `GetChat.Query`. 216 | 217 | ```typescript 218 | export type ApolloQueryResult = { 219 | data: T; 220 | // ... other fields 221 | }; 222 | ``` 223 | 224 | You get the same result with Mutation or Subscription. 225 | 226 | - Thanks to the second argument and `GetChat.Variables` we as well as TypeScript (and an IDE) know that `chatId` is a string and `amount` accepts only a number. Whenever we try to pass a value of different kind an Error will pop out. 227 | 228 | ### Take full advantage of Codegen and generate ready to use Services! 229 | 230 | I've got a fantasctic news, you don't have to manually provide those generated types to each type Apollo service is used. Because GraphQL Documents are statically analyzable, we prepared a codegen template specific for Apollo Angular users. 231 | 232 | First, let's install `graphql-codegen-typescript-apollo-angular` with the Apollo Angular plugin and update the list of plugins in `codegen.yml` 233 | 234 | yarn add -D graphql-codegen-typescript-apollo-angular 235 | 236 | {{{ diffStep "2.4" module="client" files="package.json" }}} 237 | 238 | Then you need to add `typescript-apollo-angular` next to other plugins in `codegen.yml`. 239 | 240 | The Apollo Angular template generates a ready to use in your component, strongly typed Angular services, for every defined query, mutation or subscription. 241 | 242 | It's possible thanks to the new API of Apollo Angular. More on that in ["Query, Mutation, Subscription services"](http://apollographql.com/docs/angular/basics/services.html?_ga=2.227615197.1327014552.1538988114-793224955.1532981447) chapter of the documentation. 243 | 244 | Given an example: 245 | 246 | ```graphql 247 | query GetChats($amount: Int) { 248 | chats { 249 | ...ChatWithoutMessages 250 | messages(amount: $amount) { 251 | ...Message 252 | } 253 | } 254 | } 255 | ``` 256 | 257 | The Apollo Angular template, after you run `yarn generate`, outputs a service called `GetChatsGQL`: 258 | 259 | ```typescript 260 | import { GetChatsGQL, GetChats } from '../graphql'; 261 | 262 | export class AppComponent { 263 | constructor(private getChatsGQL: GetChatsGQL) {} 264 | 265 | getChats(): GetChats.Chats[] { 266 | return this.getChatsGQL 267 | .watch({ 268 | amount: 10, 269 | }) 270 | .valueChanges.pipe(map(result => result.data.chats)); 271 | } 272 | } 273 | ``` 274 | 275 | > Remember, every operation should has an unique name. 276 | 277 | It might look a bit different then what you have already learnt but we promise, the API is even easier. 278 | 279 | But first, let's dive into how those services look like under the hood. 280 | 281 | ```typescript 282 | import { Query } from 'apollo-angular'; 283 | 284 | @Injectable({ 285 | providedIn: 'root', 286 | }) 287 | export class GetChatsGQL extends Query { 288 | document = gql` 289 | here goes the document 290 | `; 291 | } 292 | ``` 293 | 294 | Okay, seems easy but what is the `Query` class, you might ask! 295 | 296 | Apollo Angular exposes three classes for three kinds of the GraphQL operation: `Query`, `Mutation` and `Subscription`. Each of them has a different API. 297 | 298 | - `Query` has `fetch` and `watch`. First one behave like `Apollo.query()`, second one like `Apollo.watchQuery()` 299 | - `Mutation` has `mutate` 300 | - `Subscription` has `subscribe` 301 | 302 | Because the `document` is already defined in a class, they all accept two arguments (Subscription has third one). First argument defines variables, second shapes the options. Thats why we used `this.getChatsGQL.watch({ amount: 10 })`. 303 | 304 | Let's stop talking about the API itself and see what benefits it brings us: 305 | 306 | - **Less code to write** - no need to create a network call, no need to create Typescript typings, no need to create a dedicated Angular service 307 | - **Strongly typed out of the box — all** types are being generated, no need to write any Typescript definitions and struggle to keep them updated 308 | - _More pleasent API_ to work with 309 | - **Full developer experience of tools and IDEs**  —  development time, autocomplete and error checking, not only across your frontend app but also with your API teams! 310 | - **Tree-shakable** thanks to Angular 6 311 | 312 | Most IDEs with the GraphQL support (built-in or thanks to extensions) fully handles `.graphql` files and helps you with features like auto-completion, validation but they strugle with `gql` tag. To fully enjoy GraphQL we highly recommend to use static `.graphql` files. 313 | 314 | With all that knowledge, let's use GQL services in our application: 315 | 316 | {{{ diffStep "2.4" module="client" files="src/app/services/chats.service.ts" }}} 317 | -------------------------------------------------------------------------------- /.tortilla/manuals/templates/step3.tmpl: -------------------------------------------------------------------------------- 1 | ## Client 2 | 3 | Testing is a very important part of each application and for the sake of showing different testing techniques we are going to show how to test a presentational component, a container component and a service. 4 | 5 | First let's start by importing `hammerjs` for the Material Gestures inside the test script. 6 | 7 | {{{ diffStep "3.1" module="client" files="src/test.ts" }}} 8 | 9 | ### Testing a Presentional Component 10 | 11 | Let's start with the simplest one: the presentational component. 12 | We are not going to inject any service and we don't need to access our backend, so things are quite simple: we just need to pass our Chat object as an Input, detect the changes and use the query selector to match the UI content to the one we passed as input: 13 | 14 | {{{ diffStep "3.1" module="client" files="src/app/chats-lister/components/chat-item/chat-item.component.spec.ts" }}} 15 | 16 | ### Testing a Service 17 | 18 | Testing a service is a bit more complicated because we will need to mock our backend in order to get fake results instead of having to fire up the backend each time. 19 | 20 | We could simply simply mock the HTTP calls, which is a well known practice in the REST API world. Since we are using HTTP to retrieve the data, it would work as well with our Apollo Client setup. 21 | 22 | #### Testing with Apollo Angular 23 | 24 | Because Apollo Angular provides its own testing utilities, let's bring them in! 25 | 26 | Although `apollo-angular` has a lot going on under the hood, the library provides multiple tools for testing that simplify those abstractions, and allows complete focus on the component logic. 27 | 28 | The `apollo-angular/testing` module exports a `ApolloTestingModule` module and `ApolloTestingController` service which simplifies the testing of Angular components by mocking calls to the GraphQL endpoint. This allows the tests to be run in isolation and provides consistent results on every run by removing the dependence on remote data. 29 | 30 | By using this `ApolloTestingController` service, it’s possible to specify the exact results that should be returned for a certain query. 31 | 32 | Worth mentioning, `ApolloTestingModule` comes with a default cache but you can use your own with `APOLLO_TESTING_CACHE` token. 33 | 34 | Let's show everything on an example: 35 | 36 | {{{ diffStep "3.1" module="client" files="src/app/services/chats.service.spec.ts" }}} 37 | 38 | As you can see, the API of `ApolloTestingController` is pretty similar to the `HttpTestingController`. 39 | 40 | There are four methods you should know. 41 | 42 | **`expectOne()`** 43 | Important thing, it accepts two arguments. First is different for different use cases, the second one stays always the same, it’s a string with a description of your assertion. In case of failing assertion, the error is thrown with an error message including the given description. 44 | 45 | Let's explore all those possible cases `expectOne` accepts: 46 | 47 | - you can match an operation by its name, simply by passing a string as a first argument. 48 | - by passing the whole Operation object the expectOne method compares: operation’s name, variables, document and extensions. 49 | - the first argument can also be a function that provides an Operation object and expect a boolean in return 50 | - or passing a GraphQL Document 51 | 52 | **`exceptNone()`** 53 | It accepts the same arguments as expectOne but it's a negation of it. 54 | 55 | **`match()`** 56 | Search for operations that match the given parameters, without any expectations. 57 | 58 | **`verify()`** 59 | Verify that no unmatched operations are outstanding. If any operations are outstanding, fail with an error message indicating which operations were not handled. 60 | 61 | If you want to learn more about testing, we got you covered, please visit ["Testing Apollo in Angular" chapter](https://www.apollographql.com/docs/angular/guides/testing.html) in Apollo documentation. 62 | 63 | ### Testing a Container Component 64 | 65 | In the last example we are going to test a container component, which makes use of several services and multiple other components: 66 | 67 | {{{ diffStep "3.1" module="client" files="src/app/chats-lister/containers/chats/chats.component.spec.ts" }}} 68 | -------------------------------------------------------------------------------- /.tortilla/manuals/templates/step4.tmpl: -------------------------------------------------------------------------------- 1 | At this point we have a module which lists all of our chats, but we still need to show a particular chat. 2 | We're going to implement in in this chapter. 3 | 4 | ### ChatViewer module 5 | 6 | First, let's create a `ChatViewer` module: 7 | 8 | {{{ diffStep "4.1" module="client" files="src/app/app.module.ts, src/app/chat-viewer/chat-viewer.module.ts" }}} 9 | 10 | As you can see, it has already everything that we might need. 11 | 12 | ### GetChat operation 13 | 14 | Components need data, so we create the `GetChat` operation and run `yarn generator`: 15 | 16 | {{{ diffStep "4.1" module="client" files="src/graphql/getChat.query.ts" }}} 17 | 18 | The query can be now implemented in `ChatsService`: 19 | 20 | {{{ diffStep "4.1" module="client" files="src/app/services/chats.service.ts" }}} 21 | 22 | Great! 23 | 24 | ### Chat view 25 | 26 | Now we've got data but a user can't still access the chat view. 27 | There is one place where we're able to pick the chat, it's the list: 28 | 29 | {{{ diffStep "4.1" module="client" files="src/app/chats-lister/components/chat-item/chat-item.component.ts, src/app/chats-lister/components/chats-list/chats-list.component.ts, src/app/chats-lister/containers/chats/chats.component.ts" }}} 30 | 31 | Time for a next step, an actual implementation of the chat view. 32 | 33 | {{{ diffStep "4.1" module="client" files="src/app/chat-viewer/containers/chat/chat.component.ts, src/app/chat-viewer/containers/chat/chat.component.scss" }}} 34 | 35 | The `ChatComponent` component contains: 36 | 37 | - Toolbar with chat's name and a "go back" button 38 | - List of messages sorted from oldest to newest 39 | - Space to submit a new message 40 | 41 | ### List of messages 42 | 43 | First we'll download and a couple of images which will help us create a "bubble" effect for received messages: 44 | 45 | $ wget https://raw.githubusercontent.com/Urigo/whatsapp-client-angularcli-material/4f4497df6187c6cf42bb01d7c516db7f9f08e32c/src/assets/chat-background.jpg src/assets 46 | $ wget https://raw.githubusercontent.com/Urigo/whatsapp-client-angularcli-material/4f4497df6187c6cf42bb01d7c516db7f9f08e32c/src/assets/message-mine.png src/assets 47 | $ wget https://raw.githubusercontent.com/Urigo/whatsapp-client-angularcli-material/4f4497df6187c6cf42bb01d7c516db7f9f08e32c/src/assets/message-other.png src/assets 48 | 49 | We'll now split the list of messages to the host: 50 | 51 | {{{ diffStep "4.1" module="client" files="src/app/chat-viewer/components/messages-list/messages-list.component.ts, src/app/chat-viewer/components/messages-list/messages-list.component.scss" }}} 52 | 53 | and a reused component for each message: 54 | 55 | {{{ diffStep "4.1" module="client" files="src/app/chat-viewer/components/message-item/message-item.component.ts, src/app/chat-viewer/components/message-item/message-item.component.scss" }}} 56 | 57 | As you see, we could easily decide which message comes from which user based on the `ownership` property. 58 | 59 | ### Submit a new message 60 | 61 | The app shows the conversation and now we're going to focus on the last part, actually emitting a new message! 62 | 63 | {{{ diffStep "4.1" module="client" files="src/app/chat-viewer/components/new-message/new-message.component.scss, src/app/chat-viewer/components/new-message/new-message.component.ts" }}} 64 | 65 | It's not yet fully functional, we receive the message in the `ChatComponent` but we still need to send it to the server. We'll cover that in next few steps! 66 | -------------------------------------------------------------------------------- /.tortilla/manuals/templates/step5.tmpl: -------------------------------------------------------------------------------- 1 | In addition to fetching data using queries, Apollo also helps you handle GraphQL mutations. In GraphQL, mutations are identical to queries in syntax, the only difference being that you use the keyword mutation instead of query to indicate that the root fields on this query are going to be performing writes to the backend. 2 | GraphQL mutations represent two things in one query string: 3 | 4 | 1. The mutation field name with arguments, which represents the actual operation to be done on the server. 5 | 2. The fields you want back from the result of the mutation to update the client. 6 | 7 | When we use mutations in Apollo, the result is typically integrated into the cache automatically based on the id of the result, which in turn updates the UI automatically, so we often don't need to explicitly handle the results. In order for the client to correctly do this, we need to ensure we select the necessary fields in the result. One good strategy can be to simply ask for any fields that might have been affected by the mutation. Alternatively, you can use fragments to share the fields between a query and a mutation that updates that query. 8 | 9 | ## Server 10 | 11 | Finally we're going to create our mutations in the server: 12 | 13 | {{{ diffStep "3.1" module="server" }}} 14 | 15 | Let me briefly explain what's going on here. 16 | For each chat/group we store the `allTimeMemberIds`, `listingMemberIds` and `actualGroupMemberIds` properties in our NoSQL-like fake db. 17 | What's the difference between `allTimeMemberIds` and `listingMemberIds`? When a chat gets created only the user who created it will be able too see it, the chat will be displayed to the other user only once the first messaged gets sent. `allTimeMemberIds` is an array which always contain both the users, while `listingMemberIds` contains only the users which get the chat listed (initially the creator, later both users). `actualGroupMemberIds` is only used for groups. 18 | Groups, instead, get listed by all members immediately since the creation. So initially both `allTimeMemberIds`, `listingMemberIds` and `actualGroupMemberIds` are similar. Later users can leave the group or get deleted (so they will be removed from `actualGroupMemberIds`) but they will still be able to list the group in read-only mode, thus remaining in the `listingMemberIds`. Once they remove the group they will also be remove from the `listingMemberIds` array. 19 | That's why we have to check for several different conditions before adding/deleting messages: it could be necessary to add the other peer to the `listingMemberIds` (for example if we are writing the first message of a chat) or it could be necessary to physically remove the messages instead of simply removing the current user from the `holderIds`. `holderIds` is a field in each message which states which user will currently display that specific message. In fact each user can delete a specific message without affecting what the others will see. Once there will be no more users in the `holderIds` array it will be safe to delete the message. 20 | Each message has also a `recipients` array containing the receiving date and the viewing date of that particular message for all the other users. That's necessary to implement the single, double and blue ticks used by the real Whatsapp. 21 | 22 | It may seem a bit overwhelming at first, but you should keep in mind that the real Whatsapp has tons of features and also takes advantage of a local database to store messages, so it's easier for them to implement features like per-user messages: their source of truth is not the server because once downloaded the messages are kept in the client itself, so deleting messages doesn't affect anyone else. On the contrary our source of truth is the server, so our approach is more similar to Telegram instead. This is a better approach in my opinion because it allows us to show the messages for the same user on multiple clients, instead of having to rely on questionable approaches like Whatsapp Web. 23 | Also we already implemented our mutations to take care of future use cases (like reading notifications) which we still didn't implement. 24 | 25 | Finally, remember to run the generator: 26 | 27 | yarn generator 28 | 29 | ## Client 30 | 31 | For the client I'll only show you how to make use of the addMessage mutation in this chapters. The other mutations will require much more boilerplate so I left them for their own chapter. 32 | 33 | Let's start by wiring the addMessage mutation. We're going to write the GraphQL query and then use the generator to generate the types: 34 | 35 | {{{ diffStep "5.1" module="client" files="^(?!src/types.d.ts$).*" }}} 36 | 37 | Run the generator: 38 | 39 | yarn generator 40 | 41 | Now let's use the just-created query: 42 | 43 | {{{ diffStep "5.2" module="client" }}} 44 | 45 | It's that simple! You would be tempted to say that it doesn't work, but you should try to refresh the page first ;) 46 | -------------------------------------------------------------------------------- /.tortilla/manuals/templates/step6.tmpl: -------------------------------------------------------------------------------- 1 | ## Client 2 | 3 | Did you notice that after creating a new message you'll have to refresh the page in order to see it? 4 | 5 | How to fix that? 6 | 7 | Apollo performs two important core tasks which we covered in one of previous steps: executing queries and mutations, and caching the results. 8 | 9 | Thanks to Apollo's store design, it's possible for the results of a query or mutation to update your UI in all the right places. In many cases it's possible for that to happen automatically, whereas in others you need to help the client out a little in doing so. 10 | 11 | There are couple of APIs to update the cache after mutation: 12 | 13 | - `refetchQueries` - is the most straightforward way of updating the cache. You request queries to be executed once again. 14 | - `update` - super flexible and allows you to make changes to the store based on mutation. Works well with Optimistic UI which we will cover later. 15 | - `updateQueries` - It works similar to the previous option but the API may be deprecated in the future and we recommend to stay with `update`. 16 | 17 | If you thought about re-querying the server you would be wrong! The best solution is to use the response provided by the server to update our Apollo local cache. 18 | 19 | Of course that we're going to use the `update` API! 20 | 21 | ### Updating the store 22 | 23 | As you can see, the `update` option is a function that has two arguments: 24 | 25 | - `DataProxy` which we named `store` 26 | - mutation's result 27 | 28 | The `DataProxy` seems very interesting! It's a middleman between you and the cache. 29 | 30 | #### How to update fragments 31 | 32 | In pretty much every case, that middleman would allow to write and read data. Take for example the JavaScript `Map` object. 33 | 34 | It has a `get` and `set` methods and both works based on a `key`. 35 | 36 | In Apollo, we do have that key, it's something that was covered in the 5th step, remember? An object of type `Message` with an `id` would be stored in the cache as `Message:`. 37 | 38 | Let's call that small object a fragment, for simplicity. 39 | 40 | What are our _get_ and _set_ methods in Apollo's DataProxy? Simple, `readFragment` and `writeFragment`. 41 | 42 | I think it's time to work on a simple example. Scenario: every message can be liked and we keep the sum of likes under the `likes` field. 43 | 44 | How to give a like? 45 | 46 | ```typescript 47 | const store: DataProxy = ...; 48 | const messageId = 256; 49 | 50 | const fragment = gql` 51 | fragment M on Message { 52 | likes 53 | } 54 | `; 55 | const fragmentId = `Message:${messageId}`; 56 | 57 | const message = store.readFragment({ 58 | fragment, 59 | id: fragmentId 60 | }); 61 | 62 | store.writeFragment({ 63 | fragment, 64 | id: fragmentId, 65 | data: { 66 | likes: message.likes + 1 67 | } 68 | }); 69 | ``` 70 | 71 | What just happened?! 72 | 73 | At first, it might seem weird to talk to the store like that but you will see that it makes a lot of sense! 74 | 75 | - `messageId` - defined the actual id of our message 76 | - `fragment` - it's a document that tells Apollo what fields we want to read and write 77 | - `fragmentId` - an unique key under which the fragment is stored (we covered that in 5th step) 78 | - `readFragment` - reads the fragment in the store and returns the result in exact same shape as requested 79 | - `writeFragment` - accepts same properties as `readFragment` but also the `data` property 80 | 81 | ### How to update queries 82 | 83 | Similar as in case of fragments, we update queries with `readQuery` and `writeQuery`. 84 | 85 | We won't cover the same thing again but let's just look at the API based on our WhatsApp clone example: 86 | 87 | {{{ diffStep "6.1" module="client" }}} 88 | 89 | As you can see we used the `document` property of a generated GQL service. It contains the actual query that is used in every `watch` or `fetch` calls of the GQL service. It's a part of the open API. 90 | 91 | ### Keep on mind 92 | 93 | It's very important to know few things: 94 | 95 | - update function have to run synchronously 96 | - the query should always match the query you want to update (even order of fields or variables matters) 97 | - by updating a query you also update every normalized data it has (we called them fragments in few secions above) 98 | 99 | When Apollo runs the `update` function? In two cases, rigth after the mutation happens but only if it has an optimistic response defined, and also once we get the response from the server. 100 | 101 | > Optimistic Resposne is something we will cover in next steps but so you know, Apollo allows to optimistically update the store with a temporary data that is being replaced right after we get the response from the server. 102 | 103 | But why the update function has to run synchornously? It's by design and not so interesting. We won't cover it in-depth here but in short, when Apollo runs the function it switches the store with the new, empty one, to record every change that's made by the function to later merge it with the original store. If you still have questions, please let us know so we can cover that thing in-depth as a blog post or even a separate chapter in that tutorial. 104 | 105 | ### Summary 106 | 107 | Now you won't need to reload the page in order to see the new message. What's even more interesting is that the message you wrote would also be shown as the last message in the chats list, just hit the back button in the top-left corner to find out! 108 | 109 | This is because we updated our store for both the `GetChat` and the `GetChats` query. 110 | -------------------------------------------------------------------------------- /.tortilla/manuals/templates/step7.tmpl: -------------------------------------------------------------------------------- 1 | ## Client 2 | 3 | Since we're now familiar with the way mutations work, it's time to add messages and chats removal to our list of features! 4 | Since the most annoying part is going to be dealing with the user interface (because a multiple selection started by a press event is involved), I created an Angular directive to ease the process. 5 | 6 | Let's start by adding `ngx-selectable-list`: 7 | 8 | yarn add ngx-selectable-list 9 | 10 | {{{ diffStep "7.1" module="client" files="^(?!yarn.lock$).*" }}} 11 | 12 | Because we're going to use the `libSelectableList` directive, we can replace Outputs and EventEmitters with it: 13 | 14 | {{{ diffStep "7.2" module="client" files="src/app/chats-lister/components/chat-item/chat-item.component.ts, src/app/chats-lister/components/chats-list/chats-list.component.ts, src/app/chat-viewer/components/messages-list/messages-list.component.ts" }}} 15 | 16 | Don't forget to add the `NgxSelectableListModule` to `ChatViewer` and `ChatLister` modules. 17 | 18 | We also created a `ConfirmSelectionComponent` to use for content projection, since our selectable list directive will be able to listen to its events. 19 | 20 | {{{ diffStep "7.2" module="client" files="src/app/shared/components/confirm-selection/confirm-selection.component.scss, src/app/shared/components/confirm-selection/confirm-selection.component.ts, src/app/shared/shared.module.ts" }}} 21 | 22 | Great, let's finish the component part by adding the connection between our container components and the `ChatsService`. We're going to use `removeMessages` and `removeChat` methods. 23 | 24 | {{{ diffStep "7.2" module="client" files="src/app/chat-viewer/containers/chat/chat.component.ts, src/app/chat-viewer/containers/chat/chat.component.spec.ts, src/app/chats-lister/containers/chats/chats.component.ts, src/app/chats-lister/containers/chats/chats.component.spec.ts"}}} 25 | 26 | ### Connecting actions with the server 27 | 28 | The UI part is pretty much complete but we still need to implement two methods in the `ChatsService`. We're going to create mutations to remove a chat, selected messages or all of them. 29 | 30 | {{{ diffStep "7.2" module="client" files="src/graphql/removeChat.mutation.ts, src/graphql/removeMessages.mutation.ts, src/graphql/removeAllMessages.mutation.ts" }}} 31 | 32 | Once again, we run the GraphQL Code Generator to get the GQL services: 33 | 34 | yarn generator 35 | 36 | Now we can import those services, create `removeChat` and `removeMessages` methods in which we call a mutation. 37 | 38 | {{{ diffStep "7.2" module="client" files="src/app/services/chats.service.ts" }}} 39 | 40 | It might seem like a lot but we simply just updating the store there. 41 | 42 | ### Summary 43 | 44 | The selectable list directive supports much more different use cases, for info please read the documentation. 45 | 46 | As you can see `ngx-selectable-list` takes care of most of the boilerplate, giving us the freedom to concentrate on the actual code. 47 | -------------------------------------------------------------------------------- /.tortilla/manuals/templates/step8.tmpl: -------------------------------------------------------------------------------- 1 | We still cannot create new chats or groups, so let's implement it. 2 | 3 | We're going to create a `ChatsCreation` module, with a `NewChat` and a `NewGroup` containers, along with several presentational components. 4 | 5 | We're going to make use of the selectable list directive once again, to ease selecting the users when we're creating a new group. 6 | You should also notice that we are looking for existing chats before creating a new one: if it already exists we're are simply redirecting to that chat instead of creating a new one (the server wouldn't allow that anyway and it will simply 7 | return the chat id). 8 | 9 | {{{ diffStep "8.1" module="client" files="src/graphql/" }}} 10 | 11 | After creating the mutations we should run the generator to create the corresponding types and services: 12 | 13 | yarn generator 14 | 15 | Let's jump straight into `ChatsService` and add few new methods: 16 | 17 | {{{ diffStep "8.1" module="client" files="src/app/services/chats.service.ts" }}} 18 | 19 | Okay, now since we got the GraphQL part ready, we're going to focus on the component. 20 | 21 | First, space to add a new group: 22 | 23 | {{{ diffStep "8.1" module="client" files="src/app/chats-creation/containers/new-group/new-group.component.ts, src/app/chats-creation/containers/new-group/new-group.component.scss" }}} 24 | 25 | Next, a new chat view: 26 | 27 | src/app/chats-creation/containers/new-chat/new-chat.component.scss, src/app/chats-creation/containers/new-chat/new-chat.component.ts 28 | 29 | {{{ diffStep "8.1" module="client" files="src/app/app.module.ts, src/app/chats-creation, src/app/services" }}} 30 | 31 | They're both missing few components: 32 | 33 | **UsersList** 34 | 35 | {{{ diffStep "8.1" module="client" files="src/app/chats-creation/components/users-list/users-list.component.ts, src/app/chats-creation/components/users-list/users-list.component.scss" }}} 36 | 37 | **UserItem** 38 | 39 | {{{ diffStep "8.1" module="client" files="src/app/chats-creation/components/user-item/user-item.component.ts, src/app/chats-creation/components/user-item/user-item.component.scss" }}} 40 | 41 | **NewGroupDetails** 42 | 43 | {{{ diffStep "8.1" module="client" files="src/app/chats-creation/components/new-group-details/new-group-details.component.ts, src/app/chats-creation/components/new-group-details/new-group-details.component.scss" }}} 44 | 45 | With all that, we can now link NewChat component with Chat container: 46 | 47 | {{{ diffStep "8.1" module="client" files="src/app/chats-lister/containers/chats/chats.component.ts, src/app/chat-viewer/containers/chat/chat.component.spec.ts" }}} 48 | 49 | Now let's wrap it all together within the `ChatsCreation` module: 50 | 51 | {{{ diffStep "8.1" module="client" files="src/app/chats-creation/chats-creation.module.ts, src/app/app.module.ts" }}} 52 | -------------------------------------------------------------------------------- /.tortilla/manuals/templates/step9.tmpl: -------------------------------------------------------------------------------- 1 | ## Client 2 | 3 | Now let's start our client in production mode: 4 | 5 | yarn start --prod 6 | 7 | Now open the **Chrome Developers Tools** and, in the **Network tab**, select `Slow 3G Network` and `Disable cache`. 8 | Then refresh the page and look at the `DOMContentLoaded` time and at the transferred size. You'll notice that our bundle size is quite small and so the loads time. 9 | 10 | Now let's click on a specific chat. It will take some time to load the data and then add a new message. 11 | Once again it will take some time to load the data. 12 | We could also create a new chat and the result would be the same. 13 | The whole app doesn't feel as snappier as the real Whatsapp on a slow 3G Network. 14 | 15 | "That's normal, it's a web application with a remote db while Whatsapp is a native app with a local database..." 16 | That's just an excuse, because we can do as good as Whatsapp thanks to Apollo! 17 | 18 | Let's install `moment`, we will soon need it: 19 | 20 | yarn add moment 21 | 22 | Start by making our UI optimistic. We can predict most of the response we will get from our server, except for a few things like `id` of newly created messages. But since we don't really need that id, we can simply generate a fake one 23 | which will be later overridden once we get the response from the server: 24 | 25 | {{{ diffStep "9.1" module="client" files="^(?!package.json$|yarn.lock$).*" }}} 26 | 27 | When we open a specific chat we can also preload some data from our chats list cache while waiting for the server response. We will initially be able to show only the chat name, the last message or the last few messages and a few more informations instead of the whole content from the server, but that would be more than enough to entertain the user while waiting for the server response: 28 | 29 | {{{ diffStep "9.2" module="client" }}} 30 | 31 | Now let's deal with the most difficult part, chats creation. 32 | 33 | We cannot predict the `id` of the new chat and so we cannot navigate to the chat page because it contains the chat id in the url. We could simply navigate to the "optimistic" id, but then the user wouldn't be able to reach that url if he refreshes the page or bookmarks it. That's a problem we care about. 34 | 35 | How to solve it? We're going to create a landing page and we will later override the url once we get the response from the server! 36 | 37 | {{{ diffStep "9.3" module="client" }}} 38 | 39 | Poof, now our Whatsapp clone feels no more like a clone it has the same native feel. 40 | -------------------------------------------------------------------------------- /.tortilla/manuals/views/root.md: -------------------------------------------------------------------------------- 1 | ../../../README.md -------------------------------------------------------------------------------- /.tortilla/manuals/views/step11.md: -------------------------------------------------------------------------------- 1 | # Step 11: Subscriptions 2 | 3 | [//]: # (head-end) 4 | 5 | 6 | ## Server 7 | 8 | In order to use WebSockets we don't need to install any package, it comes for free with Apollo Server 2.0. 9 | 10 | Our GraphQL server will use WebSockets only for subscriptions, while using HTTP for everything else. That means that we will have to add subscriptions on a specific path. 11 | We're using `connectionParams` for the authentication over WebSockets, that means that we won't be using the `Passport` framework at all. Instead we will use the `onConnect` hook to manually validate the parameters provided by the user to either validate the WebSocket connection or throw an error. 12 | We will also return the user object we retrieved from the db, to let the resolvers know who is the current user. 13 | 14 | [{]: (diffStep "6.1" files="index.ts" module="server") 15 | 16 | #### [Step 6.1: Subscriptions](https://github.com/Urigo/WhatsApp-Clone-Server/commit/e40c1e7) 17 | 18 | ##### Changed index.ts 19 | ```diff 20 | @@ -12,6 +12,8 @@ 21 | ┊12┊12┊import basicStrategy from 'passport-http'; 22 | ┊13┊13┊import bcrypt from 'bcrypt-nodejs'; 23 | ┊14┊14┊import { db, UserDb } from "./db"; 24 | +┊ ┊15┊import { createServer } from "http"; 25 | +┊ ┊16┊import { pubsub } from "./schema/resolvers"; 26 | ┊15┊17┊ 27 | ┊16┊18┊let users = db.users; 28 | ┊17┊19┊ 29 | ``` 30 | ```diff 31 | @@ -44,6 +46,11 @@ 32 | ┊44┊46┊ name: req.body.name, 33 | ┊45┊47┊ }; 34 | ┊46┊48┊ users.push(user); 35 | +┊ ┊49┊ 36 | +┊ ┊50┊ pubsub.publish('userAdded', { 37 | +┊ ┊51┊ userAdded: user, 38 | +┊ ┊52┊ }); 39 | +┊ ┊53┊ 40 | ┊47┊54┊ return done(null, user); 41 | ┊48┊55┊ } 42 | ┊49┊56┊ return done(null, false); 43 | ``` 44 | ```diff 45 | @@ -102,9 +109,30 @@ 46 | ┊102┊109┊ schema, 47 | ┊103┊110┊ context(received: any) { 48 | ┊104┊111┊ return { 49 | -┊105┊ ┊ currentUser: received.req!['user'], 50 | +┊ ┊112┊ currentUser: received.connection ? received.connection.context.currentUser : received.req!['user'], 51 | ┊106┊113┊ } 52 | ┊107┊114┊ }, 53 | +┊ ┊115┊ subscriptions: { 54 | +┊ ┊116┊ onConnect: (connectionParams: any, webSocket: any) => { 55 | +┊ ┊117┊ if (connectionParams.authToken) { 56 | +┊ ┊118┊ // create a buffer and tell it the data coming in is base64 57 | +┊ ┊119┊ const buf = new Buffer(connectionParams.authToken.split(' ')[1], 'base64'); 58 | +┊ ┊120┊ // read it back out as a string 59 | +┊ ┊121┊ const [username, password]: string[] = buf.toString().split(':'); 60 | +┊ ┊122┊ if (username && password) { 61 | +┊ ┊123┊ const currentUser = users.find(user => user.username == username); 62 | +┊ ┊124┊ 63 | +┊ ┊125┊ if (currentUser && validPassword(password, currentUser.password)) { 64 | +┊ ┊126┊ // Set context for the WebSocket 65 | +┊ ┊127┊ return {currentUser}; 66 | +┊ ┊128┊ } else { 67 | +┊ ┊129┊ throw new Error('Wrong credentials!'); 68 | +┊ ┊130┊ } 69 | +┊ ┊131┊ } 70 | +┊ ┊132┊ } 71 | +┊ ┊133┊ throw new Error('Missing auth token!'); 72 | +┊ ┊134┊ } 73 | +┊ ┊135┊ } 74 | ┊108┊136┊}); 75 | ┊109┊137┊ 76 | ┊110┊138┊apollo.applyMiddleware({ 77 | ``` 78 | ```diff 79 | @@ -112,4 +140,11 @@ 80 | ┊112┊140┊ path: '/graphql' 81 | ┊113┊141┊}); 82 | ┊114┊142┊ 83 | -┊115┊ ┊app.listen(PORT); 84 | +┊ ┊143┊// Wrap the Express server 85 | +┊ ┊144┊const ws = createServer(app); 86 | +┊ ┊145┊ 87 | +┊ ┊146┊apollo.installSubscriptionHandlers(ws); 88 | +┊ ┊147┊ 89 | +┊ ┊148┊ws.listen(PORT, () => { 90 | +┊ ┊149┊ console.log(`Apollo Server is now running on http://localhost:${PORT}`); 91 | +┊ ┊150┊}); 92 | ``` 93 | 94 | [}]: # 95 | 96 | We will use the `PubSub` implementation from `apollo-server-express`, and publish the data using with Apollo Server. 97 | 98 | The process of setting up a GraphQL subscriptions server consist of the following steps: 99 | 100 | 1. Declaring subscriptions in the GraphQL schema 101 | 2. Setup a PubSub instance that our server will publish new events to 102 | 3. Hook together `PubSub` event and GraphQL subscription. 103 | 4. Setting up `ApolloServer` 104 | 105 | [{]: (diffStep "6.1" files="schema/typeDefs.ts" module="server") 106 | 107 | #### [Step 6.1: Subscriptions](https://github.com/Urigo/WhatsApp-Clone-Server/commit/e40c1e7) 108 | 109 | ##### Changed schema/typeDefs.ts 110 | ```diff 111 | @@ -8,6 +8,14 @@ 112 | ┊ 8┊ 8┊ chat(chatId: ID!): Chat 113 | ┊ 9┊ 9┊ } 114 | ┊10┊10┊ 115 | +┊ ┊11┊ type Subscription { 116 | +┊ ┊12┊ messageAdded(chatId: ID): Message 117 | +┊ ┊13┊ chatAdded: Chat 118 | +┊ ┊14┊ chatUpdated: Chat 119 | +┊ ┊15┊ userUpdated: User 120 | +┊ ┊16┊ userAdded: User 121 | +┊ ┊17┊ } 122 | +┊ ┊18┊ 123 | ┊11┊19┊ enum MessageType { 124 | ┊12┊20┊ LOCATION 125 | ┊13┊21┊ TEXT 126 | ``` 127 | 128 | [}]: # 129 | 130 | We created two subscriptions: one to notify for new chats and one to notify for new messages. 131 | 132 | [{]: (diffStep "6.1" files="schema/resolvers.ts" module="server") 133 | 134 | #### [Step 6.1: Subscriptions](https://github.com/Urigo/WhatsApp-Clone-Server/commit/e40c1e7) 135 | 136 | ##### Changed schema/resolvers.ts 137 | ```diff 138 | @@ -1,11 +1,14 @@ 139 | -┊ 1┊ ┊import { IResolvers } from "../types"; 140 | +┊ ┊ 1┊import { PubSub, withFilter } from 'apollo-server-express'; 141 | ┊ 2┊ 2┊import { GraphQLDateTime } from 'graphql-iso-date'; 142 | -┊ 3┊ ┊import { ChatDb, db, MessageDb, MessageType, RecipientDb } from "../db"; 143 | +┊ ┊ 3┊import { ChatDb, db, MessageDb, MessageType, RecipientDb, UserDb } from "../db"; 144 | +┊ ┊ 4┊import { IResolvers, MessageAddedSubscriptionArgs } from "../types"; 145 | ┊ 4┊ 5┊import moment from "moment"; 146 | ┊ 5┊ 6┊ 147 | ┊ 6┊ 7┊let users = db.users; 148 | ┊ 7┊ 8┊let chats = db.chats; 149 | ┊ 8┊ 9┊ 150 | +┊ ┊10┊export const pubsub = new PubSub(); 151 | +┊ ┊11┊ 152 | ┊ 9┊12┊export const resolvers: IResolvers = { 153 | ┊10┊13┊ Date: GraphQLDateTime, 154 | ┊11┊14┊ Query: { 155 | ``` 156 | ```diff 157 | @@ -19,6 +22,20 @@ 158 | ┊19┊22┊ currentUser.name = name || currentUser.name; 159 | ┊20┊23┊ currentUser.picture = picture || currentUser.picture; 160 | ┊21┊24┊ 161 | +┊ ┊25┊ pubsub.publish('userUpdated', { 162 | +┊ ┊26┊ userUpdated: currentUser, 163 | +┊ ┊27┊ }); 164 | +┊ ┊28┊ 165 | +┊ ┊29┊ // Get a list of the chats who have/had currentUser involved 166 | +┊ ┊30┊ const chatsAffected = chats.filter(chat => !chat.name && chat.allTimeMemberIds.includes(currentUser.id)); 167 | +┊ ┊31┊ 168 | +┊ ┊32┊ chatsAffected.forEach(chat => { 169 | +┊ ┊33┊ pubsub.publish('chatUpdated', { 170 | +┊ ┊34┊ updaterId: currentUser.id, 171 | +┊ ┊35┊ chatUpdated: chat, 172 | +┊ ┊36┊ }) 173 | +┊ ┊37┊ }); 174 | +┊ ┊38┊ 175 | ┊22┊39┊ return currentUser; 176 | ┊23┊40┊ }, 177 | ┊24┊41┊ addChat: (obj, {userId}, {currentUser}) => { 178 | ``` 179 | ```diff 180 | @@ -79,6 +96,12 @@ 181 | ┊ 79┊ 96┊ messages: [], 182 | ┊ 80┊ 97┊ }; 183 | ┊ 81┊ 98┊ chats.push(chat); 184 | +┊ ┊ 99┊ 185 | +┊ ┊100┊ pubsub.publish('chatAdded', { 186 | +┊ ┊101┊ creatorId: currentUser.id, 187 | +┊ ┊102┊ chatAdded: chat, 188 | +┊ ┊103┊ }); 189 | +┊ ┊104┊ 190 | ┊ 82┊105┊ return chat; 191 | ┊ 83┊106┊ }, 192 | ┊ 84┊107┊ updateGroup: (obj, {chatId, groupName, groupPicture}, {currentUser}) => { 193 | ``` 194 | ```diff 195 | @@ -95,6 +118,11 @@ 196 | ┊ 95┊118┊ chat.name = groupName || chat.name; 197 | ┊ 96┊119┊ chat.picture = groupPicture || chat.picture; 198 | ┊ 97┊120┊ 199 | +┊ ┊121┊ pubsub.publish('chatUpdated', { 200 | +┊ ┊122┊ updaterId: currentUser.id, 201 | +┊ ┊123┊ chatUpdated: chat, 202 | +┊ ┊124┊ }); 203 | +┊ ┊125┊ 204 | ┊ 98┊126┊ return chat; 205 | ┊ 99┊127┊ }, 206 | ┊100┊128┊ removeChat: (obj, {chatId}, {currentUser}) => { 207 | ``` 208 | ```diff 209 | @@ -221,6 +249,11 @@ 210 | ┊221┊249┊ chat.listingMemberIds = chat.listingMemberIds.concat(receiverId); 211 | ┊222┊250┊ 212 | ┊223┊251┊ holderIds = chat.listingMemberIds; 213 | +┊ ┊252┊ 214 | +┊ ┊253┊ pubsub.publish('chatAdded', { 215 | +┊ ┊254┊ creatorId: currentUser.id, 216 | +┊ ┊255┊ chatAdded: chat, 217 | +┊ ┊256┊ }); 218 | ┊224┊257┊ } 219 | ┊225┊258┊ } else { 220 | ┊226┊259┊ // Group 221 | ``` 222 | ```diff 223 | @@ -265,6 +298,10 @@ 224 | ┊265┊298┊ return chat; 225 | ┊266┊299┊ }); 226 | ┊267┊300┊ 227 | +┊ ┊301┊ pubsub.publish('messageAdded', { 228 | +┊ ┊302┊ messageAdded: message, 229 | +┊ ┊303┊ }); 230 | +┊ ┊304┊ 231 | ┊268┊305┊ return message; 232 | ┊269┊306┊ }, 233 | ┊270┊307┊ removeMessages: (obj, {chatId, messageIds, all}, {currentUser}) => { 234 | ``` 235 | ```diff 236 | @@ -306,6 +343,39 @@ 237 | ┊306┊343┊ return deletedIds; 238 | ┊307┊344┊ }, 239 | ┊308┊345┊ }, 240 | +┊ ┊346┊ Subscription: { 241 | +┊ ┊347┊ messageAdded: { 242 | +┊ ┊348┊ subscribe: withFilter(() => pubsub.asyncIterator('messageAdded'), 243 | +┊ ┊349┊ ({messageAdded}: {messageAdded: MessageDb & {chat: {id: number}}}, {chatId}: MessageAddedSubscriptionArgs, {currentUser}: { currentUser: UserDb }) => { 244 | +┊ ┊350┊ return (!chatId || messageAdded.chat.id === Number(chatId)) && 245 | +┊ ┊351┊ messageAdded.recipients.some(recipient => recipient.userId === currentUser.id); 246 | +┊ ┊352┊ }), 247 | +┊ ┊353┊ }, 248 | +┊ ┊354┊ chatAdded: { 249 | +┊ ┊355┊ subscribe: withFilter(() => pubsub.asyncIterator('chatAdded'), 250 | +┊ ┊356┊ ({creatorId, chatAdded}: {creatorId: number, chatAdded: ChatDb}, variables: any, {currentUser}: { currentUser: UserDb }) => { 251 | +┊ ┊357┊ return creatorId !== currentUser.id && chatAdded.listingMemberIds.includes(currentUser.id); 252 | +┊ ┊358┊ }), 253 | +┊ ┊359┊ }, 254 | +┊ ┊360┊ chatUpdated: { 255 | +┊ ┊361┊ subscribe: withFilter(() => pubsub.asyncIterator('chatUpdated'), 256 | +┊ ┊362┊ ({updaterId, chatUpdated}: {updaterId: number, chatUpdated: ChatDb}, variables: any, {currentUser}: { currentUser: UserDb }) => { 257 | +┊ ┊363┊ return updaterId !== currentUser.id && chatUpdated.listingMemberIds.includes(currentUser.id); 258 | +┊ ┊364┊ }), 259 | +┊ ┊365┊ }, 260 | +┊ ┊366┊ userUpdated: { 261 | +┊ ┊367┊ subscribe: withFilter(() => pubsub.asyncIterator('userUpdated'), 262 | +┊ ┊368┊ ({userUpdated}: {userUpdated: UserDb}, variables: any, {currentUser}: { currentUser: UserDb }) => { 263 | +┊ ┊369┊ return userUpdated.id !== currentUser.id; 264 | +┊ ┊370┊ }), 265 | +┊ ┊371┊ }, 266 | +┊ ┊372┊ userAdded: { 267 | +┊ ┊373┊ subscribe: withFilter(() => pubsub.asyncIterator('userAdded'), 268 | +┊ ┊374┊ ({userAdded}: {userAdded: UserDb}, variables: any, {currentUser}: { currentUser: UserDb }) => { 269 | +┊ ┊375┊ return userAdded.id !== currentUser.id; 270 | +┊ ┊376┊ }), 271 | +┊ ┊377┊ }, 272 | +┊ ┊378┊ }, 273 | ┊309┊379┊ Chat: { 274 | ┊310┊380┊ name: (chat, args, {currentUser}) => chat.name ? chat.name : users 275 | ┊311┊381┊ .find(user => user.id === chat.allTimeMemberIds.find(userId => userId !== currentUser.id))!.name, 276 | ``` 277 | 278 | [}]: # 279 | 280 | We will publish a message to the `messageAdded` subscription every time that a user sends a message, then we will filter them according to the current user (we don't want to send someone else's messages). 281 | The `chatAdded` subscription is similar: we will publish each time that a group gets created, but not when chats get created. This is because when a user creates a chat the chat doesn't appear to the other peer until he writes the first message. That's why we also publish when new messages get added (we first look if the other peer already gets the chat listed). 282 | 283 | ## Client 284 | 285 | In order to use WebSockets we will need to install a couple of dependencies: 286 | 287 | yarn add apollo-link-ws apollo-utilities subscriptions-transport-ws 288 | 289 | First let's create the queries for the GraphQL Subscriptions: 290 | 291 | [{]: (diffStep "11.1" files="src/graphql" module="client") 292 | 293 | #### [Step 11.1: Subscriptions](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/fc07f78) 294 | 295 | ##### Changed src/graphql.ts 296 | ```diff 297 | @@ -69,6 +69,24 @@ 298 | ┊69┊69┊ export type AddMessage = Message.Fragment; 299 | ┊70┊70┊} 300 | ┊71┊71┊ 301 | +┊ ┊72┊export namespace ChatAdded { 302 | +┊ ┊73┊ export type Variables = {}; 303 | +┊ ┊74┊ 304 | +┊ ┊75┊ export type Subscription = { 305 | +┊ ┊76┊ __typename?: "Subscription"; 306 | +┊ ┊77┊ 307 | +┊ ┊78┊ chatAdded: Maybe; 308 | +┊ ┊79┊ }; 309 | +┊ ┊80┊ 310 | +┊ ┊81┊ export type ChatAdded = { 311 | +┊ ┊82┊ __typename?: "Chat"; 312 | +┊ ┊83┊ 313 | +┊ ┊84┊ messages: (Maybe)[]; 314 | +┊ ┊85┊ } & ChatWithoutMessages.Fragment; 315 | +┊ ┊86┊ 316 | +┊ ┊87┊ export type Messages = Message.Fragment; 317 | +┊ ┊88┊} 318 | +┊ ┊89┊ 319 | ┊72┊90┊export namespace GetChat { 320 | ┊73┊91┊ export type Variables = { 321 | ┊74┊92┊ chatId: string; 322 | ``` 323 | ```diff 324 | @@ -129,6 +147,28 @@ 325 | ┊129┊147┊ }; 326 | ┊130┊148┊} 327 | ┊131┊149┊ 328 | +┊ ┊150┊export namespace MessageAdded { 329 | +┊ ┊151┊ export type Variables = {}; 330 | +┊ ┊152┊ 331 | +┊ ┊153┊ export type Subscription = { 332 | +┊ ┊154┊ __typename?: "Subscription"; 333 | +┊ ┊155┊ 334 | +┊ ┊156┊ messageAdded: Maybe; 335 | +┊ ┊157┊ }; 336 | +┊ ┊158┊ 337 | +┊ ┊159┊ export type MessageAdded = { 338 | +┊ ┊160┊ __typename?: "Message"; 339 | +┊ ┊161┊ 340 | +┊ ┊162┊ chat: Chat; 341 | +┊ ┊163┊ } & Message.Fragment; 342 | +┊ ┊164┊ 343 | +┊ ┊165┊ export type Chat = { 344 | +┊ ┊166┊ __typename?: "Chat"; 345 | +┊ ┊167┊ 346 | +┊ ┊168┊ id: string; 347 | +┊ ┊169┊ }; 348 | +┊ ┊170┊} 349 | +┊ ┊171┊ 350 | ┊132┊172┊export namespace RemoveAllMessages { 351 | ┊133┊173┊ export type Variables = { 352 | ┊134┊174┊ chatId: string; 353 | ``` 354 | ```diff 355 | @@ -390,6 +430,18 @@ 356 | ┊390┊430┊ markAsRead?: Maybe; 357 | ┊391┊431┊} 358 | ┊392┊432┊ 359 | +┊ ┊433┊export interface Subscription { 360 | +┊ ┊434┊ messageAdded?: Maybe; 361 | +┊ ┊435┊ 362 | +┊ ┊436┊ chatAdded?: Maybe; 363 | +┊ ┊437┊ 364 | +┊ ┊438┊ chatUpdated?: Maybe; 365 | +┊ ┊439┊ 366 | +┊ ┊440┊ userUpdated?: Maybe; 367 | +┊ ┊441┊ 368 | +┊ ┊442┊ userAdded?: Maybe; 369 | +┊ ┊443┊} 370 | +┊ ┊444┊ 371 | ┊393┊445┊// ==================================================== 372 | ┊394┊446┊// Arguments 373 | ┊395┊447┊// ==================================================== 374 | ``` 375 | ```diff 376 | @@ -469,6 +521,9 @@ 377 | ┊469┊521┊export interface MarkAsReadMutationArgs { 378 | ┊470┊522┊ chatId: string; 379 | ┊471┊523┊} 380 | +┊ ┊524┊export interface MessageAddedSubscriptionArgs { 381 | +┊ ┊525┊ chatId?: Maybe; 382 | +┊ ┊526┊} 383 | ┊472┊527┊ 384 | ┊473┊528┊// ==================================================== 385 | ┊474┊529┊// START: Apollo Angular template 386 | ``` 387 | ```diff 388 | @@ -595,6 +650,27 @@ 389 | ┊595┊650┊@Injectable({ 390 | ┊596┊651┊ providedIn: "root" 391 | ┊597┊652┊}) 392 | +┊ ┊653┊export class ChatAddedGQL extends Apollo.Subscription< 393 | +┊ ┊654┊ ChatAdded.Subscription, 394 | +┊ ┊655┊ ChatAdded.Variables 395 | +┊ ┊656┊> { 396 | +┊ ┊657┊ document: any = gql` 397 | +┊ ┊658┊ subscription chatAdded { 398 | +┊ ┊659┊ chatAdded { 399 | +┊ ┊660┊ ...ChatWithoutMessages 400 | +┊ ┊661┊ messages { 401 | +┊ ┊662┊ ...Message 402 | +┊ ┊663┊ } 403 | +┊ ┊664┊ } 404 | +┊ ┊665┊ } 405 | +┊ ┊666┊ 406 | +┊ ┊667┊ ${ChatWithoutMessagesFragment} 407 | +┊ ┊668┊ ${MessageFragment} 408 | +┊ ┊669┊ `; 409 | +┊ ┊670┊} 410 | +┊ ┊671┊@Injectable({ 411 | +┊ ┊672┊ providedIn: "root" 412 | +┊ ┊673┊}) 413 | ┊598┊674┊export class GetChatGQL extends Apollo.Query { 414 | ┊599┊675┊ document: any = gql` 415 | ┊600┊676┊ query GetChat($chatId: ID!) { 416 | ``` 417 | ```diff 418 | @@ -651,6 +727,26 @@ 419 | ┊651┊727┊@Injectable({ 420 | ┊652┊728┊ providedIn: "root" 421 | ┊653┊729┊}) 422 | +┊ ┊730┊export class MessageAddedGQL extends Apollo.Subscription< 423 | +┊ ┊731┊ MessageAdded.Subscription, 424 | +┊ ┊732┊ MessageAdded.Variables 425 | +┊ ┊733┊> { 426 | +┊ ┊734┊ document: any = gql` 427 | +┊ ┊735┊ subscription messageAdded { 428 | +┊ ┊736┊ messageAdded { 429 | +┊ ┊737┊ ...Message 430 | +┊ ┊738┊ chat { 431 | +┊ ┊739┊ id 432 | +┊ ┊740┊ } 433 | +┊ ┊741┊ } 434 | +┊ ┊742┊ } 435 | +┊ ┊743┊ 436 | +┊ ┊744┊ ${MessageFragment} 437 | +┊ ┊745┊ `; 438 | +┊ ┊746┊} 439 | +┊ ┊747┊@Injectable({ 440 | +┊ ┊748┊ providedIn: "root" 441 | +┊ ┊749┊}) 442 | ┊654┊750┊export class RemoveAllMessagesGQL extends Apollo.Mutation< 443 | ┊655┊751┊ RemoveAllMessages.Mutation, 444 | ┊656┊752┊ RemoveAllMessages.Variables 445 | ``` 446 | 447 | ##### Added src/graphql/chatAdded.subscription.ts 448 | ```diff 449 | @@ -0,0 +1,17 @@ 450 | +┊ ┊ 1┊import gql from 'graphql-tag'; 451 | +┊ ┊ 2┊import {fragments} from './fragment'; 452 | +┊ ┊ 3┊ 453 | +┊ ┊ 4┊// We use the gql tag to parse our query string into a query document 454 | +┊ ┊ 5┊export const chatAddedSubscription = gql` 455 | +┊ ┊ 6┊ subscription chatAdded { 456 | +┊ ┊ 7┊ chatAdded { 457 | +┊ ┊ 8┊ ...ChatWithoutMessages 458 | +┊ ┊ 9┊ messages { 459 | +┊ ┊10┊ ...Message 460 | +┊ ┊11┊ } 461 | +┊ ┊12┊ } 462 | +┊ ┊13┊ } 463 | +┊ ┊14┊ 464 | +┊ ┊15┊ ${fragments['chatWithoutMessages']} 465 | +┊ ┊16┊ ${fragments['message']} 466 | +┊ ┊17┊`; 467 | ``` 468 | 469 | ##### Added src/graphql/messageAdded.subscription.ts 470 | ```diff 471 | @@ -0,0 +1,16 @@ 472 | +┊ ┊ 1┊import gql from 'graphql-tag'; 473 | +┊ ┊ 2┊import {fragments} from './fragment'; 474 | +┊ ┊ 3┊ 475 | +┊ ┊ 4┊// We use the gql tag to parse our query string into a query document 476 | +┊ ┊ 5┊export const messageAddedSubscription = gql` 477 | +┊ ┊ 6┊ subscription messageAdded { 478 | +┊ ┊ 7┊ messageAdded { 479 | +┊ ┊ 8┊ ...Message 480 | +┊ ┊ 9┊ chat { 481 | +┊ ┊10┊ id, 482 | +┊ ┊11┊ }, 483 | +┊ ┊12┊ } 484 | +┊ ┊13┊ } 485 | +┊ ┊14┊ 486 | +┊ ┊15┊ ${fragments['message']} 487 | +┊ ┊16┊`; 488 | ``` 489 | 490 | [}]: # 491 | 492 | Then we need to run `graphql-code-generator` to generate the types: 493 | 494 | yarn generator 495 | 496 | Now we can update the chats service to update the getChats query every time that we receive a new chat from the subscription. 497 | With GraphQL subscriptions your client will be alerted on push from the server and you should choose the pattern that fits your application the most: 498 | 499 | - Use it as a notification and run any logic you want when it fires, for example alerting the user or refetching data 500 | - Use the data sent along with the notification and merge it directly into the store (existing queries are automatically notified) 501 | 502 | With subscribeToMore, you can easily do the latter. We will manipulate the store to add the newly created chat. 503 | 504 | We will do to do the same for the newMessage subscription, but this time we will have to update two different queries in the store: getChats and getChat. 505 | 506 | [{]: (diffStep "11.1" files="src/app/services/chats.service.ts" module="client") 507 | 508 | #### [Step 11.1: Subscriptions](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/fc07f78) 509 | 510 | ##### Changed src/app/services/chats.service.ts 511 | ```diff 512 | @@ -1,7 +1,7 @@ 513 | ┊1┊1┊import {concat, map, share, switchMap} from 'rxjs/operators'; 514 | ┊2┊2┊import {Injectable} from '@angular/core'; 515 | ┊3┊3┊import {Observable, AsyncSubject, of} from 'rxjs'; 516 | -┊4┊ ┊import {QueryRef} from 'apollo-angular'; 517 | +┊ ┊4┊import {Apollo, QueryRef} from 'apollo-angular'; 518 | ┊5┊5┊import * as moment from 'moment'; 519 | ┊6┊6┊import { 520 | ┊7┊7┊ GetChatsGQL, 521 | ``` 522 | ```diff 523 | @@ -13,6 +13,8 @@ 524 | ┊13┊13┊ GetUsersGQL, 525 | ┊14┊14┊ AddChatGQL, 526 | ┊15┊15┊ AddGroupGQL, 527 | +┊ ┊16┊ ChatAddedGQL, 528 | +┊ ┊17┊ MessageAddedGQL, 529 | ┊16┊18┊ AddMessage, 530 | ┊17┊19┊ GetChats, 531 | ┊18┊20┊ GetChat, 532 | ``` 533 | ```diff 534 | @@ -21,6 +23,7 @@ 535 | ┊21┊23┊ GetUsers, 536 | ┊22┊24┊ AddChat, 537 | ┊23┊25┊ AddGroup, 538 | +┊ ┊26┊ MessageAdded, 539 | ┊24┊27┊} from '../../graphql'; 540 | ┊25┊28┊import { DataProxy } from 'apollo-cache'; 541 | ┊26┊29┊import { FetchResult } from 'apollo-link'; 542 | ``` 543 | ```diff 544 | @@ -48,11 +51,69 @@ 545 | ┊ 48┊ 51┊ private getUsersGQL: GetUsersGQL, 546 | ┊ 49┊ 52┊ private addChatGQL: AddChatGQL, 547 | ┊ 50┊ 53┊ private addGroupGQL: AddGroupGQL, 548 | +┊ ┊ 54┊ private chatAddedGQL: ChatAddedGQL, 549 | +┊ ┊ 55┊ private messageAddedGQL: MessageAddedGQL, 550 | +┊ ┊ 56┊ private apollo: Apollo, 551 | ┊ 51┊ 57┊ private loginService: LoginService 552 | ┊ 52┊ 58┊ ) { 553 | ┊ 53┊ 59┊ this.getChatsWq = this.getChatsGQL.watch({ 554 | ┊ 54┊ 60┊ amount: this.messagesAmount, 555 | ┊ 55┊ 61┊ }); 556 | +┊ ┊ 62┊ 557 | +┊ ┊ 63┊ this.getChatsWq.subscribeToMore({ 558 | +┊ ┊ 64┊ document: this.chatAddedGQL.document, 559 | +┊ ┊ 65┊ updateQuery: (prev: GetChats.Query, { subscriptionData }) => { 560 | +┊ ┊ 66┊ if (!subscriptionData.data) { 561 | +┊ ┊ 67┊ return prev; 562 | +┊ ┊ 68┊ } 563 | +┊ ┊ 69┊ 564 | +┊ ┊ 70┊ const newChat: GetChats.Chats = (subscriptionData).data.chatAdded; 565 | +┊ ┊ 71┊ 566 | +┊ ┊ 72┊ return Object.assign({}, prev, { 567 | +┊ ┊ 73┊ chats: [...prev.chats, newChat] 568 | +┊ ┊ 74┊ }); 569 | +┊ ┊ 75┊ } 570 | +┊ ┊ 76┊ }); 571 | +┊ ┊ 77┊ 572 | +┊ ┊ 78┊ this.getChatsWq.subscribeToMore({ 573 | +┊ ┊ 79┊ document: this.messageAddedGQL.document, 574 | +┊ ┊ 80┊ updateQuery: (prev: GetChats.Query, { subscriptionData }) => { 575 | +┊ ┊ 81┊ if (!subscriptionData.data) { 576 | +┊ ┊ 82┊ return prev; 577 | +┊ ┊ 83┊ } 578 | +┊ ┊ 84┊ 579 | +┊ ┊ 85┊ const newMessage: MessageAdded.MessageAdded = (subscriptionData).data.messageAdded; 580 | +┊ ┊ 86┊ 581 | +┊ ┊ 87┊ // We need to update the cache for both Chat and Chats. The following updates the cache for Chat. 582 | +┊ ┊ 88┊ try { 583 | +┊ ┊ 89┊ // Read the data from our cache for this query. 584 | +┊ ┊ 90┊ const {chat}: GetChat.Query = this.apollo.getClient().readQuery({ 585 | +┊ ┊ 91┊ query: this.getChatGQL.document, 586 | +┊ ┊ 92┊ variables: { 587 | +┊ ┊ 93┊ chatId: newMessage.chat.id, 588 | +┊ ┊ 94┊ } 589 | +┊ ┊ 95┊ }); 590 | +┊ ┊ 96┊ 591 | +┊ ┊ 97┊ // Add our message from the mutation to the end. 592 | +┊ ┊ 98┊ chat.messages.push(newMessage); 593 | +┊ ┊ 99┊ // Write our data back to the cache. 594 | +┊ ┊100┊ this.apollo.getClient().writeQuery({ 595 | +┊ ┊101┊ query: this.getChatGQL.document, 596 | +┊ ┊102┊ data: { 597 | +┊ ┊103┊ chat 598 | +┊ ┊104┊ } 599 | +┊ ┊105┊ }); 600 | +┊ ┊106┊ } catch { 601 | +┊ ┊107┊ console.error('The chat we received an update for does not exist in the store'); 602 | +┊ ┊108┊ } 603 | +┊ ┊109┊ 604 | +┊ ┊110┊ return Object.assign({}, prev, { 605 | +┊ ┊111┊ chats: [...prev.chats.map(_chat => 606 | +┊ ┊112┊ _chat.id === newMessage.chat.id ? {..._chat, messages: [..._chat.messages, newMessage]} : _chat)] 607 | +┊ ┊113┊ }); 608 | +┊ ┊114┊ } 609 | +┊ ┊115┊ }); 610 | +┊ ┊116┊ 611 | ┊ 56┊117┊ this.chats$ = this.getChatsWq.valueChanges.pipe( 612 | ┊ 57┊118┊ map((result) => result.data.chats) 613 | ┊ 58┊119┊ ); 614 | ``` 615 | 616 | [}]: # 617 | 618 | We can finally configure the WebSocket in the GraphQL Module. Please notice that the WebSocket has its own authentication instead of using the `HttpInterceptor`, in fact we use `connectionParams` to send the authorization. 619 | All queries will go through HTTP except the Subscriptions, which will use the WebSocket. 620 | 621 | [{]: (diffStep "11.1" files="src/app/graphql.module.ts" module="client") 622 | 623 | #### [Step 11.1: Subscriptions](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/fc07f78) 624 | 625 | ##### Changed src/app/graphql.module.ts 626 | ```diff 627 | @@ -2,6 +2,11 @@ 628 | ┊ 2┊ 2┊import { ApolloModule, APOLLO_OPTIONS } from 'apollo-angular'; 629 | ┊ 3┊ 3┊import { HttpLinkModule, HttpLink } from 'apollo-angular-link-http'; 630 | ┊ 4┊ 4┊import { InMemoryCache, defaultDataIdFromObject } from 'apollo-cache-inmemory'; 631 | +┊ ┊ 5┊import {getMainDefinition} from 'apollo-utilities'; 632 | +┊ ┊ 6┊import {OperationDefinitionNode} from 'graphql'; 633 | +┊ ┊ 7┊import {split} from 'apollo-link'; 634 | +┊ ┊ 8┊import {WebSocketLink} from 'apollo-link-ws'; 635 | +┊ ┊ 9┊import {LoginService} from './login/services/login.service'; 636 | ┊ 5┊10┊ 637 | ┊ 6┊11┊const uri = 'http://localhost:4000/graphql'; 638 | ┊ 7┊12┊ 639 | ``` 640 | ```diff 641 | @@ -14,9 +19,28 @@ 642 | ┊14┊19┊ } 643 | ┊15┊20┊}; 644 | ┊16┊21┊ 645 | -┊17┊ ┊export function createApollo(httpLink: HttpLink) { 646 | +┊ ┊22┊export function createApollo(httpLink: HttpLink, loginService: LoginService) { 647 | +┊ ┊23┊ const subscriptionLink = new WebSocketLink({ 648 | +┊ ┊24┊ uri: uri.replace('http', 'ws'), 649 | +┊ ┊25┊ options: { 650 | +┊ ┊26┊ reconnect: true, 651 | +┊ ┊27┊ connectionParams: () => ({ 652 | +┊ ┊28┊ authToken: loginService.getAuthHeader() || null 653 | +┊ ┊29┊ }) 654 | +┊ ┊30┊ } 655 | +┊ ┊31┊ }); 656 | +┊ ┊32┊ 657 | +┊ ┊33┊ const link = split( 658 | +┊ ┊34┊ ({ query }) => { 659 | +┊ ┊35┊ const { kind, operation } = getMainDefinition(query) as OperationDefinitionNode; 660 | +┊ ┊36┊ return kind === 'OperationDefinition' && operation === 'subscription'; 661 | +┊ ┊37┊ }, 662 | +┊ ┊38┊ subscriptionLink, 663 | +┊ ┊39┊ httpLink.create({uri}) 664 | +┊ ┊40┊ ); 665 | +┊ ┊41┊ 666 | ┊18┊42┊ return { 667 | -┊19┊ ┊ link: httpLink.create({ uri }), 668 | +┊ ┊43┊ link, 669 | ┊20┊44┊ cache: new InMemoryCache({ 670 | ┊21┊45┊ dataIdFromObject, 671 | ┊22┊46┊ }), 672 | ``` 673 | ```diff 674 | @@ -29,7 +53,7 @@ 675 | ┊29┊53┊ { 676 | ┊30┊54┊ provide: APOLLO_OPTIONS, 677 | ┊31┊55┊ useFactory: createApollo, 678 | -┊32┊ ┊ deps: [HttpLink], 679 | +┊ ┊56┊ deps: [HttpLink, LoginService], 680 | ┊33┊57┊ }, 681 | ┊34┊58┊ ], 682 | ┊35┊59┊}) 683 | ``` 684 | 685 | [}]: # 686 | 687 | We used `split` of ApolloLink to decide which path should a request get, through WebSocket or HTTP. We decided to push subscriptions over the WebSocket and the rest normally, just like before. 688 | 689 | Finally, let's fix the tests: 690 | 691 | [{]: (diffStep "11.1" files="src/app/chat-viewer/containers/chat/chat.component.spec.ts, src/app/chats-lister/containers/chats/chats.component.spec.ts, src/app/services/chats.service.spec.ts" module="client") 692 | 693 | #### [Step 11.1: Subscriptions](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/fc07f78) 694 | 695 | ##### Changed src/app/chat-viewer/containers/chat/chat.component.spec.ts 696 | ```diff 697 | @@ -148,6 +148,8 @@ 698 | ┊148┊148┊ component = fixture.componentInstance; 699 | ┊149┊149┊ fixture.detectChanges(); 700 | ┊150┊150┊ 701 | +┊ ┊151┊ controller.expectOne('chatAdded', 'call to chatAdded api'); 702 | +┊ ┊152┊ controller.expectOne('messageAdded', 'call to messageAdded api'); 703 | ┊151┊153┊ controller.expectOne('GetChats', 'call to getChats api'); 704 | ┊152┊154┊ 705 | ┊153┊155┊ const req = controller.expectOne('GetChat', 'call to getChat api'); 706 | ``` 707 | 708 | ##### Changed src/app/chats-lister/containers/chats/chats.component.spec.ts 709 | ```diff 710 | @@ -358,8 +358,10 @@ 711 | ┊358┊358┊ component = fixture.componentInstance; 712 | ┊359┊359┊ fixture.detectChanges(); 713 | ┊360┊360┊ 714 | -┊361┊ ┊ const req = controller.expectOne('GetChats', 'GetChats operation'); 715 | +┊ ┊361┊ controller.expectOne('chatAdded', 'call to chatAdded api'); 716 | +┊ ┊362┊ controller.expectOne('messageAdded', 'call to messageAdded api'); 717 | ┊362┊363┊ 718 | +┊ ┊364┊ const req = controller.expectOne('GetChats', 'GetChats operation'); 719 | ┊363┊365┊ req.flush({ 720 | ┊364┊366┊ data: { 721 | ┊365┊367┊ chats, 722 | ``` 723 | 724 | ##### Changed src/app/services/chats.service.spec.ts 725 | ```diff 726 | @@ -11,7 +11,7 @@ 727 | ┊11┊11┊import { GetChats } from '../../graphql'; 728 | ┊12┊12┊import { dataIdFromObject } from '../graphql.module'; 729 | ┊13┊13┊import { ChatsService } from './chats.service'; 730 | -┊14┊ ┊import {LoginService} from '../login/services/login.service'; 731 | +┊ ┊14┊import { LoginService } from '../login/services/login.service'; 732 | ┊15┊15┊ 733 | ┊16┊16┊describe('ChatsService', () => { 734 | ┊17┊17┊ let controller: ApolloTestingController; 735 | ``` 736 | ```diff 737 | @@ -341,6 +341,9 @@ 738 | ┊341┊341┊ } 739 | ┊342┊342┊ }); 740 | ┊343┊343┊ 741 | +┊ ┊344┊ controller.expectOne('chatAdded', 'call to chatAdded api'); 742 | +┊ ┊345┊ controller.expectOne('messageAdded', 'call to messageAdded api'); 743 | +┊ ┊346┊ 744 | ┊344┊347┊ const req = controller.expectOne('GetChats', 'GetChats operation'); 745 | ┊345┊348┊ 746 | ┊346┊349┊ req.flush({ 747 | ``` 748 | 749 | [}]: # 750 | 751 | 752 | [//]: # (foot-start) 753 | 754 | [{]: (navStep) 755 | 756 | | [< Previous Step](https://github.com/Urigo/whatsapp-textrepo-angularcli-express/tree/master@2.0.0/.tortilla/manuals/views/step10.md) | [Next Step >](https://github.com/Urigo/whatsapp-textrepo-angularcli-express/tree/master@2.0.0/.tortilla/manuals/views/step12.md) | 757 | |:--------------------------------|--------------------------------:| 758 | 759 | [}]: # 760 | -------------------------------------------------------------------------------- /.tortilla/manuals/views/step2.md: -------------------------------------------------------------------------------- 1 | # Step 2: graphql-code-generator 2 | 3 | [//]: # (head-end) 4 | 5 | 6 | ## Code Generation 7 | 8 | GraphQL entities are defined as static and typed, which means they can be analyzed and use as a base for generating everything. 9 | 10 | There are many tools related to that topic, but we're going to focus on The **GraphQL Coge Generator**. 11 | 12 | The GraphQL Coge Generator can generate any code for any language  —  including type definitions, data models, query builder, resolvers, ORM code, complete full stack platforms. 13 | 14 | The tool is based on the concept of plugins. It has few official plugins to solve most required features. The GraphQL Code Gen allows to create your own custom codegen plugin in 10 minutes, that fit exactly your needs. 15 | 16 | ### Generating types for server-side code 17 | 18 | First, let's install `graphql-code-generator`: 19 | 20 | yarn add -D graphql-code-generator 21 | 22 | GraphQL Code Generator lets you setup everything by simply running the following command: 23 | 24 | yarn gql-gen init 25 | 26 | Question by question, it will guide you through the whole process of setting up a schema, selecting and intalling plugins, picking a destination of a generated file and a lot more. 27 | 28 | What type of application are you building? Angular 29 | Where is your schema? http://localhost:3000/graphql/ 30 | What are your operations and fragments? ./src/graphql/**/*.ts 31 | Pick plugins: common, client, server 32 | Where to write the output? ./src/graphql.ts 33 | Do you want to generate an introspection file? n 34 | What script in package.json should run the codegen? generator 35 | 36 | 37 | First, it will ask you what is the type of application you're going to build, pick "Backend". 38 | When it asks you for the schema, point it to `./schema/typeDefs.ts`. 39 | The output path should be: `./types.d.ts` 40 | 41 | Plugins that you want to have selected are: 42 | 43 | - TypeScript Common 44 | - TypeScript Server 45 | - TypeScript Resolvers 46 | 47 | The goal is to have a config file under `codegen.yml` and an npm script called `generator`. 48 | 49 | > You can read more about GraphQL Code Generator [on its website](https://graphql-code-generator.com/docs/getting-started/). 50 | 51 | [{]: (diffStep "2.1" files="^\(?!yarn.lock$\).*" module="server") 52 | 53 | #### [Step 2.1: Install graphql-code-generator](https://github.com/Urigo/WhatsApp-Clone-Server/commit/c8572fa) 54 | 55 | ##### Added codegen.yml 56 | ```diff 57 | @@ -0,0 +1,18 @@ 58 | +┊ ┊ 1┊overwrite: true 59 | +┊ ┊ 2┊schema: "./schema/typeDefs.ts" 60 | +┊ ┊ 3┊documents: null 61 | +┊ ┊ 4┊require: 62 | +┊ ┊ 5┊ - ts-node/register 63 | +┊ ┊ 6┊generates: 64 | +┊ ┊ 7┊ ./types.d.ts: 65 | +┊ ┊ 8┊ config: 66 | +┊ ┊ 9┊ optionalType: undefined | null 67 | +┊ ┊10┊ mappers: 68 | +┊ ┊11┊ Chat: ./db#ChatDb 69 | +┊ ┊12┊ Message: ./db#MessageDb 70 | +┊ ┊13┊ Recipient: ./db#RecipientDb 71 | +┊ ┊14┊ User: ./db#UserDb 72 | +┊ ┊15┊ plugins: 73 | +┊ ┊16┊ - "typescript-common" 74 | +┊ ┊17┊ - "typescript-server" 75 | +┊ ┊18┊ - "typescript-resolvers" 76 | ``` 77 | 78 | ##### Changed package.json 79 | ```diff 80 | @@ -8,8 +8,12 @@ 81 | ┊ 8┊ 8┊ }, 82 | ┊ 9┊ 9┊ "scripts": { 83 | ┊10┊10┊ "build": "tsc", 84 | -┊11┊ ┊ "start": "ts-node index.ts", 85 | -┊12┊ ┊ "dev": "nodemon --exec yarn start:server -e ts" 86 | +┊ ┊11┊ "generate": "gql-gen", 87 | +┊ ┊12┊ "generate:watch": "nodemon --exec yarn generate -e graphql", 88 | +┊ ┊13┊ "start:server": "ts-node index.ts", 89 | +┊ ┊14┊ "start:server:watch": "nodemon --exec yarn start:server -e ts", 90 | +┊ ┊15┊ "dev": "concurrently \"yarn generate:watch\" \"yarn start:server:watch\"", 91 | +┊ ┊16┊ "start": "yarn generate && yarn start:server" 92 | ┊13┊17┊ }, 93 | ┊14┊18┊ "devDependencies": { 94 | ┊15┊19┊ "@types/body-parser": "^1.17.0", 95 | ``` 96 | ```diff 97 | @@ -18,6 +22,10 @@ 98 | ┊18┊22┊ "@types/graphql": "^14.0.7", 99 | ┊19┊23┊ "@types/graphql-iso-date": "^3.3.1", 100 | ┊20┊24┊ "@types/node": "^11.9.5", 101 | +┊ ┊25┊ "concurrently": "^4.1.0", 102 | +┊ ┊26┊ "graphql-codegen-typescript-common": "^0.17.0", 103 | +┊ ┊27┊ "graphql-codegen-typescript-resolvers": "^0.17.0", 104 | +┊ ┊28┊ "graphql-codegen-typescript-server": "^0.17.0", 105 | ┊21┊29┊ "nodemon": "^1.18.10", 106 | ┊22┊30┊ "ts-node": "^8.0.2", 107 | ┊23┊31┊ "typescript": "^3.3.3333" 108 | ``` 109 | ```diff 110 | @@ -28,6 +36,7 @@ 111 | ┊28┊36┊ "cors": "^2.8.5", 112 | ┊29┊37┊ "express": "^4.16.4", 113 | ┊30┊38┊ "graphql": "^14.1.1", 114 | +┊ ┊39┊ "graphql-code-generator": "^0.17.0", 115 | ┊31┊40┊ "graphql-iso-date": "^3.6.1", 116 | ┊32┊41┊ "moment": "^2.24.0" 117 | ┊33┊42┊ } 118 | ``` 119 | 120 | [}]: # 121 | 122 | Few things here: 123 | 124 | - `require: ts-node/register` - makes the ts-node compile TypeScript files 125 | - `schema` - points to a file that exports the GraphQL Schema object or a string (it also accepts an url) 126 | - `generates` - is an object where key is the filepath of an output 127 | - `generates.plugins` - tells about the plugins we want to use 128 | 129 | Let's modify the `codegen.yml` a bit and tell GraphQL Code Generator that `ID` scalar matches primitive `number` type in TypeScript. 130 | 131 | We're also going to use Mappers feature. 132 | 133 | ```yaml 134 | mappers: 135 | Chat: ./db#Chat 136 | Message: ./db#Message 137 | Recipient: ./db#Recipient 138 | ``` 139 | 140 | What it means is that every resolver that is expected to resolve Chat, Message or Recipient type of our GraphQL Schema will use an according interface from `./db` module. Why this is helpful? What if an object returned by a parent resolver has `_id` property instead of `id`, it doesn't match a GraphQL Type then. That's why we implemented mappers. In our case, everything should match by this allow us to make sure it really does. 141 | 142 | The idea behind Mappers is to map an interface to a GraphQL Type so you overwrite that default logic. 143 | 144 | > Read more about [Mappers feature](https://graphql-code-generator.com/docs/plugins/typescript-resolvers#mappers-overwrite-parents-and-resolved-values) 145 | 146 | Now let's run the generator: 147 | 148 | yarn generator 149 | 150 | Please note that the server doesn't have to be running in background because we import schema through a file. 151 | 152 | Next, let's use the generated types: 153 | 154 | [{]: (diffStep "2.3" module="server") 155 | 156 | #### [Step 2.3: Use our types](https://github.com/Urigo/WhatsApp-Clone-Server/commit/295d98b) 157 | 158 | ##### Changed schema/index.ts 159 | ```diff 160 | @@ -4,5 +4,5 @@ 161 | ┊4┊4┊ 162 | ┊5┊5┊export const schema = makeExecutableSchema({ 163 | ┊6┊6┊ typeDefs, 164 | -┊7┊ ┊ resolvers, 165 | +┊ ┊7┊ resolvers: resolvers as any, 166 | ┊8┊8┊}); 167 | ``` 168 | 169 | ##### Changed schema/resolvers.ts 170 | ```diff 171 | @@ -1,6 +1,6 @@ 172 | -┊1┊ ┊import { IResolvers } from 'apollo-server-express'; 173 | +┊ ┊1┊import { db } from "../db"; 174 | ┊2┊2┊import { GraphQLDateTime } from 'graphql-iso-date'; 175 | -┊3┊ ┊import { ChatDb, db, MessageDb, RecipientDb, UserDb } from "../db"; 176 | +┊ ┊3┊import { IResolvers } from '../types'; 177 | ┊4┊4┊ 178 | ┊5┊5┊let users = db.users; 179 | ┊6┊6┊let chats = db.chats; 180 | ``` 181 | ```diff 182 | @@ -9,57 +9,57 @@ 183 | ┊ 9┊ 9┊export const resolvers: IResolvers = { 184 | ┊10┊10┊ Date: GraphQLDateTime, 185 | ┊11┊11┊ Query: { 186 | -┊12┊ ┊ me: (): UserDb => currentUser, 187 | -┊13┊ ┊ users: (): UserDb[] => users.filter(user => user.id !== currentUser.id), 188 | -┊14┊ ┊ chats: (): ChatDb[] => chats.filter(chat => chat.listingMemberIds.includes(currentUser.id)), 189 | -┊15┊ ┊ chat: (obj: any, {chatId}): ChatDb | null => chats.find(chat => chat.id === chatId) || null, 190 | +┊ ┊12┊ me: () => currentUser, 191 | +┊ ┊13┊ users: () => users.filter(user => user.id !== currentUser.id), 192 | +┊ ┊14┊ chats: () => chats.filter(chat => chat.listingMemberIds.includes(currentUser.id)), 193 | +┊ ┊15┊ chat: (obj, {chatId}) => chats.find(chat => chat.id === Number(chatId)), 194 | ┊16┊16┊ }, 195 | ┊17┊17┊ Chat: { 196 | -┊18┊ ┊ name: (chat: ChatDb): string => chat.name ? chat.name : users 197 | +┊ ┊18┊ name: (chat) => chat.name ? chat.name : users 198 | ┊19┊19┊ .find(user => user.id === chat.allTimeMemberIds.find(userId => userId !== currentUser.id))!.name, 199 | -┊20┊ ┊ picture: (chat: ChatDb) => chat.name ? chat.picture : users 200 | +┊ ┊20┊ picture: (chat) => chat.name ? chat.picture : users 201 | ┊21┊21┊ .find(user => user.id === chat.allTimeMemberIds.find(userId => userId !== currentUser.id))!.picture, 202 | -┊22┊ ┊ allTimeMembers: (chat: ChatDb): UserDb[] => users.filter(user => chat.allTimeMemberIds.includes(user.id)), 203 | -┊23┊ ┊ listingMembers: (chat: ChatDb): UserDb[] => users.filter(user => chat.listingMemberIds.includes(user.id)), 204 | -┊24┊ ┊ actualGroupMembers: (chat: ChatDb): UserDb[] => users.filter(user => chat.actualGroupMemberIds && chat.actualGroupMemberIds.includes(user.id)), 205 | -┊25┊ ┊ admins: (chat: ChatDb): UserDb[] => users.filter(user => chat.adminIds && chat.adminIds.includes(user.id)), 206 | -┊26┊ ┊ owner: (chat: ChatDb): UserDb | null => users.find(user => chat.ownerId === user.id) || null, 207 | -┊27┊ ┊ isGroup: (chat: ChatDb): boolean => !!chat.name, 208 | -┊28┊ ┊ messages: (chat: ChatDb, {amount = 0}: {amount: number}): MessageDb[] => { 209 | +┊ ┊22┊ allTimeMembers: (chat) => users.filter(user => chat.allTimeMemberIds.includes(user.id)), 210 | +┊ ┊23┊ listingMembers: (chat) => users.filter(user => chat.listingMemberIds.includes(user.id)), 211 | +┊ ┊24┊ actualGroupMembers: (chat) => users.filter(user => chat.actualGroupMemberIds && chat.actualGroupMemberIds.includes(user.id)), 212 | +┊ ┊25┊ admins: (chat) => users.filter(user => chat.adminIds && chat.adminIds.includes(user.id)), 213 | +┊ ┊26┊ owner: (chat) => users.find(user => chat.ownerId === user.id) || null, 214 | +┊ ┊27┊ isGroup: (chat) => !!chat.name, 215 | +┊ ┊28┊ messages: (chat, {amount = 0}) => { 216 | ┊29┊29┊ const messages = chat.messages 217 | ┊30┊30┊ .filter(message => message.holderIds.includes(currentUser.id)) 218 | -┊31┊ ┊ .sort((a, b) => b.createdAt.valueOf() - a.createdAt.valueOf()) || []; 219 | +┊ ┊31┊ .sort((a, b) => b.createdAt.valueOf() - a.createdAt.valueOf()) || []; 220 | ┊32┊32┊ return (amount ? messages.slice(0, amount) : messages).reverse(); 221 | ┊33┊33┊ }, 222 | -┊34┊ ┊ lastMessage: (chat: ChatDb): MessageDb => { 223 | +┊ ┊34┊ lastMessage: (chat) => { 224 | ┊35┊35┊ return chat.messages 225 | ┊36┊36┊ .filter(message => message.holderIds.includes(currentUser.id)) 226 | ┊37┊37┊ .sort((a, b) => b.createdAt.valueOf() - a.createdAt.valueOf())[0] || null; 227 | ┊38┊38┊ }, 228 | -┊39┊ ┊ updatedAt: (chat: ChatDb): Date => { 229 | +┊ ┊39┊ updatedAt: (chat) => { 230 | ┊40┊40┊ const lastMessage = chat.messages 231 | ┊41┊41┊ .filter(message => message.holderIds.includes(currentUser.id)) 232 | ┊42┊42┊ .sort((a, b) => b.createdAt.valueOf() - a.createdAt.valueOf())[0]; 233 | ┊43┊43┊ 234 | ┊44┊44┊ return lastMessage ? lastMessage.createdAt : chat.createdAt; 235 | ┊45┊45┊ }, 236 | -┊46┊ ┊ unreadMessages: (chat: ChatDb): number => chat.messages 237 | +┊ ┊46┊ unreadMessages: (chat) => chat.messages 238 | ┊47┊47┊ .filter(message => message.holderIds.includes(currentUser.id) && 239 | ┊48┊48┊ message.recipients.find(recipient => recipient.userId === currentUser.id && !recipient.readAt)) 240 | ┊49┊49┊ .length, 241 | ┊50┊50┊ }, 242 | ┊51┊51┊ Message: { 243 | -┊52┊ ┊ chat: (message: MessageDb): ChatDb | null => chats.find(chat => message.chatId === chat.id) || null, 244 | -┊53┊ ┊ sender: (message: MessageDb): UserDb | null => users.find(user => user.id === message.senderId) || null, 245 | -┊54┊ ┊ holders: (message: MessageDb): UserDb[] => users.filter(user => message.holderIds.includes(user.id)), 246 | -┊55┊ ┊ ownership: (message: MessageDb): boolean => message.senderId === currentUser.id, 247 | +┊ ┊52┊ chat: (message) => chats.find(chat => message.chatId === chat.id)!, 248 | +┊ ┊53┊ sender: (message) => users.find(user => user.id === message.senderId)!, 249 | +┊ ┊54┊ holders: (message) => users.filter(user => message.holderIds.includes(user.id)), 250 | +┊ ┊55┊ ownership: (message) => message.senderId === currentUser.id, 251 | ┊56┊56┊ }, 252 | ┊57┊57┊ Recipient: { 253 | -┊58┊ ┊ user: (recipient: RecipientDb): UserDb | null => users.find(user => recipient.userId === user.id) || null, 254 | -┊59┊ ┊ message: (recipient: RecipientDb): MessageDb | null => { 255 | -┊60┊ ┊ const chat = chats.find(chat => recipient.chatId === chat.id); 256 | -┊61┊ ┊ return chat ? chat.messages.find(message => recipient.messageId === message.id) || null : null; 257 | +┊ ┊58┊ user: (recipient) => users.find(user => recipient.userId === user.id)!, 258 | +┊ ┊59┊ message: (recipient) => { 259 | +┊ ┊60┊ const chat = chats.find(chat => recipient.chatId === chat.id)!; 260 | +┊ ┊61┊ return chat.messages.find(message => recipient.messageId === message.id)!; 261 | ┊62┊62┊ }, 262 | -┊63┊ ┊ chat: (recipient: RecipientDb): ChatDb | null => chats.find(chat => recipient.chatId === chat.id) || null, 263 | +┊ ┊63┊ chat: (recipient) => chats.find(chat => recipient.chatId === chat.id)!, 264 | ┊64┊64┊ }, 265 | ┊65┊65┊}; 266 | ``` 267 | 268 | [}]: # 269 | 270 | Don't worry, they will be much more useful when we will write our first mutation. 271 | 272 | ### Generating types for client-side code 273 | 274 | Let's do the same on the client: 275 | 276 | yarn add graphql-code-generator 277 | 278 | and also to prepare everything: 279 | 280 | yarn gql-gen init 281 | 282 | Exactly as with the server, you will need to answer few questions. 283 | 284 | First, it will ask you what is the type of application you're going to build, pick "Vanilla JS application" (there's an Angular option but we will introduce it in the next chapter). 285 | When it asks you for the schema, point it to our GraphQL Server `http://localhost:3000/graphql`. 286 | Documents are available under `./src/graphql/**/*.ts`. 287 | The output path should be: `./src/graphql.ts` 288 | 289 | Plugins that you want to have selected are: 290 | 291 | - TypeScript Common 292 | - TypeScript Client 293 | 294 | The goal again, is to have a config file under `codegen.yml` and an npm script called `generate`. 295 | 296 | Please note that in this case, the server must be started before running the generator. 297 | 298 | [{]: (diffStep "2.1" files="^\(?!yarn.lock$\).*" module="client") 299 | 300 | #### [Step 2.1: Install graphql-code-generator](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/709a215) 301 | 302 | ##### Added codegen.yml 303 | ```diff 304 | @@ -0,0 +1,9 @@ 305 | +┊ ┊1┊overwrite: true 306 | +┊ ┊2┊schema: "http://localhost:4000/graphql" 307 | +┊ ┊3┊documents: "./src/graphql/**/*.ts" 308 | +┊ ┊4┊generates: 309 | +┊ ┊5┊ ./src/graphql.ts: 310 | +┊ ┊6┊ plugins: 311 | +┊ ┊7┊ - "typescript-common" 312 | +┊ ┊8┊ - "typescript-client" 313 | +┊ ┊9┊ - "typescript-server" 314 | ``` 315 | 316 | ##### Changed package.json 317 | ```diff 318 | @@ -7,7 +7,8 @@ 319 | ┊ 7┊ 7┊ "build": "ng build", 320 | ┊ 8┊ 8┊ "test": "ng test", 321 | ┊ 9┊ 9┊ "lint": "ng lint", 322 | -┊10┊ ┊ "e2e": "ng e2e" 323 | +┊ ┊10┊ "e2e": "ng e2e", 324 | +┊ ┊11┊ "generator": "gql-gen --config codegen.yml" 325 | ┊11┊12┊ }, 326 | ┊12┊13┊ "private": true, 327 | ┊13┊14┊ "repository": { 328 | ``` 329 | ```diff 330 | @@ -48,6 +49,10 @@ 331 | ┊48┊49┊ "@types/jasminewd2": "~2.0.3", 332 | ┊49┊50┊ "@types/node": "~8.9.4", 333 | ┊50┊51┊ "codelyzer": "~4.5.0", 334 | +┊ ┊52┊ "graphql-code-generator": "^0.16.1", 335 | +┊ ┊53┊ "graphql-codegen-typescript-client": "^0.16.1", 336 | +┊ ┊54┊ "graphql-codegen-typescript-common": "^0.16.1", 337 | +┊ ┊55┊ "graphql-codegen-typescript-server": "^0.16.1", 338 | ┊51┊56┊ "jasmine-core": "~2.99.1", 339 | ┊52┊57┊ "jasmine-spec-reporter": "~4.2.1", 340 | ┊53┊58┊ "karma": "~3.1.1", 341 | ``` 342 | 343 | [}]: # 344 | 345 | We saw how to use them on the server but let's see how easy it is to take advantage of them in Apollo. 346 | 347 | First thing you should know is most of the methods of Apollo service accepts generic types. 348 | 349 | #### What are Generic Types 350 | 351 | A major part of software engineering is building components that not only have well-defined and consistent APIs, but are also reusable. Components that are capable of working on the data of today as well as the data of tomorrow will give you the most flexible capabilities for building up large software systems. 352 | 353 | We like to work on an code samples so let's do some functional programming and create the `map` method that accepts a transform function and return a new result. 354 | 355 | Without generics, we would have to use a specific type or `any`: 356 | 357 | ```typescript 358 | function map(mapFn: (value: any) => any) { 359 | return function(source: any): any { 360 | return mapFn(source); 361 | }; 362 | } 363 | ``` 364 | 365 | Imagine, we want to use `map` function to pick user's id from an object: 366 | 367 | ```typescript 368 | interface User { 369 | id: number; 370 | } 371 | const me = { 372 | id: 1, 373 | }; 374 | 375 | const pickUserId = map(user => user.id); 376 | 377 | console.log(pickUserId(me)); // outputs: 1 378 | ``` 379 | 380 | Great, it works, we get the expected result but it could be done way better. 381 | 382 | What is the main issue here? 383 | It accepts an object of any type and get `any` in return. It's not very useful because we know it return a `number` and receive a `User` but TypeScript doesn't know that and later on, in other part of the code, we end up with no information about the result. 384 | 385 | That's where Generic Types jumps in! 386 | 387 | With generics, it could look like this: 388 | 389 | ```typescript 390 | function map(mapFn: (value: T) => R) { 391 | return function(source: T): R { 392 | return mapFn(source); 393 | }; 394 | } 395 | ``` 396 | 397 | There's... a lot! So let's break it: 398 | 399 | - `map` - the map function accepts two generic types 400 | - `(mapFn: (value: T) => R)` - the only argument is a function that accepts an object of type `T` and returns value of type `R` 401 | - `function (source: T): R => {...}` - it's a higher order function that accepts a source of type `T` and transforms to be `R`. 402 | 403 | Back to our example, now with generic types: 404 | 405 | ```typescript 406 | interface User { 407 | id: number; 408 | } 409 | const me = { 410 | id: 1, 411 | }; 412 | 413 | const pickUserId = map(user => user.id); 414 | 415 | console.log(pickUserId(me)); // outputs: 1 416 | ``` 417 | 418 | Our `map` and `pickUserId` function are strongly typed now so when you try to provide an object that has no `id` field or the field is a `string` you'll receive a proper information from TypeScript (and your IDE). 419 | 420 | #### Make client-side code strongly typed 421 | 422 | With all that knowledge let's use how to use those generated typed with `Apollo` service: 423 | 424 | [{]: (diffStep "2.3" module="client") 425 | 426 | #### [Step 2.3: Use the generated types](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/89a2c0a) 427 | 428 | ##### Changed src/app/chats-lister/components/chat-item/chat-item.component.ts 429 | ```diff 430 | @@ -1,4 +1,5 @@ 431 | ┊1┊1┊import {Component, Input} from '@angular/core'; 432 | +┊ ┊2┊import {GetChats} from '../../../../graphql'; 433 | ┊2┊3┊ 434 | ┊3┊4┊@Component({ 435 | ┊4┊5┊ selector: 'app-chat-item', 436 | ``` 437 | ```diff 438 | @@ -17,5 +18,5 @@ 439 | ┊17┊18┊export class ChatItemComponent { 440 | ┊18┊19┊ // tslint:disable-next-line:no-input-rename 441 | ┊19┊20┊ @Input('item') 442 | -┊20┊ ┊ chat: any; 443 | +┊ ┊21┊ chat: GetChats.Chats; 444 | ┊21┊22┊} 445 | ``` 446 | 447 | ##### Changed src/app/chats-lister/components/chats-list/chats-list.component.ts 448 | ```diff 449 | @@ -1,4 +1,5 @@ 450 | ┊1┊1┊import {Component, Input} from '@angular/core'; 451 | +┊ ┊2┊import {GetChats} from '../../../../graphql'; 452 | ┊2┊3┊ 453 | ┊3┊4┊@Component({ 454 | ┊4┊5┊ selector: 'app-chats-list', 455 | ``` 456 | ```diff 457 | @@ -14,7 +15,7 @@ 458 | ┊14┊15┊export class ChatsListComponent { 459 | ┊15┊16┊ // tslint:disable-next-line:no-input-rename 460 | ┊16┊17┊ @Input('items') 461 | -┊17┊ ┊ chats: any[]; 462 | +┊ ┊18┊ chats: GetChats.Chats[]; 463 | ┊18┊19┊ 464 | ┊19┊20┊ constructor() {} 465 | ┊20┊21┊} 466 | ``` 467 | 468 | ##### Changed src/app/chats-lister/containers/chats/chats.component.ts 469 | ```diff 470 | @@ -1,6 +1,7 @@ 471 | ┊1┊1┊import {Component, OnInit} from '@angular/core'; 472 | ┊2┊2┊import {ChatsService} from '../../../services/chats.service'; 473 | ┊3┊3┊import {Observable} from 'rxjs'; 474 | +┊ ┊4┊import {GetChats} from '../../../../graphql'; 475 | ┊4┊5┊ 476 | ┊5┊6┊@Component({ 477 | ┊6┊7┊ template: ` 478 | ``` 479 | ```diff 480 | @@ -35,7 +36,7 @@ 481 | ┊35┊36┊ styleUrls: ['./chats.component.scss'], 482 | ┊36┊37┊}) 483 | ┊37┊38┊export class ChatsComponent implements OnInit { 484 | -┊38┊ ┊ chats$: Observable; 485 | +┊ ┊39┊ chats$: Observable; 486 | ┊39┊40┊ 487 | ┊40┊41┊ constructor(private chatsService: ChatsService) { 488 | ┊41┊42┊ } 489 | ``` 490 | 491 | ##### Changed src/app/services/chats.service.ts 492 | ```diff 493 | @@ -2,6 +2,7 @@ 494 | ┊2┊2┊import {Apollo} from 'apollo-angular'; 495 | ┊3┊3┊import {Injectable} from '@angular/core'; 496 | ┊4┊4┊import {getChatsQuery} from '../../graphql/getChats.query'; 497 | +┊ ┊5┊import {GetChats} from '../../graphql'; 498 | ┊5┊6┊ 499 | ┊6┊7┊@Injectable() 500 | ┊7┊8┊export class ChatsService { 501 | ``` 502 | ```diff 503 | @@ -10,7 +11,7 @@ 504 | ┊10┊11┊ constructor(private apollo: Apollo) {} 505 | ┊11┊12┊ 506 | ┊12┊13┊ getChats() { 507 | -┊13┊ ┊ const query = this.apollo.watchQuery({ 508 | +┊ ┊14┊ const query = this.apollo.watchQuery({ 509 | ┊14┊15┊ query: getChatsQuery, 510 | ┊15┊16┊ variables: { 511 | ┊16┊17┊ amount: this.messagesAmount, 512 | ``` 513 | 514 | [}]: # 515 | 516 | As you can probably tell by now, methods like `watchQuery` and `mutate` (and others) accepts two generic types, first one describes the result and the second one the variables. 517 | 518 | ```typescript 519 | export class ChatService { 520 | constructor(private apollo: Apollo) {} 521 | 522 | getChat(chatId: string, amount: number): GetChat.Chat { 523 | return this.apollo 524 | .watchQuery({ 525 | query: getChatQuery, 526 | variables: { 527 | chatId, 528 | amount, 529 | }, 530 | }) 531 | .pipe(map(result => result.data.chat)); 532 | } 533 | } 534 | ``` 535 | 536 | Few things here. 537 | 538 | - An object of `GetChat.Query` shape lives under the `result.data` because `watchQuery` returns an object of type `ApolloQueryResult` where `T` is our `GetChat.Query`. 539 | 540 | ```typescript 541 | export type ApolloQueryResult = { 542 | data: T; 543 | // ... other fields 544 | }; 545 | ``` 546 | 547 | You get the same result with Mutation or Subscription. 548 | 549 | - Thanks to the second argument and `GetChat.Variables` we as well as TypeScript (and an IDE) know that `chatId` is a string and `amount` accepts only a number. Whenever we try to pass a value of different kind an Error will pop out. 550 | 551 | ### Take full advantage of Codegen and generate ready to use Services! 552 | 553 | I've got a fantasctic news, you don't have to manually provide those generated types to each type Apollo service is used. Because GraphQL Documents are statically analyzable, we prepared a codegen template specific for Apollo Angular users. 554 | 555 | First, let's install `graphql-codegen-typescript-apollo-angular` with the Apollo Angular plugin and update the list of plugins in `codegen.yml` 556 | 557 | yarn add -D graphql-codegen-typescript-apollo-angular 558 | 559 | [{]: (diffStep "2.4" files="package.json" module="client") 560 | 561 | #### [Step 2.4: Use auto-generated GQL services](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/4e2d0c7) 562 | 563 | ##### Changed package.json 564 | ```diff 565 | @@ -50,6 +50,7 @@ 566 | ┊50┊50┊ "@types/node": "~8.9.4", 567 | ┊51┊51┊ "codelyzer": "~4.5.0", 568 | ┊52┊52┊ "graphql-code-generator": "^0.16.1", 569 | +┊ ┊53┊ "graphql-codegen-typescript-apollo-angular": "^0.16.1", 570 | ┊53┊54┊ "graphql-codegen-typescript-client": "^0.16.1", 571 | ┊54┊55┊ "graphql-codegen-typescript-common": "^0.16.1", 572 | ┊55┊56┊ "graphql-codegen-typescript-server": "^0.16.1", 573 | ``` 574 | 575 | [}]: # 576 | 577 | Then you need to add `typescript-apollo-angular` next to other plugins in `codegen.yml`. 578 | 579 | The Apollo Angular template generates a ready to use in your component, strongly typed Angular services, for every defined query, mutation or subscription. 580 | 581 | It's possible thanks to the new API of Apollo Angular. More on that in ["Query, Mutation, Subscription services"](http://apollographql.com/docs/angular/basics/services.html?_ga=2.227615197.1327014552.1538988114-793224955.1532981447) chapter of the documentation. 582 | 583 | Given an example: 584 | 585 | ```graphql 586 | query GetChats($amount: Int) { 587 | chats { 588 | ...ChatWithoutMessages 589 | messages(amount: $amount) { 590 | ...Message 591 | } 592 | } 593 | } 594 | ``` 595 | 596 | The Apollo Angular template, after you run `yarn generate`, outputs a service called `GetChatsGQL`: 597 | 598 | ```typescript 599 | import { GetChatsGQL, GetChats } from '../graphql'; 600 | 601 | export class AppComponent { 602 | constructor(private getChatsGQL: GetChatsGQL) {} 603 | 604 | getChats(): GetChats.Chats[] { 605 | return this.getChatsGQL 606 | .watch({ 607 | amount: 10, 608 | }) 609 | .valueChanges.pipe(map(result => result.data.chats)); 610 | } 611 | } 612 | ``` 613 | 614 | > Remember, every operation should has an unique name. 615 | 616 | It might look a bit different then what you have already learnt but we promise, the API is even easier. 617 | 618 | But first, let's dive into how those services look like under the hood. 619 | 620 | ```typescript 621 | import { Query } from 'apollo-angular'; 622 | 623 | @Injectable({ 624 | providedIn: 'root', 625 | }) 626 | export class GetChatsGQL extends Query { 627 | document = gql` 628 | here goes the document 629 | `; 630 | } 631 | ``` 632 | 633 | Okay, seems easy but what is the `Query` class, you might ask! 634 | 635 | Apollo Angular exposes three classes for three kinds of the GraphQL operation: `Query`, `Mutation` and `Subscription`. Each of them has a different API. 636 | 637 | - `Query` has `fetch` and `watch`. First one behave like `Apollo.query()`, second one like `Apollo.watchQuery()` 638 | - `Mutation` has `mutate` 639 | - `Subscription` has `subscribe` 640 | 641 | Because the `document` is already defined in a class, they all accept two arguments (Subscription has third one). First argument defines variables, second shapes the options. Thats why we used `this.getChatsGQL.watch({ amount: 10 })`. 642 | 643 | Let's stop talking about the API itself and see what benefits it brings us: 644 | 645 | - **Less code to write** - no need to create a network call, no need to create Typescript typings, no need to create a dedicated Angular service 646 | - **Strongly typed out of the box — all** types are being generated, no need to write any Typescript definitions and struggle to keep them updated 647 | - _More pleasent API_ to work with 648 | - **Full developer experience of tools and IDEs**  —  development time, autocomplete and error checking, not only across your frontend app but also with your API teams! 649 | - **Tree-shakable** thanks to Angular 6 650 | 651 | Most IDEs with the GraphQL support (built-in or thanks to extensions) fully handles `.graphql` files and helps you with features like auto-completion, validation but they strugle with `gql` tag. To fully enjoy GraphQL we highly recommend to use static `.graphql` files. 652 | 653 | With all that knowledge, let's use GQL services in our application: 654 | 655 | [{]: (diffStep "2.4" files="src/app/services/chats.service.ts" module="client") 656 | 657 | #### [Step 2.4: Use auto-generated GQL services](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/4e2d0c7) 658 | 659 | ##### Changed src/app/services/chats.service.ts 660 | ```diff 661 | @@ -1,21 +1,18 @@ 662 | ┊ 1┊ 1┊import {map} from 'rxjs/operators'; 663 | -┊ 2┊ ┊import {Apollo} from 'apollo-angular'; 664 | ┊ 3┊ 2┊import {Injectable} from '@angular/core'; 665 | -┊ 4┊ ┊import {getChatsQuery} from '../../graphql/getChats.query'; 666 | -┊ 5┊ ┊import {GetChats} from '../../graphql'; 667 | +┊ ┊ 3┊import {GetChatsGQL} from '../../graphql'; 668 | ┊ 6┊ 4┊ 669 | ┊ 7┊ 5┊@Injectable() 670 | ┊ 8┊ 6┊export class ChatsService { 671 | ┊ 9┊ 7┊ messagesAmount = 3; 672 | ┊10┊ 8┊ 673 | -┊11┊ ┊ constructor(private apollo: Apollo) {} 674 | +┊ ┊ 9┊ constructor( 675 | +┊ ┊10┊ private getChatsGQL: GetChatsGQL 676 | +┊ ┊11┊ ) {} 677 | ┊12┊12┊ 678 | ┊13┊13┊ getChats() { 679 | -┊14┊ ┊ const query = this.apollo.watchQuery({ 680 | -┊15┊ ┊ query: getChatsQuery, 681 | -┊16┊ ┊ variables: { 682 | -┊17┊ ┊ amount: this.messagesAmount, 683 | -┊18┊ ┊ }, 684 | +┊ ┊14┊ const query = this.getChatsGQL.watch({ 685 | +┊ ┊15┊ amount: this.messagesAmount, 686 | ┊19┊16┊ }); 687 | ┊20┊17┊ const chats$ = query.valueChanges.pipe( 688 | ┊21┊18┊ map((result) => result.data.chats) 689 | ``` 690 | 691 | [}]: # 692 | 693 | 694 | [//]: # (foot-start) 695 | 696 | [{]: (navStep) 697 | 698 | | [< Previous Step](https://github.com/Urigo/whatsapp-textrepo-angularcli-express/tree/master@2.0.0/.tortilla/manuals/views/step1.md) | [Next Step >](https://github.com/Urigo/whatsapp-textrepo-angularcli-express/tree/master@2.0.0/.tortilla/manuals/views/step3.md) | 699 | |:--------------------------------|--------------------------------:| 700 | 701 | [}]: # 702 | -------------------------------------------------------------------------------- /.tortilla/manuals/views/step4.md: -------------------------------------------------------------------------------- 1 | # Step 4: Chat viewer 2 | 3 | [//]: # (head-end) 4 | 5 | 6 | At this point we have a module which lists all of our chats, but we still need to show a particular chat. 7 | We're going to implement in in this chapter. 8 | 9 | ### ChatViewer module 10 | 11 | First, let's create a `ChatViewer` module: 12 | 13 | [{]: (diffStep "4.1" files="src/app/app.module.ts, src/app/chat-viewer/chat-viewer.module.ts" module="client") 14 | 15 | #### [Step 4.1: Chat Viewer](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/6bb097d) 16 | 17 | ##### Changed src/app/app.module.ts 18 | ```diff 19 | @@ -6,6 +6,7 @@ 20 | ┊ 6┊ 6┊import { GraphQLModule } from './graphql.module'; 21 | ┊ 7┊ 7┊import {ChatsListerModule} from './chats-lister/chats-lister.module'; 22 | ┊ 8┊ 8┊import {RouterModule, Routes} from '@angular/router'; 23 | +┊ ┊ 9┊import {ChatViewerModule} from './chat-viewer/chat-viewer.module'; 24 | ┊ 9┊10┊const routes: Routes = []; 25 | ┊10┊11┊ 26 | ┊11┊12┊@NgModule({ 27 | ``` 28 | ```diff 29 | @@ -20,6 +21,7 @@ 30 | ┊20┊21┊ RouterModule.forRoot(routes), 31 | ┊21┊22┊ // Feature modules 32 | ┊22┊23┊ ChatsListerModule, 33 | +┊ ┊24┊ ChatViewerModule, 34 | ┊23┊25┊ ], 35 | ┊24┊26┊ providers: [], 36 | ┊25┊27┊ bootstrap: [AppComponent] 37 | ``` 38 | 39 | ##### Added src/app/chat-viewer/chat-viewer.module.ts 40 | ```diff 41 | @@ -0,0 +1,53 @@ 42 | +┊ ┊ 1┊import { BrowserModule } from '@angular/platform-browser'; 43 | +┊ ┊ 2┊import { NgModule } from '@angular/core'; 44 | +┊ ┊ 3┊ 45 | +┊ ┊ 4┊import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; 46 | +┊ ┊ 5┊import {MatButtonModule, MatGridListModule, MatIconModule, MatListModule, MatMenuModule, MatToolbarModule} from '@angular/material'; 47 | +┊ ┊ 6┊import {RouterModule, Routes} from '@angular/router'; 48 | +┊ ┊ 7┊import {FormsModule} from '@angular/forms'; 49 | +┊ ┊ 8┊import {ChatsService} from '../services/chats.service'; 50 | +┊ ┊ 9┊import {ChatComponent} from './containers/chat/chat.component'; 51 | +┊ ┊10┊import {MessagesListComponent} from './components/messages-list/messages-list.component'; 52 | +┊ ┊11┊import {MessageItemComponent} from './components/message-item/message-item.component'; 53 | +┊ ┊12┊import {NewMessageComponent} from './components/new-message/new-message.component'; 54 | +┊ ┊13┊import {SharedModule} from '../shared/shared.module'; 55 | +┊ ┊14┊ 56 | +┊ ┊15┊const routes: Routes = [ 57 | +┊ ┊16┊ { 58 | +┊ ┊17┊ path: 'chat', children: [ 59 | +┊ ┊18┊ {path: ':id', component: ChatComponent}, 60 | +┊ ┊19┊ ], 61 | +┊ ┊20┊ }, 62 | +┊ ┊21┊]; 63 | +┊ ┊22┊ 64 | +┊ ┊23┊@NgModule({ 65 | +┊ ┊24┊ declarations: [ 66 | +┊ ┊25┊ ChatComponent, 67 | +┊ ┊26┊ MessagesListComponent, 68 | +┊ ┊27┊ MessageItemComponent, 69 | +┊ ┊28┊ NewMessageComponent, 70 | +┊ ┊29┊ ], 71 | +┊ ┊30┊ imports: [ 72 | +┊ ┊31┊ BrowserModule, 73 | +┊ ┊32┊ // Material 74 | +┊ ┊33┊ MatToolbarModule, 75 | +┊ ┊34┊ MatMenuModule, 76 | +┊ ┊35┊ MatIconModule, 77 | +┊ ┊36┊ MatButtonModule, 78 | +┊ ┊37┊ MatListModule, 79 | +┊ ┊38┊ MatGridListModule, 80 | +┊ ┊39┊ // Animations 81 | +┊ ┊40┊ BrowserAnimationsModule, 82 | +┊ ┊41┊ // Routing 83 | +┊ ┊42┊ RouterModule.forChild(routes), 84 | +┊ ┊43┊ // Forms 85 | +┊ ┊44┊ FormsModule, 86 | +┊ ┊45┊ // Feature modules 87 | +┊ ┊46┊ SharedModule, 88 | +┊ ┊47┊ ], 89 | +┊ ┊48┊ providers: [ 90 | +┊ ┊49┊ ChatsService, 91 | +┊ ┊50┊ ], 92 | +┊ ┊51┊}) 93 | +┊ ┊52┊export class ChatViewerModule { 94 | +┊ ┊53┊} 95 | ``` 96 | 97 | [}]: # 98 | 99 | As you can see, it has already everything that we might need. 100 | 101 | ### GetChat operation 102 | 103 | Components need data, so we create the `GetChat` operation and run `yarn generator`: 104 | 105 | [{]: (diffStep "4.1" files="src/graphql/getChat.query.ts" module="client") 106 | 107 | #### [Step 4.1: Chat Viewer](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/6bb097d) 108 | 109 | ##### Added src/graphql/getChat.query.ts 110 | ```diff 111 | @@ -0,0 +1,17 @@ 112 | +┊ ┊ 1┊import gql from 'graphql-tag'; 113 | +┊ ┊ 2┊import {fragments} from './fragment'; 114 | +┊ ┊ 3┊ 115 | +┊ ┊ 4┊// We use the gql tag to parse our query string into a query document 116 | +┊ ┊ 5┊export const getChatQuery = gql` 117 | +┊ ┊ 6┊ query GetChat($chatId: ID!) { 118 | +┊ ┊ 7┊ chat(chatId: $chatId) { 119 | +┊ ┊ 8┊ ...ChatWithoutMessages 120 | +┊ ┊ 9┊ messages { 121 | +┊ ┊10┊ ...Message 122 | +┊ ┊11┊ } 123 | +┊ ┊12┊ } 124 | +┊ ┊13┊ } 125 | +┊ ┊14┊ 126 | +┊ ┊15┊ ${fragments['chatWithoutMessages']} 127 | +┊ ┊16┊ ${fragments['message']} 128 | +┊ ┊17┊`; 129 | ``` 130 | 131 | [}]: # 132 | 133 | The query can be now implemented in `ChatsService`: 134 | 135 | [{]: (diffStep "4.1" files="src/app/services/chats.service.ts" module="client") 136 | 137 | #### [Step 4.1: Chat Viewer](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/6bb097d) 138 | 139 | ##### Changed src/app/services/chats.service.ts 140 | ```diff 141 | @@ -1,13 +1,14 @@ 142 | ┊ 1┊ 1┊import {map} from 'rxjs/operators'; 143 | ┊ 2┊ 2┊import {Injectable} from '@angular/core'; 144 | -┊ 3┊ ┊import {GetChatsGQL} from '../../graphql'; 145 | +┊ ┊ 3┊import {GetChatsGQL, GetChatGQL} from '../../graphql'; 146 | ┊ 4┊ 4┊ 147 | ┊ 5┊ 5┊@Injectable() 148 | ┊ 6┊ 6┊export class ChatsService { 149 | ┊ 7┊ 7┊ messagesAmount = 3; 150 | ┊ 8┊ 8┊ 151 | ┊ 9┊ 9┊ constructor( 152 | -┊10┊ ┊ private getChatsGQL: GetChatsGQL 153 | +┊ ┊10┊ private getChatsGQL: GetChatsGQL, 154 | +┊ ┊11┊ private getChatGQL: GetChatGQL 155 | ┊11┊12┊ ) {} 156 | ┊12┊13┊ 157 | ┊13┊14┊ getChats() { 158 | ``` 159 | ```diff 160 | @@ -20,4 +21,16 @@ 161 | ┊20┊21┊ 162 | ┊21┊22┊ return {query, chats$}; 163 | ┊22┊23┊ } 164 | +┊ ┊24┊ 165 | +┊ ┊25┊ getChat(chatId: string) { 166 | +┊ ┊26┊ const query = this.getChatGQL.watch({ 167 | +┊ ┊27┊ chatId: chatId, 168 | +┊ ┊28┊ }); 169 | +┊ ┊29┊ 170 | +┊ ┊30┊ const chat$ = query.valueChanges.pipe( 171 | +┊ ┊31┊ map((result) => result.data.chat) 172 | +┊ ┊32┊ ); 173 | +┊ ┊33┊ 174 | +┊ ┊34┊ return {query, chat$}; 175 | +┊ ┊35┊ } 176 | ┊23┊36┊} 177 | ``` 178 | 179 | [}]: # 180 | 181 | Great! 182 | 183 | ### Chat view 184 | 185 | Now we've got data but a user can't still access the chat view. 186 | There is one place where we're able to pick the chat, it's the list: 187 | 188 | [{]: (diffStep "4.1" files="src/app/chats-lister/components/chat-item/chat-item.component.ts, src/app/chats-lister/components/chats-list/chats-list.component.ts, src/app/chats-lister/containers/chats/chats.component.ts" module="client") 189 | 190 | #### [Step 4.1: Chat Viewer](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/6bb097d) 191 | 192 | ##### Changed src/app/chats-lister/components/chat-item/chat-item.component.ts 193 | ```diff 194 | @@ -1,10 +1,10 @@ 195 | -┊ 1┊ ┊import {Component, Input} from '@angular/core'; 196 | +┊ ┊ 1┊import {Component, EventEmitter, Input, Output} from '@angular/core'; 197 | ┊ 2┊ 2┊import {GetChats} from '../../../../graphql'; 198 | ┊ 3┊ 3┊ 199 | ┊ 4┊ 4┊@Component({ 200 | ┊ 5┊ 5┊ selector: 'app-chat-item', 201 | ┊ 6┊ 6┊ template: ` 202 | -┊ 7┊ ┊
203 | +┊ ┊ 7┊
204 | ┊ 8┊ 8┊ 205 | ┊ 9┊ 9┊
206 | ┊10┊10┊
{{ chat.name }}
207 | ``` 208 | ```diff 209 | @@ -19,4 +19,11 @@ 210 | ┊19┊19┊ // tslint:disable-next-line:no-input-rename 211 | ┊20┊20┊ @Input('item') 212 | ┊21┊21┊ chat: GetChats.Chats; 213 | +┊ ┊22┊ 214 | +┊ ┊23┊ @Output() 215 | +┊ ┊24┊ select = new EventEmitter(); 216 | +┊ ┊25┊ 217 | +┊ ┊26┊ selectChat() { 218 | +┊ ┊27┊ this.select.emit(this.chat.id); 219 | +┊ ┊28┊ } 220 | ┊22┊29┊} 221 | ``` 222 | 223 | ##### Changed src/app/chats-lister/components/chats-list/chats-list.component.ts 224 | ```diff 225 | @@ -1,4 +1,4 @@ 226 | -┊1┊ ┊import {Component, Input} from '@angular/core'; 227 | +┊ ┊1┊import {Component, EventEmitter, Input, Output} from '@angular/core'; 228 | ┊2┊2┊import {GetChats} from '../../../../graphql'; 229 | ┊3┊3┊ 230 | ┊4┊4┊@Component({ 231 | ``` 232 | ```diff 233 | @@ -6,7 +6,7 @@ 234 | ┊ 6┊ 6┊ template: ` 235 | ┊ 7┊ 7┊ 236 | ┊ 8┊ 8┊ 237 | -┊ 9┊ ┊ 238 | +┊ ┊ 9┊ 239 | ┊10┊10┊ 240 | ┊11┊11┊ 241 | ┊12┊12┊ `, 242 | ``` 243 | ```diff 244 | @@ -17,5 +17,12 @@ 245 | ┊17┊17┊ @Input('items') 246 | ┊18┊18┊ chats: GetChats.Chats[]; 247 | ┊19┊19┊ 248 | +┊ ┊20┊ @Output() 249 | +┊ ┊21┊ select = new EventEmitter(); 250 | +┊ ┊22┊ 251 | ┊20┊23┊ constructor() {} 252 | +┊ ┊24┊ 253 | +┊ ┊25┊ selectChat(id: string) { 254 | +┊ ┊26┊ this.select.emit(id); 255 | +┊ ┊27┊ } 256 | ┊21┊28┊} 257 | ``` 258 | 259 | ##### Changed src/app/chats-lister/containers/chats/chats.component.ts 260 | ```diff 261 | @@ -2,6 +2,7 @@ 262 | ┊2┊2┊import {ChatsService} from '../../../services/chats.service'; 263 | ┊3┊3┊import {Observable} from 'rxjs'; 264 | ┊4┊4┊import {GetChats} from '../../../../graphql'; 265 | +┊ ┊5┊import {Router} from '@angular/router'; 266 | ┊5┊6┊ 267 | ┊6┊7┊@Component({ 268 | ┊7┊8┊ template: ` 269 | ``` 270 | ```diff 271 | @@ -27,7 +28,7 @@ 272 | ┊27┊28┊ 273 | ┊28┊29┊ 274 | ┊29┊30┊ 275 | -┊30┊ ┊ 276 | +┊ ┊31┊ 277 | ┊31┊32┊ 278 | ┊32┊33┊ 360 | +┊ ┊11┊ 361 | +┊ ┊12┊
{{ name }}
362 | +┊ ┊13┊ 363 | +┊ ┊14┊
364 | +┊ ┊15┊ 365 | +┊ ┊16┊ 366 | +┊ ┊17┊
367 | +┊ ┊18┊ `, 368 | +┊ ┊19┊ styleUrls: ['./chat.component.scss'] 369 | +┊ ┊20┊}) 370 | +┊ ┊21┊export class ChatComponent implements OnInit { 371 | +┊ ┊22┊ chatId: string; 372 | +┊ ┊23┊ messages: any[]; 373 | +┊ ┊24┊ name: string; 374 | +┊ ┊25┊ isGroup: boolean; 375 | +┊ ┊26┊ 376 | +┊ ┊27┊ constructor(private route: ActivatedRoute, 377 | +┊ ┊28┊ private router: Router, 378 | +┊ ┊29┊ private chatsService: ChatsService) { 379 | +┊ ┊30┊ } 380 | +┊ ┊31┊ 381 | +┊ ┊32┊ ngOnInit() { 382 | +┊ ┊33┊ this.route.params.subscribe(({id: chatId}) => { 383 | +┊ ┊34┊ this.chatId = chatId; 384 | +┊ ┊35┊ this.chatsService.getChat(chatId).chat$.subscribe(chat => { 385 | +┊ ┊36┊ this.messages = chat.messages; 386 | +┊ ┊37┊ this.name = chat.name; 387 | +┊ ┊38┊ this.isGroup = chat.isGroup; 388 | +┊ ┊39┊ }); 389 | +┊ ┊40┊ }); 390 | +┊ ┊41┊ } 391 | +┊ ┊42┊ 392 | +┊ ┊43┊ goToChats() { 393 | +┊ ┊44┊ this.router.navigate(['/chats']); 394 | +┊ ┊45┊ } 395 | +┊ ┊46┊} 396 | ``` 397 | 398 | [}]: # 399 | 400 | The `ChatComponent` component contains: 401 | 402 | - Toolbar with chat's name and a "go back" button 403 | - List of messages sorted from oldest to newest 404 | - Space to submit a new message 405 | 406 | ### List of messages 407 | 408 | First we'll download and a couple of images which will help us create a "bubble" effect for received messages: 409 | 410 | $ wget https://raw.githubusercontent.com/Urigo/whatsapp-client-angularcli-material/4f4497df6187c6cf42bb01d7c516db7f9f08e32c/src/assets/chat-background.jpg src/assets 411 | $ wget https://raw.githubusercontent.com/Urigo/whatsapp-client-angularcli-material/4f4497df6187c6cf42bb01d7c516db7f9f08e32c/src/assets/message-mine.png src/assets 412 | $ wget https://raw.githubusercontent.com/Urigo/whatsapp-client-angularcli-material/4f4497df6187c6cf42bb01d7c516db7f9f08e32c/src/assets/message-other.png src/assets 413 | 414 | We'll now split the list of messages to the host: 415 | 416 | [{]: (diffStep "4.1" files="src/app/chat-viewer/components/messages-list/messages-list.component.ts, src/app/chat-viewer/components/messages-list/messages-list.component.scss" module="client") 417 | 418 | #### [Step 4.1: Chat Viewer](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/6bb097d) 419 | 420 | ##### Added src/app/chat-viewer/components/messages-list/messages-list.component.scss 421 | ```diff 422 | @@ -0,0 +1,15 @@ 423 | +┊ ┊ 1┊:host { 424 | +┊ ┊ 2┊ display: block; 425 | +┊ ┊ 3┊ height: 100%; 426 | +┊ ┊ 4┊ overflow-y: overlay; 427 | +┊ ┊ 5┊} 428 | +┊ ┊ 6┊ 429 | +┊ ┊ 7┊::ng-deep .mat-list-item-content { 430 | +┊ ┊ 8┊ display: block !important; 431 | +┊ ┊ 9┊} 432 | +┊ ┊10┊ 433 | +┊ ┊11┊/* 434 | +┊ ┊12┊:host::-webkit-scrollbar { 435 | +┊ ┊13┊ display: none; 436 | +┊ ┊14┊} 437 | +┊ ┊15┊*/🚫↵ 438 | ``` 439 | 440 | ##### Added src/app/chat-viewer/components/messages-list/messages-list.component.ts 441 | ```diff 442 | @@ -0,0 +1,23 @@ 443 | +┊ ┊ 1┊import {Component, Input} from '@angular/core'; 444 | +┊ ┊ 2┊ 445 | +┊ ┊ 3┊@Component({ 446 | +┊ ┊ 4┊ selector: 'app-messages-list', 447 | +┊ ┊ 5┊ template: ` 448 | +┊ ┊ 6┊ 449 | +┊ ┊ 7┊ 450 | +┊ ┊ 8┊ 451 | +┊ ┊ 9┊ 452 | +┊ ┊10┊ 453 | +┊ ┊11┊ `, 454 | +┊ ┊12┊ styleUrls: ['messages-list.component.scss'], 455 | +┊ ┊13┊}) 456 | +┊ ┊14┊export class MessagesListComponent { 457 | +┊ ┊15┊ // tslint:disable-next-line:no-input-rename 458 | +┊ ┊16┊ @Input('items') 459 | +┊ ┊17┊ messages: any[]; 460 | +┊ ┊18┊ 461 | +┊ ┊19┊ @Input() 462 | +┊ ┊20┊ isGroup: boolean; 463 | +┊ ┊21┊ 464 | +┊ ┊22┊ constructor() {} 465 | +┊ ┊23┊} 466 | ``` 467 | 468 | [}]: # 469 | 470 | and a reused component for each message: 471 | 472 | [{]: (diffStep "4.1" files="src/app/chat-viewer/components/message-item/message-item.component.ts, src/app/chat-viewer/components/message-item/message-item.component.scss" module="client") 473 | 474 | #### [Step 4.1: Chat Viewer](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/6bb097d) 475 | 476 | ##### Added src/app/chat-viewer/components/message-item/message-item.component.scss 477 | ```diff 478 | @@ -0,0 +1,74 @@ 479 | +┊ ┊ 1┊:host { 480 | +┊ ┊ 2┊ margin-bottom: 9px; 481 | +┊ ┊ 3┊ 482 | +┊ ┊ 4┊ &::after { 483 | +┊ ┊ 5┊ content: ""; 484 | +┊ ┊ 6┊ display: table; 485 | +┊ ┊ 7┊ clear: both; 486 | +┊ ┊ 8┊ } 487 | +┊ ┊ 9┊} 488 | +┊ ┊10┊ 489 | +┊ ┊11┊.message { 490 | +┊ ┊12┊ display: inline-block; 491 | +┊ ┊13┊ position: relative; 492 | +┊ ┊14┊ max-width: 100%; 493 | +┊ ┊15┊ border-radius: 7px; 494 | +┊ ┊16┊ box-shadow: 0 1px 2px rgba(0, 0, 0, .15); 495 | +┊ ┊17┊ margin-bottom: 20px; 496 | +┊ ┊18┊ clear: both; 497 | +┊ ┊19┊ 498 | +┊ ┊20┊ &.message-mine { 499 | +┊ ┊21┊ float: right; 500 | +┊ ┊22┊ background-color: #DCF8C6; 501 | +┊ ┊23┊ 502 | +┊ ┊24┊ &::before { 503 | +┊ ┊25┊ right: -11px; 504 | +┊ ┊26┊ background-image: url(/assets/message-mine.png) 505 | +┊ ┊27┊ } 506 | +┊ ┊28┊ } 507 | +┊ ┊29┊ 508 | +┊ ┊30┊ &.message-other { 509 | +┊ ┊31┊ float: left; 510 | +┊ ┊32┊ background-color: #FFF; 511 | +┊ ┊33┊ 512 | +┊ ┊34┊ &::before { 513 | +┊ ┊35┊ left: -11px; 514 | +┊ ┊36┊ background-image: url(/assets/message-other.png) 515 | +┊ ┊37┊ } 516 | +┊ ┊38┊ } 517 | +┊ ┊39┊ 518 | +┊ ┊40┊ &.message-other::before, &.message-mine::before { 519 | +┊ ┊41┊ content: ""; 520 | +┊ ┊42┊ position: absolute; 521 | +┊ ┊43┊ bottom: 3px; 522 | +┊ ┊44┊ width: 12px; 523 | +┊ ┊45┊ height: 19px; 524 | +┊ ┊46┊ background-position: 50% 50%; 525 | +┊ ┊47┊ background-repeat: no-repeat; 526 | +┊ ┊48┊ background-size: contain; 527 | +┊ ┊49┊ } 528 | +┊ ┊50┊ 529 | +┊ ┊51┊ .message-content { 530 | +┊ ┊52┊ padding: 5px 7px; 531 | +┊ ┊53┊ word-wrap: break-word; 532 | +┊ ┊54┊ 533 | +┊ ┊55┊ &::after { 534 | +┊ ┊56┊ content: " \00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0"; 535 | +┊ ┊57┊ display: inline; 536 | +┊ ┊58┊ } 537 | +┊ ┊59┊ } 538 | +┊ ┊60┊ 539 | +┊ ┊61┊ .message-sender { 540 | +┊ ┊62┊ font-weight: bold; 541 | +┊ ┊63┊ font-size: 0.9em; 542 | +┊ ┊64┊ padding: 5px; 543 | +┊ ┊65┊ } 544 | +┊ ┊66┊ 545 | +┊ ┊67┊ .message-timestamp { 546 | +┊ ┊68┊ position: absolute; 547 | +┊ ┊69┊ bottom: 2px; 548 | +┊ ┊70┊ right: 7px; 549 | +┊ ┊71┊ color: gray; 550 | +┊ ┊72┊ font-size: 12px; 551 | +┊ ┊73┊ } 552 | +┊ ┊74┊}🚫↵ 553 | ``` 554 | 555 | ##### Added src/app/chat-viewer/components/message-item/message-item.component.ts 556 | ```diff 557 | @@ -0,0 +1,22 @@ 558 | +┊ ┊ 1┊import {Component, Input} from '@angular/core'; 559 | +┊ ┊ 2┊ 560 | +┊ ┊ 3┊@Component({ 561 | +┊ ┊ 4┊ selector: 'app-message-item', 562 | +┊ ┊ 5┊ template: ` 563 | +┊ ┊ 6┊
565 | +┊ ┊ 8┊
{{ message.sender.name }}
566 | +┊ ┊ 9┊
{{ message.content }}
567 | +┊ ┊10┊ 00:00 568 | +┊ ┊11┊
569 | +┊ ┊12┊ `, 570 | +┊ ┊13┊ styleUrls: ['message-item.component.scss'], 571 | +┊ ┊14┊}) 572 | +┊ ┊15┊export class MessageItemComponent { 573 | +┊ ┊16┊ // tslint:disable-next-line:no-input-rename 574 | +┊ ┊17┊ @Input('item') 575 | +┊ ┊18┊ message: any; 576 | +┊ ┊19┊ 577 | +┊ ┊20┊ @Input() 578 | +┊ ┊21┊ isGroup: boolean; 579 | +┊ ┊22┊} 580 | ``` 581 | 582 | [}]: # 583 | 584 | As you see, we could easily decide which message comes from which user based on the `ownership` property. 585 | 586 | ### Submit a new message 587 | 588 | The app shows the conversation and now we're going to focus on the last part, actually emitting a new message! 589 | 590 | [{]: (diffStep "4.1" files="src/app/chat-viewer/components/new-message/new-message.component.scss, src/app/chat-viewer/components/new-message/new-message.component.ts" module="client") 591 | 592 | #### [Step 4.1: Chat Viewer](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/6bb097d) 593 | 594 | ##### Added src/app/chat-viewer/components/new-message/new-message.component.scss 595 | ```diff 596 | @@ -0,0 +1,32 @@ 597 | +┊ ┊ 1┊:host { 598 | +┊ ┊ 2┊ display: flex; 599 | +┊ ┊ 3┊ height: 50px; 600 | +┊ ┊ 4┊ padding: 5px; 601 | +┊ ┊ 5┊} 602 | +┊ ┊ 6┊ 603 | +┊ ┊ 7┊input { 604 | +┊ ┊ 8┊ width: 100%; 605 | +┊ ┊ 9┊ border: none; 606 | +┊ ┊10┊ border-radius: 999px; 607 | +┊ ┊11┊ padding: 10px; 608 | +┊ ┊12┊ padding-left: 20px; 609 | +┊ ┊13┊ padding-right: 20px; 610 | +┊ ┊14┊ font-size: 15px; 611 | +┊ ┊15┊ outline: none; 612 | +┊ ┊16┊ box-shadow: 0 1px silver; 613 | +┊ ┊17┊ font-size: 18px; 614 | +┊ ┊18┊ line-height: 45px; 615 | +┊ ┊19┊} 616 | +┊ ┊20┊ 617 | +┊ ┊21┊button { 618 | +┊ ┊22┊ min-width: 45px; 619 | +┊ ┊23┊ width: 45px; 620 | +┊ ┊24┊ border-radius: 999px; 621 | +┊ ┊25┊ background-color: var(--primary); 622 | +┊ ┊26┊ margin-left: 5px; 623 | +┊ ┊27┊ color: white; 624 | +┊ ┊28┊ 625 | +┊ ┊29┊ mat-icon { 626 | +┊ ┊30┊ margin-left: -3px; 627 | +┊ ┊31┊ } 628 | +┊ ┊32┊}🚫↵ 629 | ``` 630 | 631 | ##### Added src/app/chat-viewer/components/new-message/new-message.component.ts 632 | ```diff 633 | @@ -0,0 +1,34 @@ 634 | +┊ ┊ 1┊import {Component, EventEmitter, Input, Output} from '@angular/core'; 635 | +┊ ┊ 2┊ 636 | +┊ ┊ 3┊@Component({ 637 | +┊ ┊ 4┊ selector: 'app-new-message', 638 | +┊ ┊ 5┊ template: ` 639 | +┊ ┊ 6┊ 640 | +┊ ┊ 7┊ 643 | +┊ ┊10┊ `, 644 | +┊ ┊11┊ styleUrls: ['new-message.component.scss'], 645 | +┊ ┊12┊}) 646 | +┊ ┊13┊export class NewMessageComponent { 647 | +┊ ┊14┊ @Input() 648 | +┊ ┊15┊ disabled: boolean; 649 | +┊ ┊16┊ 650 | +┊ ┊17┊ @Output() 651 | +┊ ┊18┊ newMessage = new EventEmitter(); 652 | +┊ ┊19┊ 653 | +┊ ┊20┊ message = ''; 654 | +┊ ┊21┊ 655 | +┊ ┊22┊ onInputKeyup({ keyCode }: KeyboardEvent) { 656 | +┊ ┊23┊ if (keyCode === 13) { 657 | +┊ ┊24┊ this.emitMessage(); 658 | +┊ ┊25┊ } 659 | +┊ ┊26┊ } 660 | +┊ ┊27┊ 661 | +┊ ┊28┊ emitMessage() { 662 | +┊ ┊29┊ if (this.message && !this.disabled) { 663 | +┊ ┊30┊ this.newMessage.emit(this.message); 664 | +┊ ┊31┊ this.message = ''; 665 | +┊ ┊32┊ } 666 | +┊ ┊33┊ } 667 | +┊ ┊34┊} 668 | ``` 669 | 670 | [}]: # 671 | 672 | It's not yet fully functional, we receive the message in the `ChatComponent` but we still need to send it to the server. We'll cover that in next few steps! 673 | 674 | 675 | [//]: # (foot-start) 676 | 677 | [{]: (navStep) 678 | 679 | | [< Previous Step](https://github.com/Urigo/whatsapp-textrepo-angularcli-express/tree/master@2.0.0/.tortilla/manuals/views/step3.md) | [Next Step >](https://github.com/Urigo/whatsapp-textrepo-angularcli-express/tree/master@2.0.0/.tortilla/manuals/views/step5.md) | 680 | |:--------------------------------|--------------------------------:| 681 | 682 | [}]: # 683 | -------------------------------------------------------------------------------- /.tortilla/manuals/views/step6.md: -------------------------------------------------------------------------------- 1 | # Step 6: Updating the store 2 | 3 | [//]: # (head-end) 4 | 5 | 6 | ## Client 7 | 8 | Did you notice that after creating a new message you'll have to refresh the page in order to see it? 9 | 10 | How to fix that? 11 | 12 | Apollo performs two important core tasks which we covered in one of previous steps: executing queries and mutations, and caching the results. 13 | 14 | Thanks to Apollo's store design, it's possible for the results of a query or mutation to update your UI in all the right places. In many cases it's possible for that to happen automatically, whereas in others you need to help the client out a little in doing so. 15 | 16 | There are couple of APIs to update the cache after mutation: 17 | 18 | - `refetchQueries` - is the most straightforward way of updating the cache. You request queries to be executed once again. 19 | - `update` - super flexible and allows you to make changes to the store based on mutation. Works well with Optimistic UI which we will cover later. 20 | - `updateQueries` - It works similar to the previous option but the API may be deprecated in the future and we recommend to stay with `update`. 21 | 22 | If you thought about re-querying the server you would be wrong! The best solution is to use the response provided by the server to update our Apollo local cache. 23 | 24 | Of course that we're going to use the `update` API! 25 | 26 | ### Updating the store 27 | 28 | As you can see, the `update` option is a function that has two arguments: 29 | 30 | - `DataProxy` which we named `store` 31 | - mutation's result 32 | 33 | The `DataProxy` seems very interesting! It's a middleman between you and the cache. 34 | 35 | #### How to update fragments 36 | 37 | In pretty much every case, that middleman would allow to write and read data. Take for example the JavaScript `Map` object. 38 | 39 | It has a `get` and `set` methods and both works based on a `key`. 40 | 41 | In Apollo, we do have that key, it's something that was covered in the 5th step, remember? An object of type `Message` with an `id` would be stored in the cache as `Message:`. 42 | 43 | Let's call that small object a fragment, for simplicity. 44 | 45 | What are our _get_ and _set_ methods in Apollo's DataProxy? Simple, `readFragment` and `writeFragment`. 46 | 47 | I think it's time to work on a simple example. Scenario: every message can be liked and we keep the sum of likes under the `likes` field. 48 | 49 | How to give a like? 50 | 51 | ```typescript 52 | const store: DataProxy = ...; 53 | const messageId = 256; 54 | 55 | const fragment = gql` 56 | fragment M on Message { 57 | likes 58 | } 59 | `; 60 | const fragmentId = `Message:${messageId}`; 61 | 62 | const message = store.readFragment({ 63 | fragment, 64 | id: fragmentId 65 | }); 66 | 67 | store.writeFragment({ 68 | fragment, 69 | id: fragmentId, 70 | data: { 71 | likes: message.likes + 1 72 | } 73 | }); 74 | ``` 75 | 76 | What just happened?! 77 | 78 | At first, it might seem weird to talk to the store like that but you will see that it makes a lot of sense! 79 | 80 | - `messageId` - defined the actual id of our message 81 | - `fragment` - it's a document that tells Apollo what fields we want to read and write 82 | - `fragmentId` - an unique key under which the fragment is stored (we covered that in 5th step) 83 | - `readFragment` - reads the fragment in the store and returns the result in exact same shape as requested 84 | - `writeFragment` - accepts same properties as `readFragment` but also the `data` property 85 | 86 | ### How to update queries 87 | 88 | Similar as in case of fragments, we update queries with `readQuery` and `writeQuery`. 89 | 90 | We won't cover the same thing again but let's just look at the API based on our WhatsApp clone example: 91 | 92 | [{]: (diffStep "6.1" module="client") 93 | 94 | #### [Step 6.1: Update the store](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/764200d) 95 | 96 | ##### Changed src/app/services/chats.service.ts 97 | ```diff 98 | @@ -1,6 +1,13 @@ 99 | ┊ 1┊ 1┊import {map} from 'rxjs/operators'; 100 | ┊ 2┊ 2┊import {Injectable} from '@angular/core'; 101 | -┊ 3┊ ┊import {GetChatsGQL, GetChatGQL, AddMessageGQL} from '../../graphql'; 102 | +┊ ┊ 3┊import { 103 | +┊ ┊ 4┊ GetChatsGQL, 104 | +┊ ┊ 5┊ GetChatGQL, 105 | +┊ ┊ 6┊ AddMessageGQL, 106 | +┊ ┊ 7┊ AddMessage, 107 | +┊ ┊ 8┊ GetChats, 108 | +┊ ┊ 9┊ GetChat 109 | +┊ ┊10┊} from '../../graphql'; 110 | ┊ 4┊11┊ 111 | ┊ 5┊12┊@Injectable() 112 | ┊ 6┊13┊export class ChatsService { 113 | ``` 114 | ```diff 115 | @@ -39,6 +46,50 @@ 116 | ┊39┊46┊ return this.addMessageGQL.mutate({ 117 | ┊40┊47┊ chatId, 118 | ┊41┊48┊ content, 119 | +┊ ┊49┊ }, { 120 | +┊ ┊50┊ update: (store, { data: { addMessage } }: {data: AddMessage.Mutation}) => { 121 | +┊ ┊51┊ // Update the messages cache 122 | +┊ ┊52┊ { 123 | +┊ ┊53┊ // Read the data from our cache for this query. 124 | +┊ ┊54┊ const {chat} = store.readQuery({ 125 | +┊ ┊55┊ query: this.getChatGQL.document, 126 | +┊ ┊56┊ variables: { 127 | +┊ ┊57┊ chatId, 128 | +┊ ┊58┊ } 129 | +┊ ┊59┊ }); 130 | +┊ ┊60┊ // Add our message from the mutation to the end. 131 | +┊ ┊61┊ chat.messages.push(addMessage); 132 | +┊ ┊62┊ // Write our data back to the cache. 133 | +┊ ┊63┊ store.writeQuery({ 134 | +┊ ┊64┊ query: this.getChatGQL.document, 135 | +┊ ┊65┊ data: { 136 | +┊ ┊66┊ chat 137 | +┊ ┊67┊ } 138 | +┊ ┊68┊ }); 139 | +┊ ┊69┊ } 140 | +┊ ┊70┊ // Update last message cache 141 | +┊ ┊71┊ { 142 | +┊ ┊72┊ // Read the data from our cache for this query. 143 | +┊ ┊73┊ const {chats} = store.readQuery({ 144 | +┊ ┊74┊ query: this.getChatsGQL.document, 145 | +┊ ┊75┊ variables: { 146 | +┊ ┊76┊ amount: this.messagesAmount, 147 | +┊ ┊77┊ }, 148 | +┊ ┊78┊ }); 149 | +┊ ┊79┊ // Add our comment from the mutation to the end. 150 | +┊ ┊80┊ chats.find(chat => chat.id === chatId).messages.push(addMessage); 151 | +┊ ┊81┊ // Write our data back to the cache. 152 | +┊ ┊82┊ store.writeQuery({ 153 | +┊ ┊83┊ query: this.getChatsGQL.document, 154 | +┊ ┊84┊ variables: { 155 | +┊ ┊85┊ amount: this.messagesAmount, 156 | +┊ ┊86┊ }, 157 | +┊ ┊87┊ data: { 158 | +┊ ┊88┊ chats, 159 | +┊ ┊89┊ }, 160 | +┊ ┊90┊ }); 161 | +┊ ┊91┊ } 162 | +┊ ┊92┊ } 163 | ┊42┊93┊ }); 164 | ┊43┊94┊ } 165 | ┊44┊95┊} 166 | ``` 167 | 168 | [}]: # 169 | 170 | As you can see we used the `document` property of a generated GQL service. It contains the actual query that is used in every `watch` or `fetch` calls of the GQL service. It's a part of the open API. 171 | 172 | ### Keep on mind 173 | 174 | It's very important to know few things: 175 | 176 | - update function have to run synchronously 177 | - the query should always match the query you want to update (even order of fields or variables matters) 178 | - by updating a query you also update every normalized data it has (we called them fragments in few secions above) 179 | 180 | When Apollo runs the `update` function? In two cases, rigth after the mutation happens but only if it has an optimistic response defined, and also once we get the response from the server. 181 | 182 | > Optimistic Resposne is something we will cover in next steps but so you know, Apollo allows to optimistically update the store with a temporary data that is being replaced right after we get the response from the server. 183 | 184 | But why the update function has to run synchornously? It's by design and not so interesting. We won't cover it in-depth here but in short, when Apollo runs the function it switches the store with the new, empty one, to record every change that's made by the function to later merge it with the original store. If you still have questions, please let us know so we can cover that thing in-depth as a blog post or even a separate chapter in that tutorial. 185 | 186 | ### Summary 187 | 188 | Now you won't need to reload the page in order to see the new message. What's even more interesting is that the message you wrote would also be shown as the last message in the chats list, just hit the back button in the top-left corner to find out! 189 | 190 | This is because we updated our store for both the `GetChat` and the `GetChats` query. 191 | 192 | 193 | [//]: # (foot-start) 194 | 195 | [{]: (navStep) 196 | 197 | | [< Previous Step](https://github.com/Urigo/whatsapp-textrepo-angularcli-express/tree/master@2.0.0/.tortilla/manuals/views/step5.md) | [Next Step >](https://github.com/Urigo/whatsapp-textrepo-angularcli-express/tree/master@2.0.0/.tortilla/manuals/views/step7.md) | 198 | |:--------------------------------|--------------------------------:| 199 | 200 | [}]: # 201 | -------------------------------------------------------------------------------- /.tortilla/manuals/views/step7.md: -------------------------------------------------------------------------------- 1 | # Step 7: Messages and chats removal 2 | 3 | [//]: # (head-end) 4 | 5 | 6 | ## Client 7 | 8 | Since we're now familiar with the way mutations work, it's time to add messages and chats removal to our list of features! 9 | Since the most annoying part is going to be dealing with the user interface (because a multiple selection started by a press event is involved), I created an Angular directive to ease the process. 10 | 11 | Let's start by adding `ngx-selectable-list`: 12 | 13 | yarn add ngx-selectable-list 14 | 15 | [{]: (diffStep "7.1" files="^\(?!yarn.lock$\).*" module="client") 16 | 17 | #### [Step 7.1: Add SelectableListModule](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/abcd720) 18 | 19 | ##### Changed package.json 20 | ```diff 21 | @@ -35,6 +35,7 @@ 22 | ┊35┊35┊ "graphql": "^14.1.1", 23 | ┊36┊36┊ "graphql-tag": "^2.10.1", 24 | ┊37┊37┊ "hammerjs": "^2.0.8", 25 | +┊ ┊38┊ "ngx-selectable-list": "^1.2.1", 26 | ┊38┊39┊ "rxjs": "~6.3.3", 27 | ┊39┊40┊ "tslib": "^1.9.0", 28 | ┊40┊41┊ "zone.js": "~0.8.26" 29 | ``` 30 | 31 | ##### Changed src/app/chats-lister/chats-lister.module.ts 32 | ```diff 33 | @@ -10,6 +10,7 @@ 34 | ┊10┊10┊import {ChatsComponent} from './containers/chats/chats.component'; 35 | ┊11┊11┊import {ChatsListComponent} from './components/chats-list/chats-list.component'; 36 | ┊12┊12┊import {SharedModule} from '../shared/shared.module'; 37 | +┊ ┊13┊import {NgxSelectableListModule} from 'ngx-selectable-list'; 38 | ┊13┊14┊ 39 | ┊14┊15┊const routes: Routes = [ 40 | ┊15┊16┊ {path: '', redirectTo: 'chats', pathMatch: 'full'}, 41 | ``` 42 | ```diff 43 | @@ -37,6 +38,7 @@ 44 | ┊37┊38┊ FormsModule, 45 | ┊38┊39┊ // Feature modules 46 | ┊39┊40┊ SharedModule, 47 | +┊ ┊41┊ NgxSelectableListModule, 48 | ┊40┊42┊ ], 49 | ┊41┊43┊ providers: [ 50 | ┊42┊44┊ ChatsService, 51 | ``` 52 | 53 | [}]: # 54 | 55 | Because we're going to use the `libSelectableList` directive, we can replace Outputs and EventEmitters with it: 56 | 57 | [{]: (diffStep "7.2" files="src/app/chats-lister/components/chat-item/chat-item.component.ts, src/app/chats-lister/components/chats-list/chats-list.component.ts, src/app/chat-viewer/components/messages-list/messages-list.component.ts" module="client") 58 | 59 | #### [Step 7.2: Remove messages and chats](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/31f9205) 60 | 61 | ##### Changed src/app/chat-viewer/components/messages-list/messages-list.component.ts 62 | ```diff 63 | @@ -1,14 +1,17 @@ 64 | ┊ 1┊ 1┊import {Component, Input} from '@angular/core'; 65 | ┊ 2┊ 2┊import {GetChat} from '../../../../graphql'; 66 | +┊ ┊ 3┊import {SelectableListDirective} from 'ngx-selectable-list'; 67 | ┊ 3┊ 4┊ 68 | ┊ 4┊ 5┊@Component({ 69 | ┊ 5┊ 6┊ selector: 'app-messages-list', 70 | ┊ 6┊ 7┊ template: ` 71 | ┊ 7┊ 8┊ 72 | ┊ 8┊ 9┊ 73 | -┊ 9┊ ┊ 74 | +┊ ┊10┊ 76 | ┊10┊12┊ 77 | ┊11┊13┊ 78 | +┊ ┊14┊ 79 | ┊12┊15┊ `, 80 | ┊13┊16┊ styleUrls: ['messages-list.component.scss'], 81 | ┊14┊17┊}) 82 | ``` 83 | ```diff 84 | @@ -20,5 +23,5 @@ 85 | ┊20┊23┊ @Input() 86 | ┊21┊24┊ isGroup: boolean; 87 | ┊22┊25┊ 88 | -┊23┊ ┊ constructor() {} 89 | +┊ ┊26┊ constructor(public selectableListDirective: SelectableListDirective) {} 90 | ┊24┊27┊} 91 | ``` 92 | 93 | ##### Changed src/app/chats-lister/components/chat-item/chat-item.component.ts 94 | ```diff 95 | @@ -4,7 +4,7 @@ 96 | ┊ 4┊ 4┊@Component({ 97 | ┊ 5┊ 5┊ selector: 'app-chat-item', 98 | ┊ 6┊ 6┊ template: ` 99 | -┊ 7┊ ┊
100 | +┊ ┊ 7┊
101 | ┊ 8┊ 8┊ 102 | ┊ 9┊ 9┊
103 | ┊10┊10┊
{{ chat.name }}
104 | ``` 105 | ```diff 106 | @@ -19,11 +19,4 @@ 107 | ┊19┊19┊ // tslint:disable-next-line:no-input-rename 108 | ┊20┊20┊ @Input('item') 109 | ┊21┊21┊ chat: GetChats.Chats; 110 | -┊22┊ ┊ 111 | -┊23┊ ┊ @Output() 112 | -┊24┊ ┊ select = new EventEmitter(); 113 | -┊25┊ ┊ 114 | -┊26┊ ┊ selectChat() { 115 | -┊27┊ ┊ this.select.emit(this.chat.id); 116 | -┊28┊ ┊ } 117 | ┊29┊22┊} 118 | ``` 119 | 120 | ##### Changed src/app/chats-lister/components/chats-list/chats-list.component.ts 121 | ```diff 122 | @@ -1,14 +1,17 @@ 123 | -┊ 1┊ ┊import {Component, EventEmitter, Input, Output} from '@angular/core'; 124 | +┊ ┊ 1┊import {Component, Input} from '@angular/core'; 125 | ┊ 2┊ 2┊import {GetChats} from '../../../../graphql'; 126 | +┊ ┊ 3┊import {SelectableListDirective} from 'ngx-selectable-list'; 127 | ┊ 3┊ 4┊ 128 | ┊ 4┊ 5┊@Component({ 129 | ┊ 5┊ 6┊ selector: 'app-chats-list', 130 | ┊ 6┊ 7┊ template: ` 131 | ┊ 7┊ 8┊ 132 | ┊ 8┊ 9┊ 133 | -┊ 9┊ ┊ 134 | +┊ ┊10┊ 136 | ┊10┊12┊ 137 | ┊11┊13┊ 138 | +┊ ┊14┊ 139 | ┊12┊15┊ `, 140 | ┊13┊16┊ styleUrls: ['chats-list.component.scss'], 141 | ┊14┊17┊}) 142 | ``` 143 | ```diff 144 | @@ -17,12 +20,5 @@ 145 | ┊17┊20┊ @Input('items') 146 | ┊18┊21┊ chats: GetChats.Chats[]; 147 | ┊19┊22┊ 148 | -┊20┊ ┊ @Output() 149 | -┊21┊ ┊ select = new EventEmitter(); 150 | -┊22┊ ┊ 151 | -┊23┊ ┊ constructor() {} 152 | -┊24┊ ┊ 153 | -┊25┊ ┊ selectChat(id: string) { 154 | -┊26┊ ┊ this.select.emit(id); 155 | -┊27┊ ┊ } 156 | +┊ ┊23┊ constructor(public selectableListDirective: SelectableListDirective) {} 157 | ┊28┊24┊} 158 | ``` 159 | 160 | [}]: # 161 | 162 | Don't forget to add the `NgxSelectableListModule` to `ChatViewer` and `ChatLister` modules. 163 | 164 | We also created a `ConfirmSelectionComponent` to use for content projection, since our selectable list directive will be able to listen to its events. 165 | 166 | [{]: (diffStep "7.2" files="src/app/shared/components/confirm-selection/confirm-selection.component.scss, src/app/shared/components/confirm-selection/confirm-selection.component.ts, src/app/shared/shared.module.ts" module="client") 167 | 168 | #### [Step 7.2: Remove messages and chats](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/31f9205) 169 | 170 | ##### Added src/app/shared/components/confirm-selection/confirm-selection.component.scss 171 | ```diff 172 | @@ -0,0 +1,6 @@ 173 | +┊ ┊1┊:host { 174 | +┊ ┊2┊ display: block; 175 | +┊ ┊3┊ position: absolute; 176 | +┊ ┊4┊ bottom: 5vw; 177 | +┊ ┊5┊ right: 5vw; 178 | +┊ ┊6┊} 179 | ``` 180 | 181 | ##### Added src/app/shared/components/confirm-selection/confirm-selection.component.ts 182 | ```diff 183 | @@ -0,0 +1,21 @@ 184 | +┊ ┊ 1┊import {Component, EventEmitter, Input, Output} from '@angular/core'; 185 | +┊ ┊ 2┊ 186 | +┊ ┊ 3┊@Component({ 187 | +┊ ┊ 4┊ selector: 'app-confirm-selection', 188 | +┊ ┊ 5┊ template: ` 189 | +┊ ┊ 6┊ 192 | +┊ ┊ 9┊ `, 193 | +┊ ┊10┊ styleUrls: ['./confirm-selection.component.scss'], 194 | +┊ ┊11┊}) 195 | +┊ ┊12┊export class ConfirmSelectionComponent { 196 | +┊ ┊13┊ @Input() 197 | +┊ ┊14┊ icon = 'delete'; 198 | +┊ ┊15┊ @Output() 199 | +┊ ┊16┊ emitClick = new EventEmitter(); 200 | +┊ ┊17┊ 201 | +┊ ┊18┊ handleClick() { 202 | +┊ ┊19┊ this.emitClick.emit(); 203 | +┊ ┊20┊ } 204 | +┊ ┊21┊} 205 | ``` 206 | 207 | ##### Changed src/app/shared/shared.module.ts 208 | ```diff 209 | @@ -1,19 +1,23 @@ 210 | ┊ 1┊ 1┊import {BrowserModule} from '@angular/platform-browser'; 211 | ┊ 2┊ 2┊import {NgModule} from '@angular/core'; 212 | ┊ 3┊ 3┊ 213 | -┊ 4┊ ┊import {MatToolbarModule} from '@angular/material'; 214 | +┊ ┊ 4┊import {MatButtonModule, MatIconModule, MatToolbarModule} from '@angular/material'; 215 | ┊ 5┊ 5┊import {ToolbarComponent} from './components/toolbar/toolbar.component'; 216 | ┊ 6┊ 6┊import {FormsModule} from '@angular/forms'; 217 | ┊ 7┊ 7┊import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; 218 | +┊ ┊ 8┊import {ConfirmSelectionComponent} from './components/confirm-selection/confirm-selection.component'; 219 | ┊ 8┊ 9┊ 220 | ┊ 9┊10┊@NgModule({ 221 | ┊10┊11┊ declarations: [ 222 | ┊11┊12┊ ToolbarComponent, 223 | +┊ ┊13┊ ConfirmSelectionComponent, 224 | ┊12┊14┊ ], 225 | ┊13┊15┊ imports: [ 226 | ┊14┊16┊ BrowserModule, 227 | ┊15┊17┊ // Material 228 | ┊16┊18┊ MatToolbarModule, 229 | +┊ ┊19┊ MatIconModule, 230 | +┊ ┊20┊ MatButtonModule, 231 | ┊17┊21┊ // Animations 232 | ┊18┊22┊ BrowserAnimationsModule, 233 | ┊19┊23┊ // Forms 234 | ``` 235 | ```diff 236 | @@ -22,6 +26,7 @@ 237 | ┊22┊26┊ providers: [], 238 | ┊23┊27┊ exports: [ 239 | ┊24┊28┊ ToolbarComponent, 240 | +┊ ┊29┊ ConfirmSelectionComponent, 241 | ┊25┊30┊ ], 242 | ┊26┊31┊}) 243 | ┊27┊32┊export class SharedModule { 244 | ``` 245 | 246 | [}]: # 247 | 248 | Great, let's finish the component part by adding the connection between our container components and the `ChatsService`. We're going to use `removeMessages` and `removeChat` methods. 249 | 250 | [{]: (diffStep "7.2" files="src/app/chat-viewer/containers/chat/chat.component.ts, src/app/chat-viewer/containers/chat/chat.component.spec.ts, src/app/chats-lister/containers/chats/chats.component.ts, src/app/chats-lister/containers/chats/chats.component.spec.ts" module="client") 251 | 252 | #### [Step 7.2: Remove messages and chats](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/31f9205) 253 | 254 | ##### Changed src/app/chat-viewer/containers/chat/chat.component.spec.ts 255 | ```diff 256 | @@ -20,6 +20,7 @@ 257 | ┊20┊20┊ APOLLO_TESTING_CACHE, 258 | ┊21┊21┊} from 'apollo-angular/testing'; 259 | ┊22┊22┊import { InMemoryCache } from 'apollo-cache-inmemory'; 260 | +┊ ┊23┊import { NgxSelectableListModule } from 'ngx-selectable-list'; 261 | ┊23┊24┊ 262 | ┊24┊25┊import { dataIdFromObject } from '../../../graphql.module'; 263 | ┊25┊26┊import { ChatComponent } from './chat.component'; 264 | ``` 265 | ```diff 266 | @@ -116,6 +117,7 @@ 267 | ┊116┊117┊ SharedModule, 268 | ┊117┊118┊ ApolloTestingModule, 269 | ┊118┊119┊ RouterTestingModule, 270 | +┊ ┊120┊ NgxSelectableListModule, 271 | ┊119┊121┊ ], 272 | ┊120┊122┊ providers: [ 273 | ┊121┊123┊ ChatsService, 274 | ``` 275 | 276 | ##### Changed src/app/chat-viewer/containers/chat/chat.component.ts 277 | ```diff 278 | @@ -13,7 +13,10 @@ 279 | ┊13┊13┊
{{ name }}
280 | ┊14┊14┊ 281 | ┊15┊15┊
282 | -┊16┊ ┊ 283 | +┊ ┊16┊ 285 | +┊ ┊18┊ 286 | +┊ ┊19┊ 287 | ┊17┊20┊ 288 | ┊18┊21┊
289 | ┊19┊22┊ `, 290 | ``` 291 | ```diff 292 | @@ -48,4 +51,8 @@ 293 | ┊48┊51┊ addMessage(content: string) { 294 | ┊49┊52┊ this.chatsService.addMessage(this.chatId, content).subscribe(); 295 | ┊50┊53┊ } 296 | +┊ ┊54┊ 297 | +┊ ┊55┊ deleteMessages(messageIds: string[]) { 298 | +┊ ┊56┊ this.chatsService.removeMessages(this.chatId, this.messages, messageIds).subscribe(); 299 | +┊ ┊57┊ } 300 | ┊51┊58┊} 301 | ``` 302 | 303 | ##### Changed src/app/chats-lister/containers/chats/chats.component.spec.ts 304 | ```diff 305 | @@ -15,6 +15,7 @@ 306 | ┊15┊15┊import { InMemoryCache } from 'apollo-cache-inmemory'; 307 | ┊16┊16┊import { By } from '@angular/platform-browser'; 308 | ┊17┊17┊import { RouterTestingModule } from '@angular/router/testing'; 309 | +┊ ┊18┊import { NgxSelectableListModule } from 'ngx-selectable-list'; 310 | ┊18┊19┊ 311 | ┊19┊20┊import { GetChats } from '../../../../graphql'; 312 | ┊20┊21┊import { dataIdFromObject } from '../../../graphql.module'; 313 | ``` 314 | ```diff 315 | @@ -332,6 +333,7 @@ 316 | ┊332┊333┊ MatListModule, 317 | ┊333┊334┊ ApolloTestingModule, 318 | ┊334┊335┊ RouterTestingModule, 319 | +┊ ┊336┊ NgxSelectableListModule, 320 | ┊335┊337┊ ], 321 | ┊336┊338┊ providers: [ 322 | ┊337┊339┊ ChatsService, 323 | ``` 324 | 325 | ##### Changed src/app/chats-lister/containers/chats/chats.component.ts 326 | ```diff 327 | @@ -28,9 +28,13 @@ 328 | ┊28┊28┊ 329 | ┊29┊29┊ 330 | ┊30┊30┊ 331 | -┊31┊ ┊ 332 | +┊ ┊31┊ 335 | +┊ ┊34┊ 336 | +┊ ┊35┊ 337 | ┊32┊36┊ 338 | -┊33┊ ┊ 342 | ┊36┊40┊ `, 343 | ``` 344 | ```diff 345 | @@ -38,6 +42,7 @@ 346 | ┊38┊42┊}) 347 | ┊39┊43┊export class ChatsComponent implements OnInit { 348 | ┊40┊44┊ chats$: Observable; 349 | +┊ ┊45┊ isSelecting = false; 350 | ┊41┊46┊ 351 | ┊42┊47┊ constructor(private chatsService: ChatsService, 352 | ┊43┊48┊ private router: Router) { 353 | ``` 354 | ```diff 355 | @@ -50,4 +55,10 @@ 356 | ┊50┊55┊ goToChat(chatId: string) { 357 | ┊51┊56┊ this.router.navigate(['/chat', chatId]); 358 | ┊52┊57┊ } 359 | +┊ ┊58┊ 360 | +┊ ┊59┊ deleteChats(chatIds: string[]) { 361 | +┊ ┊60┊ chatIds.forEach(chatId => { 362 | +┊ ┊61┊ this.chatsService.removeChat(chatId).subscribe(); 363 | +┊ ┊62┊ }); 364 | +┊ ┊63┊ } 365 | ┊53┊64┊} 366 | ``` 367 | 368 | [}]: # 369 | 370 | ### Connecting actions with the server 371 | 372 | The UI part is pretty much complete but we still need to implement two methods in the `ChatsService`. We're going to create mutations to remove a chat, selected messages or all of them. 373 | 374 | [{]: (diffStep "7.2" files="src/graphql/removeChat.mutation.ts, src/graphql/removeMessages.mutation.ts, src/graphql/removeAllMessages.mutation.ts" module="client") 375 | 376 | #### [Step 7.2: Remove messages and chats](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/31f9205) 377 | 378 | ##### Added src/graphql/removeAllMessages.mutation.ts 379 | ```diff 380 | @@ -0,0 +1,8 @@ 381 | +┊ ┊1┊import gql from 'graphql-tag'; 382 | +┊ ┊2┊ 383 | +┊ ┊3┊// We use the gql tag to parse our query string into a query document 384 | +┊ ┊4┊export const removeAllMessagesMutation = gql` 385 | +┊ ┊5┊ mutation RemoveAllMessages($chatId: ID!, $all: Boolean) { 386 | +┊ ┊6┊ removeMessages(chatId: $chatId, all: $all) 387 | +┊ ┊7┊ } 388 | +┊ ┊8┊`; 389 | ``` 390 | 391 | ##### Added src/graphql/removeChat.mutation.ts 392 | ```diff 393 | @@ -0,0 +1,8 @@ 394 | +┊ ┊1┊import gql from 'graphql-tag'; 395 | +┊ ┊2┊ 396 | +┊ ┊3┊// We use the gql tag to parse our query string into a query document 397 | +┊ ┊4┊export const removeChatMutation = gql` 398 | +┊ ┊5┊ mutation RemoveChat($chatId: ID!) { 399 | +┊ ┊6┊ removeChat(chatId: $chatId) 400 | +┊ ┊7┊ } 401 | +┊ ┊8┊`; 402 | ``` 403 | 404 | ##### Added src/graphql/removeMessages.mutation.ts 405 | ```diff 406 | @@ -0,0 +1,8 @@ 407 | +┊ ┊1┊import gql from 'graphql-tag'; 408 | +┊ ┊2┊ 409 | +┊ ┊3┊// We use the gql tag to parse our query string into a query document 410 | +┊ ┊4┊export const removeMessagesMutation = gql` 411 | +┊ ┊5┊ mutation RemoveMessages($chatId: ID!, $messageIds: [ID!]) { 412 | +┊ ┊6┊ removeMessages(chatId: $chatId, messageIds: $messageIds) 413 | +┊ ┊7┊ } 414 | +┊ ┊8┊`; 415 | ``` 416 | 417 | [}]: # 418 | 419 | Once again, we run the GraphQL Code Generator to get the GQL services: 420 | 421 | yarn generator 422 | 423 | Now we can import those services, create `removeChat` and `removeMessages` methods in which we call a mutation. 424 | 425 | [{]: (diffStep "7.2" files="src/app/services/chats.service.ts" module="client") 426 | 427 | #### [Step 7.2: Remove messages and chats](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/31f9205) 428 | 429 | ##### Changed src/app/services/chats.service.ts 430 | ```diff 431 | @@ -4,10 +4,16 @@ 432 | ┊ 4┊ 4┊ GetChatsGQL, 433 | ┊ 5┊ 5┊ GetChatGQL, 434 | ┊ 6┊ 6┊ AddMessageGQL, 435 | +┊ ┊ 7┊ RemoveChatGQL, 436 | +┊ ┊ 8┊ RemoveMessagesGQL, 437 | +┊ ┊ 9┊ RemoveAllMessagesGQL, 438 | ┊ 7┊10┊ AddMessage, 439 | ┊ 8┊11┊ GetChats, 440 | -┊ 9┊ ┊ GetChat 441 | +┊ ┊12┊ GetChat, 442 | +┊ ┊13┊ RemoveMessages, 443 | +┊ ┊14┊ RemoveAllMessages, 444 | ┊10┊15┊} from '../../graphql'; 445 | +┊ ┊16┊import { DataProxy } from 'apollo-cache'; 446 | ┊11┊17┊ 447 | ┊12┊18┊@Injectable() 448 | ┊13┊19┊export class ChatsService { 449 | ``` 450 | ```diff 451 | @@ -16,7 +22,10 @@ 452 | ┊16┊22┊ constructor( 453 | ┊17┊23┊ private getChatsGQL: GetChatsGQL, 454 | ┊18┊24┊ private getChatGQL: GetChatGQL, 455 | -┊19┊ ┊ private addMessageGQL: AddMessageGQL 456 | +┊ ┊25┊ private addMessageGQL: AddMessageGQL, 457 | +┊ ┊26┊ private removeChatGQL: RemoveChatGQL, 458 | +┊ ┊27┊ private removeMessagesGQL: RemoveMessagesGQL, 459 | +┊ ┊28┊ private removeAllMessagesGQL: RemoveAllMessagesGQL, 460 | ┊20┊29┊ ) {} 461 | ┊21┊30┊ 462 | ┊22┊31┊ getChats() { 463 | ``` 464 | ```diff 465 | @@ -92,4 +101,111 @@ 466 | ┊ 92┊101┊ } 467 | ┊ 93┊102┊ }); 468 | ┊ 94┊103┊ } 469 | +┊ ┊104┊ 470 | +┊ ┊105┊ removeChat(chatId: string) { 471 | +┊ ┊106┊ return this.removeChatGQL.mutate( 472 | +┊ ┊107┊ { 473 | +┊ ┊108┊ chatId, 474 | +┊ ┊109┊ }, { 475 | +┊ ┊110┊ update: (store, { data: { removeChat } }) => { 476 | +┊ ┊111┊ // Read the data from our cache for this query. 477 | +┊ ┊112┊ const {chats} = store.readQuery({ 478 | +┊ ┊113┊ query: this.getChatsGQL.document, 479 | +┊ ┊114┊ variables: { 480 | +┊ ┊115┊ amount: this.messagesAmount, 481 | +┊ ┊116┊ }, 482 | +┊ ┊117┊ }); 483 | +┊ ┊118┊ // Remove the chat (mutable) 484 | +┊ ┊119┊ for (const index of chats.keys()) { 485 | +┊ ┊120┊ if (chats[index].id === removeChat) { 486 | +┊ ┊121┊ chats.splice(index, 1); 487 | +┊ ┊122┊ } 488 | +┊ ┊123┊ } 489 | +┊ ┊124┊ // Write our data back to the cache. 490 | +┊ ┊125┊ store.writeQuery({ 491 | +┊ ┊126┊ query: this.getChatsGQL.document, 492 | +┊ ┊127┊ variables: { 493 | +┊ ┊128┊ amount: this.messagesAmount, 494 | +┊ ┊129┊ }, 495 | +┊ ┊130┊ data: { 496 | +┊ ┊131┊ chats, 497 | +┊ ┊132┊ }, 498 | +┊ ┊133┊ }); 499 | +┊ ┊134┊ }, 500 | +┊ ┊135┊ } 501 | +┊ ┊136┊ ); 502 | +┊ ┊137┊ } 503 | +┊ ┊138┊ 504 | +┊ ┊139┊ removeMessages(chatId: string, messages: GetChat.Messages[], messageIdsOrAll: string[] | boolean) { 505 | +┊ ┊140┊ let ids: string[] = []; 506 | +┊ ┊141┊ 507 | +┊ ┊142┊ const options = { 508 | +┊ ┊143┊ update: (store: DataProxy, { data: { removeMessages } }: {data: RemoveMessages.Mutation | RemoveAllMessages.Mutation}) => { 509 | +┊ ┊144┊ // Update the messages cache 510 | +┊ ┊145┊ { 511 | +┊ ┊146┊ // Read the data from our cache for this query. 512 | +┊ ┊147┊ const {chat} = store.readQuery({ 513 | +┊ ┊148┊ query: this.getChatGQL.document, 514 | +┊ ┊149┊ variables: { 515 | +┊ ┊150┊ chatId, 516 | +┊ ┊151┊ } 517 | +┊ ┊152┊ }); 518 | +┊ ┊153┊ // Remove the messages (mutable) 519 | +┊ ┊154┊ removeMessages.forEach(messageId => { 520 | +┊ ┊155┊ for (const index of chat.messages.keys()) { 521 | +┊ ┊156┊ if (chat.messages[index].id === messageId) { 522 | +┊ ┊157┊ chat.messages.splice(index, 1); 523 | +┊ ┊158┊ } 524 | +┊ ┊159┊ } 525 | +┊ ┊160┊ }); 526 | +┊ ┊161┊ // Write our data back to the cache. 527 | +┊ ┊162┊ store.writeQuery({ 528 | +┊ ┊163┊ query: this.getChatGQL.document, 529 | +┊ ┊164┊ data: { 530 | +┊ ┊165┊ chat 531 | +┊ ┊166┊ } 532 | +┊ ┊167┊ }); 533 | +┊ ┊168┊ } 534 | +┊ ┊169┊ // Update last message cache 535 | +┊ ┊170┊ { 536 | +┊ ┊171┊ // Read the data from our cache for this query. 537 | +┊ ┊172┊ const {chats} = store.readQuery({ 538 | +┊ ┊173┊ query: this.getChatsGQL.document, 539 | +┊ ┊174┊ variables: { 540 | +┊ ┊175┊ amount: this.messagesAmount, 541 | +┊ ┊176┊ }, 542 | +┊ ┊177┊ }); 543 | +┊ ┊178┊ // Fix last message 544 | +┊ ┊179┊ chats.find(chat => chat.id === chatId).messages = messages 545 | +┊ ┊180┊ .filter(message => !ids.includes(message.id)); 546 | +┊ ┊181┊ // Write our data back to the cache. 547 | +┊ ┊182┊ store.writeQuery({ 548 | +┊ ┊183┊ query: this.getChatsGQL.document, 549 | +┊ ┊184┊ variables: { 550 | +┊ ┊185┊ amount: this.messagesAmount, 551 | +┊ ┊186┊ }, 552 | +┊ ┊187┊ data: { 553 | +┊ ┊188┊ chats, 554 | +┊ ┊189┊ }, 555 | +┊ ┊190┊ }); 556 | +┊ ┊191┊ } 557 | +┊ ┊192┊ } 558 | +┊ ┊193┊ }; 559 | +┊ ┊194┊ 560 | +┊ ┊195┊ if (typeof messageIdsOrAll === 'boolean') { 561 | +┊ ┊196┊ ids = messages.map(message => message.id); 562 | +┊ ┊197┊ 563 | +┊ ┊198┊ return this.removeAllMessagesGQL.mutate({ 564 | +┊ ┊199┊ chatId, 565 | +┊ ┊200┊ all: messageIdsOrAll 566 | +┊ ┊201┊ }, options); 567 | +┊ ┊202┊ } else { 568 | +┊ ┊203┊ ids = messageIdsOrAll; 569 | +┊ ┊204┊ 570 | +┊ ┊205┊ return this.removeMessagesGQL.mutate({ 571 | +┊ ┊206┊ chatId, 572 | +┊ ┊207┊ messageIds: messageIdsOrAll, 573 | +┊ ┊208┊ }, options); 574 | +┊ ┊209┊ } 575 | +┊ ┊210┊ } 576 | ┊ 95┊211┊} 577 | ``` 578 | 579 | [}]: # 580 | 581 | It might seem like a lot but we simply just updating the store there. 582 | 583 | ### Summary 584 | 585 | The selectable list directive supports much more different use cases, for info please read the documentation. 586 | 587 | As you can see `ngx-selectable-list` takes care of most of the boilerplate, giving us the freedom to concentrate on the actual code. 588 | 589 | 590 | [//]: # (foot-start) 591 | 592 | [{]: (navStep) 593 | 594 | | [< Previous Step](https://github.com/Urigo/whatsapp-textrepo-angularcli-express/tree/master@2.0.0/.tortilla/manuals/views/step6.md) | [Next Step >](https://github.com/Urigo/whatsapp-textrepo-angularcli-express/tree/master@2.0.0/.tortilla/manuals/views/step8.md) | 595 | |:--------------------------------|--------------------------------:| 596 | 597 | [}]: # 598 | -------------------------------------------------------------------------------- /.tortilla/manuals/views/step9.md: -------------------------------------------------------------------------------- 1 | # Step 9: Zero latency on slow 3g networks 2 | 3 | [//]: # (head-end) 4 | 5 | 6 | ## Client 7 | 8 | Now let's start our client in production mode: 9 | 10 | yarn start --prod 11 | 12 | Now open the **Chrome Developers Tools** and, in the **Network tab**, select `Slow 3G Network` and `Disable cache`. 13 | Then refresh the page and look at the `DOMContentLoaded` time and at the transferred size. You'll notice that our bundle size is quite small and so the loads time. 14 | 15 | Now let's click on a specific chat. It will take some time to load the data and then add a new message. 16 | Once again it will take some time to load the data. 17 | We could also create a new chat and the result would be the same. 18 | The whole app doesn't feel as snappier as the real Whatsapp on a slow 3G Network. 19 | 20 | "That's normal, it's a web application with a remote db while Whatsapp is a native app with a local database..." 21 | That's just an excuse, because we can do as good as Whatsapp thanks to Apollo! 22 | 23 | Let's install `moment`, we will soon need it: 24 | 25 | yarn add moment 26 | 27 | Start by making our UI optimistic. We can predict most of the response we will get from our server, except for a few things like `id` of newly created messages. But since we don't really need that id, we can simply generate a fake one 28 | which will be later overridden once we get the response from the server: 29 | 30 | [{]: (diffStep "9.1" files="^\(?!package.json$|yarn.lock$\).*" module="client") 31 | 32 | #### [Step 9.1: Optimistic updates](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/7030470) 33 | 34 | ##### Changed src/app/chats-creation/containers/new-chat/new-chat.component.ts 35 | ```diff 36 | @@ -47,7 +47,7 @@ 37 | ┊47┊47┊ // Chat is already listed for the current user 38 | ┊48┊48┊ this.router.navigate(['/chat', chatId]); 39 | ┊49┊49┊ } else { 40 | -┊50┊ ┊ this.chatsService.addChat(userId).subscribe(({data: {addChat: {id}}}: { data: AddChat.Mutation }) => { 41 | +┊ ┊50┊ this.chatsService.addChat(userId, this.users).subscribe(({data: {addChat: {id}}}: { data: AddChat.Mutation }) => { 42 | ┊51┊51┊ this.router.navigate(['/chat', id]); 43 | ┊52┊52┊ }); 44 | ┊53┊53┊ } 45 | ``` 46 | 47 | ##### Changed src/app/services/chats.service.ts 48 | ```diff 49 | @@ -2,6 +2,7 @@ 50 | ┊2┊2┊import {Injectable} from '@angular/core'; 51 | ┊3┊3┊import {Observable} from 'rxjs'; 52 | ┊4┊4┊import {QueryRef} from 'apollo-angular'; 53 | +┊ ┊5┊import * as moment from 'moment'; 54 | ┊5┊6┊import { 55 | ┊6┊7┊ GetChatsGQL, 56 | ┊7┊8┊ GetChatGQL, 57 | ``` 58 | ```diff 59 | @@ -17,10 +18,12 @@ 60 | ┊17┊18┊ GetChat, 61 | ┊18┊19┊ RemoveMessages, 62 | ┊19┊20┊ RemoveAllMessages, 63 | +┊ ┊21┊ GetUsers, 64 | ┊20┊22┊} from '../../graphql'; 65 | ┊21┊23┊import { DataProxy } from 'apollo-cache'; 66 | ┊22┊24┊ 67 | ┊23┊25┊const currentUserId = '1'; 68 | +┊ ┊26┊const currentUserName = 'Ethan Gonzalez'; 69 | ┊24┊27┊ 70 | ┊25┊28┊@Injectable() 71 | ┊26┊29┊export class ChatsService { 72 | ``` 73 | ```diff 74 | @@ -49,6 +52,10 @@ 75 | ┊49┊52┊ this.chats$.subscribe(chats => this.chats = chats); 76 | ┊50┊53┊ } 77 | ┊51┊54┊ 78 | +┊ ┊55┊ static getRandomId() { 79 | +┊ ┊56┊ return String(Math.round(Math.random() * 1000000000000)); 80 | +┊ ┊57┊ } 81 | +┊ ┊58┊ 82 | ┊52┊59┊ getChats() { 83 | ┊53┊60┊ return {query: this.getChatsWq, chats$: this.chats$}; 84 | ┊54┊61┊ } 85 | ``` 86 | ```diff 87 | @@ -70,6 +77,27 @@ 88 | ┊ 70┊ 77┊ chatId, 89 | ┊ 71┊ 78┊ content, 90 | ┊ 72┊ 79┊ }, { 91 | +┊ ┊ 80┊ optimisticResponse: { 92 | +┊ ┊ 81┊ __typename: 'Mutation', 93 | +┊ ┊ 82┊ addMessage: { 94 | +┊ ┊ 83┊ __typename: 'Message', 95 | +┊ ┊ 84┊ id: ChatsService.getRandomId(), 96 | +┊ ┊ 85┊ chat: { 97 | +┊ ┊ 86┊ __typename: 'Chat', 98 | +┊ ┊ 87┊ id: chatId, 99 | +┊ ┊ 88┊ }, 100 | +┊ ┊ 89┊ sender: { 101 | +┊ ┊ 90┊ __typename: 'User', 102 | +┊ ┊ 91┊ id: currentUserId, 103 | +┊ ┊ 92┊ name: currentUserName, 104 | +┊ ┊ 93┊ }, 105 | +┊ ┊ 94┊ content, 106 | +┊ ┊ 95┊ createdAt: moment().unix(), 107 | +┊ ┊ 96┊ type: 1, 108 | +┊ ┊ 97┊ recipients: [], 109 | +┊ ┊ 98┊ ownership: true, 110 | +┊ ┊ 99┊ }, 111 | +┊ ┊100┊ }, 112 | ┊ 73┊101┊ update: (store, { data: { addMessage } }: {data: AddMessage.Mutation}) => { 113 | ┊ 74┊102┊ // Update the messages cache 114 | ┊ 75┊103┊ { 115 | ``` 116 | ```diff 117 | @@ -121,6 +149,10 @@ 118 | ┊121┊149┊ { 119 | ┊122┊150┊ chatId, 120 | ┊123┊151┊ }, { 121 | +┊ ┊152┊ optimisticResponse: { 122 | +┊ ┊153┊ __typename: 'Mutation', 123 | +┊ ┊154┊ removeChat: chatId, 124 | +┊ ┊155┊ }, 125 | ┊124┊156┊ update: (store, { data: { removeChat } }) => { 126 | ┊125┊157┊ // Read the data from our cache for this query. 127 | ┊126┊158┊ const {chats} = store.readQuery({ 128 | ``` 129 | ```diff 130 | @@ -154,6 +186,10 @@ 131 | ┊154┊186┊ let ids: string[] = []; 132 | ┊155┊187┊ 133 | ┊156┊188┊ const options = { 134 | +┊ ┊189┊ optimisticResponse: () => ({ 135 | +┊ ┊190┊ __typename: 'Mutation', 136 | +┊ ┊191┊ removeMessages: ids, 137 | +┊ ┊192┊ }), 138 | ┊157┊193┊ update: (store: DataProxy, { data: { removeMessages } }: {data: RemoveMessages.Mutation | RemoveAllMessages.Mutation}) => { 139 | ┊158┊194┊ // Update the messages cache 140 | ┊159┊195┊ { 141 | ``` 142 | ```diff 143 | @@ -241,11 +277,33 @@ 144 | ┊241┊277┊ return _chat ? _chat.id : false; 145 | ┊242┊278┊ } 146 | ┊243┊279┊ 147 | -┊244┊ ┊ addChat(userId: string) { 148 | +┊ ┊280┊ addChat(userId: string, users: GetUsers.Users[]) { 149 | ┊245┊281┊ return this.addChatGQL.mutate( 150 | ┊246┊282┊ { 151 | ┊247┊283┊ userId, 152 | ┊248┊284┊ }, { 153 | +┊ ┊285┊ optimisticResponse: { 154 | +┊ ┊286┊ __typename: 'Mutation', 155 | +┊ ┊287┊ addChat: { 156 | +┊ ┊288┊ __typename: 'Chat', 157 | +┊ ┊289┊ id: ChatsService.getRandomId(), 158 | +┊ ┊290┊ name: users.find(user => user.id === userId).name, 159 | +┊ ┊291┊ picture: users.find(user => user.id === userId).picture, 160 | +┊ ┊292┊ allTimeMembers: [ 161 | +┊ ┊293┊ { 162 | +┊ ┊294┊ id: currentUserId, 163 | +┊ ┊295┊ __typename: 'User', 164 | +┊ ┊296┊ }, 165 | +┊ ┊297┊ { 166 | +┊ ┊298┊ id: userId, 167 | +┊ ┊299┊ __typename: 'User', 168 | +┊ ┊300┊ } 169 | +┊ ┊301┊ ], 170 | +┊ ┊302┊ unreadMessages: 0, 171 | +┊ ┊303┊ messages: [], 172 | +┊ ┊304┊ isGroup: false, 173 | +┊ ┊305┊ }, 174 | +┊ ┊306┊ }, 175 | ┊249┊307┊ update: (store, { data: { addChat } }) => { 176 | ┊250┊308┊ // Read the data from our cache for this query. 177 | ┊251┊309┊ const {chats} = store.readQuery({ 178 | ``` 179 | ```diff 180 | @@ -277,6 +335,26 @@ 181 | ┊277┊335┊ userIds, 182 | ┊278┊336┊ groupName, 183 | ┊279┊337┊ }, { 184 | +┊ ┊338┊ optimisticResponse: { 185 | +┊ ┊339┊ __typename: 'Mutation', 186 | +┊ ┊340┊ addGroup: { 187 | +┊ ┊341┊ __typename: 'Chat', 188 | +┊ ┊342┊ id: ChatsService.getRandomId(), 189 | +┊ ┊343┊ name: groupName, 190 | +┊ ┊344┊ picture: 'https://randomuser.me/api/portraits/thumb/lego/1.jpg', 191 | +┊ ┊345┊ userIds: [currentUserId, userIds], 192 | +┊ ┊346┊ allTimeMembers: [ 193 | +┊ ┊347┊ { 194 | +┊ ┊348┊ id: currentUserId, 195 | +┊ ┊349┊ __typename: 'User', 196 | +┊ ┊350┊ }, 197 | +┊ ┊351┊ ...userIds.map(id => ({id, __typename: 'User'})), 198 | +┊ ┊352┊ ], 199 | +┊ ┊353┊ unreadMessages: 0, 200 | +┊ ┊354┊ messages: [], 201 | +┊ ┊355┊ isGroup: true, 202 | +┊ ┊356┊ }, 203 | +┊ ┊357┊ }, 204 | ┊280┊358┊ update: (store, { data: { addGroup } }) => { 205 | ┊281┊359┊ // Read the data from our cache for this query. 206 | ┊282┊360┊ const {chats} = store.readQuery({ 207 | ``` 208 | 209 | [}]: # 210 | 211 | When we open a specific chat we can also preload some data from our chats list cache while waiting for the server response. We will initially be able to show only the chat name, the last message or the last few messages and a few more informations instead of the whole content from the server, but that would be more than enough to entertain the user while waiting for the server response: 212 | 213 | [{]: (diffStep "9.2" module="client") 214 | 215 | #### [Step 9.2: Get chat data from chats cache while waiting for server response](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/d672772) 216 | 217 | ##### Changed src/app/services/chats.service.ts 218 | ```diff 219 | @@ -1,6 +1,6 @@ 220 | -┊1┊ ┊import {map} from 'rxjs/operators'; 221 | +┊ ┊1┊import {concat, map} from 'rxjs/operators'; 222 | ┊2┊2┊import {Injectable} from '@angular/core'; 223 | -┊3┊ ┊import {Observable} from 'rxjs'; 224 | +┊ ┊3┊import {Observable, AsyncSubject, of} from 'rxjs'; 225 | ┊4┊4┊import {QueryRef} from 'apollo-angular'; 226 | ┊5┊5┊import * as moment from 'moment'; 227 | ┊6┊6┊import { 228 | ``` 229 | ```diff 230 | @@ -31,6 +31,7 @@ 231 | ┊31┊31┊ getChatsWq: QueryRef; 232 | ┊32┊32┊ chats$: Observable; 233 | ┊33┊33┊ chats: GetChats.Chats[]; 234 | +┊ ┊34┊ getChatWqSubject: AsyncSubject>; 235 | ┊34┊35┊ 236 | ┊35┊36┊ constructor( 237 | ┊36┊37┊ private getChatsGQL: GetChatsGQL, 238 | ``` 239 | ```diff 240 | @@ -61,15 +62,34 @@ 241 | ┊61┊62┊ } 242 | ┊62┊63┊ 243 | ┊63┊64┊ getChat(chatId: string) { 244 | +┊ ┊65┊ const _chat = this.chats && this.chats.find(chat => chat.id === chatId) || { 245 | +┊ ┊66┊ id: chatId, 246 | +┊ ┊67┊ name: '', 247 | +┊ ┊68┊ picture: null, 248 | +┊ ┊69┊ allTimeMembers: [], 249 | +┊ ┊70┊ unreadMessages: 0, 250 | +┊ ┊71┊ isGroup: false, 251 | +┊ ┊72┊ messages: [], 252 | +┊ ┊73┊ }; 253 | +┊ ┊74┊ const chat$FromCache = of(_chat); 254 | +┊ ┊75┊ 255 | ┊64┊76┊ const query = this.getChatGQL.watch({ 256 | ┊65┊77┊ chatId: chatId, 257 | ┊66┊78┊ }); 258 | ┊67┊79┊ 259 | -┊68┊ ┊ const chat$ = query.valueChanges.pipe( 260 | -┊69┊ ┊ map((result) => result.data.chat) 261 | +┊ ┊80┊ const chat$ = chat$FromCache.pipe( 262 | +┊ ┊81┊ concat( 263 | +┊ ┊82┊ query.valueChanges.pipe( 264 | +┊ ┊83┊ map((result) => result.data.chat) 265 | +┊ ┊84┊ ) 266 | +┊ ┊85┊ ) 267 | ┊70┊86┊ ); 268 | ┊71┊87┊ 269 | -┊72┊ ┊ return {query, chat$}; 270 | +┊ ┊88┊ this.getChatWqSubject = new AsyncSubject(); 271 | +┊ ┊89┊ this.getChatWqSubject.next(query); 272 | +┊ ┊90┊ this.getChatWqSubject.complete(); 273 | +┊ ┊91┊ 274 | +┊ ┊92┊ return {query$: this.getChatWqSubject.asObservable(), chat$}; 275 | ┊73┊93┊ } 276 | ┊74┊94┊ 277 | ┊75┊95┊ addMessage(chatId: string, content: string) { 278 | ``` 279 | 280 | [}]: # 281 | 282 | Now let's deal with the most difficult part, chats creation. 283 | 284 | We cannot predict the `id` of the new chat and so we cannot navigate to the chat page because it contains the chat id in the url. We could simply navigate to the "optimistic" id, but then the user wouldn't be able to reach that url if he refreshes the page or bookmarks it. That's a problem we care about. 285 | 286 | How to solve it? We're going to create a landing page and we will later override the url once we get the response from the server! 287 | 288 | [{]: (diffStep "9.3" module="client") 289 | 290 | #### [Step 9.3: Landing page for new chats/groups](https://github.com/Urigo/WhatsApp-Clone-Client-Angular/commit/3ea9109) 291 | 292 | ##### Changed src/app/chat-viewer/containers/chat/chat.component.spec.ts 293 | ```diff 294 | @@ -129,7 +129,10 @@ 295 | ┊129┊129┊ }, 296 | ┊130┊130┊ { 297 | ┊131┊131┊ provide: ActivatedRoute, 298 | -┊132┊ ┊ useValue: { params: of({ id: chat.id }) }, 299 | +┊ ┊132┊ useValue: { 300 | +┊ ┊133┊ params: of({ id: chat.id }), 301 | +┊ ┊134┊ queryParams: of({}), 302 | +┊ ┊135┊ }, 303 | ┊133┊136┊ }, 304 | ┊134┊137┊ ], 305 | ┊135┊138┊ schemas: [NO_ERRORS_SCHEMA], 306 | ``` 307 | 308 | ##### Changed src/app/chat-viewer/containers/chat/chat.component.ts 309 | ```diff 310 | @@ -1,7 +1,9 @@ 311 | ┊1┊1┊import {Component, OnInit} from '@angular/core'; 312 | ┊2┊2┊import {ActivatedRoute, Router} from '@angular/router'; 313 | ┊3┊3┊import {ChatsService} from '../../../services/chats.service'; 314 | -┊4┊ ┊import {GetChat} from '../../../../graphql'; 315 | +┊ ┊4┊import {AddChat, AddGroup, GetChat} from '../../../../graphql'; 316 | +┊ ┊5┊import {combineLatest} from 'rxjs'; 317 | +┊ ┊6┊import {Location} from '@angular/common'; 318 | ┊5┊7┊ 319 | ┊6┊8┊@Component({ 320 | ┊7┊9┊ template: ` 321 | ``` 322 | ```diff 323 | @@ -27,21 +29,40 @@ 324 | ┊27┊29┊ messages: GetChat.Messages[]; 325 | ┊28┊30┊ name: string; 326 | ┊29┊31┊ isGroup: boolean; 327 | +┊ ┊32┊ optimisticUI: boolean; 328 | ┊30┊33┊ 329 | ┊31┊34┊ constructor(private route: ActivatedRoute, 330 | ┊32┊35┊ private router: Router, 331 | +┊ ┊36┊ private location: Location, 332 | ┊33┊37┊ private chatsService: ChatsService) { 333 | ┊34┊38┊ } 334 | ┊35┊39┊ 335 | ┊36┊40┊ ngOnInit() { 336 | -┊37┊ ┊ this.route.params.subscribe(({id: chatId}) => { 337 | -┊38┊ ┊ this.chatId = chatId; 338 | -┊39┊ ┊ this.chatsService.getChat(chatId).chat$.subscribe(chat => { 339 | -┊40┊ ┊ this.messages = chat.messages; 340 | -┊41┊ ┊ this.name = chat.name; 341 | -┊42┊ ┊ this.isGroup = chat.isGroup; 342 | +┊ ┊41┊ combineLatest(this.route.params, this.route.queryParams, 343 | +┊ ┊42┊ (params: { id: string }, queryParams: { oui?: boolean }) => ({params, queryParams})) 344 | +┊ ┊43┊ .subscribe(({params: {id: chatId}, queryParams: {oui}}) => { 345 | +┊ ┊44┊ this.chatId = chatId; 346 | +┊ ┊45┊ 347 | +┊ ┊46┊ this.optimisticUI = oui; 348 | +┊ ┊47┊ 349 | +┊ ┊48┊ if (this.optimisticUI) { 350 | +┊ ┊49┊ // We are using fake IDs generated by the Optimistic UI 351 | +┊ ┊50┊ this.chatsService.addChat$.subscribe(({data}) => { 352 | +┊ ┊51┊ this.chatId = (data).addChat ? (data).addChat.id : (data).addGroup.id; 353 | +┊ ┊52┊ console.log(`Switching from the Optimistic UI id ${chatId} to ${this.chatId}`); 354 | +┊ ┊53┊ // Rewrite the URL 355 | +┊ ┊54┊ this.location.go(`chat/${this.chatId}`); 356 | +┊ ┊55┊ // Optimistic UI no more 357 | +┊ ┊56┊ this.optimisticUI = false; 358 | +┊ ┊57┊ }); 359 | +┊ ┊58┊ } 360 | +┊ ┊59┊ 361 | +┊ ┊60┊ this.chatsService.getChat(chatId, this.optimisticUI).chat$.subscribe(chat => { 362 | +┊ ┊61┊ this.messages = chat.messages; 363 | +┊ ┊62┊ this.name = chat.name; 364 | +┊ ┊63┊ this.isGroup = chat.isGroup; 365 | +┊ ┊64┊ }); 366 | ┊43┊65┊ }); 367 | -┊44┊ ┊ }); 368 | ┊45┊66┊ } 369 | ┊46┊67┊ 370 | ┊47┊68┊ goToChats() { 371 | ``` 372 | 373 | ##### Changed src/app/chats-creation/containers/new-chat/new-chat.component.ts 374 | ```diff 375 | @@ -47,9 +47,10 @@ 376 | ┊47┊47┊ // Chat is already listed for the current user 377 | ┊48┊48┊ this.router.navigate(['/chat', chatId]); 378 | ┊49┊49┊ } else { 379 | -┊50┊ ┊ this.chatsService.addChat(userId, this.users).subscribe(({data: {addChat: {id}}}: { data: AddChat.Mutation }) => { 380 | -┊51┊ ┊ this.router.navigate(['/chat', id]); 381 | -┊52┊ ┊ }); 382 | +┊ ┊50┊ // Generate id for Optimistic UI 383 | +┊ ┊51┊ const ouiId = ChatsService.getRandomId(); 384 | +┊ ┊52┊ this.chatsService.addChat(userId, this.users, ouiId).subscribe(); 385 | +┊ ┊53┊ this.router.navigate(['/chat', ouiId], {queryParams: {oui: true}, skipLocationChange: true}); 386 | ┊53┊54┊ } 387 | ┊54┊55┊ } 388 | ┊55┊56┊} 389 | ``` 390 | 391 | ##### Changed src/app/chats-creation/containers/new-group/new-group.component.ts 392 | ```diff 393 | @@ -52,9 +52,9 @@ 394 | ┊52┊52┊ 395 | ┊53┊53┊ addGroup(groupName: string) { 396 | ┊54┊54┊ if (groupName && this.userIds.length) { 397 | -┊55┊ ┊ this.chatsService.addGroup(this.userIds, groupName).subscribe(({data: {addGroup: {id}}}: { data: AddGroup.Mutation }) => { 398 | -┊56┊ ┊ this.router.navigate(['/chat', id]); 399 | -┊57┊ ┊ }); 400 | +┊ ┊55┊ const ouiId = ChatsService.getRandomId(); 401 | +┊ ┊56┊ this.chatsService.addGroup(this.userIds, groupName, ouiId).subscribe(); 402 | +┊ ┊57┊ this.router.navigate(['/chat', ouiId], {queryParams: {oui: true}, skipLocationChange: true}); 403 | ┊58┊58┊ } 404 | ┊59┊59┊ } 405 | ┊60┊60┊} 406 | ``` 407 | 408 | ##### Changed src/app/services/chats.service.ts 409 | ```diff 410 | @@ -1,4 +1,4 @@ 411 | -┊1┊ ┊import {concat, map} from 'rxjs/operators'; 412 | +┊ ┊1┊import {concat, map, share, switchMap} from 'rxjs/operators'; 413 | ┊2┊2┊import {Injectable} from '@angular/core'; 414 | ┊3┊3┊import {Observable, AsyncSubject, of} from 'rxjs'; 415 | ┊4┊4┊import {QueryRef} from 'apollo-angular'; 416 | ``` 417 | ```diff 418 | @@ -19,8 +19,11 @@ 419 | ┊19┊19┊ RemoveMessages, 420 | ┊20┊20┊ RemoveAllMessages, 421 | ┊21┊21┊ GetUsers, 422 | +┊ ┊22┊ AddChat, 423 | +┊ ┊23┊ AddGroup, 424 | ┊22┊24┊} from '../../graphql'; 425 | ┊23┊25┊import { DataProxy } from 'apollo-cache'; 426 | +┊ ┊26┊import { FetchResult } from 'apollo-link'; 427 | ┊24┊27┊ 428 | ┊25┊28┊const currentUserId = '1'; 429 | ┊26┊29┊const currentUserName = 'Ethan Gonzalez'; 430 | ``` 431 | ```diff 432 | @@ -32,6 +35,7 @@ 433 | ┊32┊35┊ chats$: Observable; 434 | ┊33┊36┊ chats: GetChats.Chats[]; 435 | ┊34┊37┊ getChatWqSubject: AsyncSubject>; 436 | +┊ ┊38┊ addChat$: Observable>; 437 | ┊35┊39┊ 438 | ┊36┊40┊ constructor( 439 | ┊37┊41┊ private getChatsGQL: GetChatsGQL, 440 | ``` 441 | ```diff 442 | @@ -61,7 +65,7 @@ 443 | ┊61┊65┊ return {query: this.getChatsWq, chats$: this.chats$}; 444 | ┊62┊66┊ } 445 | ┊63┊67┊ 446 | -┊64┊ ┊ getChat(chatId: string) { 447 | +┊ ┊68┊ getChat(chatId: string, oui?: boolean) { 448 | ┊65┊69┊ const _chat = this.chats && this.chats.find(chat => chat.id === chatId) || { 449 | ┊66┊70┊ id: chatId, 450 | ┊67┊71┊ name: '', 451 | ``` 452 | ```diff 453 | @@ -73,21 +77,44 @@ 454 | ┊ 73┊ 77┊ }; 455 | ┊ 74┊ 78┊ const chat$FromCache = of(_chat); 456 | ┊ 75┊ 79┊ 457 | -┊ 76┊ ┊ const query = this.getChatGQL.watch({ 458 | -┊ 77┊ ┊ chatId: chatId, 459 | -┊ 78┊ ┊ }); 460 | -┊ 79┊ ┊ 461 | -┊ 80┊ ┊ const chat$ = chat$FromCache.pipe( 462 | -┊ 81┊ ┊ concat( 463 | -┊ 82┊ ┊ query.valueChanges.pipe( 464 | -┊ 83┊ ┊ map((result) => result.data.chat) 465 | -┊ 84┊ ┊ ) 466 | -┊ 85┊ ┊ ) 467 | -┊ 86┊ ┊ ); 468 | +┊ ┊ 80┊ const getApolloWatchQuery = (id: string) => { 469 | +┊ ┊ 81┊ return this.getChatGQL.watch({ 470 | +┊ ┊ 82┊ chatId: id, 471 | +┊ ┊ 83┊ }); 472 | +┊ ┊ 84┊ }; 473 | ┊ 87┊ 85┊ 474 | +┊ ┊ 86┊ let chat$: Observable; 475 | ┊ 88┊ 87┊ this.getChatWqSubject = new AsyncSubject(); 476 | -┊ 89┊ ┊ this.getChatWqSubject.next(query); 477 | -┊ 90┊ ┊ this.getChatWqSubject.complete(); 478 | +┊ ┊ 88┊ 479 | +┊ ┊ 89┊ if (oui) { 480 | +┊ ┊ 90┊ chat$ = chat$FromCache.pipe( 481 | +┊ ┊ 91┊ concat(this.addChat$.pipe( 482 | +┊ ┊ 92┊ switchMap(({ data }) => { 483 | +┊ ┊ 93┊ const id = (data).addChat ? (data).addChat.id : (data).addGroup.id; 484 | +┊ ┊ 94┊ const query = getApolloWatchQuery(id); 485 | +┊ ┊ 95┊ 486 | +┊ ┊ 96┊ this.getChatWqSubject.next(query); 487 | +┊ ┊ 97┊ this.getChatWqSubject.complete(); 488 | +┊ ┊ 98┊ 489 | +┊ ┊ 99┊ return query.valueChanges.pipe( 490 | +┊ ┊100┊ map((result) => result.data.chat) 491 | +┊ ┊101┊ ); 492 | +┊ ┊102┊ })) 493 | +┊ ┊103┊ )); 494 | +┊ ┊104┊ } else { 495 | +┊ ┊105┊ const query = getApolloWatchQuery(chatId); 496 | +┊ ┊106┊ 497 | +┊ ┊107┊ this.getChatWqSubject.next(query); 498 | +┊ ┊108┊ this.getChatWqSubject.complete(); 499 | +┊ ┊109┊ 500 | +┊ ┊110┊ chat$ = chat$FromCache.pipe( 501 | +┊ ┊111┊ concat( 502 | +┊ ┊112┊ query.valueChanges.pipe( 503 | +┊ ┊113┊ map((result) => result.data.chat) 504 | +┊ ┊114┊ ) 505 | +┊ ┊115┊ ) 506 | +┊ ┊116┊ ); 507 | +┊ ┊117┊ } 508 | ┊ 91┊118┊ 509 | ┊ 92┊119┊ return {query$: this.getChatWqSubject.asObservable(), chat$}; 510 | ┊ 93┊120┊ } 511 | ``` 512 | ```diff 513 | @@ -297,8 +324,8 @@ 514 | ┊297┊324┊ return _chat ? _chat.id : false; 515 | ┊298┊325┊ } 516 | ┊299┊326┊ 517 | -┊300┊ ┊ addChat(userId: string, users: GetUsers.Users[]) { 518 | -┊301┊ ┊ return this.addChatGQL.mutate( 519 | +┊ ┊327┊ addChat(userId: string, users: GetUsers.Users[], ouiId: string) { 520 | +┊ ┊328┊ this.addChat$ = this.addChatGQL.mutate( 521 | ┊302┊329┊ { 522 | ┊303┊330┊ userId, 523 | ┊304┊331┊ }, { 524 | ``` 525 | ```diff 526 | @@ -306,7 +333,7 @@ 527 | ┊306┊333┊ __typename: 'Mutation', 528 | ┊307┊334┊ addChat: { 529 | ┊308┊335┊ __typename: 'Chat', 530 | -┊309┊ ┊ id: ChatsService.getRandomId(), 531 | +┊ ┊336┊ id: ouiId, 532 | ┊310┊337┊ name: users.find(user => user.id === userId).name, 533 | ┊311┊338┊ picture: users.find(user => user.id === userId).picture, 534 | ┊312┊339┊ allTimeMembers: [ 535 | ``` 536 | ```diff 537 | @@ -346,11 +373,12 @@ 538 | ┊346┊373┊ }); 539 | ┊347┊374┊ }, 540 | ┊348┊375┊ } 541 | -┊349┊ ┊ ); 542 | +┊ ┊376┊ ).pipe(share()); 543 | +┊ ┊377┊ return this.addChat$; 544 | ┊350┊378┊ } 545 | ┊351┊379┊ 546 | -┊352┊ ┊ addGroup(userIds: string[], groupName: string) { 547 | -┊353┊ ┊ return this.addGroupGQL.mutate( 548 | +┊ ┊380┊ addGroup(userIds: string[], groupName: string, ouiId: string) { 549 | +┊ ┊381┊ this.addChat$ = this.addGroupGQL.mutate( 550 | ┊354┊382┊ { 551 | ┊355┊383┊ userIds, 552 | ┊356┊384┊ groupName, 553 | ``` 554 | ```diff 555 | @@ -359,7 +387,7 @@ 556 | ┊359┊387┊ __typename: 'Mutation', 557 | ┊360┊388┊ addGroup: { 558 | ┊361┊389┊ __typename: 'Chat', 559 | -┊362┊ ┊ id: ChatsService.getRandomId(), 560 | +┊ ┊390┊ id: ouiId, 561 | ┊363┊391┊ name: groupName, 562 | ┊364┊392┊ picture: 'https://randomuser.me/api/portraits/thumb/lego/1.jpg', 563 | ┊365┊393┊ userIds: [currentUserId, userIds], 564 | ``` 565 | ```diff 566 | @@ -397,6 +425,7 @@ 567 | ┊397┊425┊ }); 568 | ┊398┊426┊ }, 569 | ┊399┊427┊ } 570 | -┊400┊ ┊ ); 571 | +┊ ┊428┊ ).pipe(share()); 572 | +┊ ┊429┊ return this.addChat$; 573 | ┊401┊430┊ } 574 | ┊402┊431┊} 575 | ``` 576 | 577 | [}]: # 578 | 579 | Poof, now our Whatsapp clone feels no more like a clone it has the same native feel. 580 | 581 | 582 | [//]: # (foot-start) 583 | 584 | [{]: (navStep) 585 | 586 | | [< Previous Step](https://github.com/Urigo/whatsapp-textrepo-angularcli-express/tree/master@2.0.0/.tortilla/manuals/views/step8.md) | [Next Step >](https://github.com/Urigo/whatsapp-textrepo-angularcli-express/tree/master@2.0.0/.tortilla/manuals/views/step10.md) | 587 | |:--------------------------------|--------------------------------:| 588 | 589 | [}]: # 590 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A Whatsapp clone written with Angular, Material, Express, Postgresql and Apollo GraphQL. 2 | 3 | [//]: # (head-end) 4 | 5 | 6 | ## Startup instructions 7 | 8 | This project is made out of 2 sub-projects - the one is client and the other is server. We will go through the initialization for each of these individually. We will start with the server since the client is dependent on it. 9 | 10 | ### Server 11 | 12 | First we need to clone the server project: 13 | 14 | $ git clone git@github.com:Urigo/whatsapp-server-express.git 15 | 16 | Then we need to install the NPM dependencies: 17 | 18 | $ npm install 19 | 20 | Before starting the server make sure that postgresql is installed: 21 | 22 | $ sudo apt-get update 23 | $ sudo apt-get install postgresql postgresql-contrib 24 | 25 | Setup a user named "test" with no password by first switching into "postgres" account: 26 | 27 | $ sudo -u postgres psql 28 | 29 | And running the following command: 30 | 31 | postgres=# ALTER USER test WITH PASSWORD ''; 32 | 33 | Try to run the server: 34 | 35 | $ npm start 36 | 37 | If logs show that connection refused, kill the process and set a random password for the "test" user (e.g. "test"): 38 | 39 | postgres=# ALTER USER test WITH PASSWORD 'test'; 40 | 41 | Be sure to set the password in the `ormconfig.json` as well: 42 | 43 | ```js 44 | { 45 | // ... 46 | "password": "test", 47 | // ... 48 | } 49 | ``` 50 | 51 | Run the start command again: 52 | 53 | $ npm start 54 | 55 | ### Client 56 | 57 | **Be sure to go through server initialization first** 58 | 59 | First we need to clone the server project: 60 | 61 | $ git clone git@github.com:Urigo/whatsapp-client-angularcli-material.git 62 | 63 | Then we need to install the NPM dependencies: 64 | 65 | $ npm install 66 | 67 | Start the app: 68 | 69 | $ npm start 70 | 71 | 72 | [//]: # (foot-start) 73 | 74 | [{]: (navStep) 75 | 76 | | [Begin Tutorial >](.tortilla/manuals/views/step1.md) | 77 | |----------------------:| 78 | 79 | [}]: # 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whatsapp-textrepo-angularcli-express", 3 | "description": "A newly created Tortilla project", 4 | "private": true, 5 | "repository": "https://github.com/Urigo/whatsapp-textrepo-angularcli-express", 6 | "author": "Uri Goldshtein " 7 | } 8 | --------------------------------------------------------------------------------