Browse Source

feat: setup ngx-rocket boilerplate code

master
Konstantinos Kamaropoulos 5 years ago
commit
e30571af21
  1. 15
      .editorconfig
  2. 60
      .gitignore
  3. 21
      .htmlhintrc
  4. 48
      .stylelintrc
  5. 25
      .yo-rc.json
  6. 144
      README.md
  7. 144
      angular.json
  8. 4
      docs/analytics.md
  9. 43
      docs/backend-proxy.md
  10. 202
      docs/coding-guides/angular.md
  11. 276
      docs/coding-guides/build-specific-configurations.md
  12. 101
      docs/coding-guides/e2e-tests.md
  13. 39
      docs/coding-guides/html.md
  14. 102
      docs/coding-guides/sass.md
  15. 63
      docs/coding-guides/typescript.md
  16. 46
      docs/coding-guides/unit-tests.md
  17. 51
      docs/corporate-proxy.md
  18. 58
      docs/i18n.md
  19. 9
      docs/readme.md
  20. 43
      docs/routing.md
  21. 46
      docs/updating.md
  22. 37
      e2e/protractor.conf.js
  23. 23
      e2e/src/app.e2e-spec.ts
  24. 18
      e2e/src/page-objects/app-shared.po.ts
  25. 14
      e2e/src/page-objects/shell.po.ts
  26. 9
      e2e/tsconfig.e2e.json
  27. 43
      karma.conf.js
  28. 20
      ngsw-config.json
  29. 15258
      package-lock.json
  30. 94
      package.json
  31. 41
      proxy.conf.js
  32. 17
      src/app/about/about-routing.module.ts
  33. 8
      src/app/about/about.component.html
  34. 0
      src/app/about/about.component.scss
  35. 24
      src/app/about/about.component.spec.ts
  36. 16
      src/app/about/about.component.ts
  37. 12
      src/app/about/about.module.ts
  38. 16
      src/app/app-routing.module.ts
  39. 1
      src/app/app.component.html
  40. 0
      src/app/app.component.scss
  41. 22
      src/app/app.component.spec.ts
  42. 65
      src/app/app.component.ts
  43. 35
      src/app/app.module.ts
  44. 30
      src/app/core/core.module.ts
  45. 48
      src/app/core/http/api-prefix.interceptor.spec.ts
  46. 20
      src/app/core/http/api-prefix.interceptor.ts
  47. 115
      src/app/core/http/cache.interceptor.spec.ts
  48. 57
      src/app/core/http/cache.interceptor.ts
  49. 58
      src/app/core/http/error-handler.interceptor.spec.ts
  50. 30
      src/app/core/http/error-handler.interceptor.ts
  51. 192
      src/app/core/http/http-cache.service.spec.ts
  52. 117
      src/app/core/http/http-cache.service.ts
  53. 106
      src/app/core/http/http.service.spec.ts
  54. 110
      src/app/core/http/http.service.ts
  55. 138
      src/app/core/i18n.service.spec.ts
  56. 96
      src/app/core/i18n.service.ts
  57. 10
      src/app/core/index.ts
  58. 78
      src/app/core/logger.service.spec.ts
  59. 111
      src/app/core/logger.service.ts
  60. 26
      src/app/core/route-reusable-strategy.ts
  61. 165
      src/app/core/until-destroyed.spec.ts
  62. 62
      src/app/core/until-destroyed.ts
  63. 20
      src/app/home/home-routing.module.ts
  64. 10
      src/app/home/home.component.html
  65. 9
      src/app/home/home.component.scss
  66. 30
      src/app/home/home.component.spec.ts
  67. 30
      src/app/home/home.component.ts
  68. 15
      src/app/home/home.module.ts
  69. 59
      src/app/home/quote.service.spec.ts
  70. 30
      src/app/home/quote.service.ts
  71. 2
      src/app/shared/index.ts
  72. 3
      src/app/shared/loader/loader.component.html
  73. 3
      src/app/shared/loader/loader.component.scss
  74. 64
      src/app/shared/loader/loader.component.spec.ts
  75. 15
      src/app/shared/loader/loader.component.ts
  76. 11
      src/app/shared/shared.module.ts
  77. 37
      src/app/shell/header/header.component.html
  78. 9
      src/app/shell/header/header.component.scss
  79. 30
      src/app/shell/header/header.component.spec.ts
  80. 32
      src/app/shell/header/header.component.ts
  81. 2
      src/app/shell/shell.component.html
  82. 0
      src/app/shell/shell.component.scss
  83. 31
      src/app/shell/shell.component.spec.ts
  84. 12
      src/app/shell/shell.component.ts
  85. 14
      src/app/shell/shell.module.ts
  86. 27
      src/app/shell/shell.service.spec.ts
  87. 23
      src/app/shell/shell.service.ts
  88. BIN
      src/apple-touch-icon.png
  89. BIN
      src/assets/ngx-rocket-logo.png
  90. BIN
      src/assets/ngx-rocket-logo@192.png
  91. 10
      src/browserslist
  92. 16
      src/environments/environment.prod.ts
  93. 29
      src/environments/environment.ts
  94. BIN
      src/favicon.ico
  95. 19
      src/hmr.ts
  96. 32
      src/index.html
  97. 17
      src/main.scss
  98. 24
      src/main.ts
  99. 21
      src/manifest.json
  100. 62
      src/polyfills.ts

15
.editorconfig

@ -0,0 +1,15 @@
# Editor configuration, see http://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 120
[*.md]
max_line_length = off
trim_trailing_whitespace = false

60
.gitignore

@ -0,0 +1,60 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
# Only exists if Bazel was run
/bazel-out
# Dependencies
/node_modules
# Cordova
/www
/plugins
/platforms
# Electron
/dist.electron
/electron.main.js
# IDEs and editors
.idea/*
!.idea/runConfigurations/
!.idea/codeStyleSettings.xml
.project
.classpath
.c9/
*.launch
.settings/
xcuserdata/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# Maven
/target
/log
# Misc
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
/reports
/src/translations/template.*
/src/environments/.env.*
# System Files
.DS_Store
Thumbs.db

21
.htmlhintrc

@ -0,0 +1,21 @@
{
"tagname-lowercase": false,
"attr-lowercase": false,
"attr-value-double-quotes": true,
"tag-pair": true,
"spec-char-escape": true,
"id-unique": true,
"src-not-empty": true,
"attr-no-duplication": true,
"title-require": true,
"tag-self-close": true,
"head-script-disabled": true,
"doctype-html5": true,
"id-class-value": "dash",
"style-disabled": true,
"inline-style-disabled": true,
"inline-script-disabled": true,
"space-tab-mixed-disabled": "true",
"id-class-ad-disabled": true,
"attr-unsafe-chars": true
}

48
.stylelintrc

@ -0,0 +1,48 @@
{
"extends": [
"stylelint-config-standard",
"stylelint-config-recommended-scss",
"stylelint-config-prettier"
],
"rules": {
"font-family-name-quotes": "always-where-recommended",
"function-url-quotes": [
"always",
{
"except": ["empty"]
}
],
"selector-attribute-quotes": "always",
"string-quotes": "double",
"max-nesting-depth": 3,
"selector-max-compound-selectors": 3,
"selector-max-specificity": "0,3,2",
"declaration-no-important": true,
"at-rule-no-vendor-prefix": true,
"media-feature-name-no-vendor-prefix": true,
"property-no-vendor-prefix": true,
"selector-no-vendor-prefix": true,
"value-no-vendor-prefix": true,
"no-empty-source": null,
"selector-class-pattern": "[a-z-]+",
"selector-id-pattern": "[a-z-]+",
"selector-max-id": 0,
"selector-no-qualifying-type": true,
"selector-max-universal": 0,
"selector-type-no-unknown": [
true,
{
"ignore": ["custom-elements", "default-namespace"]
}
],
"selector-pseudo-element-no-unknown": [
true,
{
"ignorePseudoElements": ["ng-deep"]
}
],
"unit-whitelist": ["px", "%", "em", "rem", "vw", "vh", "deg", "s"],
"max-empty-lines": 2,
"max-line-length": 120
}
}

25
.yo-rc.json

@ -0,0 +1,25 @@
{
"generator-ngx-rocket": {
"version": "7.1.0",
"props": {
"location": "path",
"strict": false,
"skipInstall": false,
"skipQuickstart": false,
"initGit": true,
"appName": "Aiolos",
"target": ["web"],
"pwa": true,
"ui": "bootstrap",
"auth": false,
"lazy": true,
"angulartics": false,
"tools": ["prettier", "hads"],
"utility": [],
"projectName": "aiolos",
"packageManager": "npm",
"mobile": [],
"desktop": []
}
}
}

144
README.md

@ -0,0 +1,144 @@
# Aiolos
This project was generated with [ngX-Rocket](https://github.com/ngx-rocket/generator-ngx-rocket/)
version 7.1.0
# Getting started
1. Go to project folder and install dependencies:
```sh
npm install
```
2. Launch development server, and open `localhost:4200` in your browser:
```sh
npm start
```
# Project structure
```
dist/ web app production build
docs/ project docs and coding guides
e2e/ end-to-end tests
src/ project source code
|- app/ app components
| |- core/ core module (singleton services and single-use components)
| |- shared/ shared module (common components, directives and pipes)
| |- app.component.* app root component (shell)
| |- app.module.ts app root module definition
| |- app-routing.module.ts app routes
| +- ... additional modules and components
|- assets/ app assets (images, fonts, sounds...)
|- environments/ values for various build environments
|- theme/ app global scss variables and theme
|- translations/ translations files
|- index.html html entry point
|- main.scss global style entry point
|- main.ts app entry point
|- polyfills.ts polyfills needed by Angular
+- test.ts unit tests entry point
reports/ test and coverage reports
proxy.conf.js backend proxy configuration
```
# Main tasks
Task automation is based on [NPM scripts](https://docs.npmjs.com/misc/scripts).
| Task | Description |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `npm start` | Run development server on `http://localhost:4200/` |
| `npm run serve:sw` | Run test server on `http://localhost:4200/` with service worker enabled |
| `npm run build [-- --configuration=production]` | Lint code and build web app for production (with [AOT](https://angular.io/guide/aot-compiler)) in `dist/` folder |
| `npm test` | Run unit tests via [Karma](https://karma-runner.github.io) in watch mode |
| `npm run test:ci` | Lint code and run unit tests once for continuous integration |
| `npm run e2e` | Run e2e tests using [Protractor](http://www.protractortest.org) |
| `npm run lint` | Lint code |
| `npm run translations:extract` | Extract strings from code and templates to `src/app/translations/template.json` |
| `npm run docs` | Display project documentation and coding guides |
| `npm run prettier` | Automatically format all `.ts`, `.js` & `.scss` files |
When building the application, you can specify the target configuration using the additional flag
`--configuration <name>` (do not forget to prepend `--` to pass arguments to npm scripts).
The default build configuration is `prod`.
## Development server
Run `npm start` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change
any of the source files.
You should not use `ng serve` directly, as it does not use the backend proxy configuration by default.
## Code scaffolding
Run `npm run generate -- component <name>` to generate a new component. You can also use
`npm run generate -- directive|pipe|service|class|module`.
If you have installed [angular-cli](https://github.com/angular/angular-cli) globally with `npm install -g @angular/cli`,
you can also use the command `ng generate` directly.
## Additional tools
Tasks are mostly based on the `angular-cli` tool. Use `ng help` to get more help or go check out the
[Angular-CLI README](https://github.com/angular/angular-cli).
## Code formatting
All `.ts`, `.js` & `.scss` files in this project are formatted automatically using [Prettier](https://prettier.io),
and enforced via the `test:ci` script.
A pre-commit git hook has been configured on this project to automatically format staged files, using
(pretty-quick)[https://github.com/azz/pretty-quick], so you don't have to care for it.
You can also force code formatting by running the command `npm run prettier`.
# What's in the box
The app template is based on [HTML5](http://whatwg.org/html), [TypeScript](http://www.typescriptlang.org) and
[Sass](http://sass-lang.com). The translation files use the common [JSON](http://www.json.org) format.
#### Tools
Development, build and quality processes are based on [angular-cli](https://github.com/angular/angular-cli) and
[NPM scripts](https://docs.npmjs.com/misc/scripts), which includes:
- Optimized build and bundling process with [Webpack](https://webpack.github.io)
- [Development server](https://webpack.github.io/docs/webpack-dev-server.html) with backend proxy and live reload
- Cross-browser CSS with [autoprefixer](https://github.com/postcss/autoprefixer) and
[browserslist](https://github.com/ai/browserslist)
- Asset revisioning for [better cache management](https://webpack.github.io/docs/long-term-caching.html)
- Unit tests using [Jasmine](http://jasmine.github.io) and [Karma](https://karma-runner.github.io)
- End-to-end tests using [Protractor](https://github.com/angular/protractor)
- Static code analysis: [TSLint](https://github.com/palantir/tslint), [Codelyzer](https://github.com/mgechev/codelyzer),
[Stylelint](http://stylelint.io) and [HTMLHint](http://htmlhint.com/)
- Local knowledgebase server using [Hads](https://github.com/sinedied/hads)
- Automatic code formatting with [Prettier](https://prettier.io)
#### Libraries
- [Angular](https://angular.io)
- [Bootstrap 4](https://getbootstrap.com)
- [ng-bootsrap](https://ng-bootstrap.github.io/)
- [Font Awesome](http://fontawesome.io)
- [RxJS](http://reactivex.io/rxjs)
- [ngx-translate](https://github.com/ngx-translate/core)
#### Coding guides
- [Angular](docs/coding-guides/angular.md)
- [TypeScript](docs/coding-guides/typescript.md)
- [Sass](docs/coding-guides/sass.md)
- [HTML](docs/coding-guides/html.md)
- [Unit tests](docs/coding-guides/unit-tests.md)
- [End-to-end tests](docs/coding-guides/e2e-tests.md)
#### Other documentation
- [I18n guide](docs/i18n.md)
- [Working behind a corporate proxy](docs/corporate-proxy.md)
- [Updating dependencies and tools](docs/updating.md)
- [Using a backend proxy for development](docs/backend-proxy.md)
- [Browser routing](docs/routing.md)

144
angular.json

@ -0,0 +1,144 @@
{
"$schema": "./node_modules/@angular-devkit/core/src/workspace/workspace-schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"aiolos": {
"root": "",
"sourceRoot": "src",
"projectType": "application",
"prefix": "app",
"schematics": {
"@schematics/angular:component": {
"styleext": "scss"
}
},
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist",
"index": "src/index.html",
"main": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"polyfills": "src/polyfills.ts",
"assets": [
"src/favicon.ico",
"src/apple-touch-icon.png",
"src/robots.txt",
"src/manifest.json",
"src/assets"
],
"styles": ["src/main.scss"],
"scripts": []
},
"configurations": {
"production": {
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
}
],
"serviceWorker": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
]
},
"ci": {
"progress": false
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"hmr": true,
"hmrWarning": false,
"browserTarget": "aiolos:build"
},
"configurations": {
"production": {
"hmr": false,
"browserTarget": "aiolos:build:production"
},
"ci": {
"progress": false
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "aiolos:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"karmaConfig": "karma.conf.js",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.spec.json",
"scripts": [],
"styles": ["src/main.scss"],
"assets": [
"src/favicon.ico",
"src/apple-touch-icon.png",
"src/robots.txt",
"src/manifest.json",
"src/assets"
]
},
"configurations": {
"ci": {
"progress": false,
"watch": false
}
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": ["tsconfig.app.json", "tsconfig.spec.json"],
"exclude": ["**/node_modules/**"]
}
}
}
},
"aiolos-e2e": {
"root": "e2e",
"projectType": "application",
"architect": {
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "aiolos:serve"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": ["e2e/tsconfig.e2e.json"],
"exclude": ["**/node_modules/**"]
}
}
}
}
},
"defaultProject": "aiolos"
}

4
docs/analytics.md

@ -0,0 +1,4 @@
# Analytics
This project does not come with any analytics library.
Should you decide to use one, you may want to consider [Angulartics2](https://github.com/angulartics/angulartics2).

43
docs/backend-proxy.md

@ -0,0 +1,43 @@
# Backend proxy
Usually when working on a web application you consume data from custom-made APIs.
To ease development with our development server integrating live reload while keeping your backend API calls working,
we also have setup a backend proxy to redirect API calls to whatever URL and port you want. This allows you:
- To develop frontend features without the need to run an API backend locally
- To use a local development server without [CORS](https://en.wikipedia.org/wiki/Cross-origin_resource_sharing) issues
- To debug frontend code with data from a remote testing platform directly
## How to configure
In the root folder you will find a `proxy.conf.js`, containing the backend proxy configuration.
The interesting part is there:
```js
const proxyConfig = [
{
context: '/api',
pathRewrite: { '^/api': '' },
target: 'http://api.icndb.com',
changeOrigin: true
}
];
```
This is where you can setup one or more proxy rules.
For the complete set of options, see the `http-proxy-middleware`
[documentation](https://github.com/chimurai/http-proxy-middleware#options).
### Corporate proxy support
To allow external API calls redirection through a corporate proxy, you will also find a `setupForCorporateProxy()`
function in the proxy configuration file. By default, this method configures a corporate proxy agent based on the
`HTTP_PROXY` environment variable, see the [corporate proxy documentation](corporate-proxy.md) for more details.
If you need to, you can further customize this function to fit the network of your working environment.
If your corporate proxy use a custom SSL certificate, your may need to add the `secure: false` option to your
backend proxy configuration.

202
docs/coding-guides/angular.md

@ -0,0 +1,202 @@
# Introduction to Angular and modern design patterns
[Angular](https://angular.io) (aka Angular 2, 4, 5, 6...) is a new framework completely rewritten from the ground up,
replacing the now well-known [AngularJS](https://angularjs.org) framework (aka Angular 1.x).
More that just a framework, Angular should now be considered as a whole _platform_ which comes with a complete set of
tools, like its own [CLI](https://github.com/angular/angular-cli), [debug utilities](https://augury.angular.io) or
[performance tools](https://github.com/angular/angular/tree/master/packages/benchpress).
Angular has been around for some time now, but I still get the feeling that it’s not getting the love it deserved,
probably because of other players in the field like React or VueJS. While the simplicity behind these frameworks can
definitely be attractive, they lack in my opinion what is essential when making big, enterprise-grade apps: a solid
frame to lead both experienced developers and beginners in the same direction and a rational convergence of tools,
patterns and documentation. Yes, the Angular learning curve may seems a little steep, but it’s definitely worth it.
## Getting started
#### Newcomer
If you're new to Angular you may feel overwhelmed by the quantity of new concepts to apprehend, so before digging
into this project you may want to start with [this progressive tutorial](https://angular.io/tutorial) that will guide
you step by step into building a complete Angular application.
#### AngularJS veteran
If you come from AngularJS and want to dig straight in the new version, you may want to take a look at the
[AngularJS vs 2 quick reference](https://angular.io/guide/ajs-quick-reference).
#### Cheatsheet
Until you know the full Angular API by heart, you may want to keep this
[cheatsheet](https://angular.io/guide/cheatsheet) that resumes the syntax and features on a single page at hand.
## Style guide
This project follows the standard [Angular style guide](https://angular.io/guide/styleguide).
More that just coding rules, this style guide also gives advices and best practices for a good application architecture
and is an **essential reading** for starters. Reading deeper, you can even find many explanations for some design
choices of the framework.
## FAQ
There is a lot to dig in Angular and some questions frequently bother people. In fact, most of unclear stuff seems to be
related to modules, for example the dreaded
[**"Core vs Shared modules"**](https://angular.io/guide/ngmodule-faq#what-kinds-of-modules-should-i-have-and-how-should-i-use-them)
question.
The guys at Angular may have noticed that since you can now find
[a nice FAQ on their website](https://angular.io/guide/ngmodule-faq#ngmodule-faqs) answering all the common questions
regarding modules. Don't hesitate to take a look at it, even if you think you are experienced enough with Angular :wink:.
## Going deeper
Even though they are not mandatory, Angular was designed for the use of design patterns you may not be accustomed to,
like [reactive programming](#reactive-programming), [unidirectional data flow](#unidirectional-data-flow) and
[centralized state management](#centralized-state-management).
These concepts are difficult to resume in a few words, and despite being tightly related to each other they concern
specific parts of an application flow, each being quite deep to learn on its own.
You will essentially find here a list of good starting points to learn more on these subjects.
#### Reactive programming
You may not be aware of it, but Angular is now a _reactive system_ by design.
Although you are not forced to use reactive programming patterns, they make the core of the framework and it is
definitely recommended to learn them if you want to leverage the best of Angular.
Angular uses [RxJS](http://reactivex.io/rxjs/) to implement the _Observable_ pattern.
> An _Observable_ is a stream of asynchronous events that can be processed with array-like operators.
##### From promises to observables
While AngularJS used to rely heavily on [_Promises_](https://docs.angularjs.org/api/ng/service/$q) to handle
asynchronous events, _Observables_ are now used instead in Angular. Even though in specific cases like for HTTP
requests, an _Observable_ can be converted into a _Promise_, it is recommended to embrace the new paradigm as it can a
lot more than _Promises_, with way less code. This transition is also explained in the
[Angular tutorial](https://angular.io/tutorial/toh-pt6#!%23observables).
Once you have made the switch, you will never look back again.
##### Learning references
- [What is reactive programming?](http://paulstovell.com/blog/reactive-programming), explained nicely through a simple
imaged story _(5 min)_
- [The introduction to reactive programming you've been missing](https://gist.github.com/staltz/868e7e9bc2a7b8c1f754),
the title says it all _(30 min)_
- [Functional reactive programming for Angular 2 developers](http://blog.angular-university.io/functional-reactive-programming-for-angular-2-developers-rxjs-and-observables/),
see the functional reactive programming principles in practice with Angular _(15 min)_
- [RxMarbles](http://rxmarbles.com), a graphical representation of Rx operators that greatly help to understand their
usage
#### Unidirectional data flow
In opposition with AngularJS where one of its selling points was two-way data binding which ended up causing a lot of
major headaches for complex applications, Angular now enforces unidirectional data flow.
What does it means? Said with other words, it means that change detection cannot cause cycles, which was one of
AngularJS problematic points. It also helps to maintain simpler and more predictable data flows in applications, along
with substantial performance improvements.
**Wait, then why the Angular documentation have mention of a
[two-way binding syntax](https://angular.io/guide/template-syntax#binding-syntax-an-overview)?**
If you look closely, the new two-way binding syntax is just syntactic sugar to combine two _one-way_ bindings (a
_property_ and _event_ binding), keeping the data flow unidirectional.
This change is really important, as it was often the cause of performance issues with AngularJS, and it one of the
pillars enabling better performance in new Angular apps.
While Angular tries to stay _pattern-agnostic_ and can be used with conventional MV\* patterns, it was designed with
reactive programming in mind and really shines when used with reactive data flow patterns like
[redux](http://redux.js.org/docs/basics/DataFlow.html),
[Flux](https://facebook.github.io/flux/docs/in-depth-overview.html#content) or
[MVI](http://futurice.com/blog/reactive-mvc-and-the-virtual-dom).
#### Centralized state management
As applications grow in size, keeping track of the all its individual components state and data flows can become
tedious, and tend to be difficult to manage and debug.
The main goal of using a centralized state management is to make state changes _predictable_ by imposing certain
restrictions on how and when updates can happen, using _unidirectional data flow_.
This approach was first made popular with React with introduction of the
[Flux](https://facebook.github.io/flux/docs/in-depth-overview.html#content) architecture. Many libraries emerged then
trying to adapt and refine the original concept, and one of these gained massive popularity by providing a simpler,
elegant alternative: [Redux](http://redux.js.org/docs/basics/DataFlow.html).
Redux is at the same time a library (with the big _R_) and a design pattern (with the little _r_), the latter being
framework-agnostic and working very well with Angular.
The _redux_ design pattern is based on these [3 principles](http://redux.js.org/docs/introduction/ThreePrinciples.html):
- The application state is a _single immutable_ data structure
- A state change is triggered by an _action_, an object describing what happened
- Pure functions called _reducers_ take the previous state and the next action to compute the new state
The core concepts behind these principles are nicely explained in
[this example](http://redux.js.org/docs/introduction/CoreConcepts.html) _(3 min)_.
For those interested, the redux pattern was notably inspired by
[The Elm Architecture](https://guide.elm-lang.org/architecture/) and the [CQRS](https://martinfowler.com/bliki/CQRS.html)
pattern.
##### Which library to use?
You can make Angular work with any state management library you like, but your best bet would be to use
[NGXS](http://ngxs.io) or [@ngrx](https://github.com/ngrx/platform). Both works the same as the popular
[Redux](http://redux.js.org) library, but with a tight integration with Angular and [RxJS](http://reactivex.io/rxjs/),
with some nice additional developer utilities.
NGXS is based on the same concepts as @ngrx, but with less boilerplate and a nicer syntax, making it less intimidating.
Here are some resources to get started:
- [Angular NGXS tutorial with example from scratch](https://appdividend.com/2018/07/03/angular-ngxs-tutorial-with-example-from-scratch/),
a guided tutorial for NGXS _(10 min)_
- [Build a better Angular 2 application with redux and ngrx](http://onehungrymind.com/build-better-angular-2-application-redux-ngrx/),
a nice tutorial for @ngrx _(30 min)_
- [Comprehensive introduction to @ngrx/store](https://gist.github.com/btroncone/a6e4347326749f938510), an in-depth
walkthrough to this library usage in Angular _(60 min)_
##### When to use it?
You may have noticed that the starter template does not include a centralized state management system out of the box.
Why is that? Well, while there is many benefits from using this pattern, the choice is ultimately up to your team and
what you want to achieve with your app.
Keep in mind that using a single centralized state for your app introduces a new layer a complexity
[that might not be needed](https://medium.com/@dan_abramov/you-might-not-need-redux-be46360cf367), depending of your
goal.
## Optimizing performance
While the new Angular version resolves by design most of the performance issues that could be experienced with
AngularJS, there is always room for improvements. Just keep in mind that delivering an app with good performance is
often a matter of common sense and sane development practices.
Here is [a list of key points](https://github.com/mgechev/angular-performance-checklist) to check for in your app to
make sure you deliver the best experience to your customers.
After going through the checklist, make sure to also run an audit of your page through
[**Lighthouse**](https://developers.google.com/web/tools/lighthouse/), the latest Google tool that gives you meaningful
insight about your app performance, accessibility, mobile compatibility and more.
## Keeping Angular up-to-date
Angular development is moving fast, and updates to the core libs and tools are pushed regularly.
Fortunately, the Angular team provides tools to help you follow through the updates:
- `npm run ng update` allows you to update your app and its dependencies
- The [Angular update website](https://update.angular.io) guides you through Angular changes and migrations, providing
step by step guides from one version to another.

276
docs/coding-guides/build-specific-configurations.md

@ -0,0 +1,276 @@
# Build-Specific Configuration
## tl;dr's
ngx-rocket comes with a very helpful `env` script that will save environment-variables set at build time to constants that can be used as configuration for your code. When combined with the `dotenv-cli` package, it enables maximum configurability
while maintaining lots of simplicity for local development and testing.
### Cookbook for maximum independence of deployment-specific configuration
Disclaimer: If you have a full-stack app in a monorepo, keep separate `.env` files for server-side and client-side
configs, and make sure `.env` files are .gitignore'd and that secrets never make it into client-side `.env` file.
For each configurable variable (e.g. BROWSER_URL, API_URL):
- Add it to package.json's env script so that the build-time variables will be saved for runtime:
```javascript
{
"scripts": {
"env": "ngx-scripts env npm_package_version BROWSER_URL API_URL",
 }
}
```
- Add it to or edit it in src/environments/environment.ts to expose it to your app as e.g. environment.API_URL:
```typescript
export const environment = {
// ...
API_URL: env.API_URL,
BROWSER_URL: env.BROWSER_URL
// ...
};
```
- Configure your CI's deployment to set the variables and export them to the build script before building - if your CI
gives you a shell script to run, make it something like this:
```shell
# bourne-like shells...
export API_URL='https://api.staging.example.com'
export BROWSER_URL='https://staging.example.com'
# ...
npm run build:ssr-and-client
```
- Finally, to have your cake and eat it too and avoid having to do all that for local development and testing (or clutter
your package.json up), install the `dotenv-cli` package and update your development-related npm scripts to take advantage
of it:
```shell
# environment.development.env.sh
BROWSER_URL='http://localhost:4200'
API_URL='http://localhost:4200'
```
```javascript
{
"scripts": {
"start": "dotenv -e environment.development.env.sh -- npm run env && ng serve --aot",
}
}
```
This way, app configurations will always come from deploy-specific environment variables, and your development environments
are still easy to work with.
For configuring the build itself (for example, if you want your QA build to be similar to your production build, but with
source maps enabled), consider avoiding adding a build configuration to angular.json, and instead adding the respective
overriding flag to the `ng` command in package.json:
```javascript
{
"scripts": {
"build:client-and-server-bundles:qa": "NG_BUILD_OVERRIDES='--sourceMap=true' npm run build:client-and-server-bundles",
"build:client-and-server-bundles": "npm run build:client-bundles && npm run build:server-bundles",
"build:client-bundles": "npm run env && ng build --prod $NG_BUILD_OVERRIDES",
}
}
```
The development server API proxy config can read runtime environment variables, so you can avoid having a superficial
dev-server configuration by taking advantage of them:
```javascript
{
"scripts": {
"start": "dotenv -e environment.development.env.sh -- npm run env && API_PROXY_HOST='http://localhost:9000' ng serve --aot",
"e2e": "ngtw build && npm run env && API_PROXY_HOST='http://localhost:7357' ng e2e --webdriverUpdate=false",
}
}
```
```javascript
const proxyConfig = [
{
context: '/api',
pathRewrite: { '^/api': '' },
target: `${process.env.API_PROXY_HOST}/api`,
changeOrigin: true,
secure: false
},
{
context: '/auth',
pathRewrite: { '^/auth': '' },
target: `${process.env.API_PROXY_HOST}/auth`,
changeOrigin: true,
secure: false
}
];
```
Quick SSR note: SSR works by building all the client bundles like normal, but then rendering them in real-time. So,
- the rest of your app from `main.server.ts` down has access to your build-time environment only, like your normal
client bundles
- but `server.ts` (the file configuring and running express) has access to your serve-time environment variables
### Less optimal alternatives
- On the opposite extreme of the spectrum, you can keep all build-specific configuration in a separate environment
file for each environment using Angular's built-in `fileReplacements`, but then you'll need a separate environment
file even for deployment-specific configuration (like hostnames), which can get out of hand fast.
- For a middle-of-the-road approach, you can divide configuration into two groups:
- Configuration shared by each environment-type:
- Environment-type examples include local development, staging/qa, test, production...
- Examples of configuration like this include:
- In test, animations are always disabled, but for all other environments, they're enabled
- In production, the payment gateway's publishable key is the live key, but all other environments use the
test key
- Configuration that sometimes needs to be specific to an individual deployment of a given environment:
- Examples of configuration like this include:
- This particular staging/qa server's base for constructing URLs is qa-stable.example.com, but qa/staging
environments could also be deployed to preprod.example.com or localhost:8081 or anywhereelse:7000.
- This particular deployment uses a specific bucket for Amazon S3 uploads
- In this approach, you can use Angular's `fileReplacements` for anything environment-specific and ngx-rocket's
`env` for anything deployment-specific. You can even have certain deployment-specific configuration fall back
to environment-specific defaults for certain environments like so:
```javascript
export const environment = {
// ...
BROWSER_URL: env.BROWSER_URL || 'https://qa.example.com'
// ...
};
```
- If you don't have lots of environment variables, you can avoid dotenv-cli and use your particular shell's method
to expose the variables before running the ngx-rocket env tool.
## Introduction
When building any maintainable application, a separation of configuration and code is always desired. In the case
of configuration, some of it will need to vary from environment to environment, build to build, or deployment to
deployment.
This guide focuses on this type of build-specific configuration in a very broad sense of an Angular app, describing
the specific Angular ways of controlling these configurations, detailing some angular-specific challenges, and
highlighting some ngx-rocket tooling that help with them in mind.
For an even broader non-Angular introduction of these concepts, see the
[The Twelve-Factor App](https://12factor.net/config) methodology's opinions on how this type of configuration
should be managed.
## Types of configuration
At the highest level, build-specific configuration can be divided into two categories:
1. Configuration for how your app is built and served
2. Configuration used by your codebase
### Configuration for how your app is built and served
This type of build-specific configuration is not used by your code, but is used to control the build system itself.
Configuration like this goes into Angular's
[workspace configuration](https://angular.io/guide/workspace-config#alternate-build-configurations). Instead of
rehashing existing documentation on this, this document will highlight how it relates to this subject. Namely, the
fact that in addition to specifying _HOW_ the app is built for each build configuration, the workspace configuration
allows mapping each build configuration to a separate environment configuration file for your codebase as well. It
also allows for making separate dev-server configurations in case you need to run it differently.
Therefore, each build configuration in the workspace configuration file is a tuple of
(how-to-build, environment-file-for-codebase), and you'll need a separate configuration for each combination.
## Angular's out-of-the-box environment configuration
### When it works well
This setup works quite well for configuration that's shared among all instances of an environment, like the following
examples:
- **test** environment always builds without source maps, disables animations, uses a recaptcha test key, and disables
analytics
- **dev** environment always builds with source maps, enables animations, uses a recaptcha live key, and disables
analytics
- **qa** environment always builds with source maps, enables animations, uses a recaptcha live key, and disables
analytics
- **prod** environment always builds without source maps, enables animations, uses a recaptcha live key, and enables
analytics
### Limitations of Angular's `fileReplacements`
But for certain deployment-specific configuration, things start to get really hairy, like in these examples:
- QA build configuration needs to be built for local deployment, deployment to a server on the internet for QA
purposes, and also deployment to another server on the internet for staging purposes
- Production build needs multiple different deployments of the same app to different servers
These cases can cause problems when:
- Each deployment needs a separate API URL
- Each deployment needs a separate URL for building its own URLs to where it's deployed
- Each deployment needs separate API keys, bucket names, etc
You _COULD_ start creating separate configurations for each deployment, each with its own `fileReplacements`, but that
would be really messy.
### Workarounds that don't work well
One workaround would be to keep such configurations as globals in a separate deployment-specific script file. But
that's pretty messy too. More importantly, there are limitations to where they can be used. For example, because
of AOT, such configuration variables cannot be used in Angular's decorators, because they're not statically
analyzable (i.e. their values knowable at build-time). So it would be better if we can keep everything in the same place.
### ngx-rocket to the rescue
The ngx-rocket `env` task solves this problem really well, and avoids the need for separate `environment.ts` files for
deployment-specific configuration.
To add a deployment-specific configuration:
1. edit the existing `environment.ts` files for whichever environments you'd like to make that variable
deployment-specific for by having it come from the imported "env" object - pro tip: you can even make it fall
back to an environment-based default and still be statically analyzable!
2. add that variable name to the npm script's `env` task
Now, as long as you have that environment variable set in the shell running the build, the `env` task will save it into
the `.env.ts` file before building.
If you really want, you can take things even further to the twelve-factor extreme, and you can even eliminate the
need for `fileReplacements` entirely, and make all configuration come from environment variables. Whether this will be
the right approach for your project will be up to you.
This makes separate deployments awesome and flexible, but unfortunately makes things a little bit of a hassle for your
local development, test, etc. environments because you have the burden of providing all those keys, settings, etc. as
environment variables.
To avoid having to do that, you'll can create a .gitignore'd `.env` file with all the variables set, and source it
with your shell (e.g. `source .env.sh && npm env` in bourne-like shells or `env.bat; npm env` in windows).
```shell
# bourne-like .env.sh
export BROWSER_URL=localhost:4200
```
```shell
REM windows env.bat
SET BROWSER_URL=localhost:4200
```
Luckily for us, there's a package called `dotenv-cli` that uses the `dotenv` package and does this in a cleaner and
cross-platform way and comes with even more bells and whistles. You should use that instead, and make your env file
like this instead:
```shell
BROWSER_URL=localhost:4200
```
## When you can use environment variables directly without ngx-rocket `env`
As a sidenote, ngx-rocket `env` isn't used for the proxy config file, because it isn't built and ran separately.
Fortunately, for that same reason, you can directly use `process.env` within the proxy config file to avoid having
separate proxy configs in most cases.
On that same note, the `server.ts` for SSR builds can also access `process.env` as it's set at runtime. But keep in mind
that it stops there - the app itself is built, so even in SSR the client app can't access process environment variables.
## Security Considerations
Never forget that your entire Angular app goes to the client, including its configuration, including the environment
variables you pass to the env task! As usual, you should **never add sensitive keys or secrets to the env task**.
Finally, if your Angular project is the client-side of a full-stack monorepo, make sure to keep the client-side `.env`
file separate from the server-side `.env` file, since your server-side is bound to have secrets.

101
docs/coding-guides/e2e-tests.md

@ -0,0 +1,101 @@
# End-to-end tests coding guide
End-to-end (E2E for short) tests are meant to test the behavior of your application, from start to finish.
While unit tests are the first choice for catching bugs and regression on individual components, it is a good idea to
complement them with test cases covering the integration between the individual components, hence the need for E2E
tests.
These tests use [Protractor](https://github.com/angular/protractor), which is a framework built for Angular on top of
[Selenium](https://github.com/SeleniumHQ/selenium) to control browsers and simulate user inputs.
[Jasmine](http://jasmine.github.io) is used as the base test framework.
Many of protractor's actions and assertions are asynchronous and return promises. To ensure that test steps are
performed in the intended order, generated projects are set up to use async/await as the flow control mechanism
because of its good readability. See the [Protractor async/await](https://www.protractortest.org/#/async-await) page
for more information and examples on using async/await in tests, and the
[Protractor API guide](https://www.protractortest.org/#/api) to determine which API calls are asynchronous.
Beware that some examples of protractor tests you'll find on the internet might not be using async/await. Tests like
these that you encounter were using the now-deprecated "selenium promise manager" flow control mechanism, so they
should not be used verbatim. See the [Protractor control flow](https://www.protractortest.org/#/control-flow) page
for more details.
## Good practices
- Avoid whenever possible inter-dependencies between your E2E tests
- Run E2E tests on your continuous integration server against different browsers
- If you use an Agile methodology, cover each user story acceptance factors with an E2E test
## Page objects
E2E tests should follow the _[Page Object](https://github.com/SeleniumHQ/selenium/wiki/PageObjects)_ pattern.
#### What is a page object?
A page object:
- Models the objects on a page under test:
- _Properties_ wrap page elements
- _Methods_ wrap code that interacts with the page elements
- Simplifies the test scripts
- Reduces the amount of duplicated code
If the UI changes, the fix only needs to be applied in one place.
#### How to define a page object
```typescript
// login.po.ts
import { browser, element, by } from 'protractor';
export class LoginPage {
emailInput = element(by.css('input[name=^"email"]'));
passwordInput = element(by.css('input[name=^"password"]'));
loginButton = element(by.css('button[(click)^="login"]'));
registerButton = element(by.css('button[(click)^="register"]'));
async navigateTo() {
await browser.get('/');
}
async getGreetingText() {
return await element(by.css('.greeting')).getText();
}
}
```
#### How to use a page object
```typescript
// login.e2e-spec.ts
import { LoginPage } from './login.po';
describe('Login', () => {
let page: LoginPage;
beforeEach(async () => {
page = new LoginPage();
await page.navigateTo();
});
it('should navigate to the register page when the register button is clicked', async () => {
await page.registerButton.click();
expect(await browser.getCurrentUrl()).toContain('/register');
});
it('should allow a user to log in', async () => {
await page.emailInput.sendKeys('test@mail.com');
await page.passwordInput.sendKeys('abc123');
await page.loginButton.click();
expect(await page.getGreetingText()).toContain('Welcome, Test User');
});
});
```
## Credits
Parts of this guide were freely inspired by this
[presentation](https://docs.google.com/presentation/d/1B6manhG0zEXkC-H-tPo2vwU06JhL8w9-XCF9oehXzAQ).

39
docs/coding-guides/html.md

@ -0,0 +1,39 @@
# HTML coding guide
## Naming conventions
- Everything should be named in `kebab-case` (lowercase words separated with a `-`): tags, attributes, IDs, etc,
**except for everything bound to Angular** such variables, directives or events which should be in `camelCase`
- File names should always be in `kebab-case`
## Coding rules
- Use HTML5 doctype: `<!doctype html>`
- Use HTML [semantic elements](https://developer.mozilla.org/docs/Web/HTML/Sections_and_Outlines_of_an_HTML5_document)
- Use double quotes `"` around attribute values in tags
- Use a new line for every block, list, or table element, and indent every such child element
- Clearly Separate structure (HTML) from presentation (CSS) from behavior (JavaScript):
- Never use inline CSS or JavaScript
- Keep any logic out of the HTML
- `type` attribute for stylesheets and script tags should be omitted
## Common pitfalls
- **Block**-type tags cannot be nested inside **inline**-type tags: a `<div>` tag cannot be nested in a `<span>`.
This rule also applies regarding the `display` value of an element.
- HTML is **not** XML: empty tags cannot be self-closing and will result in improper results
- `<div/>` will be interpreted as a simple `<div>` without closing tag!
- The only tags that allows self-closing are the one that does not require a closing tag in first place:
these are the void elements that do not not accept content `<br>`, `<hr>`, `<img>`, `<input>`, `<meta>`, `<link>`
(and others).
## Templates
In accordance with the [Angular style guide](https://angular.io/guide/styleguide), HTML templates should be extracted in
separate files, when more than 3 lines.
Only use inline templates sparingly in very simple components with less than 3 lines of HTML.
## Enforcement
Coding rules enforcement and basic sanity checks are done in this project by [HTMLHint](http://htmlhint.com).

102
docs/coding-guides/sass.md

@ -0,0 +1,102 @@
# Sass coding guide
[Sass](http://sass-lang.com) is a superset of CSS, which brings a lot of developer candy to help scaling CSS in large
projects and keeping it maintainable.
The main benefits of using Sass over plain CSS are _variables_, _nesting_ and _mixins_, see the
[basics guide](http://sass-lang.com/guide) for more details.
> Note that this project use the newer, CSS-compatible **SCSS** syntax over the old
> [indented syntax](http://sass-lang.com/documentation/file.INDENTED_SYNTAX.html).
## Naming conventions
- In the CSS world, everything should be named in `kebab-case` (lowercase words separated with a `-`).
- File names should always be in `kebab-case`
## Coding rules
- Use single quotes `'` for strings
- Use this general nesting hierarchy when constructing your styles:
```scss
// The base component class acts as the namespace, to avoid naming and style collisions
.my-component {
// Put here all component elements (flat)
.my-element {
// Use a third-level only for modifiers and state variations
&.active { ... }
}
}
```
Note that with
[Angular view encapsulation](https://angular.io/docs/ts/latest/guide/component-styles.html#!#view-encapsulation),
the first "namespace" level of nesting is not necessary as Angular takes care of the scoping for avoid collisions.
> As a side note, we are aware of the [BEM naming approach](https://en.bem.info/tools/bem/bem-naming/), but we found
> it impractical for large projects. The nesting approach has drawbacks such as increased specificity, but it helps
> keeping everything nicely organized, and more importantly, _scoped_.
Also keep in mind this general rules:
- Always use **class selectors**, never use ID selectors and avoid element selectors whenever possible
- No more than **3 levels** of nesting
- No more than **3 qualifiers**
## Best practices
- Use object-oriented CSS (OOCSS):
- Factorize common code in base class, and extend it, for example:
```scss
// Base button class
.btn { ... }
// Color variation
.btn-warning { ... }
// Size variation
.btn-small { ... }
```
- Try to name class by semantic, not style nor function for better reusability:
Use `.btn-warning`, not `btn-orange` nor `btn-cancel`
- Avoid undoing style, refactor using common base classes and extensions
- Keep your style scoped
- Clearly separate **global** (think _framework_) and **components** style
- Global style should only go in `src/theme/`, never in components
- Avoid style interactions between components, if some style may need to be shared, refactor it as a framework
component in put it in your global theme.
- Avoid using wider selectors than needed: always use classes if you can!
- Avoid rules multiplication
- The less CSS the better, factorize rules whenever it's possible
- CSS is code, and like any code frequent refactoring is healthy
- When ugly hacks cannot be avoided, create an explicit `src/hacks.scss` file and put it in:
- These ugly hacks should only be **temporary**
- Each hack should be documented with the author name, the problem and hack reason
- Limit this file to a reasonable length (~100 lines) and refactor hacks with proper solutions when the limit is
reached.
## Pitfalls
- Never use the `!important` keyword. Ever.
- Never use **inline** style in html, even _just for debugging_ (because we **KNOW** it will end up in your commit)
## Browser compatibility
You should never use browser-specific prefixes in your code, as [autoprefixer](https://github.com/postcss/autoprefixer)
takes care of that part for you during the build process.
You just need to declare which browsers you target in the [`browserslist`](https://github.com/ai/browserslist) file.
## Enforcement
Coding rules are enforced in this project with [stylelint](https://stylelint.io).
This tool also checks the compatibility of the rules used against the browsers you are targeting (specified in the
[`browserslist`](https://github.com/ai/browserslist) file), via [doiuse](https://github.com/anandthakker/doiuse).

63
docs/coding-guides/typescript.md

@ -0,0 +1,63 @@
# TypeScript coding guide
[TypeScript](http://www.typescriptlang.org) is a superset of JavaScript that greatly helps building large web
applications.
Coding conventions and best practices comes from the
[TypeScript guidelines](https://github.com/Microsoft/TypeScript/wiki/Coding-guidelines), and are also detailed in the
[TypeScript Deep Dive Style Guide](https://basarat.gitbooks.io/typescript/content/docs/styleguide/styleguide.html).
In addition, this project also follows the general [Angular style guide](https://angular.io/guide/styleguide).
## Naming conventions
- Use `PascalCase` for types, classes, interfaces, constants and enum values.
- Use `camelCase` for variables, properties and functions
- Avoid prefixing interfaces with a capital `I`, see [Angular style guide](https://angular.io/guide/styleguide#!#03-03)
- Do not use `_` as a prefix for private properties. An exception can be made for backing fields like this:
```typescript
private _foo: string;
get foo() { return this._foo; } // foo is read-only to consumers
```
## Ordering
- Within a file, type definitions should come first
- Within a class, these priorities should be respected:
- Properties comes before functions
- Static symbols comes before instance symbols
- Public symbols comes before private symbols
## Coding rules
- Use single quotes `'` for strings
- Always use strict equality checks: `===` and `!==` instead of `==` or `!=` to avoid comparison pitfalls (see
[JavaScript equality table](https://dorey.github.io/JavaScript-Equality-Table/)).
The only accepted usage for `==` is when you want to check a value against `null` or `undefined`.
- Use `[]` instead of `Array` constructor
- Use `{}` instead of `Object` constructor
- Always specify types for function parameters and returns (if applicable)
- Do not export types/functions unless you need to share it across multiple components
- Do not introduce new types/values to the global namespace
- Use arrow functions over anonymous function expressions
- Only surround arrow function parameters when necessary.
For example, `(x) => x + x` is wrong but the following are correct:
- `x => x + x`
- `(x, y) => x + y`
- `<T>(x: T, y: T) => x === y`
## Definitions
In order to infer types from JavaScript modules, TypeScript language supports external type definitions. They are
located in the `node_modules/@types` folder.
To manage type definitions, use standard `npm install|update|remove` commands.
## Enforcement
Coding rules are enforced in this project via [TSLint](https://github.com/palantir/tslint).
Angular-specific rules are also enforced via the [Codelyzer](https://github.com/mgechev/codelyzer) rule extensions.
## Learn more
The read of [TypeScript Deep Dive](https://basarat.gitbooks.io/typescript) is recommended, this is a very good
reference book for TypeScript (and also open-source).

46
docs/coding-guides/unit-tests.md

@ -0,0 +1,46 @@
# Unit tests coding guide
The main objective of unit tests is to detect regressions and to help you design software components. A suite of
_good_ unit tests can be _immensely_ valuable for your project and makes it easier to refactor and expand your code.
But keep in mind that a suite of _bad_ unit tests can also be _immensely_ painful, and hurt your development by
inhibiting your ability to refactor or alter your code in any way.
## What to test?
Everything! But if you need priorities, at least all business logic code must be tested: services, helpers, models...
Shared directives/components should also be covered by unit tests, if you do not have the time to test every single
component.
Keep in mind that component unit tests should not overlap with [end-to-end tests](e2e-tests.md): while unit the tests
cover the isolated behavior of the component bindings and methods, the end-to-end tests in opposition should cover the
integration and interactions with other app components based on real use cases scenarios.
## Good practices
- Name your tests cleanly and consistently
- Do not only test nominal cases, the most important tests are the one covering the edge cases
- Each test should be independent to all the others
- Avoid unnecessary assertions: it's counter-productive to assert anything covered by another test, it just increase
pointless failures and maintenance workload
- Test only one code unit at a time: if you cannot do this, it means you have an architecture problem in your app
- Mock out all external dependencies and state: if there is too much to mock, it is often a sign that maybe you
should split your tested module into several more independent modules
- Clearly separate or identify these 3 stages of each unit test (the _3A_): _arrange_, _act_ and _assert_
- When you fix a bug, add a test case for it to prevent regression
## Pitfalls
- Sometimes your architecture might mean your code modify static variables during unit tests. Avoid this if you can,
but if you can't, at least make sure each test resets the relevant statics before and after your tests.
- Don’t unit-test configuration settings
- Improving test coverage is good, but having meaningful tests is better: start with the latter first, and **only after
essential features of your code unit are tested**, your can think of improving the coverage.
## Unit testing with Angular
A good starting point for learning is the official
[testing guide](https://angular.io/docs/ts/latest/guide/testing.html).
But as you will most likely want to go bit further in real world apps, these
[example test snippets](https://gist.github.com/wkwiatek/e8a4a9d92abc4739f04f5abddd3de8a7) are also very helpful to
learn how to cover most common testing use cases.

51
docs/corporate-proxy.md

@ -0,0 +1,51 @@
# Working behind a corporate proxy
## Environment
Most tools (including npm and git) use the `HTTP_PROXY` and `HTTPS_PROXY` environment variables to work with a
corporate proxy.
### Windows
In Windows environments, add the `HTTP_PROXY` and `HTTPS_PROXY` system environment variable, with these values:
- HTTP_PROXY: `http://<username>:<password>@<proxy_server>:<proxy_port>`
- HTTPS_PROXY: `%HTTP_PROXY%`
### Unix
Add these lines to your `~/.bash_profile` or `~/.profile`:
```sh
export HTTP_PROXY="http://<username>:<password>@<proxy_server>:<proxy_port>"
export HTTPS_PROXY="$HTTP_PROXY"
```
## Proxy with SSL custom certificate
Some proxy like **zscaler** use a custom SSL certificate to inspect request, which may cause npm commands to fail.
To solve this problem, you can disable the `strict-ssl` option in npm.
## Proxy exceptions
If you need to access repositories on your local network that should bypass proxy, set the `NO_PROXY` environment
variable, in the same way as `HTTP_PROXY`:
### Windows
- NO_PROXY: `127.0.0.1, localhost, <your_local_server_ip_or_hostname>`
### Unix
```sh
export NO_PROXY="127.0.0.1, localhost, <your_local_server_ip_or_hostname>"
```
### Npm
Run this command in your project directory:
```sh
npm set strict-ssl false
```

58
docs/i18n.md

@ -0,0 +1,58 @@
# I18n
The internationalization of the application is managed by [ngx-translate](https://github.com/ngx-translate/core).
## Adding translatable strings
### In HTML templates
Use the `translate` directive on an HTML element to automatically translate its content:
```html
<span translate>This text will be translated.</span>
```
You can also use the `translate` pipe if needed:
```html
<button title="{{ 'Add item' | translate }}">+</button>
```
### In TypeScript code
If you need to translate strings in JavaScript code, import the `TranslateService` dependency and use the asynchronous
`get()` method:
```typescript
let title;
translateService.get('My page title').subscribe((res: string) => {
title = res;
});
```
## Extracting strings to translate
Once you are ready to translate your app, just run `npm run translations:extract`.
It will create a `template.json` file in the `src/translations` folder.
You can then use any text or code editor to generate the `.json` files for each of your supported languages, and put
them in the `src/translations` folder.
Do no forget to edit the files in `src/environment` to add the supported languages of your application.
### Marking strings for extraction
If strings are not directly passed to `translateService` or put in HTML templates, they may be missing from the
extraction process.
For these cases, you have to use the dummy `extract()` function:
```typescript
import { extract } from './core/i18n.service';
function toBeTranslatedLater() {
return extract('A string to be translated');
}
```
Strings marked like this will then be properly extracted.

9
docs/readme.md

@ -0,0 +1,9 @@
# Aiolos
Welcome to the project documentation!
Use `npm run docs` for easier navigation.
## Available documentation
[[index]]

43
docs/routing.md

@ -0,0 +1,43 @@
# Browser routing
To allow navigation without triggering a server request, Angular now use by default the
[HTML5 pushState](https://developer.mozilla.org/en-US/docs/Web/API/History_API#Adding_and_modifying_history_entries)
API enabling natural URL style (like `localhost:4200/home/`), in opposition to Angular 1 which used the _hashbang_ hack
routing style (like `localhost:4200/#/home/`).
This change has several consequences you should know of, be sure to read the
[browser URL styles](https://angular.io/docs/ts/latest/guide/router.html#!#browser-url-styles) notice to fully
understand the differences between the two approaches.
In short:
- It is only supported on modern browsers (IE10+), a [polyfill](https://github.com/Modernizr/Modernizr/wiki/HTML5-Cross-Browser-Polyfills#html5-history-api-pushstate-replacestate-popstate)
is required for older browsers.
- You have the option to perform _server-side rendering_ later if you need to increase your app perceived performance.
- You need to [configure URL rewriting](#server-configuration) on your server so that all routes serve your index file.
It is still possible to revert to the hash strategy, but unless you have specific needs, you should stick with the
default HTML5 routing mode.
## Server configuration
To allow your angular application working properly as a _Single Page Application_ (SPA) and allow bookmarking or
refreshing any page, you need some configuration on your server, otherwise you will be running into troubles.
> Note that during development, the live reload server already supports SPA mode.
The basic idea is simply to serve the `index.html` file for every request aimed at your application.
Here is an example on how to perform this on an [Express](http://expressjs.com) NodeJS server:
```js
// Put this in your `server.js` file, after your other rules (APIs, static files...)
app.get('/*', function(req, res) {
res.sendFile(__dirname + '/index.html');
});
```
For other servers like [Nginx](https://www.nginx.com/blog/creating-nginx-rewrite-rules/) or
[Apache](http://httpd.apache.org/docs/2.0/misc/rewriteguide.html), you may look for how to perform _URL rewriting_.

46
docs/updating.md

@ -0,0 +1,46 @@
# Updating npm dependencies
- Check outdated packages
```sh
npm outdated
```
- Update local packages according to `package.json`
```sh
npm update
```
- Upgrade packages manually
```sh
npm install --save[-dev] <package_name>@latest
```
Alternatively, you can use [npm-check](https://github.com/dylang/npm-check) to perform an interactive upgrade:
```sh
npm-check -u --skip-unused
```
## Locking package versions
Starting from `npm@5` a new `package-lock.json` file is
[automatically generated](https://docs.npmjs.com/files/package-locks) when using `npm install` commands, to ensure a
reproducible dependency tree and avoid unwanted package updates.
If you use a previous npm version, it is recommended to use [npm shrinkwrap](https://docs.npmjs.com/cli/shrinkwrap) to
lock down all your dependencies version:
```sh
npm shrinkwrap --dev
```
This will create a file `npm-shrinkwrap.json` alongside your `package.json` files.
> Do not forget to run shrinkwrap each time you manually update your dependencies!
# Updating angular-related dependencies
See the [Angular update website](https://update.angular.io) to guide you through the updating/upgrading steps.

37
e2e/protractor.conf.js

@ -0,0 +1,37 @@
// Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/lib/config.ts
const { SpecReporter } = require('jasmine-spec-reporter');
exports.config = {
SELENIUM_PROMISE_MANAGER: false,
allScriptsTimeout: 11000,
specs: ['./src/**/*.e2e-spec.ts'],
capabilities: {
browserName: process.env.PROTRACTOR_BROWSER || 'chrome',
chromeOptions: {
binary: process.env.PROTRACTOR_CHROME_BIN || undefined,
args: process.env.PROTRACTOR_CHROME_ARGS ? JSON.parse(process.env.PROTRACTOR_CHROME_ARGS) : ['lang=en-US'],
prefs: {
intl: { accept_languages: 'en-US' }
}
}
},
// Only works with Chrome and Firefox
directConnect: true,
baseUrl: 'http://localhost:4200/',
framework: 'jasmine2',
jasmineNodeOpts: {
showColors: true,
defaultTimeoutInterval: 30000,
print: function() {}
},
onPrepare() {
require('ts-node').register({
project: require('path').join(__dirname, './tsconfig.e2e.json')
});
// Better console spec reporter
jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
}
};

23
e2e/src/app.e2e-spec.ts

@ -0,0 +1,23 @@
import { browser, ExpectedConditions as until } from 'protractor';
import { AppSharedPage } from './page-objects/app-shared.po';
import { ShellPage } from './page-objects/shell.po';
describe('when the app loads', () => {
const app = new AppSharedPage();
const shell = new ShellPage();
beforeAll(async () => {
await app.navigateAndSetLanguage();
});
it('should display the shell page', async () => {
expect(await browser.getCurrentUrl()).toContain('/');
});
describe('and the page loads', () => {
it('should display the hello message', async () => {
await browser.wait(until.visibilityOf(shell.welcomeText), 5000, 'Element taking too long to appear');
expect(await shell.getParagraphText()).toEqual('Hello world !');
});
});
});

18
e2e/src/page-objects/app-shared.po.ts

@ -0,0 +1,18 @@
/*
* Use the Page Object pattern to define the page under test.
* See docs/coding-guide/e2e-tests.md for more info.
*/
import { browser, element, by } from 'protractor';
export class AppSharedPage {
async navigateAndSetLanguage() {
// Forces default language
await this.navigateTo();
await browser.executeScript(() => localStorage.setItem('language', 'en-US'));
}
async navigateTo() {
await browser.get('/');
}
}

14
e2e/src/page-objects/shell.po.ts

@ -0,0 +1,14 @@
/*
* Use the Page Object pattern to define the page under test.
* See docs/coding-guide/e2e-tests.md for more info.
*/
import { browser, element, by } from 'protractor';
export class ShellPage {
welcomeText = element(by.css('app-root h1'));
getParagraphText() {
return this.welcomeText.getText();
}
}

9
e2e/tsconfig.e2e.json

@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/e2e",
"module": "commonjs",
"target": "es5",
"types": ["jasmine", "jasminewd2", "node"]
}
}

43
karma.conf.js

@ -0,0 +1,43 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
process.env.CHROME_BIN = require('puppeteer').executablePath();
const path = require('path');
module.exports = function(config) {
config.set({
basePath: '.',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-junit-reporter'),
require('karma-coverage-istanbul-reporter'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
clearContext: false, // leave Jasmine Spec Runner output visible in browser
captureConsole: Boolean(process.env.KARMA_ENABLE_CONSOLE)
},
junitReporter: {
outputDir: path.join(__dirname, './reports/junit/'),
outputFile: 'TESTS-xunit.xml',
useBrowserName: false,
suite: '' // Will become the package name attribute in xml testsuite element
},
coverageIstanbulReporter: {
reports: ['html', 'lcovonly', 'text-summary'],
dir: path.join(__dirname, './reports/coverage'),
fixWebpackSourcePaths: true
},
reporters: ['progress', 'junit'],
port: 9876,
colors: true,
// Level of logging, can be: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['ChromeHeadless'],
singleRun: false,
restartOnFileChange: true
});
};

20
ngsw-config.json

@ -0,0 +1,20 @@
{
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": ["/favicon.ico", "/index.html", "/manifest.json", "/*.css", "/*.js"]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": ["/assets/**", "/*.woff", "/*.woff2", "/*.ttf", "/*.eot"]
}
}
]
}

15258
package-lock.json

File diff suppressed because it is too large

94
package.json

@ -0,0 +1,94 @@
{
"name": "aiolos",
"version": "1.0.0",
"private": true,
"scripts": {
"ng": "ng",
"build": "npm run env -s && ng build --prod",
"start": "npm run env -s && ng serve --proxy-config proxy.conf.js",
"serve:sw": "npm run build -s && npx http-server ./dist -p 4200",
"lint": "ng lint && stylelint \"src/**/*.scss\" --syntax scss && htmlhint \"src\" --config .htmlhintrc",
"test": "npm run env -s && ng test",
"test:ci": "npm run env -s && npm run lint -s && ng test --configuration=ci",
"e2e": "npm run env -s && ng e2e",
"translations:extract": "ngx-translate-extract --input ./src --output ./src/translations/template.json --format=json --clean --sort --marker extract",
"docs": "hads ./docs -o",
"env": "ngx-scripts env npm_package_version",
"prettier": "prettier --write \"./{src,e2e}/**/*.{ts,js,html,scss}\"",
"prettier:check": "prettier --list-different \"./{src,e2e}/**/*.{ts,js,html,scss}\"",
"postinstall": "npm run prettier -s",
"generate": "ng generate"
},
"dependencies": {
"@angular/animations": "^8.1.0",
"@angular/common": "^8.1.0",
"@angular/compiler": "^8.1.0",
"@angular/core": "^8.1.0",
"@angular/forms": "^8.1.0",
"@angular/platform-browser": "^8.1.0",
"@angular/platform-browser-dynamic": "^8.1.0",
"@angular/router": "^8.1.0",
"@ngx-translate/core": "^11.0.1",
"@angular/service-worker": "^8.1.0",
"@ng-bootstrap/ng-bootstrap": "^5.0.0-rc.1",
"bootstrap": "^4.1.1",
"@fortawesome/fontawesome-free": "^5.1.0",
"rxjs": "^6.5.2",
"zone.js": "^0.9.1"
},
"devDependencies": {
"@angular/cli": "~8.1.0",
"@angular/compiler-cli": "^8.1.0",
"@angular/language-service": "^8.1.0",
"@angular-devkit/build-angular": "^0.801.0",
"@angularclass/hmr": "^2.1.3",
"@biesbjerg/ngx-translate-extract": "^2.3.4",
"@ngx-rocket/scripts": "^4.0.0",
"@types/jasmine": "^3.3.13",
"@types/jasminewd2": "^2.0.3",
"@types/node": "^10.9.0",
"codelyzer": "^5.1.0",
"hads": "^1.7.0",
"htmlhint": "^0.11.0",
"https-proxy-agent": "^2.0.0",
"jasmine-core": "~3.4.0",
"jasmine-spec-reporter": "~4.2.1",
"karma": "~4.2.0",
"karma-chrome-launcher": "^3.0.0",
"karma-cli": "~2.0.0",
"karma-coverage-istanbul-reporter": "^2.0.2",
"karma-jasmine": "^2.0.1",
"karma-jasmine-html-reporter": "^1.4.0",
"karma-junit-reporter": "^1.2.0",
"prettier": "^1.16.3",
"tslint-config-prettier": "^1.14.0",
"stylelint-config-prettier": "^5.1.0",
"pretty-quick": "^1.10.0",
"husky": "^3.0.0",
"protractor": "~5.4.0",
"puppeteer": "^1.17.0",
"stylelint": "~10.1.0",
"stylelint-config-recommended-scss": "~3.3.0",
"stylelint-config-standard": "~18.3.0",
"stylelint-scss": "~3.9.0",
"ts-node": "^8.3.0",
"tslint": "~5.18.0",
"typescript": "~3.4.0"
},
"prettier": {
"singleQuote": true,
"overrides": [
{
"files": "*.scss",
"options": {
"singleQuote": false
}
}
]
},
"husky": {
"hooks": {
"pre-commit": "pretty-quick --staged"
}
}
}

41
proxy.conf.js

@ -0,0 +1,41 @@
const HttpsProxyAgent = require('https-proxy-agent');
/*
* API proxy configuration.
* This allows you to proxy HTTP request like `http.get('/api/stuff')` to another server/port.
* This is especially useful during app development to avoid CORS issues while running a local server.
* For more details and options, see https://angular.io/guide/build#using-corporate-proxy
*/
const proxyConfig = [
{
context: '/api',
pathRewrite: { '^/api': '' },
target: 'https://api.chucknorris.io',
changeOrigin: true,
secure: false
}
];
/*
* Configures a corporate proxy agent for the API proxy if needed.
*/
function setupForCorporateProxy(proxyConfig) {
if (!Array.isArray(proxyConfig)) {
proxyConfig = [proxyConfig];
}
const proxyServer = process.env.http_proxy || process.env.HTTP_PROXY;
let agent = null;
if (proxyServer) {
console.log(`Using corporate proxy server: ${proxyServer}`);
agent = new HttpsProxyAgent(proxyServer);
proxyConfig.forEach(entry => {
entry.agent = agent;
});
}
return proxyConfig;
}
module.exports = setupForCorporateProxy(proxyConfig);

17
src/app/about/about-routing.module.ts

@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { extract } from '@app/core';
import { AboutComponent } from './about.component';
const routes: Routes = [
// Module is lazy loaded, see app-routing.module.ts
{ path: '', component: AboutComponent, data: { title: extract('About') } }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
providers: []
})
export class AboutRoutingModule {}

8
src/app/about/about.component.html

@ -0,0 +1,8 @@
<div class="container-fluid">
<div class="jumbotron text-center">
<h1>
<span translate>APP_NAME</span>
</h1>
<p><i class="far fa-bookmark"></i> <span translate>Version</span> {{ version }}</p>
</div>
</div>

0
src/app/about/about.component.scss

24
src/app/about/about.component.spec.ts

@ -0,0 +1,24 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { AboutComponent } from './about.component';
describe('AboutComponent', () => {
let component: AboutComponent;
let fixture: ComponentFixture<AboutComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [AboutComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AboutComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

16
src/app/about/about.component.ts

@ -0,0 +1,16 @@
import { Component, OnInit } from '@angular/core';
import { environment } from '@env/environment';
@Component({
selector: 'app-about',
templateUrl: './about.component.html',
styleUrls: ['./about.component.scss']
})
export class AboutComponent implements OnInit {
version: string | null = environment.version;
constructor() {}
ngOnInit() {}
}

12
src/app/about/about.module.ts

@ -0,0 +1,12 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { AboutRoutingModule } from './about-routing.module';
import { AboutComponent } from './about.component';
@NgModule({
imports: [CommonModule, TranslateModule, AboutRoutingModule],
declarations: [AboutComponent]
})
export class AboutModule {}

16
src/app/app-routing.module.ts

@ -0,0 +1,16 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule, PreloadAllModules } from '@angular/router';
import { Shell } from '@app/shell/shell.service';
const routes: Routes = [
Shell.childRoutes([{ path: 'about', loadChildren: './about/about.module#AboutModule' }]),
// Fallback when no prior route is matched
{ path: '**', redirectTo: '', pathMatch: 'full' }
];
@NgModule({
imports: [RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })],
exports: [RouterModule],
providers: []
})
export class AppRoutingModule {}

1
src/app/app.component.html

@ -0,0 +1 @@
<router-outlet></router-outlet>

0
src/app/app.component.scss

22
src/app/app.component.spec.ts

@ -0,0 +1,22 @@
import { TestBed, async } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { CoreModule } from '@app/core';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [RouterTestingModule, TranslateModule.forRoot(), CoreModule],
declarations: [AppComponent],
providers: []
}).compileComponents();
}));
it('should create the app', async(() => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
}), 30000);
});

65
src/app/app.component.ts

@ -0,0 +1,65 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Router, NavigationEnd, ActivatedRoute } from '@angular/router';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { merge } from 'rxjs';
import { filter, map, switchMap } from 'rxjs/operators';
import { environment } from '@env/environment';
import { Logger, I18nService, untilDestroyed } from '@app/core';
const log = new Logger('App');
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit, OnDestroy {
constructor(
private router: Router,
private activatedRoute: ActivatedRoute,
private titleService: Title,
private translateService: TranslateService,
private i18nService: I18nService
) {}
ngOnInit() {
// Setup logger
if (environment.production) {
Logger.enableProductionMode();
}
log.debug('init');
// Setup translations
this.i18nService.init(environment.defaultLanguage, environment.supportedLanguages);
const onNavigationEnd = this.router.events.pipe(filter(event => event instanceof NavigationEnd));
// Change page title on navigation or language change, based on route data
merge(this.translateService.onLangChange, onNavigationEnd)
.pipe(
map(() => {
let route = this.activatedRoute;
while (route.firstChild) {
route = route.firstChild;
}
return route;
}),
filter(route => route.outlet === 'primary'),
switchMap(route => route.data),
untilDestroyed(this)
)
.subscribe(event => {
const title = event.title;
if (title) {
this.titleService.setTitle(this.translateService.instant(title));
}
});
}
ngOnDestroy() {
this.i18nService.destroy();
}
}

35
src/app/app.module.ts

@ -0,0 +1,35 @@
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { ServiceWorkerModule } from '@angular/service-worker';
import { TranslateModule } from '@ngx-translate/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { environment } from '@env/environment';
import { CoreModule } from '@app/core';
import { SharedModule } from '@app/shared';
import { HomeModule } from './home/home.module';
import { ShellModule } from './shell/shell.module';
import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
@NgModule({
imports: [
BrowserModule,
ServiceWorkerModule.register('./ngsw-worker.js', { enabled: environment.production }),
FormsModule,
HttpClientModule,
TranslateModule.forRoot(),
NgbModule,
CoreModule,
SharedModule,
ShellModule,
HomeModule,
AppRoutingModule // must be imported as the last module as it contains the fallback route
],
declarations: [AppComponent],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {}

30
src/app/core/core.module.ts

@ -0,0 +1,30 @@
import { NgModule, Optional, SkipSelf } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HTTP_INTERCEPTORS, HttpClient, HttpClientModule } from '@angular/common/http';
import { RouteReuseStrategy, RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { RouteReusableStrategy } from './route-reusable-strategy';
import { HttpService } from './http/http.service';
@NgModule({
imports: [CommonModule, HttpClientModule, TranslateModule, RouterModule],
providers: [
{
provide: HttpClient,
useClass: HttpService
},
{
provide: RouteReuseStrategy,
useClass: RouteReusableStrategy
}
]
})
export class CoreModule {
constructor(@Optional() @SkipSelf() parentModule: CoreModule) {
// Import guard
if (parentModule) {
throw new Error(`${parentModule} has already been loaded. Import Core module in the AppModule only.`);
}
}
}

48
src/app/core/http/api-prefix.interceptor.spec.ts

@ -0,0 +1,48 @@
import { Type } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
import { environment } from '@env/environment';
import { ApiPrefixInterceptor } from './api-prefix.interceptor';
describe('ApiPrefixInterceptor', () => {
let http: HttpClient;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
{
provide: HTTP_INTERCEPTORS,
useClass: ApiPrefixInterceptor,
multi: true
}
]
});
http = TestBed.get(HttpClient);
httpMock = TestBed.get(HttpTestingController as Type<HttpTestingController>);
});
afterEach(() => {
httpMock.verify();
});
it('should prepend environment.serverUrl to the request url', () => {
// Act
http.get('/toto').subscribe();
// Assert
httpMock.expectOne({ url: environment.serverUrl + '/toto' });
});
it('should not prepend environment.serverUrl to request url', () => {
// Act
http.get('hTtPs://domain.com/toto').subscribe();
// Assert
httpMock.expectOne({ url: 'hTtPs://domain.com/toto' });
});
});

20
src/app/core/http/api-prefix.interceptor.ts

@ -0,0 +1,20 @@
import { Injectable } from '@angular/core';
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '@env/environment';
/**
* Prefixes all requests not starting with `http[s]` with `environment.serverUrl`.
*/
@Injectable({
providedIn: 'root'
})
export class ApiPrefixInterceptor implements HttpInterceptor {
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (!/^(http|https):/i.test(request.url)) {
request = request.clone({ url: environment.serverUrl + request.url });
}
return next.handle(request);
}
}

115
src/app/core/http/cache.interceptor.spec.ts

@ -0,0 +1,115 @@
import { Type } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { HTTP_INTERCEPTORS, HttpClient, HttpResponse } from '@angular/common/http';
import { CacheInterceptor } from './cache.interceptor';
import { HttpCacheService } from './http-cache.service';
describe('CacheInterceptor', () => {
let interceptorOptions: object | null = {};
let httpCacheService: HttpCacheService;
let http: HttpClient;
let httpMock: HttpTestingController;
function createInterceptor(_httpCacheService: HttpCacheService) {
return new CacheInterceptor(_httpCacheService).configure(interceptorOptions);
}
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
HttpCacheService,
{
provide: HTTP_INTERCEPTORS,
useFactory: createInterceptor,
deps: [HttpCacheService],
multi: true
}
]
});
});
afterEach(() => {
httpCacheService.cleanCache();
httpMock.verify();
});
describe('with default configuration', () => {
beforeEach(() => {
interceptorOptions = null;
http = TestBed.get(HttpClient);
httpMock = TestBed.get(HttpTestingController as Type<HttpTestingController>);
httpCacheService = TestBed.get(HttpCacheService);
});
it('should cache the request', () => {
// Act
http.get('/toto').subscribe(() => {
// Assert
const cachedData = httpCacheService.getCacheData('/toto');
expect(cachedData).toBeDefined();
expect(cachedData ? cachedData.body : null).toEqual('someData');
});
httpMock.expectOne({ url: '/toto' }).flush('someData');
});
it('should respond from the cache', () => {
// Arrange
httpCacheService.setCacheData('/toto', new HttpResponse({ body: 'cachedData' }));
// Act
http.get('/toto').subscribe(response => {
// Assert
expect(response).toEqual('cachedData');
});
httpMock.expectNone({ url: '/toto' });
});
it('should not cache the request in case of error', () => {
// Act
http.get('/toto').subscribe(
() => {},
() => {
// Assert
expect(httpCacheService.getCacheData('/toto')).toBeNull();
}
);
httpMock.expectOne({}).flush(null, {
status: 404,
statusText: 'error'
});
});
});
describe('with update forced configuration', () => {
beforeEach(() => {
interceptorOptions = { update: true };
http = TestBed.get(HttpClient);
httpMock = TestBed.get(HttpTestingController as Type<HttpTestingController>);
httpCacheService = TestBed.get(HttpCacheService);
});
afterEach(() => {
httpCacheService.cleanCache();
httpMock.verify();
});
it('should force cache update', () => {
// Arrange
httpCacheService.setCacheData('/toto', new HttpResponse({ body: 'oldCachedData' }));
// Act
http.get('/toto').subscribe(response => {
// Assert
expect(response).toEqual('newData');
});
httpMock.expectOne({ url: '/toto' }).flush('newData');
});
});
});

57
src/app/core/http/cache.interceptor.ts

@ -0,0 +1,57 @@
import { Injectable } from '@angular/core';
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest, HttpResponse } from '@angular/common/http';
import { Observable, Subscriber } from 'rxjs';
import { HttpCacheService } from './http-cache.service';
/**
* Caches HTTP requests.
* Use ExtendedHttpClient fluent API to configure caching for each request.
*/
@Injectable({
providedIn: 'root'
})
export class CacheInterceptor implements HttpInterceptor {
private forceUpdate = false;
constructor(private httpCacheService: HttpCacheService) {}
/**
* Configures interceptor options
* @param options If update option is enabled, forces request to be made and updates cache entry.
* @return The configured instance.
*/
configure(options?: { update?: boolean } | null): CacheInterceptor {
const instance = new CacheInterceptor(this.httpCacheService);
if (options && options.update) {
instance.forceUpdate = true;
}
return instance;
}
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (request.method !== 'GET') {
return next.handle(request);
}
return new Observable((subscriber: Subscriber<HttpEvent<any>>) => {
const cachedData = this.forceUpdate ? null : this.httpCacheService.getCacheData(request.urlWithParams);
if (cachedData !== null) {
// Create new response to avoid side-effects
subscriber.next(new HttpResponse(cachedData as object));
subscriber.complete();
} else {
next.handle(request).subscribe(
event => {
if (event instanceof HttpResponse) {
this.httpCacheService.setCacheData(request.urlWithParams, event);
}
subscriber.next(event);
},
error => subscriber.error(error),
() => subscriber.complete()
);
}
});
}
}

58
src/app/core/http/error-handler.interceptor.spec.ts

@ -0,0 +1,58 @@
import { Type } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
import { ErrorHandlerInterceptor } from './error-handler.interceptor';
describe('ErrorHandlerInterceptor', () => {
let errorHandlerInterceptor: ErrorHandlerInterceptor;
let http: HttpClient;
let httpMock: HttpTestingController;
function createInterceptor() {
errorHandlerInterceptor = new ErrorHandlerInterceptor();
return errorHandlerInterceptor;
}
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
{
provide: HTTP_INTERCEPTORS,
useFactory: createInterceptor,
multi: true
}
]
});
http = TestBed.get(HttpClient);
httpMock = TestBed.get(HttpTestingController as Type<HttpTestingController>);
});
afterEach(() => {
httpMock.verify();
});
it('should catch error and call error handler', () => {
// Arrange
// Note: here we spy on private method since target is customization here,
// but you should replace it by actual behavior in your app
spyOn(ErrorHandlerInterceptor.prototype as any, 'errorHandler').and.callThrough();
// Act
http.get('/toto').subscribe(
() => fail('should error'),
() => {
// Assert
expect((ErrorHandlerInterceptor.prototype as any).errorHandler).toHaveBeenCalled();
}
);
httpMock.expectOne({}).flush(null, {
status: 404,
statusText: 'error'
});
});
});

30
src/app/core/http/error-handler.interceptor.ts

@ -0,0 +1,30 @@
import { Injectable } from '@angular/core';
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest } from '@angular/common/http';
import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { environment } from '@env/environment';
import { Logger } from '../logger.service';
const log = new Logger('ErrorHandlerInterceptor');
/**
* Adds a default error handler to all requests.
*/
@Injectable({
providedIn: 'root'
})
export class ErrorHandlerInterceptor implements HttpInterceptor {
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return next.handle(request).pipe(catchError(error => this.errorHandler(error)));
}
// Customize the default error handler here if needed
private errorHandler(response: HttpEvent<any>): Observable<HttpEvent<any>> {
if (!environment.production) {
// Do something with the error
log.error('Request error', response);
}
throw response;
}
}

192
src/app/core/http/http-cache.service.spec.ts

@ -0,0 +1,192 @@
import { TestBed } from '@angular/core/testing';
import { HttpResponse } from '@angular/common/http';
import { HttpCacheService, HttpCacheEntry } from './http-cache.service';
const cachePersistenceKey = 'httpCache';
describe('HttpCacheService', () => {
let httpCacheService: HttpCacheService;
let response: HttpResponse<any>;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [HttpCacheService]
});
// Start fresh
window.sessionStorage.removeItem(cachePersistenceKey);
window.localStorage.removeItem(cachePersistenceKey);
httpCacheService = TestBed.get(HttpCacheService);
response = new HttpResponse({ body: 'data' });
});
afterEach(() => {
httpCacheService.cleanCache();
});
describe('setCacheData', () => {
it('should set cache data', () => {
// Act
httpCacheService.setCacheData('/popo', response);
// Assert
expect(httpCacheService.getCacheData('/popo')).toEqual(response);
});
it('should replace existing data', () => {
// Arrange
const newResponse = new HttpResponse({ body: 'new data' });
// Act
httpCacheService.setCacheData('/popo', response);
httpCacheService.setCacheData('/popo', newResponse);
// Assert
expect(httpCacheService.getCacheData('/popo')).toEqual(newResponse);
});
it('should set cache date correctly', () => {
// Act
const date = new Date(123);
httpCacheService.setCacheData('/popo', response, date);
httpCacheService.setCacheData('/hoho', response);
// Assert
expect((httpCacheService.getHttpCacheEntry('/popo') as HttpCacheEntry).lastUpdated).toBe(date);
expect((httpCacheService.getHttpCacheEntry('/hoho') as HttpCacheEntry).lastUpdated).not.toBe(date);
});
});
describe('getCacheData', () => {
it('should return null if no cache', () => {
expect(httpCacheService.getCacheData('/hoho')).toBe(null);
});
it('should return cached data if exists', () => {
// Act
httpCacheService.setCacheData('/hoho', response);
// Assert
expect(httpCacheService.getCacheData('/hoho')).toEqual(response);
});
it('should return cached data with url parameters if exists', () => {
// Act
httpCacheService.setCacheData('/hoho?pif=paf', response);
// Assert
expect(httpCacheService.getCacheData('/hoho?pif=paf')).toEqual(response);
});
});
describe('getHttpCacheEntry', () => {
it('should return null if no cache', () => {
expect(httpCacheService.getHttpCacheEntry('/hoho')).toBe(null);
});
it('should return cached data date if exists', () => {
// Arrange
const date = new Date(123);
// Act
httpCacheService.setCacheData('/hoho', response, date);
const entry = httpCacheService.getHttpCacheEntry('/hoho') as HttpCacheEntry;
// Assert
expect(entry).not.toBeNull();
expect(entry.lastUpdated).toEqual(date);
expect(entry.data).toEqual(response);
});
});
describe('clearCacheData', () => {
it('should clear existing cache data', () => {
// Set cache
httpCacheService.setCacheData('/hoho', response);
expect(httpCacheService.getCacheData('/hoho')).toEqual(response);
// Clear cache
httpCacheService.clearCache('/hoho');
expect(httpCacheService.getCacheData('/hoho')).toBe(null);
});
it('should do nothing if no cache exists', () => {
expect(httpCacheService.getCacheData('/lolo')).toBe(null);
httpCacheService.clearCache('/hoho');
expect(httpCacheService.getCacheData('/lolo')).toBe(null);
});
});
describe('cleanCache', () => {
it('should clear all cache if no date is specified', () => {
// Set cache
httpCacheService.setCacheData('/hoho', response);
httpCacheService.setCacheData('/popo', response);
expect(httpCacheService.getCacheData('/hoho')).toBe(response);
expect(httpCacheService.getCacheData('/popo')).toBe(response);
// Clean cache
httpCacheService.cleanCache();
expect(httpCacheService.getCacheData('/hoho')).toBe(null);
expect(httpCacheService.getCacheData('/popo')).toBe(null);
});
it('should clear existing since specified date', () => {
// Set cache
httpCacheService.setCacheData('/hoho', response);
expect(httpCacheService.getCacheData('/hoho')).toBe(response);
// Clean cache
httpCacheService.cleanCache(new Date());
expect(httpCacheService.getCacheData('/hoho')).toBe(null);
});
it('should not affect cache entries newer than specified date', () => {
// Set cache
httpCacheService.setCacheData('/hoho', response);
expect(httpCacheService.getCacheData('/hoho')).toBe(response);
// Clean cache
const date = new Date();
httpCacheService.setCacheData('/lolo', response, new Date(date.getTime() + 10));
httpCacheService.cleanCache(date);
// Assert
expect(httpCacheService.getCacheData('/hoho')).toBe(null);
expect(httpCacheService.getCacheData('/lolo')).toBe(response);
});
});
describe('setPersistence', () => {
beforeEach(() => {
httpCacheService.setPersistence();
httpCacheService.cleanCache = jasmine.createSpy('cleanCache');
});
it('should clear previous cache data when persistence value change', () => {
httpCacheService.setPersistence('local');
expect(httpCacheService.cleanCache).toHaveBeenCalledWith();
});
it('should persist cache to local storage', () => {
expect(localStorage.getItem(cachePersistenceKey)).toBeNull();
httpCacheService.setPersistence('local');
httpCacheService.setCacheData('/hoho', response);
expect(localStorage.getItem(cachePersistenceKey)).not.toBeNull();
});
it('should persist cache to session storage', () => {
expect(sessionStorage.getItem(cachePersistenceKey)).toBeNull();
httpCacheService.setPersistence('session');
httpCacheService.setCacheData('/hoho', response);
expect(sessionStorage.getItem(cachePersistenceKey)).not.toBeNull();
});
});
});

117
src/app/core/http/http-cache.service.ts

@ -0,0 +1,117 @@
import { Injectable } from '@angular/core';
import { HttpResponse } from '@angular/common/http';
import { Logger } from '../logger.service';
const log = new Logger('HttpCacheService');
const cachePersistenceKey = 'httpCache';
export interface HttpCacheEntry {
lastUpdated: Date;
data: HttpResponse<any>;
}
/**
* Provides a cache facility for HTTP requests with configurable persistence policy.
*/
@Injectable({
providedIn: 'root'
})
export class HttpCacheService {
private cachedData: { [key: string]: HttpCacheEntry } = {};
private storage: Storage | null = null;
constructor() {
this.loadCacheData();
}
/**
* Sets the cache data for the specified request.
* @param url The request URL.
* @param data The received data.
* @param lastUpdated The cache last update, current date is used if not specified.
*/
setCacheData(url: string, data: HttpResponse<any>, lastUpdated?: Date) {
this.cachedData[url] = {
lastUpdated: lastUpdated || new Date(),
data
};
log.debug(`Cache set for key: "${url}"`);
this.saveCacheData();
}
/**
* Gets the cached data for the specified request.
* @param url The request URL.
* @return The cached data or null if no cached data exists for this request.
*/
getCacheData(url: string): HttpResponse<any> | null {
const cacheEntry = this.cachedData[url];
if (cacheEntry) {
log.debug(`Cache hit for key: "${url}"`);
return cacheEntry.data;
}
return null;
}
/**
* Gets the cached entry for the specified request.
* @param url The request URL.
* @return The cache entry or null if no cache entry exists for this request.
*/
getHttpCacheEntry(url: string): HttpCacheEntry | null {
return this.cachedData[url] || null;
}
/**
* Clears the cached entry (if exists) for the specified request.
* @param url The request URL.
*/
clearCache(url: string): void {
delete this.cachedData[url];
log.debug(`Cache cleared for key: "${url}"`);
this.saveCacheData();
}
/**
* Cleans cache entries older than the specified date.
* @param expirationDate The cache expiration date. If no date is specified, all cache is cleared.
*/
cleanCache(expirationDate?: Date) {
if (expirationDate) {
Object.entries(this.cachedData).forEach(([key, value]) => {
if (expirationDate >= value.lastUpdated) {
delete this.cachedData[key];
}
});
} else {
this.cachedData = {};
}
this.saveCacheData();
}
/**
* Sets the cache persistence policy.
* Note that changing the cache persistence will also clear the cache from its previous storage.
* @param persistence How the cache should be persisted, it can be either local or session storage, or if no value is
* provided it will be only in-memory (default).
*/
setPersistence(persistence?: 'local' | 'session') {
this.cleanCache();
this.storage = persistence === 'local' || persistence === 'session' ? window[persistence + 'Storage'] : null;
this.loadCacheData();
}
private saveCacheData() {
if (this.storage) {
this.storage.setItem(cachePersistenceKey, JSON.stringify(this.cachedData));
}
}
private loadCacheData() {
const data = this.storage ? this.storage.getItem(cachePersistenceKey) : null;
this.cachedData = data ? JSON.parse(data) : {};
}
}

106
src/app/core/http/http.service.spec.ts

@ -0,0 +1,106 @@
import { Type } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { HttpClient, HttpInterceptor } from '@angular/common/http';
import { HttpService } from './http.service';
import { HttpCacheService } from './http-cache.service';
import { ErrorHandlerInterceptor } from './error-handler.interceptor';
import { CacheInterceptor } from './cache.interceptor';
import { ApiPrefixInterceptor } from './api-prefix.interceptor';
describe('HttpService', () => {
let httpCacheService: HttpCacheService;
let http: HttpClient;
let httpMock: HttpTestingController;
let interceptors: HttpInterceptor[];
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
ErrorHandlerInterceptor,
CacheInterceptor,
ApiPrefixInterceptor,
HttpCacheService,
{
provide: HttpClient,
useClass: HttpService
}
]
});
http = TestBed.get(HttpClient);
httpMock = TestBed.get(HttpTestingController as Type<HttpTestingController>);
httpCacheService = TestBed.get(HttpCacheService);
const realRequest = http.request;
spyOn(HttpService.prototype, 'request').and.callFake(function(
this: any,
method: string,
url: string,
options?: any
) {
interceptors = this.interceptors;
return realRequest.call(this, method, url, options);
});
});
afterEach(() => {
httpCacheService.cleanCache();
httpMock.verify();
});
it('should use error handler, API prefix and no cache by default', () => {
// Act
const request = http.get('/toto');
// Assert
request.subscribe(() => {
expect(http.request).toHaveBeenCalled();
expect(interceptors.some(i => i instanceof ApiPrefixInterceptor)).toBeTruthy();
expect(interceptors.some(i => i instanceof ErrorHandlerInterceptor)).toBeTruthy();
expect(interceptors.some(i => i instanceof CacheInterceptor)).toBeFalsy();
});
httpMock.expectOne({}).flush({});
});
it('should use cache', () => {
// Act
const request = http.cache().get('/toto');
// Assert
request.subscribe(() => {
expect(interceptors.some(i => i instanceof ApiPrefixInterceptor)).toBeTruthy();
expect(interceptors.some(i => i instanceof ErrorHandlerInterceptor)).toBeTruthy();
expect(interceptors.some(i => i instanceof CacheInterceptor)).toBeTruthy();
});
httpMock.expectOne({}).flush({});
});
it('should skip error handler', () => {
// Act
const request = http.skipErrorHandler().get('/toto');
// Assert
request.subscribe(() => {
expect(interceptors.some(i => i instanceof ApiPrefixInterceptor)).toBeTruthy();
expect(interceptors.some(i => i instanceof ErrorHandlerInterceptor)).toBeFalsy();
expect(interceptors.some(i => i instanceof CacheInterceptor)).toBeFalsy();
});
httpMock.expectOne({}).flush({});
});
it('should not use API prefix', () => {
// Act
const request = http.disableApiPrefix().get('/toto');
// Assert
request.subscribe(() => {
expect(interceptors.some(i => i instanceof ApiPrefixInterceptor)).toBeFalsy();
expect(interceptors.some(i => i instanceof ErrorHandlerInterceptor)).toBeTruthy();
expect(interceptors.some(i => i instanceof CacheInterceptor)).toBeFalsy();
});
httpMock.expectOne({}).flush({});
});
});

110
src/app/core/http/http.service.ts

@ -0,0 +1,110 @@
import { Inject, Injectable, InjectionToken, Injector, Optional, Type } from '@angular/core';
import { HttpClient, HttpEvent, HttpInterceptor, HttpHandler, HttpRequest } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ErrorHandlerInterceptor } from './error-handler.interceptor';
import { CacheInterceptor } from './cache.interceptor';
import { ApiPrefixInterceptor } from './api-prefix.interceptor';
// HttpClient is declared in a re-exported module, so we have to extend the original module to make it work properly
// (see https://github.com/Microsoft/TypeScript/issues/13897)
declare module '@angular/common/http/http' {
// Augment HttpClient with the added configuration methods from HttpService, to allow in-place replacement of
// HttpClient with HttpService using dependency injection
export interface HttpClient {
/**
* Enables caching for this request.
* @param forceUpdate Forces request to be made and updates cache entry.
* @return The new instance.
*/
cache(forceUpdate?: boolean): HttpClient;
/**
* Skips default error handler for this request.
* @return The new instance.
*/
skipErrorHandler(): HttpClient;
/**
* Do not use API prefix for this request.
* @return The new instance.
*/
disableApiPrefix(): HttpClient;
}
}
// From @angular/common/http/src/interceptor: allows to chain interceptors
class HttpInterceptorHandler implements HttpHandler {
constructor(private next: HttpHandler, private interceptor: HttpInterceptor) {}
handle(request: HttpRequest<any>): Observable<HttpEvent<any>> {
return this.interceptor.intercept(request, this.next);
}
}
/**
* Allows to override default dynamic interceptors that can be disabled with the HttpService extension.
* Except for very specific needs, you should better configure these interceptors directly in the constructor below
* for better readability.
*
* For static interceptors that should always be enabled (like ApiPrefixInterceptor), use the standard
* HTTP_INTERCEPTORS token.
*/
export const HTTP_DYNAMIC_INTERCEPTORS = new InjectionToken<HttpInterceptor>('HTTP_DYNAMIC_INTERCEPTORS');
/**
* Extends HttpClient with per request configuration using dynamic interceptors.
*/
@Injectable({
providedIn: 'root'
})
export class HttpService extends HttpClient {
constructor(
private httpHandler: HttpHandler,
private injector: Injector,
@Optional() @Inject(HTTP_DYNAMIC_INTERCEPTORS) private interceptors: HttpInterceptor[] = []
) {
super(httpHandler);
if (!this.interceptors) {
// Configure default interceptors that can be disabled here
this.interceptors = [this.injector.get(ApiPrefixInterceptor), this.injector.get(ErrorHandlerInterceptor)];
}
}
cache(forceUpdate?: boolean): HttpClient {
const cacheInterceptor = this.injector
.get(CacheInterceptor as Type<CacheInterceptor>)
.configure({ update: forceUpdate });
return this.addInterceptor(cacheInterceptor);
}
skipErrorHandler(): HttpClient {
return this.removeInterceptor(ErrorHandlerInterceptor);
}
disableApiPrefix(): HttpClient {
return this.removeInterceptor(ApiPrefixInterceptor);
}
// Override the original method to wire interceptors when triggering the request.
request(method?: any, url?: any, options?: any): any {
const handler = this.interceptors.reduceRight(
(next, interceptor) => new HttpInterceptorHandler(next, interceptor),
this.httpHandler
);
return new HttpClient(handler).request(method, url, options);
}
private removeInterceptor(interceptorType: Type<HttpInterceptor>): HttpService {
return new HttpService(
this.httpHandler,
this.injector,
this.interceptors.filter(i => !(i instanceof interceptorType))
);
}
private addInterceptor(interceptor: HttpInterceptor): HttpService {
return new HttpService(this.httpHandler, this.injector, this.interceptors.concat([interceptor]));
}
}

138
src/app/core/i18n.service.spec.ts

@ -0,0 +1,138 @@
import { TestBed } from '@angular/core/testing';
import { TranslateService, LangChangeEvent } from '@ngx-translate/core';
import { Subject } from 'rxjs';
import { extract, I18nService } from './i18n.service';
const defaultLanguage = 'en-US';
const supportedLanguages = ['eo', 'en-US', 'fr-FR'];
class MockTranslateService {
currentLang = '';
onLangChange = new Subject();
use(language: string) {
this.currentLang = language;
this.onLangChange.next({
lang: this.currentLang,
translations: {}
});
}
getBrowserCultureLang() {
return 'en-US';
}
setTranslation(lang: string, translations: object, shouldMerge?: boolean) {}
}
describe('I18nService', () => {
let i18nService: I18nService;
let translateService: TranslateService;
let onLangChangeSpy: jasmine.Spy;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [I18nService, { provide: TranslateService, useClass: MockTranslateService }]
});
i18nService = TestBed.get(I18nService);
translateService = TestBed.get(TranslateService);
// Create spies
onLangChangeSpy = jasmine.createSpy('onLangChangeSpy');
translateService.onLangChange.subscribe((event: LangChangeEvent) => {
onLangChangeSpy(event.lang);
});
spyOn(translateService, 'use').and.callThrough();
});
afterEach(() => {
// Cleanup
localStorage.removeItem('language');
});
describe('extract', () => {
it('should not modify string', () => {
expect(extract('Hello world !')).toEqual('Hello world !');
});
});
describe('init', () => {
it('should init with default language', () => {
// Act
i18nService.init(defaultLanguage, supportedLanguages);
// Assert
expect(translateService.use).toHaveBeenCalledWith(defaultLanguage);
expect(onLangChangeSpy).toHaveBeenCalledWith(defaultLanguage);
});
it('should init with save language', () => {
// Arrange
const savedLanguage = 'eo';
localStorage.setItem('language', savedLanguage);
// Act
i18nService.init(defaultLanguage, supportedLanguages);
// Assert
expect(translateService.use).toHaveBeenCalledWith(savedLanguage);
expect(onLangChangeSpy).toHaveBeenCalledWith(savedLanguage);
});
});
describe('set language', () => {
it('should change current language', () => {
// Arrange
const newLanguage = 'eo';
i18nService.init(defaultLanguage, supportedLanguages);
// Act
i18nService.language = newLanguage;
// Assert
expect(translateService.use).toHaveBeenCalledWith(newLanguage);
expect(onLangChangeSpy).toHaveBeenCalledWith(newLanguage);
});
it('should change current language without a region match', () => {
// Arrange
const newLanguage = 'fr-CA';
i18nService.init(defaultLanguage, supportedLanguages);
// Act
i18nService.language = newLanguage;
// Assert
expect(translateService.use).toHaveBeenCalledWith('fr-FR');
expect(onLangChangeSpy).toHaveBeenCalledWith('fr-FR');
});
it('should change current language to default if unsupported', () => {
// Arrange
const newLanguage = 'es';
i18nService.init(defaultLanguage, supportedLanguages);
// Act
i18nService.language = newLanguage;
// Assert
expect(translateService.use).toHaveBeenCalledWith(defaultLanguage);
expect(onLangChangeSpy).toHaveBeenCalledWith(defaultLanguage);
});
});
describe('get language', () => {
it('should return current language', () => {
// Arrange
i18nService.init(defaultLanguage, supportedLanguages);
// Act
const currentLanguage = i18nService.language;
// Assert
expect(currentLanguage).toEqual(defaultLanguage);
});
});
});

96
src/app/core/i18n.service.ts

@ -0,0 +1,96 @@
import { Injectable } from '@angular/core';
import { TranslateService, LangChangeEvent } from '@ngx-translate/core';
import { Subscription } from 'rxjs';
import { Logger } from './logger.service';
import enUS from '../../translations/en-US.json';
import frFR from '../../translations/fr-FR.json';
const log = new Logger('I18nService');
const languageKey = 'language';
/**
* Pass-through function to mark a string for translation extraction.
* Running `npm translations:extract` will include the given string by using this.
* @param s The string to extract for translation.
* @return The same string.
*/
export function extract(s: string) {
return s;
}
@Injectable({
providedIn: 'root'
})
export class I18nService {
defaultLanguage!: string;
supportedLanguages!: string[];
private langChangeSubscription!: Subscription;
constructor(private translateService: TranslateService) {
// Embed languages to avoid extra HTTP requests
translateService.setTranslation('en-US', enUS);
translateService.setTranslation('fr-FR', frFR);
}
/**
* Initializes i18n for the application.
* Loads language from local storage if present, or sets default language.
* @param defaultLanguage The default language to use.
* @param supportedLanguages The list of supported languages.
*/
init(defaultLanguage: string, supportedLanguages: string[]) {
this.defaultLanguage = defaultLanguage;
this.supportedLanguages = supportedLanguages;
this.language = '';
// Warning: this subscription will always be alive for the app's lifetime
this.langChangeSubscription = this.translateService.onLangChange.subscribe((event: LangChangeEvent) => {
localStorage.setItem(languageKey, event.lang);
});
}
/**
* Cleans up language change subscription.
*/
destroy() {
if (this.langChangeSubscription) {
this.langChangeSubscription.unsubscribe();
}
}
/**
* Sets the current language.
* Note: The current language is saved to the local storage.
* If no parameter is specified, the language is loaded from local storage (if present).
* @param language The IETF language code to set.
*/
set language(language: string) {
language = language || localStorage.getItem(languageKey) || this.translateService.getBrowserCultureLang();
let isSupportedLanguage = this.supportedLanguages.includes(language);
// If no exact match is found, search without the region
if (language && !isSupportedLanguage) {
language = language.split('-')[0];
language = this.supportedLanguages.find(supportedLanguage => supportedLanguage.startsWith(language)) || '';
isSupportedLanguage = Boolean(language);
}
// Fallback if language is not supported
if (!isSupportedLanguage) {
language = this.defaultLanguage;
}
log.debug(`Language set to ${language}`);
this.translateService.use(language);
}
/**
* Gets the current language.
* @return The current language code.
*/
get language(): string {
return this.translateService.currentLang;
}
}

10
src/app/core/index.ts

@ -0,0 +1,10 @@
export * from './core.module';
export * from './i18n.service';
export * from './http/http.service';
export * from './http/http-cache.service';
export * from './http/api-prefix.interceptor';
export * from './http/cache.interceptor';
export * from './http/error-handler.interceptor';
export * from './route-reusable-strategy';
export * from './logger.service';
export * from './until-destroyed';

78
src/app/core/logger.service.spec.ts

@ -0,0 +1,78 @@
import { Logger, LogLevel, LogOutput } from './logger.service';
const logMethods = ['log', 'info', 'warn', 'error'];
describe('Logger', () => {
let savedConsole: any[];
let savedLevel: LogLevel;
let savedOutputs: LogOutput[];
beforeAll(() => {
savedConsole = [];
logMethods.forEach(m => {
savedConsole[m] = console[m];
console[m] = () => {};
});
savedLevel = Logger.level;
savedOutputs = Logger.outputs;
});
beforeEach(() => {
Logger.level = LogLevel.Debug;
});
afterAll(() => {
logMethods.forEach(m => {
console[m] = savedConsole[m];
});
Logger.level = savedLevel;
Logger.outputs = savedOutputs;
});
it('should create an instance', () => {
expect(new Logger()).toBeTruthy();
});
it('should add a new LogOutput and receives log entries', () => {
// Arrange
const outputSpy = jasmine.createSpy('outputSpy');
const log = new Logger('test');
// Act
Logger.outputs.push(outputSpy);
log.debug('d');
log.info('i');
log.warn('w');
log.error('e', { error: true });
// Assert
expect(outputSpy).toHaveBeenCalled();
expect(outputSpy.calls.count()).toBe(4);
expect(outputSpy).toHaveBeenCalledWith('test', LogLevel.Debug, 'd');
expect(outputSpy).toHaveBeenCalledWith('test', LogLevel.Info, 'i');
expect(outputSpy).toHaveBeenCalledWith('test', LogLevel.Warning, 'w');
expect(outputSpy).toHaveBeenCalledWith('test', LogLevel.Error, 'e', { error: true });
});
it('should add a new LogOutput and receives only production log entries', () => {
// Arrange
const outputSpy = jasmine.createSpy('outputSpy');
const log = new Logger('test');
// Act
Logger.outputs.push(outputSpy);
Logger.enableProductionMode();
log.debug('d');
log.info('i');
log.warn('w');
log.error('e', { error: true });
// Assert
expect(outputSpy).toHaveBeenCalled();
expect(outputSpy.calls.count()).toBe(2);
expect(outputSpy).toHaveBeenCalledWith('test', LogLevel.Warning, 'w');
expect(outputSpy).toHaveBeenCalledWith('test', LogLevel.Error, 'e', { error: true });
});
});

111
src/app/core/logger.service.ts

@ -0,0 +1,111 @@
/**
* Simple logger system with the possibility of registering custom outputs.
*
* 4 different log levels are provided, with corresponding methods:
* - debug : for debug information
* - info : for informative status of the application (success, ...)
* - warning : for non-critical errors that do not prevent normal application behavior
* - error : for critical errors that prevent normal application behavior
*
* Example usage:
* ```
* import { Logger } from 'app/core/logger.service';
*
* const log = new Logger('myFile');
* ...
* log.debug('something happened');
* ```
*
* To disable debug and info logs in production, add this snippet to your root component:
* ```
* export class AppComponent implements OnInit {
* ngOnInit() {
* if (environment.production) {
* Logger.enableProductionMode();
* }
* ...
* }
* }
*
* If you want to process logs through other outputs than console, you can add LogOutput functions to Logger.outputs.
*/
/**
* The possible log levels.
* LogLevel.Off is never emitted and only used with Logger.level property to disable logs.
*/
export enum LogLevel {
Off = 0,
Error,
Warning,
Info,
Debug
}
/**
* Log output handler function.
*/
export type LogOutput = (source: string | undefined, level: LogLevel, ...objects: any[]) => void;
export class Logger {
/**
* Current logging level.
* Set it to LogLevel.Off to disable logs completely.
*/
static level = LogLevel.Debug;
/**
* Additional log outputs.
*/
static outputs: LogOutput[] = [];
/**
* Enables production mode.
* Sets logging level to LogLevel.Warning.
*/
static enableProductionMode() {
Logger.level = LogLevel.Warning;
}
constructor(private source?: string) {}
/**
* Logs messages or objects with the debug level.
* Works the same as console.log().
*/
debug(...objects: any[]) {
this.log(console.log, LogLevel.Debug, objects);
}
/**
* Logs messages or objects with the info level.
* Works the same as console.log().
*/
info(...objects: any[]) {
this.log(console.info, LogLevel.Info, objects);
}
/**
* Logs messages or objects with the warning level.
* Works the same as console.log().
*/
warn(...objects: any[]) {
this.log(console.warn, LogLevel.Warning, objects);
}
/**
* Logs messages or objects with the error level.
* Works the same as console.log().
*/
error(...objects: any[]) {
this.log(console.error, LogLevel.Error, objects);
}
private log(func: (...args: any[]) => void, level: LogLevel, objects: any[]) {
if (level <= Logger.level) {
const log = this.source ? ['[' + this.source + ']'].concat(objects) : objects;
func.apply(console, log);
Logger.outputs.forEach(output => output.apply(output, [this.source, level, ...objects]));
}
}
}

26
src/app/core/route-reusable-strategy.ts

@ -0,0 +1,26 @@
import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router';
/**
* A route strategy allowing for explicit route reuse.
* Used as a workaround for https://github.com/angular/angular/issues/18374
* To reuse a given route, add `data: { reuse: true }` to the route definition.
*/
export class RouteReusableStrategy extends RouteReuseStrategy {
public shouldDetach(route: ActivatedRouteSnapshot): boolean {
return false;
}
public store(route: ActivatedRouteSnapshot, detachedTree: DetachedRouteHandle | null): void {}
public shouldAttach(route: ActivatedRouteSnapshot): boolean {
return false;
}
public retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null {
return null;
}
public shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
return future.routeConfig === curr.routeConfig || future.data.reuse;
}
}

165
src/app/core/until-destroyed.spec.ts

@ -0,0 +1,165 @@
import { OnInit, OnDestroy } from '@angular/core';
import { Subject, Subscription } from 'rxjs';
import { untilDestroyed } from './until-destroyed';
function createObserver() {
return {
next: jasmine.createSpy(),
error: jasmine.createSpy(),
complete: jasmine.createSpy()
};
}
describe('untilDestroyed', () => {
it('should not destroy other instances', () => {
// Arrange
const spy = createObserver();
const spy2 = createObserver();
class Test implements OnDestroy {
obs!: Subscription;
ngOnDestroy() {}
subscribe(cb: any) {
this.obs = new Subject().pipe(untilDestroyed(this)).subscribe(cb);
}
}
// Act
const component1 = new Test();
const component2 = new Test();
component1.subscribe(spy);
component2.subscribe(spy2);
component1.ngOnDestroy();
// Assert
expect(spy.complete).toHaveBeenCalledTimes(1);
expect(spy2.complete).not.toHaveBeenCalled();
component2.ngOnDestroy();
expect(spy2.complete).toHaveBeenCalledTimes(1);
});
it('should work with multiple observables', () => {
// Arrange
const spy = createObserver();
const spy2 = createObserver();
const spy3 = createObserver();
class Test implements OnDestroy {
obs = new Subject().pipe(untilDestroyed(this)).subscribe(spy);
obs2 = new Subject().pipe(untilDestroyed(this)).subscribe(spy2);
obs3 = new Subject().pipe(untilDestroyed(this)).subscribe(spy3);
ngOnDestroy() {}
}
// Act
const instance = new Test();
instance.ngOnDestroy();
// Assert
expect(spy.complete).toHaveBeenCalledTimes(1);
expect(spy2.complete).toHaveBeenCalledTimes(1);
expect(spy3.complete).toHaveBeenCalledTimes(1);
});
it('should work with classes that are not components', () => {
// Arrange
const spy = createObserver();
// Act
class Test {
obs = new Subject().pipe(untilDestroyed(this, 'destroy')).subscribe(spy);
destroy() {}
}
// Assert
const instance = new Test();
instance.destroy();
expect(spy.complete).toHaveBeenCalledTimes(1);
});
it('should unsubscribe from anywhere', () => {
// Arrange
const spy = createObserver();
const spy2 = createObserver();
const spy3 = createObserver();
class LoginComponent implements OnInit, OnDestroy {
dummy = new Subject().pipe(untilDestroyed(this)).subscribe(spy);
constructor() {
new Subject().pipe(untilDestroyed(this)).subscribe(spy2);
}
ngOnInit() {
new Subject().pipe(untilDestroyed(this)).subscribe(spy3);
}
ngOnDestroy() {}
}
// Act
const instance = new LoginComponent();
instance.ngOnInit();
instance.ngOnDestroy();
// Assert
expect(spy.complete).toHaveBeenCalledTimes(1);
expect(spy2.complete).toHaveBeenCalledTimes(1);
expect(spy3.complete).toHaveBeenCalledTimes(1);
});
it('should throw when destroy method doesnt exist', () => {
// Arrange
const spy = createObserver();
class LoginComponent {
dummy = new Subject().pipe(untilDestroyed(this)).subscribe(spy);
}
// Assert
expect(() => new LoginComponent()).toThrow();
});
it('should not throw when destroy method is implemented on super class', () => {
// Arrange
const spy = createObserver();
class A implements OnDestroy {
ngOnDestroy() {}
}
class B extends A {
dummy = new Subject().pipe(untilDestroyed(this)).subscribe(spy);
}
// Assert
expect(() => new B()).not.toThrow();
});
it('should work with subclass', () => {
// Arrange
const spy = createObserver();
class Parent implements OnDestroy {
ngOnDestroy() {}
}
class Child extends Parent {
obs = new Subject().pipe(untilDestroyed(this)).subscribe(spy);
constructor() {
super();
}
}
// Assert
const instance = new Child();
instance.ngOnDestroy();
expect(spy.complete).toHaveBeenCalledTimes(1);
});
});

62
src/app/core/until-destroyed.ts

@ -0,0 +1,62 @@
import { Observable, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
const untilDestroyedSymbol = Symbol('untilDestroyed');
/**
* RxJS operator that unsubscribe from observables on destory.
* Code forked from https://github.com/NetanelBasal/ngx-take-until-destroy
*
* IMPORTANT: Add the `untilDestroyed` operator as the last one to prevent leaks with intermediate observables in the
* operator chain.
*
* @param instance The parent Angular component or object instance.
* @param destroyMethodName The method to hook on (default: 'ngOnDestroy').
* @example
* ```
* import { untilDestroyed } from '@app/core';
*
* @Component({
* selector: 'app-example',
* templateUrl: './example.component.html'
* })
* export class ExampleComponent implements OnInit, OnDestroy {
* ngOnInit() {
* interval(1000)
* .pipe(untilDestroyed(this))
* .subscribe(val => console.log(val));
* }
*
* // This method must be present, even if empty.
* ngOnDestroy() {
* // To protect you, an error will be thrown if it doesn't exist.
* }
* }
* ```
*/
export function untilDestroyed(instance: object, destroyMethodName: string = 'ngOnDestroy') {
return <T>(source: Observable<T>) => {
const originalDestroy = instance[destroyMethodName];
const hasDestroyFunction = typeof originalDestroy === 'function';
if (!hasDestroyFunction) {
throw new Error(
`${instance.constructor.name} is using untilDestroyed but doesn't implement ${destroyMethodName}`
);
}
if (!instance[untilDestroyedSymbol]) {
instance[untilDestroyedSymbol] = new Subject();
instance[destroyMethodName] = function() {
if (hasDestroyFunction) {
originalDestroy.apply(this, arguments);
}
instance[untilDestroyedSymbol].next();
instance[untilDestroyedSymbol].complete();
};
}
return source.pipe(takeUntil<T>(instance[untilDestroyedSymbol]));
};
}

20
src/app/home/home-routing.module.ts

@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { extract } from '@app/core';
import { HomeComponent } from './home.component';
import { Shell } from '@app/shell/shell.service';
const routes: Routes = [
Shell.childRoutes([
{ path: '', redirectTo: '/home', pathMatch: 'full' },
{ path: 'home', component: HomeComponent, data: { title: extract('Home') } }
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
providers: []
})
export class HomeRoutingModule {}

10
src/app/home/home.component.html

@ -0,0 +1,10 @@
<div class="container-fluid">
<div class="jumbotron text-center">
<h1>
<img class="logo" src="assets/ngx-rocket-logo.png" alt="angular logo" />
<span translate>Hello world !</span>
</h1>
<app-loader [isLoading]="isLoading"></app-loader>
<q [hidden]="isLoading">{{ quote }}</q>
</div>
</div>

9
src/app/home/home.component.scss

@ -0,0 +1,9 @@
.logo {
width: 100px;
}
q {
font-style: italic;
font-size: 1.2rem;
quotes: "« " " »";
}

30
src/app/home/home.component.spec.ts

@ -0,0 +1,30 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { CoreModule } from '@app/core';
import { SharedModule } from '@app/shared';
import { HomeComponent } from './home.component';
import { QuoteService } from './quote.service';
describe('HomeComponent', () => {
let component: HomeComponent;
let fixture: ComponentFixture<HomeComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CoreModule, SharedModule, HttpClientTestingModule],
declarations: [HomeComponent],
providers: [QuoteService]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(HomeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

30
src/app/home/home.component.ts

@ -0,0 +1,30 @@
import { Component, OnInit } from '@angular/core';
import { finalize } from 'rxjs/operators';
import { QuoteService } from './quote.service';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.scss']
})
export class HomeComponent implements OnInit {
quote: string | undefined;
isLoading = false;
constructor(private quoteService: QuoteService) {}
ngOnInit() {
this.isLoading = true;
this.quoteService
.getRandomQuote({ category: 'dev' })
.pipe(
finalize(() => {
this.isLoading = false;
})
)
.subscribe((quote: string) => {
this.quote = quote;
});
}
}

15
src/app/home/home.module.ts

@ -0,0 +1,15 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { CoreModule } from '@app/core';
import { SharedModule } from '@app/shared';
import { HomeRoutingModule } from './home-routing.module';
import { HomeComponent } from './home.component';
import { QuoteService } from './quote.service';
@NgModule({
imports: [CommonModule, TranslateModule, CoreModule, SharedModule, HomeRoutingModule],
declarations: [HomeComponent]
})
export class HomeModule {}

59
src/app/home/quote.service.spec.ts

@ -0,0 +1,59 @@
import { Type } from '@angular/core';
import { TestBed, async } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { CoreModule, HttpCacheService } from '@app/core';
import { QuoteService } from './quote.service';
describe('QuoteService', () => {
let quoteService: QuoteService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CoreModule, HttpClientTestingModule],
providers: [HttpCacheService, QuoteService]
});
quoteService = TestBed.get(QuoteService);
httpMock = TestBed.get(HttpTestingController as Type<HttpTestingController>);
const htttpCacheService = TestBed.get(HttpCacheService);
htttpCacheService.cleanCache();
});
afterEach(() => {
httpMock.verify();
});
describe('getRandomQuote', () => {
it('should return a random Chuck Norris quote', () => {
// Arrange
const mockQuote = { value: 'a random quote' };
// Act
const randomQuoteSubscription = quoteService.getRandomQuote({ category: 'toto' });
// Assert
randomQuoteSubscription.subscribe((quote: string) => {
expect(quote).toEqual(mockQuote.value);
});
httpMock.expectOne({}).flush(mockQuote);
});
it('should return a string in case of error', () => {
// Act
const randomQuoteSubscription = quoteService.getRandomQuote({ category: 'toto' });
// Assert
randomQuoteSubscription.subscribe((quote: string) => {
expect(typeof quote).toEqual('string');
expect(quote).toContain('Error');
});
httpMock.expectOne({}).flush(null, {
status: 500,
statusText: 'error'
});
});
});
});

30
src/app/home/quote.service.ts

@ -0,0 +1,30 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
const routes = {
quote: (c: RandomQuoteContext) => `/jokes/random?category=${c.category}`
};
export interface RandomQuoteContext {
// The quote's category: 'dev', 'explicit'...
category: string;
}
@Injectable({
providedIn: 'root'
})
export class QuoteService {
constructor(private httpClient: HttpClient) {}
getRandomQuote(context: RandomQuoteContext): Observable<string> {
return this.httpClient
.cache()
.get(routes.quote(context))
.pipe(
map((body: any) => body.value),
catchError(() => of('Error, could not load joke :-('))
);
}
}

2
src/app/shared/index.ts

@ -0,0 +1,2 @@
export * from './shared.module';
export * from './loader/loader.component';

3
src/app/shared/loader/loader.component.html

@ -0,0 +1,3 @@
<div [hidden]="!isLoading" class="text-xs-center">
<i class="fas fa-cog fa-spin fa-3x"></i> <span>{{ message }}</span>
</div>

3
src/app/shared/loader/loader.component.scss

@ -0,0 +1,3 @@
.fa {
vertical-align: middle;
}

64
src/app/shared/loader/loader.component.spec.ts

@ -0,0 +1,64 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { LoaderComponent } from './loader.component';
describe('LoaderComponent', () => {
let component: LoaderComponent;
let fixture: ComponentFixture<LoaderComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [LoaderComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(LoaderComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should not be visible by default', () => {
// Arrange
const element = fixture.nativeElement;
const div = element.querySelectorAll('div')[0];
// Assert
expect(div.getAttribute('hidden')).not.toBeNull();
});
it('should be visible when app is loading', () => {
// Arrange
const element = fixture.nativeElement;
const div = element.querySelectorAll('div')[0];
// Act
fixture.componentInstance.isLoading = true;
fixture.detectChanges();
// Assert
expect(div.getAttribute('hidden')).toBeNull();
});
it('should not display a message by default', () => {
// Arrange
const element = fixture.nativeElement;
const span = element.querySelectorAll('span')[0];
// Assert
expect(span.textContent).toBe('');
});
it('should display specified message', () => {
// Arrange
const element = fixture.nativeElement;
const span = element.querySelectorAll('span')[0];
// Act
fixture.componentInstance.message = 'testing';
fixture.detectChanges();
// Assert
expect(span.textContent).toBe('testing');
});
});

15
src/app/shared/loader/loader.component.ts

@ -0,0 +1,15 @@
import { Component, OnInit, Input } from '@angular/core';
@Component({
selector: 'app-loader',
templateUrl: './loader.component.html',
styleUrls: ['./loader.component.scss']
})
export class LoaderComponent implements OnInit {
@Input() isLoading = false;
@Input() message: string | undefined;
constructor() {}
ngOnInit() {}
}

11
src/app/shared/shared.module.ts

@ -0,0 +1,11 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LoaderComponent } from './loader/loader.component';
@NgModule({
imports: [CommonModule],
declarations: [LoaderComponent],
exports: [LoaderComponent]
})
export class SharedModule {}

37
src/app/shell/header/header.component.html

@ -0,0 +1,37 @@
<header>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<a class="navbar-brand" href="https://github.com/ngx-rocket" translate>APP_NAME</a>
<button
class="navbar-toggler"
type="button"
aria-controls="navbar-menu"
aria-label="Toggle navigation"
(click)="toggleMenu()"
[attr.aria-expanded]="!menuHidden"
>
<span class="navbar-toggler-icon"></span>
</button>
<div id="navbar-menu" class="collapse navbar-collapse float-xs-none" [ngbCollapse]="menuHidden">
<div class="navbar-nav">
<a class="nav-item nav-link text-uppercase" routerLink="/home" routerLinkActive="active">
<i class="fas fa-home"></i>
<span translate>Home</span>
</a>
<a class="nav-item nav-link text-uppercase" routerLink="/about" routerLinkActive="active">
<i class="fas fa-question-circle"></i>
<span translate>About</span>
</a>
</div>
<div class="navbar-nav ml-auto">
<div class="nav-item" ngbDropdown display="dynamic" placement="bottom-right">
<a id="language-dropdown" class="nav-link" ngbDropdownToggle>{{ currentLanguage }}</a>
<div ngbDropdownMenu aria-labelledby="language-dropdown">
<button class="dropdown-item" *ngFor="let language of languages" (click)="setLanguage(language)">
{{ language }}
</button>
</div>
</div>
</div>
</div>
</nav>
</header>

9
src/app/shell/header/header.component.scss

@ -0,0 +1,9 @@
@import "src/theme/theme-variables";
.navbar {
margin-bottom: $spacer;
}
.nav-link.dropdown-toggle {
cursor: pointer;
}

30
src/app/shell/header/header.component.spec.ts

@ -0,0 +1,30 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core';
import { RouterTestingModule } from '@angular/router/testing';
import { I18nService } from '@app/core';
import { HeaderComponent } from './header.component';
describe('HeaderComponent', () => {
let component: HeaderComponent;
let fixture: ComponentFixture<HeaderComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [RouterTestingModule, NgbModule, TranslateModule.forRoot()],
declarations: [HeaderComponent],
providers: [I18nService]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(HeaderComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

32
src/app/shell/header/header.component.ts

@ -0,0 +1,32 @@
import { Component, OnInit } from '@angular/core';
import { I18nService } from '@app/core';
@Component({
selector: 'app-header',
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss']
})
export class HeaderComponent implements OnInit {
menuHidden = true;
constructor(private i18nService: I18nService) {}
ngOnInit() {}
toggleMenu() {
this.menuHidden = !this.menuHidden;
}
setLanguage(language: string) {
this.i18nService.language = language;
}
get currentLanguage(): string {
return this.i18nService.language;
}
get languages(): string[] {
return this.i18nService.supportedLanguages;
}
}

2
src/app/shell/shell.component.html

@ -0,0 +1,2 @@
<app-header></app-header>
<router-outlet></router-outlet>

0
src/app/shell/shell.component.scss

31
src/app/shell/shell.component.spec.ts

@ -0,0 +1,31 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { CoreModule } from '@app/core';
import { ShellComponent } from './shell.component';
import { HeaderComponent } from './header/header.component';
describe('ShellComponent', () => {
let component: ShellComponent;
let fixture: ComponentFixture<ShellComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [RouterTestingModule, TranslateModule.forRoot(), NgbModule, CoreModule],
declarations: [HeaderComponent, ShellComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ShellComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

12
src/app/shell/shell.component.ts

@ -0,0 +1,12 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-shell',
templateUrl: './shell.component.html',
styleUrls: ['./shell.component.scss']
})
export class ShellComponent implements OnInit {
constructor() {}
ngOnInit() {}
}

14
src/app/shell/shell.module.ts

@ -0,0 +1,14 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { RouterModule } from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { ShellComponent } from './shell.component';
import { HeaderComponent } from './header/header.component';
@NgModule({
imports: [CommonModule, TranslateModule, NgbModule, RouterModule],
declarations: [HeaderComponent, ShellComponent]
})
export class ShellModule {}

27
src/app/shell/shell.service.spec.ts

@ -0,0 +1,27 @@
import { TestBed, inject } from '@angular/core/testing';
import { ShellComponent } from './shell.component';
import { Shell } from './shell.service';
describe('Shell', () => {
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ShellComponent]
});
});
describe('childRoutes', () => {
it('should create routes as children of shell', () => {
// Prepare
const testRoutes = [{ path: 'test' }];
// Act
const result = Shell.childRoutes(testRoutes);
// Assert
expect(result.path).toBe('');
expect(result.children).toBe(testRoutes);
expect(result.component).toBe(ShellComponent);
});
});
});

23
src/app/shell/shell.service.ts

@ -0,0 +1,23 @@
import { Routes, Route } from '@angular/router';
import { ShellComponent } from './shell.component';
/**
* Provides helper methods to create routes.
*/
export class Shell {
/**
* Creates routes using the shell component and authentication.
* @param routes The routes to add.
* @return The new route using shell as the base.
*/
static childRoutes(routes: Routes): Route {
return {
path: '',
component: ShellComponent,
children: routes,
// Reuse ShellComponent instance when navigating between child views
data: { reuse: true }
};
}
}

BIN
src/apple-touch-icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
src/assets/ngx-rocket-logo.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
src/assets/ngx-rocket-logo@192.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

10
src/browserslist

@ -0,0 +1,10 @@
# This file is currently used by autoprefixer to adjust CSS to support the below specified browsers
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
last 2 versions
> 0.5%
Firefox ESR
not dead
# For IE 9-11 support, please remove 'not' from the last line of the file and adjust as needed
not IE 9-11

16
src/environments/environment.prod.ts

@ -0,0 +1,16 @@
// `.env.ts` is generated by the `npm run env` command
// `npm run env` exposes environment variables as JSON for any usage you might
// want, like displaying the version or getting extra config from your CI bot, etc.
// This is useful for granularity you might need beyond just the environment.
// Note that as usual, any environment variables you expose through it will end up in your
// bundle, and you should not use it for any sensitive information like passwords or keys.
import { env } from './.env';
export const environment = {
production: true,
hmr: false,
version: env.npm_package_version,
serverUrl: 'https://api.chucknorris.io',
defaultLanguage: 'en-US',
supportedLanguages: ['en-US', 'fr-FR']
};

29
src/environments/environment.ts

@ -0,0 +1,29 @@
// This file can be replaced during build by using the `fileReplacements` array.
// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
// The list of file replacements can be found in `angular.json`.
// `.env.ts` is generated by the `npm run env` command
// `npm run env` exposes environment variables as JSON for any usage you might
// want, like displaying the version or getting extra config from your CI bot, etc.
// This is useful for granularity you might need beyond just the environment.
// Note that as usual, any environment variables you expose through it will end up in your
// bundle, and you should not use it for any sensitive information like passwords or keys.
import { env } from './.env';
export const environment = {
production: false,
hmr: true,
version: env.npm_package_version + '-dev',
serverUrl: '/api',
defaultLanguage: 'en-US',
supportedLanguages: ['en-US', 'fr-FR']
};
/*
* For easier debugging in development mode, you can import the following file
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
*
* This import should be commented out in production mode because it will have a negative impact
* on performance if an error is thrown.
*/
// import 'zone.js/dist/zone-error'; // Included with Angular CLI.

BIN
src/favicon.ico

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

19
src/hmr.ts

@ -0,0 +1,19 @@
import { enableProdMode, NgModuleRef, ApplicationRef } from '@angular/core';
import { createNewHosts } from '@angularclass/hmr';
export function hmrBootstrap(module: any, bootstrap: () => Promise<NgModuleRef<any>>) {
let ngModule: NgModuleRef<any>;
module.hot.accept();
bootstrap()
.then(mod => (ngModule = mod))
.catch(err => console.error(err));
module.hot.dispose(() => {
const appRef: ApplicationRef = ngModule.injector.get(ApplicationRef);
const elements = appRef.components.map(c => c.location.nativeElement);
const makeVisible = createNewHosts(elements);
ngModule.destroy();
makeVisible();
});
}

32
src/index.html

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<!-- needs to be right at the top to prevent Chrome from reloading favicon on every route change -->
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<title>Aiolos</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#4e8ef7" />
<link rel="manifest" href="manifest.json" />
<!-- add to homescreen for ios -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png" />
</head>
<body>
<!--[if lt IE 10]>
<p>
You are using an <strong>outdated</strong> browser. Please
<a href="http://browsehappy.com/">upgrade your browser</a> to improve your experience.
</p>
<![endif]-->
<noscript>
<p>
This page requires JavaScript to work properly. Please enable JavaScript in your browser.
</p>
</noscript>
<app-root></app-root>
</body>
</html>

17
src/main.scss

@ -0,0 +1,17 @@
/*
* Entry point of global application style.
* Component-specific style should not go here and be included directly as part of the components.
*/
// Theme variables, must be included before the libraries to allow overriding defaults
@import "theme/theme-variables";
// 3rd party libraries
@import "~bootstrap/scss/bootstrap";
@import "~@fortawesome/fontawesome-free/scss/fontawesome.scss";
@import "~@fortawesome/fontawesome-free/scss/brands.scss";
@import "~@fortawesome/fontawesome-free/scss/regular.scss";
@import "~@fortawesome/fontawesome-free/scss/solid.scss";
// Theme customization
@import "theme/theme";

24
src/main.ts

@ -0,0 +1,24 @@
/*
* Entry point of the application.
* Only platform bootstrapping code should be here.
* For app-specific initialization, use `app/app.component.ts`.
*/
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from '@app/app.module';
import { environment } from '@env/environment';
import { hmrBootstrap } from './hmr';
if (environment.production) {
enableProdMode();
}
const bootstrap = () => platformBrowserDynamic().bootstrapModule(AppModule);
if (environment.hmr) {
hmrBootstrap(module, bootstrap);
} else {
bootstrap().catch(err => console.error(err));
}

21
src/manifest.json

@ -0,0 +1,21 @@
{
"name": "Aiolos",
"short_name": "Aiolos",
"theme_color": "#488aff",
"background_color": "#488aff",
"scope": "/",
"start_url": "/",
"display": "standalone",
"icons": [
{
"src": "assets/ngx-rocket-logo.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "assets/ngx-rocket-logo@192.png",
"sizes": "192x192",
"type": "image/png"
}
]
}

62
src/polyfills.ts

@ -0,0 +1,62 @@
/**
* This file includes polyfills needed by Angular and is loaded before the app.
* You can add your own extra polyfills to this file.
*
* This file is divided into 2 sections:
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
* file.
*
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
* automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
* Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
*
* Learn more in https://angular.io/guide/browser-support
*/
/***************************************************************************************************
* BROWSER POLYFILLS
*/
/** IE10 and IE11 requires the following for NgClass support on SVG elements */
// import 'classlist.js'; // Run `npm install --save classlist.js`.
/**
* Web Animations `@angular/platform-browser/animations`
* Only required if AnimationBuilder is used within the application and using IE/Edge or Safari.
* Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0).
*/
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
/**
* By default, zone.js will patch all possible macroTask and DomEvents
* user can disable parts of macroTask/DomEvents patch by setting following flags
* because those flags need to be set before `zone.js` being loaded, and webpack
* will put import in the top of bundle, so user need to create a separate file
* in this directory (for example: zone-flags.ts), and put the following flags
* into that file, and then add the following code before importing zone.js.
* import './zone-flags.ts';
*
* The flags allowed in zone-flags.ts are listed here.
*
* The following flags will work for all browsers.
*
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
*
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
* with the following flag, it will bypass `zone.js` patch for IE/Edge
*
* (window as any).__Zone_enable_cross_context_check = true;
*
*/
/***************************************************************************************************
* Zone JS is required by default for Angular itself.
*/
import 'zone.js/dist/zone'; // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS
*/

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save