├── .browserslistrc ├── .editorconfig ├── .gitignore ├── README.md ├── angular.json ├── debug.log ├── e2e ├── protractor.conf.js ├── src │ ├── app.e2e-spec.ts │ └── app.po.ts └── tsconfig.json ├── karma.conf.js ├── package-lock.json ├── package.json ├── src ├── app │ ├── README.md │ ├── admin │ │ ├── admin.component.css │ │ ├── admin.component.html │ │ ├── admin.component.spec.ts │ │ └── admin.component.ts │ ├── app-material.module.ts │ ├── app-router.selector.ts │ ├── app-routing.module.ts │ ├── app-store.module.ts │ ├── app.component.css │ ├── app.component.html │ ├── app.component.spec.ts │ ├── app.component.ts │ ├── app.module.ts │ ├── app.state.ts │ ├── custom-router-state-serializer.ts │ ├── footer │ │ ├── footer.component.css │ │ ├── footer.component.html │ │ ├── footer.component.spec.ts │ │ └── footer.component.ts │ ├── header │ │ ├── header.component.css │ │ ├── header.component.html │ │ ├── header.component.spec.ts │ │ └── header.component.ts │ ├── home │ │ ├── home.component.css │ │ ├── home.component.html │ │ ├── home.component.spec.ts │ │ └── home.component.ts │ ├── in-memory-data.service.ts │ ├── map │ │ ├── README.md │ │ ├── bookmarks │ │ │ ├── README.md │ │ │ ├── bookmark.ts │ │ │ ├── bookmarks-actions-types.ts │ │ │ ├── bookmarks-actions.ts │ │ │ ├── bookmarks-state.ts │ │ │ ├── bookmarks.component.css │ │ │ ├── bookmarks.component.html │ │ │ ├── bookmarks.component.spec.ts │ │ │ ├── bookmarks.component.ts │ │ │ ├── bookmarks.effects.ts │ │ │ ├── bookmarks.reducer.ts │ │ │ ├── bookmarks.selectors.ts │ │ │ └── bookmarks.service.ts │ │ ├── map-contents │ │ │ ├── map-contents.component.css │ │ │ ├── map-contents.component.html │ │ │ ├── map-contents.component.spec.ts │ │ │ └── map-contents.component.ts │ │ ├── map-view │ │ │ ├── map-view.component.css │ │ │ ├── map-view.component.html │ │ │ ├── map-view.component.spec.ts │ │ │ └── map-view.component.ts │ │ ├── map.action.types.ts │ │ ├── map.actions.ts │ │ ├── map.component.css │ │ ├── map.component.html │ │ ├── map.component.spec.ts │ │ ├── map.component.ts │ │ ├── map.effects.ts │ │ ├── map.factory.spec.ts │ │ ├── map.factory.ts │ │ ├── map.reducer.ts │ │ ├── map.selectors.ts │ │ ├── map.service.spec.ts │ │ ├── map.service.ts │ │ ├── map.state.ts │ │ ├── notifications │ │ │ ├── notifications.component.css │ │ │ ├── notifications.component.html │ │ │ ├── notifications.component.spec.ts │ │ │ └── notifications.component.ts │ │ ├── search │ │ │ ├── search.component.css │ │ │ ├── search.component.html │ │ │ ├── search.component.spec.ts │ │ │ └── search.component.ts │ │ └── settings │ │ │ ├── settings.component.css │ │ │ ├── settings.component.html │ │ │ ├── settings.component.spec.ts │ │ │ └── settings.component.ts │ ├── shared │ │ ├── components │ │ │ ├── README.md │ │ │ ├── scrollable-container │ │ │ │ ├── scrollable-container.component.css │ │ │ │ ├── scrollable-container.component.html │ │ │ │ ├── scrollable-container.component.spec.ts │ │ │ │ └── scrollable-container.component.ts │ │ │ ├── status-container │ │ │ │ ├── status-container.component.css │ │ │ │ ├── status-container.component.html │ │ │ │ ├── status-container.component.spec.ts │ │ │ │ └── status-container.component.ts │ │ │ └── toolbar │ │ │ │ ├── toolbar.component.css │ │ │ │ ├── toolbar.component.html │ │ │ │ ├── toolbar.component.spec.ts │ │ │ │ └── toolbar.component.ts │ │ ├── helpers │ │ │ ├── README.md │ │ │ └── helpers.ts │ │ ├── models │ │ │ ├── README.md │ │ │ ├── map-view-properties.ts │ │ │ ├── rest-response.ts │ │ │ ├── route-metadata.ts │ │ │ ├── service-status.ts │ │ │ ├── service-status.types.ts │ │ │ └── webmap-document.ts │ │ └── services │ │ │ ├── README.md │ │ │ ├── auth-guard.service.ts │ │ │ ├── base.service.ts │ │ │ ├── http-client.service.ts │ │ │ ├── navigation.service.ts │ │ │ ├── router.service.spec.ts │ │ │ └── router.service.ts │ └── tools │ │ ├── tools.component.css │ │ ├── tools.component.html │ │ ├── tools.component.spec.ts │ │ └── tools.component.ts ├── assets │ └── .gitkeep ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── main.ts ├── polyfills.ts ├── styles.css └── test.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.spec.json └── tslint.json /.browserslistrc: -------------------------------------------------------------------------------- 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 | # For the full list of supported browsers by the Angular framework, please see: 6 | # https://angular.io/guide/browser-support 7 | 8 | # You can see what browsers were selected by your queries by running: 9 | # npx browserslist 10 | 11 | last 1 Chrome version 12 | last 1 Firefox version 13 | last 2 Edge major versions 14 | last 2 Safari major versions 15 | last 2 iOS major versions 16 | Firefox ESR 17 | not IE 9-10 # Angular support for IE 9-10 has been deprecated and will be removed as of Angular v11. To opt-in, remove the 'not' prefix on this line. 18 | not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line. 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://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 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.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 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events*.json 15 | speed-measure-plugin*.json 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | .history/* 33 | 34 | # misc 35 | /.sass-cache 36 | /connect.lock 37 | /coverage 38 | /libpeerconnection.log 39 | npm-debug.log 40 | yarn-error.log 41 | testem.log 42 | /typings 43 | 44 | # System Files 45 | .DS_Store 46 | Thumbs.db 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ArcGIS API for JavaScript with Angular and NgRx 2 | 3 | This project was created for the Developer Summit 2021 ArcGIS JavaScript API and Angular presentation. The project was used to demonstrate several aspects of Angular and the ArcGIS JavaScript API 4.18 or later. This can be considered a starter application to help Angular developers create a single page map-centric application using the ArcGIS JavaScript API. 4 | 5 | The project touches on the following Packages 6 | 7 | - [Angular](https://angular.io/) 8 | - [Angular Material](https://material.angular.io/) 9 | - [Angular Flex-Layout](https://github.com/angular/flex-layout) (used to create an adapative layout) 10 | - [Angular In-Memory Web API](https://angular.io/tutorial/toh-pt6) 11 | - [ArcGIS JavaScript API](https://developers.arcgis.com/javascript/) (v4.18) 12 | - [NgRx](https://ngrx.io/) (Store, Effects, RouterStore, StoreDevTools, and Freeze) 13 | - [Jasmine Testing](https://jasmine.github.io/) using mocks with createSpyObj 14 | - [Jasmine Marbles](https://www.npmjs.com/package/jasmine-marbles) 15 | - [Karma Code Coverage](https://angular.io/guide/testing-code-coverage) 16 | 17 | The project also contains the following custom components and services 18 | 19 | - [Status Container](src/app/shared/components) 20 | - [Toolbar](src/app/shared/components) (vertical toolbar with overflow) 21 | - [HttpClientService](src/app/shared/services) (service to wrap the HttpClient) 22 | - [BaseService](src/app/shared/services) 23 | - [RouterService](src/app/shared/services) 24 | - [RestResponse](src/app/shared/models) 25 | - [RouteMetadata](src/app/shared/models) 26 | - [ServiceStatus](src/app/shared/models) 27 | - [Helpers](src/app/shared/helpers) 28 | 29 | There are a few configuration changes that have to be made to an Angular Project so that it works wtih the ArcGIS JavaScript API (v4.18). 30 | 31 | - angular.json 32 | 33 | Add the following to the architect/build/options section of the [angular.json](https://github.com/epaitz/jsapi-angular-ngrx-ds2021/blob/f99bb2d7268a5ea8b47217cc412e9f49b80b585d/angular.json#L25-L33) file. 34 | 35 | ``` 36 | "assets": [ 37 | { 38 | "glob": "**/*", 39 | "input": "node_modules/@arcgis/core/assets", 40 | "output": "/assets/" 41 | }, 42 | "src/favicon.ico", 43 | "src/assets" 44 | ] 45 | ``` 46 | 47 | - styles.css 48 | 49 | Add the following to the styles.css so that the main.css for the JSAPI is loaded. The "~" character tells the Webpack loader to resolove the path starting in node_modules. If the [@arcgis/core](https://www.npmjs.com/package/@arcgis/core) package is updated to use a newer version then this syntax will always use the main.css from the [@arcgis/core](https://www.npmjs.com/package/@arcgis/core) in node_modules. 50 | 51 | ``` 52 | @import url('~@arcgis/core/assets/esri/themes/light/main.css'); 53 | ``` 54 | 55 | - app.component.ts 56 | 57 | Don't forget to define the assetsPath in the JSAPI config. 58 | 59 | ``` 60 | config.assetsPath = '/assets'; 61 | ``` 62 | 63 | I have this defined in the [app.component.ts](https://github.com/epaitz/jsapi-angular-ngrx-ds2021/blob/58800b2c051dcee4af0c49532c442f7d2a44043b/src/app/app.component.ts#L11) file. 64 | 65 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "jsapi-angular-ngrx-ds2021": { 7 | "projectType": "application", 8 | "schematics": {}, 9 | "root": "", 10 | "sourceRoot": "src", 11 | "prefix": "app", 12 | "architect": { 13 | "build": { 14 | "builder": "@angular-devkit/build-angular:browser", 15 | "options": { 16 | "outputPath": "dist/jsapi-angular-ngrx-ds2021", 17 | "index": "src/index.html", 18 | "main": "src/main.ts", 19 | "polyfills": "src/polyfills.ts", 20 | "tsConfig": "tsconfig.app.json", 21 | "aot": true, 22 | "allowedCommonJsDependencies": [ 23 | "moment" 24 | ], 25 | "assets": [ 26 | { 27 | "glob": "**/*", 28 | "input": "node_modules/@arcgis/core/assets", 29 | "output": "/assets/" 30 | }, 31 | "src/favicon.ico", 32 | "src/assets" 33 | ], 34 | "styles": [ 35 | "./node_modules/@angular/material/prebuilt-themes/indigo-pink.css", 36 | "src/styles.css" 37 | ], 38 | "scripts": [] 39 | }, 40 | "configurations": { 41 | "production": { 42 | "fileReplacements": [ 43 | { 44 | "replace": "src/environments/environment.ts", 45 | "with": "src/environments/environment.prod.ts" 46 | } 47 | ], 48 | "optimization": true, 49 | "outputHashing": "all", 50 | "sourceMap": false, 51 | "namedChunks": false, 52 | "extractLicenses": true, 53 | "vendorChunk": false, 54 | "buildOptimizer": true, 55 | "budgets": [ 56 | { 57 | "type": "initial", 58 | "maximumWarning": "2mb", 59 | "maximumError": "5mb" 60 | }, 61 | { 62 | "type": "anyComponentStyle", 63 | "maximumWarning": "6kb", 64 | "maximumError": "10kb" 65 | } 66 | ] 67 | } 68 | } 69 | }, 70 | "serve": { 71 | "builder": "@angular-devkit/build-angular:dev-server", 72 | "options": { 73 | "browserTarget": "jsapi-angular-ngrx-ds2021:build" 74 | }, 75 | "configurations": { 76 | "production": { 77 | "browserTarget": "jsapi-angular-ngrx-ds2021:build:production" 78 | } 79 | } 80 | }, 81 | "extract-i18n": { 82 | "builder": "@angular-devkit/build-angular:extract-i18n", 83 | "options": { 84 | "browserTarget": "jsapi-angular-ngrx-ds2021:build" 85 | } 86 | }, 87 | "test": { 88 | "builder": "@angular-devkit/build-angular:karma", 89 | "options": { 90 | "main": "src/test.ts", 91 | "polyfills": "src/polyfills.ts", 92 | "tsConfig": "tsconfig.spec.json", 93 | "karmaConfig": "karma.conf.js", 94 | "assets": [ 95 | "src/favicon.ico", 96 | "src/assets" 97 | ], 98 | "styles": [ 99 | "./node_modules/@angular/material/prebuilt-themes/indigo-pink.css", 100 | "src/styles.css" 101 | ], 102 | "scripts": [] 103 | } 104 | }, 105 | "lint": { 106 | "builder": "@angular-devkit/build-angular:tslint", 107 | "options": { 108 | "tsConfig": [ 109 | "tsconfig.app.json", 110 | "tsconfig.spec.json", 111 | "e2e/tsconfig.json" 112 | ], 113 | "exclude": [ 114 | "**/node_modules/**" 115 | ] 116 | } 117 | }, 118 | "e2e": { 119 | "builder": "@angular-devkit/build-angular:protractor", 120 | "options": { 121 | "protractorConfig": "e2e/protractor.conf.js", 122 | "devServerTarget": "jsapi-angular-ngrx-ds2021:serve" 123 | }, 124 | "configurations": { 125 | "production": { 126 | "devServerTarget": "jsapi-angular-ngrx-ds2021:serve:production" 127 | } 128 | } 129 | } 130 | } 131 | } 132 | }, 133 | "defaultProject": "jsapi-angular-ngrx-ds2021" 134 | } -------------------------------------------------------------------------------- /debug.log: -------------------------------------------------------------------------------- 1 | [1213/192011.145:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 2 | [1213/193552.933:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 3 | [1216/124834.479:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 4 | [1216/134547.781:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 5 | [1216/141657.757:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 6 | [1216/150101.261:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 7 | [1216/152636.707:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 8 | [1216/160914.419:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 9 | [1216/164602.441:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 10 | [1219/182318.787:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 11 | [1219/183928.962:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 12 | [1219/185939.443:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 13 | [1220/095618.319:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 14 | [1220/120535.000:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 15 | [1220/133748.352:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 16 | [1220/135707.258:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 17 | [1220/152135.827:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 18 | [1221/114552.204:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 19 | [1221/155642.448:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 20 | [1223/143416.060:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 21 | [1223/151524.495:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 22 | [1223/192559.899:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 23 | [1229/202506.059:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 24 | [1230/171055.735:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 25 | [0105/130523.954:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 26 | [0105/140400.241:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 27 | [0105/142455.933:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 28 | [0105/161102.272:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 29 | [0106/234154.677:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 30 | [0107/234154.701:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 31 | [0108/234154.733:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 32 | [0109/234154.752:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 33 | [0119/174907.689:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) 34 | -------------------------------------------------------------------------------- /e2e/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Protractor configuration file, see link for more information 3 | // https://github.com/angular/protractor/blob/master/lib/config.ts 4 | 5 | const { SpecReporter, StacktraceOption } = require('jasmine-spec-reporter'); 6 | 7 | /** 8 | * @type { import("protractor").Config } 9 | */ 10 | exports.config = { 11 | allScriptsTimeout: 11000, 12 | specs: [ 13 | './src/**/*.e2e-spec.ts' 14 | ], 15 | capabilities: { 16 | browserName: 'chrome' 17 | }, 18 | directConnect: true, 19 | baseUrl: 'http://localhost:4200/', 20 | framework: 'jasmine', 21 | jasmineNodeOpts: { 22 | showColors: true, 23 | defaultTimeoutInterval: 30000, 24 | print: function() {} 25 | }, 26 | onPrepare() { 27 | require('ts-node').register({ 28 | project: require('path').join(__dirname, './tsconfig.json') 29 | }); 30 | jasmine.getEnv().addReporter(new SpecReporter({ 31 | spec: { 32 | displayStacktrace: StacktraceOption.PRETTY 33 | } 34 | })); 35 | } 36 | }; -------------------------------------------------------------------------------- /e2e/src/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppPage } from './app.po'; 2 | import { browser, logging } from 'protractor'; 3 | 4 | describe('workspace-project App', () => { 5 | let page: AppPage; 6 | 7 | beforeEach(() => { 8 | page = new AppPage(); 9 | }); 10 | 11 | it('should display welcome message', () => { 12 | page.navigateTo(); 13 | expect(page.getTitleText()).toEqual('jsapi-angular-ngrx-ds2021 app is running!'); 14 | }); 15 | 16 | afterEach(async () => { 17 | // Assert that there are no errors emitted from the browser 18 | const logs = await browser.manage().logs().get(logging.Type.BROWSER); 19 | expect(logs).not.toContain(jasmine.objectContaining({ 20 | level: logging.Level.SEVERE, 21 | } as logging.Entry)); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /e2e/src/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo(): Promise { 5 | return browser.get(browser.baseUrl) as Promise; 6 | } 7 | 8 | getTitleText(): Promise { 9 | return element(by.css('app-root .content span')).getText() as Promise; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../out-tsc/e2e", 6 | "module": "commonjs", 7 | "target": "es2018", 8 | "types": [ 9 | "jasmine", 10 | "jasminewd2", 11 | "node" 12 | ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, './coverage/jsapi-angular-ngrx-ds2021'), 20 | reports: ['html', 'lcovonly', 'text-summary'], 21 | fixWebpackSourcePaths: true 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false, 30 | restartOnFileChange: true 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jsapi-angular-ngrx-ds2021", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "test": "ng test", 9 | "lint": "ng lint", 10 | "e2e": "ng e2e" 11 | }, 12 | "private": true, 13 | "dependencies": { 14 | "@angular/animations": "~11.1.1", 15 | "@angular/cdk": "^11.1.1", 16 | "@angular/common": "~11.1.1", 17 | "@angular/compiler": "~11.1.1", 18 | "@angular/core": "~11.1.1", 19 | "@angular/flex-layout": "^11.0.0-beta.33", 20 | "@angular/forms": "~11.1.1", 21 | "@angular/material": "^11.1.1", 22 | "@angular/platform-browser": "~11.1.1", 23 | "@angular/platform-browser-dynamic": "~11.1.1", 24 | "@angular/router": "~11.1.1", 25 | "@arcgis/core": "4.18.1", 26 | "@ngrx/effects": "^10.1.2", 27 | "@ngrx/router-store": "^10.1.2", 28 | "@ngrx/store": "^10.1.2", 29 | "@ngrx/store-devtools": "^10.1.2", 30 | "angular-in-memory-web-api": "^0.11.0", 31 | "ngrx-store-freeze": "^0.2.4", 32 | "rxjs": "~6.6.0", 33 | "tslib": "^2.0.0", 34 | "zone.js": "~0.10.2" 35 | }, 36 | "devDependencies": { 37 | "@angular-devkit/build-angular": "~0.1101.2", 38 | "@angular/cli": "~11.1.2", 39 | "@angular/compiler-cli": "~11.1.1", 40 | "@types/node": "^12.11.1", 41 | "@types/jasmine": "~3.6.0", 42 | "@types/jasminewd2": "~2.0.3", 43 | "codelyzer": "^6.0.0", 44 | "jasmine-core": "~3.6.0", 45 | "jasmine-spec-reporter": "~5.0.0", 46 | "jasmine-marbles": "^0.6.0", 47 | "karma": "~5.2.3", 48 | "karma-chrome-launcher": "~3.1.0", 49 | "karma-coverage-istanbul-reporter": "~3.0.2", 50 | "karma-jasmine": "~4.0.0", 51 | "karma-jasmine-html-reporter": "^1.5.0", 52 | "protractor": "~7.0.0", 53 | "ts-node": "~8.3.0", 54 | "tslint": "~6.1.0", 55 | "typescript": "~4.0.2" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/app/README.md: -------------------------------------------------------------------------------- 1 | ## Creating an adaptive user interface 2 | 3 | The header and map components have navigation elements that will adjust as the width of the browser changes. This was done using the [Angular Flex Layout](https://github.com/angular/flex-layout). Let’s start by looking at the [header.component.html](https://github.com/epaitz/jsapi-angular-ngrx-ds2021/blob/master/src/app/header/header.component.html). 4 | 5 | The root element in the header component template is an Angular Material Toolbar with one toolbar row. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | The next element in the toolbar row is a normal HTML button that uses the Angular Material mat-button directive and additional Flex Layout directives. 14 | 15 | 18 | 19 | The button has the [fxShow](https://github.com/angular/flex-layout/wiki/fxShow-API) and the [fxHide](https://github.com/angular/flex-layout/wiki/fxHide-API) directives added. This means the above element will be shown by default and ONLY hidden on viewport sizes greater than `sm` medaiQuery ranges. The Material Menu is tied to the button and will contain router elements for the root and one level of children. If you have a more complicated set of children routes, then you will have to modify this menu. 20 | 21 | The next elements are the title and a spacer. 22 | 23 | 24 |
25 | 26 | The style of the span could have been replaced with `fxFlex=”1 1 auto”` do be consistent with using the Flex Layout directives. 27 | 28 | The last element is a DIV that contains a button for each root level route, a button for the username, and a menu that is displayed when the user name is clicked. 29 | 30 |
31 |
32 | 33 | This DIV also uses the [fxShow](https://github.com/angular/flex-layout/wiki/fxShow-API) and the [fxHide](https://github.com/angular/flex-layout/wiki/fxHide-API) directives. This means the above element will be shown by default and ONLY hidden on viewport sizes less than `md` medaiQuery ranges. 34 | 35 | Next lets take a look at the map.component.html template. 36 | 37 | The root element is a DIV that use the Flex Layout directives [fxLayout](https://github.com/angular/flex-layout/wiki/fxLayout-API) and [fxFill](https://github.com/angular/flex-layout/wiki/fxFlexFill-API). 38 | 39 |
40 | 41 | These two directives have nothing to do with the adaptive UI but are a kind of short-hand syntax when using the CSS Flexbox and other CSS. 42 | 43 | The next element is my vertical toolbar which has several input bindings and an output event that do not effect the adaptive UI. 44 | 45 | 46 | 47 | The toolbar does use the [fxShow](https://github.com/angular/flex-layout/wiki/fxShow-API) and the [fxHide](https://github.com/angular/flex-layout/wiki/fxHide-API) directives. This means the above element will be shown by default and ONLY hidden on viewport sizes less than `md` medaiQuery ranges. 48 | 49 | Next is the [Angular Material Sidenav component](https://material.angular.io/components/sidenav/overview). Its behavior changes as the browser width changes by changing is `mode` property and by dynamically adding a CSS class I called `is-xs`. 50 | 51 | 52 | 53 | 54 | The `mode` and `is-xs` values are updated from the [map.component.ts](https://github.com/epaitz/jsapi-angular-ngrx-ds2021/blob/d14253c0bf6bc9b1c283d340b5c10ed910ff0132/src/app/map/map.component.ts#L49) by watching for media changes using the [MediaObserver](https://github.com/angular/flex-layout/wiki/MediaObserver) from the Angular Flex-Layout. 55 | 56 | The [map.component.css](https://github.com/epaitz/jsapi-angular-ngrx-ds2021/blob/master/src/app/map/map.component.css) contains two classes that control the width of the mat-drawer. 57 | 58 | .mat-drawer.is-xs { 59 | width: 100%; 60 | } 61 | 62 | .mat-drawer { 63 | width: 300px; 64 | } 65 | 66 | By default the mat-drawer width is set to 300px but when the is-xs class is added the mat-drawer width will chagne to 100%. 67 | 68 | The final DIV uses the fxLayout and fxFill directives and is just a wrapper for the app-map-view-component. 69 | -------------------------------------------------------------------------------- /src/app/admin/admin.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epaitz/jsapi-angular-ngrx-ds2021/79cd425a1b3ced8ee119af3c15300bd14f1e2d16/src/app/admin/admin.component.css -------------------------------------------------------------------------------- /src/app/admin/admin.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | Admin Component 4 |
5 |
-------------------------------------------------------------------------------- /src/app/admin/admin.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { AdminComponent } from './admin.component'; 4 | 5 | describe('AdminComponent', () => { 6 | let component: AdminComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ AdminComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(AdminComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/admin/admin.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-admin', 5 | templateUrl: './admin.component.html', 6 | styleUrls: ['./admin.component.css'] 7 | }) 8 | export class AdminComponent implements OnInit { 9 | 10 | constructor() { } 11 | 12 | ngOnInit(): void { 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/app/app-material.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { MatToolbarModule } from '@angular/material/toolbar'; 3 | import { MatButtonModule } from '@angular/material/button'; 4 | import { MatMenuModule } from '@angular/material/menu'; 5 | import { MatIconModule } from '@angular/material/icon'; 6 | import { MatBadgeModule } from '@angular/material/badge'; 7 | import { MatListModule } from '@angular/material/list'; 8 | import { MatFormFieldModule } from '@angular/material/form-field'; 9 | import { MatInputModule } from '@angular/material/input'; 10 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; 11 | import { MatCardModule } from '@angular/material/card'; 12 | import { MatSidenavModule } from '@angular/material/sidenav'; 13 | import { MatSelectModule } from '@angular/material/select'; 14 | import { MatTabsModule } from '@angular/material/tabs'; 15 | import { MatExpansionModule } from '@angular/material/expansion'; 16 | import { MatCheckboxModule } from '@angular/material/checkbox'; 17 | 18 | @NgModule({ 19 | exports: [ 20 | MatToolbarModule, 21 | MatButtonModule, 22 | MatMenuModule, 23 | MatIconModule, 24 | MatBadgeModule, 25 | MatListModule, 26 | MatFormFieldModule, 27 | MatInputModule, 28 | MatProgressSpinnerModule, 29 | MatCardModule, 30 | MatSidenavModule, 31 | MatSelectModule, 32 | MatTabsModule, 33 | MatExpansionModule, 34 | MatCheckboxModule 35 | ] 36 | }) 37 | export class AppMaterialModule { } 38 | -------------------------------------------------------------------------------- /src/app/app-router.selector.ts: -------------------------------------------------------------------------------- 1 | import { RouterReducerState } from '@ngrx/router-store'; 2 | import { createSelector } from '@ngrx/store'; 3 | import { AppState } from './app.state'; 4 | 5 | export const selectRouterState = (state: AppState) => state.router; 6 | 7 | export const selectRouteUrl = createSelector( 8 | selectRouterState, 9 | (state: RouterReducerState) => { 10 | return state?.state?.url; 11 | } 12 | ); 13 | 14 | export const selectRouteData = createSelector( 15 | selectRouterState, 16 | (state: RouterReducerState) => { 17 | /* tslint:disable:no-string-literal */ 18 | return state?.state?.['data']; 19 | /* tslint:enable:no-string-literal */ 20 | } 21 | ); 22 | 23 | export const selectRouteDataLabel = createSelector( 24 | selectRouterState, 25 | (state: RouterReducerState) => { 26 | /* tslint:disable:no-string-literal */ 27 | return state?.state?.['data'].label; 28 | /* tslint:enable:no-string-literal */ 29 | } 30 | ); 31 | -------------------------------------------------------------------------------- /src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Routes, RouterModule } from '@angular/router'; 3 | import { AdminComponent } from './admin/admin.component'; 4 | import { HomeComponent } from './home/home.component'; 5 | import { BookmarksComponent } from './map/bookmarks/bookmarks.component'; 6 | import { MapContentsComponent } from './map/map-contents/map-contents.component'; 7 | import { MapComponent } from './map/map.component'; 8 | import { NotificationsComponent } from './map/notifications/notifications.component'; 9 | import { SearchComponent } from './map/search/search.component'; 10 | import { SettingsComponent } from './map/settings/settings.component'; 11 | import { AuthGuardService } from './shared/services/auth-guard.service'; 12 | import { ToolsComponent } from './tools/tools.component'; 13 | 14 | const routes: Routes = [ 15 | { 16 | path: 'home', 17 | component: HomeComponent, 18 | data: { 19 | label: 'Home' 20 | } 21 | }, 22 | { 23 | path: 'map', 24 | component: MapComponent, 25 | data: { 26 | label: 'Map' 27 | }, 28 | children: [ 29 | { 30 | path: '', 31 | redirectTo: 'search', 32 | pathMatch: 'full' 33 | }, 34 | { 35 | path: 'contents', 36 | component: MapContentsComponent, 37 | data: { 38 | label: 'Map Contents', 39 | icon: 'list' 40 | } 41 | }, 42 | { 43 | path: 'search', 44 | component: SearchComponent, 45 | data: { 46 | label: 'Search', 47 | icon: 'search' 48 | } 49 | }, 50 | { 51 | path: 'bookmarks', 52 | component: BookmarksComponent, 53 | data: { 54 | label: 'Bookmarks', 55 | icon: 'bookmarks' 56 | } 57 | }, 58 | { 59 | path: 'notifications', 60 | component: NotificationsComponent, 61 | data: { 62 | label: 'Notifications', 63 | icon: 'notifications' 64 | } 65 | }, 66 | { 67 | path: 'settings', 68 | component: SettingsComponent, 69 | data: { 70 | label: 'Settings', 71 | icon: 'settings' 72 | } 73 | } 74 | ] 75 | }, 76 | { 77 | path: 'tools', 78 | component: ToolsComponent, 79 | data: { 80 | label: 'Tools' 81 | } 82 | }, 83 | { 84 | path: 'admin', 85 | component: AdminComponent, 86 | canActivate: [AuthGuardService], 87 | data: { 88 | label: 'Admin' 89 | } 90 | }, 91 | { path: '', redirectTo: '/home', pathMatch: 'full' }, 92 | { path: '**', redirectTo: '/home'} 93 | ]; 94 | 95 | @NgModule({ 96 | imports: [RouterModule.forRoot(routes, { relativeLinkResolution: 'legacy' })], 97 | exports: [RouterModule] 98 | }) 99 | export class AppRoutingModule { } 100 | -------------------------------------------------------------------------------- /src/app/app-store.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { ActionReducerMap, MetaReducer, StoreModule } from '@ngrx/store'; 3 | import { EffectsModule } from '@ngrx/effects'; 4 | import { StoreDevtoolsModule } from '@ngrx/store-devtools'; 5 | import { routerReducer, RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store'; 6 | import { CustomRouterStateSerializer } from './custom-router-state-serializer'; 7 | import { AppState } from './app.state'; 8 | import { storeFreeze } from 'ngrx-store-freeze'; 9 | import { environment } from '../environments/environment'; 10 | import { mapReducer } from './map/map.reducer'; 11 | import { MapEffects } from './map/map.effects'; 12 | import { bookmarksReducer } from './map/bookmarks/bookmarks.reducer'; 13 | import { BookmarksEffects } from './map/bookmarks/bookmarks.effects'; 14 | 15 | export const reducers: ActionReducerMap = { 16 | bookmarksState: bookmarksReducer, 17 | mapState: mapReducer, 18 | router: routerReducer 19 | }; 20 | 21 | export const metaReducers: MetaReducer[] = !environment.production ? [storeFreeze] : []; 22 | 23 | @NgModule({ 24 | imports: [ 25 | StoreModule.forRoot(reducers, { metaReducers }), 26 | EffectsModule.forRoot([ 27 | MapEffects, 28 | BookmarksEffects 29 | ]), 30 | StoreDevtoolsModule.instrument({ 31 | maxAge: 25 32 | }), 33 | StoreRouterConnectingModule.forRoot({ 34 | stateKey: 'router', 35 | serializer: CustomRouterStateSerializer 36 | }) 37 | ], 38 | providers: [ 39 | { provide: RouterStateSerializer, useClass: CustomRouterStateSerializer } 40 | ] 41 | }) 42 | export class AppStoreModule { } 43 | -------------------------------------------------------------------------------- /src/app/app.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epaitz/jsapi-angular-ngrx-ds2021/79cd425a1b3ced8ee119af3c15300bd14f1e2d16/src/app/app.component.css -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 |
6 | 7 |
-------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { RouterTestingModule } from '@angular/router/testing'; 4 | import { AppComponent } from './app.component'; 5 | 6 | describe('AppComponent', () => { 7 | let appComponent: AppComponent; 8 | let componentFixture: ComponentFixture; 9 | 10 | beforeEach(async () => { 11 | await TestBed.configureTestingModule({ 12 | imports: [ 13 | RouterTestingModule 14 | ], 15 | declarations: [ 16 | AppComponent 17 | ], 18 | schemas: [ 19 | CUSTOM_ELEMENTS_SCHEMA 20 | ] 21 | }).compileComponents(); 22 | }); 23 | 24 | beforeEach(() => { 25 | componentFixture = TestBed.createComponent(AppComponent); 26 | appComponent = componentFixture.componentInstance; 27 | }); 28 | 29 | it('should create the app', () => { 30 | expect(appComponent).toBeTruthy(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import config from '@arcgis/core/config.js'; 3 | 4 | @Component({ 5 | selector: 'app-root', 6 | templateUrl: './app.component.html', 7 | styleUrls: ['./app.component.css'] 8 | }) 9 | export class AppComponent implements OnInit { 10 | ngOnInit(): void { 11 | config.assetsPath = '/assets'; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from '@angular/platform-browser'; 2 | import { NgModule } from '@angular/core'; 3 | import { FlexLayoutModule } from '@angular/flex-layout'; 4 | import { AppRoutingModule } from './app-routing.module'; 5 | import { AppComponent } from './app.component'; 6 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 7 | import { HeaderComponent } from './header/header.component'; 8 | import { MapComponent } from './map/map.component'; 9 | import { FooterComponent } from './footer/footer.component'; 10 | import { ToolsComponent } from './tools/tools.component'; 11 | import { HomeComponent } from './home/home.component'; 12 | import { AppMaterialModule } from './app-material.module'; 13 | import { InMemoryWebApiModule } from 'angular-in-memory-web-api'; 14 | import { InMemoryDataService } from './in-memory-data.service'; 15 | import { HttpClientModule } from '@angular/common/http'; 16 | import { StatusContainerComponent } from './shared/components/status-container/status-container.component'; 17 | import { BookmarksComponent } from './map/bookmarks/bookmarks.component'; 18 | import { SearchComponent } from './map/search/search.component'; 19 | import { NotificationsComponent } from './map/notifications/notifications.component'; 20 | import { MapContentsComponent } from './map/map-contents/map-contents.component'; 21 | import { SettingsComponent } from './map/settings/settings.component'; 22 | import { ToolbarComponent } from './shared/components/toolbar/toolbar.component'; 23 | import { MapViewComponent } from './map/map-view/map-view.component'; 24 | import { AppStoreModule } from './app-store.module'; 25 | import { AdminComponent } from './admin/admin.component'; 26 | import { ScrollableContainerComponent } from './shared/components/scrollable-container/scrollable-container.component'; 27 | import { FormsModule } from '@angular/forms'; 28 | 29 | @NgModule({ 30 | declarations: [ 31 | AppComponent, 32 | HeaderComponent, 33 | MapComponent, 34 | FooterComponent, 35 | ToolsComponent, 36 | HomeComponent, 37 | StatusContainerComponent, 38 | BookmarksComponent, 39 | SearchComponent, 40 | NotificationsComponent, 41 | MapContentsComponent, 42 | SettingsComponent, 43 | ToolbarComponent, 44 | MapViewComponent, 45 | AdminComponent, 46 | ScrollableContainerComponent 47 | ], 48 | imports: [ 49 | BrowserModule, 50 | FormsModule, 51 | AppRoutingModule, 52 | BrowserAnimationsModule, 53 | FlexLayoutModule, 54 | AppMaterialModule, 55 | AppStoreModule, 56 | HttpClientModule, 57 | InMemoryWebApiModule.forRoot(InMemoryDataService) 58 | ], 59 | providers: [], 60 | bootstrap: [AppComponent] 61 | }) 62 | export class AppModule { } 63 | -------------------------------------------------------------------------------- /src/app/app.state.ts: -------------------------------------------------------------------------------- 1 | import { RouterReducerState } from '@ngrx/router-store'; 2 | import { BookmarksState } from './map/bookmarks/bookmarks-state'; 3 | import { MapState } from './map/map.state'; 4 | 5 | export interface AppState { 6 | readonly mapState: MapState; 7 | readonly bookmarksState: BookmarksState; 8 | readonly router: RouterReducerState; 9 | } 10 | -------------------------------------------------------------------------------- /src/app/custom-router-state-serializer.ts: -------------------------------------------------------------------------------- 1 | import { Params, RouterStateSnapshot } from '@angular/router'; 2 | import { RouterStateSerializer } from '@ngrx/router-store'; 3 | 4 | export interface RouterStateUrl { 5 | url: string; 6 | params: Params; 7 | queryParams: Params; 8 | data: any; 9 | } 10 | 11 | export class CustomRouterStateSerializer implements RouterStateSerializer { 12 | serialize(routerState: RouterStateSnapshot): RouterStateUrl { 13 | let route = routerState.root; 14 | 15 | while (route.firstChild) { 16 | route = route.firstChild; 17 | } 18 | 19 | const { url, root: { queryParams } } = routerState; 20 | const { params, data } = route; 21 | 22 | return { url, params, queryParams, data }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/app/footer/footer.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epaitz/jsapi-angular-ngrx-ds2021/79cd425a1b3ced8ee119af3c15300bd14f1e2d16/src/app/footer/footer.component.css -------------------------------------------------------------------------------- /src/app/footer/footer.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Powered by Esri 4 | 5 | -------------------------------------------------------------------------------- /src/app/footer/footer.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { MatToolbarModule } from '@angular/material/toolbar'; 3 | import { FooterComponent } from './footer.component'; 4 | 5 | describe('FooterComponent', () => { 6 | let footerComponent: FooterComponent; 7 | let componentFixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ 12 | FooterComponent 13 | ], 14 | imports: [ 15 | MatToolbarModule 16 | ] 17 | }) 18 | .compileComponents(); 19 | }); 20 | 21 | beforeEach(() => { 22 | componentFixture = TestBed.createComponent(FooterComponent); 23 | footerComponent = componentFixture.componentInstance; 24 | }); 25 | 26 | it('ngOnInit_shouldInitializeComponent', () => { 27 | componentFixture.detectChanges(); 28 | expect(footerComponent).toBeTruthy(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/app/footer/footer.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-footer', 5 | templateUrl: './footer.component.html', 6 | styleUrls: ['./footer.component.css'] 7 | }) 8 | export class FooterComponent implements OnInit { 9 | 10 | constructor() { } 11 | 12 | ngOnInit(): void { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/app/header/header.component.css: -------------------------------------------------------------------------------- 1 | .active { 2 | box-shadow: 0 3px; 3 | border-radius: 0; 4 | } -------------------------------------------------------------------------------- /src/app/header/header.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 22 | 23 | 24 | 25 | 26 | 29 | 30 | 31 | 32 | 33 | 34 | ArcGIS API for JavaScript with Angular 35 | 36 | 37 | 38 |
39 | 40 | 44 | 45 | 48 | 49 | 50 | 54 | 58 | 62 | 63 | 64 |
65 | 66 |
67 |
-------------------------------------------------------------------------------- /src/app/header/header.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { MatIconModule } from '@angular/material/icon'; 4 | import { MatMenuModule } from '@angular/material/menu'; 5 | import { MatToolbarModule } from '@angular/material/toolbar'; 6 | import { RouterTestingModule } from '@angular/router/testing'; 7 | import { Store } from '@ngrx/store'; 8 | import { MapActionTypes } from '../map/map.action.types'; 9 | import { HeaderComponent } from './header.component'; 10 | 11 | describe('HeaderComponent', () => { 12 | let headerComponent: HeaderComponent; 13 | let componentFixture: ComponentFixture; 14 | let mockStore: any; 15 | let mockRouterService: any; 16 | 17 | beforeEach(async () => { 18 | 19 | mockStore = jasmine.createSpyObj('mockStore', ['dispatch']); 20 | mockRouterService = jasmine.createSpyObj('mockRouterService', ['getRouterConfigMetadata']); 21 | 22 | await TestBed.configureTestingModule({ 23 | declarations: [ 24 | HeaderComponent 25 | ], 26 | schemas: [ 27 | CUSTOM_ELEMENTS_SCHEMA 28 | ], 29 | imports: [ 30 | RouterTestingModule, 31 | MatToolbarModule, 32 | MatIconModule, 33 | MatMenuModule 34 | ], 35 | providers: [ 36 | { provide: Store, useValue: mockStore }, 37 | { provide: Store, useValue: mockStore } 38 | ] 39 | }) 40 | .compileComponents(); 41 | }); 42 | 43 | beforeEach(() => { 44 | componentFixture = TestBed.createComponent(HeaderComponent); 45 | headerComponent = componentFixture.componentInstance; 46 | }); 47 | 48 | it('ngOnInit_shouldInitializeRoutes_givenRouterServiceGetRouterConfigMetadata', () => { 49 | 50 | const routerConfigMetadata = [{label: 'Home', path: 'home'}]; 51 | mockRouterService.getRouterConfigMetadata.and.returnValue(routerConfigMetadata); 52 | 53 | // Call the method under test 54 | componentFixture.detectChanges(); 55 | 56 | expect(JSON.stringify(headerComponent.routes)).toBeTruthy(JSON.stringify(routerConfigMetadata)); 57 | }); 58 | 59 | it('sidenavOpen_should_given', () => { 60 | 61 | // Call the method under test 62 | headerComponent.sidenavOpen('home'); 63 | 64 | expect(mockStore.dispatch).toHaveBeenCalledTimes(1); 65 | expect(mockStore.dispatch).toHaveBeenCalledWith({path: 'home', type: MapActionTypes.SidenavOpen}); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/app/header/header.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Store } from '@ngrx/store'; 3 | import { AppState } from '../app.state'; 4 | import { RouteMetadata } from '../shared/models/route-metadata'; 5 | import { RouterService } from '../shared/services/router.service'; 6 | import * as MapActions from '../map/map.actions'; 7 | 8 | @Component({ 9 | selector: 'app-header', 10 | templateUrl: './header.component.html', 11 | styleUrls: ['./header.component.css'] 12 | }) 13 | export class HeaderComponent implements OnInit { 14 | 15 | public routes: RouteMetadata[]; 16 | 17 | constructor( 18 | private store: Store, 19 | private routerService: RouterService) { } 20 | 21 | ngOnInit(): void { 22 | this.routes = this.routerService.getRouterConfigMetadata(); 23 | this.routerService.updateRedirectTo(); 24 | } 25 | 26 | sidenavOpen(path: string): void { 27 | this.store.dispatch(MapActions.SidenavOpen({path: path})); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/home/home.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epaitz/jsapi-angular-ngrx-ds2021/79cd425a1b3ced8ee119af3c15300bd14f1e2d16/src/app/home/home.component.css -------------------------------------------------------------------------------- /src/app/home/home.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | Home Component 4 |
5 |
-------------------------------------------------------------------------------- /src/app/home/home.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { HomeComponent } from './home.component'; 3 | 4 | describe('HomeComponent', () => { 5 | let component: HomeComponent; 6 | let fixture: ComponentFixture; 7 | 8 | beforeEach(async () => { 9 | await TestBed.configureTestingModule({ 10 | declarations: [ HomeComponent ] 11 | }) 12 | .compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(HomeComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/home/home.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-home', 5 | templateUrl: './home.component.html', 6 | styleUrls: ['./home.component.css'] 7 | }) 8 | export class HomeComponent implements OnInit { 9 | 10 | constructor() { } 11 | 12 | ngOnInit(): void { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/app/in-memory-data.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { InMemoryDbService } from 'angular-in-memory-web-api'; 3 | 4 | @Injectable({ 5 | providedIn: 'root' 6 | }) 7 | export class InMemoryDataService implements InMemoryDbService { 8 | 9 | constructor() { } 10 | 11 | createDb(): any { 12 | const webmap = { 13 | operationalLayers: [ 14 | { 15 | id: '1234567890', 16 | layerType: 'ArcGISMapServiceLayer', 17 | url: 'https://sampleserver6.arcgisonline.com/arcgis/rest/services/PoolPermits/MapServer', 18 | visibility: true, 19 | opacity: 1, 20 | title: 'Pool Permits' 21 | } 22 | ], 23 | baseMap: { 24 | baseMapLayers: [ 25 | { 26 | id: 'defaultBasemap', 27 | layerType: 'ArcGISTiledMapServiceLayer', 28 | url: 'http://services.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer', 29 | visibility: true, 30 | opacity: 1, 31 | title: 'World Imagery' 32 | } 33 | ], 34 | title: 'World Imagery' 35 | }, 36 | spatialReference: { 37 | wkid: 102100, 38 | latestWkid: 3857 39 | }, 40 | initialState: { 41 | viewpoint: { 42 | targetGeometry: { 43 | xmin: -13075816.404716644, 44 | ymin: 4014771.4695451558, 45 | xmax: -13073005.679717692, 46 | ymax: 4016869.786173813, 47 | spatialReference: { 48 | wkid: 102100, 49 | latestWkid: 3857 50 | } 51 | } 52 | }, 53 | }, 54 | authoringApp: 'WebMapViewer', 55 | authoringAppVersion: '4.1', 56 | version: '2.4' 57 | }; 58 | const bookmarks = [ 59 | { 60 | id: '1', 61 | name: 'Stonehenge', 62 | extent: { 63 | spatialReference: { 64 | latestWkid: 3857, 65 | wkid: 102100 66 | }, 67 | xmin: -204464.48473945778, 68 | ymin: 6652004.209942442, 69 | xmax: -202118.82343389466, 70 | ymax: 6653996.349992177 71 | } 72 | }, 73 | { 74 | id: '2', 75 | name: 'Colosseum', 76 | extent: { 77 | spatialReference: { 78 | latestWkid: 3857, 79 | wkid: 102100 80 | }, 81 | xmin: 1389452.5122349507, 82 | ymin: 5143550.030337398, 83 | xmax: 1391798.1735405137, 84 | ymax: 5145542.170387133 85 | } 86 | }, 87 | { 88 | id: '3', 89 | name: 'Eiffel Tower', 90 | extent: { 91 | spatialReference: { 92 | latestWkid: 3857, 93 | wkid: 102100 94 | }, 95 | xmin: 254836.15629884042, 96 | ymin: 6250370.866467227, 97 | xmax: 256008.9869514921, 98 | ymax: 6251366.936491984 99 | } 100 | }, 101 | { 102 | id: '4', 103 | name: 'Statue of Liberty', 104 | extent: { 105 | spatialReference: { 106 | latestWkid: 3857, 107 | wkid: 102100 108 | }, 109 | xmin: -8243167.852529133, 110 | ymin: 4966081.014563843, 111 | xmax: -8241995.02187648, 112 | ymax: 4967077.084588599 113 | } 114 | }, 115 | { 116 | id: '5', 117 | name: 'Windsor Castle', 118 | extent: { 119 | spatialReference: { 120 | latestWkid: 3857, 121 | wkid: 102100 122 | }, 123 | xmin: -67610.72121511001, 124 | ymin: 6706739.909635874, 125 | xmax: -66701.83717573332, 126 | ymax: 6707862.578488718 127 | } 128 | }, 129 | { 130 | id: '6', 131 | name: 'Taj Mahal', 132 | extent: { 133 | spatialReference: { 134 | latestWkid: 3857, 135 | wkid: 102100 136 | }, 137 | xmin: 8687093.89033436, 138 | ymin: 3144335.978792, 139 | xmax: 8688234.474115707, 140 | ymax: 3145720.205600985 141 | } 142 | }, 143 | { 144 | id: '7', 145 | name: 'Buckingham Palace', 146 | extent: { 147 | spatialReference: { 148 | latestWkid: 3857, 149 | wkid: 102100 150 | }, 151 | xmin: -16277.84390548297, 152 | ymin: 6709784.378336319, 153 | xmax: -15137.260124136397, 154 | ymax: 6711168.605145304 155 | } 156 | }, 157 | { 158 | id: '8', 159 | name: 'Hoover Dam', 160 | extent: { 161 | spatialReference: { 162 | latestWkid: 3857, 163 | wkid: 102100 164 | }, 165 | xmin: -12773689.858014941, 166 | ymin: 4301219.77087626, 167 | xmax: -12771408.690451995, 168 | ymax: 4303988.224494536 169 | } 170 | }, 171 | { 172 | id: '9', 173 | name: 'Golden Gate Bridge', 174 | extent: { 175 | spatialReference: { 176 | latestWkid: 3857, 177 | wkid: 102100 178 | }, 179 | xmin: -13636267.594747687, 180 | ymin: 4551143.453573501, 181 | xmax: -13631705.259621793, 182 | ymax: 4556680.3608100545 183 | } 184 | }, 185 | { 186 | id: '10', 187 | name: 'Burj Khalifa', 188 | extent: { 189 | spatialReference: { 190 | latestWkid: 3857, 191 | wkid: 102100 192 | }, 193 | xmin: 6151916.178940228, 194 | ymin: 2898591.7962207696, 195 | xmax: 6154197.346503175, 196 | ymax: 2901360.2498390465 197 | } 198 | }, 199 | { 200 | id: '11', 201 | name: 'United States Capitol', 202 | extent: { 203 | spatialReference: { 204 | latestWkid: 3857, 205 | wkid: 102100 206 | }, 207 | xmin: -8573951.502900934, 208 | ymin: 4704732.808105457, 209 | xmax: -8571636.89413811, 210 | ymax: 4707064.137468216 211 | } 212 | }, 213 | { 214 | id: '12', 215 | name: 'Mount Rushmore', 216 | extent: { 217 | spatialReference: { 218 | latestWkid: 3857, 219 | wkid: 102100 220 | }, 221 | xmin: -11517805.59671855, 222 | ymin: 5445507.165204986, 223 | xmax: -11515490.987955727, 224 | ymax: 5447838.494567745 225 | } 226 | }, 227 | { 228 | id: '13', 229 | name: 'Louvre Museum', 230 | extent: { 231 | spatialReference: { 232 | latestWkid: 3857, 233 | wkid: 102100 234 | }, 235 | xmin: 258814.60783697452, 236 | ymin: 6250035.458354502, 237 | xmax: 261129.216599796, 238 | ymax: 6252366.787717261 239 | } 240 | }, 241 | { 242 | id: '14', 243 | name: 'Notre-Dame Cathedrale', 244 | extent: { 245 | spatialReference: { 246 | latestWkid: 3857, 247 | wkid: 102100 248 | }, 249 | xmin: 261308.36588480198, 250 | ymin: 6249616.846191733, 251 | xmax: 261887.01807557142, 252 | ymax: 6250199.678532488 253 | } 254 | }, 255 | { 256 | id: '15', 257 | name: 'Pantheon', 258 | extent: { 259 | spatialReference: { 260 | latestWkid: 3857, 261 | wkid: 102100 262 | }, 263 | xmin: 260908.26581485878, 264 | ymin: 6248516.869581529, 265 | xmax: 261486.91800562822, 266 | ymax: 6249099.7019222835 267 | } 268 | } 269 | ]; 270 | return { 271 | webmap, 272 | bookmarks 273 | }; 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /src/app/map/README.md: -------------------------------------------------------------------------------- 1 | ## Bookmarks 2 | 3 | The BookmarksComponent is tied to the /bookmarks route and is used to demonstrate how you can use an NgRx action to navigate the map. 4 | 5 | ## Map Contents 6 | 7 | The MapContentsComponetn is tied to the /contents route and is currently empty but can be used to display the layers currently on the map. 8 | 9 | ## Map View 10 | 11 | The MapViewComponent, is a sub-component to the MapComponent, and is where the ESRI MapView is added to the DOM. It uses the MapFactory and the MapService to get the WebMap JSON and initialize the Map and MapView objects. 12 | 13 | ## Notifications 14 | 15 | The NotificationsComponent is tied to the /notifications route and is currently empty. 16 | 17 | ## Search 18 | 19 | The SearchComponent is tied to the /search route and is currently empty. 20 | 21 | ## Settings 22 | 23 | The SettingsComponent is tied to the /settings route and is currently empty. 24 | 25 | ## Map Component 26 | 27 | The MapComponent is the component tied to the /map route and contains the Toolbar, Sidenav, and MapView components. 28 | 29 | ## Map Factory 30 | 31 | The MapFactory is a service that is responsible for managing the WebMap and the MapView. This service will accept a WebMap JSON object and a DIV element. When initialized a new DIV element will be created and appended to the input DIV element. The WebMap will use the internally created DIV element so that it can be removed from the DOM when the component is being destroyed. If the DIV used to create the MapView is removed from the DOM when the component is being destroyed the MapView will not be destroyed and its state will remain in memory. When the component is recreated the MapFactory will know not to generate a new DIV and MapView. It will append the previously created DIV (which is attached to the MapView) back to the DOM and the map will display in the state it was before the component was destroyed. I did consider merging the Map Factory into the Map Service but for now I wanted to keep the creation of the Map and Map View separate from other Map related code like fetching the WebMap JSON. 32 | 33 | ## Map Service 34 | 35 | The MapService is an Angular Service used to talk to a REST API (or Portal) to fetch the WebMap JSON. 36 | -------------------------------------------------------------------------------- /src/app/map/bookmarks/README.md: -------------------------------------------------------------------------------- 1 | ## Bookmarks 2 | 3 | The Bookmark model class 4 | 5 | ## BookmarksActionTypes 6 | 7 | An enumeration of bookmarks action types for NgRx 8 | 9 | ## Bookmarks Actions 10 | 11 | A set of NgRx Actions created with the createAction helper method. 12 | 13 | ## Bookmarks State 14 | 15 | The Bookmarks State model class 16 | 17 | ## Bookmarks Component 18 | 19 | The Angular Component associated with the /bookmarks route. This component will initialize a serviceStatus$ and bookmarks$ observable from the NgRx Store and dispatch the GetBookmarks action from ngOnInit(). This component will also dispatch ReloadBookmarks from the refresh() method and dispatch NavigationRequest from the zoomTo() method. 20 | 21 | ## Bookmarks Effects 22 | 23 | The NgRx Effects for anything related to bookmarks. The first effect will listen to the GetBookmarks action and use the BookmarksService to get the Bookmarks array from the server and finally dispatch the GetBookmarksCompleted action. 24 | 25 | This effect also uses withLatestFrom to check if the bookmarks are already in the state and uses that array instead of calling BookmarksService.getBookmarks() which would make an unnecessary call to the server. 26 | 27 | This effect will also not dispatch GetBookmarksCompleted if the MapService is still fetching bookmarks from the server. If the server is slow in returning the bookmarks array, or the network is slow, we do not want to make a second call to the server if the first call is not finished yet. The way I came up with to detect this was to track, in the Bookmarks State, how many times a call to getBookmarks() has been made. This integer is stored in the BookmarksState.bookmarkCalls property. If the bookmarkCalls integer is > 1 then we know that this is not the first call and will dispatch the "no operation" action called GetBookmarksNoOp. This Action is not part of the Bookmarks Reducer so the state is not altered if this action is dispatched. If the bookmarksState.bookmarks is not null or undefined then we dispatch GetBookmarksCompleted without needing to call the server. Finally if this is the first time the Effect is intercepting the GetBookmarks Action we need to call getBookmarks() on the BookmarksServer to get the bookmarks from the server and dispatch the GetBookmarksCompleted action. The Bookmarks Reducer will reset the bookmarkCalls integer back to 1 on success or error of GetBookmarksCompleted and on ReloadBookmarks. 28 | 29 | ## Bookmarks Reducer 30 | 31 | The NgRx Reducer for the BookmarksState. 32 | 33 | The GetBookmarks action will increase the bookmarksCalls property by one and set the status to loading. 34 | 35 | The GetBookmarksCompleted action will add the bookmarks array to the state, set the status to content, and reset the bookmarkCalls to zero. 36 | 37 | The GetBookmarksError action will set the state to error. 38 | 39 | The ReloadBookmarks action will set the state to loading and reset the bookmarkCalls to zero. 40 | 41 | ## Bookmarks Selectors 42 | 43 | The NgRx Selectors for the Bookmarks State. 44 | 45 | ## Bookmarks Service 46 | 47 | The Angular Service for Bookmarks. This service currently only has a getBookmarks() method which will get the bookmarks array from the server. No need to catch an error here because the Bookmarks Effect will catch the error and dispatch the GetBookmarksError action. -------------------------------------------------------------------------------- /src/app/map/bookmarks/bookmark.ts: -------------------------------------------------------------------------------- 1 | export class Bookmark { 2 | constructor(public id: string, public name: string, public extent: any) {} 3 | } 4 | -------------------------------------------------------------------------------- /src/app/map/bookmarks/bookmarks-actions-types.ts: -------------------------------------------------------------------------------- 1 | export enum BookmarksActionTypes { 2 | GetBookmarks = 'GET_BOOKMARKS', 3 | GetBookmarksCompleted = 'GET_BOOKMARKS_COMPLETED', 4 | GetBookmarksError = 'GET_BOOKMARKS_ERROR', 5 | GetBookmarksNoOp = 'GET_BOOKMARKS_NOOP', 6 | ReloadBookmarks = 'RELOAD_BOOKMARKS' 7 | } 8 | -------------------------------------------------------------------------------- /src/app/map/bookmarks/bookmarks-actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | import { Bookmark } from './bookmark'; 3 | import { BookmarksActionTypes } from './bookmarks-actions-types'; 4 | 5 | export const GetBookmarks = createAction(BookmarksActionTypes.GetBookmarks); 6 | export const GetBookmarksCompleted = createAction(BookmarksActionTypes.GetBookmarksCompleted, props<{bookmarks: Bookmark[]}>()); 7 | export const GetBookmarksError = createAction(BookmarksActionTypes.GetBookmarksError, props<{error: any}>()); 8 | export const GetBookmarksNoOp = createAction(BookmarksActionTypes.GetBookmarksNoOp); 9 | export const ReloadBookmarks = createAction(BookmarksActionTypes.ReloadBookmarks); 10 | -------------------------------------------------------------------------------- /src/app/map/bookmarks/bookmarks-state.ts: -------------------------------------------------------------------------------- 1 | import { ServiceStatus } from 'src/app/shared/models/service-status'; 2 | import { Bookmark } from './bookmark'; 3 | 4 | export interface BookmarksState { 5 | status: ServiceStatus; 6 | bookmarks: Bookmark[]; 7 | bookmarkCalls: number; 8 | } 9 | -------------------------------------------------------------------------------- /src/app/map/bookmarks/bookmarks.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epaitz/jsapi-angular-ngrx-ds2021/79cd425a1b3ced8ee119af3c15300bd14f1e2d16/src/app/map/bookmarks/bookmarks.component.css -------------------------------------------------------------------------------- /src/app/map/bookmarks/bookmarks.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |
5 | 6 | Add 7 | 8 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | {{bookmark.name}} 18 | 21 | 24 | 25 | 26 | 27 | 28 |
29 | Refresh Bookmarks 30 |
31 | 32 |
33 |
-------------------------------------------------------------------------------- /src/app/map/bookmarks/bookmarks.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { MockStore, provideMockStore } from '@ngrx/store/testing'; 4 | import { BookmarksActionTypes } from './bookmarks-actions-types'; 5 | import { BookmarksComponent } from './bookmarks.component'; 6 | import { cold, hot } from 'jasmine-marbles'; 7 | import { FormsModule } from '@angular/forms'; 8 | import { MapActionTypes } from '../map.action.types'; 9 | 10 | describe('BookmarksComponent', () => { 11 | let bookmarksComponent: BookmarksComponent; 12 | let componentFixture: ComponentFixture; 13 | let mockStore: any; 14 | 15 | const initialState = { }; 16 | 17 | beforeEach(async () => { 18 | await TestBed.configureTestingModule({ 19 | declarations: [ 20 | BookmarksComponent 21 | ], 22 | schemas: [ 23 | CUSTOM_ELEMENTS_SCHEMA 24 | ], 25 | imports: [ 26 | FormsModule 27 | ], 28 | providers: [ 29 | provideMockStore({ initialState }) 30 | ] 31 | }) 32 | .compileComponents(); 33 | 34 | mockStore = TestBed.inject(MockStore); 35 | }); 36 | 37 | beforeEach(() => { 38 | componentFixture = TestBed.createComponent(BookmarksComponent); 39 | bookmarksComponent = componentFixture.componentInstance; 40 | 41 | }); 42 | 43 | it('ngOnInit_shouldDispatchBookmarksActions.GetBookmarks', () => { 44 | 45 | spyOn(mockStore, 'dispatch').and.callThrough(); 46 | 47 | // Call the method under test 48 | componentFixture.detectChanges(); 49 | 50 | expect(mockStore.dispatch).toHaveBeenCalledOnceWith({ type: BookmarksActionTypes.GetBookmarks }); 51 | }); 52 | 53 | it('ngOnInit_shouldInitializeStatusObservable', () => { 54 | 55 | mockStore.setState({bookmarksState: { status: { type: 'loading'}}}); 56 | 57 | // Call the method under test 58 | componentFixture.detectChanges(); 59 | 60 | const expected = cold('(a)', { a: { type: 'loading' } }); 61 | expect(bookmarksComponent.serviceStatus$).toBeObservable(expected); 62 | }); 63 | 64 | it('ngOnInit_shouldInitializeBookmarksObservable', () => { 65 | 66 | mockStore.setState({bookmarksState: { bookmarks: []}}); 67 | 68 | // Call the method under test 69 | componentFixture.detectChanges(); 70 | 71 | const expected = cold('(a)', { a: [] }); 72 | expect(bookmarksComponent.bookmarks$).toBeObservable(expected); 73 | }); 74 | 75 | it('refresh_shouldDispatchBookmarksActions.ReloadBookmarks', () => { 76 | 77 | spyOn(mockStore, 'dispatch').and.callThrough(); 78 | 79 | // Call the method under test 80 | bookmarksComponent.refresh(); 81 | 82 | expect(mockStore.dispatch).toHaveBeenCalledOnceWith({ type: BookmarksActionTypes.ReloadBookmarks }); 83 | }); 84 | 85 | it('zoomTo_shouldDispatchMapActions.NavigationRequest', () => { 86 | 87 | spyOn(mockStore, 'dispatch').and.callThrough(); 88 | 89 | const extent = {}; 90 | 91 | // Call the method under test 92 | bookmarksComponent.zoomTo(extent); 93 | 94 | expect(mockStore.dispatch).toHaveBeenCalledOnceWith({ type: MapActionTypes.NavigationRequest, target: extent }); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /src/app/map/bookmarks/bookmarks.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Store } from '@ngrx/store'; 3 | import { Observable } from 'rxjs'; 4 | import { AppState } from 'src/app/app.state'; 5 | import { ServiceStatus } from 'src/app/shared/models/service-status'; 6 | import { Bookmark } from './bookmark'; 7 | import * as BookmarksActions from './bookmarks-actions'; 8 | import * as MapActions from '../map.actions'; 9 | import { selectBookmarks, selectBookmarksStatus } from './bookmarks.selectors'; 10 | 11 | @Component({ 12 | selector: 'app-bookmarks', 13 | templateUrl: './bookmarks.component.html', 14 | styleUrls: ['./bookmarks.component.css'] 15 | }) 16 | export class BookmarksComponent implements OnInit { 17 | 18 | public name: string; 19 | public serviceStatus$: Observable; 20 | public bookmarks$: Observable; 21 | 22 | constructor(private store: Store) { } 23 | 24 | ngOnInit(): void { 25 | this.serviceStatus$ = this.store.select(selectBookmarksStatus); 26 | this.bookmarks$ = this.store.select(selectBookmarks); 27 | this.store.dispatch(BookmarksActions.GetBookmarks()); 28 | } 29 | 30 | refresh(): void { 31 | this.store.dispatch(BookmarksActions.ReloadBookmarks()); 32 | } 33 | 34 | addBookmark(): void { 35 | 36 | } 37 | 38 | zoomTo(extent: any): void { 39 | this.store.dispatch(MapActions.NavigationRequest({target: extent})); 40 | } 41 | 42 | deleteBookmark(): void { 43 | 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/app/map/bookmarks/bookmarks.effects.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Actions, createEffect, ofType } from '@ngrx/effects'; 3 | import { Action, Store } from '@ngrx/store'; 4 | import { catchError, map, mergeMap, withLatestFrom } from 'rxjs/operators'; 5 | import { AppState } from 'src/app/app.state'; 6 | import { Bookmark } from './bookmark'; 7 | import { BookmarksActionTypes } from './bookmarks-actions-types'; 8 | import { BookmarksService } from './bookmarks.service'; 9 | import * as BookmarksActions from './bookmarks-actions'; 10 | import { selectBookmarks, selectBookmarksState } from './bookmarks.selectors'; 11 | import { of } from 'rxjs'; 12 | import { BookmarksState } from './bookmarks-state'; 13 | 14 | @Injectable() 15 | export class BookmarksEffects { 16 | 17 | constructor( 18 | private actions$: Actions, 19 | private store: Store, 20 | private bookmarksService: BookmarksService) { 21 | } 22 | 23 | getBookmarks$ = createEffect(() => { 24 | return this.actions$.pipe( 25 | ofType(BookmarksActionTypes.GetBookmarks), 26 | withLatestFrom(this.store.select(selectBookmarksState), 27 | (action: Action, bookmarksState: BookmarksState) => { 28 | return bookmarksState; 29 | } 30 | ), 31 | mergeMap((bookmarksState: BookmarksState) => { 32 | 33 | if (bookmarksState.bookmarkCalls > 1) { 34 | return [BookmarksActions.GetBookmarksNoOp()]; 35 | } 36 | 37 | if (bookmarksState.bookmarks != null) { 38 | return [BookmarksActions.GetBookmarksCompleted({bookmarks: bookmarksState.bookmarks})]; 39 | } 40 | 41 | return this.bookmarksService.getBookmarks() 42 | .pipe( 43 | map((bookmarks: Bookmark[]) => { 44 | return BookmarksActions.GetBookmarksCompleted({bookmarks: bookmarks}); 45 | }), 46 | catchError((error) => { 47 | return of(BookmarksActions.GetBookmarksError({error: error})); 48 | }) 49 | ); 50 | 51 | }) 52 | ); 53 | }); 54 | 55 | // getBookmarks$ = createEffect(() => { 56 | // return this.actions$.pipe( 57 | // ofType(BookmarksActionTypes.GetBookmarks), 58 | // withLatestFrom(this.store.select(selectBookmarks), 59 | // (action: Action, bookmarks: Bookmark[]) => { 60 | // return bookmarks; 61 | // } 62 | // ), 63 | // mergeMap((bookmarks: Bookmark[]) => { 64 | // if (bookmarks != null) { return [BookmarksActions.GetBookmarksCompleted({bookmarks: bookmarks})]; } 65 | // return this.bookmarksService.getBookmarks() 66 | // .pipe( 67 | // map((x: Bookmark[]) => { 68 | // return BookmarksActions.GetBookmarksCompleted({bookmarks: x}); 69 | // }), 70 | // catchError((error) => { 71 | // return of(BookmarksActions.GetBookmarksError({error: error})); 72 | // }) 73 | // ); 74 | // }) 75 | // ); 76 | // }); 77 | 78 | reloadBookmarks$ = createEffect(() => { 79 | return this.actions$.pipe( 80 | ofType(BookmarksActionTypes.ReloadBookmarks), 81 | mergeMap(() => { 82 | return this.bookmarksService.getBookmarks() 83 | .pipe( 84 | map((bookmarks: Bookmark[]) => { 85 | return BookmarksActions.GetBookmarksCompleted({bookmarks: bookmarks}); 86 | }) 87 | ); 88 | }) 89 | ); 90 | }); 91 | 92 | } 93 | -------------------------------------------------------------------------------- /src/app/map/bookmarks/bookmarks.reducer.ts: -------------------------------------------------------------------------------- 1 | import { Action, createReducer, on } from '@ngrx/store'; 2 | import { ServiceStatus } from 'src/app/shared/models/service-status'; 3 | import { ServiceStatusTypes } from 'src/app/shared/models/service-status.types'; 4 | import { BookmarksState } from './bookmarks-state'; 5 | import * as BookmarksActions from './bookmarks-actions'; 6 | import { Bookmark } from './bookmark'; 7 | 8 | const initialState: BookmarksState = { 9 | status: new ServiceStatus(ServiceStatusTypes.content), 10 | bookmarks: null, 11 | bookmarkCalls: 0 12 | }; 13 | 14 | export function bookmarksReducer(state: BookmarksState = initialState, action: Action): BookmarksState { 15 | return bookmarksReducerFn(state, action); 16 | } 17 | 18 | const bookmarksReducerFn = createReducer( 19 | initialState, 20 | on(BookmarksActions.GetBookmarks, (state, action) => { 21 | return updateGetBookmarks(state); 22 | }), 23 | on(BookmarksActions.ReloadBookmarks, (state, action) => { 24 | return updateReloadBookmarks(state); 25 | }), 26 | on(BookmarksActions.GetBookmarksCompleted, (state, action) => { 27 | return addBookmarksToState(state, action.bookmarks); 28 | }), 29 | on(BookmarksActions.GetBookmarksError, (state, action) => { 30 | return updateServiceStatus(state, ServiceStatusTypes.error, action.error); 31 | }) 32 | ); 33 | 34 | function updateGetBookmarks(state: BookmarksState): BookmarksState { 35 | return { 36 | ...state, 37 | bookmarkCalls: state.bookmarkCalls + 1, 38 | status: new ServiceStatus(ServiceStatusTypes.loading) 39 | }; 40 | } 41 | 42 | function updateReloadBookmarks(state: BookmarksState): BookmarksState { 43 | return { 44 | ...state, 45 | bookmarkCalls: 0, 46 | status: new ServiceStatus(ServiceStatusTypes.loading) 47 | }; 48 | } 49 | 50 | function updateServiceStatus(state: BookmarksState, type: ServiceStatusTypes, error?: any): BookmarksState { 51 | return { ...state, status: new ServiceStatus(type, error)}; 52 | } 53 | 54 | function addBookmarksToState(state: BookmarksState, bookmarks: Bookmark[]): BookmarksState { 55 | return { 56 | status: new ServiceStatus(ServiceStatusTypes.content), 57 | bookmarks: bookmarks.slice().sort(compareBookmarksFn), 58 | bookmarkCalls: 0 59 | }; 60 | } 61 | 62 | function compareBookmarksFn(bookmark1: Bookmark, bookmark2: Bookmark): number { 63 | if (bookmark1.name < bookmark2.name) { return -1; } 64 | if (bookmark1.name > bookmark2.name) { return 1; } 65 | return 0; 66 | } 67 | -------------------------------------------------------------------------------- /src/app/map/bookmarks/bookmarks.selectors.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from '@ngrx/store'; 2 | import { AppState } from '../../app.state'; 3 | import { BookmarksState } from './bookmarks-state'; 4 | 5 | export const selectBookmarksState = (state: AppState) => state.bookmarksState; 6 | 7 | export const selectBookmarksStatus = createSelector( 8 | selectBookmarksState, 9 | (state: BookmarksState) => { 10 | return state?.status; 11 | } 12 | ); 13 | 14 | export const selectBookmarks = createSelector ( 15 | selectBookmarksState, 16 | (state: BookmarksState) => { 17 | return state?.bookmarks; 18 | } 19 | ); 20 | -------------------------------------------------------------------------------- /src/app/map/bookmarks/bookmarks.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | import { HttpClientService } from 'src/app/shared/services/http-client.service'; 4 | import { Bookmark } from './bookmark'; 5 | 6 | @Injectable({ 7 | providedIn: 'root' 8 | }) 9 | export class BookmarksService { 10 | 11 | constructor(private httpClientService: HttpClientService) { } 12 | 13 | getBookmarks(): Observable { 14 | return this.httpClientService.get('api/bookmarks'); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/app/map/map-contents/map-contents.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epaitz/jsapi-angular-ngrx-ds2021/79cd425a1b3ced8ee119af3c15300bd14f1e2d16/src/app/map/map-contents/map-contents.component.css -------------------------------------------------------------------------------- /src/app/map/map-contents/map-contents.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | Map Contents Component 4 |
5 |
-------------------------------------------------------------------------------- /src/app/map/map-contents/map-contents.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { MapContentsComponent } from './map-contents.component'; 3 | 4 | describe('MapContentsComponent', () => { 5 | let component: MapContentsComponent; 6 | let fixture: ComponentFixture; 7 | 8 | beforeEach(async () => { 9 | await TestBed.configureTestingModule({ 10 | declarations: [ MapContentsComponent ] 11 | }) 12 | .compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(MapContentsComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/map/map-contents/map-contents.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-map-contents', 5 | templateUrl: './map-contents.component.html', 6 | styleUrls: ['./map-contents.component.css'] 7 | }) 8 | export class MapContentsComponent implements OnInit { 9 | 10 | constructor() { } 11 | 12 | ngOnInit(): void { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/app/map/map-view/map-view.component.css: -------------------------------------------------------------------------------- 1 | :host { 2 | height: 100%; 3 | } -------------------------------------------------------------------------------- /src/app/map/map-view/map-view.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 | -------------------------------------------------------------------------------- /src/app/map/map-view/map-view.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { MapFactory } from '../map.factory'; 4 | import { MapViewComponent } from './map-view.component'; 5 | import { cold, hot } from 'jasmine-marbles'; 6 | import { provideMockStore, MockStore } from '@ngrx/store/testing'; 7 | import { MapActionTypes } from '../map.action.types'; 8 | 9 | describe('MapViewComponent', () => { 10 | let mapViewComponent: MapViewComponent; 11 | let componentFixture: ComponentFixture; 12 | let mockStore: any; 13 | let mockMapFactory: any; 14 | 15 | const initialState = { }; 16 | 17 | beforeEach(async () => { 18 | mockMapFactory = jasmine.createSpyObj('mockMapFactory', ['initializeMapView', 'removeMapViewContainer']); 19 | 20 | await TestBed.configureTestingModule({ 21 | declarations: [ 22 | MapViewComponent 23 | ], 24 | schemas: [ 25 | CUSTOM_ELEMENTS_SCHEMA 26 | ], 27 | providers: [ 28 | provideMockStore({ initialState }), 29 | { provide: MapFactory, useValue: mockMapFactory } 30 | ] 31 | }) 32 | .compileComponents(); 33 | 34 | mockStore = TestBed.inject(MockStore); 35 | }); 36 | 37 | beforeEach(() => { 38 | componentFixture = TestBed.createComponent(MapViewComponent); 39 | mapViewComponent = componentFixture.componentInstance; 40 | }); 41 | 42 | it ('ngOnInit_shouldInitializeServiceStatus', () => { 43 | 44 | // Define the app state in the store 45 | mockStore.setState({ mapState: { status: { type: 'loading'}}}); 46 | 47 | // Call the method under test 48 | componentFixture.detectChanges(); 49 | 50 | const expected = cold('(a)', { a: { type: 'loading' } }); 51 | expect(mapViewComponent.serviceStatus$).toBeObservable(expected); 52 | }); 53 | 54 | it ('ngOnInit_shouldDispatchGetWebMap_andCallInitializeMapView_givenWebMap', () => { 55 | 56 | spyOn(mockStore, 'dispatch').and.callThrough(); 57 | 58 | // Define the app state in the store 59 | mockStore.setState({ mapState: { webMap: {}}}); 60 | 61 | // Call the method under test 62 | componentFixture.detectChanges(); 63 | 64 | expect(mockMapFactory.initializeMapView).toHaveBeenCalledTimes(1); 65 | expect(mockStore.dispatch).toHaveBeenCalledWith({ type: MapActionTypes.GetWebMap }); 66 | }); 67 | 68 | it ('ngOnInit_shouldNotDispatchGetWebMap_andCallInitializeMapView_givenNullWebMap', () => { 69 | 70 | spyOn(mockStore, 'dispatch').and.callThrough(); 71 | 72 | // Define the app state in the store 73 | mockStore.setState({ mapState: { webMap: null}}); 74 | 75 | // Call the method under test 76 | componentFixture.detectChanges(); 77 | 78 | expect(mockMapFactory.initializeMapView).toHaveBeenCalledTimes(0); 79 | expect(mockStore.dispatch).toHaveBeenCalledWith({ type: MapActionTypes.GetWebMap }); 80 | }); 81 | 82 | it('refresh_should_given', () => { 83 | 84 | spyOn(mockStore, 'dispatch').and.callThrough(); 85 | 86 | // Call the method under test 87 | mapViewComponent.refresh(); 88 | 89 | expect(mockStore.dispatch).toHaveBeenCalledWith({ type: MapActionTypes.GetWebMap }); 90 | }); 91 | 92 | it('ngOnDestroy_shouldCallRemoveMapViewContainer', () => { 93 | 94 | // Initialize the component 95 | componentFixture.detectChanges(); 96 | 97 | // Call the method under test 98 | componentFixture.destroy(); 99 | 100 | expect(mockMapFactory.removeMapViewContainer).toHaveBeenCalledTimes(1); 101 | }); 102 | 103 | it('ngOnDestroy_shouldUnsubscribe', () => { 104 | 105 | /* tslint:disable:no-string-literal */ 106 | spyOn(mapViewComponent['ngUnsubscribe'], 'next').and.callThrough(); 107 | spyOn(mapViewComponent['ngUnsubscribe'], 'complete').and.callThrough(); 108 | /* tslint:enable:no-string-literal */ 109 | 110 | // Initialize the component 111 | componentFixture.detectChanges(); 112 | 113 | // Call the method under test 114 | componentFixture.destroy(); 115 | 116 | /* tslint:disable:no-string-literal */ 117 | expect(mapViewComponent['ngUnsubscribe'].next).toHaveBeenCalledTimes(1); 118 | expect(mapViewComponent['ngUnsubscribe'].complete).toHaveBeenCalledTimes(1); 119 | /* tslint:enable:no-string-literal */ 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /src/app/map/map-view/map-view.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; 2 | import { select, Store } from '@ngrx/store'; 3 | import { Observable, Subject } from 'rxjs'; 4 | import { filter, takeUntil } from 'rxjs/operators'; 5 | import { AppState } from 'src/app/app.state'; 6 | import { ServiceStatus } from 'src/app/shared/models/service-status'; 7 | import * as MapActions from '../map.actions'; 8 | import { MapFactory } from '../map.factory'; 9 | import { selectMapState, selectWebMap, selectWebMapStatus } from '../map.selectors'; 10 | 11 | @Component({ 12 | selector: 'app-map-view-component', 13 | templateUrl: './map-view.component.html', 14 | styleUrls: ['./map-view.component.css'] 15 | }) 16 | export class MapViewComponent implements OnInit, OnDestroy { 17 | 18 | @ViewChild('mapViewDiv', { static: true }) private elementRef: ElementRef; 19 | 20 | public serviceStatus$: Observable; 21 | 22 | private ngUnsubscribe: Subject = new Subject(); 23 | 24 | constructor( 25 | private store: Store, 26 | private mapFactory: MapFactory) { } 27 | 28 | ngOnInit(): void { 29 | 30 | this.serviceStatus$ = this.store.pipe(select(selectWebMapStatus)); 31 | 32 | this.store.select(selectWebMap) 33 | .pipe(filter(webMapDocument => webMapDocument != null)) 34 | .pipe(takeUntil(this.ngUnsubscribe)) 35 | .subscribe((webMapDocument) => { 36 | this.mapFactory.initializeMapView(this.elementRef, webMapDocument); 37 | }); 38 | 39 | this.store.dispatch(MapActions.GetWebMap()); 40 | } 41 | 42 | ngOnDestroy(): void { 43 | this.ngUnsubscribe.next(); 44 | this.ngUnsubscribe.complete(); 45 | this.mapFactory.removeMapViewContainer(this.elementRef); 46 | } 47 | 48 | refresh(): void { 49 | this.store.dispatch(MapActions.GetWebMap()); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/app/map/map.action.types.ts: -------------------------------------------------------------------------------- 1 | export enum MapActionTypes { 2 | GetWebMap = 'GET_WEBMAP', 3 | GetWebMapCompleted = 'GET_WEBMAP_COMPLETED', 4 | GetWebMapError = 'GET_WEBMAP_ERROR', 5 | GetWebMapNoOp = 'GET_WEBMAP_NOOP', 6 | UpdateMapViewProperties = 'UPDATE_MAP_VIEW_PROPERTIES', 7 | NavigationRequest = 'NAVIGATION_REQUEST', 8 | SidenavToggle = 'SIDENAV_TOGGLE', 9 | SidenavOpen = 'SIDENAV_OPEN', 10 | SidenavClose = 'SIDENAV_CLOSE' 11 | } 12 | -------------------------------------------------------------------------------- /src/app/map/map.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | import { MapActionTypes } from './map.action.types'; 3 | import { MapViewProperties } from '../shared/models/map-view-properties'; 4 | import { WebMapDocument } from '../shared/models/webmap-document'; 5 | 6 | export const GetWebMap = createAction(MapActionTypes.GetWebMap); 7 | export const GetWebMapCompleted = createAction(MapActionTypes.GetWebMapCompleted, props<{webMapDocument: WebMapDocument}>()); 8 | export const GetWebMapError = createAction(MapActionTypes.GetWebMapError, props<{error: any}>()); 9 | export const GetWebMapNoOp = createAction(MapActionTypes.GetWebMapNoOp); 10 | 11 | export const NavigationRequest = createAction(MapActionTypes.NavigationRequest, props<{target: any}>()); 12 | 13 | export const UpdateMapViewProperties = createAction( 14 | MapActionTypes.UpdateMapViewProperties, 15 | props<{mapViewProperties: MapViewProperties}>() 16 | ); 17 | 18 | export const SidenavToggle = createAction(MapActionTypes.SidenavToggle, props<{path: string}>()); 19 | export const SidenavOpen = createAction(MapActionTypes.SidenavOpen, props<{path: string}>()); 20 | export const SidenavClose = createAction(MapActionTypes.SidenavClose, props<{path: string}>()); 21 | -------------------------------------------------------------------------------- /src/app/map/map.component.css: -------------------------------------------------------------------------------- 1 | 2 | .mat-drawer.is-xs { 3 | width: 100%; 4 | } 5 | 6 | .mat-drawer { 7 | width: 300px; 8 | } -------------------------------------------------------------------------------- /src/app/map/map.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 |
6 |
7 |
8 | {{routeDataLabel$ | async}} 9 |
10 |
11 | 14 |
15 |
16 |
17 | 18 |
19 |
20 |
21 | 22 |
23 | 24 |
25 |
26 |
27 |
-------------------------------------------------------------------------------- /src/app/map/map.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { MediaObserver } from '@angular/flex-layout'; 4 | import { MapComponent } from './map.component'; 5 | import { cold, hot } from 'jasmine-marbles'; 6 | import { MockStore, provideMockStore } from '@ngrx/store/testing'; 7 | import { RouterService } from '../shared/services/router.service'; 8 | import { of } from 'rxjs'; 9 | import { MapActionTypes } from './map.action.types'; 10 | 11 | describe('MapComponent', () => { 12 | let mapComponent: MapComponent; 13 | let componentFixture: ComponentFixture; 14 | let mockMediaObserver: any; 15 | let mockRouterService: any; 16 | let mockStore: any; 17 | 18 | const initialState = { }; 19 | 20 | beforeEach(async () => { 21 | mockMediaObserver = jasmine.createSpyObj('mockMediaObserver', ['asObservable', 'isActive']); 22 | mockRouterService = jasmine.createSpyObj('mockRouterService', ['getRouterConfigMetadata']); 23 | 24 | await TestBed.configureTestingModule({ 25 | declarations: [ 26 | MapComponent 27 | ], 28 | schemas: [ 29 | CUSTOM_ELEMENTS_SCHEMA 30 | ], 31 | providers: [ 32 | provideMockStore({ initialState }), 33 | { provide: MediaObserver, useValue: mockMediaObserver }, 34 | { provide: RouterService, useValue: mockRouterService } 35 | ] 36 | }) 37 | .compileComponents(); 38 | 39 | mockStore = TestBed.inject(MockStore); 40 | }); 41 | 42 | beforeEach(() => { 43 | componentFixture = TestBed.createComponent(MapComponent); 44 | mapComponent = componentFixture.componentInstance; 45 | }); 46 | 47 | it('ngOnInit_shouldSetRouterConfig', () => { 48 | 49 | // Define the app state in the store 50 | mockStore.setState({ router: { state: { url: '/map/search', data: { label: 'search'}}}}); 51 | 52 | // Setup a response for asObservable and isActive on mockMediaObserver 53 | mockMediaObserver.asObservable.and.returnValue(of({})); 54 | mockMediaObserver.isActive.and.returnValue(true); 55 | 56 | // Setup a response for getRouterConfigMetadata 57 | const routerConfig = []; 58 | mockRouterService.getRouterConfigMetadata.and.returnValue(routerConfig); 59 | 60 | // Call the method under test 61 | componentFixture.detectChanges(); 62 | 63 | expect(JSON.stringify(mapComponent.routerConfig)).toBe(JSON.stringify(routerConfig)); 64 | }); 65 | 66 | it('ngOnInit_shouldSidenavOpened', () => { 67 | 68 | mockStore.setState( 69 | { 70 | mapState: { 71 | sidenav: { 72 | opened: true, 73 | path: '/map/search' 74 | } 75 | }, 76 | router: { 77 | state: { 78 | url: '/map/search', 79 | data: { 80 | label: 'Search' 81 | } 82 | } 83 | } 84 | }); 85 | 86 | // Setup a response for asObservable and isActive on mockMediaObserver 87 | mockMediaObserver.asObservable.and.returnValue(of({})); 88 | mockMediaObserver.isActive.and.returnValue(true); 89 | 90 | // Setup a response for getRouterConfigMetadata 91 | const routerConfig = []; 92 | mockRouterService.getRouterConfigMetadata.and.returnValue(routerConfig); 93 | 94 | // Call the method under test 95 | componentFixture.detectChanges(); 96 | 97 | const expected = cold('(a)', { a: true }); 98 | expect(mapComponent.sidenavOpened$).toBeObservable(expected); 99 | }); 100 | 101 | it('ngOnInit_shouldSetModeOver_andDispatchSidenavClose_givenMediaObserverIsActiveTrue', () => { 102 | 103 | spyOn(mockStore, 'dispatch').and.callThrough(); 104 | 105 | // Define the app state in the store 106 | mockStore.setState({ router: { state: { url: '/map/search', data: { label: 'search'}}}}); 107 | 108 | // Setup a response for asObservable and isActive on mockMediaObserver 109 | mockMediaObserver.asObservable.and.returnValue(of({})); 110 | mockMediaObserver.isActive.and.returnValue(true); 111 | 112 | // Setup a response for getRouterConfigMetadata 113 | const routerConfig = []; 114 | mockRouterService.getRouterConfigMetadata.and.returnValue(routerConfig); 115 | 116 | // Call the method under test 117 | componentFixture.detectChanges(); 118 | 119 | expect(mapComponent.isXs).toBe(true); 120 | expect(mapComponent.isSm).toBe(true); 121 | expect(mapComponent.mode).toBe('over'); 122 | }); 123 | 124 | it('ngOnInit_shouldSetModeSide_andDispatchSidenavOpen_givenMediaObserverIsActiveFalse', () => { 125 | 126 | spyOn(mockStore, 'dispatch').and.callThrough(); 127 | 128 | // Define the app state in the store 129 | mockStore.setState({ router: { state: { url: '/map/search', data: { label: 'search'}}}}); 130 | 131 | // Setup a response for asObservable and isActive on mockMediaObserver 132 | mockMediaObserver.asObservable.and.returnValue(of({})); 133 | mockMediaObserver.isActive.and.returnValue(false); 134 | 135 | // Setup a response for getRouterConfigMetadata 136 | const routerConfig = []; 137 | mockRouterService.getRouterConfigMetadata.and.returnValue(routerConfig); 138 | 139 | // Call the method under test 140 | componentFixture.detectChanges(); 141 | 142 | expect(mapComponent.isXs).toBe(false); 143 | expect(mapComponent.isSm).toBe(false); 144 | expect(mapComponent.mode).toBe('side'); 145 | expect(mockStore.dispatch).toHaveBeenCalledOnceWith({ type: MapActionTypes.SidenavOpen, path: '/map/search' }); 146 | }); 147 | 148 | it('ngOnDestroy_shouldUnsubscribe', () => { 149 | 150 | // Define the app state in the store 151 | mockStore.setState({ router: { state: { url: '/map/search', data: { label: 'search'}}}}); 152 | 153 | // Setup a response for asObservable and isActive on mockMediaObserver 154 | mockMediaObserver.asObservable.and.returnValue(of({})); 155 | mockMediaObserver.isActive.and.returnValue(true); 156 | 157 | // Setup a response for getRouterConfigMetadata 158 | const routerConfig = []; 159 | mockRouterService.getRouterConfigMetadata.and.returnValue(routerConfig); 160 | 161 | /* tslint:disable:no-string-literal */ 162 | spyOn(mapComponent['ngUnsubscribe'], 'next').and.callThrough(); 163 | spyOn(mapComponent['ngUnsubscribe'], 'complete').and.callThrough(); 164 | /* tslint:enable:no-string-literal */ 165 | 166 | // Initialize the component 167 | componentFixture.detectChanges(); 168 | 169 | // Call the method under test 170 | componentFixture.destroy(); 171 | 172 | /* tslint:disable:no-string-literal */ 173 | expect(mapComponent['ngUnsubscribe'].next).toHaveBeenCalledTimes(1); 174 | expect(mapComponent['ngUnsubscribe'].complete).toHaveBeenCalledTimes(1); 175 | /* tslint:enable:no-string-literal */ 176 | }); 177 | 178 | it('sidenavToggle_shouldDispatchSidenavToggle', () => { 179 | 180 | spyOn(mockStore, 'dispatch').and.callThrough(); 181 | 182 | const path = '/map/search'; 183 | 184 | // Call the method under test 185 | mapComponent.sidenavToggle(path); 186 | 187 | expect(mockStore.dispatch).toHaveBeenCalledOnceWith({ type: MapActionTypes.SidenavToggle, path: '/map/search'}); 188 | }); 189 | 190 | it('sidenavClose_shouldDispatchSidenavClose', () => { 191 | 192 | // Define the app state in the store 193 | mockStore.setState({ router: { state: { url: '/map/search', data: { label: 'search'}}}}); 194 | 195 | // Setup a response for asObservable and isActive on mockMediaObserver 196 | mockMediaObserver.asObservable.and.returnValue(of({})); 197 | mockMediaObserver.isActive.and.returnValue(false); 198 | 199 | // Setup a response for getRouterConfigMetadata 200 | const routerConfig = []; 201 | mockRouterService.getRouterConfigMetadata.and.returnValue(routerConfig); 202 | 203 | // Initlize the component 204 | componentFixture.detectChanges(); 205 | 206 | // SpyOn dispatch after detectChanges (i.e. ngOnInit()) 207 | spyOn(mockStore, 'dispatch').and.callThrough(); 208 | 209 | // Call the method under test 210 | mapComponent.sidenavClose(); 211 | 212 | expect(mockStore.dispatch).toHaveBeenCalledOnceWith({ type: MapActionTypes.SidenavClose, path: '/map/search'}); 213 | }); 214 | }); 215 | -------------------------------------------------------------------------------- /src/app/map/map.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy, OnInit } from '@angular/core'; 2 | import { MediaObserver } from '@angular/flex-layout'; 3 | import { Store } from '@ngrx/store'; 4 | import { Observable, Subject } from 'rxjs'; 5 | import { filter, takeUntil } from 'rxjs/operators'; 6 | import { AppState } from '../app.state'; 7 | import { selectSidenavOpened } from './map.selectors'; 8 | import * as MapActions from './map.actions'; 9 | import { RouterService } from '../shared/services/router.service'; 10 | import { RouteMetadata } from '../shared/models/route-metadata'; 11 | import { selectRouteDataLabel, selectRouteUrl } from '../app-router.selector'; 12 | 13 | @Component({ 14 | selector: 'app-map-component', 15 | templateUrl: './map.component.html', 16 | styleUrls: ['./map.component.css'] 17 | }) 18 | export class MapComponent implements OnInit, OnDestroy { 19 | 20 | public isXs = false; 21 | public isSm = false; 22 | public mode = 'side'; 23 | public routerConfig: RouteMetadata[]; 24 | public sidenavOpened$: Observable; 25 | public routeDataLabel$: Observable; 26 | 27 | private routeChildUrl: string; 28 | private ngUnsubscribe: Subject = new Subject(); 29 | private initializeSidenav = false; 30 | 31 | constructor( 32 | private store: Store, 33 | private mediaObserver: MediaObserver, 34 | private routerService: RouterService) { } 35 | 36 | ngOnInit(): void { 37 | 38 | this.routerConfig = this.routerService.getRouterConfigMetadata(); 39 | this.sidenavOpened$ = this.store.select(selectSidenavOpened); 40 | this.routeDataLabel$ = this.store.select(selectRouteDataLabel); 41 | 42 | this.store.select(selectRouteUrl) 43 | .pipe(filter(url => url != null)) 44 | .pipe(takeUntil(this.ngUnsubscribe)) 45 | .subscribe((url) => { 46 | this.routeChildUrl = url; 47 | }); 48 | 49 | this.mediaObserver.asObservable() 50 | .pipe(takeUntil(this.ngUnsubscribe)) 51 | .subscribe(() => { 52 | this.isXs = this.mediaObserver.isActive('xs'); 53 | this.isSm = this.mediaObserver.isActive('sm'); 54 | if (this.isXs || this.isSm) { 55 | this.mode = 'over'; 56 | } else { 57 | this.mode = 'side'; 58 | if (this.initializeSidenav === false ) { 59 | this.initializeSidenav = true; 60 | this.store.dispatch(MapActions.SidenavOpen({path: this.routeChildUrl})); 61 | } 62 | } 63 | }); 64 | } 65 | 66 | ngOnDestroy(): void { 67 | this.ngUnsubscribe.next(); 68 | this.ngUnsubscribe.complete(); 69 | } 70 | 71 | sidenavToggle(path: string): void { 72 | this.store.dispatch(MapActions.SidenavToggle({path: path})); 73 | } 74 | 75 | sidenavClose(): void { 76 | this.store.dispatch(MapActions.SidenavClose({path: this.routeChildUrl})); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/app/map/map.effects.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Actions, createEffect, ofType } from '@ngrx/effects'; 3 | import { Store, Action } from '@ngrx/store'; 4 | import { EMPTY, of } from 'rxjs'; 5 | import { catchError, map, mergeMap, tap, withLatestFrom } from 'rxjs/operators'; 6 | import { AppState } from '../app.state'; 7 | import { NavigationService } from '../shared/services/navigation.service'; 8 | import { selectMapState, selectWebMap } from './map.selectors'; 9 | import { MapService } from './map.service'; 10 | import * as MapActions from './map.actions'; 11 | import { MapState } from './map.state'; 12 | import { ServiceStatusTypes } from '../shared/models/service-status.types'; 13 | 14 | @Injectable() 15 | export class MapEffects { 16 | constructor( 17 | private actions$: Actions, 18 | private store: Store, 19 | private mapService: MapService, 20 | private navigationService: NavigationService) { 21 | } 22 | 23 | webMap$ = createEffect(() => { 24 | return this.actions$ 25 | .pipe( 26 | ofType(MapActions.GetWebMap), 27 | withLatestFrom(this.store.select(selectMapState), 28 | (action: Action, mapState: MapState) => { 29 | return mapState; 30 | } 31 | ), 32 | mergeMap((mapState: MapState) => { 33 | if (mapState.webMap != null) { return [MapActions.GetWebMapCompleted({webMapDocument: mapState.webMap})]; } 34 | return this.mapService.getWebMap() 35 | .pipe( 36 | map((webMapDocument) => { 37 | return MapActions.GetWebMapCompleted({webMapDocument: webMapDocument}); 38 | }), 39 | catchError((error) => { 40 | return of(MapActions.GetWebMapError({error: error})); 41 | }) 42 | ); 43 | 44 | }) 45 | ); 46 | }); 47 | 48 | // webMap$ = createEffect(() => { 49 | // return this.actions$ 50 | // .pipe( 51 | // ofType(MapActions.GetWebMap), 52 | // withLatestFrom(this.store.select(selectWebMap), 53 | // (action: Action, webMapDocument) => { 54 | // return webMapDocument; 55 | // } 56 | // ), 57 | // mergeMap((webMapDocument) => { 58 | // if (webMapDocument != null) { return [MapActions.GetWebMapCompleted({webMapDocument: webMapDocument})]; } 59 | // return this.mapService.getWebMap() 60 | // .pipe( 61 | // map((x) => { 62 | // return MapActions.GetWebMapCompleted({webMapDocument: x}); 63 | // }), 64 | // catchError((error) => { 65 | // return of(MapActions.GetWebMapError({error: error})); 66 | // }) 67 | // ); 68 | // }) 69 | // ); 70 | // }); 71 | 72 | // Create a navigation.effects.ts for this or move NavigationService into MapService 73 | navigationRequest$ = createEffect(() => this.actions$ 74 | .pipe( 75 | ofType(MapActions.NavigationRequest), 76 | tap((action: any) => { 77 | this.navigationService.goTo(action.target); 78 | }) 79 | ), 80 | { dispatch: false } 81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /src/app/map/map.factory.spec.ts: -------------------------------------------------------------------------------- 1 | import { MapFactory } from './map.factory'; 2 | import config from '@arcgis/core/config.js'; 3 | import { WebMapDocument } from '../shared/models/webmap-document'; 4 | 5 | describe('MapFactory', () => { 6 | 7 | let mapFactory: MapFactory; 8 | let mockRendererFactory: any; 9 | 10 | beforeEach(() => { 11 | config.assetsPath = '/assets'; 12 | mockRendererFactory = jasmine.createSpyObj('mockRendererFactory', ['createRenderer']); 13 | mapFactory = new MapFactory(mockRendererFactory); 14 | }); 15 | 16 | it('initializeMapView_shouldReturnMapView', (done) => { 17 | 18 | const renderer = { appendChild(): void {} }; 19 | mockRendererFactory.createRenderer.and.returnValue(renderer); 20 | 21 | spyOn(renderer, 'appendChild').and.callThrough(); 22 | 23 | mapFactory 24 | .getMapView() 25 | .subscribe((mapView: any) => { 26 | expect(mapView != null).toBe(true); 27 | expect(renderer.appendChild).toHaveBeenCalledTimes(1); 28 | done(); 29 | }); 30 | 31 | const elementRef = { nativeElement: {}} as any; 32 | const webMapDocument = new WebMapDocument(); 33 | 34 | // Call the method under test 35 | mapFactory.initializeMapView(elementRef, webMapDocument); 36 | }); 37 | 38 | it('removeMapViewContainer_shouldCallRemoveChild_givenElementRef', () => { 39 | 40 | const renderer = { appendChild(): void {}, removeChild(): void {} }; 41 | mockRendererFactory.createRenderer.and.returnValue(renderer); 42 | 43 | spyOn(renderer, 'removeChild').and.callThrough(); 44 | 45 | const elementRef = { nativeElement: {}} as any; 46 | const webMapDocument = new WebMapDocument(); 47 | 48 | mapFactory.initializeMapView(elementRef, webMapDocument); 49 | 50 | // Call the method under test 51 | mapFactory.removeMapViewContainer(elementRef); 52 | 53 | expect(renderer.removeChild).toHaveBeenCalledTimes(1); 54 | }); 55 | }); 56 | 57 | -------------------------------------------------------------------------------- /src/app/map/map.factory.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, ElementRef, Renderer2, RendererFactory2 } from '@angular/core'; 2 | import MapView from '@arcgis/core/views/MapView'; 3 | import WebMap from '@arcgis/core/WebMap'; 4 | import { Observable, ReplaySubject } from 'rxjs'; 5 | import { WebMapDocument } from '../shared/models/webmap-document'; 6 | 7 | @Injectable({ 8 | providedIn: 'root', 9 | }) 10 | export class MapFactory { 11 | 12 | private webMap: WebMap; 13 | private mapView: MapView; 14 | private renderer: Renderer2; 15 | private mapViewContainer: HTMLDivElement; 16 | private mapViewSubject: ReplaySubject; 17 | 18 | constructor(private rendererFactory: RendererFactory2) { 19 | this.mapViewSubject = new ReplaySubject(1); 20 | } 21 | 22 | public getMapView(): Observable { 23 | return this.mapViewSubject.asObservable(); 24 | } 25 | 26 | public initializeMapView(elementRef: ElementRef, webMapDocument: WebMapDocument): MapView { 27 | this.createMapViewContainer(elementRef); 28 | this.createWebMap(webMapDocument); 29 | this.createMapView(); 30 | this.mapViewSubject.next(this.mapView); 31 | return this.mapView; 32 | } 33 | 34 | private createMapViewContainer(elementRef: ElementRef): void { 35 | if (elementRef == null) { return; } 36 | if (this.mapViewContainer == null) { 37 | this.mapViewContainer = document.createElement('div'); 38 | this.mapViewContainer.style.cssText = 'height: 100%'; 39 | } 40 | this.initializeRenderer(); 41 | this.renderer.appendChild(elementRef.nativeElement, this.mapViewContainer); 42 | } 43 | 44 | private createWebMap(webMapDocument: WebMapDocument): void { 45 | if (this.webMap == null) { 46 | 47 | // The JSON from NgRx is immutable the WebMap.fromJSON() validates the JSON 48 | // and removes whitespace so we are uisng parse/stringify to make a clone. 49 | // This should be fixed in a future version of the JSAPI. 50 | this.webMap = WebMap.fromJSON(JSON.parse(JSON.stringify(webMapDocument))); 51 | } 52 | } 53 | 54 | private createMapView(): void { 55 | if (this.mapView == null) { 56 | this.mapView = new MapView( 57 | { 58 | container: this.mapViewContainer, 59 | map: this.webMap 60 | } 61 | ); 62 | } 63 | } 64 | 65 | private initializeRenderer(): void { 66 | if (this.renderer == null) { 67 | this.renderer = this.rendererFactory.createRenderer(null, null); 68 | } 69 | } 70 | 71 | public removeMapViewContainer(elementRef: ElementRef): void { 72 | if (this.mapViewContainer == null) { return; } 73 | this.initializeRenderer(); 74 | this.renderer.removeChild(elementRef.nativeElement, this.mapViewContainer); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/app/map/map.reducer.ts: -------------------------------------------------------------------------------- 1 | import { Action, createReducer, on } from '@ngrx/store'; 2 | import { MapViewProperties } from '../shared/models/map-view-properties'; 3 | import { ServiceStatus } from '../shared/models/service-status'; 4 | import { ServiceStatusTypes } from '../shared/models/service-status.types'; 5 | import * as MapActions from './map.actions'; 6 | import { MapState } from './map.state'; 7 | 8 | const initialState: MapState = { 9 | status: new ServiceStatus(ServiceStatusTypes.content) 10 | }; 11 | 12 | export function mapReducer(state = initialState, action: Action): MapState { 13 | return reducer(state, action); 14 | } 15 | 16 | const reducer = createReducer( 17 | initialState, 18 | on(MapActions.GetWebMap, (state, action) => { 19 | return updateServiceStatus(state, ServiceStatusTypes.loading); 20 | }), 21 | on(MapActions.GetWebMapCompleted, (state, action) => { 22 | return addWebMapToState(state, action.webMapDocument); 23 | }), 24 | on(MapActions.UpdateMapViewProperties, (state, action) => { 25 | return updateMapViewProperties(state, action.mapViewProperties); 26 | }), 27 | on(MapActions.SidenavToggle, (state, action) => { 28 | return sidenavUpdateToggle(state, action.path); 29 | }), 30 | on(MapActions.SidenavOpen, (state, action) => { 31 | return sidenavUpdateOpened(state, action.path, true); 32 | }), 33 | on(MapActions.SidenavClose, (state, action) => { 34 | return sidenavUpdateOpened(state, action.path, false); 35 | }), 36 | ); 37 | 38 | function updateServiceStatus(state: MapState, type: ServiceStatusTypes, error?: any): MapState { 39 | return { ...state, status: new ServiceStatus(type, error) }; 40 | } 41 | 42 | function addWebMapToState(state: MapState, webMap: any): MapState { 43 | return { 44 | ...state, 45 | status: new ServiceStatus(ServiceStatusTypes.content), 46 | webMap: webMap 47 | }; 48 | } 49 | 50 | function updateMapViewProperties(state: MapState, mapViewProperties: MapViewProperties): MapState { 51 | return { 52 | ...state, 53 | mapViewProperties: { 54 | ...state.mapViewProperties, 55 | ...mapViewProperties 56 | } 57 | }; 58 | } 59 | 60 | function sidenavUpdateToggle(state: MapState, path: string): MapState { 61 | 62 | return { 63 | ...state, 64 | sidenav: { 65 | opened: (state.sidenav?.path === path ? !state.sidenav.opened : true), 66 | path: path 67 | } 68 | }; 69 | } 70 | 71 | function sidenavUpdateOpened(state: MapState, path: string, opened: boolean): MapState { 72 | return { 73 | ...state, 74 | sidenav: { 75 | opened: opened, 76 | path: path 77 | } 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /src/app/map/map.selectors.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from '@ngrx/store'; 2 | import { AppState } from '../app.state'; 3 | import { MapState } from './map.state'; 4 | 5 | export const selectMapState = (state: AppState) => state.mapState; 6 | 7 | export const selectWebMapStatus = createSelector( 8 | selectMapState, 9 | (state: MapState) => { 10 | return state?.status; 11 | } 12 | ); 13 | 14 | export const selectWebMap = createSelector( 15 | selectMapState, 16 | (state: MapState) => { 17 | return state?.webMap; 18 | } 19 | ); 20 | 21 | export const selectMapViewPropertiesExtent = createSelector( 22 | selectMapState, 23 | (state: MapState) => { 24 | return state?.mapViewProperties?.extent; 25 | } 26 | ); 27 | 28 | export const selectSidenavOpened = createSelector( 29 | selectMapState, 30 | (state: MapState) => { 31 | return state?.sidenav?.opened; 32 | } 33 | ); 34 | -------------------------------------------------------------------------------- /src/app/map/map.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { ServiceStatus } from '../shared/models/service-status'; 2 | import { ServiceStatusTypes } from '../shared/models/service-status.types'; 3 | import { MapService } from './map.service'; 4 | import { of } from 'rxjs'; 5 | 6 | describe('MapService', () => { 7 | 8 | let mapService: MapService; 9 | let mockHttpService: any; 10 | 11 | beforeEach(() => { 12 | mockHttpService = jasmine.createSpyObj('mockHttpService', ['get']); 13 | mapService = new MapService(mockHttpService); 14 | }); 15 | 16 | it('getWebMap_shouldReturnWebMap', (done) => { 17 | 18 | const webMap = {}; 19 | mockHttpService.get.and.returnValue(of(webMap)); 20 | 21 | mapService 22 | .getWebMap() 23 | .subscribe((x) => { 24 | expect(JSON.stringify(webMap)).toBe(JSON.stringify(x)); 25 | done(); 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/app/map/map.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | import { WebMapDocument } from '../shared/models/webmap-document'; 4 | import { HttpClientService } from '../shared/services/http-client.service'; 5 | 6 | @Injectable({ 7 | providedIn: 'root', 8 | }) 9 | export class MapService { 10 | 11 | constructor(private httpClientService: HttpClientService) { } 12 | 13 | getWebMap(): Observable { 14 | return this.httpClientService.get('api/webmap'); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/app/map/map.state.ts: -------------------------------------------------------------------------------- 1 | import { MapViewProperties } from '../shared/models/map-view-properties'; 2 | import { ServiceStatus } from '../shared/models/service-status'; 3 | import { WebMapDocument } from '../shared/models/webmap-document'; 4 | 5 | export interface MapState { 6 | status: ServiceStatus; 7 | webMap?: WebMapDocument; 8 | mapViewProperties?: MapViewProperties; 9 | sidenav?: { 10 | opened: boolean, 11 | path: string 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /src/app/map/notifications/notifications.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epaitz/jsapi-angular-ngrx-ds2021/79cd425a1b3ced8ee119af3c15300bd14f1e2d16/src/app/map/notifications/notifications.component.css -------------------------------------------------------------------------------- /src/app/map/notifications/notifications.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | Notifications Component 4 |
5 |
-------------------------------------------------------------------------------- /src/app/map/notifications/notifications.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { NotificationsComponent } from './notifications.component'; 3 | 4 | describe('NotificationsComponent', () => { 5 | let component: NotificationsComponent; 6 | let fixture: ComponentFixture; 7 | 8 | beforeEach(async () => { 9 | await TestBed.configureTestingModule({ 10 | declarations: [ NotificationsComponent ] 11 | }) 12 | .compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(NotificationsComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/map/notifications/notifications.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-notifications', 5 | templateUrl: './notifications.component.html', 6 | styleUrls: ['./notifications.component.css'] 7 | }) 8 | export class NotificationsComponent implements OnInit { 9 | 10 | constructor() { } 11 | 12 | ngOnInit(): void { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/app/map/search/search.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epaitz/jsapi-angular-ngrx-ds2021/79cd425a1b3ced8ee119af3c15300bd14f1e2d16/src/app/map/search/search.component.css -------------------------------------------------------------------------------- /src/app/map/search/search.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | Search Component 4 |
5 |
-------------------------------------------------------------------------------- /src/app/map/search/search.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { SearchComponent } from './search.component'; 3 | 4 | describe('SearchComponent', () => { 5 | let component: SearchComponent; 6 | let fixture: ComponentFixture; 7 | 8 | beforeEach(async () => { 9 | await TestBed.configureTestingModule({ 10 | declarations: [ SearchComponent ] 11 | }) 12 | .compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(SearchComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/map/search/search.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-search', 5 | templateUrl: './search.component.html', 6 | styleUrls: ['./search.component.css'] 7 | }) 8 | export class SearchComponent implements OnInit { 9 | 10 | constructor() { } 11 | 12 | ngOnInit(): void { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/app/map/settings/settings.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epaitz/jsapi-angular-ngrx-ds2021/79cd425a1b3ced8ee119af3c15300bd14f1e2d16/src/app/map/settings/settings.component.css -------------------------------------------------------------------------------- /src/app/map/settings/settings.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | Settings Component 4 |
5 |
-------------------------------------------------------------------------------- /src/app/map/settings/settings.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { SettingsComponent } from './settings.component'; 3 | 4 | describe('SettingsComponent', () => { 5 | let component: SettingsComponent; 6 | let fixture: ComponentFixture; 7 | 8 | beforeEach(async () => { 9 | await TestBed.configureTestingModule({ 10 | declarations: [ SettingsComponent ] 11 | }) 12 | .compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(SettingsComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/map/settings/settings.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-settings', 5 | templateUrl: './settings.component.html', 6 | styleUrls: ['./settings.component.css'] 7 | }) 8 | export class SettingsComponent implements OnInit { 9 | 10 | constructor() { } 11 | 12 | ngOnInit(): void { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/app/shared/components/README.md: -------------------------------------------------------------------------------- 1 | ## Scrollable Container Component 2 | 3 | The Scrollable Container Component can be used to display a vertical scrollbar for content like lists as needed. This component could be expanded to support both vertical and horizontal scrolling. 4 | 5 | ## Status Container Component 6 | 7 | The Status Container Component can be used to display various status states for any part of the application. The component works with the ServiceStatus model that currently has an enumeration of 'loading', 'content', 'empty', and 'error'. The component has a default input message and an output refresh event. The refresh event can be used to retry the operation that is in an error state. When the state is set to 'loading' the Status Container will display the Angular Material Progress Spinner and a message. If the state is set to 'error' an error icon will be displayed with the generic message 'An error has occurred' and a Material Accordion can be expanded to show the error details. In the 'content' state the component will simply just show the content that is being wrapped. 8 | 9 | This component, and the ServiceStauts model, can be expanded to support other states or Template inputs can be added for each state so that the status template can be changed as needed. The 'empty' state is currently not implemented. 10 | 11 | ## Toolbar Component 12 | 13 | The Toolbar Component is a vertical toolbar based on the Angular Material Toolbar. In this project its currently used as the vertical toolbar on the Map Component. It will generate navigation icons for children of the route specified by the routePath input variable. If the children of that route contain a data object that define properties like icon and label, then a button will be generated. If the vertical space is not enough to display all the buttons an overflow menu will appear. If the settingsPath is defined, then the child route path matching the settingsPath will be displayed at the bottom. 14 | -------------------------------------------------------------------------------- /src/app/shared/components/scrollable-container/scrollable-container.component.css: -------------------------------------------------------------------------------- 1 | :host { 2 | overflow-y: hidden; 3 | border: solid 1px rgba(0,0,0,.12); 4 | border-radius: 5px; 5 | /* transition: border 0.6s; */ 6 | } 7 | 8 | /* :host:hover { 9 | border: solid 2px rgba(0,0,0,.87); 10 | } */ -------------------------------------------------------------------------------- /src/app/shared/components/scrollable-container/scrollable-container.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |
5 | -------------------------------------------------------------------------------- /src/app/shared/components/scrollable-container/scrollable-container.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ScrollableContainerComponent } from './scrollable-container.component'; 4 | 5 | describe('ScrollableContainerComponent', () => { 6 | let component: ScrollableContainerComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ ScrollableContainerComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(ScrollableContainerComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/shared/components/scrollable-container/scrollable-container.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-scrollable-container', 5 | templateUrl: './scrollable-container.component.html', 6 | styleUrls: ['./scrollable-container.component.css'] 7 | }) 8 | export class ScrollableContainerComponent implements OnInit { 9 | 10 | constructor() { } 11 | 12 | ngOnInit(): void { 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/app/shared/components/status-container/status-container.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epaitz/jsapi-angular-ngrx-ds2021/79cd425a1b3ced8ee119af3c15300bd14f1e2d16/src/app/shared/components/status-container/status-container.component.css -------------------------------------------------------------------------------- /src/app/shared/components/status-container/status-container.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 |
6 | 8 | 9 |

{{message}}

10 |
11 | 12 |
13 | 14 |
15 | error 16 |

An error has occurred

17 |
18 | 19 |
20 | 21 | 22 | 23 | 24 | Details 25 | 26 | 27 | 28 | {{serviceStatus.error}} 29 | 30 | 31 |
32 | 33 |
34 | 35 |
36 | 37 |
38 | 39 |
40 | 41 |
42 | 43 | -------------------------------------------------------------------------------- /src/app/shared/components/status-container/status-container.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { StatusContainerComponent } from './status-container.component'; 3 | 4 | describe('StatusContainerComponent', () => { 5 | let statusContainerComponent: StatusContainerComponent; 6 | let componentFixture: ComponentFixture; 7 | 8 | beforeEach(async () => { 9 | await TestBed.configureTestingModule({ 10 | declarations: [ StatusContainerComponent ] 11 | }) 12 | .compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | componentFixture = TestBed.createComponent(StatusContainerComponent); 17 | statusContainerComponent = componentFixture.componentInstance; 18 | componentFixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(statusContainerComponent).toBeTruthy(); 23 | }); 24 | 25 | it('onRefresh_should_given', () => { 26 | 27 | spyOn(statusContainerComponent.refresh, 'emit').and.callThrough(); 28 | 29 | // Call the method under test 30 | statusContainerComponent.onRefresh(); 31 | 32 | expect(statusContainerComponent.refresh.emit).toHaveBeenCalledTimes(1); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/app/shared/components/status-container/status-container.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; 2 | import { ServiceStatus } from '../../models/service-status'; 3 | 4 | @Component({ 5 | selector: 'app-status-container', 6 | templateUrl: './status-container.component.html', 7 | styleUrls: ['./status-container.component.css'] 8 | }) 9 | export class StatusContainerComponent implements OnInit { 10 | 11 | @Input() serviceStatus: ServiceStatus; 12 | @Input() message = 'Please wait...'; 13 | @Output() refresh: EventEmitter = new EventEmitter(); 14 | 15 | constructor() { } 16 | 17 | ngOnInit(): void { } 18 | 19 | onRefresh(): void { 20 | this.refresh.emit(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/app/shared/components/toolbar/toolbar.component.css: -------------------------------------------------------------------------------- 1 | .mat-toolbar-row-vertical { 2 | height: 100%; 3 | flex-direction: column; 4 | } 5 | 6 | .mat-toolbar-vertical { 7 | height: 100%; 8 | width: 64px; 9 | flex-direction: column; 10 | } 11 | 12 | .mat-toolbar-vertical[compact] { 13 | height: 100%; 14 | width: 48px; 15 | flex-direction: column; 16 | } 17 | 18 | .mat-toolbar-multiple-rows[compact] { 19 | min-height: unset; 20 | } 21 | 22 | .mat-toolbar-multiple-rows[compact] .mat-toolbar-row { 23 | height: 100%; 24 | } 25 | 26 | .active { 27 | box-shadow: inset 3px 0 0px 0px; 28 | border-radius: 0; 29 | } -------------------------------------------------------------------------------- /src/app/shared/components/toolbar/toolbar.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 15 | 16 | 17 | 18 | 19 | 22 | 23 |
24 | 25 | 33 | 34 | 35 | 36 | 37 | 41 | 42 | 43 | 44 | 45 |
46 |
47 |
-------------------------------------------------------------------------------- /src/app/shared/components/toolbar/toolbar.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { CUSTOM_ELEMENTS_SCHEMA, ElementRef } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { MatMenuModule } from '@angular/material/menu'; 4 | import { RouterTestingModule } from '@angular/router/testing'; 5 | import { RouteMetadata } from '../../models/route-metadata'; 6 | import { ToolbarComponent } from './toolbar.component'; 7 | 8 | describe('ToolbarComponent', () => { 9 | let toolbarComponent: ToolbarComponent; 10 | let componentFixture: ComponentFixture; 11 | let mockElementRef: any; 12 | 13 | beforeEach(async () => { 14 | 15 | mockElementRef = jasmine.createSpyObj('mockElementRef', ['']); 16 | 17 | await TestBed.configureTestingModule({ 18 | declarations: [ 19 | ToolbarComponent 20 | ], 21 | schemas: [ 22 | CUSTOM_ELEMENTS_SCHEMA 23 | ], 24 | providers: [ 25 | { provide: ElementRef, useValue: mockElementRef } 26 | ], 27 | imports: [ 28 | RouterTestingModule, 29 | MatMenuModule 30 | ] 31 | }) 32 | .compileComponents(); 33 | }); 34 | 35 | beforeEach(() => { 36 | componentFixture = TestBed.createComponent(ToolbarComponent); 37 | toolbarComponent = componentFixture.componentInstance; 38 | }); 39 | 40 | it('ngOnInit_shouldNotInitializeButtons_givenNullOrUndefinedRoutePath', () => { 41 | 42 | // Call the method under test 43 | componentFixture.detectChanges(); 44 | 45 | expect(toolbarComponent.buttons == null).toBe(true); 46 | }); 47 | 48 | it('ngOnInit_shouldInitializeButtons_givenRouterConfigWithChildren', () => { 49 | 50 | const routerConfigMetadata = [ 51 | new RouteMetadata('Home', 'home', 'home'), 52 | new RouteMetadata('Map', 'map', 'map', null, [ 53 | new RouteMetadata('Search', 'search', 'map/search', 'search'), 54 | new RouteMetadata('Settings', 'settings', 'map/settings', 'settings') 55 | ]), 56 | ]; 57 | 58 | toolbarComponent.routePath = 'map'; 59 | toolbarComponent.settingsPath = 'settings'; 60 | toolbarComponent.routerConfig = routerConfigMetadata; 61 | 62 | // Call the method under test 63 | componentFixture.detectChanges(); 64 | 65 | const expectedButtons = [{id: 0, path: 'map/search', icon: 'search', label: 'Search', bottom: 48, visible: false}]; 66 | expect(JSON.stringify(toolbarComponent.buttons)).toBe(JSON.stringify(expectedButtons)); 67 | 68 | const expectedSettingsButton = {path: 'settings', icon: 'settings', label: 'Settings'}; 69 | expect(JSON.stringify(toolbarComponent.settingsButton)).toBe(JSON.stringify(expectedSettingsButton)); 70 | }); 71 | 72 | it('ngOnInit_shouldInitializeButtonsEmpty_givenRouterConfigWithNoChildren', () => { 73 | 74 | const routerConfigMetadata = [ 75 | new RouteMetadata('Home', 'home', 'home'), 76 | new RouteMetadata('Map', 'map', 'map'), 77 | new RouteMetadata('Tools', 'tools', 'tools') 78 | ]; 79 | 80 | toolbarComponent.routePath = 'map'; 81 | toolbarComponent.settingsPath = 'settings'; 82 | toolbarComponent.routerConfig = routerConfigMetadata; 83 | 84 | // Call the method under test 85 | componentFixture.detectChanges(); 86 | 87 | const expectedButtons = []; 88 | expect(JSON.stringify(toolbarComponent.buttons)).toBe(JSON.stringify(expectedButtons)); 89 | }); 90 | 91 | it('onSidenavToggle_should_given', () => { 92 | 93 | spyOn(toolbarComponent.sidenavToggle, 'emit').and.callThrough(); 94 | 95 | const path = 'map'; 96 | 97 | // Call the method under test 98 | toolbarComponent.onSidenavToggle(path); 99 | 100 | expect(toolbarComponent.sidenavToggle.emit).toHaveBeenCalledTimes(1); 101 | expect(toolbarComponent.sidenavToggle.emit).toHaveBeenCalledWith(path); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /src/app/shared/components/toolbar/toolbar.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Input, HostListener, ElementRef, Output, EventEmitter } from '@angular/core'; 2 | import { RouteMetadata } from '../../models/route-metadata'; 3 | 4 | @Component({ 5 | selector: 'app-toolbar', 6 | templateUrl: './toolbar.component.html', 7 | styleUrls: ['./toolbar.component.css'] 8 | }) 9 | export class ToolbarComponent implements OnInit { 10 | 11 | public buttons: any[]; 12 | public showButtonsFromRoute = false; 13 | public isOverflowVisible = false; 14 | public settingsButton: any; 15 | 16 | @Input() orientation: string; 17 | @Input() routePath: string; 18 | @Input() settingsPath: string; 19 | @Input() routerConfig: RouteMetadata[]; 20 | 21 | @Output() sidenavToggle: EventEmitter = new EventEmitter(); 22 | 23 | @HostListener('window:resize') onResize(): void { 24 | this.updateButtonVisibility(); 25 | } 26 | 27 | constructor(private elementRef: ElementRef) { } 28 | 29 | ngOnInit(): void { 30 | this.showButtonsFromRoute = (this.routePath != null); 31 | this.initializeButtonsFromRoute(); 32 | this.initializeSettingsButton(); 33 | this.updateButtonVisibility(); 34 | } 35 | 36 | onSidenavToggle(path: string): void { 37 | this.sidenavToggle.emit(path); 38 | } 39 | 40 | private initializeButtonsFromRoute(): void { 41 | if (this.showButtonsFromRoute === false) { return; } 42 | if (this.routerConfig == null) { return; } 43 | 44 | this.buttons = this.routerConfig 45 | .find((route: RouteMetadata) => { 46 | return route.path === this.routePath; 47 | }) 48 | ?.children 49 | ?.filter((route: RouteMetadata) => { 50 | return route.path !== this.settingsPath; 51 | }) 52 | .map((route: RouteMetadata, index: number) => { 53 | return { 54 | id: index, 55 | path: route.fullPath, 56 | icon: route.icon, 57 | label: route.label, 58 | bottom: 48 * (index + 1) 59 | }; 60 | }) ?? []; 61 | } 62 | 63 | private initializeSettingsButton(): void { 64 | if (this.showButtonsFromRoute === false) { return; } 65 | if (this.routerConfig == null) { return; } 66 | 67 | const settingsRoute = this.routerConfig 68 | .find((route: any) => { 69 | return route.path === this.routePath; 70 | }) 71 | ?.children 72 | ?.find((route: any) => { 73 | return route.path === this.settingsPath; 74 | }); 75 | 76 | if (settingsRoute != null) { 77 | this.settingsButton = { 78 | path: settingsRoute.path, 79 | icon: settingsRoute.icon, 80 | label: settingsRoute.label 81 | }; 82 | } 83 | } 84 | 85 | private updateButtonVisibility(): void { 86 | if (this.showButtonsFromRoute === false) { return; } 87 | if (this.buttons == null) { return; } 88 | 89 | const height = this.elementRef.nativeElement.getBoundingClientRect().height; 90 | 91 | this.buttons = this.buttons.map((button) => { 92 | button.visible = button.bottom < height - 100; 93 | return button; 94 | }); 95 | 96 | this.isOverflowVisible = this.buttons.some((button) => { 97 | return button.visible === false; 98 | }); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/app/shared/helpers/README.md: -------------------------------------------------------------------------------- 1 | ## Helpers 2 | 3 | The Helpers class is a collection of methods used throughout the application. Currently it only contains the following methods. 4 | 5 | - isNullOrEmpty() 6 | 7 | Checks if an array is null or contains zero elements. 8 | 9 | - isNullOrWhitespace() 10 | 11 | Chekcs if a string is null or undefined or contains only space characters. 12 | 13 | - isNullOrUndefined() 14 | 15 | Checks if a value is null or undefined by using the [juggling-check](https://stackoverflow.com/questions/28975896/is-there-a-way-to-check-for-both-null-and-undefined). 16 | -------------------------------------------------------------------------------- /src/app/shared/helpers/helpers.ts: -------------------------------------------------------------------------------- 1 | export class Helpers { 2 | 3 | public static isNullOrEmpty(value: any[]): boolean { 4 | return !(value != null && value.length > 0); 5 | } 6 | 7 | public static isNullOrWhitespace(value: string): boolean { 8 | return !(value != null && value.trim().length > 0); 9 | } 10 | 11 | public static isNullOrUndefined(value: any): boolean { 12 | return (value == null); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/app/shared/models/README.md: -------------------------------------------------------------------------------- 1 | ## Models 2 | 3 | MapViewProperties 4 | 5 | The MapViewProperties class is used to track the property changes to the MapView. This object is stored in the NgRx State. 6 | 7 | RestResponse 8 | 9 | The RestResponse class is used for a REST API response that uses an object with properties like data, error, and others. Some REST APIs will return a root object that contains a data property that holds the actual response. Other properties like error and size may be included. Currently the RestResponse class only contains a data and error properties. It is used by the HttpClientService in this application. 10 | 11 | RouteMetadata 12 | 13 | The RouteMetadata class is used by the RouterService which will transform the router config array to an array of RouteMetadata. This was done because currently in Angular static routes and lazy loaded routes are defined differently in the Angular Router. Any parts of the application that need router data can use the RouterService.getRouterConfigMetadata() which will return a consistent object model. In the future if the application is reconfigured to use lazy loaded routes then the RouterService can be updated but still return the same object array. That way any dependent parts of the application do not need to change. 14 | 15 | ServiceStatus 16 | 17 | The ServiceStatus class is a simple model used to describe the status of a service or any long running process. It contains a type property of type ServiceStatusTypes and an optional error object. 18 | 19 | ServiceStatusTypes 20 | 21 | Status type enumeration used by the ServiceStatus model. 22 | 23 | WebMapDocument 24 | 25 | This class us used so hold the JSON representation of a WebMap from a REST API. The JSAPI WebMap is created from this class. 26 | -------------------------------------------------------------------------------- /src/app/shared/models/map-view-properties.ts: -------------------------------------------------------------------------------- 1 | 2 | export class MapViewProperties { 3 | center: any; 4 | extent: any; 5 | zoom: number; 6 | } 7 | -------------------------------------------------------------------------------- /src/app/shared/models/rest-response.ts: -------------------------------------------------------------------------------- 1 | export class RestResponse { 2 | data: T; 3 | error: any; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/shared/models/route-metadata.ts: -------------------------------------------------------------------------------- 1 | export class RouteMetadata { 2 | constructor( 3 | public label: string, 4 | public path: string, 5 | public fullPath: string, 6 | public icon?: string, 7 | public children?: RouteMetadata[] 8 | ) { } 9 | } 10 | -------------------------------------------------------------------------------- /src/app/shared/models/service-status.ts: -------------------------------------------------------------------------------- 1 | import { ServiceStatusTypes } from './service-status.types'; 2 | 3 | export class ServiceStatus { 4 | constructor( 5 | public type: ServiceStatusTypes, 6 | public error?: any) { 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/app/shared/models/service-status.types.ts: -------------------------------------------------------------------------------- 1 | export enum ServiceStatusTypes { 2 | loading = 'loading', 3 | content = 'content', 4 | empty = 'empty', 5 | error = 'error' 6 | } 7 | -------------------------------------------------------------------------------- /src/app/shared/models/webmap-document.ts: -------------------------------------------------------------------------------- 1 | export class WebMapDocument { 2 | operationalLayers: any[]; 3 | basemap: any; 4 | spatialReference: any; 5 | initialState: any; 6 | authoringApp: any; 7 | authoringAppVersion: any; 8 | version: any; 9 | } 10 | -------------------------------------------------------------------------------- /src/app/shared/services/README.md: -------------------------------------------------------------------------------- 1 | # Shared Services 2 | 3 | ## AuthGuardService 4 | 5 | The AuthGuardService is just an example of how to guard a route. The service is currently hard-coded to hide the admin route but can be expanded to compare data from a user object to data in the route data property. The hasAccess() method is exposed as public because it is leveraged by the RouterService which it uses to filter out routes the current user does not have access to. If the logic to guard a route is in the hadAccess() method then it can be used by the AuthGuardService and leveraged by the RouterService to hide navigation elements the current user does not have access to. 6 | 7 | ## BaseService 8 | 9 | The BaseService class currently only contains an updateStatus() method used to update a Subject of type ServiceStatus. This was done so the method did not have to be reapeated in any service that had a ServiceStatus. 10 | 11 | ## HttpClientService 12 | 13 | The HttpClientService is used to centralize methods for all of the HTTP verbs (i.e. GET, POST, PUT, and DELETE). Currenlty only the GET verb has been added to this service. This service also supports the RestResponse model. The naming convention is getIt(), postIt(), putIt(), and deleteIt() are for HTTP calls with a REST API that supports the RestResponse style. The get(), post(), put(), and delete() are for HTTP calls with a REST API that does not support the RestRespons style. 14 | 15 | ## NavigationService 16 | 17 | The NavigationService is primarily used to keep the Map and MapView in sync with the NgRx State. This is similar to how the RouterStore works for NgRx. The RouterStore is used to keep the Angular Router configuration in sync with the NgRx State. The difference is that NgRx recommends using the Angular Router to navigate by calling navigateByUrl() but to navigate the Map and MapView you dispatch the NavigationRequest action. 18 | 19 | Another approach that would be similar to how the NgRx RouterStore works would be to get a reference to the MapView by using MapFactor.getWebMap() and then call webMap.goTo() for navigation. Then we can remove the NavigtionRequest action. I am not sure I like this approach because there will be other map requirements like drawing for example which could also follow the action approach by creating a DrawRequest action. These actions would not need to be written to the state but just intercepted by the Effect. 20 | 21 | ## RouterService 22 | 23 | The RouterService provides two public methods called getRouterConfigMetadata() and updateRedirectTo(). 24 | 25 | The getRouterConfigMetadata() method will process the router.config into an array of RouteMetadata. If the project is reconfigured to use lazy loaded routes the processRoutes() method can be modified to parse the routes into an array of RouteMetadata so that any components using the RouterService do not have to change. 26 | 27 | The updateRedirectTo() method will subscribe to the Angular Router events and watch for when NavigationEnd is fired. It will check if the route has children by looking for the '/' character and then find the base route. For example, if the route last navigated was /map/bookmarks then it will get a reference to the Route object for /map and alter its default redirect child route to /map/bookmarks. This is done so that if the user navigates away from /map to /tools or /home and then navigates back to /map that it will automatically redirect to /map/bookmarks vs. whatever was the default redirect child of /map which is /map/search. This helps with the user experience of navigating to the route the user was at the last time they were navigated to the /map route. This logic would have to be reconfigured if there are more than one generation of child routes. 28 | -------------------------------------------------------------------------------- /src/app/shared/services/auth-guard.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ActivatedRouteSnapshot, CanActivate, Route, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; 3 | import { Observable, Observer } from 'rxjs'; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class AuthGuardService implements CanActivate { 9 | 10 | constructor(private router: Router) { } 11 | 12 | public hasAccess(route: Route | ActivatedRouteSnapshot): boolean { 13 | if (route instanceof ActivatedRouteSnapshot) { 14 | return !(route.routeConfig.path === 'admin'); 15 | } else { 16 | return !(route.path === 'admin'); 17 | } 18 | } 19 | 20 | canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): UrlTree { 21 | const hasAccess = this.hasAccess(route); 22 | if (hasAccess === false) { 23 | return this.router.createUrlTree(['/home']); 24 | } 25 | } 26 | 27 | // canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { 28 | // return new Observable((observer: Observer) => { 29 | // let hasAccess = this.hasAccess(route); 30 | // if (hasAccess === false) { this.router.navigateByUrl('/home'); } 31 | // observer.next(hasAccess); 32 | // }); 33 | // } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/app/shared/services/base.service.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from 'rxjs'; 2 | import { ServiceStatus } from '../models/service-status'; 3 | import { ServiceStatusTypes } from '../models/service-status.types'; 4 | 5 | abstract class BaseService { 6 | protected updateStatus(statusSubjet: Subject, type: ServiceStatusTypes, error = null): void { 7 | statusSubjet.next(new ServiceStatus(type, error)); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/app/shared/services/http-client.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { Observable } from 'rxjs'; 4 | import { tap, map, catchError, delay } from 'rxjs/operators'; 5 | import { Helpers } from '../helpers/helpers'; 6 | import { RestResponse } from '../models/rest-response'; 7 | 8 | @Injectable({ 9 | providedIn: 'root' 10 | }) 11 | export class HttpClientService { 12 | constructor(private httpClient: HttpClient) {} 13 | 14 | public getIt(url: string, options?: {}): Observable { 15 | return this.httpClient 16 | .get>(url, options) 17 | .pipe( 18 | tap((response: RestResponse) => { 19 | if (!Helpers.isNullOrWhitespace(response.error)) { 20 | throw response.error; 21 | } 22 | }), 23 | map((response: RestResponse) => { 24 | return response.data; 25 | }), 26 | catchError((error) => { 27 | throw error; 28 | }) 29 | ); 30 | } 31 | 32 | public get(url: string, options?: {}): Observable { 33 | return this.httpClient 34 | .get(url, options) 35 | .pipe( 36 | delay(2000), 37 | map((response: T) => { 38 | return response; 39 | }), 40 | catchError((error) => { 41 | console.error(error); 42 | throw error; 43 | }) 44 | ); 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/app/shared/services/navigation.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Action, Store } from '@ngrx/store'; 3 | import { AppState } from '../../app.state'; 4 | import { MapFactory } from '../../map/map.factory'; 5 | import { Subject } from 'rxjs'; 6 | import { MapViewProperties } from '../models/map-view-properties'; 7 | import { fromJSON } from '@arcgis/core/geometry/support/jsonUtils'; 8 | import MapView from '@arcgis/core/views/MapView'; 9 | import Extent from '@arcgis/core/geometry/Extent'; 10 | import { selectMapViewPropertiesExtent } from 'src/app/map/map.selectors'; 11 | import { debounceTime, filter } from 'rxjs/operators'; 12 | import * as MapActions from '../../map/map.actions'; 13 | 14 | @Injectable({ 15 | providedIn: 'root' 16 | }) 17 | export class NavigationService { 18 | 19 | private mapView: MapView; 20 | private mapViewPropertiesSubject: Subject; 21 | private dispatchTriggeredByMap = false; 22 | 23 | constructor(private store: Store, private mapFactory: MapFactory) { 24 | this.mapFactory.getMapView() 25 | .subscribe((mapView: MapView) => { 26 | this.mapView = mapView; 27 | this.initializeWatch(); 28 | }); 29 | 30 | this.store.select(selectMapViewPropertiesExtent) 31 | .pipe(filter(state => state != null)) 32 | .subscribe((extent: any) => { 33 | this.navigateTargetFromDispatch(extent); 34 | }); 35 | 36 | this.mapViewPropertiesSubject = new Subject(); 37 | this.mapViewPropertiesSubject 38 | .pipe(debounceTime(150)) 39 | .subscribe((mapViewProperties: MapViewProperties) => { 40 | this.dispatchMapAction(MapActions.UpdateMapViewProperties({mapViewProperties: mapViewProperties})); 41 | this.dispatchTriggeredByMap = false; 42 | }); 43 | } 44 | 45 | public navigateTargetFromDispatch(target: any): void { 46 | if (this.mapView == null) { return; } 47 | if (this.dispatchTriggeredByMap) { return; } 48 | this.goTo(target); 49 | } 50 | 51 | public goTo(target: any): void { 52 | if (this.mapView == null) { return; } 53 | if (target.hasOwnProperty('zoom')) { 54 | this.mapView.goTo(target); 55 | } else { 56 | this.mapView.goTo(fromJSON(target), {duration: 2000}); 57 | } 58 | } 59 | 60 | private initializeWatch(): void { 61 | this.mapView.watch('extent', (extent: Extent) => { 62 | this.mapViewPropertiesSubject.next({ 63 | center: extent.center, 64 | zoom: this.mapView.zoom, 65 | extent: extent 66 | }); 67 | }); 68 | } 69 | 70 | private dispatchMapAction(action: Action): void { 71 | this.dispatchTriggeredByMap = true; 72 | try { 73 | this.store.dispatch(action); 74 | } finally { 75 | this.dispatchTriggeredByMap = false; 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/app/shared/services/router.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { RouterService } from './router.service'; 2 | import { cold, hot } from 'jasmine-marbles'; 3 | 4 | describe('RouterService', () => { 5 | let routerService: RouterService; 6 | let mockRouter: any; 7 | let mockAuthGuardService: any; 8 | 9 | beforeEach(() => { 10 | mockRouter = jasmine.createSpyObj('mockRouter', [''], { config: [] }); 11 | mockAuthGuardService = jasmine.createSpyObj('mockAuthGuardService', ['hasAccess']); 12 | routerService = new RouterService(mockRouter, mockAuthGuardService); 13 | }); 14 | 15 | it('getRouterConfigMetadata_shouldInitializeRouterConfigMetadata', () => { 16 | 17 | mockAuthGuardService.hasAccess.and.returnValue(true); 18 | 19 | // Setup a response for the config property on mockRouter 20 | const config = [ 21 | { path: 'home', data: { label: 'Home' }}, 22 | { path: 'map', data: { label: 'Map', }, children: [ 23 | { path: 'search', data: { label: 'Search', icon: 'search' }} 24 | ] 25 | } 26 | ]; 27 | (Object.getOwnPropertyDescriptor(mockRouter, 'config').get as any) 28 | .and.returnValue(config); 29 | 30 | // Call the method under test 31 | const routerConfigMetadata = routerService.getRouterConfigMetadata(); 32 | 33 | const expectedRouteMetaData = [ 34 | {label: 'Home', path: 'home', fullPath: 'home', children: null}, 35 | {label: 'Map', path: 'map', fullPath: 'map', children: [ 36 | {label: 'Search', path: 'search', fullPath: '/map/search', icon: 'search', children: null} 37 | ] 38 | } 39 | ]; 40 | 41 | expect(JSON.stringify(routerConfigMetadata)).toBe(JSON.stringify(expectedRouteMetaData)); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/app/shared/services/router.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { NavigationEnd, Route, Router } from '@angular/router'; 3 | import { Subscription } from 'rxjs'; 4 | import { RouteMetadata } from '../models/route-metadata'; 5 | import { AuthGuardService } from './auth-guard.service'; 6 | 7 | @Injectable({ 8 | providedIn: 'root' 9 | }) 10 | export class RouterService { 11 | 12 | private eventsSubscription: Subscription; 13 | 14 | constructor( 15 | private router: Router, 16 | private authGuardService: AuthGuardService) { 17 | } 18 | 19 | getRouterConfigMetadata(): RouteMetadata[] { 20 | return this.processRoutes(null, this.router.config); 21 | } 22 | 23 | updateRedirectTo(): void { 24 | if (this.eventsSubscription != null) { return; } 25 | 26 | this.eventsSubscription = this.router.events 27 | .subscribe((event) => { 28 | if (event instanceof NavigationEnd) { 29 | const lastIndex = event.url.lastIndexOf('/'); 30 | if (lastIndex > 0) { 31 | const baseUrl = event.url.substring(1, lastIndex); 32 | const baseRoute = this.getBaseRoute(baseUrl); 33 | this.updateRedirectToForRoute(baseRoute, event.url); 34 | } 35 | } 36 | }); 37 | } 38 | 39 | private processRoutes(parent: Route, routes: Route[]): RouteMetadata[] { 40 | if (routes == null) { return null; } 41 | 42 | return routes 43 | .filter((route: Route) => { 44 | return route.data != null; 45 | }) 46 | .filter((route: Route) => { 47 | return (route.canActivate == null ? true : this.authGuardService.hasAccess(route)); 48 | }) 49 | .map((route: Route) => { 50 | return new RouteMetadata( 51 | route.data.label, 52 | route.path, 53 | (parent != null ? '/' + parent.path + '/' + route.path : route.path), 54 | route.data.icon, 55 | this.processRoutes(route, route.children) 56 | ); 57 | }); 58 | } 59 | 60 | private getBaseRoute(baseUrl: string): Route { 61 | return this.router.config 62 | .find((route) => { 63 | return route.path.toLowerCase() === baseUrl.toLowerCase(); 64 | }); 65 | } 66 | 67 | private updateRedirectToForRoute(route: Route, url: string): void { 68 | if (route == null) { return; } 69 | const childRoute = route.children 70 | .find((x) => { 71 | return x.path === '' && x.redirectTo != null; 72 | }); 73 | if (childRoute != null) { 74 | childRoute.redirectTo = url; 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/app/tools/tools.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epaitz/jsapi-angular-ngrx-ds2021/79cd425a1b3ced8ee119af3c15300bd14f1e2d16/src/app/tools/tools.component.css -------------------------------------------------------------------------------- /src/app/tools/tools.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | Tools Component 4 |
5 |
-------------------------------------------------------------------------------- /src/app/tools/tools.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { ToolsComponent } from './tools.component'; 3 | 4 | describe('ToolsComponent', () => { 5 | let component: ToolsComponent; 6 | let fixture: ComponentFixture; 7 | 8 | beforeEach(async () => { 9 | await TestBed.configureTestingModule({ 10 | declarations: [ ToolsComponent ] 11 | }) 12 | .compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(ToolsComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/tools/tools.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-tools', 5 | templateUrl: './tools.component.html', 6 | styleUrls: ['./tools.component.css'] 7 | }) 8 | export class ToolsComponent implements OnInit { 9 | 10 | constructor() { } 11 | 12 | ngOnInit(): void { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epaitz/jsapi-angular-ngrx-ds2021/79cd425a1b3ced8ee119af3c15300bd14f1e2d16/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epaitz/jsapi-angular-ngrx-ds2021/79cd425a1b3ced8ee119af3c15300bd14f1e2d16/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ArcGIS API for JavaScript with Angular 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /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().bootstrapModule(AppModule) 12 | .catch(err => console.error(err)); 13 | -------------------------------------------------------------------------------- /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'; 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 | /*************************************************************************************************** 62 | * APPLICATION IMPORTS 63 | */ 64 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | 3 | @import url('~@arcgis/core/assets/esri/themes/light/main.css'); 4 | 5 | html, body { height: 100%; } 6 | body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } 7 | 8 | button[mat-icon-button][hover] { 9 | display: none; 10 | } 11 | 12 | .mat-list.mat-list-base { 13 | padding-top: 0; 14 | } 15 | 16 | mat-list-item:hover button[mat-icon-button][hover] { 17 | display: block; 18 | } 19 | 20 | mat-list-item button[mat-icon-button]:hover { 21 | color: #3f51b5; 22 | } 23 | 24 | mat-form-field { 25 | width: 100% 26 | } 27 | 28 | .mat-form-field-wrapper { 29 | padding-bottom: 0; 30 | } -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/dist/zone-testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting 8 | } from '@angular/platform-browser-dynamic/testing'; 9 | 10 | declare const require: { 11 | context(path: string, deep?: boolean, filter?: RegExp): { 12 | keys(): string[]; 13 | (id: string): T; 14 | }; 15 | }; 16 | 17 | // First, initialize the Angular testing environment. 18 | getTestBed().initTestEnvironment( 19 | BrowserDynamicTestingModule, 20 | platformBrowserDynamicTesting() 21 | ); 22 | // Then we find all the tests. 23 | const context = require.context('./', true, /\.spec\.ts$/); 24 | // And load the modules. 25 | context.keys().map(context); 26 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts", 10 | "src/polyfills.ts" 11 | ], 12 | "include": [ 13 | "src/**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "sourceMap": true, 8 | "declaration": false, 9 | "downlevelIteration": true, 10 | "experimentalDecorators": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "target": "es2015", 14 | "module": "es2020", 15 | "lib": [ 16 | "es2018", 17 | "dom" 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts", 12 | "src/polyfills.ts" 13 | ], 14 | "include": [ 15 | "src/**/*.spec.ts", 16 | "src/**/*.d.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rulesDirectory": [ 4 | "codelyzer" 5 | ], 6 | "rules": { 7 | "object-literal-shorthand": false, 8 | "align": { 9 | "options": [ 10 | "parameters", 11 | "statements" 12 | ] 13 | }, 14 | "array-type": false, 15 | "arrow-return-shorthand": true, 16 | "curly": true, 17 | "deprecation": { 18 | "severity": "warning" 19 | }, 20 | "eofline": true, 21 | "import-blacklist": [ 22 | true, 23 | "rxjs/Rx" 24 | ], 25 | "import-spacing": true, 26 | "indent": { 27 | "options": [ 28 | "spaces" 29 | ] 30 | }, 31 | "max-classes-per-file": false, 32 | "max-line-length": [ 33 | true, 34 | 140 35 | ], 36 | "member-ordering": [ 37 | true, 38 | { 39 | "order": [ 40 | "static-field", 41 | "instance-field", 42 | "static-method", 43 | "instance-method" 44 | ] 45 | } 46 | ], 47 | "no-console": [ 48 | true, 49 | "debug", 50 | "info", 51 | "time", 52 | "timeEnd", 53 | "trace" 54 | ], 55 | "no-empty": false, 56 | "no-inferrable-types": [ 57 | true, 58 | "ignore-params" 59 | ], 60 | "no-non-null-assertion": true, 61 | "no-redundant-jsdoc": true, 62 | "no-switch-case-fall-through": true, 63 | "no-var-requires": false, 64 | "object-literal-key-quotes": [ 65 | true, 66 | "as-needed" 67 | ], 68 | "quotemark": [ 69 | true, 70 | "single" 71 | ], 72 | "semicolon": { 73 | "options": [ 74 | "always" 75 | ] 76 | }, 77 | "space-before-function-paren": { 78 | "options": { 79 | "anonymous": "never", 80 | "asyncArrow": "always", 81 | "constructor": "never", 82 | "method": "never", 83 | "named": "never" 84 | } 85 | }, 86 | "typedef": [ 87 | true, 88 | "call-signature" 89 | ], 90 | "typedef-whitespace": { 91 | "options": [ 92 | { 93 | "call-signature": "nospace", 94 | "index-signature": "nospace", 95 | "parameter": "nospace", 96 | "property-declaration": "nospace", 97 | "variable-declaration": "nospace" 98 | }, 99 | { 100 | "call-signature": "onespace", 101 | "index-signature": "onespace", 102 | "parameter": "onespace", 103 | "property-declaration": "onespace", 104 | "variable-declaration": "onespace" 105 | } 106 | ] 107 | }, 108 | "variable-name": { 109 | "options": [ 110 | "ban-keywords", 111 | "check-format", 112 | "allow-pascal-case" 113 | ] 114 | }, 115 | "whitespace": { 116 | "options": [ 117 | "check-branch", 118 | "check-decl", 119 | "check-operator", 120 | "check-separator", 121 | "check-type", 122 | "check-typecast" 123 | ] 124 | }, 125 | "component-class-suffix": true, 126 | "contextual-lifecycle": true, 127 | "directive-class-suffix": true, 128 | "no-conflicting-lifecycle": true, 129 | "no-host-metadata-property": true, 130 | "no-input-rename": true, 131 | "no-inputs-metadata-property": true, 132 | "no-output-native": true, 133 | "no-output-on-prefix": true, 134 | "no-output-rename": true, 135 | "no-outputs-metadata-property": true, 136 | "template-banana-in-box": true, 137 | "template-no-negated-async": true, 138 | "use-lifecycle-interface": true, 139 | "use-pipe-transform-interface": true, 140 | "directive-selector": [ 141 | true, 142 | "attribute", 143 | "app", 144 | "camelCase" 145 | ], 146 | "component-selector": [ 147 | true, 148 | "element", 149 | "app", 150 | "kebab-case" 151 | ] 152 | } 153 | } 154 | --------------------------------------------------------------------------------