├── .editorconfig ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── extensions.json ├── README.md ├── angular.json ├── apps ├── .gitkeep ├── api │ ├── jest.config.js │ ├── src │ │ ├── app │ │ │ ├── .gitkeep │ │ │ ├── app.module.ts │ │ │ └── events │ │ │ │ ├── events.gateway.ts │ │ │ │ └── events.module.ts │ │ ├── assets │ │ │ └── .gitkeep │ │ ├── environments │ │ │ ├── environment.prod.ts │ │ │ └── environment.ts │ │ └── main.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.spec.json │ └── tslint.json ├── web-client-e2e │ ├── cypress.json │ ├── src │ │ ├── fixtures │ │ │ └── example.json │ │ ├── integration │ │ │ └── app.spec.ts │ │ ├── plugins │ │ │ └── index.js │ │ └── support │ │ │ ├── app.po.ts │ │ │ ├── commands.ts │ │ │ └── index.ts │ ├── tsconfig.e2e.json │ ├── tsconfig.json │ └── tslint.json └── web-client │ ├── browserslist │ ├── jest.config.js │ ├── proxy.conf.json │ ├── src │ ├── app │ │ ├── app.component.html │ │ ├── app.component.scss │ │ ├── app.component.ts │ │ ├── app.module.ts │ │ └── core │ │ │ ├── actions │ │ │ ├── client-connected.action.ts │ │ │ ├── data.action.ts │ │ │ ├── index.ts │ │ │ ├── init.action.ts │ │ │ ├── patch-value.action.ts │ │ │ └── value-patched.action.ts │ │ │ ├── effects │ │ │ ├── form-changes.effect.ts │ │ │ ├── index.ts │ │ │ ├── patch-value.effect.ts │ │ │ └── value-patched.effect.ts │ │ │ └── state │ │ │ ├── index.ts │ │ │ ├── initial-state.const.ts │ │ │ ├── state.interface.ts │ │ │ └── state.reducer.ts │ ├── assets │ │ └── .gitkeep │ ├── environments │ │ ├── environment.prod.ts │ │ └── environment.ts │ ├── favicon.ico │ ├── index.html │ ├── main.ts │ ├── polyfills.ts │ ├── styles.scss │ └── test-setup.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.spec.json │ └── tslint.json ├── jest.config.js ├── libs ├── .gitkeep └── data │ ├── README.md │ ├── jest.config.js │ ├── src │ ├── index.ts │ └── lib │ │ ├── action-types.enum.ts │ │ └── form-data.interface.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ ├── tsconfig.spec.json │ └── tslint.json ├── nx.json ├── package-lock.json ├── package.json ├── tools ├── schematics │ └── .gitkeep └── tsconfig.tools.json ├── tsconfig.json └── tslint.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | yarn-error.log 34 | testem.log 35 | /typings 36 | 37 | # System Files 38 | .DS_Store 39 | Thumbs.db 40 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Add files here to ignore them from prettier formatting 2 | 3 | /dist 4 | /coverage 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "nrwl.angular-console", 4 | "angular.ng-template", 5 | "ms-vscode.vscode-typescript-tslint-plugin", 6 | "esbenp.prettier-vscode" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Multiple Users using the same Form in Real Time. Nx, NestJs and Angular 2 | 3 | ![Visual representation of the end product](https://dev-to-uploads.s3.amazonaws.com/i/ni2wsrw1w23zsr6n6zcj.gif) 4 | 5 | In this article I wanted to explore something I've been asked to build several times for different use cases. With distributed and remote teams, real time cooperation is key for success. Whenever we hear about Real Time applications we always see the same example, a Chat. Although chats and cool and important, there's a simpler thing that can help teams maximize cooperation, forms that can be edited by multiple users concurrently. 6 | 7 | It seems challenging, and of course, depending on the use case it can be harder and more _expensive_. It can get expensive simply because it means more data being sent back and forward. If your application is running on a VPS or a dedicated server you may be able to do this without any extra expenses, but if you are doing serverless this means more money you'll spend at the end of the month. 8 | 9 | In a traditional form implementation, every client has its own state and it sends a request only when the form is submitted. In this case, things are more complex, every time a client updates the form, all the other clients should receive this information. If you are planning to use this feature in apps with just a few users, its Okay, but if you are planning to have 1,000 users concurrently changing the form, things dramatically change. 10 | 11 | > In this case I'm gonna focus on doing a very simple implementation to get you started, this is by no means a production ready application. 12 | 13 | ## The Problem 14 | 15 | Let's say you have multiple users that have to work together towards a goal, you want to reduce friction as much as possible. Having a mechanism to work on the same task together in real time can be really useful. 16 | 17 | ## The Solution 18 | 19 | There should be a service responsible for tracking the current state of the task and sending updates to all the connected clients. The Web Client that will be used by the clients, should display the connected clients and a form that can be changed by user interaction or by updates coming from the service. 20 | 21 | Since there's a big chance of concurrency, we have to choose a strategy that helps us with that. I'm personally a fan of Redux, so I based my implementation on it but adjusted it according to my needs. Since this is a very small app, I used pure RxJs for my state management implementation. The actions that can occur are: 22 | 23 | - Init: It sets the initial state of the web client, its triggered when each client loads. 24 | - ClientConnected: Everytime a client connects to the service, all the clients receive an updated list of the currently connected clients. 25 | - Data: Whenever a client is connected, the service responds with the current form state. 26 | - PatchValue: When a client updates the form by directly interacting with it, it sends the changes to the service. 27 | - ValuePatched: When the service receives a change to the state, it broadcasts it to all the other clients. 28 | 29 | For this sample the form data is very simple and it only consists of a title and description, both of type string. 30 | 31 | ## Implementation 32 | 33 | First thing is to choose the technologies we want to use. I'm a proud Angular Developer, so I choose to use Angular for the Web Client. Since NestJs is so cool, I decided to use it for the service responsible for synchronization. Finally since the Web Client and the service are going to be communicating in real time, Nx can be really helpful to reduce duplication and ensure the messages passing through are type safe using shared interfaces. 34 | 35 | > NOTE: For the Web Client you can use any JS framework or even plain Javascript. Same thing with the service, you can use Node or whatever you want as long as you have a Socket.IO implementation. I used Nx just because I like it but you can also skip that part. 36 | 37 | We'll start by generating the Nx workspace. 38 | 39 | - Run the command `npx create-nx-workspace@latest realtime-form` 40 | - Choose `angular-nest` workspace in the prompt options 41 | - Type `web-client` as the Application name 42 | - Select your preferred stylesheet format (I always use SASS) 43 | - Go to the `realtime-form` directory 44 | 45 | One of the cool things about using Nx with NestJs and Angular is the possibility to share things between them. Let's take advantage of it and create our Form's state interface and Action types enum. 46 | 47 | Go to `/libs/api-interfaces/src/lib/api-interfaces.ts` and change its content to this: 48 | 49 | ```typescript 50 | export enum ActionTypes { 51 | Data = '[Socket] Data', 52 | ClientConnected = '[Socket] Client Connected', 53 | ValuePatched = '[Socket] Value Patched', 54 | PatchValue = '[Form] Patch Value', 55 | Init = '[Init] Init' 56 | } 57 | 58 | export interface FormData { 59 | title: string; 60 | description: string; 61 | } 62 | ``` 63 | 64 | Now we are able to use them from the service and the web client, since its shared it works as a contract between the two of them. 65 | 66 | We're going to start with the service: 67 | 68 | - Run `npm i --save @nestjs/websockets @nestjs/platform-socket.io` 69 | - Run `npm i --save-dev @types/socket.io` 70 | - Go to the directory `/apps/api/src/app` 71 | - Create a new directory called `events` and move to that directory 72 | - Create a file named `events.gateway.ts` 73 | - Create a file named `events.module.ts` 74 | 75 | And next you just have to write the new file's content. 76 | 77 | Go to `/apps/api/src/app/events/events.gateway.ts`: 78 | 79 | ```typescript 80 | import { 81 | SubscribeMessage, 82 | WebSocketGateway, 83 | WebSocketServer 84 | } from '@nestjs/websockets'; 85 | import { Server, Socket } from 'socket.io'; 86 | import { Logger } from '@nestjs/common'; 87 | 88 | import { ActionTypes, FormData } from '@realtime-form/api-interfaces'; 89 | 90 | @WebSocketGateway() 91 | export class EventsGateway { 92 | connectedClients = []; 93 | data = {}; 94 | @WebSocketServer() 95 | server: Server; 96 | private logger: Logger = new Logger('EventsGateway'); 97 | 98 | handleConnection(client: Socket) { 99 | this.connectedClients = [...this.connectedClients, client.id]; 100 | this.logger.log( 101 | `Client connected: ${client.id} - ${this.connectedClients.length} connected clients.` 102 | ); 103 | this.server.emit(ActionTypes.ClientConnected, this.connectedClients); 104 | client.emit(ActionTypes.Data, this.data); 105 | } 106 | 107 | handleDisconnect(client: Socket) { 108 | this.connectedClients = this.connectedClients.filter( 109 | connectedClient => connectedClient !== client.id 110 | ); 111 | this.logger.log( 112 | `Client disconnected: ${client.id} - ${this.connectedClients.length} connected clients.` 113 | ); 114 | this.server.emit(ActionTypes.ClientConnected, this.connectedClients); 115 | } 116 | 117 | @SubscribeMessage(ActionTypes.PatchValue) 118 | patchValue(client: Socket, payload: Partial) { 119 | this.data = { ...this.data, ...payload }; 120 | this.logger.log(`Patch value: ${JSON.stringify(payload)}.`); 121 | client.broadcast.emit(ActionTypes.ValuePatched, payload); 122 | } 123 | } 124 | ``` 125 | 126 | If you are scratching your head with that code snippet, don't worry, we are trusting NestJs to do all the heavy lifting. You can think of each method as the response to an event; connection, disconnection and patch value. 127 | 128 | - Connection: Update the list of connected clients, log to the service the event occurred, emit the new connectedClients list to all the currently connected clients and emit to the client the current state of the form. 129 | - Disconnection: Update the list of connected clients, log to the service the event occurred, emit the new connectedClients list to all the currently connected clients. 130 | - PatchValue: Update the current state of the form, log to the service the event occurred, broadcast the new state to all the currently connected clients. 131 | 132 | > NOTE: The difference between this.server.emit and client.broadcast.emit, is that the first sends the message to all the clients while the second sends the message to all _BUT the sender_. 133 | 134 | Now lets update the `/apps/api/src/app/events/events.module.ts` file: 135 | 136 | ```typescript 137 | import { Module } from '@nestjs/common'; 138 | import { EventsGateway } from './events.gateway'; 139 | 140 | @Module({ 141 | providers: [EventsGateway] 142 | }) 143 | export class EventsModule {} 144 | ``` 145 | 146 | And the `/apps/api/src/app/app.module.ts` file: 147 | 148 | ```typescript 149 | import { Module } from '@nestjs/common'; 150 | import { EventsModule } from './events/events.module'; 151 | 152 | @Module({ 153 | imports: [EventsModule] 154 | }) 155 | export class AppModule {} 156 | ``` 157 | 158 | I also removed the `AppController` and `AppService` files. And also updated the `apps/api/src/main.ts` file with this: 159 | 160 | ```typescript 161 | import { NestFactory } from '@nestjs/core'; 162 | import { AppModule } from './app/app.module'; 163 | 164 | async function bootstrap() { 165 | const app = await NestFactory.create(AppModule); 166 | const port = 3000; 167 | await app.listen(port, () => { 168 | console.log('Listening at http://localhost:' + port); 169 | }); 170 | } 171 | 172 | bootstrap(); 173 | ``` 174 | 175 | Now it's time to get started with the web client, go to `apps/web-client/src/app/app.component.html`: 176 | 177 | ```html 178 |
179 |

Realtime Form

180 |
181 | 182 |
183 |
184 |
185 | 189 | 190 | 194 |
195 |
196 | 197 | 198 |

Clients ({{ clients.length }})

199 |
    200 |
  • {{ client }}
  • 201 |
202 |
203 |
204 | ``` 205 | 206 | Just to make sure it looks just like what I showed at the beginning, Go to `/apps/web-client/src/app/app.component.scss` and replace its content with this: 207 | 208 | ```scss 209 | form { 210 | width: 100%; 211 | padding: 0.5rem; 212 | max-width: 600px; 213 | 214 | .form-control { 215 | display: flex; 216 | margin-bottom: 1rem; 217 | 218 | & > span { 219 | flex-basis: 20%; 220 | } 221 | 222 | & > input, 223 | & > textarea { 224 | flex-grow: 1; 225 | } 226 | } 227 | } 228 | ``` 229 | 230 | Install the Socket IO package for Angular by using the command `npm install --save ngx-socket-io` 231 | 232 | Don't forget to inject `ReactiveFormsModule` and `SocketIoModule` in the `AppModule` of the Web Client. Go to `/apps/web-client/src/app/app.module.ts`: 233 | 234 | ```typescript 235 | import { BrowserModule } from '@angular/platform-browser'; 236 | import { NgModule } from '@angular/core'; 237 | import { ReactiveFormsModule } from '@angular/forms'; 238 | 239 | import { AppComponent } from './app.component'; 240 | 241 | import { SocketIoModule, SocketIoConfig } from 'ngx-socket-io'; 242 | 243 | const config: SocketIoConfig = { 244 | url: 'http://192.168.1.2:3000', 245 | options: {} 246 | }; 247 | 248 | @NgModule({ 249 | declarations: [AppComponent], 250 | imports: [BrowserModule, ReactiveFormsModule, SocketIoModule.forRoot(config)], 251 | providers: [], 252 | bootstrap: [AppComponent] 253 | }) 254 | export class AppModule {} 255 | ``` 256 | 257 | Next go to `apps/web-client/src/app/app.component.ts`: 258 | 259 | ```typescript 260 | import { Component, OnInit } from '@angular/core'; 261 | import { BehaviorSubject, merge } from 'rxjs'; 262 | import { scan, map } from 'rxjs/operators'; 263 | import { FormBuilder } from '@angular/forms'; 264 | import { Socket } from 'ngx-socket-io'; 265 | 266 | import { ActionTypes, FormData } from '@realtime-form/api-interfaces'; 267 | import { State, reducer } from './core/state'; 268 | import { 269 | ClientConnected, 270 | Data, 271 | ValuePatched, 272 | Action, 273 | Init 274 | } from './core/actions'; 275 | import { 276 | getPatchValueEffect, 277 | getValuePatchedEffect, 278 | getFormChangesEffect 279 | } from './core/effects'; 280 | 281 | @Component({ 282 | selector: 'realtime-form-root', 283 | templateUrl: './app.component.html', 284 | styleUrls: ['./app.component.scss'] 285 | }) 286 | export class AppComponent implements OnInit { 287 | // 1: Action dispatcher 288 | private dispatcher = new BehaviorSubject(new Init()); 289 | actions$ = this.dispatcher.asObservable(); 290 | // 2: State stream 291 | store$ = this.actions$.pipe( 292 | scan((state: State, action: Action) => reducer(state, action)) 293 | ); 294 | // 3: Define all the selectors 295 | connectedClients$ = this.store$.pipe( 296 | map((state: State) => state.connectedClients) 297 | ); 298 | data$ = this.store$.pipe(map((state: State) => state.data)); 299 | title$ = this.data$.pipe(map((state: Partial) => state.title)); 300 | description$ = this.data$.pipe( 301 | map((state: Partial) => state.description) 302 | ); 303 | 304 | // 4: Initialize the form 305 | form = this.fb.group({ 306 | title: [''], 307 | description: [''] 308 | }); 309 | 310 | constructor(private socket: Socket, private fb: FormBuilder) {} 311 | 312 | ngOnInit() { 313 | // 5: Connect to all the socket events 314 | this.socket.on(ActionTypes.ClientConnected, (payload: string[]) => { 315 | this.dispatcher.next(new ClientConnected(payload)); 316 | }); 317 | 318 | this.socket.on(ActionTypes.Data, (payload: Partial) => { 319 | this.dispatcher.next(new Data(payload)); 320 | }); 321 | 322 | this.socket.on(ActionTypes.ValuePatched, (payload: Partial) => { 323 | this.dispatcher.next(new ValuePatched(payload)); 324 | }); 325 | 326 | // 6: Subscribe to all the effects 327 | merge( 328 | getPatchValueEffect(this.socket, this.actions$), 329 | getValuePatchedEffect(this.form, this.actions$), 330 | getFormChangesEffect(this.form, this.dispatcher) 331 | ).subscribe(); 332 | } 333 | } 334 | ``` 335 | 336 | Let's go through each of the things I just did right there: 337 | 338 | ### 1: Action dispatcher 339 | 340 | I start by creating an action dispatcher and an observable from the stream of actions going through, I use RxJs BehaviorSubject with an initial action that looks like this: 341 | 342 | ```typescript 343 | // apps/web-client/src/app/core/actions/init.action.ts 344 | import { ActionTypes } from '@realtime-form/api-interfaces'; 345 | 346 | export class Init { 347 | type = ActionTypes.Init; 348 | payload = null; 349 | } 350 | ``` 351 | 352 | I also created an `Action` type inside a barrel import to make it easier to use: 353 | 354 | ```typescript 355 | // apps/web-client/src/app/core/actions/index.ts 356 | import { Init } from './init.action'; 357 | 358 | export type Action = Init; 359 | export { Init }; 360 | ``` 361 | 362 | ### 2: State stream 363 | 364 | By using the scan operator we can take every emission of an observable, keep an internal state that gets updated by the return of its callback. With a reducer function that takes a state and action, and returns a state in an inmutable way we can have a stream of the current state in a safer way. 365 | 366 | I created a reducer that looks like this: 367 | 368 | ```typescript 369 | // apps/web-client/src/app/core/state/state.reducer.ts 370 | import { ActionTypes } from '@realtime-form/api-interfaces'; 371 | import { State } from './state.interface'; 372 | import { Action } from '../actions'; 373 | import { initialState } from './initial-state.const'; 374 | 375 | export const reducer = (state: State, action: Action): State => { 376 | switch (action.type) { 377 | case ActionTypes.Init: 378 | return { ...initialState }; 379 | case ActionTypes.ClientConnected: 380 | return { 381 | ...state, 382 | connectedClients: action.payload 383 | }; 384 | case ActionTypes.Data: 385 | return { ...state, data: action.payload }; 386 | case ActionTypes.PatchValue: 387 | return { ...state, data: { ...state.data, ...action.payload } }; 388 | default: 389 | return { ...state }; 390 | } 391 | }; 392 | ``` 393 | 394 | A brief description of the actions: 395 | 396 | - Init: Set the state to the `initialState` const. 397 | - ClientConnected: Update the connectedClients in the state with the updated list. 398 | - Data: Set the data of the state to the value returned upon connection. 399 | - PatchValue: Patch the data with the changes from the payload. 400 | 401 | The `State` interface looks like this: 402 | 403 | ```typescript 404 | // apps/web-client/src/app/core/state/state.interface.ts 405 | import { FormData } from '@realtime-form/api-interfaces'; 406 | 407 | export interface State { 408 | connectedClients: string[]; 409 | data: Partial; 410 | } 411 | ``` 412 | 413 | The `initialState` const looks like this: 414 | 415 | ```typescript 416 | // apps/web-client/src/app/core/state/initial-state.const.ts 417 | import { State } from './state.interface'; 418 | 419 | export const initialState = { 420 | connectedClients: [], 421 | data: {} 422 | } as State; 423 | ``` 424 | 425 | I also created a barrel import here, I kinda love them. 426 | 427 | ```typescript 428 | export { initialState } from './initial-state.const'; 429 | export { State } from './state.interface'; 430 | export { reducer } from './state.reducer'; 431 | ``` 432 | 433 | ### 3: Define all the selectors 434 | 435 | In order to make it easy to access the values in the store, I created an extra set of observables that are basically mapping the state to sub states, it works like a projection. 436 | 437 | ### 4: Initialize the form 438 | 439 | I just created a very **VERY** simple form using ReactiveForms, if you want to learn more about them you can take a look at my ReactiveForms series. 440 | 441 | ### 5: Connect to all the socket events 442 | 443 | As we just saw, there are three events that can be emitted by our service, in this step we are listening to those events and responding accordingly. To make it cleaner I created some action creator classes. 444 | 445 | ```typescript 446 | // apps/web-client/src/app/core/actions/client-connected.action.ts 447 | import { ActionTypes } from '@realtime-form/api-interfaces'; 448 | 449 | export class ClientConnected { 450 | type = ActionTypes.ClientConnected; 451 | 452 | constructor(public payload: string[]) {} 453 | } 454 | ``` 455 | 456 | ```typescript 457 | // apps/web-client/src/app/core/actions/data.action.ts 458 | import { ActionTypes, FormData } from '@realtime-form/api-interfaces'; 459 | 460 | export class Data { 461 | type = ActionTypes.Data; 462 | 463 | constructor(public payload: Partial) {} 464 | } 465 | ``` 466 | 467 | ```typescript 468 | // apps/web-client/src/app/core/actions/value-patched.action.ts 469 | import { ActionTypes, FormData } from '@realtime-form/api-interfaces'; 470 | 471 | export class ValuePatched { 472 | type = ActionTypes.ValuePatched; 473 | 474 | constructor(public payload: Partial) {} 475 | } 476 | ``` 477 | 478 | And do not forget to update the barrel import 479 | 480 | ```typescript 481 | // apps/web-client/src/app/core/actions/index.ts 482 | import { Init } from './init.action'; 483 | import { Data } from './data.action'; 484 | import { ClientConnected } from './client-connected.action'; 485 | import { ValuePatched } from './value-patched.action'; 486 | 487 | export type Action = Init | Data | ClientConnected | ValuePatched; 488 | export { Init, Data, ClientConnected, ValuePatched }; 489 | ``` 490 | 491 | ### 6: Subscribe to all the effects 492 | 493 | The only thing left are the side effects. Let's go through each: 494 | 495 | When the user updates the form, the changes have to be broadcasted to all the other clients, for this we need to emit to the service. We can achieve that doing this: 496 | 497 | ```typescript 498 | // apps/web-client/src/app/core/effects/patch-value.effect.ts 499 | import { Action } from '../actions'; 500 | import { Observable, asyncScheduler } from 'rxjs'; 501 | import { observeOn, filter, tap } from 'rxjs/operators'; 502 | import { ActionTypes } from '@realtime-form/api-interfaces'; 503 | import { Socket } from 'ngx-socket-io'; 504 | 505 | export const getPatchValueEffect = ( 506 | socket: Socket, 507 | actions: Observable 508 | ) => { 509 | return actions.pipe( 510 | observeOn(asyncScheduler), 511 | filter(action => action.type === ActionTypes.PatchValue), 512 | tap(action => socket.emit(ActionTypes.PatchValue, action.payload)) 513 | ); 514 | }; 515 | ``` 516 | 517 | > NOTE: I use the `asyncScheduler` only because I want to ensure that the reducer is always first. 518 | 519 | When the service emits that the value has changed or it sends the current form state upon connection, we have to respond accordingly. We are already mapping the socket event to an action in both cases, now we just need an effect that updates the form locally for each client. 520 | 521 | ```typescript 522 | // apps/web-client/src/app/core/effects/value-patched.effect.ts 523 | import { Action } from '../actions'; 524 | import { Observable, asyncScheduler } from 'rxjs'; 525 | import { observeOn, filter, tap } from 'rxjs/operators'; 526 | import { ActionTypes } from '@realtime-form/api-interfaces'; 527 | import { FormGroup } from '@angular/forms'; 528 | 529 | export const getValuePatchedEffect = ( 530 | form: FormGroup, 531 | actions: Observable 532 | ) => { 533 | return actions.pipe( 534 | observeOn(asyncScheduler), 535 | filter( 536 | action => 537 | action.type === ActionTypes.ValuePatched || 538 | action.type === ActionTypes.Data 539 | ), 540 | tap(action => form.patchValue(action.payload, { emitEvent: false })) 541 | ); 542 | }; 543 | ``` 544 | 545 | And finally, whenever a client interacts with the form we want to emit a message to the service that will propagate this change across all the connected clients. 546 | 547 | ```typescript 548 | // apps/web-client/src/app/core/effects/form-changes.effect.ts 549 | import { Action, PatchValue } from '../actions'; 550 | import { merge, BehaviorSubject } from 'rxjs'; 551 | import { debounceTime, map, tap } from 'rxjs/operators'; 552 | import { FormGroup } from '@angular/forms'; 553 | import { FormData } from '@realtime-form/api-interfaces'; 554 | 555 | export const getFormChangesEffect = ( 556 | form: FormGroup, 557 | dispatcher: BehaviorSubject 558 | ) => { 559 | const title$ = form 560 | .get('title') 561 | .valueChanges.pipe(map((title: string) => ({ title }))); 562 | 563 | const description$ = form 564 | .get('description') 565 | .valueChanges.pipe(map((description: string) => ({ description }))); 566 | 567 | return merge(title$, description$).pipe( 568 | debounceTime(300), 569 | tap((payload: Partial) => 570 | dispatcher.next(new PatchValue(payload)) 571 | ) 572 | ); 573 | }; 574 | ``` 575 | 576 | You probably noticed a new `PatchValue` action, so let's create it: 577 | 578 | ```typescript 579 | // apps/web-client/src/app/core/actions/patch-value.action.ts 580 | import { ActionTypes, FormData } from '@realtime-form/api-interfaces'; 581 | 582 | export class PatchValue { 583 | type = ActionTypes.PatchValue; 584 | 585 | constructor(public payload: Partial) {} 586 | } 587 | ``` 588 | 589 | And also update the barrel import: 590 | 591 | ```typescript 592 | // apps/web-client/src/app/core/actions/index.ts 593 | import { Init } from './init.action'; 594 | import { Data } from './data.action'; 595 | import { ClientConnected } from './client-connected.action'; 596 | import { ValuePatched } from './value-patched.action'; 597 | import { PatchValue } from './patch-value.action'; 598 | 599 | export type Action = Init | Data | ClientConnected | ValuePatched | PatchValue; 600 | export { Init, Data, ClientConnected, ValuePatched, PatchValue }; 601 | ``` 602 | 603 | Since I love barrel imports I created another one for the effects: 604 | 605 | ```typescript 606 | // apps/web-client/src/app/core/effects/index.ts 607 | export { getFormChangesEffect } from './form-changes.effect'; 608 | export { getPatchValueEffect } from './patch-value.effect'; 609 | export { getValuePatchedEffect } from './value-patched.effect'; 610 | ``` 611 | 612 | Now you just have to run the services, each in a different terminal while in the main directory of the application: 613 | 614 | - Run the command `ng serve` 615 | - Run the command `ng serve api` 616 | 617 | ## Conclusion 618 | 619 | And that was it. The first time I had to do this was really challenging, so I tried to be as explicit as I could with each step, hoping you don't get lost. As I mentioned before this is not a production ready implementation but a really good point of start. Now that you know how to solve this problem, don't forget that sometimes the solution can be worse and in some cases this could increase infrastructure costs. 620 | 621 | Icons made by [itim2101](https://www.flaticon.com/authors/itim2101) from [Flaticon](https://www.flaticon.com) 622 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "projects": { 4 | "api": { 5 | "root": "apps/api", 6 | "sourceRoot": "apps/api/src", 7 | "projectType": "application", 8 | "prefix": "api", 9 | "schematics": {}, 10 | "architect": { 11 | "build": { 12 | "builder": "@nrwl/node:build", 13 | "options": { 14 | "outputPath": "dist/apps/api", 15 | "main": "apps/api/src/main.ts", 16 | "tsConfig": "apps/api/tsconfig.app.json", 17 | "assets": ["apps/api/src/assets"] 18 | }, 19 | "configurations": { 20 | "production": { 21 | "optimization": true, 22 | "extractLicenses": true, 23 | "inspect": false, 24 | "fileReplacements": [ 25 | { 26 | "replace": "apps/api/src/environments/environment.ts", 27 | "with": "apps/api/src/environments/environment.prod.ts" 28 | } 29 | ] 30 | } 31 | } 32 | }, 33 | "serve": { 34 | "builder": "@nrwl/node:execute", 35 | "options": { 36 | "buildTarget": "api:build" 37 | } 38 | }, 39 | "lint": { 40 | "builder": "@angular-devkit/build-angular:tslint", 41 | "options": { 42 | "tsConfig": [ 43 | "apps/api/tsconfig.app.json", 44 | "apps/api/tsconfig.spec.json" 45 | ], 46 | "exclude": ["**/node_modules/**", "!apps/api/**"] 47 | } 48 | }, 49 | "test": { 50 | "builder": "@nrwl/jest:jest", 51 | "options": { 52 | "jestConfig": "apps/api/jest.config.js", 53 | "tsConfig": "apps/api/tsconfig.spec.json" 54 | } 55 | } 56 | } 57 | }, 58 | "web-client": { 59 | "projectType": "application", 60 | "schematics": { 61 | "@nrwl/angular:component": { 62 | "style": "scss" 63 | } 64 | }, 65 | "root": "apps/web-client", 66 | "sourceRoot": "apps/web-client/src", 67 | "prefix": "realtime-form", 68 | "architect": { 69 | "build": { 70 | "builder": "@angular-devkit/build-angular:browser", 71 | "options": { 72 | "outputPath": "dist/apps/web-client", 73 | "index": "apps/web-client/src/index.html", 74 | "main": "apps/web-client/src/main.ts", 75 | "polyfills": "apps/web-client/src/polyfills.ts", 76 | "tsConfig": "apps/web-client/tsconfig.app.json", 77 | "aot": false, 78 | "assets": [ 79 | "apps/web-client/src/favicon.ico", 80 | "apps/web-client/src/assets" 81 | ], 82 | "styles": ["apps/web-client/src/styles.scss"], 83 | "scripts": [] 84 | }, 85 | "configurations": { 86 | "production": { 87 | "fileReplacements": [ 88 | { 89 | "replace": "apps/web-client/src/environments/environment.ts", 90 | "with": "apps/web-client/src/environments/environment.prod.ts" 91 | } 92 | ], 93 | "optimization": true, 94 | "outputHashing": "all", 95 | "sourceMap": false, 96 | "extractCss": true, 97 | "namedChunks": false, 98 | "aot": true, 99 | "extractLicenses": true, 100 | "vendorChunk": false, 101 | "buildOptimizer": true, 102 | "budgets": [ 103 | { 104 | "type": "initial", 105 | "maximumWarning": "2mb", 106 | "maximumError": "5mb" 107 | }, 108 | { 109 | "type": "anyComponentStyle", 110 | "maximumWarning": "6kb", 111 | "maximumError": "10kb" 112 | } 113 | ] 114 | } 115 | } 116 | }, 117 | "serve": { 118 | "builder": "@angular-devkit/build-angular:dev-server", 119 | "options": { 120 | "browserTarget": "web-client:build", 121 | "proxyConfig": "apps/web-client/proxy.conf.json" 122 | }, 123 | "configurations": { 124 | "production": { 125 | "browserTarget": "web-client:build:production" 126 | } 127 | } 128 | }, 129 | "extract-i18n": { 130 | "builder": "@angular-devkit/build-angular:extract-i18n", 131 | "options": { 132 | "browserTarget": "web-client:build" 133 | } 134 | }, 135 | "lint": { 136 | "builder": "@angular-devkit/build-angular:tslint", 137 | "options": { 138 | "tsConfig": [ 139 | "apps/web-client/tsconfig.app.json", 140 | "apps/web-client/tsconfig.spec.json" 141 | ], 142 | "exclude": ["**/node_modules/**", "!apps/web-client/**"] 143 | } 144 | }, 145 | "test": { 146 | "builder": "@nrwl/jest:jest", 147 | "options": { 148 | "jestConfig": "apps/web-client/jest.config.js", 149 | "tsConfig": "apps/web-client/tsconfig.spec.json", 150 | "setupFile": "apps/web-client/src/test-setup.ts" 151 | } 152 | } 153 | } 154 | }, 155 | "web-client-e2e": { 156 | "root": "apps/web-client-e2e", 157 | "sourceRoot": "apps/web-client-e2e/src", 158 | "projectType": "application", 159 | "architect": { 160 | "e2e": { 161 | "builder": "@nrwl/cypress:cypress", 162 | "options": { 163 | "cypressConfig": "apps/web-client-e2e/cypress.json", 164 | "tsConfig": "apps/web-client-e2e/tsconfig.e2e.json", 165 | "devServerTarget": "web-client:serve" 166 | }, 167 | "configurations": { 168 | "production": { 169 | "devServerTarget": "web-client:serve:production" 170 | } 171 | } 172 | }, 173 | "lint": { 174 | "builder": "@angular-devkit/build-angular:tslint", 175 | "options": { 176 | "tsConfig": ["apps/web-client-e2e/tsconfig.e2e.json"], 177 | "exclude": ["**/node_modules/**", "!apps/web-client-e2e/**"] 178 | } 179 | } 180 | } 181 | }, 182 | "data": { 183 | "root": "libs/data", 184 | "sourceRoot": "libs/data/src", 185 | "projectType": "library", 186 | "schematics": {}, 187 | "architect": { 188 | "lint": { 189 | "builder": "@angular-devkit/build-angular:tslint", 190 | "options": { 191 | "tsConfig": [ 192 | "libs/data/tsconfig.lib.json", 193 | "libs/data/tsconfig.spec.json" 194 | ], 195 | "exclude": ["**/node_modules/**", "!libs/data/**"] 196 | } 197 | }, 198 | "test": { 199 | "builder": "@nrwl/jest:jest", 200 | "options": { 201 | "jestConfig": "libs/data/jest.config.js", 202 | "tsConfig": "libs/data/tsconfig.spec.json" 203 | } 204 | } 205 | } 206 | } 207 | }, 208 | "cli": { 209 | "defaultCollection": "@nrwl/angular" 210 | }, 211 | "schematics": { 212 | "@nrwl/angular:application": { 213 | "unitTestRunner": "jest", 214 | "e2eTestRunner": "cypress" 215 | }, 216 | "@nrwl/angular:library": { 217 | "unitTestRunner": "jest" 218 | } 219 | }, 220 | "defaultProject": "web-client" 221 | } 222 | -------------------------------------------------------------------------------- /apps/.gitkeep: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /apps/api/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'api', 3 | preset: '../../jest.config.js', 4 | coverageDirectory: '../../coverage/apps/api' 5 | }; 6 | -------------------------------------------------------------------------------- /apps/api/src/app/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danmt/realtime-form/5e0fa146f3a2dd40707e81b7c5bbaebbb96e8c88/apps/api/src/app/.gitkeep -------------------------------------------------------------------------------- /apps/api/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { EventsModule } from './events/events.module'; 3 | 4 | @Module({ 5 | imports: [EventsModule] 6 | }) 7 | export class AppModule {} 8 | -------------------------------------------------------------------------------- /apps/api/src/app/events/events.gateway.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SubscribeMessage, 3 | WebSocketGateway, 4 | WebSocketServer 5 | } from '@nestjs/websockets'; 6 | import { Server, Socket } from 'socket.io'; 7 | import { Logger } from '@nestjs/common'; 8 | 9 | import { ActionTypes, FormData } from '@realtime-form/data'; 10 | 11 | @WebSocketGateway() 12 | export class EventsGateway { 13 | connectedClients = []; 14 | data = {}; 15 | @WebSocketServer() 16 | server: Server; 17 | private logger: Logger = new Logger('EventsGateway'); 18 | 19 | handleConnection(client: Socket) { 20 | this.connectedClients = [...this.connectedClients, client.id]; 21 | this.logger.log( 22 | `Client connected: ${client.id} - ${this.connectedClients.length} connected clients.` 23 | ); 24 | this.server.emit(ActionTypes.ClientConnected, this.connectedClients); 25 | client.emit(ActionTypes.Data, this.data); 26 | } 27 | 28 | handleDisconnect(client: Socket) { 29 | this.connectedClients = this.connectedClients.filter( 30 | connectedClient => connectedClient !== client.id 31 | ); 32 | this.logger.log( 33 | `Client disconnected: ${client.id} - ${this.connectedClients.length} connected clients.` 34 | ); 35 | this.server.emit(ActionTypes.ClientConnected, this.connectedClients); 36 | } 37 | 38 | @SubscribeMessage(ActionTypes.PatchValue) 39 | patchValue(client: Socket, payload: Partial) { 40 | this.data = { ...this.data, ...payload }; 41 | this.logger.log(`Patch value: ${JSON.stringify(payload)}.`); 42 | client.broadcast.emit(ActionTypes.ValuePatched, payload); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /apps/api/src/app/events/events.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { EventsGateway } from './events.gateway'; 3 | 4 | @Module({ 5 | providers: [EventsGateway] 6 | }) 7 | export class EventsModule {} 8 | -------------------------------------------------------------------------------- /apps/api/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danmt/realtime-form/5e0fa146f3a2dd40707e81b7c5bbaebbb96e8c88/apps/api/src/assets/.gitkeep -------------------------------------------------------------------------------- /apps/api/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /apps/api/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: false 3 | }; 4 | -------------------------------------------------------------------------------- /apps/api/src/main.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This is not a production server yet! 3 | * This is only a minimal backend to get started. 4 | */ 5 | 6 | import { NestFactory } from '@nestjs/core'; 7 | 8 | import { AppModule } from './app/app.module'; 9 | 10 | async function bootstrap() { 11 | const app = await NestFactory.create(AppModule); 12 | const port = 3000; 13 | await app.listen(port, () => { 14 | console.log('Listening at http://localhost:' + port); 15 | }); 16 | } 17 | 18 | bootstrap(); 19 | -------------------------------------------------------------------------------- /apps/api/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": ["node"] 6 | }, 7 | "exclude": ["**/*.spec.ts"], 8 | "include": ["**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /apps/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["node", "jest"], 5 | "emitDecoratorMetadata": true 6 | }, 7 | "include": ["**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /apps/api/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": ["**/*.spec.ts", "**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /apps/api/tslint.json: -------------------------------------------------------------------------------- 1 | { "extends": "../../tslint.json", "rules": {} } 2 | -------------------------------------------------------------------------------- /apps/web-client-e2e/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "fileServerFolder": ".", 3 | "fixturesFolder": "./src/fixtures", 4 | "integrationFolder": "./src/integration", 5 | "modifyObstructiveCode": false, 6 | "pluginsFile": "./src/plugins/index", 7 | "supportFile": "./src/support/index.ts", 8 | "video": true, 9 | "videosFolder": "../../dist/cypress/apps/web-client-e2e/videos", 10 | "screenshotsFolder": "../../dist/cypress/apps/web-client-e2e/screenshots", 11 | "chromeWebSecurity": false 12 | } 13 | -------------------------------------------------------------------------------- /apps/web-client-e2e/src/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io" 4 | } 5 | -------------------------------------------------------------------------------- /apps/web-client-e2e/src/integration/app.spec.ts: -------------------------------------------------------------------------------- 1 | import { getGreeting } from '../support/app.po'; 2 | 3 | describe('web-client', () => { 4 | beforeEach(() => cy.visit('/')); 5 | 6 | it('should display welcome message', () => { 7 | // Custom command example, see `../support/commands.ts` file 8 | cy.login('my-email@something.com', 'myPassword'); 9 | 10 | // Function helper example, see `../support/app.po.ts` file 11 | getGreeting().contains('Welcome to web-client!'); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /apps/web-client-e2e/src/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | const { preprocessTypescript } = require('@nrwl/cypress/plugins/preprocessor'); 15 | 16 | module.exports = (on, config) => { 17 | // `on` is used to hook into various events Cypress emits 18 | // `config` is the resolved Cypress config 19 | 20 | // Preprocess Typescript file using Nx helper 21 | on('file:preprocessor', preprocessTypescript(config)); 22 | }; 23 | -------------------------------------------------------------------------------- /apps/web-client-e2e/src/support/app.po.ts: -------------------------------------------------------------------------------- 1 | export const getGreeting = () => cy.get('h1'); 2 | -------------------------------------------------------------------------------- /apps/web-client-e2e/src/support/commands.ts: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // eslint-disable-next-line @typescript-eslint/no-namespace 11 | declare namespace Cypress { 12 | interface Chainable { 13 | login(email: string, password: string): void; 14 | } 15 | } 16 | // 17 | // -- This is a parent command -- 18 | Cypress.Commands.add('login', (email, password) => { 19 | console.log('Custom command example: Login', email, password); 20 | }); 21 | // 22 | // -- This is a child command -- 23 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 24 | // 25 | // 26 | // -- This is a dual command -- 27 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 28 | // 29 | // 30 | // -- This will overwrite an existing command -- 31 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 32 | -------------------------------------------------------------------------------- /apps/web-client-e2e/src/support/index.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands'; 18 | -------------------------------------------------------------------------------- /apps/web-client-e2e/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": false, 5 | "outDir": "../../dist/out-tsc" 6 | }, 7 | "include": ["src/**/*.ts", "src/**/*.js"] 8 | } 9 | -------------------------------------------------------------------------------- /apps/web-client-e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["cypress", "node"] 5 | }, 6 | "include": ["**/*.ts", "**/*.js"] 7 | } 8 | -------------------------------------------------------------------------------- /apps/web-client-e2e/tslint.json: -------------------------------------------------------------------------------- 1 | { "extends": "../../tslint.json", "rules": {} } 2 | -------------------------------------------------------------------------------- /apps/web-client/browserslist: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # You can see what browsers were selected by your queries by running: 6 | # npx browserslist 7 | 8 | > 0.5% 9 | last 2 versions 10 | Firefox ESR 11 | not dead 12 | not IE 9-11 # For IE 9-11 support, remove 'not'. -------------------------------------------------------------------------------- /apps/web-client/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'web-client', 3 | preset: '../../jest.config.js', 4 | coverageDirectory: '../../coverage/apps/web-client', 5 | snapshotSerializers: [ 6 | 'jest-preset-angular/AngularSnapshotSerializer.js', 7 | 'jest-preset-angular/HTMLCommentSerializer.js' 8 | ] 9 | }; 10 | -------------------------------------------------------------------------------- /apps/web-client/proxy.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "/api": { 3 | "target": "http://localhost:3333", 4 | "secure": false 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /apps/web-client/src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 |

Realtime Form

3 |
4 | 5 |
6 |
7 |
8 | 12 | 13 | 17 |
18 |
19 | 20 | 21 |

Clients ({{ clients.length }})

22 |
    23 |
  • {{ client }}
  • 24 |
25 |
26 |
27 | -------------------------------------------------------------------------------- /apps/web-client/src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | form { 2 | width: 100%; 3 | padding: 0.5rem; 4 | max-width: 600px; 5 | 6 | .form-control { 7 | display: flex; 8 | margin-bottom: 1rem; 9 | 10 | & > span { 11 | flex-basis: 20%; 12 | } 13 | 14 | & > input, 15 | & > textarea { 16 | flex-grow: 1; 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /apps/web-client/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { BehaviorSubject, merge } from 'rxjs'; 3 | import { scan, map } from 'rxjs/operators'; 4 | import { FormBuilder } from '@angular/forms'; 5 | import { Socket } from 'ngx-socket-io'; 6 | 7 | import { ActionTypes, FormData } from '@realtime-form/data'; 8 | import { State, reducer } from './core/state'; 9 | import { 10 | ClientConnected, 11 | Data, 12 | ValuePatched, 13 | Action, 14 | Init 15 | } from './core/actions'; 16 | import { 17 | getPatchValueEffect, 18 | getValuePatchedEffect, 19 | getFormChangesEffect 20 | } from './core/effects'; 21 | 22 | @Component({ 23 | selector: 'realtime-form-root', 24 | templateUrl: './app.component.html', 25 | styleUrls: ['./app.component.scss'] 26 | }) 27 | export class AppComponent implements OnInit { 28 | private dispatcher = new BehaviorSubject(new Init()); 29 | actions$ = this.dispatcher.asObservable(); 30 | store$ = this.actions$.pipe( 31 | scan((state: State, action: Action) => reducer(state, action)) 32 | ); 33 | connectedClients$ = this.store$.pipe( 34 | map((state: State) => state.connectedClients) 35 | ); 36 | data$ = this.store$.pipe(map((state: State) => state.data)); 37 | title$ = this.data$.pipe(map((state: Partial) => state.title)); 38 | description$ = this.data$.pipe( 39 | map((state: Partial) => state.description) 40 | ); 41 | form = this.fb.group({ 42 | title: [''], 43 | description: [''] 44 | }); 45 | 46 | constructor(private socket: Socket, private fb: FormBuilder) {} 47 | 48 | ngOnInit() { 49 | this.socket.on(ActionTypes.ClientConnected, (payload: string[]) => { 50 | this.dispatcher.next(new ClientConnected(payload)); 51 | }); 52 | 53 | this.socket.on(ActionTypes.Data, (payload: Partial) => { 54 | this.dispatcher.next(new Data(payload)); 55 | }); 56 | 57 | this.socket.on(ActionTypes.ValuePatched, (payload: Partial) => { 58 | this.dispatcher.next(new ValuePatched(payload)); 59 | }); 60 | 61 | merge( 62 | getPatchValueEffect(this.socket, this.actions$), 63 | getValuePatchedEffect(this.form, this.actions$), 64 | getFormChangesEffect(this.form, this.dispatcher) 65 | ).subscribe(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /apps/web-client/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from '@angular/platform-browser'; 2 | import { NgModule } from '@angular/core'; 3 | import { ReactiveFormsModule } from '@angular/forms'; 4 | 5 | import { AppComponent } from './app.component'; 6 | 7 | import { SocketIoModule, SocketIoConfig } from 'ngx-socket-io'; 8 | 9 | const config: SocketIoConfig = { 10 | url: 'http://192.168.1.2:3000', 11 | options: {} 12 | }; 13 | 14 | @NgModule({ 15 | declarations: [AppComponent], 16 | imports: [BrowserModule, ReactiveFormsModule, SocketIoModule.forRoot(config)], 17 | providers: [], 18 | bootstrap: [AppComponent] 19 | }) 20 | export class AppModule {} 21 | -------------------------------------------------------------------------------- /apps/web-client/src/app/core/actions/client-connected.action.ts: -------------------------------------------------------------------------------- 1 | import { ActionTypes } from '@realtime-form/data'; 2 | 3 | export class ClientConnected { 4 | type = ActionTypes.ClientConnected; 5 | 6 | constructor(public payload: string[]) {} 7 | } 8 | -------------------------------------------------------------------------------- /apps/web-client/src/app/core/actions/data.action.ts: -------------------------------------------------------------------------------- 1 | import { FormData, ActionTypes } from '@realtime-form/data'; 2 | 3 | export class Data { 4 | type = ActionTypes.Data; 5 | 6 | constructor(public payload: Partial) {} 7 | } 8 | -------------------------------------------------------------------------------- /apps/web-client/src/app/core/actions/index.ts: -------------------------------------------------------------------------------- 1 | import { Init } from './init.action'; 2 | import { Data } from './data.action'; 3 | import { ClientConnected } from './client-connected.action'; 4 | import { PatchValue } from './patch-value.action'; 5 | import { ValuePatched } from './value-patched.action'; 6 | 7 | export type Action = Init | Data | ClientConnected | PatchValue | ValuePatched; 8 | export { Init, Data, ClientConnected, PatchValue, ValuePatched }; 9 | -------------------------------------------------------------------------------- /apps/web-client/src/app/core/actions/init.action.ts: -------------------------------------------------------------------------------- 1 | import { ActionTypes } from '@realtime-form/data'; 2 | 3 | export class Init { 4 | type = ActionTypes.Init; 5 | payload = null; 6 | } 7 | -------------------------------------------------------------------------------- /apps/web-client/src/app/core/actions/patch-value.action.ts: -------------------------------------------------------------------------------- 1 | import { FormData, ActionTypes } from '@realtime-form/data'; 2 | 3 | export class PatchValue { 4 | type = ActionTypes.PatchValue; 5 | 6 | constructor(public payload: Partial) {} 7 | } 8 | -------------------------------------------------------------------------------- /apps/web-client/src/app/core/actions/value-patched.action.ts: -------------------------------------------------------------------------------- 1 | import { FormData, ActionTypes } from '@realtime-form/data'; 2 | 3 | export class ValuePatched { 4 | type = ActionTypes.ValuePatched; 5 | 6 | constructor(public payload: Partial) {} 7 | } 8 | -------------------------------------------------------------------------------- /apps/web-client/src/app/core/effects/form-changes.effect.ts: -------------------------------------------------------------------------------- 1 | import { Action, PatchValue } from '../actions'; 2 | import { merge, BehaviorSubject } from 'rxjs'; 3 | import { debounceTime, map, tap } from 'rxjs/operators'; 4 | import { FormGroup } from '@angular/forms'; 5 | import { FormData } from '@realtime-form/data'; 6 | 7 | export const getFormChangesEffect = ( 8 | form: FormGroup, 9 | dispatcher: BehaviorSubject 10 | ) => { 11 | const title$ = form 12 | .get('title') 13 | .valueChanges.pipe(map((title: string) => ({ title }))); 14 | 15 | const description$ = form 16 | .get('description') 17 | .valueChanges.pipe(map((description: string) => ({ description }))); 18 | 19 | return merge(title$, description$).pipe( 20 | debounceTime(300), 21 | tap((payload: Partial) => 22 | dispatcher.next(new PatchValue(payload)) 23 | ) 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /apps/web-client/src/app/core/effects/index.ts: -------------------------------------------------------------------------------- 1 | export { getFormChangesEffect } from './form-changes.effect'; 2 | export { getPatchValueEffect } from './patch-value.effect'; 3 | export { getValuePatchedEffect } from './value-patched.effect'; 4 | -------------------------------------------------------------------------------- /apps/web-client/src/app/core/effects/patch-value.effect.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '../actions'; 2 | import { Observable, asyncScheduler } from 'rxjs'; 3 | import { observeOn, filter, tap } from 'rxjs/operators'; 4 | import { ActionTypes } from '@realtime-form/data'; 5 | import { Socket } from 'ngx-socket-io'; 6 | 7 | export const getPatchValueEffect = ( 8 | socket: Socket, 9 | actions: Observable 10 | ) => { 11 | return actions.pipe( 12 | observeOn(asyncScheduler), 13 | filter(action => action.type === ActionTypes.PatchValue), 14 | tap(action => socket.emit(ActionTypes.PatchValue, action.payload)) 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /apps/web-client/src/app/core/effects/value-patched.effect.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '../actions'; 2 | import { Observable, asyncScheduler } from 'rxjs'; 3 | import { observeOn, filter, tap } from 'rxjs/operators'; 4 | import { ActionTypes } from '@realtime-form/data'; 5 | import { FormGroup } from '@angular/forms'; 6 | 7 | export const getValuePatchedEffect = ( 8 | form: FormGroup, 9 | actions: Observable 10 | ) => { 11 | return actions.pipe( 12 | observeOn(asyncScheduler), 13 | filter( 14 | action => 15 | action.type === ActionTypes.ValuePatched || 16 | action.type === ActionTypes.Data 17 | ), 18 | tap(action => form.patchValue(action.payload, { emitEvent: false })) 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /apps/web-client/src/app/core/state/index.ts: -------------------------------------------------------------------------------- 1 | export { initialState } from './initial-state.const'; 2 | export { State } from './state.interface'; 3 | export { reducer } from './state.reducer'; 4 | -------------------------------------------------------------------------------- /apps/web-client/src/app/core/state/initial-state.const.ts: -------------------------------------------------------------------------------- 1 | import { State } from './state.interface'; 2 | 3 | export const initialState = { 4 | connectedClients: [], 5 | data: {} 6 | } as State; 7 | -------------------------------------------------------------------------------- /apps/web-client/src/app/core/state/state.interface.ts: -------------------------------------------------------------------------------- 1 | import { FormData } from '@realtime-form/data'; 2 | 3 | export interface State { 4 | connectedClients: string[]; 5 | data: Partial; 6 | } 7 | -------------------------------------------------------------------------------- /apps/web-client/src/app/core/state/state.reducer.ts: -------------------------------------------------------------------------------- 1 | import { State } from './state.interface'; 2 | import { Action } from '../actions'; 3 | import { ActionTypes } from '@realtime-form/data'; 4 | import { initialState } from './initial-state.const'; 5 | 6 | export const reducer = (state: State, action: Action): State => { 7 | switch (action.type) { 8 | case ActionTypes.Init: 9 | return { ...initialState }; 10 | case ActionTypes.ClientConnected: 11 | return { 12 | ...state, 13 | connectedClients: action.payload 14 | }; 15 | case ActionTypes.Data: 16 | return { ...state, data: action.payload }; 17 | case ActionTypes.PatchValue: 18 | return { ...state, data: { ...state.data, ...action.payload } }; 19 | default: 20 | return { ...state }; 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /apps/web-client/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danmt/realtime-form/5e0fa146f3a2dd40707e81b7c5bbaebbb96e8c88/apps/web-client/src/assets/.gitkeep -------------------------------------------------------------------------------- /apps/web-client/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /apps/web-client/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/dist/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /apps/web-client/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danmt/realtime-form/5e0fa146f3a2dd40707e81b7c5bbaebbb96e8c88/apps/web-client/src/favicon.ico -------------------------------------------------------------------------------- /apps/web-client/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | WebClient 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /apps/web-client/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic() 12 | .bootstrapModule(AppModule) 13 | .catch(err => console.error(err)); 14 | -------------------------------------------------------------------------------- /apps/web-client/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 22 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 23 | 24 | /** 25 | * Web Animations `@angular/platform-browser/animations` 26 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 27 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 28 | */ 29 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 30 | 31 | /** 32 | * By default, zone.js will patch all possible macroTask and DomEvents 33 | * user can disable parts of macroTask/DomEvents patch by setting following flags 34 | * because those flags need to be set before `zone.js` being loaded, and webpack 35 | * will put import in the top of bundle, so user need to create a separate file 36 | * in this directory (for example: zone-flags.ts), and put the following flags 37 | * into that file, and then add the following code before importing zone.js. 38 | * import './zone-flags.ts'; 39 | * 40 | * The flags allowed in zone-flags.ts are listed here. 41 | * 42 | * The following flags will work for all browsers. 43 | * 44 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 45 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 46 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 47 | * 48 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 49 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 50 | * 51 | * (window as any).__Zone_enable_cross_context_check = true; 52 | * 53 | */ 54 | 55 | /*************************************************************************************************** 56 | * Zone JS is required by default for Angular itself. 57 | */ 58 | import 'zone.js/dist/zone'; // Included with Angular CLI. 59 | 60 | /*************************************************************************************************** 61 | * APPLICATION IMPORTS 62 | */ 63 | -------------------------------------------------------------------------------- /apps/web-client/src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | -------------------------------------------------------------------------------- /apps/web-client/src/test-setup.ts: -------------------------------------------------------------------------------- 1 | import 'jest-preset-angular'; 2 | -------------------------------------------------------------------------------- /apps/web-client/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": [] 6 | }, 7 | "files": ["src/main.ts", "src/polyfills.ts"], 8 | "include": ["**/*.ts"], 9 | "exclude": ["src/test-setup.ts", "**/*.spec.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /apps/web-client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["node", "jest"] 5 | }, 6 | "include": ["**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /apps/web-client/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "files": ["src/test-setup.ts"], 9 | "include": ["**/*.spec.ts", "**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /apps/web-client/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tslint.json", 3 | "rules": { 4 | "directive-selector": [true, "attribute", "realtimeForm", "camelCase"], 5 | "component-selector": [true, "element", "realtime-form", "kebab-case"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testMatch: ['**/+(*.)+(spec|test).+(ts|js)?(x)'], 3 | transform: { 4 | '^.+\\.(ts|js|html)$': 'ts-jest' 5 | }, 6 | resolver: '@nrwl/jest/plugins/resolver', 7 | moduleFileExtensions: ['ts', 'js', 'html'], 8 | coverageReporters: ['html'], 9 | passWithNoTests: true 10 | }; 11 | -------------------------------------------------------------------------------- /libs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danmt/realtime-form/5e0fa146f3a2dd40707e81b7c5bbaebbb96e8c88/libs/.gitkeep -------------------------------------------------------------------------------- /libs/data/README.md: -------------------------------------------------------------------------------- 1 | # data 2 | 3 | This library was generated with [Nx](https://nx.dev). 4 | 5 | ## Running unit tests 6 | 7 | Run `ng test data` to execute the unit tests via [Jest](https://jestjs.io). 8 | -------------------------------------------------------------------------------- /libs/data/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'data', 3 | preset: '../../jest.config.js', 4 | transform: { 5 | '^.+\\.[tj]sx?$': 'ts-jest' 6 | }, 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'html'], 8 | coverageDirectory: '../../coverage/libs/data' 9 | }; 10 | -------------------------------------------------------------------------------- /libs/data/src/index.ts: -------------------------------------------------------------------------------- 1 | export { ActionTypes } from './lib/action-types.enum'; 2 | export { FormData } from './lib/form-data.interface'; 3 | -------------------------------------------------------------------------------- /libs/data/src/lib/action-types.enum.ts: -------------------------------------------------------------------------------- 1 | export enum ActionTypes { 2 | Data = '[Socket] Data', 3 | ClientConnected = '[Socket] Client Connected', 4 | ValuePatched = '[Socket] Value Patched', 5 | PatchValue = '[Form] Patch Value', 6 | Init = '[Init] Init' 7 | } 8 | -------------------------------------------------------------------------------- /libs/data/src/lib/form-data.interface.ts: -------------------------------------------------------------------------------- 1 | export interface FormData { 2 | title: string; 3 | description: string; 4 | } 5 | -------------------------------------------------------------------------------- /libs/data/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["node", "jest"] 5 | }, 6 | "include": ["**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /libs/data/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": [] 6 | }, 7 | "exclude": ["**/*.spec.ts"], 8 | "include": ["**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /libs/data/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "**/*.spec.ts", 10 | "**/*.spec.tsx", 11 | "**/*.spec.js", 12 | "**/*.spec.jsx", 13 | "**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /libs/data/tslint.json: -------------------------------------------------------------------------------- 1 | { "extends": "../../tslint.json", "rules": {} } 2 | -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "npmScope": "realtime-form", 3 | "implicitDependencies": { 4 | "angular.json": "*", 5 | "package.json": { 6 | "dependencies": "*", 7 | "devDependencies": "*" 8 | }, 9 | "tsconfig.json": "*", 10 | "tslint.json": "*", 11 | "nx.json": "*" 12 | }, 13 | "projects": { 14 | "realtime-form": { 15 | "tags": [] 16 | }, 17 | "realtime-form-e2e": { 18 | "tags": [], 19 | "implicitDependencies": ["realtime-form"] 20 | }, 21 | "api": { 22 | "tags": [] 23 | }, 24 | "web-client": { 25 | "tags": [] 26 | }, 27 | "web-client-e2e": { 28 | "tags": [], 29 | "implicitDependencies": ["web-client"] 30 | }, 31 | "data": { 32 | "tags": [] 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "realtime-form", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "ng": "ng", 7 | "nx": "nx", 8 | "start": "ng serve", 9 | "build": "ng build", 10 | "test": "ng test", 11 | "lint": "nx workspace-lint && ng lint", 12 | "e2e": "ng e2e", 13 | "affected:apps": "nx affected:apps", 14 | "affected:libs": "nx affected:libs", 15 | "affected:build": "nx affected:build", 16 | "affected:e2e": "nx affected:e2e", 17 | "affected:test": "nx affected:test", 18 | "affected:lint": "nx affected:lint", 19 | "affected:dep-graph": "nx affected:dep-graph", 20 | "affected": "nx affected", 21 | "format": "nx format:write", 22 | "format:write": "nx format:write", 23 | "format:check": "nx format:check", 24 | "update": "ng update @nrwl/workspace", 25 | "workspace-schematic": "nx workspace-schematic", 26 | "dep-graph": "nx dep-graph", 27 | "help": "nx help" 28 | }, 29 | "private": true, 30 | "dependencies": { 31 | "@angular/animations": "^8.2.0", 32 | "@angular/common": "^8.2.0", 33 | "@angular/compiler": "^8.2.0", 34 | "@angular/core": "^8.2.0", 35 | "@angular/forms": "^8.2.0", 36 | "@angular/platform-browser": "^8.2.0", 37 | "@angular/platform-browser-dynamic": "^8.2.0", 38 | "@angular/router": "^8.2.0", 39 | "@nestjs/common": "^6.8.3", 40 | "@nestjs/core": "^6.8.3", 41 | "@nestjs/platform-express": "^6.8.3", 42 | "@nestjs/platform-socket.io": "^6.11.5", 43 | "@nestjs/websockets": "^6.11.5", 44 | "@nrwl/angular": "8.12.0", 45 | "core-js": "^2.5.4", 46 | "ngx-socket-io": "^3.0.1", 47 | "reflect-metadata": "^0.1.13", 48 | "rxjs": "~6.4.0", 49 | "zone.js": "^0.9.1" 50 | }, 51 | "devDependencies": { 52 | "@angular-devkit/build-angular": "^0.803.23", 53 | "@angular/cli": "8.3.14", 54 | "@angular/compiler-cli": "^8.2.0", 55 | "@angular/language-service": "^8.2.0", 56 | "@nestjs/schematics": "^6.7.0", 57 | "@nestjs/testing": "^6.8.3", 58 | "@nrwl/cypress": "8.12.0", 59 | "@nrwl/jest": "8.12.0", 60 | "@nrwl/nest": "8.12.0", 61 | "@nrwl/node": "8.12.0", 62 | "@nrwl/workspace": "8.12.0", 63 | "@types/jest": "24.0.9", 64 | "@types/node": "~8.9.4", 65 | "@types/socket.io": "^2.1.4", 66 | "codelyzer": "~5.0.1", 67 | "cypress": "^3.8.2", 68 | "dotenv": "6.2.0", 69 | "eslint": "6.1.0", 70 | "jest": "24.1.0", 71 | "jest-preset-angular": "7.0.0", 72 | "prettier": "1.18.2", 73 | "ts-jest": "24.0.0", 74 | "ts-node": "~7.0.0", 75 | "tslint": "~5.11.0", 76 | "typescript": "~3.5.3" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tools/schematics/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danmt/realtime-form/5e0fa146f3a2dd40707e81b7c5bbaebbb96e8c88/tools/schematics/.gitkeep -------------------------------------------------------------------------------- /tools/tsconfig.tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/out-tsc/tools", 5 | "rootDir": ".", 6 | "module": "commonjs", 7 | "target": "es5", 8 | "types": ["node"] 9 | }, 10 | "include": ["**/*.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "sourceMap": true, 6 | "declaration": false, 7 | "moduleResolution": "node", 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "importHelpers": true, 11 | "target": "es2015", 12 | "module": "esnext", 13 | "typeRoots": ["node_modules/@types"], 14 | "lib": ["es2017", "dom"], 15 | "skipLibCheck": true, 16 | "skipDefaultLibCheck": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "@realtime-form/data": ["libs/data/src/index.ts"] 20 | } 21 | }, 22 | "exclude": ["node_modules", "tmp"] 23 | } 24 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": [ 3 | "node_modules/@nrwl/workspace/src/tslint", 4 | "node_modules/codelyzer" 5 | ], 6 | "rules": { 7 | "arrow-return-shorthand": true, 8 | "callable-types": true, 9 | "class-name": true, 10 | "deprecation": { 11 | "severity": "warn" 12 | }, 13 | "forin": true, 14 | "import-blacklist": [true, "rxjs/Rx"], 15 | "interface-over-type-literal": true, 16 | "member-access": false, 17 | "member-ordering": [ 18 | true, 19 | { 20 | "order": [ 21 | "static-field", 22 | "instance-field", 23 | "static-method", 24 | "instance-method" 25 | ] 26 | } 27 | ], 28 | "no-arg": true, 29 | "no-bitwise": true, 30 | "no-console": [true, "debug", "info", "time", "timeEnd", "trace"], 31 | "no-construct": true, 32 | "no-debugger": true, 33 | "no-duplicate-super": true, 34 | "no-empty": false, 35 | "no-empty-interface": true, 36 | "no-eval": true, 37 | "no-inferrable-types": [true, "ignore-params"], 38 | "no-misused-new": true, 39 | "no-non-null-assertion": true, 40 | "no-shadowed-variable": true, 41 | "no-string-literal": false, 42 | "no-string-throw": true, 43 | "no-switch-case-fall-through": true, 44 | "no-unnecessary-initializer": true, 45 | "no-unused-expression": true, 46 | "no-var-keyword": true, 47 | "object-literal-sort-keys": false, 48 | "prefer-const": true, 49 | "radix": true, 50 | "triple-equals": [true, "allow-null-check"], 51 | "unified-signatures": true, 52 | "variable-name": false, 53 | "nx-enforce-module-boundaries": [ 54 | true, 55 | { 56 | "enforceBuildableLibDependency": true, 57 | "allow": [], 58 | "depConstraints": [ 59 | { 60 | "sourceTag": "*", 61 | "onlyDependOnLibsWithTags": ["*"] 62 | } 63 | ] 64 | } 65 | ], 66 | "directive-selector": [true, "attribute", "app", "camelCase"], 67 | "component-selector": [true, "element", "app", "kebab-case"], 68 | "no-conflicting-lifecycle": true, 69 | "no-host-metadata-property": true, 70 | "no-input-rename": true, 71 | "no-inputs-metadata-property": true, 72 | "no-output-native": true, 73 | "no-output-on-prefix": true, 74 | "no-output-rename": true, 75 | "no-outputs-metadata-property": true, 76 | "template-banana-in-box": true, 77 | "template-no-negated-async": true, 78 | "use-lifecycle-interface": true, 79 | "use-pipe-transform-interface": true 80 | } 81 | } 82 | --------------------------------------------------------------------------------