├── .gitignore ├── ManagingFileUploadsWithNgRx.pdf ├── README.md ├── angular.json ├── apps ├── .gitkeep ├── api │ ├── jest.config.js │ ├── src │ │ ├── app │ │ │ ├── .gitkeep │ │ │ ├── app.controller.ts │ │ │ └── app.module.ts │ │ ├── assets │ │ │ └── .gitkeep │ │ ├── environments │ │ │ ├── environment.prod.ts │ │ │ └── environment.ts │ │ └── main.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.spec.json │ └── tslint.json ├── app-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 └── app │ ├── browserslist │ ├── jest.config.js │ ├── proxy.conf.json │ ├── src │ ├── app │ │ ├── app.component.css │ │ ├── app.component.html │ │ ├── app.component.spec.ts │ │ ├── app.component.ts │ │ └── app.module.ts │ ├── assets │ │ ├── .gitkeep │ │ └── ngrx-badge.svg │ ├── environments │ │ ├── environment.prod.ts │ │ └── environment.ts │ ├── favicon.ico │ ├── index.html │ ├── main.ts │ ├── polyfills.ts │ ├── styles.css │ └── test-setup.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.spec.json │ └── tslint.json ├── jest.config.js ├── libs ├── .gitkeep └── file-upload │ ├── README.md │ ├── jest.config.js │ ├── src │ ├── index.ts │ ├── lib │ │ ├── file-upload.module.spec.ts │ │ ├── file-upload.module.ts │ │ ├── file-upload │ │ │ ├── file-upload.component.css │ │ │ ├── file-upload.component.html │ │ │ ├── file-upload.component.spec.ts │ │ │ └── file-upload.component.ts │ │ ├── models │ │ │ ├── file-upload-model.ts │ │ │ ├── file-upload-status.ts │ │ │ ├── file-view-model.ts │ │ │ └── index.ts │ │ ├── pipes │ │ │ ├── file-size.pipe.ts │ │ │ └── index.ts │ │ ├── services │ │ │ ├── file-upload.service.spec.ts │ │ │ ├── file-upload.service.ts │ │ │ └── index.ts │ │ └── state │ │ │ ├── file-upload-api.actions.ts │ │ │ ├── file-upload-ui.actions.ts │ │ │ ├── file-upload.effects.ts │ │ │ ├── file-upload.reducer.ts │ │ │ ├── file-upload.selectors.ts │ │ │ └── index.ts │ └── test-setup.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 /.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 | !.vscode/snippets.code-snippets 27 | 28 | # misc 29 | /.sass-cache 30 | /connect.lock 31 | /coverage 32 | /libpeerconnection.log 33 | npm-debug.log 34 | yarn-error.log 35 | testem.log 36 | /typings 37 | 38 | # System Files 39 | .DS_Store 40 | Thumbs.db 41 | node_modules 42 | -------------------------------------------------------------------------------- /ManagingFileUploadsWithNgRx.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wesleygrimes/managing-file-uploads-with-ngrx/9e344617546fab19e3b007205cf2f33c7607ab22/ManagingFileUploadsWithNgRx.pdf -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RealWorldApp 2 | 3 | Run the app: `npm run serve-with-api` 4 | 5 | Download the presentation: [ManagingFileUploadsWithNgRx.pdf](./ManagingFileUploadsWithNgRx.pdf) 6 | 7 | ## Further help 8 | 9 | Visit the [Nx Documentation](https://nx.dev/angular) to learn more. 10 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "projects": { 4 | "app": { 5 | "projectType": "application", 6 | "schematics": {}, 7 | "root": "apps/app", 8 | "sourceRoot": "apps/app/src", 9 | "prefix": "app", 10 | "architect": { 11 | "build": { 12 | "builder": "@angular-devkit/build-angular:browser", 13 | "options": { 14 | "outputPath": "dist/apps/app", 15 | "index": "apps/app/src/index.html", 16 | "main": "apps/app/src/main.ts", 17 | "polyfills": "apps/app/src/polyfills.ts", 18 | "tsConfig": "apps/app/tsconfig.app.json", 19 | "aot": false, 20 | "assets": ["apps/app/src/favicon.ico", "apps/app/src/assets"], 21 | "styles": ["apps/app/src/styles.css"], 22 | "scripts": [] 23 | }, 24 | "configurations": { 25 | "production": { 26 | "fileReplacements": [ 27 | { 28 | "replace": "apps/app/src/environments/environment.ts", 29 | "with": "apps/app/src/environments/environment.prod.ts" 30 | } 31 | ], 32 | "optimization": true, 33 | "outputHashing": "all", 34 | "sourceMap": false, 35 | "extractCss": true, 36 | "namedChunks": false, 37 | "aot": true, 38 | "extractLicenses": true, 39 | "vendorChunk": false, 40 | "buildOptimizer": true, 41 | "budgets": [ 42 | { 43 | "type": "initial", 44 | "maximumWarning": "2mb", 45 | "maximumError": "5mb" 46 | }, 47 | { 48 | "type": "anyComponentStyle", 49 | "maximumWarning": "6kb", 50 | "maximumError": "10kb" 51 | } 52 | ] 53 | } 54 | } 55 | }, 56 | "serve": { 57 | "builder": "@angular-devkit/build-angular:dev-server", 58 | "options": { 59 | "browserTarget": "app:build", 60 | "proxyConfig": "apps/app/proxy.conf.json" 61 | }, 62 | "configurations": { 63 | "production": { 64 | "browserTarget": "app:build:production" 65 | } 66 | } 67 | }, 68 | "serve-with-api": { 69 | "builder": "@angular-devkit/architect:allOf", 70 | "options": { 71 | "targets": [ 72 | { 73 | "target": "app:serve" 74 | }, 75 | { 76 | "target": "api:serve" 77 | } 78 | ] 79 | } 80 | }, 81 | "extract-i18n": { 82 | "builder": "@angular-devkit/build-angular:extract-i18n", 83 | "options": { 84 | "browserTarget": "app:build" 85 | } 86 | }, 87 | "lint": { 88 | "builder": "@angular-devkit/build-angular:tslint", 89 | "options": { 90 | "tsConfig": [ 91 | "apps/app/tsconfig.app.json", 92 | "apps/app/tsconfig.spec.json" 93 | ], 94 | "exclude": ["**/node_modules/**", "!apps/app/**"] 95 | } 96 | }, 97 | "test": { 98 | "builder": "@nrwl/jest:jest", 99 | "options": { 100 | "jestConfig": "apps/app/jest.config.js", 101 | "tsConfig": "apps/app/tsconfig.spec.json", 102 | "setupFile": "apps/app/src/test-setup.ts" 103 | } 104 | } 105 | } 106 | }, 107 | "app-e2e": { 108 | "root": "apps/app-e2e", 109 | "sourceRoot": "apps/app-e2e/src", 110 | "projectType": "application", 111 | "architect": { 112 | "e2e": { 113 | "builder": "@nrwl/cypress:cypress", 114 | "options": { 115 | "cypressConfig": "apps/app-e2e/cypress.json", 116 | "tsConfig": "apps/app-e2e/tsconfig.e2e.json", 117 | "devServerTarget": "app:serve" 118 | }, 119 | "configurations": { 120 | "production": { 121 | "devServerTarget": "app:serve:production" 122 | } 123 | } 124 | }, 125 | "lint": { 126 | "builder": "@angular-devkit/build-angular:tslint", 127 | "options": { 128 | "tsConfig": ["apps/app-e2e/tsconfig.e2e.json"], 129 | "exclude": ["**/node_modules/**", "!apps/app-e2e/**"] 130 | } 131 | } 132 | } 133 | }, 134 | "api": { 135 | "root": "apps/api", 136 | "sourceRoot": "apps/api/src", 137 | "projectType": "application", 138 | "prefix": "api", 139 | "schematics": {}, 140 | "architect": { 141 | "build": { 142 | "builder": "@nrwl/node:build", 143 | "options": { 144 | "outputPath": "dist/apps/api", 145 | "main": "apps/api/src/main.ts", 146 | "tsConfig": "apps/api/tsconfig.app.json", 147 | "assets": ["apps/api/src/assets"] 148 | }, 149 | "configurations": { 150 | "production": { 151 | "optimization": true, 152 | "extractLicenses": true, 153 | "inspect": false, 154 | "fileReplacements": [ 155 | { 156 | "replace": "apps/api/src/environments/environment.ts", 157 | "with": "apps/api/src/environments/environment.prod.ts" 158 | } 159 | ] 160 | } 161 | } 162 | }, 163 | "serve": { 164 | "builder": "@nrwl/node:execute", 165 | "options": { 166 | "buildTarget": "api:build" 167 | } 168 | }, 169 | "lint": { 170 | "builder": "@angular-devkit/build-angular:tslint", 171 | "options": { 172 | "tsConfig": [ 173 | "apps/api/tsconfig.app.json", 174 | "apps/api/tsconfig.spec.json" 175 | ], 176 | "exclude": ["**/node_modules/**", "!apps/api/**"] 177 | } 178 | }, 179 | "test": { 180 | "builder": "@nrwl/jest:jest", 181 | "options": { 182 | "jestConfig": "apps/api/jest.config.js", 183 | "tsConfig": "apps/api/tsconfig.spec.json" 184 | } 185 | } 186 | } 187 | }, 188 | "file-upload": { 189 | "projectType": "library", 190 | "root": "libs/file-upload", 191 | "sourceRoot": "libs/file-upload/src", 192 | "prefix": "app", 193 | "architect": { 194 | "lint": { 195 | "builder": "@angular-devkit/build-angular:tslint", 196 | "options": { 197 | "tsConfig": [ 198 | "libs/file-upload/tsconfig.lib.json", 199 | "libs/file-upload/tsconfig.spec.json" 200 | ], 201 | "exclude": ["**/node_modules/**", "!libs/file-upload/**"] 202 | } 203 | }, 204 | "test": { 205 | "builder": "@nrwl/jest:jest", 206 | "options": { 207 | "jestConfig": "libs/file-upload/jest.config.js", 208 | "tsConfig": "libs/file-upload/tsconfig.spec.json", 209 | "setupFile": "libs/file-upload/src/test-setup.ts" 210 | } 211 | } 212 | }, 213 | "schematics": {} 214 | } 215 | }, 216 | "cli": { 217 | "defaultCollection": "@nrwl/angular" 218 | }, 219 | "schematics": { 220 | "@nrwl/angular:application": { 221 | "unitTestRunner": "jest", 222 | "e2eTestRunner": "cypress" 223 | }, 224 | "@nrwl/angular:library": { 225 | "unitTestRunner": "jest" 226 | } 227 | }, 228 | "defaultProject": "app" 229 | } 230 | -------------------------------------------------------------------------------- /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/wesleygrimes/managing-file-uploads-with-ngrx/9e344617546fab19e3b007205cf2f33c7607ab22/apps/api/src/app/.gitkeep -------------------------------------------------------------------------------- /apps/api/src/app/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Controller, 4 | HttpCode, 5 | Post, 6 | UploadedFile, 7 | UseInterceptors 8 | } from '@nestjs/common'; 9 | import { FileInterceptor } from '@nestjs/platform-express'; 10 | import { writeFileSync } from 'fs'; 11 | 12 | @Controller() 13 | export class AppController { 14 | private UPLOAD_PATH = './tmp'; 15 | constructor() {} 16 | 17 | @Post('upload') 18 | @HttpCode(200) 19 | @UseInterceptors(FileInterceptor('file')) 20 | async uploadFile(@UploadedFile() 21 | file: { 22 | originalname: string; 23 | buffer: Buffer; 24 | }) { 25 | try { 26 | writeFileSync(`${this.UPLOAD_PATH}/${file.originalname}`, file.buffer); 27 | } catch (error) { 28 | throw new BadRequestException(`Failed to upload file. ${error}`); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /apps/api/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | @Module({ 4 | controllers: [AppController] 5 | }) 6 | export class AppModule {} 7 | -------------------------------------------------------------------------------- /apps/api/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wesleygrimes/managing-file-uploads-with-ngrx/9e344617546fab19e3b007205cf2f33c7607ab22/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 | import * as bodyParser from 'body-parser'; 8 | import { AppModule } from './app/app.module'; 9 | 10 | async function bootstrap() { 11 | const app = await NestFactory.create(AppModule); 12 | const globalPrefix = 'api'; 13 | app.setGlobalPrefix(globalPrefix); 14 | app.use(bodyParser.json({ limit: '50mb' })); 15 | app.use(bodyParser.urlencoded({ limit: '50mb', extended: true })); 16 | const port = process.env.port || 3333; 17 | await app.listen(port, () => { 18 | console.log('Listening at http://localhost:' + port + '/' + globalPrefix); 19 | }); 20 | } 21 | 22 | bootstrap(); 23 | -------------------------------------------------------------------------------- /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 | }, 6 | "include": ["**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /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/app-e2e/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "fileServerFolder": ".", 3 | "fixturesFolder": "./src/fixtures", 4 | "integrationFolder": "./src/integration", 5 | "pluginsFile": "./src/plugins/index", 6 | "supportFile": false, 7 | "video": true, 8 | "videosFolder": "../../dist/cypress/apps/app-e2e/videos", 9 | "screenshotsFolder": "../../dist/cypress/apps/app-e2e/screenshots", 10 | "chromeWebSecurity": false 11 | } 12 | -------------------------------------------------------------------------------- /apps/app-e2e/src/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io" 4 | } 5 | -------------------------------------------------------------------------------- /apps/app-e2e/src/integration/app.spec.ts: -------------------------------------------------------------------------------- 1 | import { getGreeting } from '../support/app.po'; 2 | 3 | describe('app', () => { 4 | beforeEach(() => cy.visit('/')); 5 | 6 | it('should display welcome message', () => { 7 | getGreeting().contains('Welcome to app!'); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /apps/app-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 21 | on('file:preprocessor', preprocessTypescript(config)); 22 | }; 23 | -------------------------------------------------------------------------------- /apps/app-e2e/src/support/app.po.ts: -------------------------------------------------------------------------------- 1 | export const getGreeting = () => cy.get('h1'); 2 | -------------------------------------------------------------------------------- /apps/app-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 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /apps/app-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/app-e2e/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": false, 5 | "outDir": "../../dist/out-tsc" 6 | }, 7 | "include": ["src/**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /apps/app-e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["cypress", "node"] 5 | }, 6 | "include": ["**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /apps/app-e2e/tslint.json: -------------------------------------------------------------------------------- 1 | { "extends": "../../tslint.json", "rules": [] } 2 | -------------------------------------------------------------------------------- /apps/app/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/app/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'app', 3 | preset: '../../jest.config.js', 4 | coverageDirectory: '../../coverage/apps/app', 5 | snapshotSerializers: [ 6 | 'jest-preset-angular/AngularSnapshotSerializer.js', 7 | 'jest-preset-angular/HTMLCommentSerializer.js' 8 | ] 9 | }; 10 | -------------------------------------------------------------------------------- /apps/app/proxy.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "/api": { 3 | "target": "http://localhost:3333", 4 | "secure": false 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /apps/app/src/app/app.component.css: -------------------------------------------------------------------------------- 1 | .container-fluid { 2 | margin-top: 15px; 3 | } 4 | -------------------------------------------------------------------------------- /apps/app/src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 | 14 | 15 | 16 |
17 | -------------------------------------------------------------------------------- /apps/app/src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { TestBed, async } from '@angular/core/testing'; 3 | import { HttpClientModule } from '@angular/common/http'; 4 | import { AppComponent } from './app.component'; 5 | 6 | describe('AppComponent', () => { 7 | beforeEach(async(() => { 8 | TestBed.configureTestingModule({ 9 | declarations: [AppComponent], 10 | imports: [HttpClientModule] 11 | }).compileComponents(); 12 | })); 13 | 14 | it('should create the app', () => { 15 | const fixture = TestBed.createComponent(AppComponent); 16 | const app = fixture.debugElement.componentInstance; 17 | expect(app).toBeTruthy(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /apps/app/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-root', 5 | templateUrl: './app.component.html', 6 | styleUrls: ['./app.component.css'] 7 | }) 8 | export class AppComponent {} 9 | -------------------------------------------------------------------------------- /apps/app/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { HttpClientModule } from '@angular/common/http'; 2 | import { NgModule } from '@angular/core'; 3 | import { BrowserModule } from '@angular/platform-browser'; 4 | import { FileUploadModule } from '@app/file-upload'; 5 | import { EffectsModule } from '@ngrx/effects'; 6 | import { StoreModule } from '@ngrx/store'; 7 | import { StoreDevtoolsModule } from '@ngrx/store-devtools'; 8 | import { AppComponent } from './app.component'; 9 | 10 | @NgModule({ 11 | declarations: [AppComponent], 12 | imports: [ 13 | BrowserModule, 14 | HttpClientModule, 15 | StoreModule.forRoot({}), 16 | EffectsModule.forRoot([]), 17 | StoreDevtoolsModule.instrument(), 18 | FileUploadModule 19 | ], 20 | bootstrap: [AppComponent] 21 | }) 22 | export class AppModule {} 23 | -------------------------------------------------------------------------------- /apps/app/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wesleygrimes/managing-file-uploads-with-ngrx/9e344617546fab19e3b007205cf2f33c7607ab22/apps/app/src/assets/.gitkeep -------------------------------------------------------------------------------- /apps/app/src/assets/ngrx-badge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | badge 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /apps/app/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /apps/app/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/app/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wesleygrimes/managing-file-uploads-with-ngrx/9e344617546fab19e3b007205cf2f33c7607ab22/apps/app/src/favicon.ico -------------------------------------------------------------------------------- /apps/app/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Managing File Uploads w/ NgRx 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /apps/app/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/app/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/app/src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | @import '~bootstrap/dist/css/bootstrap.min.css'; 3 | -------------------------------------------------------------------------------- /apps/app/src/test-setup.ts: -------------------------------------------------------------------------------- 1 | import 'jest-preset-angular'; 2 | -------------------------------------------------------------------------------- /apps/app/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/app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["node", "jest"] 5 | }, 6 | "include": ["**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /apps/app/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/app/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tslint.json", 3 | "rules": { 4 | "directive-selector": [true, "attribute", "realWorldApp", "camelCase"], 5 | "component-selector": [true, "element", "app", "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/wesleygrimes/managing-file-uploads-with-ngrx/9e344617546fab19e3b007205cf2f33c7607ab22/libs/.gitkeep -------------------------------------------------------------------------------- /libs/file-upload/README.md: -------------------------------------------------------------------------------- 1 | # file-upload 2 | 3 | This library was generated with [Nx](https://nx.dev). 4 | 5 | ## Running unit tests 6 | 7 | Run `nx test file-upload` to execute the unit tests. 8 | -------------------------------------------------------------------------------- /libs/file-upload/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'file-upload', 3 | preset: '../../jest.config.js', 4 | coverageDirectory: '../../coverage/libs/file-upload', 5 | snapshotSerializers: [ 6 | 'jest-preset-angular/AngularSnapshotSerializer.js', 7 | 'jest-preset-angular/HTMLCommentSerializer.js' 8 | ] 9 | }; 10 | -------------------------------------------------------------------------------- /libs/file-upload/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/file-upload.module'; 2 | -------------------------------------------------------------------------------- /libs/file-upload/src/lib/file-upload.module.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, TestBed } from '@angular/core/testing'; 2 | import { FileUploadModule } from './file-upload.module'; 3 | 4 | describe('FileUploadModule', () => { 5 | beforeEach(async(() => { 6 | TestBed.configureTestingModule({ 7 | imports: [FileUploadModule] 8 | }).compileComponents(); 9 | })); 10 | 11 | it('should create', () => { 12 | expect(FileUploadModule).toBeDefined(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /libs/file-upload/src/lib/file-upload.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; 4 | import { NgbProgressbarModule } from '@ng-bootstrap/ng-bootstrap'; 5 | import { EffectsModule } from '@ngrx/effects'; 6 | import { StoreModule } from '@ngrx/store'; 7 | import { FileUploadComponent } from './file-upload/file-upload.component'; 8 | import { FileSizePipe } from './pipes'; 9 | import { FileUploadEffects, fileUploadFeatureKey, reducer } from './state'; 10 | 11 | @NgModule({ 12 | imports: [ 13 | CommonModule, 14 | FontAwesomeModule, 15 | NgbProgressbarModule, 16 | StoreModule.forFeature(fileUploadFeatureKey, reducer), 17 | EffectsModule.forFeature([FileUploadEffects]) 18 | ], 19 | declarations: [FileUploadComponent, FileSizePipe], 20 | exports: [FileUploadComponent] 21 | }) 22 | export class FileUploadModule {} 23 | -------------------------------------------------------------------------------- /libs/file-upload/src/lib/file-upload/file-upload.component.css: -------------------------------------------------------------------------------- 1 | /* Container */ 2 | .file-upload { 3 | margin-top: 10px; 4 | } 5 | 6 | /* Buttons */ 7 | .upload-files, 8 | .clear-files, 9 | .cancel-upload { 10 | margin-left: 10px; 11 | } 12 | 13 | .upload-files > label, 14 | .browse-files > label, 15 | .clear-files > label, 16 | .cancel-upload > label { 17 | margin-bottom: 0; 18 | } 19 | 20 | /* File Status */ 21 | .red { 22 | color: red; 23 | } 24 | 25 | .black { 26 | color: black; 27 | } 28 | 29 | .green { 30 | color: green; 31 | } 32 | 33 | .file-upload-error { 34 | margin-left: 5px; 35 | color: red; 36 | } 37 | 38 | /* Other */ 39 | .file-input { 40 | display: none; 41 | } 42 | 43 | /* File Upload Table */ 44 | table.file-list { 45 | color: #333; 46 | margin-top: 0; 47 | margin-bottom: 20px; 48 | width: 100%; 49 | border-collapse: collapse; 50 | border-spacing: 0; 51 | } 52 | 53 | td, 54 | th { 55 | border: 1px solid transparent; 56 | height: 30px; 57 | transition: all 0.3s; 58 | } 59 | 60 | tr:nth-child(even) td { 61 | background: #f1f1f1; 62 | border-bottom: 1px solid #333; 63 | } 64 | 65 | tr:nth-child(odd) td { 66 | background: #fefefe; 67 | border-bottom: 1px solid #333; 68 | } 69 | 70 | .file-name { 71 | width: 40%; 72 | } 73 | 74 | .file-size { 75 | width: 100px; 76 | } 77 | 78 | .file-action { 79 | width: 100px; 80 | text-align: center; 81 | } 82 | 83 | .file-action-buttons { 84 | display: grid; 85 | grid-template-columns: 1fr 1fr; 86 | } 87 | -------------------------------------------------------------------------------- /libs/file-upload/src/lib/file-upload/file-upload.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 11 | 18 | 25 | 32 |
33 |
34 | 35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 67 | 76 | 77 | 78 |
File NameFile SizeUpload Status
{{ file.fileName }}{{ file.formattedFileSize }} 50 |
51 | {{ file.progress }}% 62 | {{ 63 | file.errorMessage 64 | }} 65 |
66 |
68 |
69 | 74 |
75 |
79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 |
No files chosen
87 |
88 |
89 |
90 | -------------------------------------------------------------------------------- /libs/file-upload/src/lib/file-upload/file-upload.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { FileUploadComponent } from './file-upload.component'; 4 | 5 | describe('FileUploadComponent', () => { 6 | let component: FileUploadComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ FileUploadComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(FileUploadComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /libs/file-upload/src/lib/file-upload/file-upload.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { faUndo } from '@fortawesome/free-solid-svg-icons'; 3 | import { Store } from '@ngrx/store'; 4 | import { FileUploadSelectors, FileUploadUIActions } from '../state'; 5 | 6 | @Component({ 7 | selector: 'app-file-upload', 8 | templateUrl: './file-upload.component.html', 9 | styleUrls: ['./file-upload.component.css'] 10 | }) 11 | export class FileUploadComponent { 12 | filesInQueue$ = this.store.select(FileUploadSelectors.selectFileUploadQueue); 13 | 14 | faUndo = faUndo; 15 | 16 | constructor(private store: Store<{}>) {} 17 | 18 | addFiles(event) { 19 | const files: File[] = event.target.files ? [...event.target.files] : []; 20 | 21 | files.forEach(file => 22 | this.store.dispatch(FileUploadUIActions.added({ file })) 23 | ); 24 | 25 | event.target.value = ''; 26 | } 27 | 28 | requestRetry(id: string) { 29 | this.store.dispatch(FileUploadUIActions.retryRequested({ id })); 30 | } 31 | 32 | requestCancel() { 33 | this.store.dispatch(FileUploadUIActions.cancelRequested()); 34 | } 35 | 36 | requestProcess() { 37 | this.store.dispatch(FileUploadUIActions.processRequested()); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /libs/file-upload/src/lib/models/file-upload-model.ts: -------------------------------------------------------------------------------- 1 | import { FileUploadStatus } from './file-upload-status'; 2 | export interface FileUploadModel { 3 | id: string; 4 | fileName: string; 5 | fileSize: number; 6 | rawFile: File; 7 | progress: number; 8 | status: FileUploadStatus; 9 | error: string; 10 | } 11 | 12 | export interface FileUploadState { 13 | ids: string[]; 14 | entities: { [id: string]: FileUploadModel }; 15 | } 16 | -------------------------------------------------------------------------------- /libs/file-upload/src/lib/models/file-upload-status.ts: -------------------------------------------------------------------------------- 1 | export enum FileUploadStatus { 2 | Ready = 'Ready', 3 | Requested = 'Requested', 4 | Started = 'Started', 5 | InProgress = 'InProgress', 6 | Completed = 'Completed', 7 | Failed = 'Failed', 8 | Canceled = 'Canceled' 9 | } 10 | -------------------------------------------------------------------------------- /libs/file-upload/src/lib/models/file-view-model.ts: -------------------------------------------------------------------------------- 1 | import { IconDefinition } from '@fortawesome/free-solid-svg-icons'; 2 | 3 | export interface FileViewModel { 4 | id: string; 5 | fileName: string; 6 | formattedFileSize: string; 7 | canRetry: boolean; 8 | canDelete: boolean; 9 | statusIcon: IconDefinition; 10 | statusColorClass: 'red' | 'green' | 'black'; 11 | showProgress: boolean; 12 | progress: number; 13 | errorMessage: string; 14 | } 15 | -------------------------------------------------------------------------------- /libs/file-upload/src/lib/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './file-upload-model'; 2 | export * from './file-upload-status'; 3 | export * from './file-view-model'; 4 | -------------------------------------------------------------------------------- /libs/file-upload/src/lib/pipes/file-size.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | @Pipe({ 4 | name: 'fileSize' 5 | }) 6 | export class FileSizePipe implements PipeTransform { 7 | transform(value: any, args?: any): any { 8 | if (value && !isNaN(value)) { 9 | return `${value / 1024}`; 10 | } 11 | return value; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /libs/file-upload/src/lib/pipes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './file-size.pipe'; 2 | -------------------------------------------------------------------------------- /libs/file-upload/src/lib/services/file-upload.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { FileUploadService } from './file-upload.service'; 4 | 5 | describe('FileUploadService', () => { 6 | beforeEach(() => TestBed.configureTestingModule({})); 7 | 8 | it('should be created', () => { 9 | const service: FileUploadService = TestBed.get(FileUploadService); 10 | expect(service).toBeTruthy(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /libs/file-upload/src/lib/services/file-upload.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient, HttpRequest } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | 4 | @Injectable({ 5 | providedIn: 'root' 6 | }) 7 | export class FileUploadService { 8 | private _apiBaseUrl = '/api'; 9 | constructor(private http: HttpClient) {} 10 | 11 | uploadFile(file: File) { 12 | const formData = new FormData(); 13 | formData.append('file', file); 14 | 15 | const httpOptions = { 16 | reportProgress: true 17 | }; 18 | 19 | const req = new HttpRequest( 20 | 'POST', 21 | `${this._apiBaseUrl}/upload`, 22 | formData, 23 | httpOptions 24 | ); 25 | 26 | return this.http.request(req); 27 | } 28 | 29 | uploadFileError(file: File) { 30 | const formData = new FormData(); 31 | formData.append('file', file); 32 | 33 | const httpOptions = { 34 | reportProgress: true 35 | }; 36 | 37 | const req = new HttpRequest('POST', ``, formData, httpOptions); 38 | 39 | return this.http.request(req); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /libs/file-upload/src/lib/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './file-upload.service'; 2 | -------------------------------------------------------------------------------- /libs/file-upload/src/lib/state/file-upload-api.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | import { FileUploadModel } from '../models'; 3 | 4 | export const uploadRequested = createAction( 5 | '[Upload Effect] Upload Requested', 6 | props<{ fileToUpload: FileUploadModel }>() 7 | ); 8 | export const uploadFailed = createAction( 9 | '[Upload API] Upload Failed', 10 | props<{ id: string; error: string }>() 11 | ); 12 | export const uploadStarted = createAction( 13 | '[Upload API] Upload Started', 14 | props<{ id: string }>() 15 | ); 16 | export const uploadProgressed = createAction( 17 | '[Upload API] Upload Progressed', 18 | props<{ id: string; progress: number }>() 19 | ); 20 | export const uploadCompleted = createAction( 21 | '[Upload API] Upload Completed', 22 | props<{ id: string }>() 23 | ); 24 | -------------------------------------------------------------------------------- /libs/file-upload/src/lib/state/file-upload-ui.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | 3 | export const added = createAction( 4 | '[Upload Form] Added', 5 | props<{ file: File }>() 6 | ); 7 | 8 | export const removed = createAction( 9 | '[Upload Form] Removed', 10 | props<{ id: string }>() 11 | ); 12 | 13 | export const processRequested = createAction('[Upload Form] Process Requested'); 14 | 15 | export const cancelRequested = createAction('[Upload Form] Cancel Requested'); 16 | 17 | export const retryRequested = createAction( 18 | '[Upload Form] Retry Requested', 19 | props<{ id: string }>() 20 | ); 21 | -------------------------------------------------------------------------------- /libs/file-upload/src/lib/state/file-upload.effects.ts: -------------------------------------------------------------------------------- 1 | import { HttpEvent, HttpEventType } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { Actions, createEffect, ofType } from '@ngrx/effects'; 4 | import { Store } from '@ngrx/store'; 5 | import { of } from 'rxjs'; 6 | import { 7 | catchError, 8 | map, 9 | mergeMap, 10 | switchMap, 11 | takeUntil, 12 | withLatestFrom 13 | } from 'rxjs/operators'; 14 | import { FileUploadService } from '../services'; 15 | import * as FileUploadAPIActions from './file-upload-api.actions'; 16 | import * as FileUploadUIActions from './file-upload-ui.actions'; 17 | import * as FileUploadSelectors from './file-upload.selectors'; 18 | 19 | @Injectable() 20 | export class FileUploadEffects { 21 | constructor( 22 | private fileUploadService: FileUploadService, 23 | private actions$: Actions, 24 | private store: Store<{}> 25 | ) {} 26 | 27 | processQueueEffect$ = createEffect(() => 28 | this.actions$.pipe( 29 | ofType( 30 | FileUploadUIActions.processRequested, 31 | FileUploadUIActions.retryRequested 32 | ), 33 | withLatestFrom( 34 | this.store.select(FileUploadSelectors.selectFilesReadyForUpload) 35 | ), 36 | switchMap(([_, filesToUpload]) => 37 | filesToUpload.map(fileToUpload => 38 | FileUploadAPIActions.uploadRequested({ fileToUpload }) 39 | ) 40 | ) 41 | ) 42 | ); 43 | 44 | uploadEffect$ = createEffect(() => 45 | this.actions$.pipe( 46 | ofType(FileUploadAPIActions.uploadRequested), 47 | mergeMap(({ fileToUpload }) => 48 | this.fileUploadService.uploadFile(fileToUpload.rawFile).pipe( 49 | takeUntil( 50 | this.actions$.pipe(ofType(FileUploadUIActions.cancelRequested)) 51 | ), 52 | map(event => this.getActionFromHttpEvent(fileToUpload.id, event)), 53 | catchError(error => 54 | of( 55 | FileUploadAPIActions.uploadFailed({ 56 | error: error.message, 57 | id: fileToUpload.id 58 | }) 59 | ) 60 | ) 61 | ) 62 | ) 63 | ) 64 | ); 65 | 66 | private getActionFromHttpEvent(id: string, event: HttpEvent) { 67 | switch (event.type) { 68 | case HttpEventType.Sent: { 69 | return FileUploadAPIActions.uploadStarted({ id }); 70 | } 71 | case HttpEventType.DownloadProgress: 72 | case HttpEventType.UploadProgress: { 73 | return FileUploadAPIActions.uploadProgressed({ 74 | id, 75 | progress: Math.round((100 * event.loaded) / event.total) 76 | }); 77 | } 78 | case HttpEventType.ResponseHeader: 79 | case HttpEventType.Response: { 80 | if (event.status === 200) { 81 | return FileUploadAPIActions.uploadCompleted({ id }); 82 | } else { 83 | return FileUploadAPIActions.uploadFailed({ 84 | id, 85 | error: event.statusText 86 | }); 87 | } 88 | } 89 | 90 | default: { 91 | return FileUploadAPIActions.uploadFailed({ 92 | id, 93 | error: `Unknown Event: ${JSON.stringify(event)}` 94 | }); 95 | } 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /libs/file-upload/src/lib/state/file-upload.reducer.ts: -------------------------------------------------------------------------------- 1 | import { createEntityAdapter, EntityState } from '@ngrx/entity'; 2 | import { Action, createReducer, on } from '@ngrx/store'; 3 | import * as uuid from 'uuid'; 4 | import { FileUploadModel, FileUploadStatus } from '../models'; 5 | import * as FileUploadAPIActions from './file-upload-api.actions'; 6 | import * as FileUploadUIActions from './file-upload-ui.actions'; 7 | 8 | export interface FileUploadState extends EntityState {} 9 | 10 | export const fileUploadFeatureKey = 'fileUpload'; 11 | 12 | export const featureAdapter = createEntityAdapter({ 13 | selectId: (model: FileUploadModel) => model.id 14 | }); 15 | 16 | export const initialState: FileUploadState = featureAdapter.getInitialState(); 17 | 18 | const fileUploadReducer = createReducer( 19 | initialState, 20 | on(FileUploadUIActions.added, (state, { file }) => 21 | featureAdapter.addOne( 22 | { 23 | id: uuid.v4(), 24 | fileName: file.name, 25 | fileSize: file.size, 26 | rawFile: file, 27 | error: null, 28 | progress: null, 29 | status: FileUploadStatus.Ready 30 | }, 31 | state 32 | ) 33 | ), 34 | on(FileUploadUIActions.removed, (state, { id }) => 35 | featureAdapter.removeOne(id, state) 36 | ), 37 | on(FileUploadUIActions.retryRequested, (state, { id }) => 38 | featureAdapter.updateOne( 39 | { 40 | id, 41 | changes: { 42 | status: FileUploadStatus.Ready, 43 | progress: 0, 44 | error: null 45 | } 46 | }, 47 | state 48 | ) 49 | ), 50 | on(FileUploadAPIActions.uploadRequested, (state, { fileToUpload }) => 51 | featureAdapter.updateOne( 52 | { id: fileToUpload.id, changes: { status: FileUploadStatus.Requested } }, 53 | state 54 | ) 55 | ), 56 | on(FileUploadAPIActions.uploadStarted, (state, { id }) => 57 | featureAdapter.updateOne( 58 | { id: id, changes: { status: FileUploadStatus.Started, progress: 0 } }, 59 | state 60 | ) 61 | ), 62 | on(FileUploadAPIActions.uploadProgressed, (state, { id, progress }) => 63 | featureAdapter.updateOne( 64 | { 65 | id: id, 66 | changes: { status: FileUploadStatus.InProgress, progress: progress } 67 | }, 68 | state 69 | ) 70 | ), 71 | on(FileUploadAPIActions.uploadCompleted, (state, { id }) => 72 | featureAdapter.updateOne( 73 | { 74 | id: id, 75 | changes: { status: FileUploadStatus.Completed, progress: 100 } 76 | }, 77 | state 78 | ) 79 | ), 80 | on(FileUploadAPIActions.uploadFailed, (state, { id, error }) => 81 | featureAdapter.updateOne( 82 | { 83 | id: id, 84 | changes: { 85 | status: FileUploadStatus.Failed, 86 | progress: null, 87 | error 88 | } 89 | }, 90 | state 91 | ) 92 | ), 93 | on(FileUploadUIActions.cancelRequested, _ => ({ 94 | ...initialState 95 | })) 96 | ); 97 | 98 | export function reducer(state: FileUploadState | undefined, action: Action) { 99 | return fileUploadReducer(state, action); 100 | } 101 | -------------------------------------------------------------------------------- /libs/file-upload/src/lib/state/file-upload.selectors.ts: -------------------------------------------------------------------------------- 1 | import { 2 | faCheck, 3 | faExclamationCircle 4 | } from '@fortawesome/free-solid-svg-icons'; 5 | import { createFeatureSelector, createSelector } from '@ngrx/store'; 6 | import { FileUploadModel, FileUploadStatus, FileViewModel } from '../models'; 7 | import { 8 | featureAdapter, 9 | fileUploadFeatureKey, 10 | FileUploadState 11 | } from './file-upload.reducer'; 12 | 13 | const selectFileUploadState = createFeatureSelector( 14 | fileUploadFeatureKey 15 | ); 16 | 17 | const selectAllFileUploads: ( 18 | state: object 19 | ) => FileUploadModel[] = featureAdapter.getSelectors(selectFileUploadState) 20 | .selectAll; 21 | 22 | const getFileViewModelIcon = (fileStatus: FileUploadStatus) => { 23 | switch (fileStatus) { 24 | case FileUploadStatus.Completed: 25 | return faCheck; 26 | case FileUploadStatus.Failed: 27 | return faExclamationCircle; 28 | default: 29 | return null; 30 | } 31 | }; 32 | 33 | const getFileViewModelColorClass = (fileStatus: FileUploadStatus) => { 34 | switch (fileStatus) { 35 | case FileUploadStatus.Completed: 36 | return 'green'; 37 | case FileUploadStatus.Failed: 38 | return 'red'; 39 | default: 40 | return 'black'; 41 | } 42 | }; 43 | 44 | const getFormattedFileSize = (fileSize: number) => { 45 | if (fileSize && !isNaN(fileSize)) { 46 | const fileSizeInKB = Math.round(fileSize / 1024); 47 | return `${fileSizeInKB} KB`; 48 | } 49 | return `${fileSize}`; 50 | }; 51 | 52 | const getFileViewModel = (file: FileUploadModel): FileViewModel => ({ 53 | id: file.id, 54 | fileName: file.fileName, 55 | formattedFileSize: getFormattedFileSize(file.fileSize), 56 | canRetry: file.status === FileUploadStatus.Failed, 57 | canDelete: file.status !== FileUploadStatus.Completed, 58 | statusIcon: getFileViewModelIcon(file.status), 59 | statusColorClass: getFileViewModelColorClass(file.status), 60 | showProgress: 61 | file.status === FileUploadStatus.InProgress && 62 | file.progress && 63 | file.progress >= 0, 64 | progress: file.progress, 65 | errorMessage: file.status === FileUploadStatus.Failed && file.error 66 | }); 67 | 68 | export const selectFilesReadyForUpload = createSelector( 69 | selectAllFileUploads, 70 | (allUploads: FileUploadModel[]) => 71 | allUploads && allUploads.filter(f => f.status === FileUploadStatus.Ready) 72 | ); 73 | 74 | export const selectFileUploadQueue = createSelector( 75 | selectAllFileUploads, 76 | files => files && files.map(file => getFileViewModel(file)) 77 | ); 78 | -------------------------------------------------------------------------------- /libs/file-upload/src/lib/state/index.ts: -------------------------------------------------------------------------------- 1 | import * as FileUploadUIActions from './file-upload-ui.actions'; 2 | import * as FileUploadSelectors from './file-upload.selectors'; 3 | export { FileUploadEffects } from './file-upload.effects'; 4 | export { fileUploadFeatureKey, reducer } from './file-upload.reducer'; 5 | export { FileUploadSelectors, FileUploadUIActions }; 6 | -------------------------------------------------------------------------------- /libs/file-upload/src/test-setup.ts: -------------------------------------------------------------------------------- 1 | import 'jest-preset-angular'; 2 | -------------------------------------------------------------------------------- /libs/file-upload/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["node", "jest"] 5 | }, 6 | "include": ["**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /libs/file-upload/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "target": "es2015", 6 | "declaration": true, 7 | "inlineSources": true, 8 | "types": [], 9 | "lib": ["dom", "es2018"] 10 | }, 11 | "angularCompilerOptions": { 12 | "annotateForClosureCompiler": true, 13 | "skipTemplateCodegen": true, 14 | "strictMetadataEmit": true, 15 | "fullTemplateTypeCheck": true, 16 | "strictInjectionParameters": true, 17 | "enableResourceInlining": true 18 | }, 19 | "exclude": ["src/test-setup.ts", "**/*.spec.ts"] 20 | } 21 | -------------------------------------------------------------------------------- /libs/file-upload/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 | -------------------------------------------------------------------------------- /libs/file-upload/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tslint.json", 3 | "rules": { 4 | "directive-selector": [true, "attribute", "realWorldApp", "camelCase"], 5 | "component-selector": [true, "element", "app", "kebab-case"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "npmScope": "app", 3 | "implicitDependencies": { 4 | "angular.json": "*", 5 | "package.json": "*", 6 | "tsconfig.json": "*", 7 | "tslint.json": "*", 8 | "nx.json": "*" 9 | }, 10 | "projects": { 11 | "app-e2e": { 12 | "tags": [] 13 | }, 14 | "app": { 15 | "tags": [] 16 | }, 17 | "api": { 18 | "tags": [] 19 | }, 20 | "file-upload": { 21 | "tags": [] 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 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 | "serve-with-api": "ng run app:serve-with-api", 14 | "affected:apps": "nx affected:apps", 15 | "affected:libs": "nx affected:libs", 16 | "affected:build": "nx affected:build", 17 | "affected:e2e": "nx affected:e2e", 18 | "affected:test": "nx affected:test", 19 | "affected:lint": "nx affected:lint", 20 | "affected:dep-graph": "nx affected:dep-graph", 21 | "affected": "nx affected", 22 | "format": "nx format:write", 23 | "format:write": "nx format:write", 24 | "format:check": "nx format:check", 25 | "update": "ng update @nrwl/workspace", 26 | "update:check": "ng update", 27 | "workspace-schematic": "nx workspace-schematic", 28 | "dep-graph": "nx dep-graph", 29 | "help": "nx help" 30 | }, 31 | "private": true, 32 | "dependencies": { 33 | "@angular/animations": "^8.2.0", 34 | "@angular/common": "^8.2.0", 35 | "@angular/compiler": "^8.2.0", 36 | "@angular/core": "^8.2.0", 37 | "@angular/forms": "^8.2.0", 38 | "@angular/platform-browser": "^8.2.0", 39 | "@angular/platform-browser-dynamic": "^8.2.0", 40 | "@angular/router": "^8.2.0", 41 | "@fortawesome/angular-fontawesome": "^0.5.0", 42 | "@fortawesome/fontawesome-svg-core": "^1.2.22", 43 | "@fortawesome/free-solid-svg-icons": "^5.10.2", 44 | "@nestjs/common": "^6.2.4", 45 | "@nestjs/core": "^6.2.4", 46 | "@nestjs/platform-express": "^6.7.2", 47 | "@ng-bootstrap/ng-bootstrap": "^5.1.1", 48 | "@ngrx/effects": "^8.3.0", 49 | "@ngrx/entity": "^8.3.0", 50 | "@ngrx/store": "^8.3.0", 51 | "@ngrx/store-devtools": "^8.3.0", 52 | "@nrwl/angular": "8.5.0", 53 | "bootstrap": "^4.3.1", 54 | "core-js": "^2.5.4", 55 | "file-saver": "^2.0.2", 56 | "reflect-metadata": "^0.1.12", 57 | "rxjs": "~6.4.0", 58 | "serialize-error": "^5.0.0", 59 | "uuid": "^3.3.3", 60 | "zone.js": "^0.9.1" 61 | }, 62 | "devDependencies": { 63 | "@angular-devkit/build-angular": "^0.803.3", 64 | "@angular/cli": "8.3.3", 65 | "@angular/compiler-cli": "^8.2.0", 66 | "@angular/language-service": "^8.2.0", 67 | "@nestjs/schematics": "^6.3.0", 68 | "@nestjs/testing": "^6.2.4", 69 | "@nrwl/cypress": "8.5.0", 70 | "@nrwl/jest": "8.5.0", 71 | "@nrwl/nest": "8.5.0", 72 | "@nrwl/node": "8.5.0", 73 | "@nrwl/workspace": "8.5.0", 74 | "@types/file-saver": "^2.0.1", 75 | "@types/jest": "24.0.9", 76 | "@types/node": "~8.9.4", 77 | "codelyzer": "~5.0.1", 78 | "cypress": "3.4.1", 79 | "dotenv": "6.2.0", 80 | "eslint": "6.1.0", 81 | "jest": "24.1.0", 82 | "jest-preset-angular": "7.0.0", 83 | "prettier": "1.16.4", 84 | "ts-jest": "24.0.0", 85 | "ts-node": "~7.0.0", 86 | "tslint": "~5.11.0", 87 | "typescript": "~3.4.5" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /tools/schematics/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wesleygrimes/managing-file-uploads-with-ngrx/9e344617546fab19e3b007205cf2f33c7607ab22/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 | "@app/file-upload": ["libs/file-upload/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 | "allow": [], 57 | "depConstraints": [ 58 | { 59 | "sourceTag": "*", 60 | "onlyDependOnLibsWithTags": ["*"] 61 | } 62 | ] 63 | } 64 | ], 65 | "directive-selector": [true, "attribute", "app", "camelCase"], 66 | "component-selector": [true, "element", "app", "kebab-case"], 67 | "no-conflicting-lifecycle": true, 68 | "no-host-metadata-property": true, 69 | "no-input-rename": true, 70 | "no-inputs-metadata-property": true, 71 | "no-output-native": true, 72 | "no-output-on-prefix": true, 73 | "no-output-rename": true, 74 | "no-outputs-metadata-property": true, 75 | "template-banana-in-box": true, 76 | "template-no-negated-async": true, 77 | "use-lifecycle-interface": true, 78 | "use-pipe-transform-interface": true 79 | } 80 | } 81 | --------------------------------------------------------------------------------