- 1. Presentation
- 2. Prerequisites
- 3. Installation
- 4. Screenshots
- 5. Upload Excel files
- 6. Main technical constraints for the study
- 7. Style Guide
- 8. Codacy configuration
- 9. PHPStorm configuration
- 10. Resources & Inspiration
- 10.1. Standalone components
- 10.2. Container & Presentational Components
- 10.3. Observable Data Services
- 10.4. OnPush Change Detection
- 10.5. Design Patterns in Angular
- 10.6. Angular Resolvers
- 10.7. Angular Service Layers
- 10.8. RxJs Mapping Operators
- 10.9. HTTP request & Cache
- 10.10. JWT, Authentication & Permissions
- 10.11. Error handling & Best practices
- 10.12. Architecture
- 10.13. Material
- 10.14. Angular Dev Tools
- 10.15. Skeleton Loading
- 11. Troubleshooting
- 12. Comments, suggestions?
- 13. License
Study of a complete application, with a SPA (Angular) and an API (Symfony), which allows users to connect, and to be able, according to their rights, to import Excel data and modify them online.
Excel Editor SPA |
|
Excel Editor API |
|
Excel Editor Insomnia |
❗
|
Be sure to install the latest version of Docker Compose CLI plugin. |
-
$ git clone [email protected]:jprivet-dev/excel-editor-spa
. -
$ cd excel-editor-spa
. -
$ make install
: This command will execute the following commands:-
$ make build
: Build or rebuild fresh images if necessary. -
$ make start
: Create and start containers (alias:$ make up
).
-
-
Open your browser on http://localhost:4200.
|
To use Excel Editor SPA, you will need to install also Excel Editor API, an API made with Symfony. |
-
Just launch the project with
$ make start
command. -
Open your browser on http://localhost:4200.
💡
|
|
The Excel files are uploaded and renamed (with a unique indentifier) in the uploads
folder of the Excel Editor API.
💡
|
You can test and upload the Excel files in the data folder of the Excel Editor API. |
-
Use of the latest version of Angular.
-
No NgRx Store: the objective is to study in depth the observable data services and principles.
-
Only Angular Material UI: no merge with Bootstrap, tailwindcss, or other CSS frameworks.
-
Use mainly the code generation commands (ng generate).
-
The project must be dockerized.
-
The project must have a consistent and correct code coverage.
-
The data imported from the excel file are in French: this force us to dissociate the specific language of the data (in French) from the "technical" language of the framework (in English).
📎
|
That project (API & SPA) use the camelCase
format for the property names of JSON responses:
{
"thisPropertyIsAnIdentifier": "identifier value"
}
Duplicate CODACY_PROJECT_TOKEN.sh
:
$ cp scripts/CODACY_PROJECT_TOKEN.sh.dist scripts/CODACY_PROJECT_TOKEN.sh
And define the API token CODACY_PROJECT_TOKEN
(see https://app.codacy.com/gh/jprivet-dev/excel-editor-spa/settings/coverage).
The file scripts/CODACY_PROJECT_TOKEN.sh
is ignored by Git and imported by scripts/reporter.sh
.
The file scripts/reporter.sh
generates code coverage (a lcov.info
with Instanbul) and uploads the coverage reports on Codacy.
💡
|
Karma: generate code coverage using Istanbul. |
Codacy scans the ESLint configuration in the .eslintrc.json
file in this repository root.
💡
|
PHPStorm can also use ESLint. See Configure ESLint. |
📎
|
More information on: |
Codacy scans the Stylelint configuration in the .stylelintrc
file in this repository root.
We need to install stylelint-config-standard-scss
:
$ npm install --save-dev stylelint stylelint-config-standard-scss
And create a .stylelintrc.json
configuration file:
{
"extends": "stylelint-config-standard-scss"
}
Use the $ make stylelint
command to execute Stylelint on the src
folder.
💡
|
PHPStorm can also use Stylelint. See Configure Stylelint. |
📎
|
More information on: |
❗
|
The following configuration are provided for PHPStorm 2022.3.1 |
-
Go in Settings (Ctrl+Alt+S) > Languages & Frameworks > Node.js.
-
In Node interpreter, click on … and Add Remote….
-
In the Configure Node.js Remote Interpreter window, choose excel-editor-spa-node:latest and click on OK.
-
In the Settings window, click on OK.
🔥
|
Before you start: Configure a remote Node.js interpreter. |
Configure in Settings (Ctrl+Alt+S) > Languages & Frameworks > JavaScript > Code Quality Tools > ESLint :
After the configuration, you can see the ESLint alerts in your code. For example:
|
If you have this error: ESLint: Can’t run process: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: exec: \"node\": executable file not found in $PATH: unknown: Is that the Node.js interpreter is badly configured. See Configure a remote Node.js interpreter. |
📎
|
More information on https://www.jetbrains.com/help/phpstorm/eslint.html |
🔥
|
Before you start: Configure a remote Node.js interpreter. |
Configure in Settings (Ctrl+Alt+S)> Languages & Frameworks > JavaScript > Prettier :
After the configuration, you can reformat your code :
-
With the shortcut Ctrl+Alt+Maj+P.
-
From the contextual menu (Right click > Reformat with Prettier).
💡
|
It’s possible to reformat on save. |
To reformat on save, Go in Settings (Ctrl+Alt+S)> Languages & Frameworks > JavaScript > Prettier, and check On save option:
If you click on All actions on save…, you will see the list of all activated actions:
💡
|
I also use the Optimize import option. This removes unused imports and organizes import statements in the current file. See https://www.jetbrains.com/help/phpstorm/creating-and-optimizing-imports.html#optimize-imports. |
📎
|
More information on https://www.jetbrains.com/help/phpstorm/prettier.html |
🔥
|
Before you start: Configure a remote Node.js interpreter. |
Configure in Settings (Ctrl+Alt+S)> Languages & Frameworks > JavaScript > Prettier :
❗
|
For the moment I have an error in PHPStorm with a missing module. I tried to install v8-compile-cache, but nothing works: However, everything works with the |
📎
|
More information on https://www.jetbrains.com/help/phpstorm/using-stylelint-code-quality-tool.html |
This project does not yet use completely the standalone components (introduced in Angular 14).
💡
|
Generate a standalone component:$ ng g c myComponent --standalone
|
This project is inspired by this pattern (which can quickly become an anti-pattern if applied dogmatically).
What are the main points?
-
Container (or Smart) components:
-
Components that are aware of the service layer (no @Input, no @Output).
-
Top-level components: highest level of components, only for components attached to a route.
-
Examples: UserPage, FollowersSidebar, StoryContainer, FollowedUserList.
-
-
Presentational components:
-
Components that receive inputs and emit events, nothing else (no services, only @Input and @Ouput).
-
Examples: Sidebar, Story, UserInfo, List
-
-
A presentational component can contain a container component: it allows for logic for interaction with the service layer to be put deeply into the component tree (if that is where it makes the most sense to have it), also to simplify the intermediate components and avoids code repetition.
In practice its actually much more practical to mix and match the multiple types of component design as we need, and use different types of components at different levels of the tree as necessary - mixing the different features as much as we need.
This project uses simple stores with Observable Data Services, instead of @ngrx/store.
You’ll know when you need Flux. If you aren’t sure if you need it, you don’t need it.
-
Inject services deep in the component tree.
-
Inject components or services into each other if we feel they are inherently tightly coupled.
-
Create shared data services that might or might not store the data.
💡
|
Use shareReplay : https://www.learnrxjs.io/learn-rxjs/operators/multicasting/sharereplay
|
-
Communicating with backend services using HTTP: https://angular.io/guide/http
-
Angular Authentication With JWT: https://blog.angular-university.io/angular-jwt-authentication/
-
Angular Authentication: Using Route Guards : https://medium.com/@ryanchenkie_40935/angular-authentication-using-route-guards-bf7a4ca13ae3
-
JWT debugger: https://jwt.io/
-
Angular permissions based on roles | Part 1. Roles, permissions and Permission Manager: https://medium.com/@rjlopezdev/angular-permissions-based-on-roles-part-1-roles-permissions-and-permission-manager-f3dde1bc82bb
📎
|
The authentication service of this project is mainly inspired by https://github.com/auth0/auth0-angular. |
{
"code":401,
"message":"Invalid credentials."
}
{
"headers": {
"normalizedNames": {},
"lazyUpdate": null
},
"status": 401,
"statusText": "OK",
"url": "https://localhost/api/login_check",
"ok": false,
"name": "HttpErrorResponse",
"message": "Http failure response for https://localhost/api/login_check: 401 OK",
"error": {
"code": 401,
"message": "Invalid credentials."
}
}
{
"status": 401,
"name": "HttpErrorResponse",
"message": "Http failure response for https://localhost/api/login_check: 401 OK",
"url": "https://localhost/api/login_check",
"detail": "Invalid credentials."
}
{
"type": "https://tools.ietf.org/html/rfc2616#section-10",
"title": "An error occurred",
"status": 400,
"detail": "Object(App\\Entity\\Data).nomDuGroupe:\n The music group \"Nirvana\" already exists. (code 23bd9dbf-6b9b-41cd-a99e-4844bcf3077f)\n",
"class": "Symfony\\Component\\HttpKernel\\Exception\\HttpException",
"trace": ['...']
}
{
"headers": {
"normalizedNames": {},
"lazyUpdate": null
},
"status": 400,
"statusText": "OK",
"url": "https://localhost/api/data",
"ok": false,
"name": "HttpErrorResponse",
"message": "Http failure response for https://localhost/api/data: 400 OK",
"error": {
"type": "https://tools.ietf.org/html/rfc2616#section-10",
"title": "An error occurred",
"status": 400,
"detail": "Object(App\\Entity\\Data).nomDuGroupe:\n The music group \"Nirvana\" already exists. (code 23bd9dbf-6b9b-41cd-a99e-4844bcf3077f)\n",
"class": "Symfony\\Component\\HttpKernel\\Exception\\HttpException",
"trace": ['...']
}
}
{
"status": 400,
"name": "HttpErrorResponse",
"message": "Http failure response for https://localhost/api/data: 400 OK",
"url": "https://localhost/api/data",
"detail": "Object(App\\Entity\\Data).nomDuGroupe:\n The music group \"Nirvana\" already exists. (code 23bd9dbf-6b9b-41cd-a99e-4844bcf3077f)\n",
"exception": "HttpException"
}
{
"type": "https://tools.ietf.org/html/rfc2616#section-10",
"title": "An error occurred",
"status": 403,
"detail": "You do not have sufficient rights to delete a music group.",
"class": "Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException",
"trace": ['...']
}
{
"headers": {
"normalizedNames": {},
"lazyUpdate": null
},
"status": 403,
"statusText": "OK",
"url": "https://localhost/api/data/78",
"ok": false,
"name": "HttpErrorResponse",
"message": "Http failure response for https://localhost/api/data/78: 403 OK",
"error": {
"type": "https://tools.ietf.org/html/rfc2616#section-10",
"title": "An error occurred",
"status": 403,
"detail": "You do not have sufficient rights to delete a music group.",
"class": "Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException",
"trace": ['...']
}
}
{
"status": 403,
"name": "HttpErrorResponse",
"message": "Http failure response for https://localhost/api/data/78: 403 OK",
"url": "https://localhost/api/data/78",
"detail": "You do not have sufficient rights to delete a music group.",
"exception": "AccessDeniedHttpException"
}
throw new Error('Parameter is not a number!');
Error: Uncaught (in promise): Error: Parameter is not a number!
Error: Parameter is not a number!
at new PageNotFoundComponent (page-not-found.component.ts:14:11)
at NodeInjectorFactory.PageNotFoundComponent_Factory [as factory] (page-not-found.component.ts:15:4)
at getNodeInjectable (core.mjs:3523:44)
at instantiateRootComponent (core.mjs:12592:23)
at createRootComponent (core.mjs:14035:23)
at ComponentFactory.create (core.mjs:13912:17)
at ViewContainerRef.createComponent (core.mjs:23230:47)
at RouterOutlet.activateWith (router.mjs:2569:39)
at ActivateRoutes.activateRoutes (router.mjs:3003:40)
at router.mjs:2952:18
at resolvePromise (zone.js:1211:31)
at resolvePromise (zone.js:1165:17)
at zone.js:1278:17
at _ZoneDelegate.invokeTask (zone.js:406:31)
at Object.onInvokeTask (core.mjs:26261:33)
at _ZoneDelegate.invokeTask (zone.js:405:60)
at Zone.runTask (zone.js:178:47)
at drainMicroTaskQueue (zone.js:585:35)
{
"name": "Error",
"message": "Uncaught (in promise): Error: Parameter is not a number!\nError: Parameter is not a number!\n at new PageNotFoundComponent (http://localhost:4200/main.js:892:15)\n at NodeInjectorFactory.PageNotFoundComponent_Factory [as factory] (http://localhost:4200/main.js:895:81)\n at getNodeInjectable (http://localhost:4200/vendor.js:52246:38)\n at instantiateRootComponent (http://localhost:4200/vendor.js:62953:21)\n at createRootComponent (http://localhost:4200/vendor.js:64667:21)\n at ComponentFactory.create (http://localhost:4200/vendor.js:64517:19)\n at ViewContainerRef.createComponent (http://localhost:4200/vendor.js:75063:43)\n at RouterOutlet.activateWith (http://localhost:4200/vendor.js:113096:33)\n at ActivateRoutes.activateRoutes (http://localhost:4200/vendor.js:113679:28)\n at http://localhost:4200/vendor.js:113625:12"
}
-
https://dev-academy.com/angular-architecture-best-practices/
-
https://medium.com/dev-jam/5-tips-best-practices-to-organize-your-angular-project-e900db08702e https://angular.io/guide/styleguide#lift
-
https://stackoverflow.com/questions/70924931/angular-structure-best-practices-coremodule
-
https://michelestieven.medium.com/organizing-angular-applications-f0510761d65a
-
https://tomastrajan.medium.com/the-best-way-to-architect-your-angular-libraries-87959301d3d3
-
Using public_api.ts in libraries instead of piling everything in index.ts: nrwl/nx#155
-
Installation: https://material.angular.io/guide/getting-started
-
Material Symbols: https://fonts.google.com/icons
When I use Slow 3G network conditions:
I got the following console error on Chrome:
[webpack-dev-server] Disconnected! index.js:551
[webpack-dev-server] Trying to reconnect...
[webpack-dev-server] Disconnected! index.js:551
[webpack-dev-server] Trying to reconnect...
...
This is a problem when using SSL. See angular/angular-cli#4839.
📎
|
No problem with Fast 3G. |
Whaaaaat !??
ERROR Error: Input type "file" isn't supported by matInput.
💡
|
Workaround : <button type="button" mat-raised-button (click)="fileInput.click()">Choose File</button>
<input hidden (change)="onFileSelected()" #fileInput type="file" id="file"> onFileSelected() {
const inputNode: any = document.querySelector('#file');
if (typeof (FileReader) !== 'undefined') {
const reader = new FileReader();
reader.onload = (e: any) => {
this.srcResult = e.target.result;
};
reader.readAsArrayBuffer(inputNode.files[0]);
}
} |
Feel free to make comments/suggestions to me in the Git issues section.