initial codebase

This commit is contained in:
anweshknayak
2018-05-28 21:01:27 +05:30
commit 5eedfa13bd
136 changed files with 19812 additions and 0 deletions
+15
View File
@@ -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
+54
View File
@@ -0,0 +1,54 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
# Dependencies
/node_modules
# Cordova
/www
/plugins
/platforms
# 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
View File
@@ -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
}
+38
View File
@@ -0,0 +1,38 @@
{
"extends": ["stylelint-config-standard", "stylelint-config-recommended-scss"],
"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-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
}
}
+97
View File
@@ -0,0 +1,97 @@
# MifosX
Revamped codebase of community-app
# Getting started
1. Go to project folder and install dependencies:
```bash
npm install
```
2. Launch development server, and open `localhost:4200` in your browser:
```bash
npm start
```
# Main tasks
Task automation is based on [NPM scripts](https://docs.npmjs.com/misc/scripts).
Tasks | Description
------------------------------|---------------------------------------------------------------------------------------
npm start | Run development server on `http://localhost:4200/`
npm run build [-- --env=prod] | Lint code and build app for production 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
When building the application, you can specify the target environment using the additional flag `--env <name>` (do not
forget to prepend `--` to pass arguments to npm scripts).
The default build environment 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).
# 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).
#### Design guides
- [Bootstrap Material](https://bootswatch.com/materia/)
- [Angular Material](https://material.angular.io/components/categories)
#### Steps to add component
1. Create a module using
`ng generate module [name]`
2. Go to *app.module.ts*
- import the module
import { module_name } from './module'
- declare the module in
```
@NgModule({
imports: [ module_name ]})
```
3. Setup routing in newly created module
#### 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)
+135
View File
@@ -0,0 +1,135 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"ng-x-rocket": {
"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": "src/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,
"serviceWorker": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
]
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "ng-x-rocket:build"
},
"configurations": {
"production": {
"browserTarget": "ng-x-rocket:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "ng-x-rocket:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"karmaConfig": "src/karma.conf.js",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/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"
]
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"src/tsconfig.app.json",
"src/tsconfig.spec.json"
],
"exclude": [
"**/node_modules/**"
]
}
}
}
},
"ng-x-rocket-e2e": {
"root": "e2e",
"projectType": "application",
"architect": {
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "ng-x-rocket:serve"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"e2e/tsconfig.e2e.json"
],
"exclude": [
"**/node_modules/**"
]
}
}
}
}
},
"defaultProject": "ng-x-rocket"
}
+5
View File
@@ -0,0 +1,5 @@
# 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).
+42
View File
@@ -0,0 +1,42 @@
# 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.
+171
View File
@@ -0,0 +1,171 @@
# 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.
## 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)*.
##### Which library to use?
You can make Angular work with any state management library you like, but your best bet would be to use
[@ngrx/store](https://github.com/ngrx/store). It 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.
Here are some resources to get started:
- [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/store *(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.
+90
View File
@@ -0,0 +1,90 @@
# 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.
## 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"]'));
navigateTo() {
return browser.get('/');
}
getGreetingText() {
return 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(() => {
page = new LoginPage();
page.navigateTo();
});
it('should navigate to the register page when the register button is clicked', () => {
page.registerButton.click();
expect(browser.getCurrentUrl()).toContain('/register');
});
it('should allow a user to log in', () => {
page.emailInput.sendKeys('test@mail.com');
page.passwordInput.sendKeys('abc123');
page.loginButton.click();
expect(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).
+40
View File
@@ -0,0 +1,40 @@
# 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).
+95
View File
@@ -0,0 +1,95 @@
# 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
View File
@@ -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
View File
@@ -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.
+49
View File
@@ -0,0 +1,49 @@
# 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
```
+53
View File
@@ -0,0 +1,53 @@
# 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 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
View File
@@ -0,0 +1,9 @@
# ngX-Rocket
Welcome to the project documentation!
Use `npm run docs` for easier navigation.
## Available documentation
[[index]]
+43
View File
@@ -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*.
+42
View File
@@ -0,0 +1,42 @@
# 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-cli
The `angular-cli` package needs extra care with updating, see
[this doc](https://github.com/angular/angular-cli#updating-angular-cli).
+30
View File
@@ -0,0 +1,30 @@
// 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 = {
allScriptsTimeout: 11000,
specs: [
'./src/**/*.e2e-spec.ts'
],
capabilities: {
'browserName': process.env.PROTRACTOR_BROWSER || 'chrome'
},
// 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 } }));
}
};
+21
View File
@@ -0,0 +1,21 @@
import { browser } from 'protractor';
import { AppPage } from './app.po';
describe('app', () => {
let page: AppPage;
beforeEach(() => {
page = new AppPage();
});
it('should display login page and login into app', () => {
page.navigateTo();
expect(browser.getCurrentUrl()).toContain('/login');
page.login();
});
it('should display hello message', () => {
page.navigateTo();
expect(page.getParagraphText()).toEqual('Hello world !');
});
});
+32
View File
@@ -0,0 +1,32 @@
/*
* 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 AppPage {
usernameField = element(by.css('input[formControlName="username"]'));
passwordField = element(by.css('input[formControlName="password"]'));
loginButton = element(by.css('button[type="submit"]'));
constructor() {
// Forces default language
this.navigateTo();
browser.executeScript(() => localStorage.setItem('language', 'en-US'));
}
navigateTo() {
return browser.get('/');
}
login() {
this.usernameField.sendKeys('test');
this.passwordField.sendKeys('123');
this.loginButton.click();
}
getParagraphText() {
return element(by.css('app-root h1')).getText();
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/e2e",
"baseUrl": "./",
"module": "commonjs",
"target": "es5",
"types": [
"jasmine",
"jasminewd2",
"node"
]
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.html",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/assets/**"
]
}
}
]
}
+14082
View File
File diff suppressed because it is too large Load Diff
+75
View File
@@ -0,0 +1,75 @@
{
"name": "ng-x-rocket",
"version": "1.0.0",
"private": true,
"scripts": {
"ng": "ng",
"build": "npm run env -s && ng build --prod",
"start": "npm run env -s && ng serve --aot --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 --code-coverage --watch=false",
"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",
"generate": "ng generate"
},
"dependencies": {
"@angular/animations": "^6.0.3",
"@angular/cdk": "^6.1.0",
"@angular/common": "^6.0.1",
"@angular/compiler": "^6.0.1",
"@angular/core": "^6.0.1",
"@angular/forms": "^6.0.1",
"@angular/http": "^6.0.1",
"@angular/material": "^6.1.0",
"@angular/platform-browser": "^6.0.1",
"@angular/platform-browser-dynamic": "^6.0.1",
"@angular/router": "^6.0.1",
"@angular/service-worker": "^6.0.1",
"@fortawesome/fontawesome-free-webfonts": "^1.0.9",
"@ng-bootstrap/ng-bootstrap": "^2.0.0",
"@ngx-translate/core": "^10.0.1",
"bootstrap": "^4.1.1",
"core-js": "^2.5.6",
"hammerjs": "^2.0.8",
"lodash": "^4.17.10",
"rxjs": "^6.1.0",
"zone.js": "^0.8.26"
},
"devDependencies": {
"@angular/cli": "~6.0.1",
"@angular/compiler-cli": "^6.0.1",
"@angular/language-service": "^6.0.1",
"@angular-devkit/build-angular": "~0.6.0",
"@biesbjerg/ngx-translate-extract": "^2.3.4",
"@ngx-rocket/scripts": "^2.1.0",
"@types/jasmine": "^2.8.7",
"@types/jasminewd2": "^2.0.2",
"@types/lodash": "^4.14.108",
"@types/node": "^8.10.13",
"codelyzer": "^4.3.0",
"hads": "^1.3.4",
"htmlhint": "^0.9.13",
"https-proxy-agent": "^2.0.0",
"jasmine-core": "~2.99.1",
"jasmine-spec-reporter": "^4.1.0",
"karma": "~2.0.0",
"karma-chrome-launcher": "^2.2.0",
"karma-cli": "~1.0.1",
"karma-coverage-istanbul-reporter": "^1.2.1",
"karma-jasmine": "^1.1.2",
"karma-junit-reporter": "^1.2.0",
"protractor": "~5.3.0",
"puppeteer": "^1.4.0",
"stylelint": "~9.2.0",
"stylelint-config-recommended-scss": "~3.2.0",
"stylelint-config-standard": "~18.2.0",
"stylelint-scss": "~3.1.0",
"ts-node": "~6.0.3",
"tslint": "~5.10.0",
"typescript": "~2.7.2"
}
}
+41
View File
@@ -0,0 +1,41 @@
'use strict';
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://github.com/angular/angular-cli#proxy-to-backend
*/
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);
+18
View File
@@ -0,0 +1,18 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route, extract } from '@app/core';
import { AboutComponent } from './about.component';
const routes: Routes = [
Route.withShell([
{ path: 'about', component: AboutComponent, data: { title: extract('About') } }
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
providers: []
})
export class AboutRoutingModule { }
+10
View File
@@ -0,0 +1,10 @@
<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>
View File
+25
View File
@@ -0,0 +1,25 @@
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();
});
});
+18
View File
@@ -0,0 +1,18 @@
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 = environment.version;
constructor() { }
ngOnInit() { }
}
+18
View File
@@ -0,0 +1,18 @@
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 { }
+14
View File
@@ -0,0 +1,14 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
const routes: Routes = [
// Fallback when no prior route is matched
{ path: '**', redirectTo: '', pathMatch: 'full' },
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
providers: []
})
export class AppRoutingModule { }
+1
View File
@@ -0,0 +1 @@
<router-outlet></router-outlet>
View File
+27
View File
@@ -0,0 +1,27 @@
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: []
});
TestBed.compileComponents();
}));
it('should create the app', async(() => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
}));
});
+61
View File
@@ -0,0 +1,61 @@
import { Component, OnInit } 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, mergeMap } from 'rxjs/operators';
import { environment } from '@env/environment';
import { Logger, I18nService } 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 {
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'),
mergeMap(route => route.data)
)
.subscribe(event => {
const title = event['title'];
if (title) {
this.titleService.setTitle(this.translateService.instant(title));
}
});
}
}
+40
View File
@@ -0,0 +1,40 @@
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 { AboutModule } from './about/about.module';
import { ClientsModule} from './clients/clients.module'
import { LoginModule } from './login/login.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.forRoot(),
CoreModule,
SharedModule,
HomeModule,
AboutModule,
ClientsModule,
LoginModule,
AppRoutingModule
],
declarations: [AppComponent,],
providers: [
],
bootstrap: [AppComponent]
})
export class AppModule { }
+16
View File
@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { extract } from '@app/core';
import { ClientsComponent } from './clients.component';
const routes: Routes = [
{ path: 'clients', component: ClientsComponent }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
providers: []
})
export class ClientsRoutingModule { }
+4
View File
@@ -0,0 +1,4 @@
<app-header> </app-header>
<p>
clients works!
</p>
+18
View File
@@ -0,0 +1,18 @@
import { Component, OnInit } from '@angular/core';
import { environment } from '@env/environment';
import { HeaderComponent } from '../core/shell/header/header.component';
@Component({
selector: 'app-clients',
templateUrl: './clients.component.html',
styleUrls: ['./clients.component.scss']
})
export class ClientsComponent implements OnInit {
constructor() { }
ngOnInit() {
}
}
+23
View File
@@ -0,0 +1,23 @@
import { NgModule,CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ClientsComponent } from './clients.component';
import { ClientsRoutingModule } from './clients-routing.module';
import { CoreModule } from '../core/core.module';
@NgModule({
imports: [
CoreModule,
CommonModule,
ClientsRoutingModule,
],
declarations: [
ClientsComponent
],
schemas: [
CUSTOM_ELEMENTS_SCHEMA
],
})
export class ClientsModule { }
@@ -0,0 +1,55 @@
import { TestBed, inject } from '@angular/core/testing';
import { Router } from '@angular/router';
import { AuthenticationService } from './authentication.service';
import { MockAuthenticationService } from './authentication.service.mock';
import { AuthenticationGuard } from './authentication.guard';
describe('AuthenticationGuard', () => {
let authenticationGuard: AuthenticationGuard;
let authenticationService: MockAuthenticationService;
let mockRouter: any;
beforeEach(() => {
mockRouter = {
navigate: jasmine.createSpy('navigate')
};
TestBed.configureTestingModule({
providers: [
AuthenticationGuard,
{ provide: AuthenticationService, useClass: MockAuthenticationService },
{ provide: Router, useValue: mockRouter },
]
});
});
beforeEach(inject([
AuthenticationGuard,
AuthenticationService
], (_authenticationGuard: AuthenticationGuard,
_authenticationService: MockAuthenticationService) => {
authenticationGuard = _authenticationGuard;
authenticationService = _authenticationService;
}));
it('should have a canActivate method', () => {
expect(typeof authenticationGuard.canActivate).toBe('function');
});
it('should return true if user is authenticated', () => {
expect(authenticationGuard.canActivate()).toBe(true);
});
it('should return false and redirect to login if user is not authenticated', () => {
// Arrange
authenticationService.credentials = null;
// Act
const result = authenticationGuard.canActivate();
// Assert
expect(mockRouter.navigate).toHaveBeenCalledWith(['/login'], {replaceUrl: true});
expect(result).toBe(false);
});
});
@@ -0,0 +1,25 @@
import { Injectable } from '@angular/core';
import { Router, CanActivate } from '@angular/router';
import { Logger } from '../logger.service';
import { AuthenticationService } from './authentication.service';
const log = new Logger('AuthenticationGuard');
@Injectable()
export class AuthenticationGuard implements CanActivate {
constructor(private router: Router,
private authenticationService: AuthenticationService) { }
canActivate(): boolean {
if (this.authenticationService.isAuthenticated()) {
return true;
}
log.debug('Not authenticated, redirecting...');
this.router.navigate(['/login'], { replaceUrl: true });
return false;
}
}
@@ -0,0 +1,28 @@
import { Observable, of } from 'rxjs';
import { Credentials, LoginContext } from './authentication.service';
export class MockAuthenticationService {
credentials: Credentials | null = {
username: 'test',
token: '123'
};
login(context: LoginContext): Observable<Credentials> {
return of({
username: context.username,
token: '123456'
});
}
logout(): Observable<boolean> {
this.credentials = null;
return of(true);
}
isAuthenticated(): boolean {
return !!this.credentials;
}
}
@@ -0,0 +1,144 @@
import { TestBed, inject, fakeAsync, tick } from '@angular/core/testing';
import { AuthenticationService, Credentials} from './authentication.service';
const credentialsKey = 'credentials';
describe('AuthenticationService', () => {
let authenticationService: AuthenticationService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [AuthenticationService]
});
});
beforeEach(inject([
AuthenticationService
], (_authenticationService: AuthenticationService) => {
authenticationService = _authenticationService;
}));
afterEach(() => {
// Cleanup
localStorage.removeItem(credentialsKey);
sessionStorage.removeItem(credentialsKey);
});
describe('login', () => {
it('should return credentials', fakeAsync(() => {
// Act
const request = authenticationService.login({
username: 'toto',
password: '123'
});
tick();
// Assert
request.subscribe(credentials => {
expect(credentials).toBeDefined();
expect(credentials.token).toBeDefined();
});
}));
it('should authenticate user', fakeAsync(() => {
expect(authenticationService.isAuthenticated()).toBe(false);
// Act
const request = authenticationService.login({
username: 'toto',
password: '123'
});
tick();
// Assert
request.subscribe(() => {
expect(authenticationService.isAuthenticated()).toBe(true);
expect(authenticationService.credentials).toBeDefined();
expect(authenticationService.credentials).not.toBeNull();
expect((<Credentials>authenticationService.credentials).token).toBeDefined();
expect((<Credentials>authenticationService.credentials).token).not.toBeNull();
});
}));
it('should persist credentials for the session', fakeAsync(() => {
// Act
const request = authenticationService.login({
username: 'toto',
password: '123'
});
tick();
// Assert
request.subscribe(() => {
expect(sessionStorage.getItem(credentialsKey)).not.toBeNull();
});
}));
it('should persist credentials across sessions', fakeAsync(() => {
// Act
const request = authenticationService.login({
username: 'toto',
password: '123',
remember: true
});
tick();
// Assert
request.subscribe(() => {
expect(localStorage.getItem(credentialsKey)).not.toBeNull();
});
}));
});
describe('logout', () => {
it('should clear user authentication', fakeAsync(() => {
// Arrange
const loginRequest = authenticationService.login({
username: 'toto',
password: '123'
});
tick();
// Assert
loginRequest.subscribe(() => {
expect(authenticationService.isAuthenticated()).toBe(true);
const request = authenticationService.logout();
tick();
request.subscribe(() => {
expect(authenticationService.isAuthenticated()).toBe(false);
expect(authenticationService.credentials).toBeNull();
expect(sessionStorage.getItem(credentialsKey)).toBeNull();
expect(localStorage.getItem(credentialsKey)).toBeNull();
});
});
}));
it('should clear persisted user authentication', fakeAsync(() => {
// Arrange
const loginRequest = authenticationService.login({
username: 'toto',
password: '123',
remember: true
});
tick();
// Assert
loginRequest.subscribe(() => {
expect(authenticationService.isAuthenticated()).toBe(true);
const request = authenticationService.logout();
tick();
request.subscribe(() => {
expect(authenticationService.isAuthenticated()).toBe(false);
expect(authenticationService.credentials).toBeNull();
expect(sessionStorage.getItem(credentialsKey)).toBeNull();
expect(localStorage.getItem(credentialsKey)).toBeNull();
});
});
}));
});
});
@@ -0,0 +1,94 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
export interface Credentials {
// Customize received credentials here
username: string;
token: string;
}
export interface LoginContext {
username: string;
password: string;
remember?: boolean;
}
const credentialsKey = 'credentials';
/**
* Provides a base for authentication workflow.
* The Credentials interface as well as login/logout methods should be replaced with proper implementation.
*/
@Injectable()
export class AuthenticationService {
private _credentials: Credentials | null;
constructor() {
const savedCredentials = sessionStorage.getItem(credentialsKey) || localStorage.getItem(credentialsKey);
if (savedCredentials) {
this._credentials = JSON.parse(savedCredentials);
}
}
/**
* Authenticates the user.
* @param {LoginContext} context The login parameters.
* @return {Observable<Credentials>} The user credentials.
*/
login(context: LoginContext): Observable<Credentials> {
// Replace by proper authentication call
const data = {
username: context.username,
token: '123456'
};
this.setCredentials(data, context.remember);
return of(data);
}
/**
* Logs out the user and clear credentials.
* @return {Observable<boolean>} True if the user was logged out successfully.
*/
logout(): Observable<boolean> {
// Customize credentials invalidation here
this.setCredentials();
return of(true);
}
/**
* Checks is the user is authenticated.
* @return {boolean} True if the user is authenticated.
*/
isAuthenticated(): boolean {
return !!this.credentials;
}
/**
* Gets the user credentials.
* @return {Credentials} The user credentials or null if the user is not authenticated.
*/
get credentials(): Credentials | null {
return this._credentials;
}
/**
* Sets the user credentials.
* The credentials may be persisted across sessions by setting the `remember` parameter to true.
* Otherwise, the credentials are only persisted for the current session.
* @param {Credentials=} credentials The user credentials.
* @param {boolean=} remember True to remember credentials across sessions.
*/
private setCredentials(credentials?: Credentials, remember?: boolean) {
this._credentials = credentials || null;
if (credentials) {
const storage = remember ? localStorage : sessionStorage;
storage.setItem(credentialsKey, JSON.stringify(credentials));
} else {
sessionStorage.removeItem(credentialsKey);
localStorage.removeItem(credentialsKey);
}
}
}
+63
View File
@@ -0,0 +1,63 @@
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 { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { ShellComponent } from './shell/shell.component';
import { HeaderComponent } from './shell/header/header.component';
import { RouteReusableStrategy } from './route-reusable-strategy';
import { AuthenticationService } from './authentication/authentication.service';
import { AuthenticationGuard } from './authentication/authentication.guard';
import { I18nService } from './i18n.service';
import { HttpService } from './http/http.service';
import { HttpCacheService } from './http/http-cache.service';
import { ApiPrefixInterceptor } from './http/api-prefix.interceptor';
import { ErrorHandlerInterceptor } from './http/error-handler.interceptor';
import { CacheInterceptor } from './http/cache.interceptor';
@NgModule({
imports: [
CommonModule,
HttpClientModule,
TranslateModule,
NgbModule,
RouterModule
],
declarations: [
HeaderComponent,
ShellComponent
],
exports: [
HeaderComponent],
providers: [
AuthenticationService,
AuthenticationGuard,
I18nService,
HttpCacheService,
ApiPrefixInterceptor,
ErrorHandlerInterceptor,
CacheInterceptor,
{
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.`);
}
}
}
@@ -0,0 +1,44 @@
import { TestBed, inject } 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
}]
});
});
beforeEach(inject([
HttpClient,
HttpTestingController
], (_http: HttpClient,
_httpMock: HttpTestingController) => {
http = _http;
httpMock = _httpMock;
}));
afterEach(() => {
httpMock.verify();
});
it('should prepend environment.serverUrl to the request url', () => {
// Act
http.get('/toto').subscribe();
// Assert
httpMock.expectOne({ url: environment.serverUrl + '/toto' });
});
});
@@ -0,0 +1,18 @@
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 with `environment.serverUrl`.
*/
@Injectable()
export class ApiPrefixInterceptor implements HttpInterceptor {
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
request = request.clone({ url: environment.serverUrl + request.url });
return next.handle(request);
}
}
+135
View File
@@ -0,0 +1,135 @@
import { TestBed, inject } 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 cacheInterceptor: CacheInterceptor;
let http: HttpClient;
let httpMock: HttpTestingController;
function createInterceptor(_httpCacheService: HttpCacheService) {
cacheInterceptor = new CacheInterceptor(_httpCacheService).configure(interceptorOptions);
return cacheInterceptor;
}
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;
});
beforeEach(inject([
HttpClient,
HttpTestingController,
HttpCacheService
], (_http: HttpClient,
_httpMock: HttpTestingController,
_httpCacheService: HttpCacheService) => {
http = _http;
httpMock = _httpMock;
httpCacheService = _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 };
});
beforeEach(inject([
HttpClient,
HttpTestingController,
HttpCacheService
], (_http: HttpClient,
_httpMock: HttpTestingController,
_httpCacheService: HttpCacheService) => {
http = _http;
httpMock = _httpMock;
httpCacheService = _httpCacheService;
}));
afterEach(() => {
httpCacheService.cleanCache();
httpMock.verify();
});
it('should force cache update', () => {
// Arrange
httpCacheService.setCacheData('/toto', new HttpResponse({ body: 'oldCachedData' }));
cacheInterceptor.configure({ update: true });
// Act
http.get('/toto').subscribe(response => {
// Assert
expect(response).toEqual('newData');
});
httpMock.expectOne({ url: '/toto' }).flush('newData');
});
});
});
+58
View File
@@ -0,0 +1,58 @@
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()
export class CacheInterceptor implements HttpInterceptor {
private forceUpdate = false;
constructor(private httpCacheService: HttpCacheService) { }
/**
* Configures interceptor options
* @param {{update: boolean}} options If update option is enabled, forces request to be made and updates cache entry.
* @return {CacheInterceptor} 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()
);
}
});
}
}
@@ -0,0 +1,59 @@
import { TestBed, inject } 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
}]
});
});
beforeEach(inject([
HttpClient,
HttpTestingController
], (_http: HttpClient,
_httpMock: HttpTestingController) => {
http = _http;
httpMock = _httpMock;
}));
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['errorHandler']).toHaveBeenCalled();
});
httpMock.expectOne({}).flush(null, {
status: 404,
statusText: 'error'
});
});
});
@@ -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()
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;
}
}
@@ -0,0 +1,194 @@
import { TestBed, inject } 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);
});
beforeEach(inject([HttpCacheService], (_httpCacheService: HttpCacheService) => {
httpCacheService = _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((<HttpCacheEntry>httpCacheService.getHttpCacheEntry('/popo')).lastUpdated).toBe(date);
expect((<HttpCacheEntry>httpCacheService.getHttpCacheEntry('/hoho')).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');
// Assert
expect(entry).not.toBeNull();
expect((<HttpCacheEntry>entry).lastUpdated).toEqual(date);
expect((<HttpCacheEntry>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();
});
});
});
+118
View File
@@ -0,0 +1,118 @@
import { Injectable } from '@angular/core';
import { HttpResponse } from '@angular/common/http';
import { each } from 'lodash';
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()
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 {!string} url The request URL.
* @param {ResponseOptions} data The received data.
* @param {Date=} 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: data
};
log.debug(`Cache set for key: "${url}"`);
this.saveCacheData();
}
/**
* Gets the cached data for the specified request.
* @param {!string} url The request URL.
* @return {?ResponseOptions} 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 {!string} url The request URL.
* @return {?HttpCacheEntry} 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 {!string} 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 {date=} expirationDate The cache expiration date. If no date is specified, all cache is cleared.
*/
cleanCache(expirationDate?: Date) {
if (expirationDate) {
each(this.cachedData, (value: HttpCacheEntry, key: string) => {
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 {'local'|'session'=} 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[cachePersistenceKey] = JSON.stringify(this.cachedData);
}
}
private loadCacheData() {
const data = this.storage ? this.storage[cachePersistenceKey] : null;
this.cachedData = data ? JSON.parse(data) : {};
}
}
+140
View File
@@ -0,0 +1,140 @@
import { TestBed, inject } 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;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
ErrorHandlerInterceptor,
CacheInterceptor,
ApiPrefixInterceptor,
HttpCacheService,
{
provide: HttpClient,
useClass: HttpService
},
]
});
});
beforeEach(inject([
HttpClient,
HttpTestingController,
HttpCacheService
], (_http: HttpClient,
_httpMock: HttpTestingController,
_httpCacheService: HttpCacheService) => {
http = _http;
httpMock = _httpMock;
httpCacheService = _httpCacheService;
}));
afterEach(() => {
httpCacheService.cleanCache();
httpMock.verify();
});
it('should use error handler, API prefix and no cache by default', () => {
// Arrange
let interceptors: HttpInterceptor[];
const realRequest = http.request;
spyOn(HttpService.prototype, 'request').and.callFake(function(this: any) {
interceptors = this.interceptors;
return realRequest.apply(this, arguments);
});
// 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', () => {
// Arrange
let interceptors: HttpInterceptor[];
const realRequest = http.request;
spyOn(HttpService.prototype, 'request').and.callFake(function(this: any) {
interceptors = this.interceptors;
return realRequest.apply(this, arguments);
});
// 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', () => {
// Arrange
let interceptors: HttpInterceptor[];
const realRequest = http.request;
spyOn(HttpService.prototype, 'request').and.callFake(function(this: any) {
interceptors = this.interceptors;
return realRequest.apply(this, arguments);
});
// 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', () => {
// Arrange
let interceptors: HttpInterceptor[];
const realRequest = http.request;
spyOn(HttpService.prototype, 'request').and.callFake(function(this: any) {
interceptors = this.interceptors;
return realRequest.apply(this, arguments);
});
// 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({});
});
});
+119
View File
@@ -0,0 +1,119 @@
import { Inject, Injectable, InjectionToken, Injector, Optional } 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/src/client' {
// 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 {boolean} forceUpdate Forces request to be made and updates cache entry.
* @return {HttpClient} The new instance.
*/
cache(forceUpdate?: boolean): HttpClient;
/**
* Skips default error handler for this request.
* @return {HttpClient} The new instance.
*/
skipErrorHandler(): HttpClient;
/**
* Do not use API prefix for this request.
* @return {HttpClient} 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()
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).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: Function): 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])
);
}
}
+152
View File
@@ -0,0 +1,152 @@
import { TestBed, inject } 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: string;
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 },
]
});
});
beforeEach(inject([
I18nService,
TranslateService
], (_i18nService: I18nService,
_translateService: TranslateService) => {
i18nService = _i18nService;
translateService = _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);
});
});
});
+83
View File
@@ -0,0 +1,83 @@
import { Injectable } from '@angular/core';
import { TranslateService, LangChangeEvent } from '@ngx-translate/core';
import { includes } from 'lodash';
import { Logger } from './logger.service';
import * as enUS from '../../translations/en-US.json';
import * as 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 {string} s The string to extract for translation.
* @return {string} The same string.
*/
export function extract(s: string) {
return s;
}
@Injectable()
export class I18nService {
defaultLanguage: string;
supportedLanguages: string[];
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 {!string} defaultLanguage The default language to use.
* @param {Array.<String>} supportedLanguages The list of supported languages.
*/
init(defaultLanguage: string, supportedLanguages: string[]) {
this.defaultLanguage = defaultLanguage;
this.supportedLanguages = supportedLanguages;
this.language = '';
this.translateService.onLangChange
.subscribe((event: LangChangeEvent) => { localStorage.setItem(languageKey, event.lang); });
}
/**
* 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 {string} language The IETF language code to set.
*/
set language(language: string) {
language = language || localStorage.getItem(languageKey) || this.translateService.getBrowserCultureLang();
let isSupportedLanguage = includes(this.supportedLanguages, 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 {string} The current language code.
*/
get language(): string {
return this.translateService.currentLang;
}
}
+13
View File
@@ -0,0 +1,13 @@
export * from './core.module';
export * from './authentication/authentication.service';
export * from './authentication/authentication.service.mock';
export * from './authentication/authentication.guard';
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.service';
export * from './route-reusable-strategy';
export * from './logger.service';
+72
View File
@@ -0,0 +1,72 @@
import { Logger, LogLevel, LogOutput } from './logger.service';
const logMethods = ['log', 'info', 'warn', 'error'];
describe('Logger', () => {
let savedConsole: Function[];
let savedLevel: LogLevel;
let savedOutputs: LogOutput[];
beforeAll(() => {
savedConsole = [];
logMethods.forEach((m) => {
savedConsole[m] = console[m];
console[m] = () => {};
});
savedLevel = Logger.level;
savedOutputs = Logger.outputs;
});
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 });
});
});
+113
View File
@@ -0,0 +1,113 @@
/**
* 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, 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: Function, 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].concat(objects)));
}
}
}
+28
View File
@@ -0,0 +1,28 @@
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;
}
}
+40
View File
@@ -0,0 +1,40 @@
import { TestBed, inject } from '@angular/core/testing';
import { AuthenticationService } from './authentication/authentication.service';
import { MockAuthenticationService } from './authentication/authentication.service.mock';
import { AuthenticationGuard } from './authentication/authentication.guard';
import { ShellComponent } from './shell/shell.component';
import { Route } from './route.service';
describe('Route', () => {
let route: Route;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
AuthenticationGuard,
{ provide: AuthenticationService, useClass: MockAuthenticationService },
Route
]
});
});
beforeEach(inject([Route], (_route: Route) => {
route = _route;
}));
describe('withShell', () => {
it('should create routes as children of shell', () => {
// Prepare
const testRoutes = [{ path: 'test' }];
// Act
const result = Route.withShell(testRoutes);
// Assert
expect(result.path).toBe('');
expect(result.children).toBe(testRoutes);
expect(result.component).toBe(ShellComponent);
});
});
});
+27
View File
@@ -0,0 +1,27 @@
import { Route as ngRoute, Routes } from '@angular/router';
import { ShellComponent } from './shell/shell.component';
import { AuthenticationGuard } from './authentication/authentication.guard';
/**
* Provides helper methods to create routes.
*/
export class Route {
/**
* Creates routes using the shell component and authentication.
* @param routes The routes to add.
* @return {Route} The new route using shell as the base.
*/
static withShell(routes: Routes): ngRoute {
return {
path: '',
component: ShellComponent,
children: routes,
canActivate: [AuthenticationGuard],
// Reuse ShellComponent instance when navigating between child views
data: { reuse: true }
};
}
}
@@ -0,0 +1,71 @@
<!-- <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 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 class="nav-item" ngbDropdown placement="bottom-right">
<a id="user-dropdown" class="nav-link" ngbDropdownToggle>
<i class="fas fa-user-circle"></i>
</a>
<div ngbDropdownMenu aria-labelledby="user-dropdown">
<h6 class="dropdown-header">
<span translate>Logged in as</span> <b>{{username}}</b></h6>
<div class="dropdown-divider"></div>
<button class="dropdown-item" (click)="logout()" translate>Logout</button>
</div>
</div>
</div>
</div>
</nav>
</header> -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<a class="navbar-brand" href="#">MifosX</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarColor02" aria-controls="navbarColor02" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarColor02">
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a class="nav-link" href="#">Home <span class="sr-only">(current)</span></a>
</li>
<li class="nav-item">
<a class="nav-link" routerLink="/clients" routerLinkActive="active">Clients</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Groups</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Loans</a>
</li>
</ul>
<form class="form-inline my-2 my-lg-0">
<input class="form-control mr-sm-2" placeholder="Search" type="text">
<button class="btn btn-secondary my-2 mr-sm-2 my-sm-0" type="submit">Search</button>
</form>
<button class="btn btn-secondary my-2 mr-sm-2 my-sm-0" (click)="logout()" type="submit">Logout</button>
</div>
</nav>
@@ -0,0 +1,10 @@
/* @import "src/theme/theme-variables";
.navbar {
margin-bottom: $spacer;
}
.nav-link.dropdown-toggle {
cursor: pointer;
}
*/
@@ -0,0 +1,40 @@
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 { AuthenticationService } from '../../authentication/authentication.service';
import { MockAuthenticationService } from '../../authentication/authentication.service.mock';
import { I18nService } from '../../i18n.service';
import { HeaderComponent } from './header.component';
describe('HeaderComponent', () => {
let component: HeaderComponent;
let fixture: ComponentFixture<HeaderComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
RouterTestingModule,
NgbModule.forRoot(),
TranslateModule.forRoot()
],
declarations: [HeaderComponent],
providers: [
{ provide: AuthenticationService, useClass: MockAuthenticationService },
I18nService
]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(HeaderComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,48 @@
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { AuthenticationService } from '../../authentication/authentication.service';
import { I18nService } from '../../i18n.service';
@Component({
selector: 'app-header',
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss']
})
export class HeaderComponent implements OnInit {
menuHidden = true;
constructor(private router: Router,
private authenticationService: AuthenticationService,
private i18nService: I18nService) { }
ngOnInit() { }
toggleMenu() {
this.menuHidden = !this.menuHidden;
}
setLanguage(language: string) {
this.i18nService.language = language;
}
logout() {
this.authenticationService.logout()
.subscribe(() => this.router.navigate(['/login'], { replaceUrl: true }));
}
get currentLanguage(): string {
return this.i18nService.language;
}
get languages(): string[] {
return this.i18nService.supportedLanguages;
}
get username(): string | null {
const credentials = this.authenticationService.credentials;
return credentials ? credentials.username : null;
}
}
+2
View File
@@ -0,0 +1,2 @@
<app-header></app-header>
<router-outlet></router-outlet>
@@ -0,0 +1,39 @@
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 { AuthenticationService } from '../authentication/authentication.service';
import { MockAuthenticationService } from '../authentication/authentication.service.mock';
import { CoreModule } from '../core.module';
import { ShellComponent } from './shell.component';
describe('ShellComponent', () => {
let component: ShellComponent;
let fixture: ComponentFixture<ShellComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
RouterTestingModule,
TranslateModule.forRoot(),
NgbModule.forRoot(),
CoreModule
],
providers: [
{ provide: AuthenticationService, useClass: MockAuthenticationService }
]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ShellComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
+14
View File
@@ -0,0 +1,14 @@
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() { }
}
+19
View File
@@ -0,0 +1,19 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route, extract } from '@app/core';
import { HomeComponent } from './home.component';
const routes: Routes = [
Route.withShell([
{ path: '', redirectTo: '/home', pathMatch: 'full' },
{ path: 'home', component: HomeComponent, data: { title: extract('Home') } }
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
providers: []
})
export class HomeRoutingModule { }
+7
View File
@@ -0,0 +1,7 @@
<div class="container-fluid">
<div class="text-center">
<img class="logo" src="assets/images/mifos_lg-logo.jpg"/>
<!-- <app-loader [isLoading]="isLoading"></app-loader>
<q [hidden]="isLoading">{{quote}}</q> -->
</div>
</div>
+9
View File
@@ -0,0 +1,9 @@
.logo {
width: 500px;
}
q {
font-style: italic;
font-size: 1.2rem;
quotes: "« " " »";
}
+35
View File
@@ -0,0 +1,35 @@
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();
});
});
+25
View File
@@ -0,0 +1,25 @@
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;
isLoading: boolean;
constructor(private quoteService: QuoteService) { }
ngOnInit() {
this.isLoading = true;
this.quoteService.getRandomQuote({ category: 'dev' })
.pipe(finalize(() => { this.isLoading = false; }))
.subscribe((quote: string) => { this.quote = quote; });
}
}
+26
View File
@@ -0,0 +1,26 @@
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
],
providers: [
QuoteService
]
})
export class HomeModule { }
+72
View File
@@ -0,0 +1,72 @@
import { TestBed, inject, 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(async(() => {
TestBed.configureTestingModule({
imports: [
CoreModule,
HttpClientTestingModule
],
providers: [
HttpCacheService,
QuoteService
]
});
}));
beforeEach(inject([
HttpCacheService,
QuoteService,
HttpTestingController
], (htttpCacheService: HttpCacheService,
_quoteService: QuoteService,
_httpMock: HttpTestingController) => {
quoteService = _quoteService;
httpMock = _httpMock;
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
View File
@@ -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()
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 :-('))
);
}
}
+16
View File
@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { extract } from '@app/core';
import { LoginComponent } from './login.component';
const routes: Routes = [
{ path: 'login', component: LoginComponent, data: { title: extract('Login') } }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
providers: []
})
export class LoginRoutingModule { }
+62
View File
@@ -0,0 +1,62 @@
<div class="login-container bg-light">
<div class="login-box">
<img class="text-center img-responsive" src="assets/images/mifos-logo-flat.png" style="width:30%">
<div>
<h6 class="d-inline-block">v{{version}}</h6>
<div class="d-inline-block ml-3" ngbDropdown>
<button id="language-dropdown" class="btn btn-sm btn-secondary" ngbDropdownToggle>
{{currentLanguage}}
</button>
<div ngbDropdownMenu aria-labelledby="language-dropdown">
<button class="dropdown-item" *ngFor="let language of languages" (click)="setLanguage(language)">
{{language}}
</button>
</div>
</div>
</div>
<div class="container">
<div class="card col-md-6 mt-3 mx-auto">
<div class="card-body">
<h4 class="card-title text-center">
<i class="far fa-3x fa-user-circle text-muted"></i>
</h4>
<form (ngSubmit)="login()" [formGroup]="loginForm" novalidate>
<div class="alert alert-danger" [hidden]="!error || isLoading" translate>
Username or password incorrect.
</div>
<div class="form-group">
<label class="d-block">
<input type="text" class="form-control" formControlName="username" autocomplete="username"
[placeholder]="'Username' | translate"/>
<span hidden translate>Username</span>
<small [hidden]="loginForm.controls.username.valid || loginForm.controls.username.untouched"
class="text-danger" translate>
Username is required
</small>
</label>
<label class="d-block">
<input type="password" class="form-control" formControlName="password" autocomplete="current-password"
[placeholder]="'Password' | translate" required/>
<span hidden translate>Password</span>
<small [hidden]="loginForm.controls.password.valid || loginForm.controls.password.untouched"
class="text-danger" translate>
Password is required
</small>
</label>
<div class="form-check">
<label class="form-check-label">
<input type="checkbox" class="form-check-input" formControlName="remember"/>
<span translate>Remember me</span>
</label>
</div>
</div>
<button class="btn btn-primary w-100" type="submit" [disabled]="loginForm.invalid || isLoading">
<i class="fas fa-cog fa-spin" [hidden]="!isLoading"></i>
<span translate>Login</span>
</button>
</form>
</div>
</div>
</div>
</div>
</div>
+29
View File
@@ -0,0 +1,29 @@
@import "src/theme/theme-variables";
@import "~bootstrap/scss/mixins/_breakpoints";
.login-container {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.login-box {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
min-height: 100%;
}
.ng-invalid.ng-touched:not(form) {
border-left: 4px solid theme-color("danger");
}
@include media-breakpoint-down(xs) {
.container {
width: 100%;
}
}
+37
View File
@@ -0,0 +1,37 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { RouterTestingModule } from '@angular/router/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { CoreModule } from '@app/core';
import { LoginComponent } from './login.component';
describe('LoginComponent', () => {
let component: LoginComponent;
let fixture: ComponentFixture<LoginComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
NgbModule.forRoot(),
RouterTestingModule,
TranslateModule.forRoot(),
ReactiveFormsModule,
CoreModule
],
declarations: [LoginComponent]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(LoginComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
+68
View File
@@ -0,0 +1,68 @@
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
import { finalize } from 'rxjs/operators';
import { environment } from '@env/environment';
import { Logger, I18nService, AuthenticationService } from '@app/core';
const log = new Logger('Login');
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss']
})
export class LoginComponent implements OnInit {
version: string = environment.version;
error: string;
loginForm: FormGroup;
isLoading = false;
constructor(private router: Router,
private formBuilder: FormBuilder,
private i18nService: I18nService,
private authenticationService: AuthenticationService) {
this.createForm();
}
ngOnInit() { }
login() {
this.isLoading = true;
this.authenticationService.login(this.loginForm.value)
.pipe(finalize(() => {
this.loginForm.markAsPristine();
this.isLoading = false;
}))
.subscribe(credentials => {
log.debug(`${credentials.username} successfully logged in`);
this.router.navigate(['/'], { replaceUrl: true });
}, error => {
log.debug(`Login error: ${error}`);
this.error = error;
});
}
setLanguage(language: string) {
this.i18nService.language = language;
}
get currentLanguage(): string {
return this.i18nService.language;
}
get languages(): string[] {
return this.i18nService.supportedLanguages;
}
private createForm() {
this.loginForm = this.formBuilder.group({
username: ['', Validators.required],
password: ['', Validators.required],
remember: true
});
}
}
+22
View File
@@ -0,0 +1,22 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { LoginRoutingModule } from './login-routing.module';
import { LoginComponent } from './login.component';
@NgModule({
imports: [
CommonModule,
ReactiveFormsModule,
TranslateModule,
NgbModule,
LoginRoutingModule
],
declarations: [
LoginComponent
]
})
export class LoginModule { }
+2
View File
@@ -0,0 +1,2 @@
export * from './shared.module';
export * from './loader/loader.component';
@@ -0,0 +1,4 @@
<div [hidden]="!isLoading" class="text-xs-center">
<i class="fas fa-cog fa-spin fa-3x"></i> <span>{{message}}</span>
</div>
@@ -0,0 +1,3 @@
.fa {
vertical-align: middle;
}
@@ -0,0 +1,65 @@
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.innerText).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.innerText).toBe('testing');
});
});
+17
View File
@@ -0,0 +1,17 @@
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;
constructor() { }
ngOnInit() { }
}
+17
View File
@@ -0,0 +1,17 @@
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 { }
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.0" width="50px" height="50px" viewBox="0 0 128 128" xml:space="preserve"><path fill="#4f99ed" fill-opacity="1" d="M64.4 16a49 49 0 0 0-50 48 51 51 0 0 0 50 52.2 53 53 0 0 0 54-52c-.7-48-45-55.7-45-55.7s45.3 3.8 49 55.6c.8 32-24.8 59.5-58 60.2-33 .8-61.4-25.7-62-60C1.3 29.8 28.8.6 64.3 0c0 0 8.5 0 8.7 8.4 0 8-8.6 7.6-8.6 7.6z"><animateTransform attributeName="transform" type="rotate" from="0 64 64" to="360 64 64" dur="800ms" repeatCount="indefinite"></animateTransform></path></svg>

After

Width:  |  Height:  |  Size: 672 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

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