├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .tool-versions ├── .vscode └── launch.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── angular.json ├── commitlint.config.js ├── content ├── architecture │ ├── .category │ ├── never-mutate-objects.md │ ├── provide-shared-services-on-root-level.md │ ├── put-business-logic-into-services.md │ ├── use-descriptive-file-names.md │ └── use-smart-and-dumb-components.md ├── components │ ├── .category │ ├── clean-up-resource-in-ng-on-destroy.md │ ├── dont-use-property-bindings-to-pass-static-strings.md │ ├── minimize-logic-in-templates.md │ └── only-manipulate-the-dom-via-the-renderer.md ├── forms │ ├── .category │ └── do-not-mix-form-apis.md ├── general │ ├── .category │ └── use-latest-version-of-everything.md ├── http │ └── .category ├── ngrx │ ├── .category │ ├── action-hygiene.md │ ├── actions-are-defined-as-classes.md │ ├── do-not-put-everything-in-the-store.md │ ├── dont-store-state-that-can-be-derived.md │ ├── reducers-are-pure-functions.md │ ├── use-entity-pattern.md │ └── use-selectors.md ├── performance │ ├── .category │ ├── dont-call-function-from-template.md │ ├── lazy-load-animations.md │ ├── track-by-option-on-ng-for.md │ ├── use-angular-new-cache.md │ ├── use-aot-compilation.md │ └── use-on-push-cd-strategy.md ├── router │ ├── .category │ ├── add-404-route.md │ ├── default-route.md │ ├── lazy-load-feature-modules.md │ ├── protect-restricted-pages-with-guards.md │ └── use-preloading-strategy.md ├── rxjs │ ├── .category │ ├── avoid-nested-subscriptions.md │ ├── pipeable-operators.md │ ├── takeuntil-operator.md │ ├── use-async-pipe.md │ ├── use-ngifas.md │ └── use-switchMap-only-when-you-need-cancellation.md ├── tooling │ ├── .category │ ├── compodoc.md │ ├── use-angular-cli.md │ ├── use-angular-dev-tools.md │ └── use-prettier.md └── typescript │ ├── .category │ ├── avoid-using-any.md │ ├── define-interfaces-for-models.md │ ├── define-types-at-the-non-typed-boundaries.md │ ├── move-common-types-to-interfaces.md │ └── use-type-inference.md ├── cypress.json ├── cypress ├── plugins │ ├── cy-ts-preprocessor.js │ └── index.js ├── support │ ├── commands.js │ └── index.js └── tsconfig.json ├── karma.conf.js ├── nodemon.json ├── package.json ├── src ├── app │ ├── app.component.html │ ├── app.component.scss │ ├── app.component.ts │ ├── app.config.ts │ ├── app.routes.ts │ ├── checklist │ │ ├── checklist-cta-bar │ │ │ ├── checklist-cta-bar.component.html │ │ │ ├── checklist-cta-bar.component.scss │ │ │ └── checklist-cta-bar.component.ts │ │ ├── checklist-detail-view │ │ │ ├── checklist-detail-view.component.html │ │ │ ├── checklist-detail-view.component.scss │ │ │ └── checklist-detail-view.component.ts │ │ ├── checklist-favorite-button │ │ │ ├── checklist-favorite-button.component.html │ │ │ ├── checklist-favorite-button.component.scss │ │ │ └── checklist-favorite-button.component.ts │ │ ├── checklist-favorites-view │ │ │ ├── checklist-favorites-view.component.html │ │ │ ├── checklist-favorites-view.component.scss │ │ │ └── checklist-favorites-view.component.ts │ │ ├── checklist-item-metadata │ │ │ ├── checklist-metadata.component.html │ │ │ ├── checklist-metadata.component.scss │ │ │ └── checklist-metadata.component.ts │ │ ├── checklist-list-view │ │ │ ├── checklist-list-view.component.html │ │ │ ├── checklist-list-view.component.scss │ │ │ └── checklist-list-view.component.ts │ │ ├── checklist-list │ │ │ ├── checklist-list-item.component.html │ │ │ ├── checklist-list-item.component.scss │ │ │ ├── checklist-list-item.component.ts │ │ │ ├── checklist-list.component.scss │ │ │ └── checklist-list.component.ts │ │ ├── checklist-overview │ │ │ ├── checklist-overview.component.html │ │ │ ├── checklist-overview.component.scss │ │ │ └── checklist-overview.component.ts │ │ ├── checklist-search │ │ │ ├── checklist-search.component.html │ │ │ ├── checklist-search.component.scss │ │ │ └── checklist-search.component.ts │ │ ├── checklist.component.html │ │ ├── checklist.component.scss │ │ ├── checklist.component.ts │ │ ├── checklist.routes.ts │ │ ├── confirmation-dialog │ │ │ ├── confirmation-dialog.component.html │ │ │ ├── confirmation-dialog.component.scss │ │ │ └── confirmation-dialog.component.ts │ │ ├── models │ │ │ └── checklist.model.ts │ │ ├── project-exists.guard.ts │ │ ├── search │ │ │ ├── search.models.ts │ │ │ └── search.service.ts │ │ └── state │ │ │ ├── checklist.actions.ts │ │ │ ├── checklist.reducer.ts │ │ │ ├── checklist.selectors.ts │ │ │ └── checklist.state.ts │ ├── projects │ │ ├── models │ │ │ └── projects.model.ts │ │ ├── project-dialog │ │ │ ├── project-dialog.component.html │ │ │ ├── project-dialog.component.scss │ │ │ └── project-dialog.component.ts │ │ ├── projects-view │ │ │ ├── projects-view.component.html │ │ │ ├── projects-view.component.scss │ │ │ └── projects-view.component.ts │ │ ├── projects.routes.ts │ │ └── state │ │ │ ├── project-state.utils.ts │ │ │ ├── projects.actions.ts │ │ │ ├── projects.reducer.ts │ │ │ └── projects.selectors.ts │ ├── shared │ │ ├── about-dialog │ │ │ ├── about-dialog.component.html │ │ │ ├── about-dialog.component.scss │ │ │ └── about-dialog.component.ts │ │ ├── authors │ │ │ ├── authors.component.html │ │ │ ├── authors.component.scss │ │ │ └── authors.component.ts │ │ ├── banner │ │ │ ├── banner.component.html │ │ │ ├── banner.component.scss │ │ │ └── banner.component.ts │ │ ├── breakpoint.service.ts │ │ ├── chip │ │ │ ├── chip.component.html │ │ │ ├── chip.component.scss │ │ │ └── chip.component.ts │ │ ├── dropdown │ │ │ ├── dropdown-static-option.component.scss │ │ │ ├── dropdown-static-options.component.scss │ │ │ ├── dropdown-static-options.component.ts │ │ │ ├── dropdown.component.html │ │ │ ├── dropdown.component.scss │ │ │ └── dropdown.component.ts │ │ ├── footer │ │ │ ├── footer.component.html │ │ │ ├── footer.component.scss │ │ │ └── footer.component.ts │ │ ├── models.ts │ │ ├── operators.ts │ │ ├── router.utils.ts │ │ ├── score-chart │ │ │ ├── score-chart.component.html │ │ │ ├── score-chart.component.scss │ │ │ └── score-chart.component.ts │ │ ├── toolbar │ │ │ ├── toolbar-logo │ │ │ │ ├── toolbar-logo.component.html │ │ │ │ ├── toolbar-logo.component.scss │ │ │ │ └── toolbar-logo.component.ts │ │ │ ├── toolbar.component.html │ │ │ ├── toolbar.component.scss │ │ │ └── toolbar.component.ts │ │ └── utils.ts │ └── state │ │ ├── app-state.utils.ts │ │ ├── app.selectors.ts │ │ └── app.state.ts ├── assets │ ├── .gitkeep │ ├── angular-checklist.png │ ├── undraw_checklist.svg │ └── undraw_no_data.svg ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── main.ts ├── scss │ ├── custom-theme.scss │ ├── index.scss │ ├── mixins.scss │ └── utils.scss ├── styles.scss └── test.ts ├── tools ├── build-checklist.ts ├── markdown.ts ├── models.ts └── utils.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.spec.json ├── types └── shorthash.d.ts └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": [ 4 | "projects/**/*" 5 | ], 6 | "overrides": [ 7 | { 8 | "files": [ 9 | "*.ts" 10 | ], 11 | "parserOptions": { 12 | "project": [ 13 | "tsconfig.json", 14 | "e2e/tsconfig.json" 15 | ], 16 | "createDefaultProgram": true 17 | }, 18 | "extends": [ 19 | "plugin:@angular-eslint/recommended", 20 | "plugin:@angular-eslint/template/process-inline-templates" 21 | ], 22 | "rules": { 23 | "@angular-eslint/component-selector": [ 24 | "error", 25 | { 26 | "prefix": "ac", 27 | "style": "kebab-case", 28 | "type": "element" 29 | } 30 | ], 31 | "@angular-eslint/directive-selector": [ 32 | "error", 33 | { 34 | "prefix": "ac", 35 | "style": "camelCase", 36 | "type": "attribute" 37 | } 38 | ] 39 | } 40 | }, 41 | { 42 | "files": [ 43 | "*.html" 44 | ], 45 | "extends": [ 46 | "plugin:@angular-eslint/template/recommended" 47 | ], 48 | "rules": {} 49 | } 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # generated content 9 | content.json 10 | 11 | # dependencies 12 | /node_modules 13 | 14 | # IDEs and editors 15 | /.idea 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # IDE - VSCode 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | 30 | # misc 31 | /.angular/cache 32 | /.sass-cache 33 | /connect.lock 34 | /coverage 35 | /libpeerconnection.log 36 | npm-debug.log 37 | yarn-error.log 38 | testem.log 39 | /typings 40 | 41 | # System Files 42 | .DS_Store 43 | Thumbs.db 44 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | content.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "useTabs": false, 5 | "tabWidth": 2, 6 | "semi": true, 7 | "bracketSpacing": true 8 | } 9 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 18.19.0 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Launch current file w/ ts-node", 8 | "args": ["${relativeFile}"], 9 | "cwd": "${workspaceRoot}", 10 | "runtimeArgs": ["-r", "ts-node/register"], 11 | "internalConsoleOptions": "openOnSessionStart" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at hello@angular-checklist.dev. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 angular-checklist 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Angular Checklist 4 |

5 | 6 | This repo contains the code for [angular-checklist.dev](https://angular-checklist.dev). 7 | 8 | --- 9 | 10 | # 🤔 What is it? 11 | 12 | Angular Checklist is a curated list of items that we believe every application should follow. Over the past couple of years, we have been doing a lot of code reviews and have often seen the same mistakes being made again and again. 13 | 14 | That's when we decided to create **Angular Checklist**. 15 | 16 | It's a curated list of best practices to avoid some common pitfalls. 17 | 18 | Therefore, we transformed a bunch of best practices and common mistakes into _todo_ items. 19 | 20 | The idea is that for all your projects, you can go over the checklist and see which items your projects already comply with and which you still have to put in some more effort! 21 | 22 | To keep track of your progress, every group has a progress indicator which tells you how many items you have already checked. If the pie chart has been completely filled, congratulations 🎉 ... your project will definitely be on track to success 🏆! 23 | 24 | # 👨‍💻 Who is behind this project? 25 | 26 | This project is brought to you with ❤️ by [Dominic Elm](https://twitter.com/elmd_) and [Kwinten Pisman](https://twitter.com/KwintenP). 27 | 28 | ## Core Maintainers 29 | 30 | * [Gerome Grignon](https://github.com/geromegrignon) 31 | 32 | # 👷 Want to contribute? 33 | 34 | If you want to add a checklist item, file a bug, contribute some code, or improve our documentation, read up on our [contributing guidelines](CONTRIBUTING.md) and [code of conduct](CODE_OF_CONDUCT.md), and check out [open issues](https://github.com/typebytes/angular-checklist/issues) as well as [open pull requests](https://github.com/typebytes/angular-checklist/pulls) to avoid potential conflicts. 35 | 36 | # 📄 Licence 37 | 38 | MIT License (MIT) © [Dominic Elm](https://github.com/d3lm) and [Kwinten Pisman](https://github.com/KwintenP) 39 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-angular'], 3 | rules: { 4 | 'subject-max-length': [1, 'always', 72], 5 | 'scope-empty': [1, 'never'], 6 | 'scope-enum': [2, 'always', ['app']], 7 | 'scope-case': [2, 'always', 'lowerCase'], 8 | 'type-enum': [ 9 | 2, 10 | 'always', 11 | [ 12 | 'content', 13 | 'chore', 14 | 'build', 15 | 'ci', 16 | 'docs', 17 | 'feat', 18 | 'fix', 19 | 'perf', 20 | 'refactor', 21 | 'release', 22 | 'revert', 23 | 'style', 24 | 'test' 25 | ] 26 | ] 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /content/architecture/.category: -------------------------------------------------------------------------------- 1 | --- 2 | title: Architecture 3 | summary: This category summarizes best practices regarding architecture. 4 | --- -------------------------------------------------------------------------------- /content/architecture/never-mutate-objects.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: never mutate objects and embrace immutability 3 | --- 4 | 5 | # Problem 6 | 7 | Performing a deep comparison of objects in JavaScript is a quite costly operation. Reference checks however, are extremely fast and easy. For that reason, Angular and lots of other libraries depend on reference check comparisons instead of deeply comparing objects. If you mutate objects, you most likely experience weird and unexpected behavior when using any of these libs. 8 | 9 | Here are some examples of things that don't work properly when mutating objects: 10 | 11 | - `OnPush` ChangeDetectionStrategy in Angular 12 | - NgRx selectors 13 | - RxJS operators such as `distinct`, `distinctUntilChanged`, `tap`, ... 14 | 15 | # Solution 16 | 17 | Instead of mutating objects, we need to work immutable. Immutability means that we will never mutate objects. Instead, if we need to update state or some object properties, we first copy the object and then make our changes. 18 | 19 | This can easily be done with the object/array spread operator: 20 | 21 | ```ts 22 | // Original State 23 | const state = { 24 | users: [ 25 | { id: 1, name: "Dominic Elm" }, 26 | { id: 2, name: "Kwinten Pisman" }, 27 | ], 28 | selectedUserId = 1 29 | } 30 | 31 | // New State 32 | const newState = { ...state, selectedUserId: 2 }; 33 | ``` 34 | 35 | In this example, we have a state object with some data. We want to update the `selectedUserId` property, without mutating the original object. Using the object spread operator, we create a new object, keeping the same reference to the `users` array but updating the `selectedUserId` to 2. 36 | 37 | **Note:** This is just one of the ways we can work immutable. The spread operator is available in the latest versions of JavaScript. There are also libraries that can help us to work immutable that will be more performant for big collections, for example [Immutable.js](https://facebook.github.io/immutable-js/), [Immer](https://github.com/mweststrate/immer) or [Seamless Immutable](https://github.com/rtfeldman/seamless-immutable). The point here is that we should embrace immutability and try to avoid mutating objects, regardless of how you accomplish this. 38 | -------------------------------------------------------------------------------- /content/architecture/provide-shared-services-on-root-level.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: provide shared services only on root level 3 | --- 4 | 5 | # Problem 6 | 7 | Due to the way DI (Dependency Injection) in Angular is implemented, with an injector tree, we can provide instances of our service on multiple levels, e.g. component, directive or module. While this is a useful feature, this is not always what we want. 8 | 9 | Working with a shared module is quite common and recommended. This module can be used to share services, components, directives, pipes, etc. between different feature modules. If we import our shared module in multiple modules, we will provide the service multiple times and multiple instances will be created. Our services are no longer singletons. 10 | 11 | # Solution 12 | 13 | When creating a `SharedModule`, we want to import the components in all feature modules but only provide the services in our root module, for instance `AppModule`. We can accomplish this by leveraging the `forRoot` convention. Here's what our `SharedModule` would look like this: 14 | 15 | ```ts 16 | @NgModule({ 17 | imports: [...modules], 18 | declarations: [...declarations], 19 | exports: [...declarations] 20 | }) 21 | export class SharedModule { 22 | static forRoot(): ModuleWithProviders { 23 | return { 24 | ngModule: SharedModule, 25 | providers: [...services] 26 | }; 27 | } 28 | } 29 | ``` 30 | 31 | Note that the actual module definition **does not** contain any providers. 32 | 33 | In our `AppModule`, we could use this module as follows: 34 | 35 | ```ts 36 | @NgModule({ 37 | imports: [ 38 | ...modules, 39 | SharedModule.forRoot() 40 | ], 41 | ... 42 | }) 43 | export class AppModule {} 44 | ``` 45 | 46 | By calling the static `forRoot` method on the `SharedModule` we import the entire module **including** its providers. 47 | 48 | In a feature module we would simply import the `SharedModule` **without** calling `forRoot`: 49 | 50 | ```ts 51 | @NgModule({ 52 | imports: [ 53 | ...modules, 54 | SharedModule 55 | ], 56 | ... 57 | }) 58 | export class SomeFeature {} 59 | ``` 60 | 61 | The fact that each component has its own injector that inherits from its parent injector, allows us to ask for services provided on a root level. Therefore, we have access to all components, pipes, etc. provided by the `SharedModule` without creating multiple instances of its services. 62 | 63 | # Additional Resources 64 | 65 | - [Dependency Injection in Angular](https://blog.thoughtram.io/angular/2015/05/18/dependency-injection-in-angular-2.html) by Pascal Precht 66 | - [Bypassing Providers in Angular](https://blog.thoughtram.io/angular/2016/09/14/bypassing-providers-in-angular-2.html) by Pascal Precht 67 | - [Avoiding common confusions with modules in Angular](https://blog.angularindepth.com/avoiding-common-confusions-with-modules-in-angular-ada070e6891f) by Maxim Koretskyi 68 | -------------------------------------------------------------------------------- /content/architecture/put-business-logic-into-services.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: put business logic into services 3 | --- 4 | 5 | # Problem 6 | 7 | With Angular we are creating applications using a layered architecture. Every layer in our application should have its own responsibility. This means we have decoupled layers and each with its own concern. 8 | Business logic in our application does not belong in the component layer. The component layer is purely meant to be used for visualization, displaying user interface and handling user input. Therefore, business logic should be extracted into the service layer. 9 | 10 | In the following example, we are using the `HttpClient` to fetch data from a backend. This should not be done from the component layer. 11 | 12 | ```ts 13 | @Component({ 14 | ... 15 | }) 16 | export class PeopleComponent implements OnInit { 17 | #httpClient = inject(HttpClient); 18 | people$ = this.http.get('http://some-api.com/api/people'); 19 | } 20 | ``` 21 | 22 | # Solution 23 | 24 | Let's move the logic into a dedicated `PeopleService` service instead! 25 | 26 | ```ts 27 | @Injectable({ 28 | providedIn: 'root' 29 | }) 30 | export class PeopleService { 31 | #http = inject(HttpClient); 32 | 33 | getPeople(): Observable { 34 | return this.http.get('http://some-api.com/api/people'); 35 | } 36 | } 37 | ``` 38 | 39 | Now we can inject the `PeopleService` into our component and use the `getPeople` method. 40 | 41 | ```ts 42 | @Component({ 43 | ... 44 | }) 45 | export class PeopleComponent { 46 | #peopleService = inject(PeopleService); 47 | people$ = this.peopleService.getPeople(); 48 | } 49 | ``` 50 | -------------------------------------------------------------------------------- /content/architecture/use-descriptive-file-names.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: use descriptive file names 3 | --- 4 | 5 | # Problem 6 | 7 | When applications grow over time, it can be quite hard to identify and find certain parts in our application. When we don't give a descriptive name to our files, this makes it even more difficult to do so. 8 | 9 | # Solution 10 | 11 | ## Separate file names with dots and dashes 12 | 13 | It is recommended to separate words with dashes and dots to separate the descriptive name from the type. The descriptive name of a file should describe the component's feature. 14 | 15 | Also, try to use conventional suffix that describe the type of the file, e.g. `.component.ts`, `.directive.ts`, `.service.ts`, `.module.ts`, `.pipe.ts`. 16 | 17 | Here are a few examples: 18 | 19 | - `app.component.ts` 20 | - `contacts.service.ts` 21 | - `product-list.component.ts` 22 | 23 | Using such naming convention helps to provide a consistent way to find content very quickly and easily. Consistency will save you time and make you and your team more efficient. 24 | 25 | ## Use the name and type of the file for your class names 26 | 27 | If the file you are working on is `app.component.ts` it is obvious that this must be a component. It also tells us the name of this component, which is `app`. This means we'd call our class `AppComponent`: 28 | 29 | ```ts 30 | @Component({ ... }) 31 | export class AppComponent { } 32 | ``` 33 | 34 | Here's another example of a class defined in `product-list.component.ts`: 35 | 36 | ```ts 37 | @Component({ ... }) 38 | export class ProductListComponent { } 39 | ``` 40 | -------------------------------------------------------------------------------- /content/architecture/use-smart-and-dumb-components.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: use smart and dumb components 3 | --- 4 | 5 | # Problem 6 | 7 | Every major frontend framework is moving towards a component-based architecture. Components are a combination of HTML, JavaScript and CSS. If we start injecting services in every component, tightly couple them by letting them fetch their own data, we are not leveraging the power of a component-based architecture. 8 | 9 | # Solution 10 | 11 | The most advocated way to lay out your components is to use smart and dumb components (there is a variety of other names for this principle but the general idea is the same). 12 | 13 | ## Component Types 14 | 15 | ### Dumb Component 16 | 17 | * Receives data through `@Input`s and communicates only with it's direct parent through `@Output`s. 18 | * Dumb components should not receive `Observables` as inputs. 19 | * They do not know about the rest of the application and hence does not know where they are being used. 20 | * Can contain business logic, but only logic that belongs to the scope of this component. For example, a pagination component can contain logic to calculate the number of 'boxes' to show. It does not know what happens when a user clicks a page number. In that case, it would emit a custom event to notify its parent that something has happened. The parent component then decides what to do and takes action. 21 | * They can use other dumb components as children. 22 | * They can inject services that are related to the view layer of your application (think `TranslateService`, `Router`, ...) but never services that handle business logic such as fetching data. 23 | 24 | ### Smart Component 25 | 26 | * Smart components are application-level components. 27 | * They know how to fetch data and persist changes. 28 | * They pass data down to dumb components as much as possible and mostly only contains business logic to fetch data. 29 | * They compose several other dumb components in its template. 30 | * They listen for events emitted by dumb components and perform the required action. 31 | 32 | ## Benefits 33 | 34 | * Dumb components are completely reusable since they have a defined API and are independent of any business logic. 35 | * Dumb components are easy to test as they are completely isolated. 36 | * The entire architecture of your components becomes easier to reason about. If there is problem with business logic or if the data is not correctly fetched, you know that you need to start searching in your smart components since this is their only responsibility. 37 | 38 | # Resources 39 | 40 | * [Presentational and container components](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) by Dan Abramov 41 | * [Smart components vs presentational components](https://blog.angular-university.io/angular-2-smart-components-vs-presentation-components-whats-the-difference-when-to-use-each-and-why/) by Angular University 42 | * [The smart vs dumb component quiz](https://blog.strongbrew.io/the-smart-vs-dumb-components-quiz/) by Kwinten Pisman 43 | -------------------------------------------------------------------------------- /content/components/.category: -------------------------------------------------------------------------------- 1 | --- 2 | title: Components 3 | summary: This category summarizes best practices regarding Components. 4 | --- 5 | -------------------------------------------------------------------------------- /content/components/clean-up-resource-in-ng-on-destroy.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: release resources in ngOnDestroy 3 | --- 4 | 5 | ## Problem 6 | 7 | When creating Angular components, we need to use resources to get user input, fetch data from the backend, create animations, etc. The way we do this varies. We could use Observables, browser APIs, event listeners or other means. When using resources, we also need to release those resources when they are no longer required. If we do **not** do this, we might introduce memory leaks which will make our application crash and introduce other unwanted behavior. 8 | 9 | ## Solution 10 | 11 | For every component and directive, Angular offers lifecycle hooks that provide visibility into key life moments of a component, such as creation, rendering, or when data-bound properties have changed. 12 | 13 | In order to release our resources, we can hook into the `ngOnDestroy` lifecyle of a component. This hook is called **before** a component is destroyed and removed from the DOM. 14 | 15 | In the following example, we set up a function to be executed every 5000ms using the `setInterval` API. Inside `ngOnDestroy`, we clear the interval and release the resource. 16 | 17 | ```ts 18 | @Component({ 19 | ... 20 | }) 21 | export class SomeComponent implements OnInit, OnDestroy { 22 | intervalId; 23 | 24 | ngOnInit() { 25 | this.intervalId = setInterval(() => {...}, 5000); 26 | } 27 | 28 | ngOnDestroy() { 29 | clearInterval(this.intervalId); 30 | } 31 | } 32 | ``` 33 | -------------------------------------------------------------------------------- /content/components/dont-use-property-bindings-to-pass-static-strings.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: don't use property bindings to pass static strings to native attributes 3 | --- 4 | 5 | # Problem 6 | 7 | Property bindings in Angular allows us to bind to properties. During change detection, when a component is checked, all of the bindings for that component are being checked to see if the view needs to be updated. This means that the more bindings we create, the slower the CD cycle will be, as more bindings need to be checked. 8 | 9 | If we have static strings that we want to pass to a native attribute (such as `id` and `title`) of an HTML element, it's not necessary to use a property binding as the value will never change. This seems to be a trivial thing to talk about, but it can have a great impact the performance of our application. 10 | 11 | # Solution 12 | 13 | Only use a property binding for dynamic values. Use attributes to pass static string values to native attributes. 14 | 15 | In the following example, we bind a static string to the `id` property of an input field. This doesn't make much sense because this string is passed statically and will never change. So, why use a property binding for this? 16 | 17 | ```ts 18 | 19 | ``` 20 | 21 | In order to fix this, we can remove the property binding and use the native `id` attribute instead. 22 | 23 | ```ts 24 | 25 | ``` 26 | -------------------------------------------------------------------------------- /content/components/minimize-logic-in-templates.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: minimize logic in templates 3 | --- 4 | 5 | # Problem 6 | 7 | When we put too much logic in our templates, we are making our applications more difficult to test. The fastest way to write and execute tests is to use simple unit tests. Of course we could also test a component's template with a unit test but that increases the complexity and introduces some challenges we have to deal with. 8 | 9 | In addition, too much logic inside the template makes them less readable. We cannot take a quick glance at the template and quickly understand what's going on. 10 | 11 | For example here, we have have an `@if` that has too much logic. 12 | 13 | ```ts 14 | @Component({ 15 | template: ` 16 | @if(users().length && !users().some(user => user.criteriaMet)) { 17 |

No items meet the criteria.

18 | } 19 | `, 20 | }) 21 | export class SomeComponent { 22 | users: signal([]); 23 | } 24 | ``` 25 | 26 | # Solution 27 | 28 | Let's refactor this by extracting the logic into the component's class. This will make the template more readable and the logic easier to test. 29 | To do so, we use `computed`, introduced in Angular 16, to create a computed property that will be used in the template. 30 | 31 | ```ts 32 | @Component({ 33 | template: ` 34 | @if(noCriteriaMet()) { 35 |

No items meet the criteria.

36 | } 37 | `, 38 | }) 39 | export class SomeComponent { 40 | users: signal([]); 41 | 42 | noCriteriaMet = computed(() => this.users().length && !this.users().some(user => user.criteriaMet)); 43 | } 44 | ``` 45 | 46 | ### Best Practices 47 | 48 | Be careful when the `ChangeDetectionStrategy` is set to `Default`, as it'd cause the functions bound in the template to be called each time the `Change Detection Cycle` runs. 49 | You can optimize this by turning on the `OnPush` change detection strategy and leverage the `async` pipe in combination with `Observables` that return the desired value. 50 | -------------------------------------------------------------------------------- /content/components/only-manipulate-the-dom-via-the-renderer.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: only manipulate the DOM via the Renderer 3 | author: 4 | name: Billy Lando 5 | url: https://github.com/billyjov 6 | --- 7 | 8 | # Problem 9 | 10 | According to the Angular documentation, relying on direct DOM access creates tight coupling between your application and rendering layers which will make it impossible to separate the two and deploy your application into a web worker. 11 | 12 | Consequently, using jQuery , `document` object, or `ElementRef.nativeElement` is not recommended as it's not available on other platforms such as server (for server-side rendering) or web worker. 13 | 14 | In addition, permitting direct access to the DOM can make your application more vulnerable to **XSS** attacks. 15 | 16 | # Solution 17 | 18 | Always try to prefer the `Renderer2` for DOM manipulations. It provides an API that can safely be used even when direct access to native elements is not supported. 19 | 20 | - **Bad practice** 21 | ```ts 22 | @Component({ 23 | ... 24 | template: ` 25 | 26 | 27 | ` 28 | }) 29 | export class SomeComponent implements OnInit { 30 | 31 | constructor(private elementRef: ElementRef) {} 32 | 33 | ngOnInit() { 34 | this.elementRef.nativeElement.style.backgroundColor = '#fff'; 35 | this.elementRef.nativeElement.style.display = 'inline'; 36 | const textareaElement = document.querySelector('textarea'); 37 | const myChildComponent = $('my-child-component'); 38 | } 39 | } 40 | ``` 41 | 42 | We can refactor this by using a combination of `ElementRef` and `Renderer2`. 43 | 44 | - **Good practice** 45 | ```ts 46 | import { MyChildComponent } from './my-child.component'; 47 | 48 | @Component({ 49 | ... 50 | template: ` 51 | 52 | 53 | ` 54 | }) 55 | export class SomeComponent implements OnInit { 56 | 57 | @ViewChild('textareaRef') myTextAreaRef: ElementRef; 58 | @ViewChild(MyChildComponent) myChildComponentRef: MyChildComponent; 59 | 60 | constructor(private elementRef: ElementRef, private renderer: Renderer2) {} 61 | 62 | ngOnInit() { 63 | this.renderer.setStyle(this.elementRef.nativeElement, 'backgroundColor', '#fff'); 64 | this.renderer.setStyle(this.elementRef.nativeElement, 'display', 'inline'); 65 | const textareaElement = this.myTextAreaRef.nativeElement; 66 | const myComponent = this.myChildComponent; 67 | } 68 | } 69 | ``` 70 | 71 | # Resources 72 | 73 | - [Angular Documentation for ElementRef](https://angular.io/api/core/ElementRef#description) 74 | - [Exploring Angular DOM manipulation techniques using ViewContainerRef](https://blog.angularindepth.com/exploring-angular-dom-abstractions-80b3ebcfc02) by Max Koretskyi 75 | -------------------------------------------------------------------------------- /content/forms/.category: -------------------------------------------------------------------------------- 1 | --- 2 | title: Forms 3 | summary: This category summarizes best practices regarding Forms. 4 | --- 5 | -------------------------------------------------------------------------------- /content/forms/do-not-mix-form-apis.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Do not mix Angular Forms API 3 | --- 4 | 5 | # Problem 6 | 7 | Angular provides two APIs for building forms: the `Template-driven` and the `Reactive` forms API. 8 | Both APIs can be used to build forms in Angular, but they are not meant to be mixed. 9 | Mixing these APIs can lead to confusion and make the code harder to maintain. 10 | 11 | Here is an example of mixing the two APIs: 12 | 13 | ```ts 14 | import { FormControl } from '@angular/forms'; 15 | 16 | @Component({ 17 | template: ` 18 |
19 | 20 | 21 |
22 | ` 23 | }) 24 | export class SomeComponent { 25 | name: string = 'Gerome'; 26 | 27 | form = new FormGroup({ 28 | name: new FormControl('Gerome', {Validators.required}) 29 | }); 30 | } 31 | ``` 32 | 33 | In this example, we are using both `formControl` and `ngModel` to bind the input field to the `name` property. 34 | 35 | # Solution 36 | 37 | Choose one API and stick to it. If you are using the `Reactive` forms API, then use it consistently throughout your application. 38 | 39 | Here is the same example using the `Reactive` forms API: 40 | 41 | ```ts 42 | import { FormControl } from '@angular/forms'; 43 | 44 | @Component({ 45 | template: ` 46 |
47 | 48 | 49 |
50 | ` 51 | }) 52 | export class SomeComponent { 53 | form = new FormGroup({ 54 | name: new FormControl('Gerome', {Validators.required}) 55 | }); 56 | } 57 | ``` 58 | -------------------------------------------------------------------------------- /content/general/.category: -------------------------------------------------------------------------------- 1 | --- 2 | title: General 3 | summary: This category summarizes best practices regarding general topics. 4 | --- 5 | -------------------------------------------------------------------------------- /content/general/use-latest-version-of-everything.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: use latest version of everything 3 | author: 4 | name: Billy Lando 5 | url: https://github.com/billyjov 6 | --- 7 | 8 | # Problem 9 | 10 | The Angular team and community are continually improving the ecosystem to make it easier to build applications. Both the performance and the compiler (e.g., Ivy Renderer) are constantly being improved for better web applications. 11 | 12 | Angular uses semantic versioning (semver), which means they use a regular schedule of releases. This includes a major release every six months, 1-3 minor releases for each major release, and a patch release almost every week. It’s important to keep up with major releases, as they contain significant new features. The longer we wait to update our application, the more expensive a future update can be. **Be aware** that major releases may contain breaking changes. 13 | 14 | In addition, when APIs are deprecated, they remain present in the next two major releases until they are removed. Again, if we wait too long, it’s likely that an update will require much more work. You can read more about deprecations in the changelog. 15 | 16 | **Note:** In the case of significant refactors, the Angular team may create schematics that can help update your app for you. At present, there are [schematics](https://angular.dev/reference/migrations) that convert to standalone components, the new control flow syntax, and more. 17 | 18 | # Solution 19 | 20 | You can follow this steps using Angular CLI: 21 | 22 | - **Step 1:** Create a new feature branch 23 | - **Step 2:** Run `ng update @angular/core @angular/cli` inside your project directory 24 | - **Step 3:** Run `ng serve`, `ng test`, `ng build --prod` and make sure your app works as expected 25 | - **Step 4:** Fix update deprecations, issues, styling issues in case of Angular Material and run the previous step again 26 | - **Step 5:** merge or rebase your changes on top of the main branch 27 | 28 | For more information, check out the [official update guide](https://update.angular.io/) on how to update from different versions. 29 | 30 | # Resources 31 | 32 | - [Keeping your Angular Projects Up-to-Date](https://angular.io/guide/updating) 33 | - [Don’t be afraid and just `ng update`!](https://itnext.io/dont-be-afraid-and-just-ng-update-1ad096147640) by Bram Borggreve 34 | -------------------------------------------------------------------------------- /content/http/.category: -------------------------------------------------------------------------------- 1 | --- 2 | title: HTTP 3 | summary: This category summarizes best practices regarding HTTP interactions and modules. 4 | --- 5 | -------------------------------------------------------------------------------- /content/ngrx/.category: -------------------------------------------------------------------------------- 1 | --- 2 | title: NgRx 3 | summary: This category summarizes best practices regarding NgRx. 4 | --- 5 | -------------------------------------------------------------------------------- /content/ngrx/action-hygiene.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: capture events with actions, not commands 3 | --- 4 | 5 | # Problem 6 | 7 | When using NgRx, we are constantly dispatching actions to the store. These can be dispatched from different places such as components and effects. It can become really hard to figure out where all these actions originated from, why they were sent and how they are impacting the state. 8 | 9 | # Solution 10 | 11 | By changing the way we name our actions, we can more easily see where actions are being dispatched from. It allows us, by just looking at the action history, what the user was doing and the order he was doing it in. So instead of having something like this as action log: 12 | 13 | - [Users] Add User 14 | - [Users] Remove User 15 | - [Users] Update User 16 | 17 | We can have something like: 18 | 19 | - [Users Overview Page] Add User 20 | - [Users Overview Page] Remove User 21 | - [Users Detail Page] Update User 22 | 23 | This also implies that actions should not be reused. This might seem like an overkill to create a second action that will have the same result. But we have to keep in mind that at some point in time, we might need to update this code later on. This explicitness will help us in the future. 24 | 25 | This is considered good _action hygiene_. The format for action names should be `[${source}] ${event}`. 26 | 27 | # Resources 28 | 29 | - [Good action hygiene](https://www.youtube.com/watch?v=JmnsEvoy-gY) by Mike Ryan 30 | -------------------------------------------------------------------------------- /content/ngrx/actions-are-defined-as-classes.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: define actions as classes 3 | --- 4 | 5 | # Problem 6 | 7 | When we send an action to the store, we need to send an object that has a type property and optional metadata (often added as a payload property). We could recreate an object every time we want to send that but we would violate the DRY principle. 8 | 9 | One of the promises in NgRx is that it provides extreme type safety. This is something that cannot be achieved with plain objects. 10 | 11 | # Solution 12 | 13 | We want to define our actions as classes. When we use classes to define our actions, we can define them once in a separate file and reuse them everywhere. 14 | 15 | Here's an example on how to define an action. 16 | 17 | ```ts 18 | import { Action } from '@ngrx/store'; 19 | 20 | export enum AppActionTypes { 21 | APP_PAGE_LOAD_USERS = '[App Page] Load Users' 22 | } 23 | 24 | export class AppPageLoadUsers implements Action { 25 | readonly type = AppActionTypes.APP_PAGE_LOAD_USERS; 26 | } 27 | 28 | export type AppActions = AppPageLoadUsers; 29 | ``` 30 | 31 | Now, we can use the `AppPageLoadUsers` class to send this action to the store which is then passed to our reducers. 32 | 33 | **Note:** Because of the way the action is being defined, using features like string literals and union types, we can leverage discriminated unions in our reducers to have extreme type safety when it comes to typing the action's payload. See the additional resources for more info. 34 | 35 | # Resources 36 | 37 | - [Type safe actions in reducers](https://blog.strongbrew.io/type-safe-actions-in-reducers/) by Kwinten Pisman 38 | -------------------------------------------------------------------------------- /content/ngrx/do-not-put-everything-in-the-store.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: don't put everything in the store 3 | --- 4 | 5 | # Problem 6 | 7 | `@ngrx/store` (or Redux in general) provides us with a lot of great features and can be used in a lot of use cases. But sometimes this pattern can be an overkill. Implementing it means we get the downside of using Redux (a lot of extra code and complexity) without benefiting of the upsides (predictable state container and unidirectional data flow). 8 | 9 | # Solution 10 | 11 | The NgRx core team has come up with a principle called **SHARI**, that can be used as a rule of thumb on data that needs to be added to the store. 12 | 13 | - Shared: State that is shared between many components and services 14 | - Hydrated: State that needs to be persisted and hydrated across page reloads 15 | - Available: State that needs to be available when re-entering routes 16 | - Retrieved: State that needs to be retrieved with a side effect, e.g. an HTTP request 17 | - Impacted: State that is impacted by other components 18 | 19 | Try not to over-engineer your state management layer. Data is often fetched via XHR requests or is being sent over a WebSocket, and therefore is handled on the server side. Always ask yourself **when** and **why** to put some data in a client side store and keep alternatives in mind. For example, use routes to reflect applied filters on a list or use a `BehaviorSubject` in a service if you need to store some simple data, such as settings. Mike Ryan gave a very good talk on this topic: [You might not need NgRx](https://youtu.be/omnwu_etHTY) 20 | 21 | # Resources 22 | 23 | - [Reducing the Boilerplate with NgRx](https://www.youtube.com/watch?v=t3jx0EC-Y3c) by Mike Ryan and Brandon Roberts 24 | - [Do we really need @ngrx/store](https://blog.strongbrew.io/do-we-really-need-redux/) by Brecht Billiet 25 | - [Simple State Management with RxJS’s scan operator](https://juristr.com/blog/2018/10/simple-state-management-with-scan/) by Juri Strumpflohner 26 | -------------------------------------------------------------------------------- /content/ngrx/dont-store-state-that-can-be-derived.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: don't store state that can be derived 3 | --- 4 | 5 | # Problem 6 | 7 | We can use `@ngrx/store` to store data. When we store duplicate data, we are making our reducer logic way more difficult. Take a look at the following type definition for a potential state object: 8 | 9 | ```ts 10 | export interface ApplicationState { 11 | users: Array; 12 | selectedUserId: number; 13 | selectedUser: User; 14 | } 15 | ``` 16 | 17 | In this scenario, we are both storing the id of the `selectedUser` and the object of the `selectedUser`. This poses a lot of problems. First of all, when we change the selected user, we need to remember to update both references. But even worse, what if we update the user that is currently selected. Then we need to update both the reference in the `users` array and the `selectedUser`. This is easily overlooked and makes the implementation much more difficult and verbose. 18 | 19 | # Solution 20 | 21 | To fix this, we **shouldn't store state that can be derived**. If we store the `users` and the `selectedUserId`, we can easily derive which user is selected. This is logic that we can put in a selector or most probably in a composed selector. As a solution, we can define the state object as follows: 22 | 23 | ```ts 24 | export interface ApplicationState { 25 | users: Array; 26 | selectedUserId: number; 27 | } 28 | ``` 29 | 30 | Now, when we update a user, we only need to update the reference in the `users` array. 31 | -------------------------------------------------------------------------------- /content/ngrx/reducers-are-pure-functions.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: reducers are pure functions 3 | --- 4 | 5 | # Problem 6 | 7 | Reducers are responsible for updating the state in our application based on actions. It is extremely important that these are pure making them deterministic, so that every action, given the same input, will always have the same result. If they are not pure, we can no longer trust them to manage our state. 8 | 9 | # Solution 10 | 11 | By writing our reducers as pure functions, we are 100% sure that the reducer is deterministic and can be used to manage our state. A pure function has the following properties: 12 | 13 | * it does not depend on external state 14 | * it does not produce any side-effects 15 | * it does not mutate any of its inputs 16 | * if you call it over and over again, with the same arguments, you always get back the same results 17 | 18 | These properties are exactly what we need for our reducers to be deterministic and to comply with the key concepts of Redux. 19 | 20 | In addition, pure functions are very easy to test. 21 | 22 | Example of an **impure** function: 23 | 24 | ```ts 25 | const state = 1; 26 | 27 | function impureFunction(value: number) { 28 | return value + state; 29 | } 30 | 31 | // Returns 2 32 | impureFunction(1); 33 | ``` 34 | 35 | The `impureFunction` relies on external state making it non-deterministic. We have no control of the state defined outside of the function as it is visible to many other functions. 36 | 37 | Instead, we can make this function **pure** by passing in the data it needs: 38 | 39 | ```ts 40 | const state = 1; 41 | 42 | function pureFunction(value: number, otherValue: number) { 43 | return value + otherValue; 44 | } 45 | 46 | // Returns 2 47 | pureFunction(1, state); 48 | ``` 49 | 50 | Now, `pureFunction` only relies on its parameters, does not mutate its arguments and has no side-effects. 51 | 52 | The same is true for reducers. They have the following signature `(state, action) => state`. They do not rely on external state and shouldn't update its inputs. 53 | -------------------------------------------------------------------------------- /content/ngrx/use-entity-pattern.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: use the entity pattern for large collections 3 | --- 4 | 5 | # Problem 6 | 7 | In our applications, we use a lot of arrays to store our data. When we fetch a list of users and we want to show them in the view, we can loop over them really easily using the `*ngFor` directive. We can put that data in our store so that we, for example, don't have to fetch it again later, or if the list is impacted by other components. 8 | 9 | But arrays are not the most performant solution when we want to update, delete, or get a single element out of the list. All these operations have a linear time complexity of O(n). For large collections, this can have a huge impact on the performance. 10 | 11 | # Solution 12 | 13 | To make the CRUD operations more efficient we can adopt the entity pattern. This means that we will no longer store the data as an array but transform it to an object where the key is the unique identifier of the element and the value is the actual element. This is also called state normalization. 14 | 15 | Here's an example. 16 | 17 | ```ts 18 | const contacts = [ 19 | { id: 1, name: 'Dominic Elm' }, 20 | { id: 2, name: 'Kwinten Pisman' } 21 | ]; 22 | ``` 23 | 24 | We can normalize this into the following: 25 | 26 | ```ts 27 | const entities = { 28 | 1: { id: 1, name: 'Dominic Elm' }, 29 | 2: { id: 2, name: 'Kwinten Pisman' } 30 | }; 31 | ``` 32 | 33 | Now, finding, deleting, or updating an element all have a complexity of O(1). 34 | 35 | **Note:** As this is a common pattern in NgRx, there is a separate package that will help us to implement the entity pattern called `@ngrx/entity`. 36 | 37 | # Resources 38 | 39 | - [@ngrx/entity](https://github.com/ngrx/platform/tree/master/docs/entity) 40 | -------------------------------------------------------------------------------- /content/ngrx/use-selectors.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: use selectors to select data from the store 3 | --- 4 | 5 | # Problem 6 | 7 | When we want to fetch data from the store, we can use queries to get the data out. These queries are functions that have the following signature `(state: T) => K`. 8 | 9 | While retrieving state from the store, we can execute some pretty complex and potentially inefficient or blocking logic. Every time the state changes, this logic will be re-executed. 10 | 11 | Also, the plain queries we define cannot be used to compose new ones. This means that we have to define the same queries in multiple locations violating the DRY principle. 12 | 13 | # Solution 14 | 15 | `@ngrx/store` provides us with the concept of selectors. A selector helps us to build up queries that have a type signature of `(state: T): K`. The great benefit of these selectors is that they are composable. 16 | 17 | `@ngrx/store` exposes a `createSelector` function that accepts other selectors to create new ones based on these. This means that we only have to define every selector just once and reuse them in multiple places. 18 | 19 | Let's look at a simple example: 20 | 21 | ```ts 22 | // Plain Selector 23 | export const selectFeature = (state: AppState) => state.feature; 24 | 25 | // Composed Selector 26 | export const selectFeatureCount = createSelector( 27 | selectFeature, 28 | (state: FeatureState) => state.counter 29 | ); 30 | ``` 31 | 32 | Another benefit of composed selectors is that they use an optimization technique called memoization. This means that the selector logic will **not** be re-executed if the source selectors did not update. As a result, the complex logic we might execute to get data from the store is only executed when it is actually needed. 33 | 34 | # Resources 35 | 36 | * [Selectors in Ngrx](https://github.com/ngrx/platform/blob/master/docs/store/selectors.md) 37 | * [NgRx: Parameterized selectors](https://blog.angularindepth.com/ngrx-parameterized-selector-e3f610529f8) by Tim Deschryver 38 | * [Memoization](https://en.wikipedia.org/wiki/Memoization) 39 | -------------------------------------------------------------------------------- /content/performance/.category: -------------------------------------------------------------------------------- 1 | --- 2 | title: Performance 3 | summary: This category contains a list of practices which will help us boost the performance of our Angular applications. It covers different topics - from server-side pre-rendering and bundling of our applications, to runtime performance and optimization of the change detection performed by the framework. 4 | --- -------------------------------------------------------------------------------- /content/performance/lazy-load-animations.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: lazy load animation providers 3 | --- 4 | 5 | # Problem 6 | 7 | Angular uses the [animations package](https://angular.dev/guide/animations) to add motion to your application. 8 | To enable animations, add the `provideAnimations()` call to your `app.config.ts` file: 9 | 10 | ```typescript 11 | import { provideAnimations } from '@angular/platform-browser/animations'; 12 | 13 | export const appConfig: ApplicationConfig = { 14 | providers: [ 15 | provideRouter(routes), 16 | provideAnimations() 17 | ] 18 | }; 19 | ``` 20 | 21 | However, this will eagerly load the animations package with the main bundle, which can slow down the initial load time of your application. 22 | The unminified size of the animations package is around **65kb**, which is not a lot, but it can add up with other packages. 23 | 24 | # Solution 25 | 26 | Starting with Angular **17.0.0**, you can now lazy load the animation package. You can change the way you provide the animations package in favor of `provideAnimationsAsync()`: 27 | 28 | ```typescript 29 | import { provideAnimationsAsync } from '@angular/platform-browser/animations'; 30 | 31 | export const appConfig: ApplicationConfig = { 32 | providers: [ 33 | provideRouter(routes), 34 | provideAnimationsAsync() 35 | ] 36 | }; 37 | ``` 38 | 39 | **Note:** This behavior will only work if you use the animations in lazy loaded components. Otherwise, the animations will be eagerly loaded with the main bundle. 40 | 41 | # Resources 42 | 43 | - [Lazy-loading Angular's animation module](https://riegler.fr/blog/2023-10-04-animations-async) by [Matthieu Riegler](https://twitter.com/Jean__Meche) 44 | -------------------------------------------------------------------------------- /content/performance/track-by-option-on-ng-for.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: use trackBy option on *ngFor 3 | source: https://github.com/mgechev/angular-performance-checklist#use-trackby-option-for-ngfor-directive 4 | author: 5 | name: Minko Gechev 6 | url: https://twitter.com/mgechev 7 | --- 8 | 9 | # Problem 10 | 11 | The `*ngFor` directive is used for rendering a collection. By default `*ngFor` identifies object uniqueness by reference. 12 | 13 | Which means when developer breaks reference to object during updating item's content Angular treats it as removal of the old object and addition of the new object. This effects in destroying old DOM node in the list and adding new DOM node on its place. 14 | 15 | # Solution 16 | 17 | We can provide a hint for angular how to identify object uniqueness: custom tracking function as the `trackBy` option for the `*ngFor` directive. Tracking function takes two arguments: index and item. Angular uses the value returned from tracking function to track items identity. It is very common to use ID of the particular record as the unique key. 18 | 19 | ```ts 20 | @Component({ 21 | selector: 'yt-feed', 22 | template: ` 23 |

Your video feed

24 | 25 | ` 26 | }) 27 | export class YtFeedComponent { 28 | feed = [ 29 | { 30 | id: 3849, // note "id" field, we refer to it in "trackById" function 31 | title: 'Angular in 60 minutes', 32 | url: 'http://youtube.com/ng2-in-60-min', 33 | likes: '29345' 34 | } 35 | // ... 36 | ]; 37 | 38 | trackById(index, item) { 39 | return item.id; 40 | } 41 | } 42 | ``` 43 | 44 | # Resources 45 | 46 | - ["NgFor directive"](https://angular.io/docs/ts/latest/api/common/index/NgFor-directive.html) - Official documentation for `*ngFor` 47 | - ["Angular  —  Improve performance with trackBy"](https://netbasal.com/angular-2-improve-performance-with-trackby-cc147b5104e5) - By Netanel Basal 48 | -------------------------------------------------------------------------------- /content/performance/use-angular-new-cache.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: use persistent disk cache 3 | --- 4 | 5 | ## Problem 6 | 7 | The build process for large Angular applications can take some time to complete, and having to rebuild the application frequently will increase the time you spend waiting for it to finish. 8 | 9 | ## Solution 10 | 11 | **This only applies to Angular v13+** 12 | 13 | Use the new persistent build cache. This results in up to 68% improvement in build speed and more ergonomic options. 14 | 15 | New projects using Angular CLI v13+ have the persistent disk cache already enabled by default but, if you're updating your app from previous versions, you need to add the following to you `angular.json` file: 16 | 17 | ```json 18 | { 19 | "$schema": "...", 20 | "cli": { 21 | "cache": { 22 | "enabled": true, 23 | "path": ".cache", 24 | "environment": "all" 25 | } 26 | } 27 | ... 28 | } 29 | ``` 30 | 31 | 32 | # Resources 33 | 34 | - [Official documentation for Angular's persistent disk cache](https://angular.io/cli/cache) 35 | - [Angular v13 is now Available](https://blog.angular.io/angular-v13-is-now-available-cce66f7bc296) 36 | -------------------------------------------------------------------------------- /content/performance/use-aot-compilation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: use AOT compilation for prod builds 3 | source: https://github.com/mgechev/angular-performance-checklist 4 | author: 5 | name: Minko Gechev 6 | url: https://twitter.com/mgechev 7 | --- 8 | 9 | **Note**: Starting from Angular v9, the AOT compiler is the default compiler, so if you're using Angular v9+ you don't need to take any action. 10 | 11 | # Problem 12 | 13 | The biggest part of the code that we ship to the browser when we use Angular is the compiler. The compiler is needed to transform our HTML-like templates to Javascript. This is doesn't only has a negative impact on the bundle size but also on the performance as this process is computationally expensive. 14 | 15 | # Solution 16 | 17 | We can avoid shipping the compiler by performing the compile step as part of the build step. We can achieve this by using AOT. 18 | 19 | AoT can be helpful not only for achieving more efficient bundling by performing tree-shaking, but also for improving the runtime performance of our applications. The alternative of AoT is Just-in-Time compilation (JiT) which is performed runtime, therefore we can reduce the amount of computations required for rendering of our application by performing the compilation as part of our build process. 20 | 21 | # Tooling 22 | 23 | * [@angular/compiler-cli](https://github.com/angular/angular/tree/master/packages/compiler-cli) - a drop-in replacement for [tsc](https://www.npmjs.com/package/typescript) which statically analyzes our application and emits TypeScript/JavaScript for the component's templates. 24 | * [angular2-seed](https://github.com/mgechev/angular-seed) - a starter project which includes support for AoT compilation. 25 | * [Angular CLI](https://cli.angular.io/) Using the ng serve --prod 26 | 27 | # Resources 28 | 29 | * [Ahead-of-Time Compilation in Angular](http://blog.mgechev.com/2016/08/14/ahead-of-time-compilation-angular-offline-precompilation/) by Minko Gechev 30 | -------------------------------------------------------------------------------- /content/performance/use-on-push-cd-strategy.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: use onPush CD strategy on dumb components 3 | --- 4 | 5 | # Problem 6 | 7 | Change detection (CD) in Angular is performed from top to bottom. This means that everything is only checked once. This is a huge difference compared to AngularJS where change detection was performed in cycles until everything was considered stable. 8 | 9 | However, it still means that everything is checked every time CD is triggered, even things that we know for sure have not changed. 10 | 11 | # Solution 12 | 13 | Angular components can use different strategies for change detection. They can either use `Default` or `OnPush`. 14 | 15 | The default strategy means that the component will be checked during every CD cycle. 16 | 17 | With the `OnPush` strategy, the component (and all of its children!) will only be checked if one of its `@Input`s have changed (reference check) **or** if an event was triggered within the component. 18 | 19 | This means that we can easily tell Angular to not run CD for huge parts of our component tree, speeding up CD a lot! We can enable the `OnPush` strategy like this: 20 | 21 | ```ts 22 | @Component({ 23 | ... 24 | changeDetection: ChangeDetectionStrategy.OnPush 25 | }) 26 | ``` 27 | 28 | **Note 1:** This also implies that we should always try to work immutable. Let's say that we add an element to an array by mutating the array and we pass the array to a component to visualise it. If we apply the `OnPush` strategy for this component, we wouldn't see the changes in the UI. Angular will not check if the array's content has changed. It will only check the reference. As the reference has not changed, it means that CD will not run for that component and the view will not be updated. 29 | 30 | **Note 2:** This also means that, every component we apply this strategy to, has to be dumb. If the component fetches its own data, we cannot have the `OnPush` strategy. Because in that case, the component's `@Input`s wouldn't be the only reason to run CD, but also data being fetched. 31 | 32 | **Note 3:** When using the `async` pipe, it will automatically call `markForCheck` under the hood. This marks the path to that component as "to be checked". When the next CD cycle kicks in, the path to that component is not disabled and the view will be updated. 33 | 34 | # Resources 35 | 36 | - [Angular change detection explained](https://blog.thoughtram.io/angular/2016/02/22/angular-2-change-detection-explained.html) by Pascal Precht 37 | - [Everything you need to know about change detection in Angular](https://blog.angularindepth.com/everything-you-need-to-know-about-change-detection-in-angular-8006c51d206f) by Maxim Koretskyi 38 | -------------------------------------------------------------------------------- /content/router/.category: -------------------------------------------------------------------------------- 1 | --- 2 | title: Router 3 | summary: This category summarizes best practices regarding routing. 4 | --- 5 | -------------------------------------------------------------------------------- /content/router/add-404-route.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: add 404 fallback route 3 | --- 4 | 5 | # Problem 6 | 7 | There are multiple reasons why we need to make sure that we have a fallback for when a page is not found. 8 | 9 | - Our users are humans. Humans are quite error-prone. This means that they are likely to mistype a url at some point. 10 | - Over time, our applications will change. Users might bookmark urls for pages which are not supported anymore. 11 | 12 | # Solution 13 | 14 | Every application should define a 404 route. This is a route to be shown whenever the user tries to go to a non existing route. 15 | 16 | ```ts 17 | [ 18 | ..., 19 | { path: '404', component: NotFoundComponent }, 20 | { path: '**', redirectTo: '/404' }, 21 | ] 22 | ``` 23 | 24 | The last route definition uses a wildcard as a path. Since the Angular router will render the first definitions that matches, be sure to always put this route definition last! 25 | -------------------------------------------------------------------------------- /content/router/default-route.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: make sure default route is defined 3 | --- 4 | 5 | # Problem 6 | 7 | When users type in the url for your application, they do not know all the routes of our application. We need to make sure that we always have a landing page or a redirect set up. 8 | 9 | # Solution 10 | 11 | Every application should define a default route. This is the route that will be used whenever the user goes to `/`. 12 | 13 | ```ts 14 | [ 15 | { path: '', redirectTo: '/heroes', pathMatch: 'full' }, 16 | ... 17 | ] 18 | ``` 19 | 20 | Note that `pathMatch: full` should be used to make sure that this route definitions is only triggered when the user is going to `/`. 21 | -------------------------------------------------------------------------------- /content/router/lazy-load-feature-modules.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: lazy load feature modules 3 | --- 4 | 5 | # Problem 6 | 7 | When working with SPAs, we need to ship an entire application to the client. The more bytes we need to ship, the slower it will be to load but also to parse. This will greatly influence the TTI (Time to Interactive) of our application. 8 | 9 | We are shipping way too much JavaScript to the client. 10 | 11 | # Solution 12 | 13 | Angular provides us with a module system. When we break up our application in feature modules, we can leverage this to only load the modules that are needed for the first page render. The other modules can be lazily loaded only when they are needed. We can do this, when the user requests them or via a more sophisticated preloading strategy. 14 | 15 | The following module is **not** using lazy loading to load the `UsersModule`. 16 | 17 | ```ts 18 | // app.routing.ts 19 | const routes: Routes = [ 20 | ... 21 | {path: 'users', component: UsersComponent} 22 | ... 23 | ]; 24 | 25 | // app.module.ts 26 | @NgModule({ 27 | declarations: [AppComponent], 28 | imports: [ 29 | ... 30 | UsersModule, 31 | RouterModule.forRoot(routes), 32 | ], 33 | bootstrap: [AppComponent] 34 | }) 35 | export class AppModule {} 36 | ``` 37 | 38 | This means that the `UsersModule` will be added to the main bundle. The main bundle contains all the code that is needed for the first page load. As the `UsersModule` is only needed when the user specifically navigates to the `/users` page, it doesn't make sense to load it up front. Let's leverage lazy loading to fix this. 39 | 40 | ```ts 41 | // app.routing.ts 42 | const routes: Routes = [ 43 | ... 44 | { 45 | path: 'users', 46 | loadChildren: () => import('../users/usersModule').then(m => m.UsersModule) 47 | } 48 | ... 49 | ]; 50 | 51 | // app.module.ts 52 | @NgModule({ 53 | declarations: [AppComponent], 54 | imports: [ 55 | ... 56 | RouterModule.forRoot(routes), 57 | ], 58 | bootstrap: [AppComponent] 59 | }) 60 | export class AppModule {} 61 | ``` 62 | 63 | We updated the `/users` route to use the `loadChildren` property. This uses the standard dynamic import syntax. 64 | Called as a function, the import returns a promise which loads the module. 65 | 66 | Also note that we no longer add the `UsersModule` to the imports of the `AppModule`. This is important because otherwise lazy loading wouldn't work as expected. If the `UsersModule` was referenced by the `AppModule` the code for that module would be added to the main bundle. 67 | 68 | By using `loadChildren` and removing the module import from the `AppModule`, the `UsersModule` will be packaged in its own bundle and will only be loaded when the user navigates to `/users`. 69 | 70 | 71 | # Resources 72 | 73 | [The cost of JavaScript](https://medium.com/@addyosmani/the-cost-of-javascript-in-2018-7d8950fbb5d4) by Addy Osmani 74 | -------------------------------------------------------------------------------- /content/router/protect-restricted-pages-with-guards.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: protect restricted pages with guards 3 | --- 4 | 5 | # Problem 6 | 7 | Users should not be able to access pages that they don't have access to. We could hide the menu item so they could not navigate to it by clicking on that menu item but this means they can still manually type in the url to go to that page. We need some way to protect certain routes. 8 | 9 | # Solution 10 | 11 | We can use guards to allow or deny route changes. Every part of your application that should be limited to users with certain roles should be protected with guards. 12 | 13 | We can create a guard by creating a service that implements the `CanActivate` interface to avoid users going to a certain component or a `canLoad` interface to avoid entire modules to be loaded. 14 | 15 | The following example shows how to use a `canActivate` guard. 16 | 17 | ```ts 18 | @Injectable() 19 | export class UserHasRoleGuard implements CanActivate { 20 | constructor(private activatedRoute) {} 21 | 22 | canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { 23 | // return an Observable | Promise | boolean; 24 | } 25 | } 26 | ``` 27 | 28 | We can now use it in our route definitions: 29 | 30 | ```ts 31 | [ 32 | ..., 33 | { path: 'users', component: UsersComponent, canActivate: [UserHasRoleGuard] }, 34 | ] 35 | ``` 36 | 37 | You can see that the `canActivate` property on the route definition takes an array. This means we can add multiple guards which will be called chronologically in the order they are defined. 38 | -------------------------------------------------------------------------------- /content/router/use-preloading-strategy.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: use preloading strategy 3 | --- 4 | 5 | # Problem 6 | 7 | When we use lazy loading, we are only loading the code that is needed for the first page render. Modules that are not yet needed are not loaded. 8 | 9 | By default, the next modules will be loaded whenever the user requests them. This is not ideal in every scenario because it means that whenever a user requests a url, they have to wait until the module is loaded and parsed. 10 | 11 | # Solution 12 | 13 | Depending on the application you are building and whether you have to deal with low bandwidth, it might be better to use a different strategy other than loading modules on request. 14 | 15 | When working on an application that will be used only on a steady WiFi connection, it makes sense to preload all of the modules when the CPU is idle. If our application will be used mainly on a slow 3G connection, we should only load the modules that are most likely used. 16 | 17 | ## Load all modules after first page render 18 | 19 | One strategy provided by the Angular team is to preload all modules when the CPU becomes idle. This means that, after the first page render, the modules will all be loaded in the background. 20 | 21 | ```ts 22 | @NgModule({ 23 | imports: [ 24 | ...modules, 25 | RouterModule.forRoot(routes, { 26 | preloadingStrategy: PreloadAllModules 27 | }) 28 | ], 29 | ... 30 | }) 31 | export class AppModule {} 32 | ``` 33 | 34 | ## Defining a custom preloading strategy 35 | 36 | If our users can be both on mobile and on WiFi, it might make sense to only preload the modules if they are on WiFi. To do this, we can implement a custom preloading strategy. 37 | 38 | A custom preloading strategy is implemented as a class and implements the `PreloadingStrategy` interface. 39 | 40 | ```ts 41 | // custom.preloading-strategy.ts 42 | export class MyCustomPreloadingStrategy implements PreloadingStrategy { 43 | preload(route: Route, load: Function): Observable { 44 | // Implement your strategy here 45 | } 46 | } 47 | 48 | // app.module.ts 49 | @NgModule({ 50 | imports: [ 51 | ...modules, 52 | // Custom Preloading Strategy 53 | RouterModule.forRoot(routes, { preloadingStrategy: MyCustomPreloadingStrategy }); 54 | ], 55 | ... 56 | }) 57 | export class AppModule {} 58 | ``` 59 | 60 | ## Data-driven bundling 61 | 62 | Another way is to use [Guess.js](https://github.com/guess-js/guess), a data-driven bundling approach. The goal with Guess.js is to minimize the bundle layout configuration, make it data-driven, and much more accurate. Guess.js will figure out which bundles to be combined together and what pre-fetching mechanism to be used. 63 | 64 | Guess.js can also be used with the Angular CLI. Here's an [example](https://github.com/mgechev/guess-js-angular-demo). 65 | 66 | # Resources 67 | 68 | - [Angular Router: Preloading Modules](https://vsavkin.com/angular-router-preloading-modules-ba3c75e424cb) by Victor Savkin 69 | - [Introducing Guess.js - a toolkit for enabling data-driven user-experiences on the Web](https://blog.mgechev.com/2018/05/09/introducing-guess-js-data-driven-user-experiences-web/) by Minko Gechev 70 | -------------------------------------------------------------------------------- /content/rxjs/.category: -------------------------------------------------------------------------------- 1 | --- 2 | title: RxJS 3 | summary: This category summarizes best practices regarding RxJS. 4 | --- -------------------------------------------------------------------------------- /content/rxjs/avoid-nested-subscriptions.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: avoid nested subscriptions 3 | --- 4 | 5 | # Problem 6 | 7 | Sometimes we need to aggregate values from multiple observables or deal with nested observables to perform an action. In that case, you could subscribe to an observable in the subscribe block of another observable. This makes handling subscriptions way more difficult and feels like callback hell all over again. 8 | 9 | # Solution 10 | 11 | For aggregating values or dealing with nested observables we can use one of the combination or flattening operators. 12 | 13 | Let's consider the following example: In an e-commerce system we are fetching a product and based on that product we want to fetch similar ones. 14 | 15 | A naive solution could look like this: 16 | 17 | ```ts 18 | fetchProduct(1).subscribe(product => { 19 | fetchSimilarProducts(product).subscribe(similarProducts => { 20 | ... 21 | }); 22 | }); 23 | ``` 24 | 25 | We first fetch the product and once the request is resolved we fetch similar products inside the subscribe block of the first, most outer observable. 26 | 27 | This is considered to be an anti-pattern or code smell for the following reasons: 28 | - 👹 it brings us back to callback hell, 29 | - 💔 it breaks Reactive Programming, 30 | - 🐢 it breaks observables laziness, 31 | - 💥 it doesn’t help with subscription management, 32 | - 🤢 it's ugly anyway. 33 | 34 | Instead we can use one of the flattening operators to get rid of this code smell and solve it more elegantly: 35 | 36 | ```ts 37 | fetchProduct(1).pipe( 38 | switchMap(product => fetchSimilarProducts(product)) 39 | ).subscribe(...) 40 | ``` 41 | 42 | Here's another example: A simple list view where the user can filter and paginate the list. Whenever the user goes to the next page we also need to take into account the filter: 43 | 44 | Naive solution: 45 | 46 | ```ts 47 | nextPage$.subscribe(page => { 48 | filter$.pipe(take(1)).subscribe(filter => { 49 | fetchData(page, filter).subscribe(items => { 50 | this.items = items; 51 | }); 52 | }); 53 | }); 54 | ``` 55 | 56 | That's again not the most idiomatic solution because we have introduced several nested subscriptions. 57 | 58 | Let's fix this with a combination and flattening operator: 59 | 60 | ```ts 61 | nextPage$ 62 | .pipe( 63 | withLatestFrom(filter$), 64 | switchMap(([page, filter]) => fetchData(page, filter)) 65 | ) 66 | .subscribe(items => { 67 | this.items = items; 68 | }); 69 | ``` 70 | 71 | Or when we want to listen for changes in both the `nextPage$` and the `filter$` we could use `combineLatest`: 72 | 73 | ```ts 74 | combineLatest(nextPage$, filter$) 75 | .pipe(switchMap(([page, filter]) => fetchData(page, filter))) 76 | .subscribe(items => { 77 | this.items = items; 78 | }); 79 | ``` 80 | 81 | Both solutions are much more readable and they also reduces the complexity of our code. 82 | 83 | Here are some very common combination and flattening operators: 84 | 85 | **Combination Operators**: 86 | 87 | - `combineLatest` 88 | - `withLatestFrom` 89 | - `merge` 90 | - `concat` 91 | - `zip` 92 | - `forkJoin` 93 | - `pairwise` 94 | - `startWith` 95 | 96 | **Flattening Operators**: 97 | 98 | - `switchMap` 99 | - `mergeMap` 100 | - `concatMap` 101 | - `exhaustMap` 102 | -------------------------------------------------------------------------------- /content/rxjs/pipeable-operators.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: use pipeable operators 3 | --- 4 | 5 | # Problem 6 | 7 | Since the release of RxJS 6, patch operators have been removed. This means that we can no longer use them. 8 | 9 | This means the following is no longer possible: 10 | 11 | ```ts 12 | import 'rxjs/add/observable/interval'; 13 | import 'rxjs/add/operator/map'; 14 | import 'rxjs/add/operator/filter'; 15 | import 'rxjs/add/operator/switchMap'; 16 | 17 | Observable.interval(1000) 18 | .filter(x => x % 2 === 0) 19 | .map(x => x*2) 20 | .switchMap(x => mapToObservable(x)) 21 | ``` 22 | 23 | # Solution 24 | 25 | Instead, we should be using pipeable operators. 26 | 27 | ```ts 28 | import { interval } from 'rxjs'; 29 | import { filter, map, switchMap } from 'rxjs/operators'; 30 | 31 | Observable.interval(1000) 32 | .pipe( 33 | filter(x => x % 2 === 0), 34 | map(x => x*2), 35 | switchMap(x => mapToObservable(x)), 36 | ); 37 | ``` 38 | 39 | Even if you are using the older versions of RxJS, all new code should be written using pipeable operators. 40 | 41 | ## Upgrading 42 | 43 | If you have a lot of code written using patch operators, you can use a script released written by Google engineers to do this upgrade automatically for you. You can find the script and how to use it in the [rxjs-tslint](https://github.com/ReactiveX/rxjs-tslint#migration-to-rxjs-6) package. 44 | -------------------------------------------------------------------------------- /content/rxjs/takeuntil-operator.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: don't manage subscriptions imperatively 3 | --- 4 | 5 | # Problem 6 | 7 | When we subscribe to an Observable, we also need to unsubscribe to clean up its resources. Unsubscribing can be done like this: 8 | 9 | ```ts 10 | // hold a reference to the subscription object 11 | const subscription = interval(1000).subscribe(console.log); 12 | 13 | // use the subscription object to kill the subscription 14 | subscription.unsubscribe(); 15 | ``` 16 | 17 | But if we have multiple subscriptions, we need to manage all of them. We could do this in an array but this gets extremely verbose. We want to avoid having to do this imperatively. 18 | 19 | # Solution 20 | 21 | RxJS provides us with the `takeUntil` operator, and a few other conditional operators. This operator will mirror the source observable until a certain event happens. In most cases, we want to stop listening to Observables when the component gets destroyed. This allows us to write something like this: 22 | 23 | ```ts 24 | @Component({...}) 25 | export class SomeComponent implements OnInit, OnDestroy { 26 | private destroy$ = new Subject(); 27 | users: Array; 28 | 29 | constructor(private usersService: UsersService) {} 30 | 31 | ngOnInit() { 32 | // long-living stream of users 33 | this.usersService.getUsers() 34 | .pipe( 35 | takeUntil(this.destroy$) 36 | ) 37 | .subscribe( 38 | users => this.users = users; 39 | ); 40 | } 41 | 42 | ngOnDestroy() { 43 | this.destroy$.next(); 44 | } 45 | } 46 | ``` 47 | 48 | We create a `Subject` called `destroy$` and when the `ngOnDestroy` hook is called, we `next` a value onto the subject. 49 | 50 | The manual subscribe we defined in the `ngOnInit` hook uses the `takeUntil` operator in combination with our subject. This means that the subscription will remain active **until** `destroy$` emits a value. After that, it will unsubscribe from the source stream and complete it. 51 | 52 | This is a lot better than imperatively handling the subscriptions. 53 | 54 | **Note:** Using the `async` pipe is even better as we don't have to think about this at all. It will hook into the destroy lifecycle hook and unsubscribe for us. 55 | 56 | # Resources 57 | 58 | * [RxJS: don't unsubscribe](https://medium.com/@benlesh/rxjs-dont-unsubscribe-6753ed4fda87) by Ben Lesh 59 | * [RxJS: Avoiding takeUntil leaks](https://blog.angularindepth.com/rxjs-avoiding-takeuntil-leaks-fb5182d047ef) by Nicholas Jamieson 60 | -------------------------------------------------------------------------------- /content/rxjs/use-async-pipe.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: use the async pipe 3 | --- 4 | 5 | # Problem 6 | 7 | In Angular, everything async is handled by Observables and they are triggered by subscribing to them. Whenever we do so, it is very important to also unsubscribe. Unsubscribing will clean up the resources being used by this stream. If we neglect to do this, we might introduce memory leaks. 8 | 9 | If we manually subscribe, it also means that we have to manually unsubscribe. This is something that is easily forgotten. 10 | 11 | # Solution 12 | 13 | Instead of manually subscribing, we can use the `async` pipe provided by Angular. 14 | 15 | The async pipe will: 16 | 17 | - subscribe to an Observable 18 | - unsubscribe from the Observable when the component is destroyed by hooking into the `onDestroy` hook 19 | - mark this component as "to be checked" for the next change detection cycle 20 | 21 | Using the `async` pipe as much as possible will make sure all the resources are cleaned up properly and reduce the likelihood of memory leaks. 22 | 23 | Here's an example: 24 | 25 | ```ts 26 | @Component({ 27 | template: `{{data$ | async}}`, 28 | ... 29 | }) 30 | export class SomeComponent { 31 | data$ = interval(1000); 32 | } 33 | ``` 34 | 35 | Here, we set up an `interval` that emits a value every second. This is a long-living Observable and because we are using the `async` pipe, the resource (subscription) is cleaned up when the component is destroyed. 36 | 37 | # Resources 38 | 39 | [Three things you didn't know about the async pipe](https://blog.thoughtram.io/angular/2017/02/27/three-things-you-didnt-know-about-the-async-pipe.html) by Christoph Burgdorf 40 | -------------------------------------------------------------------------------- /content/rxjs/use-ngifas.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: use ngIfAs to subscribe only once 3 | --- 4 | 5 | # Problem 6 | 7 | An Observable is lazy and unicast by default. This means that for every subscription, the Observable is executed. If the Observable is triggering a backend call when subscribed to, the following code will trigger two backend calls. 8 | 9 | ```ts 10 | @Component({ 11 | 12 | 13 | }) 14 | export class SomeComponent implements OnInit, OnDestroy { 15 | data$; 16 | ... 17 | } 18 | ``` 19 | 20 | This is not the intended behavior. We want to fetch the data only once. 21 | 22 | # Solution 23 | 24 | We can fix this problem in multiple ways, either with the `ngIfAs` syntax, or by making our Observable hot. 25 | 26 | ## ngIfAs syntax 27 | 28 | We can use an `*ngIf` to hide an element. We can also leverage it to _unpack_ an observable and bind the value to a variable. We can then use that variable inside of the template. 29 | 30 | ```ts 31 | @Component({ 32 |
33 | 34 | 35 |
36 | }) 37 | export class SomeComponent implements OnInit, OnDestroy { 38 | data$; 39 | ... 40 | } 41 | ``` 42 | 43 | By wrapping the components with a div that hides the element if no data is present, we were able to reduce the number of subscriptions from 2 to 1. This means that we only have a single subscription. Using the `as` syntax, we can also _catch_ the event from that observable and bind it to a variable and use that variable to pass it to our components. 44 | 45 | Better yet, if we don't want to introduce another level of nesting, we can use the `` element. This elements lets us group sibling elements under an invisible container element that is not rendered. 46 | 47 | Here's what the code from above looks like using ``: 48 | 49 | ```ts 50 | @Component({ 51 | 52 | 53 | 54 | 55 | }) 56 | export class SomeComponent implements OnInit, OnDestroy { 57 | data$; 58 | ... 59 | } 60 | ``` 61 | 62 | Now, the template will be rendered as: 63 | 64 | ```html 65 | 66 | 67 | ``` 68 | 69 | ## Make the Observable hot 70 | 71 | We can also make our Observable hot so that the Observable will no longer trigger a backend call with every subscription. A hot Observable will share the underlying subscription so the source Observable is only executed once. 72 | 73 | This fixes our problem because it means it doesn't matter anymore if we have multiple subscriptions. 74 | 75 | To do this, we can use for example the `shareReplay` operator. 76 | 77 | ```ts 78 | @Component({ 79 | 80 | 81 | }) 82 | export class SomeComponent implements OnInit, OnDestroy { 83 | sharedData$ = data$.pipe( 84 | shareReplay({ bufferSize: 1, refCount: true }) 85 | ); 86 | ... 87 | } 88 | ``` 89 | 90 | > Note: we should specify `refCount: true` to prevent possible memory leaks. 91 | 92 | # Resources 93 | 94 | - [Multicasting operators in RxJS](https://blog.strongbrew.io/multicasting-operators-in-rxjs/) by Kwinten Pisman 95 | -------------------------------------------------------------------------------- /content/rxjs/use-switchMap-only-when-you-need-cancellation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: use switchMap only when you need cancellation 3 | --- 4 | 5 | # Problem 6 | 7 | In certain scenarios, using the wrong flattening operators from RxJS can result in unwanted behavior and race conditions. 8 | 9 | # Solution 10 | 11 | For example, in an e-commerce application users can add and remove items from their shopping cart. The logic for removing an item could look like this: 12 | 13 | ```ts 14 | removeItemButtonClick.pipe( 15 | switchMap(item => this.backend.removeFromCart(item.id)) 16 | ) 17 | ``` 18 | 19 | Whenever the user clicks on the button to remove a certain item from the shopping cart, this action is forwarded to the application's backend. Most of the times this works as expected. However, the behavior depends on how rapidly items are removed from the cart. For example, either all items could be removed, or only some of them. 20 | 21 | In this example, `switchMap` is not the right operator because for every new action it will abort / cancel the previous action. This behavior makes `switchMap` unsafe for create, update and delete actions. 22 | 23 | There are several other flattening operators that may be more appropriate: 24 | 25 | - `mergeMap`: concurrently handle all emissions 26 | - `concatMap`: handle emissions one after the other 27 | - `exhaustMap`: when you want to cancel new emissions while processing a previous emission 28 | 29 | So we could fix the problem from above by `mergeMap`: 30 | 31 | ```ts 32 | removeItemButtonClick.pipe( 33 | mergeMap(item => this.backend.removeFromCart(item.id)) 34 | ) 35 | ``` 36 | 37 | If the order is important we could use `concatMap`. 38 | 39 | For more information see the article from [Nicholas Jamieson](https://twitter.com/ncjamieson) listed below. 40 | 41 | # Resources 42 | 43 | - [RxJS: Avoiding switchMap-Related Bugs](https://blog.angularindepth.com/switchmap-bugs-b6de69155524) by Nicholas Jamieson 44 | -------------------------------------------------------------------------------- /content/tooling/.category: -------------------------------------------------------------------------------- 1 | --- 2 | title: Tooling 3 | summary: This category summarizes best practices regarding tooling. 4 | --- 5 | -------------------------------------------------------------------------------- /content/tooling/compodoc.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: use compodoc for documentation 3 | optional: true 4 | --- 5 | 6 | # Problem 7 | 8 | On boarding of new developers in your project can be quite difficult. Especially if the applications are getting bigger and bigger. 9 | 10 | When the project becomes really big, even for developers that have been working on it for a long time, keeping an overview is not that easy. 11 | 12 | # Solution 13 | 14 | Documentation for our code is the solution to this problem. Of course, everyone knows that writing documentation is hard, boring and the documentation itself gets out of date quite quickly. 15 | 16 | To fix this, we can use compodoc to generate documentation from our code. This means that it doesn't take any time to write and it can never get out of date as it is generated from the existing code at all times. 17 | 18 | # Resources 19 | 20 | * [Compodoc](https://compodoc.app/) 21 | -------------------------------------------------------------------------------- /content/tooling/use-angular-cli.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: use @angular/cli 3 | optional: true 4 | --- 5 | 6 | # Problem 7 | 8 | When we ship our code to the browsers, our code needs to be optimised, bundled, minified, uglified and much more. There are also other steps involved in a proper build process. This can be quite a difficult and cumbersome task to do and especially to maintain. 9 | 10 | # Solution 11 | 12 | To fix this, we should use the `@angular/cli` to take over the build process. The Angular CLI simplifies the development of your Angular applications drastically. Aside from the build process, the CLI also provides you with code scaffolding which you can use to easily generate entire projects, components and much more. 13 | 14 | The CLI abstracts everything for us. This also means that when there are better solutions available to for example perform the build process, and if they integrate this, we get this update without putting any effort in. Since version 6, it also possible to hook into the entire build process via builders. 15 | 16 | # Resources 17 | 18 | - [Angular CLI](https://cli.angular.io/) 19 | - [Angular CLI under the hood - builders demystified](https://medium.com/dailyjs/angular-cli-6-under-the-hood-builders-demystified-f0690ebcf01) by Evgeny Barabanov 20 | -------------------------------------------------------------------------------- /content/tooling/use-angular-dev-tools.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: use Angular DevTools 3 | author: 4 | name: Maciej Wójcik 5 | url: https://twitter.com/maciej_wwojcik 6 | --- 7 | 8 | # Problem 9 | 10 | Understanding complex and nested component trees can be challenging, especially if you are interested in the DOM view, the logic, and data layer. In addition, it's not easy to debug Angular components using standard debugging tools because they are not adapted very well for Angular-specific features. 11 | 12 | # Solution 13 | 14 | Angular DevTools (available in Chrome) is an extension to Chrome DevTools. It provides a set of helpful features to debug your Angular application. 15 | 16 | You can: 17 | 18 | - explore component or directive structure, check its current state or even edit it 19 | - profile your application to check for performance bottlenecks, including change detection execution information 20 | 21 | # Resources 22 | 23 | - [Angular DevTools for Chrome](https://chrome.google.com/webstore/detail/angular-devtools/ienfalfjdbdpebioblfackkekamfmbnh) 24 | - [Introduction to Angular DevTools](https://blog.angular.io/introducing-angular-devtools-2d59ff4cf62f) by Minko Gechev 25 | -------------------------------------------------------------------------------- /content/tooling/use-prettier.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: use prettier for code formatting 3 | --- 4 | 5 | # Problem 6 | 7 | Whenever we write code, we want this code to be formatted in a standardised way. This poses two problems. 8 | 9 | - We need to align everyone in the team to agree with the same standards. 10 | - We need to get all of their IDE's/editors aligned as well. This can be extremely difficult. 11 | 12 | # Solution 13 | 14 | Prettier is an opinionated code formatter that can fix both of these problems. It imposes a standard way of formatting and it has a CLI that makes sure the formatting happens the same way on all environments. Adding Prettier and running it as a pre-commit hook will make sure only formatted code can be checked in. 15 | 16 | # Resources 17 | 18 | - [Prettier](https://prettier.io/) 19 | - [Add prettier to Angular CLI schematic](https://github.com/schuchard/prettier-schematic) 20 | -------------------------------------------------------------------------------- /content/typescript/.category: -------------------------------------------------------------------------------- 1 | --- 2 | title: Typescript 3 | summary: This category summarizes best practices regarding Typescript. 4 | --- 5 | -------------------------------------------------------------------------------- /content/typescript/avoid-using-any.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: avoid using any 3 | --- 4 | 5 | # Problem 6 | 7 | TypeScript allows us to write code that is statically type checked. This provides huge benefits. It helps us during development with auto completion, it simplifies working with third party libraries, helps us to refactor our code, spots errors during development that would have otherwise been runtime errors and much more. 8 | 9 | If we start using the `any` type, we lose all these benefits. 10 | 11 | # Solution 12 | 13 | The solution is to avoid the `any` type wherever possible in our code and we should define proper types instead. 14 | 15 | Here's a classic example: 16 | 17 | ```ts 18 | var x: number = 10; 19 | var y: any = "a"; 20 | x = y; 21 | ``` 22 | 23 | See how we assign a string to `x` although `x` is defined as a `number`? That's a nightmare. 24 | 25 | Let's look at another example: 26 | 27 | ```ts 28 | const x: number = 10; 29 | const y: any = 'a'; 30 | const z = x + y; 31 | 32 | // Prints out 10a 33 | console.log(z); 34 | ``` 35 | 36 | In the last example we add `x` and `y` together, and typing `y` as `any`, TypeScript cannot really help us and avoid this bug at development time. Basically, we end up with a concatenation and we’re essentially back in JavaScriptLand. 37 | 38 | ## Compiler Options 39 | 40 | Set the compiler `–noImplicitAny` flag. With this flag enabled the compiler will complain if anything has an implicit type of `any`. 41 | 42 | ## 3rd party libraries 43 | 44 | When working with 3rd party libraries that are written in vanilla JavaScript, we most likely don't have type information available. Luckily there is an initiative to create type definitions for those libraries. If it exists, you can find it by installing the type package via `yarn add --dev @types/${library-name}`. 45 | 46 | If this does not exist yet, you can create one yourself. Contributions are always welcome and appreciated. 47 | 48 | # Best Practices 49 | 50 | Using types is not just about enhancing your coding experience. 51 | Starting a feature by defining the types of the data you are going to work with can help you to better understand it and might even lead to a better design. 52 | -------------------------------------------------------------------------------- /content/typescript/define-interfaces-for-models.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: define interfaces for models 3 | --- 4 | 5 | # Problem 6 | 7 | TypeScript helps us to create type safe code. When working with REST APIs, we will get back data (a DTO) at runtime that has a specific format. In case we don't define types in our code for the objects we expect to get back, we lose the benefit of TypeScript. 8 | 9 | # Solution 10 | 11 | We should define our models or DTOs (Data Transfer Objects) as interfaces instead of classes. Interfaces are virtual structures that only exist within the context of TypeScript. This means an interface does not generate code whereas a class is primarily syntactical sugar over JavaScript's existing prototype-based inheritance. Consequently, a class generates code when it's compiled to JavaScript. 12 | 13 | For example, if we make a backend request that will return an a user object with the properties `userName` and `password`, both strings, we can define an interface `User` that describes the shape of the response: 14 | 15 | ```ts 16 | export interface User { 17 | userName: string; 18 | password: string; 19 | } 20 | ``` 21 | -------------------------------------------------------------------------------- /content/typescript/define-types-at-the-non-typed-boundaries.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: define types at the non-typed boundaries 3 | --- 4 | 5 | # Problem 6 | 7 | All our JavaScript code is written in TypeScript. This means that we can leverage types. However, our codes interacts with different non-typed boundaries such as the HTML layer (think of events) and backend requests. Interacting with these boundaries influences the type safety of our code. 8 | 9 | # Solution 10 | 11 | When interacting with these boundaries, it is important to add type information so TypeScript knows the structure of the objects we are dealing with. By providing the type right at the boundary, TypeScript is able to infer it everywhere else where that variable is being used. 12 | 13 | For example when working with custom events: 14 | 15 | ```ts 16 | @Component({ 17 | template: `` 18 | }) 19 | export class SomeComponent { 20 | someEventHandler(event: TypeForThisEvent) { 21 | ... 22 | } 23 | } 24 | ``` 25 | 26 | `TypeForThisEvent` will make sure that the non-typed HTML event is typed inside of our TypeScript code. 27 | -------------------------------------------------------------------------------- /content/typescript/move-common-types-to-interfaces.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: move common types to interfaces 3 | --- 4 | 5 | # Problem 6 | 7 | With Typescript, we can easily add types to our code like this: 8 | 9 | ```ts 10 | let user: { userName: string; password: string }; 11 | ``` 12 | 13 | In this case, we defined the type of our user _inline_. While this is a valid option, it also means that it's not reusable. We could define it in multiple places. The downside here is that, when it is updated, we have to update multiple places. 14 | 15 | # Solution 16 | 17 | Whenever a type is reused in multiple places, it is recommended to move it into a separate interface. 18 | 19 | For example, we could define an interface `User`: 20 | 21 | ```ts 22 | export interface User { 23 | userName: string; 24 | password: string; 25 | } 26 | ``` 27 | -------------------------------------------------------------------------------- /content/typescript/use-type-inference.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: use type inference 3 | --- 4 | 5 | # Problem 6 | 7 | Typescript is really good at inferring the types in our code. Whenever it can do that, we don't have to add the types ourselves. 8 | 9 | If we do add them everywhere, it doesn't only take a lot of time, but it also means that we have to update them everywhere whenever anything changes. 10 | 11 | # Solution 12 | 13 | In TypeScript, we want to take advantage of type inference as much as possible. TypeScript uses this to to provide type information when there is no explicit type annotation. 14 | 15 | Here's an example: 16 | 17 | ```ts 18 | const x: number = 3; 19 | const y: string = 'typescript will automatically infer the string type'; 20 | ``` 21 | 22 | In both cases, the type is inferred when initializing the variables. 23 | 24 | To keep this code clean, we can omit the type information and use the type inference to automatically provide type information. 25 | 26 | ```ts 27 | const x = 3; 28 | const y = 'typescript will automatically infer the string type'; 29 | ``` 30 | 31 | Type inference does not only take place when initializing variables but also when initializing class members, setting parameter default values, and determining function return types. 32 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:4200" 3 | } 4 | -------------------------------------------------------------------------------- /cypress/plugins/cy-ts-preprocessor.js: -------------------------------------------------------------------------------- 1 | const wp = require('@cypress/webpack-preprocessor'); 2 | 3 | const webpackOptions = { 4 | resolve: { 5 | extensions: ['.ts', '.js'] 6 | }, 7 | module: { 8 | rules: [ 9 | { 10 | test: /\.ts$/, 11 | exclude: [/node_modules/], 12 | use: [ 13 | { 14 | loader: 'ts-loader' 15 | } 16 | ] 17 | } 18 | ] 19 | } 20 | }; 21 | 22 | const options = { 23 | webpackOptions 24 | }; 25 | 26 | module.exports = wp(options); 27 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | const cypressTypeScriptPreprocessor = require('./cy-ts-preprocessor'); 2 | 3 | module.exports = on => { 4 | on('file:preprocessor', cypressTypeScriptPreprocessor); 5 | }; 6 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This is will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands'; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "baseUrl": "../node_modules", 5 | "target": "es5", 6 | "experimentalDecorators": true, 7 | "skipLibCheck": true, 8 | "noImplicitAny": false, 9 | "lib": ["es6", "dom"], 10 | "types": ["cypress"] 11 | }, 12 | "include": ["**/*.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function(config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | jasmineHtmlReporter: { 19 | suppressAll: true // removes the duplicated traces 20 | }, 21 | coverageReporter: { 22 | dir: require('path').join(__dirname, './coverage/angular-checklist'), 23 | subdir: '.', 24 | reporters: [{ type: 'html' }, { type: 'text-summary' }] 25 | }, 26 | reporters: ['progress', 'kjhtml'], 27 | port: 9876, 28 | colors: true, 29 | logLevel: config.LOG_INFO, 30 | autoWatch: true, 31 | browsers: ['Chrome'], 32 | singleRun: false, 33 | restartOnFileChange: true 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [".git", "node_modules"], 3 | "watch": ["content/**/*"], 4 | "exec": "yarn build-content", 5 | "ext": "md" 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-checklist", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "concurrently \"ng serve\" \"yarn build-content:watch\"", 7 | "build": "ng build", 8 | "test": "ng test", 9 | "lint": "ng lint", 10 | "e2e": "cypress open", 11 | "build-content": "tsx ./tools/build-checklist.ts", 12 | "build-content:watch": "nodemon", 13 | "ci": "npm run build-content && ng build", 14 | "format:base": "prettier \"{src,tools,cypress}/**/*{.ts,.js,.json,.scss,.html}\"", 15 | "format:check": "npm run format:base -- --list-different", 16 | "format:fix": "npm run format:base -- --write" 17 | }, 18 | "packageManager": "yarn@1.22.19", 19 | "engines": { 20 | "node": "18 || 20" 21 | }, 22 | "husky": { 23 | "hooks": { 24 | "commit-msg": "commitlint --env HUSKY_GIT_PARAMS", 25 | "pre-commit": "lint-staged" 26 | } 27 | }, 28 | "lint-staged": { 29 | "{src,tools}/**/*{.ts,.js,.json,.html}": [ 30 | "prettier --write", 31 | "git add" 32 | ] 33 | }, 34 | "private": true, 35 | "dependencies": { 36 | "@angular/animations": "^17.1.1", 37 | "@angular/cdk": "^17.1.1", 38 | "@angular/common": "^17.1.1", 39 | "@angular/compiler": "^17.1.1", 40 | "@angular/core": "^17.1.1", 41 | "@angular/forms": "^17.1.1", 42 | "@angular/material": "^17.1.1", 43 | "@angular/platform-browser": "^17.1.1", 44 | "@angular/platform-browser-dynamic": "^17.1.1", 45 | "@angular/platform-server": "^17.1.1", 46 | "@angular/router": "^17.1.1", 47 | "@fortawesome/angular-fontawesome": "^0.11.1", 48 | "@fortawesome/fontawesome-svg-core": "^6.2.0", 49 | "@fortawesome/free-brands-svg-icons": "^6.2.0", 50 | "@fortawesome/free-solid-svg-icons": "^6.2.0", 51 | "@ngrx/router-store": "^17.1.0", 52 | "@ngrx/store": "^17.1.0", 53 | "@ngrx/store-devtools": "^17.1.0", 54 | "fuzzysort": "^1.1.4", 55 | "hammerjs": "^2.0.8", 56 | "highlight.js": "^11.3.1", 57 | "lodash.groupby": "^4.6.0", 58 | "ngrx-store-freeze": "^0.2.4", 59 | "ngrx-store-localstorage": "^16.1.0", 60 | "normalize.css": "^8.0.0", 61 | "rxjs": "~7.5.5", 62 | "tslib": "^2.3.1", 63 | "zone.js": "~0.14.3" 64 | }, 65 | "devDependencies": { 66 | "@angular-devkit/build-angular": "^17.1.1", 67 | "@angular-devkit/schematics": "17.1.1", 68 | "@angular-eslint/builder": "17.2.1", 69 | "@angular-eslint/eslint-plugin": "17.2.1", 70 | "@angular-eslint/eslint-plugin-template": "17.2.1", 71 | "@angular-eslint/schematics": "17.2.1", 72 | "@angular-eslint/template-parser": "17.2.1", 73 | "@angular/cli": "^17.1.1", 74 | "@angular/compiler-cli": "^17.1.1", 75 | "@angular/language-service": "^17.1.1", 76 | "@commitlint/cli": "^7.2.1", 77 | "@commitlint/config-angular": "^7.1.2", 78 | "@commitlint/travis-cli": "^7.2.1", 79 | "@cypress/webpack-preprocessor": "^4.0.2", 80 | "@types/express": "^4.17.0", 81 | "@types/jasmine": "~3.10.0", 82 | "@types/lodash": "^4.14.202", 83 | "@types/markdown-it": "^12.2.3", 84 | "@types/node": "^20.11.16", 85 | "@typescript-eslint/eslint-plugin": "^6.10.0", 86 | "@typescript-eslint/parser": "^6.10.0", 87 | "browser-sync": "^3.0.0", 88 | "chalk": "^2.4.1", 89 | "concurrently": "^4.1.0", 90 | "cypress": "^3.1.3", 91 | "eslint": "^8.53.0", 92 | "gray-matter": "^4.0.1", 93 | "http-server": "^0.11.1", 94 | "husky": "^1.2.0", 95 | "jasmine-core": "~3.10.0", 96 | "karma": "~6.3.0", 97 | "karma-chrome-launcher": "~3.1.0", 98 | "karma-coverage": "~2.0.3", 99 | "karma-jasmine": "~4.0.0", 100 | "karma-jasmine-html-reporter": "~1.7.0", 101 | "lint-staged": "^8.1.0", 102 | "markdown-it": "^12.2.0", 103 | "nodemon": "^1.18.7", 104 | "prettier": "^1.15.2", 105 | "shorthash": "^0.0.2", 106 | "ts-loader": "^5.3.1", 107 | "tsx": "^4.15.6", 108 | "typescript": "~5.3.3" 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | mat-progress-bar { 2 | position: fixed; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; 2 | import { NavigationEnd, NavigationStart, Router, RouterOutlet } from '@angular/router'; 3 | import { merge, Observable } from 'rxjs'; 4 | import { filter, mapTo } from 'rxjs/operators'; 5 | import { MatProgressBar } from '@angular/material/progress-bar'; 6 | import { AsyncPipe, NgIf } from '@angular/common'; 7 | 8 | @Component({ 9 | standalone: true, 10 | selector: 'ac-root', 11 | templateUrl: './app.component.html', 12 | styleUrls: ['./app.component.scss'], 13 | changeDetection: ChangeDetectionStrategy.OnPush, 14 | imports: [RouterOutlet, AsyncPipe, NgIf, MatProgressBar] 15 | }) 16 | export class AppComponent implements OnInit { 17 | loading$: Observable; 18 | 19 | constructor(private router: Router) {} 20 | 21 | ngOnInit() { 22 | const navigationStart$ = this.router.events.pipe( 23 | filter(event => event instanceof NavigationStart), 24 | mapTo(true) 25 | ); 26 | 27 | const navigationEnd$ = this.router.events.pipe( 28 | filter(event => event instanceof NavigationEnd), 29 | mapTo(false) 30 | ); 31 | 32 | this.loading$ = merge(navigationStart$, navigationEnd$); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationConfig } from '@angular/core'; 2 | import { provideState, provideStore } from '@ngrx/store'; 3 | import { provideStoreDevtools } from '@ngrx/store-devtools'; 4 | import { environment } from '../environments/environment'; 5 | import { ROOT_REDUCER, USER_PROVIDED_META_REDUCERS } from './state/app.state'; 6 | import { PreloadAllModules, provideRouter, withPreloading } from '@angular/router'; 7 | import { APP_ROUTES } from './app.routes'; 8 | import { provideRouterStore } from '@ngrx/router-store'; 9 | import { provideAnimations } from '@angular/platform-browser/animations'; 10 | import { checklistReducer } from './checklist/state/checklist.reducer'; 11 | import { projectsReducer } from './projects/state/projects.reducer'; 12 | import { MAT_CHECKBOX_DEFAULT_OPTIONS, MatCheckboxDefaultOptions } from '@angular/material/checkbox'; 13 | 14 | export const appConfig: ApplicationConfig = { 15 | providers: [ 16 | provideAnimations(), 17 | provideRouter(APP_ROUTES, withPreloading(PreloadAllModules)), 18 | provideStore(ROOT_REDUCER, { 19 | metaReducers: USER_PROVIDED_META_REDUCERS 20 | }), 21 | provideState('checklist', checklistReducer), 22 | provideState('projects', projectsReducer), 23 | provideStoreDevtools({ 24 | maxAge: 25, 25 | logOnly: environment.production, 26 | connectInZone: true 27 | }), 28 | provideRouterStore(), 29 | 30 | // material design 31 | { 32 | provide: MAT_CHECKBOX_DEFAULT_OPTIONS, 33 | useValue: { clickAction: 'noop' } as MatCheckboxDefaultOptions 34 | } 35 | ] 36 | }; 37 | -------------------------------------------------------------------------------- /src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | 3 | export const APP_ROUTES: Routes = [ 4 | { path: 'projects', loadChildren: () => import('./projects/projects.routes').then(m => m.PROJECTS_ROUTES) }, 5 | { 6 | path: ':project/checklist', 7 | loadChildren: () => import('./checklist/checklist.routes').then(m => m.CHECKLIST_ROUTES) 8 | }, 9 | { path: '', redirectTo: 'projects', pathMatch: 'full' } 10 | ]; 11 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-cta-bar/checklist-cta-bar.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 7 | 11 | 15 |
16 |
17 | 18 | 19 |
20 |
21 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-cta-bar/checklist-cta-bar.component.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | @use '../../../scss/custom-theme' as theme; 3 | @import '../../../scss/utils'; 4 | 5 | .cta-bar { 6 | display: flex; 7 | flex-wrap: wrap; 8 | margin: 1.5rem 0 1rem; 9 | 10 | button { 11 | @include button-small(); 12 | 13 | mat-icon { 14 | font-size: 17px; 15 | line-height: 17px; 16 | height: 17px; 17 | width: 17px; 18 | } 19 | } 20 | } 21 | 22 | .cta-buttons { 23 | margin-left: auto; 24 | 25 | button { 26 | min-width: 40px; 27 | padding: 0; 28 | } 29 | } 30 | 31 | .filter { 32 | min-width: 75px; 33 | 34 | ::ng-deep { 35 | .mat-mdc-button-focus-overlay { 36 | background-color: mat.get-color-from-palette(theme.$app-primary, default, 0.1); 37 | } 38 | 39 | .mat-mdc-ripple-element { 40 | background-color: mat.get-color-from-palette(theme.$app-primary, default, 0.1); 41 | } 42 | } 43 | 44 | &.active { 45 | color: theme.$primary; 46 | background: mat.get-color-from-palette(theme.$app-primary, lighter, 0.3); 47 | } 48 | 49 | & + & { 50 | margin-left: 5px; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-cta-bar/checklist-cta-bar.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, Output, EventEmitter } from '@angular/core'; 2 | import { ChecklistFilter } from '../models/checklist.model'; 3 | import { MatIcon } from '@angular/material/icon'; 4 | import { MatButton } from '@angular/material/button'; 5 | import { NgIf } from '@angular/common'; 6 | 7 | @Component({ 8 | standalone: true, 9 | selector: 'ac-checklist-cta-bar', 10 | templateUrl: './checklist-cta-bar.component.html', 11 | styleUrls: ['./checklist-cta-bar.component.scss'], 12 | imports: [NgIf, MatButton, MatIcon] 13 | }) 14 | export class ChecklistCtaBarComponent { 15 | @Input() filter: ChecklistFilter; 16 | @Input() showActionButtons = true; 17 | @Output() filterChange = new EventEmitter(); 18 | @Output() checkAll = new EventEmitter(); 19 | @Output() uncheckAll = new EventEmitter(); 20 | } 21 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-detail-view/checklist-detail-view.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | Done 4 | 8 | 9 |
10 | 11 | This practice includes outdated content currently under review. Contributions are welcome. 12 | 13 |
14 |
15 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-detail-view/checklist-detail-view.component.scss: -------------------------------------------------------------------------------- 1 | @use '../../../scss/custom-theme' as theme; 2 | 3 | header { 4 | display: flex; 5 | align-items: center; 6 | flex-wrap: wrap; 7 | margin: 10px 0; 8 | 9 | mat-checkbox { 10 | margin-right: 0.5rem; 11 | color: #757575; 12 | } 13 | 14 | mat-icon { 15 | padding-left: 13px; 16 | margin-right: 1rem; 17 | } 18 | 19 | ac-checklist-metadata { 20 | margin-left: auto; 21 | } 22 | } 23 | 24 | main { 25 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 26 | 'Segoe UI Emoji', 'Segoe UI Symbol'; 27 | font-size: 16px; 28 | line-height: 1.5; 29 | margin-top: 1.5rem; 30 | 31 | ::ng-deep { 32 | :not(pre) code { 33 | margin: 0; 34 | padding: 0.2em 0.4em; 35 | background-color: rgba(27, 31, 35, 0.05); 36 | border-radius: 3px; 37 | } 38 | 39 | a { 40 | color: theme.$primary; 41 | 42 | &:hover { 43 | text-decoration: underline; 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-detail-view/checklist-detail-view.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { select, Store } from '@ngrx/store'; 3 | import { Observable } from 'rxjs'; 4 | import { ToggleFavorite, ToggleItem } from '../../projects/state/projects.actions'; 5 | import { ApplicationState } from '../../state/app.state'; 6 | import { ChecklistItem } from '../models/checklist.model'; 7 | import { ChecklistSelectors } from '../state/checklist.selectors'; 8 | import { BannerComponent } from '../../shared/banner/banner.component'; 9 | import { ChecklistMetadataComponent } from '../checklist-item-metadata/checklist-metadata.component'; 10 | import { ChecklistFavoriteButtonComponent } from '../checklist-favorite-button/checklist-favorite-button.component'; 11 | import { MatCheckbox } from '@angular/material/checkbox'; 12 | import { NgIf, AsyncPipe } from '@angular/common'; 13 | 14 | @Component({ 15 | standalone: true, 16 | selector: 'ac-checklist-detail-view', 17 | templateUrl: './checklist-detail-view.component.html', 18 | styleUrls: ['./checklist-detail-view.component.scss'], 19 | imports: [NgIf, MatCheckbox, ChecklistFavoriteButtonComponent, ChecklistMetadataComponent, BannerComponent, AsyncPipe] 20 | }) 21 | export class ChecklistDetailViewComponent implements OnInit { 22 | item$: Observable; 23 | 24 | constructor(private store: Store) {} 25 | 26 | ngOnInit() { 27 | this.item$ = this.store.pipe(select(ChecklistSelectors.getSelectedItem)); 28 | } 29 | 30 | toggleItem(item: ChecklistItem) { 31 | this.store.dispatch(new ToggleItem(item)); 32 | } 33 | 34 | toggleFavorite(item: ChecklistItem) { 35 | this.store.dispatch(new ToggleFavorite(item)); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-favorite-button/checklist-favorite-button.component.html: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-favorite-button/checklist-favorite-button.component.scss: -------------------------------------------------------------------------------- 1 | @use '../../../scss/custom-theme' as theme; 2 | 3 | .favorite-icon { 4 | color: #d4d4d4; 5 | 6 | &.active { 7 | color: theme.$accent; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-favorite-button/checklist-favorite-button.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Output, EventEmitter, Input } from '@angular/core'; 2 | import { MatIcon } from '@angular/material/icon'; 3 | import { NgStyle } from '@angular/common'; 4 | import { MatIconButton } from '@angular/material/button'; 5 | 6 | @Component({ 7 | standalone: true, 8 | selector: 'ac-checklist-favorite-button', 9 | templateUrl: './checklist-favorite-button.component.html', 10 | styleUrls: ['./checklist-favorite-button.component.scss'], 11 | imports: [MatIconButton, NgStyle, MatIcon] 12 | }) 13 | export class ChecklistFavoriteButtonComponent { 14 | _style = {}; 15 | 16 | @Input() active = false; 17 | @Input() disableRipple = false; 18 | 19 | @Input() 20 | set size(value) { 21 | this._style = { 22 | width: value, 23 | height: value, 24 | lineHeight: value 25 | }; 26 | } 27 | 28 | @Output() 29 | toggle = new EventEmitter(); 30 | } 31 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-favorites-view/checklist-favorites-view.component.html: -------------------------------------------------------------------------------- 1 |
Favorites
2 | 3 | 4 | 5 | 6 | 7 | 8 |
    9 |

    {{ favorite.category.title }}

    10 | 11 | 17 | 18 | 19 |
20 |
21 | 22 |
23 | No Data 24 | You have no favorites yet... but they are only a few clicks away! 25 |
26 |
27 |
28 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-favorites-view/checklist-favorites-view.component.scss: -------------------------------------------------------------------------------- 1 | @use '../../../scss/custom-theme' as theme; 2 | @import '../../../scss/mixins'; 3 | 4 | :host { 5 | display: block; 6 | padding: 20px 30px; 7 | } 8 | 9 | header { 10 | color: theme.$accent; 11 | padding: 10px 5px; 12 | min-height: 25px; 13 | font-size: 1.3rem; 14 | border-bottom: 1px solid gainsboro; 15 | } 16 | 17 | .category { 18 | padding: 12px 0 8px; 19 | margin: 0; 20 | 21 | h4 { 22 | margin: 0 0 0.5rem; 23 | } 24 | } 25 | 26 | .no-favorites { 27 | display: flex; 28 | flex-direction: column; 29 | align-items: center; 30 | 31 | span { 32 | color: #7a7a7a; 33 | } 34 | 35 | img { 36 | margin: 30px 0 15px; 37 | width: 250px; 38 | 39 | @include small-only() { 40 | width: 200px; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-favorites-view/checklist-favorites-view.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { select, Store } from '@ngrx/store'; 3 | import { Observable } from 'rxjs'; 4 | import { ToggleFavorite, ToggleItem } from '../../projects/state/projects.actions'; 5 | import { ApplicationState } from '../../state/app.state'; 6 | import { ChecklistFilter, ChecklistItem, Favorite } from '../models/checklist.model'; 7 | import { SetFavoritesFilter } from '../state/checklist.actions'; 8 | import { ChecklistSelectors } from '../state/checklist.selectors'; 9 | import { ChecklistListItemComponent } from '../checklist-list/checklist-list-item.component'; 10 | import { ChecklistListComponent } from '../checklist-list/checklist-list.component'; 11 | import { NgIf, NgFor, AsyncPipe } from '@angular/common'; 12 | import { ChecklistCtaBarComponent } from '../checklist-cta-bar/checklist-cta-bar.component'; 13 | 14 | @Component({ 15 | standalone: true, 16 | selector: 'ac-checklist-favorites-view', 17 | templateUrl: './checklist-favorites-view.component.html', 18 | styleUrls: ['./checklist-favorites-view.component.scss'], 19 | imports: [ChecklistCtaBarComponent, NgIf, NgFor, ChecklistListComponent, ChecklistListItemComponent, AsyncPipe] 20 | }) 21 | export class ChecklistFavoritesViewComponent implements OnInit { 22 | favorites$: Observable>; 23 | filter$: Observable; 24 | 25 | constructor(private store: Store) {} 26 | 27 | ngOnInit() { 28 | this.favorites$ = this.store.pipe(select(ChecklistSelectors.getFilteredFavorites)); 29 | this.filter$ = this.store.pipe(select(ChecklistSelectors.getFavoritesFilter)); 30 | } 31 | 32 | setFilter(filter: ChecklistFilter) { 33 | this.store.dispatch(new SetFavoritesFilter(filter)); 34 | } 35 | 36 | toggleItem(item: ChecklistItem) { 37 | this.store.dispatch(new ToggleItem(item)); 38 | } 39 | 40 | toggleFavorite(item: ChecklistItem) { 41 | this.store.dispatch(new ToggleFavorite(item)); 42 | } 43 | 44 | trackByCategoryTitle(_, favorite: Favorite) { 45 | return favorite.category.title; 46 | } 47 | 48 | trackById(_, item: ChecklistItem) { 49 | return item.id; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-item-metadata/checklist-metadata.component.html: -------------------------------------------------------------------------------- 1 | {{ author.name }} 2 | Source 3 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-item-metadata/checklist-metadata.component.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | @use '../../../scss/custom-theme' as theme; 3 | 4 | :host { 5 | display: flex; 6 | font-size: 0.8rem; 7 | } 8 | 9 | .info { 10 | display: flex; 11 | align-items: center; 12 | border: 1px solid theme.$primary; 13 | color: theme.$primary; 14 | padding: 4px 7px; 15 | transition: background-color 0.3s cubic-bezier(0.35, 0, 0.25, 1); 16 | 17 | &:hover { 18 | background-color: mat.get-color-from-palette(theme.$app-primary, lighter, 0.3); 19 | } 20 | 21 | & + & { 22 | margin-left: 0.7rem; 23 | } 24 | 25 | a { 26 | color: inherit; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-item-metadata/checklist-metadata.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { Author } from '../models/checklist.model'; 3 | import { NgIf } from '@angular/common'; 4 | 5 | @Component({ 6 | standalone: true, 7 | selector: 'ac-checklist-metadata', 8 | templateUrl: './checklist-metadata.component.html', 9 | styleUrls: ['./checklist-metadata.component.scss'], 10 | imports: [NgIf] 11 | }) 12 | export class ChecklistMetadataComponent { 13 | @Input() author: Author; 14 | @Input() source: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-list-view/checklist-list-view.component.html: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-list-view/checklist-list-view.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typebytes/angular-checklist/2f9ac4c6a01da0ed705f7ac52bc880832873a263/src/app/checklist/checklist-list-view/checklist-list-view.component.scss -------------------------------------------------------------------------------- /src/app/checklist/checklist-list-view/checklist-list-view.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { select, Store } from '@ngrx/store'; 3 | import { combineLatest, Observable } from 'rxjs'; 4 | import { CheckAll, ToggleFavorite, ToggleItem, UncheckAll } from '../../projects/state/projects.actions'; 5 | import { BreakpointService } from '../../shared/breakpoint.service'; 6 | import { selectOnce } from '../../shared/operators'; 7 | import { ApplicationState } from '../../state/app.state'; 8 | import { CategoryEntity, ChecklistFilter, ChecklistItem } from '../models/checklist.model'; 9 | import { SetCategoriesFilter } from '../state/checklist.actions'; 10 | import { ChecklistSelectors } from '../state/checklist.selectors'; 11 | import { ChecklistListItemComponent } from '../checklist-list/checklist-list-item.component'; 12 | import { NgFor, AsyncPipe } from '@angular/common'; 13 | import { ChecklistListComponent } from '../checklist-list/checklist-list.component'; 14 | import { ChecklistCtaBarComponent } from '../checklist-cta-bar/checklist-cta-bar.component'; 15 | 16 | @Component({ 17 | standalone: true, 18 | selector: 'ac-list-view', 19 | templateUrl: './checklist-list-view.component.html', 20 | styleUrls: ['./checklist-list-view.component.scss'], 21 | imports: [ChecklistCtaBarComponent, ChecklistListComponent, NgFor, ChecklistListItemComponent, AsyncPipe] 22 | }) 23 | export class ListViewComponent implements OnInit { 24 | items$: Observable; 25 | filter$: Observable; 26 | showActionButtons$: Observable; 27 | 28 | constructor(private store: Store, private breakpointService: BreakpointService) {} 29 | 30 | ngOnInit() { 31 | this.items$ = this.store.pipe(select(ChecklistSelectors.getItemsFromSelectedCategory)); 32 | this.filter$ = this.store.pipe(select(ChecklistSelectors.getCategoriesFilter)); 33 | 34 | const { medium$, desktop$ } = this.breakpointService.getAllBreakpoints(); 35 | this.showActionButtons$ = combineLatest(medium$, desktop$, (medium, desktop) => medium || desktop); 36 | } 37 | 38 | toggleItem(item: ChecklistItem) { 39 | this.store.dispatch(new ToggleItem(item)); 40 | } 41 | 42 | setFilter(filter: ChecklistFilter) { 43 | this.store.dispatch(new SetCategoriesFilter(filter)); 44 | } 45 | 46 | checkAllItems() { 47 | this.getSelectedCategory().subscribe(category => this.store.dispatch(new CheckAll(category))); 48 | } 49 | 50 | uncheckAllItems() { 51 | this.getSelectedCategory().subscribe(category => this.store.dispatch(new UncheckAll(category))); 52 | } 53 | 54 | toggleFavorite(item: ChecklistItem) { 55 | this.store.dispatch(new ToggleFavorite(item)); 56 | } 57 | 58 | trackById(_, item: ChecklistItem) { 59 | return item.id; 60 | } 61 | 62 | private getSelectedCategory(): Observable { 63 | return this.store.pipe(selectOnce(ChecklistSelectors.getSelectedCategory)); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-list/checklist-list-item.component.html: -------------------------------------------------------------------------------- 1 | 2 | {{ item.title }} 3 | needs rework 4 | 8 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-list/checklist-list-item.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | display: flex; 4 | align-items: center; 5 | padding: 4px 4px 4px 0; 6 | } 7 | 8 | mat-checkbox { 9 | margin-right: 8px; 10 | } 11 | 12 | ac-chip { 13 | margin-left: 1rem; 14 | } 15 | 16 | ac-checklist-favorite-button { 17 | margin-left: auto; 18 | } 19 | 20 | .done { 21 | text-decoration: line-through; 22 | opacity: 0.5; 23 | } 24 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-list/checklist-list-item.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, Output, EventEmitter } from '@angular/core'; 2 | import { ChecklistItem } from '../models/checklist.model'; 3 | import { ChecklistFavoriteButtonComponent } from '../checklist-favorite-button/checklist-favorite-button.component'; 4 | import { ChipComponent } from '../../shared/chip/chip.component'; 5 | import { NgIf } from '@angular/common'; 6 | import { RouterLink } from '@angular/router'; 7 | import { MatCheckbox } from '@angular/material/checkbox'; 8 | 9 | @Component({ 10 | standalone: true, 11 | selector: 'ac-checklist-list-item', 12 | templateUrl: './checklist-list-item.component.html', 13 | styleUrls: ['./checklist-list-item.component.scss'], 14 | imports: [MatCheckbox, RouterLink, NgIf, ChipComponent, ChecklistFavoriteButtonComponent] 15 | }) 16 | export class ChecklistListItemComponent { 17 | @Input() item: ChecklistItem; 18 | @Output() toggleItem = new EventEmitter(); 19 | @Output() toggleFavorite = new EventEmitter(); 20 | } 21 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-list/checklist-list.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | ::ng-deep ac-checklist-list-item { 6 | & + & { 7 | border-top: 1px solid gainsboro; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-list/checklist-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | standalone: true, 5 | selector: 'ac-checklist-list', 6 | template: '', 7 | styleUrls: ['./checklist-list.component.scss'] 8 | }) 9 | export class ChecklistListComponent {} 10 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-overview/checklist-overview.component.html: -------------------------------------------------------------------------------- 1 | 7 | 8 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-overview/checklist-overview.component.scss: -------------------------------------------------------------------------------- 1 | @use '../../../scss/custom-theme' as theme; 2 | @import '../../../scss/utils'; 3 | 4 | :host { 5 | display: block; 6 | padding: 20px 30px; 7 | } 8 | 9 | .breadcrumb { 10 | margin: 0; 11 | list-style: none; 12 | display: flex; 13 | color: #bfbfbf; 14 | align-items: center; 15 | padding: 10px 5px; 16 | min-height: 25px; 17 | font-size: 1.3rem; 18 | border-bottom: 1px solid gainsboro; 19 | 20 | mat-icon { 21 | display: flex; 22 | align-items: center; 23 | margin: 0 5px; 24 | color: gainsboro; 25 | } 26 | 27 | .breadcrumb-item-separator { 28 | display: flex; 29 | } 30 | 31 | .breadcrumb-item { 32 | transition: color 0.3s cubic-bezier(0.35, 0, 0.25, 1); 33 | 34 | &:last-of-type { 35 | @include truncate(); 36 | flex: 1; 37 | } 38 | 39 | &:not(:last-of-type) { 40 | cursor: pointer; 41 | } 42 | 43 | &.active { 44 | color: theme.$accent; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-overview/checklist-overview.component.ts: -------------------------------------------------------------------------------- 1 | import { animate, query, stagger, style, transition, trigger } from '@angular/animations'; 2 | import { Component, OnInit } from '@angular/core'; 3 | import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'; 4 | import { select, Store } from '@ngrx/store'; 5 | import { Observable, zip } from 'rxjs'; 6 | import { filter, switchMap, tap } from 'rxjs/operators'; 7 | import { selectOnce } from '../../shared/operators'; 8 | import { extractRouteParams, getActivatedChild } from '../../shared/router.utils'; 9 | import { ApplicationState } from '../../state/app.state'; 10 | import { Category, ChecklistItem } from '../models/checklist.model'; 11 | import { ChecklistSelectors } from '../state/checklist.selectors'; 12 | import { MatIcon } from '@angular/material/icon'; 13 | import { NgIf, NgFor, AsyncPipe } from '@angular/common'; 14 | 15 | @Component({ 16 | standalone: true, 17 | selector: 'ac-checklist-overview', 18 | templateUrl: './checklist-overview.component.html', 19 | styleUrls: ['./checklist-overview.component.scss'], 20 | imports: [NgIf, NgFor, MatIcon, RouterOutlet, AsyncPipe], 21 | animations: [ 22 | trigger('breadcrumb', [ 23 | transition('* <=> *', [ 24 | query(':enter', style({ opacity: 0, position: 'fixed' }), { 25 | optional: true 26 | }), 27 | query( 28 | ':leave', 29 | stagger(0, [ 30 | animate( 31 | '0ms cubic-bezier(0.35, 0, 0.25, 1)', 32 | style({ transform: 'translateX(-10px)', opacity: 0, position: 'fixed' }) 33 | ) 34 | ]), 35 | { optional: true } 36 | ), 37 | query( 38 | ':enter', 39 | stagger(100, [ 40 | style({ 41 | position: '*', 42 | transform: 'translateX(-10px)', 43 | opacity: 0 44 | }), 45 | animate('300ms cubic-bezier(0.35, 0, 0.25, 1)', style({ transform: 'translateX(0)', opacity: 1 })) 46 | ]), 47 | { optional: true } 48 | ) 49 | ]) 50 | ]) 51 | ] 52 | }) 53 | export class ChecklistOverviewComponent implements OnInit { 54 | breadcrumb$: Observable; 55 | 56 | constructor(private store: Store, private router: Router, private route: ActivatedRoute) {} 57 | 58 | ngOnInit() { 59 | this.breadcrumb$ = this.store.pipe(select(ChecklistSelectors.getBreadcrumb)); 60 | 61 | this.route.params 62 | .pipe( 63 | switchMap(_ => 64 | zip( 65 | this.store.pipe(selectOnce(ChecklistSelectors.getActiveCategoryEntities)), 66 | this.store.pipe(selectOnce(ChecklistSelectors.getActiveCategories)), 67 | this.store.pipe(selectOnce(ChecklistSelectors.getEditMode)) 68 | ) 69 | ), 70 | filter(([, categories]) => !!categories.length), 71 | tap(([entities, categories, editMode]) => { 72 | const { category } = extractRouteParams(this.route.snapshot, 1); 73 | const categoryDisabled = !category || !entities[category]; 74 | 75 | if (categoryDisabled && !editMode) { 76 | this.router.navigate([categories[0].slug], { 77 | relativeTo: this.route 78 | }); 79 | } 80 | }) 81 | ) 82 | .subscribe(); 83 | } 84 | 85 | goBack(last: boolean) { 86 | if (!last) { 87 | const currentActivatedChild = getActivatedChild(this.route); 88 | this.router.navigate(['../'], { relativeTo: currentActivatedChild }); 89 | } 90 | } 91 | 92 | trackByTitle(_, item: Category | ChecklistItem) { 93 | return item.title; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-search/checklist-search.component.html: -------------------------------------------------------------------------------- 1 | 8 | 13 | 14 | {{ result.document.category }} 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-search/checklist-search.component.scss: -------------------------------------------------------------------------------- 1 | @use '../../../scss/custom-theme' as theme; 2 | 3 | :host { 4 | width: 450px; 5 | display: flex; 6 | } 7 | 8 | input { 9 | flex: 1; 10 | outline: 0; 11 | height: 15px; 12 | border-radius: 3px; 13 | border: 1px solid gainsboro; 14 | font-size: 1rem; 15 | line-height: 1rem; 16 | padding: 10px 8px; 17 | -webkit-appearance: none; 18 | } 19 | 20 | .result-type { 21 | border-radius: 2px; 22 | background: gainsboro; 23 | padding: 2px 4px; 24 | font-size: 11px; 25 | margin-right: 6px; 26 | color: #737373; 27 | text-transform: uppercase; 28 | } 29 | 30 | ::ng-deep .mat-mdc-autocomplete-panel { 31 | margin-top: 5px; 32 | box-shadow: none !important; 33 | border: 1px solid gainsboro; 34 | 35 | b { 36 | color: theme.$primary; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/app/checklist/checklist-search/checklist-search.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { FormControl, ReactiveFormsModule } from '@angular/forms'; 3 | import { MatAutocompleteSelectedEvent, MatAutocompleteTrigger, MatAutocomplete } from '@angular/material/autocomplete'; 4 | import { Router } from '@angular/router'; 5 | import * as fuzzysort from 'fuzzysort'; 6 | import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; 7 | import { debounceTime, map, switchMap } from 'rxjs/operators'; 8 | import { CategoryEntity, ChecklistItem } from '../models/checklist.model'; 9 | import { IndexEntry, SearchResult } from '../search/search.models'; 10 | import { SearchService } from '../search/search.service'; 11 | import { MatOption } from '@angular/material/core'; 12 | import { NgFor, NgIf, AsyncPipe } from '@angular/common'; 13 | 14 | @Component({ 15 | standalone: true, 16 | selector: 'ac-checklist-search', 17 | templateUrl: './checklist-search.component.html', 18 | styleUrls: ['./checklist-search.component.scss'], 19 | imports: [ReactiveFormsModule, MatAutocompleteTrigger, MatAutocomplete, NgFor, MatOption, NgIf, AsyncPipe] 20 | }) 21 | export class ChecklistSearchComponent implements OnInit { 22 | results$: Observable; 23 | searchField = new FormControl(''); 24 | 25 | focus$ = new BehaviorSubject('INIT'); 26 | 27 | constructor(private searchService: SearchService, private router: Router) {} 28 | 29 | ngOnInit() { 30 | const search$ = this.searchField.valueChanges.pipe(debounceTime(150)); 31 | 32 | this.results$ = combineLatest([this.focus$, search$]).pipe( 33 | map(([, term]) => term), 34 | switchMap(term => this.searchService.search(term)), 35 | map(results => results.map(this.mapToSearchResult)) 36 | ); 37 | } 38 | 39 | getOptionText(value: SearchResult) { 40 | if (!value) { 41 | return ''; 42 | } 43 | 44 | return value.document.title; 45 | } 46 | 47 | optionSelected({ option }: MatAutocompleteSelectedEvent) { 48 | this.searchField.setValue(''); 49 | this.router.navigate([option.value.link]); 50 | } 51 | 52 | private mapToSearchResult(result: Fuzzysort.KeyResult>) { 53 | return { 54 | text: fuzzysort.highlight(result, '', ''), 55 | document: result.obj.value, 56 | link: result.obj.link 57 | } as SearchResult; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/app/checklist/checklist.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | edit Manage Projects 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |
21 | 33 |
34 |
35 |
36 |

CATEGORIES

37 | Edit 38 |
39 |
40 | 58 |
59 |
60 |
61 |
62 | 63 |
64 |
🧐 Ups, seems like you're in edit mode!
65 |
66 |
67 | 68 | -------------------------------------------------------------------------------- /src/app/checklist/checklist.component.scss: -------------------------------------------------------------------------------- 1 | @use '../../scss/custom-theme' as theme; 2 | @import '../../scss/mixins'; 3 | 4 | :host { 5 | display: flex; 6 | height: 100%; 7 | flex-direction: column; 8 | } 9 | 10 | mat-sidenav { 11 | width: 300px; 12 | 13 | .section-container { 14 | padding: 1rem 0; 15 | } 16 | 17 | ::ng-deep .mat-drawer-inner-container { 18 | overflow-x: hidden; 19 | } 20 | } 21 | 22 | mat-sidenav-content { 23 | display: flex; 24 | overflow: hidden; 25 | } 26 | 27 | mat-sidenav-container { 28 | flex: 1; 29 | } 30 | 31 | .scroll-container { 32 | flex: 1; 33 | overflow: auto; 34 | } 35 | 36 | .overlay { 37 | display: flex; 38 | align-items: center; 39 | justify-content: center; 40 | position: absolute; 41 | top: 0; 42 | right: 0; 43 | bottom: 0; 44 | left: 0; 45 | background: rgba(250, 250, 250, 0.85); 46 | 47 | &:hover span { 48 | opacity: 1; 49 | } 50 | 51 | span { 52 | transition: opacity 0.2s cubic-bezier(0.35, 0, 0.25, 1); 53 | opacity: 0; 54 | } 55 | } 56 | 57 | ac-toolbar { 58 | $spacing: 16px; 59 | 60 | ac-dropdown, 61 | ac-checklist-search { 62 | margin-left: $spacing; 63 | 64 | @include small-only() { 65 | margin-left: auto; 66 | } 67 | } 68 | 69 | &.desktop ac-dropdown { 70 | margin-left: auto; 71 | } 72 | 73 | &:not(.desktop) ac-checklist-search { 74 | flex: 1; 75 | margin-left: $spacing; 76 | } 77 | } 78 | 79 | .logo { 80 | display: flex; 81 | flex-direction: row; 82 | 83 | img { 84 | height: 38px; 85 | width: auto; 86 | } 87 | 88 | h4 { 89 | display: flex; 90 | align-items: center; 91 | margin-left: 8px; 92 | } 93 | } 94 | 95 | .sidenav-link { 96 | @include linkHover(); 97 | 98 | display: flex; 99 | align-items: center; 100 | color: initial; 101 | padding: 8px; 102 | font-weight: 500; 103 | padding-left: 45px; 104 | 105 | ::ng-deep .mat-badge-content { 106 | top: -14px; 107 | } 108 | 109 | mat-icon { 110 | margin-right: 10px; 111 | color: #d4d4d4; 112 | } 113 | 114 | &.disabled { 115 | color: gainsboro; 116 | } 117 | 118 | &.active { 119 | mat-icon { 120 | color: theme.$primary; 121 | } 122 | } 123 | } 124 | 125 | .menu-button { 126 | margin-right: 10px; 127 | } 128 | 129 | section { 130 | padding: 15px 0 0 15px; 131 | } 132 | 133 | .section-header { 134 | display: flex; 135 | align-items: center; 136 | margin: 15px 0; 137 | flex-shrink: 0; 138 | 139 | h4 { 140 | flex: 1; 141 | margin: 0; 142 | } 143 | 144 | mat-slide-toggle { 145 | margin-right: 12px; 146 | } 147 | } 148 | 149 | .category-item-list { 150 | padding: 0; 151 | margin: 10px 0; 152 | list-style: none; 153 | 154 | a { 155 | display: inline-block; 156 | width: 100%; 157 | color: rgba(0, 0, 0, 0.54); 158 | font-size: 13px; 159 | line-height: 16px; 160 | 161 | &.done { 162 | text-decoration: line-through; 163 | opacity: 0.5; 164 | } 165 | 166 | &.active { 167 | color: theme.$primary; 168 | } 169 | } 170 | } 171 | 172 | .category, 173 | .nav-item { 174 | position: relative; 175 | display: flex; 176 | flex-direction: column; 177 | align-items: stretch; 178 | margin-bottom: 5px; 179 | 180 | mat-checkbox { 181 | display: flex; 182 | left: 15px; 183 | } 184 | 185 | ac-score-chart { 186 | left: 10px; 187 | } 188 | 189 | mat-checkbox, 190 | ac-score-chart { 191 | position: absolute; 192 | top: 50%; 193 | transform: translateY(-50%); 194 | } 195 | } 196 | 197 | ac-score-chart { 198 | width: 26px; 199 | margin-right: 10px; 200 | } 201 | -------------------------------------------------------------------------------- /src/app/checklist/checklist.routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | import { ChecklistDetailViewComponent } from './checklist-detail-view/checklist-detail-view.component'; 3 | import { ChecklistFavoritesViewComponent } from './checklist-favorites-view/checklist-favorites-view.component'; 4 | import { ListViewComponent } from './checklist-list-view/checklist-list-view.component'; 5 | import { ChecklistOverviewComponent } from './checklist-overview/checklist-overview.component'; 6 | import { ChecklistComponent } from './checklist.component'; 7 | import { ProjectExistsGuard } from './project-exists.guard'; 8 | 9 | export const CHECKLIST_ROUTES: Routes = [ 10 | { 11 | path: '', 12 | component: ChecklistComponent, 13 | canActivate: [ProjectExistsGuard], 14 | children: [ 15 | { path: 'favorites', component: ChecklistFavoritesViewComponent }, 16 | { 17 | path: '', 18 | component: ChecklistOverviewComponent, 19 | children: [ 20 | { path: ':category', component: ListViewComponent }, 21 | { path: ':category/:item', component: ChecklistDetailViewComponent } 22 | ] 23 | } 24 | ] 25 | } 26 | ]; 27 | -------------------------------------------------------------------------------- /src/app/checklist/confirmation-dialog/confirmation-dialog.component.html: -------------------------------------------------------------------------------- 1 |

{{ data.title }}

2 |
{{ data.text }}
3 |
4 | 5 | 6 |
7 | -------------------------------------------------------------------------------- /src/app/checklist/confirmation-dialog/confirmation-dialog.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | [mat-dialog-content] { 6 | line-height: 1.3rem; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/checklist/confirmation-dialog/confirmation-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, HostBinding, Inject, OnInit } from '@angular/core'; 2 | import { 3 | MAT_DIALOG_DATA, 4 | MatDialogTitle, 5 | MatDialogContent, 6 | MatDialogActions, 7 | MatDialogClose 8 | } from '@angular/material/dialog'; 9 | import { MatButton } from '@angular/material/button'; 10 | 11 | @Component({ 12 | standalone: true, 13 | selector: 'ac-confirmation-dialog', 14 | templateUrl: './confirmation-dialog.component.html', 15 | styleUrls: ['./confirmation-dialog.component.scss'], 16 | imports: [MatDialogTitle, MatDialogContent, MatDialogActions, MatButton, MatDialogClose] 17 | }) 18 | export class ConfirmationDialogComponent implements OnInit { 19 | @HostBinding('style.maxWidth') 20 | width = '350px'; 21 | 22 | confirmationButtonColor = 'warn'; 23 | 24 | constructor(@Inject(MAT_DIALOG_DATA) public data: any) {} 25 | 26 | ngOnInit() { 27 | const { width, confirmationButtonColor } = this.data; 28 | 29 | if (width) { 30 | this.width = width; 31 | } 32 | 33 | if (this.data.confirmationButtonColor) { 34 | this.confirmationButtonColor = confirmationButtonColor; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/app/checklist/models/checklist.model.ts: -------------------------------------------------------------------------------- 1 | import { EntityState } from '../../shared/models'; 2 | 3 | export interface Author { 4 | name: string; 5 | link: string; 6 | } 7 | 8 | export interface ChecklistItem { 9 | id: string; 10 | slug: string; 11 | title: string; 12 | content: string; 13 | checked: boolean; 14 | favorite: boolean; 15 | category: string; 16 | author: Author; 17 | rework: boolean; 18 | } 19 | 20 | export type BreadcrumbItem = CategoryEntity | ChecklistItem; 21 | 22 | interface BaseCategory { 23 | title: string; 24 | summary: string; 25 | slug: string; 26 | source: string; 27 | author: Author; 28 | score: number; 29 | enabled: boolean; 30 | } 31 | 32 | export interface Category extends BaseCategory { 33 | items: Array; 34 | } 35 | 36 | export type ChecklistFilter = 'ALL' | 'DONE' | 'TODO'; 37 | 38 | export interface Filter { 39 | categories: ChecklistFilter; 40 | favorites: ChecklistFilter; 41 | } 42 | 43 | export interface Favorite { 44 | category: Category; 45 | items: Array; 46 | } 47 | 48 | export interface CategoryEntity extends BaseCategory { 49 | items: Array; 50 | } 51 | 52 | export type CategoryEntities = EntityState; 53 | export type ItemEntities = EntityState; 54 | export type FavoriteEntity = EntityState>; 55 | -------------------------------------------------------------------------------- /src/app/checklist/project-exists.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ActivatedRouteSnapshot, Router } from '@angular/router'; 3 | import { select, Store } from '@ngrx/store'; 4 | import { of } from 'rxjs'; 5 | import { switchMap } from 'rxjs/operators'; 6 | import { SelectProject } from '../projects/state/projects.actions'; 7 | import { ProjectsSelectors } from '../projects/state/projects.selectors'; 8 | import { ApplicationState } from '../state/app.state'; 9 | 10 | @Injectable({ 11 | providedIn: 'root' 12 | }) 13 | export class ProjectExistsGuard { 14 | constructor(private store: Store, private router: Router) {} 15 | 16 | canActivate(snapshot: ActivatedRouteSnapshot) { 17 | const projectId = snapshot.params.project; 18 | 19 | return this.store.pipe( 20 | select(ProjectsSelectors.getProjectById(projectId)), 21 | switchMap(project => { 22 | if (!project) { 23 | void this.router.navigate(['/projects']); 24 | return of(false); 25 | } 26 | 27 | this.store.dispatch(new SelectProject(projectId)); 28 | 29 | return of(true); 30 | }) 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/app/checklist/search/search.models.ts: -------------------------------------------------------------------------------- 1 | import { CategoryEntity, ChecklistItem } from '../models/checklist.model'; 2 | 3 | export interface IndexEntry { 4 | value: T; 5 | link: string; 6 | } 7 | 8 | export interface SearchResult { 9 | text: string; 10 | document: CategoryEntity | ChecklistItem; 11 | link: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/app/checklist/search/search.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ActionsSubject, select, Store } from '@ngrx/store'; 3 | import * as fuzzysort from 'fuzzysort'; 4 | import { merge, of, zip } from 'rxjs'; 5 | import { filter, switchMap, take } from 'rxjs/operators'; 6 | import { ProjectsActionTypes } from '../../projects/state/projects.actions'; 7 | import { ProjectsSelectors } from '../../projects/state/projects.selectors'; 8 | import { AppSelectors } from '../../state/app.selectors'; 9 | import { ApplicationState } from '../../state/app.state'; 10 | import { CategoryEntities, CategoryEntity, ChecklistItem, ItemEntities } from '../models/checklist.model'; 11 | import { ChecklistSelectors } from '../state/checklist.selectors'; 12 | import { IndexEntry } from './search.models'; 13 | 14 | @Injectable() 15 | export class SearchService { 16 | private index: Array>; 17 | 18 | private options: Fuzzysort.KeyOptions = { 19 | key: 'value.title', 20 | allowTypo: false, 21 | limit: 100, 22 | threshold: -10000 23 | }; 24 | 25 | constructor(private store: Store, private actions: ActionsSubject) { 26 | const actions$ = this.actions.pipe(filter(action => action.type === ProjectsActionTypes.TOGGLE_CATEGORY)); 27 | 28 | merge(actions$, of('INIT INDEX')) 29 | .pipe(switchMap(_ => this.getStoreData())) 30 | .subscribe(([categories, items, projectId]) => { 31 | this.index = this.createIndex(categories, items, projectId); 32 | }); 33 | } 34 | 35 | search(term: string) { 36 | return of(fuzzysort.go(term, this.index, this.options)); 37 | } 38 | 39 | createIndex(categoryEntities: CategoryEntities, itemEntities: ItemEntities, projectId: string) { 40 | const categories = Object.values(categoryEntities).map(category => this.compileCategory(category, projectId)); 41 | const items = categories.reduce(this.compileCategoryItems(itemEntities, projectId), []); 42 | return [...categories, ...items]; 43 | } 44 | 45 | private getStoreData() { 46 | return zip( 47 | this.store.pipe(select(ChecklistSelectors.getActiveCategoryEntities)), 48 | this.store.pipe(select(AppSelectors.getItemEntities)), 49 | this.store.pipe(select(ProjectsSelectors.getSelectedProjectId)) 50 | ).pipe(take(1)); 51 | } 52 | 53 | private compileCategory(category: CategoryEntity, projectId: string) { 54 | return { 55 | value: category, 56 | link: `${this.getBaseLink(projectId)}/${category.slug}` 57 | }; 58 | } 59 | 60 | private compileCategoryItems(itemEntities: ItemEntities, projectId: string) { 61 | return (acc: Array>, category: IndexEntry) => { 62 | return acc.concat( 63 | category.value.items.map(itemId => { 64 | const checklistItem = itemEntities[itemId]; 65 | return { 66 | value: checklistItem, 67 | link: `${this.getBaseLink(projectId)}/${checklistItem.category}/${checklistItem.id}` 68 | }; 69 | }) 70 | ); 71 | }; 72 | } 73 | 74 | private getBaseLink(projectId: string) { 75 | return `/${projectId}/checklist`; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/app/checklist/state/checklist.actions.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '@ngrx/store'; 2 | import { ChecklistFilter } from '../models/checklist.model'; 3 | 4 | export enum ChecklistActionTypes { 5 | SET_CATEGORIES_FILTER = '[Checklist] set categories filter', 6 | SET_FAVORITES_FILTER = '[Checklist] set favroites filter', 7 | TOGGLE_EDIT_MODE = '[Checklist] toggle edit mode' 8 | } 9 | 10 | export class SetCategoriesFilter implements Action { 11 | readonly type = ChecklistActionTypes.SET_CATEGORIES_FILTER; 12 | 13 | constructor(public payload: ChecklistFilter) {} 14 | } 15 | 16 | export class SetFavoritesFilter implements Action { 17 | readonly type = ChecklistActionTypes.SET_FAVORITES_FILTER; 18 | 19 | constructor(public payload: ChecklistFilter) {} 20 | } 21 | 22 | export class ToggleEditMode implements Action { 23 | readonly type = ChecklistActionTypes.TOGGLE_EDIT_MODE; 24 | 25 | constructor() {} 26 | } 27 | 28 | export type ChecklistActions = SetCategoriesFilter | SetFavoritesFilter | ToggleEditMode; 29 | -------------------------------------------------------------------------------- /src/app/checklist/state/checklist.reducer.ts: -------------------------------------------------------------------------------- 1 | import { Filter } from '../models/checklist.model'; 2 | import { ChecklistActions, ChecklistActionTypes } from './checklist.actions'; 3 | import { ChecklistState } from './checklist.state'; 4 | 5 | const CHECKLIST = require('../../../assets/content.json'); 6 | 7 | export const INITIAL_STATE: ChecklistState = { 8 | ...CHECKLIST, 9 | filter: { 10 | categories: 'ALL', 11 | favorites: 'ALL' 12 | }, 13 | editMode: false 14 | }; 15 | 16 | export function filterReducer(state: Filter, action: ChecklistActions) { 17 | switch (action.type) { 18 | case ChecklistActionTypes.SET_CATEGORIES_FILTER: 19 | return { 20 | ...state, 21 | categories: action.payload 22 | }; 23 | case ChecklistActionTypes.SET_FAVORITES_FILTER: 24 | return { 25 | ...state, 26 | favorites: action.payload 27 | }; 28 | default: 29 | return state; 30 | } 31 | } 32 | 33 | export function checklistReducer(state = INITIAL_STATE, action: ChecklistActions) { 34 | switch (action.type) { 35 | case ChecklistActionTypes.SET_CATEGORIES_FILTER: 36 | case ChecklistActionTypes.SET_FAVORITES_FILTER: 37 | return { 38 | ...state, 39 | filter: filterReducer(state.filter, action) 40 | }; 41 | case ChecklistActionTypes.TOGGLE_EDIT_MODE: 42 | return { 43 | ...state, 44 | editMode: !state.editMode 45 | }; 46 | default: 47 | return state; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/app/checklist/state/checklist.state.ts: -------------------------------------------------------------------------------- 1 | import { CategoryEntities, Filter, ItemEntities } from '../models/checklist.model'; 2 | 3 | export interface ChecklistState { 4 | categories: CategoryEntities; 5 | items: ItemEntities; 6 | filter: Filter; 7 | editMode: boolean; 8 | } 9 | -------------------------------------------------------------------------------- /src/app/projects/models/projects.model.ts: -------------------------------------------------------------------------------- 1 | import { EntityState } from '../../shared/models'; 2 | 3 | export interface Project { 4 | id: string; 5 | name: string; 6 | disabledCategories: EntityState; 7 | favorites: FavoriteEntities; 8 | items: EntityState; 9 | creationTime: number; 10 | score?: number; 11 | } 12 | 13 | export type ProjectEntities = EntityState; 14 | export type FavoriteEntities = EntityState; 15 | 16 | export interface ProjectsState { 17 | selectedProjectId: string; 18 | entities: ProjectEntities; 19 | } 20 | -------------------------------------------------------------------------------- /src/app/projects/project-dialog/project-dialog.component.html: -------------------------------------------------------------------------------- 1 |

2 | {{ title }} 3 |

4 | 5 |
6 | 7 | 16 | {{ projectName.value?.length || 0 }}/{{ maxLength }} 17 | Project already exists 18 | 19 | 20 |
21 |
22 |

DANGER ZONE

23 |
    24 |
  • 25 | Delete Project 26 |
    27 |

    Once you delete a project, there is no going back. Please be certain.

    28 | 29 | 30 | Please type in the project name to confirm. 31 | 32 | 35 |
    36 |
  • 37 |
38 |
39 |
40 | 41 | 42 | 51 | 52 | -------------------------------------------------------------------------------- /src/app/projects/project-dialog/project-dialog.component.scss: -------------------------------------------------------------------------------- 1 | @use '../../../scss/custom-theme' as theme; 2 | 3 | :host ::ng-deep { 4 | .mat-mdc-form-field-infix { 5 | padding: 7px 0; 6 | margin: 0; 7 | border-top: 13px solid transparent; 8 | } 9 | 10 | .mat-mdc-form-field-suffix { 11 | transform: translateY(4px); 12 | margin-left: 7px; 13 | } 14 | } 15 | 16 | mat-form-field { 17 | width: 100%; 18 | } 19 | 20 | .danger-zone-wrapper { 21 | margin-top: 24px; 22 | 23 | h4 { 24 | margin: 5px 0; 25 | color: theme.$warn; 26 | font-weight: 400; 27 | } 28 | } 29 | 30 | .danger-zone { 31 | list-style: none; 32 | border: 1px solid theme.$warn; 33 | border-radius: 3px; 34 | padding: 15px; 35 | margin: 10px 0; 36 | } 37 | 38 | .danger-zone-cta-title { 39 | font-weight: 500; 40 | } 41 | 42 | .danger-zone-cta-content { 43 | display: flex; 44 | flex-direction: column; 45 | 46 | button { 47 | align-self: flex-end; 48 | } 49 | 50 | ::ng-deep .mat-mdc-form-field-infix { 51 | border-top: 0; 52 | } 53 | 54 | mat-form-field { 55 | margin-bottom: 1rem; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/app/projects/projects-view/projects-view.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 |
6 |
7 |
8 |

Welcome to Angular Checklist!

9 |

Curated list of common mistakes made when developing Angular applications.

10 |
11 | Checklist 12 |
13 |
14 |
15 |
Your Projects
16 |
17 | 18 | add 19 | Add Project 20 | 21 | 22 |
23 | 24 |

{{ project.name }}

25 |
26 | 27 |
28 | 29 | {{ project.score | percent: '.0-2' }} completed 30 |
31 | 34 |
35 |
36 |
37 |
38 |
39 | 40 | -------------------------------------------------------------------------------- /src/app/projects/projects-view/projects-view.component.scss: -------------------------------------------------------------------------------- 1 | @use '../../../scss/custom-theme' as theme; 2 | @import '../../../scss/mixins'; 3 | 4 | @mixin wrapper() { 5 | --wrapper-width: 650px; 6 | --padding-bottom: 20px; 7 | 8 | margin: 0 auto; 9 | max-width: var(--wrapper-width); 10 | padding: 65px 12px var(--padding-bottom); 11 | 12 | @include medium-up() { 13 | --padding-bottom: 50px; 14 | } 15 | 16 | @include large-up() { 17 | --wrapper-width: 850px; 18 | } 19 | } 20 | 21 | :host { 22 | display: flex; 23 | flex-direction: column; 24 | height: 100%; 25 | } 26 | 27 | main { 28 | flex: 1; 29 | overflow: scroll; 30 | background: #eceff1; 31 | } 32 | 33 | .projects-header { 34 | height: 300px; 35 | background: white; 36 | margin-bottom: -150px; 37 | } 38 | 39 | .welcome-message { 40 | max-width: 350px; 41 | 42 | h1 { 43 | font-size: 1.5rem; 44 | font-weight: 400; 45 | } 46 | } 47 | 48 | .welcome-subtitle { 49 | color: rgba(0, 0, 0, 0.54); 50 | } 51 | 52 | .welcome-wrapper { 53 | position: relative; 54 | 55 | img { 56 | position: absolute; 57 | left: 50%; 58 | top: 25px; 59 | height: 200px; 60 | transform: translateX(100px); 61 | 62 | @include small-only { 63 | display: none; 64 | } 65 | 66 | @include large-up() { 67 | transform: translateX(0px); 68 | } 69 | } 70 | } 71 | 72 | .welcome-wrapper, 73 | .projects-list-wrapper { 74 | @include wrapper(); 75 | } 76 | 77 | .projects-grid { 78 | --grid-gap: 20px; 79 | --column-template: 1fr; 80 | 81 | display: grid; 82 | grid-column-gap: var(--grid-gap); 83 | grid-row-gap: var(--grid-gap); 84 | grid-template-columns: var(--column-template); 85 | 86 | @include medium-up() { 87 | --column-template: 1fr 1fr; 88 | } 89 | 90 | @include large-up() { 91 | --column-template: 1fr 1fr 1fr; 92 | } 93 | } 94 | 95 | mat-card { 96 | height: 130px; 97 | border-radius: 5px; 98 | display: flex; 99 | flex-direction: column; 100 | cursor: pointer; 101 | 102 | .card-ripple { 103 | position: absolute; 104 | top: 0; 105 | right: 0; 106 | bottom: 0; 107 | left: 0; 108 | } 109 | 110 | &:hover { 111 | background: whitesmoke; 112 | } 113 | } 114 | 115 | mat-card-content { 116 | flex: 1; 117 | } 118 | 119 | .projects-list-wrapper { 120 | h5 { 121 | margin: 0 0 15px; 122 | font-weight: 400; 123 | color: rgba(0, 0, 0, 0.54); 124 | } 125 | } 126 | 127 | .project-name { 128 | max-width: 100%; 129 | text-overflow: ellipsis; 130 | overflow: hidden; 131 | font-weight: 400; 132 | font-size: 18px; 133 | line-height: 22px; 134 | } 135 | 136 | .add-project-card { 137 | display: flex; 138 | flex-direction: column; 139 | justify-content: center; 140 | align-items: center; 141 | color: theme.$primary; 142 | font-weight: 500; 143 | 144 | mat-icon { 145 | width: 40px; 146 | height: 40px; 147 | font-size: 40px; 148 | margin-bottom: 5px; 149 | } 150 | } 151 | 152 | mat-card-actions { 153 | display: flex; 154 | align-items: center; 155 | } 156 | 157 | .progress { 158 | flex: 1; 159 | padding-left: 10px; 160 | display: flex; 161 | align-items: center; 162 | pointer-events: none; 163 | 164 | .score { 165 | margin-left: 10px; 166 | font-weight: 100; 167 | font-size: 0.9rem; 168 | font-weight: 400; 169 | color: theme.$primary; 170 | } 171 | 172 | ac-score-chart { 173 | width: 30px; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/app/projects/projects-view/projects-view.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { MatDialog } from '@angular/material/dialog'; 3 | import { Router } from '@angular/router'; 4 | import { select, Store } from '@ngrx/store'; 5 | import { asyncScheduler, Observable } from 'rxjs'; 6 | import { filter, map, observeOn } from 'rxjs/operators'; 7 | import { ApplicationState } from '../../state/app.state'; 8 | import { Project } from '../models/projects.model'; 9 | 10 | import { 11 | ProjectDialogComponent, 12 | ProjectDialogData, 13 | ProjectDialogMode, 14 | ProjectDialogResult, 15 | ProjectDialogResultType 16 | } from '../project-dialog/project-dialog.component'; 17 | 18 | import { AddProject, DeleteProject, EditProject } from '../state/projects.actions'; 19 | import { ProjectsSelectors } from '../state/projects.selectors'; 20 | import { MatIconButton } from '@angular/material/button'; 21 | import { NgFor, AsyncPipe, PercentPipe } from '@angular/common'; 22 | import { MatIcon } from '@angular/material/icon'; 23 | import { MatRipple } from '@angular/material/core'; 24 | import { MatCard, MatCardContent, MatCardActions } from '@angular/material/card'; 25 | import { ToolbarComponent } from 'src/app/shared/toolbar/toolbar.component'; 26 | import { ToolbarLogoComponent } from 'src/app/shared/toolbar/toolbar-logo/toolbar-logo.component'; 27 | import { ScoreChartComponent } from 'src/app/shared/score-chart/score-chart.component'; 28 | import { FooterComponent } from 'src/app/shared/footer/footer.component'; 29 | 30 | @Component({ 31 | standalone: true, 32 | selector: 'ac-projects-view', 33 | templateUrl: './projects-view.component.html', 34 | styleUrls: ['./projects-view.component.scss'], 35 | imports: [ 36 | MatCard, 37 | MatRipple, 38 | MatIcon, 39 | NgFor, 40 | MatCardContent, 41 | MatCardActions, 42 | MatIconButton, 43 | AsyncPipe, 44 | PercentPipe, 45 | ToolbarComponent, 46 | ToolbarLogoComponent, 47 | ScoreChartComponent, 48 | FooterComponent 49 | ] 50 | }) 51 | export class ProjectsViewComponent implements OnInit { 52 | projects$: Observable>; 53 | 54 | constructor(private store: Store, private router: Router, private dialog: MatDialog) {} 55 | 56 | ngOnInit() { 57 | this.projects$ = this.store.pipe(select(ProjectsSelectors.getProjects)); 58 | } 59 | 60 | navigateToProject(projectId: string) { 61 | this.router.navigate([`/${projectId}/checklist`]); 62 | } 63 | 64 | addProject() { 65 | this.openProjectDialog({ title: 'Add Project', submitButtonText: 'Create' }) 66 | .pipe( 67 | map(({ payload: newProject }) => { 68 | this.store.dispatch(new AddProject(newProject)); 69 | return newProject; 70 | }), 71 | observeOn(asyncScheduler) 72 | ) 73 | .subscribe(({ id }) => this.navigateToProject(id)); 74 | } 75 | 76 | editProject(event: MouseEvent, project: Project) { 77 | event.stopPropagation(); 78 | 79 | this.openProjectDialog({ 80 | title: 'Edit Project', 81 | submitButtonText: 'Save', 82 | mode: ProjectDialogMode.Edit, 83 | project 84 | }).subscribe(result => { 85 | const updatedProject = result.payload; 86 | 87 | if (result.type === ProjectDialogResultType.Delete) { 88 | this.store.dispatch(new DeleteProject(updatedProject.id)); 89 | } else { 90 | this.store.dispatch(new EditProject({ current: project, updated: updatedProject })); 91 | } 92 | }); 93 | } 94 | 95 | private openProjectDialog(data: Partial) { 96 | return this.dialog 97 | .open(ProjectDialogComponent, { 98 | minWidth: 350, 99 | data 100 | }) 101 | .afterClosed() 102 | .pipe(filter(dialogResult => !!dialogResult)); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/app/projects/projects.routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | import { ProjectsViewComponent } from './projects-view/projects-view.component'; 3 | 4 | export const PROJECTS_ROUTES: Routes = [ 5 | { 6 | path: '', 7 | component: ProjectsViewComponent 8 | } 9 | ]; 10 | -------------------------------------------------------------------------------- /src/app/projects/state/project-state.utils.ts: -------------------------------------------------------------------------------- 1 | import { EntityState } from '../../shared/models'; 2 | import { Project } from '../models/projects.model'; 3 | 4 | interface ToggleMannyOptions { 5 | initialValue: EntityState; 6 | value?: boolean; 7 | } 8 | 9 | export const toggleEntity = (entities: EntityState, entity: string, value?: boolean | undefined) => { 10 | value = typeof value === 'undefined' ? !entities[entity] : value; 11 | 12 | if (!value) { 13 | const { [entity]: removed, ...rest } = entities; 14 | return rest; 15 | } 16 | 17 | return { 18 | ...entities, 19 | [entity]: value 20 | }; 21 | }; 22 | 23 | export const toggleManny = ( 24 | items: Array, 25 | projectionFn: (item: T) => string, 26 | { initialValue, value }: ToggleMannyOptions = { initialValue: {} } 27 | ) => { 28 | return items.reduce((selectedItems, item) => { 29 | return toggleEntity(selectedItems, projectionFn(item), value); 30 | }, initialValue); 31 | }; 32 | 33 | export const createNewProject = (id: string, name?: string): Project => { 34 | return { 35 | id, 36 | name: name ? name : id, 37 | disabledCategories: {}, 38 | items: {}, 39 | favorites: {}, 40 | creationTime: Date.now() 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /src/app/projects/state/projects.actions.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '@ngrx/store'; 2 | import { CategoryEntity, ChecklistItem } from '../../checklist/models/checklist.model'; 3 | import { Project } from '../models/projects.model'; 4 | 5 | export enum ProjectsActionTypes { 6 | TOGGLE_ITEM = '[Projects] toggle item', 7 | TOGGLE_CATEGORY = '[Projects] toggle category', 8 | ADD_PROJECT = '[Projects] add project', 9 | DELETE_PROJECT = '[Projects] delete project', 10 | EDIT_PROJECT = '[Projects] edit project', 11 | SELECT_PROJECT = '[Projects] select project', 12 | CHECK_ALL = '[Projects] check all', 13 | UNCHECK_ALL = '[Projects] uncheck all', 14 | TOGGLE_FAVORITE = '[Projects] add favorite', 15 | TOGGLE_ALL_FAVORITES = '[Projects] toggle all favorites' 16 | } 17 | 18 | export class UncheckAll implements Action { 19 | readonly type = ProjectsActionTypes.UNCHECK_ALL; 20 | 21 | constructor(public payload: CategoryEntity) {} 22 | } 23 | 24 | export class CheckAll implements Action { 25 | readonly type = ProjectsActionTypes.CHECK_ALL; 26 | 27 | constructor(public payload: CategoryEntity) {} 28 | } 29 | 30 | export class ToggleItem implements Action { 31 | readonly type = ProjectsActionTypes.TOGGLE_ITEM; 32 | 33 | constructor(public payload: ChecklistItem) {} 34 | } 35 | 36 | export class ToggleCategory implements Action { 37 | readonly type = ProjectsActionTypes.TOGGLE_CATEGORY; 38 | 39 | constructor(public payload: string) {} 40 | } 41 | 42 | export class AddProject implements Action { 43 | readonly type = ProjectsActionTypes.ADD_PROJECT; 44 | 45 | constructor(public payload: Partial) {} 46 | } 47 | 48 | export class DeleteProject implements Action { 49 | readonly type = ProjectsActionTypes.DELETE_PROJECT; 50 | 51 | constructor(public payload: string) {} 52 | } 53 | 54 | export class EditProject implements Action { 55 | readonly type = ProjectsActionTypes.EDIT_PROJECT; 56 | 57 | constructor(public payload: { current: Project; updated: Partial }) {} 58 | } 59 | 60 | export class SelectProject implements Action { 61 | readonly type = ProjectsActionTypes.SELECT_PROJECT; 62 | 63 | constructor(public payload: string) {} 64 | } 65 | 66 | export class ToggleFavorite implements Action { 67 | readonly type = ProjectsActionTypes.TOGGLE_FAVORITE; 68 | 69 | constructor(public payload: ChecklistItem) {} 70 | } 71 | 72 | export class ToggleAllFavorites implements Action { 73 | readonly type = ProjectsActionTypes.TOGGLE_ALL_FAVORITES; 74 | 75 | constructor(public payload: Array) {} 76 | } 77 | 78 | export type ProjectsActions = 79 | | ToggleCategory 80 | | AddProject 81 | | DeleteProject 82 | | EditProject 83 | | SelectProject 84 | | ToggleItem 85 | | CheckAll 86 | | UncheckAll 87 | | ToggleFavorite 88 | | ToggleAllFavorites; 89 | -------------------------------------------------------------------------------- /src/app/projects/state/projects.selectors.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from '@ngrx/store'; 2 | import { extractRouteParams } from '../../shared/router.utils'; 3 | import { computeScore } from '../../state/app-state.utils'; 4 | import { AppSelectors } from '../../state/app.selectors'; 5 | import { Project } from '../models/projects.model'; 6 | 7 | export namespace ProjectsSelectors { 8 | export const getProjectEntities = createSelector(AppSelectors.getProjectsState, projects => projects.entities); 9 | 10 | export const getSelectedProjectId = createSelector(AppSelectors.getRouterState, (routerState): string => { 11 | const { project } = extractRouteParams(routerState.root, 1); 12 | 13 | return project; 14 | }); 15 | 16 | export const getSelectedProject = createSelector( 17 | getProjectEntities, 18 | getSelectedProjectId, 19 | (projectEntities, projectId): Project => { 20 | const emptyProject = {} as Project; 21 | 22 | if (!projectEntities) { 23 | return emptyProject; 24 | } 25 | 26 | return projectEntities[projectId] || emptyProject; 27 | } 28 | ); 29 | 30 | export const getProjectItems = createSelector(getSelectedProject, project => { 31 | return project.items || {}; 32 | }); 33 | 34 | export const getDisabledCategories = createSelector(getSelectedProject, project => { 35 | return project.disabledCategories || {}; 36 | }); 37 | 38 | export const getFavoriteEntities = createSelector(getSelectedProject, project => { 39 | return project.favorites || {}; 40 | }); 41 | 42 | export const getProjectsScores = createSelector( 43 | getProjectEntities, 44 | AppSelectors.getCategoryEntities, 45 | (projectEntities, categoryEntities) => { 46 | return Object.keys(projectEntities).reduce((scores, projectId) => { 47 | const disabledCategories = projectEntities[projectId].disabledCategories; 48 | const activeCategories = Object.keys(categoryEntities).filter(categoryId => !disabledCategories[categoryId]); 49 | const categoryScore = activeCategories.reduce((score, categoryId) => { 50 | return score + computeScore(categoryEntities[categoryId].items, projectEntities[projectId].items); 51 | }, 0); 52 | 53 | scores[projectId] = parseFloat((categoryScore / activeCategories.length).toFixed(2)); 54 | 55 | return scores; 56 | }, {}); 57 | } 58 | ); 59 | 60 | export const getProjects = createSelector(getProjectEntities, getProjectsScores, (projectEntities, projectScores) => { 61 | const addScore = (project): Project => { 62 | return { 63 | ...project, 64 | score: projectScores[project.id] 65 | }; 66 | }; 67 | 68 | return Object.values(projectEntities) 69 | .map(addScore) 70 | .sort((a: Project, b: Project) => b.creationTime - a.creationTime); 71 | }); 72 | 73 | export const getProjectById = (id: string) => { 74 | return createSelector(getProjectEntities, projectEntities => projectEntities[id]); 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /src/app/shared/about-dialog/about-dialog.component.html: -------------------------------------------------------------------------------- 1 |

🤔 What is it?

2 | 3 |

4 | Angular Checklist is a curated list of best practices that we believe every application should follow in order to 5 | avoid some common pitfalls. 6 |

7 |

8 | The idea is that for all your projects, you can go over the checklist and see which items your projects already 9 | comply with and which you still have to put in some more effort! 10 |

11 |

If you follow the items in this list, your project will definitely be on track to success 🏆!

12 | 13 |
14 | -------------------------------------------------------------------------------- /src/app/shared/about-dialog/about-dialog.component.scss: -------------------------------------------------------------------------------- 1 | h2 { 2 | margin: 0 0 0.5rem; 3 | font-weight: 500; 4 | } 5 | 6 | ac-authors { 7 | margin-top: 1rem; 8 | } 9 | 10 | p { 11 | line-height: 1.3rem; 12 | } 13 | -------------------------------------------------------------------------------- /src/app/shared/about-dialog/about-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { AuthorsComponent } from '../authors/authors.component'; 3 | import { MatDialogContent } from '@angular/material/dialog'; 4 | 5 | @Component({ 6 | standalone: true, 7 | selector: 'ac-about-dialog', 8 | templateUrl: './about-dialog.component.html', 9 | styleUrls: ['./about-dialog.component.scss'], 10 | imports: [MatDialogContent, AuthorsComponent] 11 | }) 12 | export class AboutDialogComponent {} 13 | -------------------------------------------------------------------------------- /src/app/shared/authors/authors.component.html: -------------------------------------------------------------------------------- 1 |
2 | Made with by 3 | 14 |
15 | -------------------------------------------------------------------------------- /src/app/shared/authors/authors.component.scss: -------------------------------------------------------------------------------- 1 | @use '../../../scss/custom-theme' as theme; 2 | @import '../../../scss/mixins'; 3 | 4 | $vertical-margin: 6px; 5 | 6 | :host { 7 | display: flex; 8 | align-items: center; 9 | min-height: 34px; 10 | } 11 | 12 | .authors { 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | flex-wrap: wrap; 17 | margin-top: -$vertical-margin; 18 | 19 | span:first-child { 20 | margin-top: $vertical-margin; 21 | margin-right: 10px; 22 | } 23 | 24 | .ampersand { 25 | margin: 0 5px; 26 | } 27 | } 28 | 29 | .author-list { 30 | display: flex; 31 | align-items: center; 32 | margin-top: $vertical-margin; 33 | } 34 | 35 | .author { 36 | font-size: 0.8rem; 37 | line-height: 0.8rem; 38 | background: #e2e2e2; 39 | border-radius: 15px; 40 | padding: 5px 10px; 41 | color: rgba(0, 0, 0, 0.5); 42 | 43 | .name { 44 | margin: 0 0 0 5px; 45 | } 46 | 47 | &:hover { 48 | background: theme.$primary; 49 | color: white !important; 50 | } 51 | } 52 | 53 | .heart { 54 | color: rgba(244, 81, 30, 0.9); 55 | } 56 | -------------------------------------------------------------------------------- /src/app/shared/authors/authors.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject } from '@angular/core'; 2 | import { FaIconLibrary, FontAwesomeModule } from '@fortawesome/angular-fontawesome'; 3 | import { faTwitter } from '@fortawesome/free-brands-svg-icons'; 4 | import { faHeart } from '@fortawesome/free-solid-svg-icons'; 5 | 6 | @Component({ 7 | standalone: true, 8 | selector: 'ac-authors', 9 | templateUrl: './authors.component.html', 10 | styleUrls: ['./authors.component.scss'], 11 | imports: [FontAwesomeModule] 12 | }) 13 | export class AuthorsComponent { 14 | library = inject(FaIconLibrary); 15 | 16 | constructor() { 17 | this.library.addIcons(faTwitter, faHeart); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/app/shared/banner/banner.component.html: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 |

5 | -------------------------------------------------------------------------------- /src/app/shared/banner/banner.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | background: var(--warn-color); 4 | border-radius: 8px; 5 | padding: 0.5rem 1rem; 6 | } 7 | 8 | fa-icon { 9 | color: var(--warn-color-darker); 10 | margin-inline-end: 0.5rem; 11 | } 12 | -------------------------------------------------------------------------------- /src/app/shared/banner/banner.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject } from '@angular/core'; 2 | import { FaIconLibrary, FontAwesomeModule } from '@fortawesome/angular-fontawesome'; 3 | import { faCircleInfo } from '@fortawesome/free-solid-svg-icons'; 4 | 5 | @Component({ 6 | standalone: true, 7 | selector: 'ac-banner', 8 | templateUrl: './banner.component.html', 9 | styleUrl: './banner.component.scss', 10 | imports: [FontAwesomeModule] 11 | }) 12 | export class BannerComponent { 13 | library = inject(FaIconLibrary); 14 | 15 | constructor() { 16 | this.library.addIcons(faCircleInfo); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/app/shared/breakpoint.service.ts: -------------------------------------------------------------------------------- 1 | import { BreakpointObserver, BreakpointState } from '@angular/cdk/layout'; 2 | import { Injectable } from '@angular/core'; 3 | import { map, shareReplay } from 'rxjs/operators'; 4 | import { Observable } from 'rxjs'; 5 | 6 | export enum Breakpoint { 7 | Small = 'small$', 8 | Medium = 'medium$', 9 | Desktop = 'desktop$' 10 | } 11 | 12 | @Injectable({ 13 | providedIn: 'root' 14 | }) 15 | export class BreakpointService { 16 | private _small$: Observable; 17 | private _medium$: Observable; 18 | private _desktop$: Observable; 19 | 20 | constructor(private breakPointObserver: BreakpointObserver) { 21 | this.setupBreakpoints(); 22 | } 23 | 24 | getAllBreakpoints() { 25 | return { 26 | [Breakpoint.Small]: this._small$, 27 | [Breakpoint.Medium]: this._medium$, 28 | [Breakpoint.Desktop]: this._desktop$ 29 | }; 30 | } 31 | 32 | getBreakpoint(breakpoint: Breakpoint) { 33 | const breakpoints = this.getAllBreakpoints(); 34 | return breakpoints[breakpoint]; 35 | } 36 | 37 | private setupBreakpoints() { 38 | const small$ = this.breakPointObserver.observe(['(max-width: 600px)']).pipe( 39 | map(breakPoint => breakPoint.matches), 40 | shareReplay(1) 41 | ); 42 | 43 | const medium$ = this.breakPointObserver.observe(['(min-width: 600px) and (max-width: 992px)']).pipe( 44 | map(breakPoint => breakPoint.matches), 45 | shareReplay(1) 46 | ); 47 | 48 | const desktop$ = this.breakPointObserver.observe(['(min-width: 992px)']).pipe( 49 | map(breakPoint => breakPoint.matches), 50 | shareReplay(1) 51 | ); 52 | 53 | this._small$ = small$; 54 | this._medium$ = medium$; 55 | this._desktop$ = desktop$; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/app/shared/chip/chip.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/app/shared/chip/chip.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | background-color: var(--warn-color); 3 | border-radius: 20px; 4 | padding: 0.5rem 1rem; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/shared/chip/chip.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | standalone: true, 5 | selector: 'ac-chip', 6 | templateUrl: './chip.component.html', 7 | styleUrl: './chip.component.scss' 8 | }) 9 | export class ChipComponent {} 10 | -------------------------------------------------------------------------------- /src/app/shared/dropdown/dropdown-static-option.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | align-items: center; 4 | font-size: inherit; 5 | line-height: 3em; 6 | height: 3em; 7 | padding: 0 16px; 8 | cursor: pointer; 9 | outline: none; 10 | min-width: 150px; 11 | 12 | ::ng-deep mat-icon { 13 | margin-right: 10px; 14 | } 15 | 16 | &:hover { 17 | background: rgba(0, 0, 0, 0.04); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/app/shared/dropdown/dropdown-static-options.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | border-bottom: 1px solid rgba(0, 0, 0, 0.12); 4 | } 5 | -------------------------------------------------------------------------------- /src/app/shared/dropdown/dropdown-static-options.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | standalone: true, 5 | selector: 'ac-dropdown-static-options', 6 | template: ` 7 | 8 | `, 9 | styleUrls: ['./dropdown-static-options.component.scss'] 10 | }) 11 | export class DropdownStaticOptionsComponent {} 12 | 13 | @Component({ 14 | standalone: true, 15 | selector: 'ac-dropdown-static-option', 16 | template: ` 17 | 18 | `, 19 | styleUrls: ['./dropdown-static-option.component.scss'] 20 | }) 21 | export class DropdownStaticOptionComponent {} 22 | -------------------------------------------------------------------------------- /src/app/shared/dropdown/dropdown.component.html: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | {{ item[bindLabel] }} 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/app/shared/dropdown/dropdown.component.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | @use '../../../scss/custom-theme' as theme; 3 | @import '../../../scss/mixins'; 4 | 5 | :host { 6 | font-size: 1rem; 7 | background: mat.get-color-from-palette(theme.$app-primary, lighter, 0.3); 8 | border: 1px solid mat.get-color-from-palette(theme.$app-primary, 0.8); 9 | border-radius: 3px; 10 | color: #2b2b2b; 11 | } 12 | 13 | ::ng-deep { 14 | .ac-dropdown { 15 | border-radius: 8px; 16 | margin: 5px 0 0 0; 17 | max-height: 268px !important; 18 | box-shadow: 0 1px 2px 0 rgba(60, 64, 67, 0.3), 0 2px 6px 2px rgba(60, 64, 67, 0.15) !important; 19 | 20 | .mat-mdc-selected:not(.mat-mdc-option-multiple) { 21 | background: mat.get-color-from-palette(theme.$app-primary, lighter, 0.3) !important; 22 | } 23 | } 24 | } 25 | 26 | :host ::ng-deep { 27 | .mat-mdc-form-field-subscript-wrapper, 28 | .mdc-line-ripple { 29 | display: none; 30 | } 31 | 32 | mat-select { 33 | line-height: 2rem; 34 | } 35 | 36 | .mat-mdc-select-trigger { 37 | display: flex; 38 | align-items: center; 39 | height: 35px; 40 | padding: 0px 10px; 41 | } 42 | 43 | .mat-mdc-select-value { 44 | max-width: 140px !important; 45 | padding-right: 5px; 46 | } 47 | 48 | .mat-mdc-select-arrow { 49 | margin-right: 0 !important; 50 | } 51 | 52 | .mat-mdc-text-field-infix { 53 | width: auto !important; 54 | } 55 | 56 | .mat-mdc-text-field-wrapper, 57 | .mat-mdc-form-field-infix { 58 | padding: 0 !important; 59 | border: 0 !important; 60 | min-height: unset; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/app/shared/dropdown/dropdown.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; 2 | import { FormControl, ReactiveFormsModule } from '@angular/forms'; 3 | import { MatOption } from '@angular/material/core'; 4 | import { MatSelect } from '@angular/material/select'; 5 | import { MatFormField } from '@angular/material/form-field'; 6 | import { NgIf, NgFor } from '@angular/common'; 7 | 8 | @Component({ 9 | standalone: true, 10 | selector: 'ac-dropdown', 11 | templateUrl: './dropdown.component.html', 12 | styleUrls: ['./dropdown.component.scss'], 13 | imports: [NgIf, MatFormField, MatSelect, ReactiveFormsModule, NgFor, MatOption] 14 | }) 15 | export class DropdownComponent implements OnChanges { 16 | @Input() items: Array; 17 | @Input() bindLabel: string; 18 | @Input() bindValue: string; 19 | @Input() selected: string; 20 | 21 | @Output() selectionChange = new EventEmitter(); 22 | 23 | select = new FormControl(''); 24 | 25 | ngOnChanges(changes: SimpleChanges) { 26 | if (changes.selected) { 27 | this.select.setValue(changes.selected.currentValue); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/app/shared/footer/footer.component.html: -------------------------------------------------------------------------------- 1 | 2 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/app/shared/footer/footer.component.scss: -------------------------------------------------------------------------------- 1 | @use '../../../scss/custom-theme' as theme; 2 | @import '../../../scss/mixins'; 3 | 4 | mat-toolbar { 5 | height: 45px; 6 | color: rgba(0, 0, 0, 0.5); 7 | font-size: 1rem !important; 8 | font-weight: 300 !important; 9 | } 10 | 11 | .links { 12 | flex: 1; 13 | 14 | .cta + .cta { 15 | margin-left: 15px; 16 | } 17 | } 18 | 19 | .cta { 20 | background: transparent; 21 | border: 0; 22 | padding: 0; 23 | outline: none; 24 | color: inherit; 25 | font-weight: inherit; 26 | font-size: inherit; 27 | cursor: pointer; 28 | 29 | fa-icon { 30 | margin-right: 5px; 31 | } 32 | 33 | &:hover { 34 | color: theme.$primary; 35 | } 36 | } 37 | 38 | .links { 39 | min-height: 34px; 40 | display: flex; 41 | align-items: center; 42 | } 43 | 44 | ac-authors { 45 | @media only screen and (max-width: 768px) { 46 | display: none; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/app/shared/footer/footer.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject } from '@angular/core'; 2 | import { MatDialog } from '@angular/material/dialog'; 3 | import { AboutDialogComponent } from '../about-dialog/about-dialog.component'; 4 | import { AuthorsComponent } from '../authors/authors.component'; 5 | import { FaIconLibrary, FontAwesomeModule } from '@fortawesome/angular-fontawesome'; 6 | import { MatToolbar } from '@angular/material/toolbar'; 7 | import { faHandsHelping, faInfo } from '@fortawesome/free-solid-svg-icons'; 8 | import { faGithub } from '@fortawesome/free-brands-svg-icons'; 9 | 10 | @Component({ 11 | standalone: true, 12 | selector: 'ac-footer', 13 | templateUrl: './footer.component.html', 14 | styleUrls: ['./footer.component.scss'], 15 | imports: [MatToolbar, FontAwesomeModule, AuthorsComponent] 16 | }) 17 | export class FooterComponent { 18 | library = inject(FaIconLibrary); 19 | 20 | constructor(private dialog: MatDialog) { 21 | this.library.addIcons(faGithub, faHandsHelping, faInfo); 22 | } 23 | 24 | showAbout() { 25 | this.dialog.open(AboutDialogComponent, { 26 | maxWidth: 600 27 | }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/shared/models.ts: -------------------------------------------------------------------------------- 1 | export interface EntityState { 2 | [key: string]: T; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/shared/operators.ts: -------------------------------------------------------------------------------- 1 | import { BreakpointState } from '@angular/cdk/layout'; 2 | import { select } from '@ngrx/store'; 3 | import { pipe, Observable } from 'rxjs'; 4 | import { filter, take } from 'rxjs/operators'; 5 | 6 | export const matches = pipe(filter((result: BreakpointState) => result.matches)); 7 | 8 | export const selectOnce = query => { 9 | return (source: Observable) => { 10 | return source.pipe(select(query), take(1)); 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /src/app/shared/router.utils.ts: -------------------------------------------------------------------------------- 1 | import { ActivatedRoute, ActivatedRouteSnapshot, Params } from '@angular/router'; 2 | 3 | export const extractRouteParams = (snapshot: ActivatedRouteSnapshot, levels = 0): Params => { 4 | if (levels === 0 || !snapshot.firstChild) { 5 | return snapshot.params; 6 | } 7 | 8 | return extractRouteParams(snapshot.firstChild, --levels); 9 | }; 10 | 11 | export const getActivatedChild = (route: ActivatedRoute) => { 12 | if (!route.firstChild) { 13 | return route; 14 | } 15 | 16 | return getActivatedChild(route.firstChild); 17 | }; 18 | -------------------------------------------------------------------------------- /src/app/shared/score-chart/score-chart.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/app/shared/score-chart/score-chart.component.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | @use '../../../scss/custom-theme' as theme; 3 | 4 | $default-filling: #dadada; 5 | $default-border: #dadada; 6 | 7 | :host { 8 | display: inherit; 9 | --percentage: 0; 10 | --max-stroke: 189; 11 | --progress-color: #{$default-filling}; 12 | --done-color: #{$default-filling}; 13 | --border-color: #{$default-border}; 14 | 15 | &.primary { 16 | --progress-color: #{mat.get-color-from-palette(theme.$app-primary, lighter)}; 17 | --border-color: #{theme.$primary}; 18 | --done-color: #{theme.$primary}; 19 | } 20 | 21 | &.done { 22 | .filling-done { 23 | transform: scale(1); 24 | transition: transform 0.3s cubic-bezier(0.77, 0, 0.175, 1); 25 | transition-delay: 0.25s; 26 | } 27 | 28 | .path { 29 | animation: dash-check 0.5s ease-in-out forwards; 30 | animation-delay: 0.4s; 31 | } 32 | } 33 | } 34 | 35 | .filling-done { 36 | transform: scale(0); 37 | fill: var(--done-color); 38 | transform-origin: center; 39 | } 40 | 41 | .path { 42 | stroke-dasharray: 400; 43 | stroke-dashoffset: 500; 44 | stroke-linecap: round; 45 | stroke: white; 46 | stroke-width: 10px; 47 | transform: rotate(90deg) translate(16px, 20px) scale(0.9); 48 | transform-origin: center; 49 | } 50 | 51 | .pie { 52 | transform: rotate(-90deg); 53 | border-radius: 50%; 54 | } 55 | 56 | .filling { 57 | fill: transparent; 58 | stroke: var(--progress-color); 59 | stroke-width: 60px; 60 | transition: stroke-dasharray 0.2s ease-out; 61 | stroke-dasharray: calc(var(--percentage) * var(--max-stroke)) var(--max-stroke); 62 | } 63 | 64 | .border { 65 | fill: transparent; 66 | stroke-width: 8px; 67 | stroke: var(--border-color); 68 | } 69 | 70 | @keyframes dash-check { 71 | from { 72 | stroke-dashoffset: 400; 73 | } 74 | to { 75 | stroke-dashoffset: 0; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/app/shared/score-chart/score-chart.component.ts: -------------------------------------------------------------------------------- 1 | import { isPlatformBrowser } from '@angular/common'; 2 | import { 3 | ChangeDetectionStrategy, 4 | Component, 5 | ElementRef, 6 | Input, 7 | OnChanges, 8 | SimpleChanges, 9 | HostBinding, 10 | Inject, 11 | PLATFORM_ID 12 | } from '@angular/core'; 13 | 14 | @Component({ 15 | standalone: true, 16 | selector: 'ac-score-chart', 17 | templateUrl: './score-chart.component.html', 18 | styleUrls: ['./score-chart.component.scss'], 19 | changeDetection: ChangeDetectionStrategy.OnPush 20 | }) 21 | export class ScoreChartComponent implements OnChanges { 22 | @Input() score: number; 23 | 24 | @HostBinding('class.done') done = false; 25 | 26 | isBrowser = true; 27 | 28 | constructor(private elementRef: ElementRef, @Inject(PLATFORM_ID) platformId: string) { 29 | this.isBrowser = isPlatformBrowser(platformId); 30 | } 31 | 32 | ngOnChanges(changes: SimpleChanges) { 33 | if (changes.score && this.isBrowser) { 34 | this.elementRef.nativeElement.style.setProperty('--percentage', this.score); 35 | this.done = this.score === 1; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/app/shared/toolbar/toolbar-logo/toolbar-logo.component.html: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /src/app/shared/toolbar/toolbar-logo/toolbar-logo.component.scss: -------------------------------------------------------------------------------- 1 | .logo { 2 | display: flex; 3 | flex-direction: row; 4 | color: inherit; 5 | 6 | img { 7 | height: 38px; 8 | width: auto; 9 | } 10 | 11 | h4 { 12 | display: flex; 13 | align-items: center; 14 | margin-left: 8px; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/app/shared/toolbar/toolbar-logo/toolbar-logo.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { NgIf } from '@angular/common'; 3 | import { RouterLink } from '@angular/router'; 4 | 5 | @Component({ 6 | standalone: true, 7 | selector: 'ac-toolbar-logo', 8 | templateUrl: './toolbar-logo.component.html', 9 | styleUrls: ['./toolbar-logo.component.scss'], 10 | imports: [RouterLink, NgIf] 11 | }) 12 | export class ToolbarLogoComponent { 13 | @Input() showText = true; 14 | } 15 | -------------------------------------------------------------------------------- /src/app/shared/toolbar/toolbar.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/app/shared/toolbar/toolbar.component.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | @use '../../../scss/custom-theme' as theme; 3 | -------------------------------------------------------------------------------- /src/app/shared/toolbar/toolbar.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { MatToolbar } from '@angular/material/toolbar'; 3 | 4 | @Component({ 5 | standalone: true, 6 | selector: 'ac-toolbar', 7 | templateUrl: './toolbar.component.html', 8 | styleUrls: ['./toolbar.component.scss'], 9 | imports: [MatToolbar] 10 | }) 11 | export class ToolbarComponent {} 12 | -------------------------------------------------------------------------------- /src/app/shared/utils.ts: -------------------------------------------------------------------------------- 1 | import { EntityState } from './models'; 2 | 3 | export const isObject = (item: any) => { 4 | return item && typeof item === 'object' && !Array.isArray(item) && item !== null; 5 | }; 6 | 7 | export const hasEntities = (entityState: EntityState) => { 8 | return Object.keys(entityState).length > 0; 9 | }; 10 | 11 | export const convertToProjectId = (projectName: string) => { 12 | return projectName 13 | .toLowerCase() 14 | .replace(/\s+/g, '-') 15 | .trim(); 16 | }; 17 | -------------------------------------------------------------------------------- /src/app/state/app-state.utils.ts: -------------------------------------------------------------------------------- 1 | import { ChecklistFilter, ChecklistItem, ItemEntities } from '../checklist/models/checklist.model'; 2 | import { EntityState } from '../shared/models'; 3 | 4 | export const createChecklistItem = ( 5 | id: string, 6 | itemEntities: ItemEntities, 7 | projectItems: EntityState, 8 | favorites: EntityState 9 | ): ChecklistItem => { 10 | return { 11 | ...itemEntities[id], 12 | checked: projectItems[id], 13 | favorite: favorites[id] 14 | }; 15 | }; 16 | 17 | export const computeScore = (categoryItems: Array, items: EntityState) => { 18 | const score = categoryItems.reduce((acc, id) => { 19 | return items[id] ? acc + 1 : acc; 20 | }, 0); 21 | 22 | return calculatePercentage(score, categoryItems.length); 23 | }; 24 | 25 | export function calculatePercentage(value: number, max: number) { 26 | return (value * 1.0) / max; 27 | } 28 | 29 | export const filterItems = (items: Array, filter: ChecklistFilter) => { 30 | let filteredItems = items; 31 | 32 | if (filter === 'DONE') { 33 | filteredItems = items.filter(item => item.checked); 34 | } else if (filter === 'TODO') { 35 | filteredItems = items.filter(item => !item.checked); 36 | } 37 | 38 | return filteredItems; 39 | }; 40 | -------------------------------------------------------------------------------- /src/app/state/app.selectors.ts: -------------------------------------------------------------------------------- 1 | import { RouterReducerState } from '@ngrx/router-store'; 2 | import { createFeatureSelector, createSelector } from '@ngrx/store'; 3 | import { ChecklistState } from '../checklist/state/checklist.state'; 4 | import { ProjectsState } from '../projects/models/projects.model'; 5 | 6 | export namespace AppSelectors { 7 | export const getChecklistState = createFeatureSelector('checklist'); 8 | export const getProjectsState = createFeatureSelector('projects'); 9 | export const getRouterReducerState = createFeatureSelector('router'); 10 | export const getRouterState = createSelector(getRouterReducerState, router => router.state); 11 | export const getCategoryEntities = createSelector(getChecklistState, checklist => checklist.categories); 12 | export const getItemEntities = createSelector(getChecklistState, checklist => checklist.items); 13 | } 14 | -------------------------------------------------------------------------------- /src/app/state/app.state.ts: -------------------------------------------------------------------------------- 1 | import { routerReducer, RouterReducerState } from '@ngrx/router-store'; 2 | import { ActionReducer, ActionReducerMap } from '@ngrx/store'; 3 | import { storeFreeze } from 'ngrx-store-freeze'; 4 | import { localStorageSync } from 'ngrx-store-localstorage'; 5 | import { environment } from '../../environments/environment'; 6 | import { ChecklistState } from '../checklist/state/checklist.state'; 7 | import { ProjectsState } from '../projects/models/projects.model'; 8 | 9 | export interface ApplicationState { 10 | checklist: ChecklistState; 11 | projects: ProjectsState; 12 | router: RouterReducerState; 13 | } 14 | 15 | export function localStorageSyncReducer(reducer: ActionReducer): ActionReducer { 16 | return localStorageSync({ 17 | keys: ['projects'], 18 | rehydrate: true, 19 | checkStorageAvailability: true 20 | })(reducer); 21 | } 22 | 23 | const DEFAULT_META_REDUCERS = [localStorageSyncReducer]; 24 | const DEV_META_REDUCERS = [storeFreeze, ...DEFAULT_META_REDUCERS]; 25 | 26 | export const USER_PROVIDED_META_REDUCERS = !environment.production ? DEV_META_REDUCERS : DEFAULT_META_REDUCERS; 27 | 28 | export const ROOT_REDUCER: ActionReducerMap> = { 29 | router: routerReducer 30 | }; 31 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typebytes/angular-checklist/2f9ac4c6a01da0ed705f7ac52bc880832873a263/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/assets/angular-checklist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typebytes/angular-checklist/2f9ac4c6a01da0ed705f7ac52bc880832873a263/src/assets/angular-checklist.png -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build ---prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | }; 8 | 9 | /* 10 | * In development mode, to ignore zone related error stack frames such as 11 | * `zone.run`, `zoneDelegate.invokeTask` for easier debugging, you can 12 | * import the following file, but please comment it out in production mode 13 | * because it will have performance impact when throw error 14 | */ 15 | // import 'zone.js/dist/zone-error'; // Included with Angular CLI. 16 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typebytes/angular-checklist/2f9ac4c6a01da0ed705f7ac52bc880832873a263/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Angular Checklist 6 | 7 | 8 | 12 | 13 | 14 | 15 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import 'hammerjs'; 2 | 3 | import { enableProdMode } from '@angular/core'; 4 | 5 | import { environment } from './environments/environment'; 6 | import { bootstrapApplication } from '@angular/platform-browser'; 7 | import { AppComponent } from './app/app.component'; 8 | import { appConfig } from './app/app.config'; 9 | 10 | if (environment.production) { 11 | enableProdMode(); 12 | } 13 | 14 | function bootstrap() { 15 | bootstrapApplication(AppComponent, appConfig).catch(err => console.error(err)); 16 | } 17 | 18 | if (document.readyState === 'complete') { 19 | bootstrap(); 20 | } else { 21 | document.addEventListener('DOMContentLoaded', bootstrap); 22 | } 23 | -------------------------------------------------------------------------------- /src/scss/custom-theme.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | 3 | @include mat.core(); 4 | 5 | $custom-green: ( 6 | 50: #e6fcf5, 7 | 100: #bff8e6, 8 | 200: #95f3d5, 9 | 300: #6beec4, 10 | 400: #4beab8, 11 | 500: #2be6ab, 12 | 600: #26e3a4, 13 | 700: #20df9a, 14 | 800: #1adb91, 15 | 900: #10d580, 16 | A100: #ffffff, 17 | A200: #cfffe9, 18 | A400: #9cffd1, 19 | A700: #83ffc5, 20 | contrast: ( 21 | 50: black, 22 | 100: black, 23 | 200: black, 24 | 300: black, 25 | 400: black, 26 | 500: black, 27 | 600: black, 28 | 700: black, 29 | 800: black, 30 | 900: black, 31 | A100: black, 32 | A200: black, 33 | A400: black, 34 | A700: black, 35 | ) 36 | ); 37 | 38 | $app-primary: mat.define-palette(mat.$deep-purple-palette); 39 | $app-accent: mat.define-palette($custom-green, 600); 40 | $app-warn: mat.define-palette(mat.$red-palette); 41 | 42 | $app-theme: mat.define-light-theme(( 43 | color: ( 44 | primary: $app-primary, 45 | accent: $app-accent, 46 | warn: $app-warn, 47 | ) 48 | )); 49 | 50 | $primary: mat.get-theme-color($app-theme, primary); 51 | $accent: mat.get-theme-color($app-theme, accent); 52 | $warn: mat.get-theme-color($app-theme, warn); 53 | 54 | @include mat.all-component-themes($app-theme); 55 | -------------------------------------------------------------------------------- /src/scss/index.scss: -------------------------------------------------------------------------------- 1 | @use './custom-theme' as theme; 2 | -------------------------------------------------------------------------------- /src/scss/mixins.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | @use './custom-theme' as theme; 3 | 4 | @mixin linkHover() { 5 | --hover-box-translate-x: 120%; 6 | --hover-box-skew: 0; 7 | 8 | position: relative; 9 | 10 | &::after { 11 | content: ''; 12 | position: absolute; 13 | z-index: -1; 14 | background: mat.get-color-from-palette(theme.$app-primary, lighter, 0.3); 15 | top: 0; 16 | bottom: 0; 17 | right: 0; 18 | left: 0; 19 | transition: transform 0.2s cubic-bezier(0.35, 0, 0.25, 1); 20 | transform: skew(var(--hover-box-skew)) translateX(var(--hover-box-translate-x)); 21 | } 22 | 23 | &:not(.active):hover::after { 24 | --hover-box-translate-x: 200px; 25 | --hover-box-skew: 20deg; 26 | } 27 | 28 | &.active { 29 | color: theme.$primary; 30 | --hover-box-translate-x: 0; 31 | --hover-box-skew: 0deg; 32 | } 33 | } 34 | 35 | @mixin small-only { 36 | @media (max-width: 599px) { 37 | @content; 38 | } 39 | } 40 | 41 | @mixin medium-up { 42 | @media (min-width: 600px) { 43 | @content; 44 | } 45 | } 46 | 47 | @mixin large-up { 48 | @media (min-width: 900px) { 49 | @content; 50 | } 51 | } 52 | 53 | @mixin for-desktop-up { 54 | @media (min-width: 1200px) { 55 | @content; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/scss/utils.scss: -------------------------------------------------------------------------------- 1 | @mixin button-small() { 2 | font-size: 12px; 3 | height: 30px; 4 | line-height: 30px; 5 | } 6 | 7 | @mixin truncate() { 8 | overflow: hidden; 9 | white-space: nowrap; 10 | text-overflow: ellipsis; 11 | } 12 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | @use './scss/custom-theme' as theme; 2 | @import 'normalize.css/normalize.css'; 3 | @import 'highlight.js/styles/atom-one-dark.css'; 4 | @import 'scss/index'; 5 | 6 | html, 7 | body { 8 | height: 100%; 9 | font-family: Roboto, 'Helvetica Neue', sans-serif; 10 | overflow: hidden; 11 | } 12 | 13 | :root { 14 | --warn-color: hsl(48 95% 85% / 1); 15 | --warn-color-darker: hsl(48 95% 48% / 1) 16 | } 17 | 18 | a { 19 | color: theme.$primary; 20 | text-decoration: none; 21 | } 22 | 23 | .hljs { 24 | padding: 0.5em; 25 | border-radius: 8px; 26 | } 27 | 28 | // adding position and dimensions styles to avoid icon flickering with SSR 29 | .svg-inline--fa { 30 | vertical-align: -0.125em; 31 | } 32 | 33 | fa-icon svg { 34 | display: inline-block; 35 | font-size: inherit; 36 | height: 1em; 37 | } 38 | 39 | fa-icon .fa-2x { 40 | font-size: 2em; 41 | } 42 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/dist/zone-testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; 6 | 7 | // First, initialize the Angular testing environment. 8 | getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting()); 9 | -------------------------------------------------------------------------------- /tools/build-checklist.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | import { buildChecklist, dumpDataToDisk, printSuccess } from './utils'; 3 | 4 | const CONTENT_FOLDER = join(__dirname, '../content'); 5 | const ASSET_FOLDER = join(__dirname, '../src/assets'); 6 | 7 | buildChecklist(CONTENT_FOLDER).then(checklist => { 8 | if (checklist) { 9 | dumpDataToDisk('content', checklist, ASSET_FOLDER); 10 | printSuccess('Content was successfully compiled', 'Done'); 11 | process.exit(0); 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /tools/markdown.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt from 'markdown-it'; 2 | import hljs from 'highlight.js'; 3 | 4 | export const convertHeadingsPlugin = (md, options) => { 5 | md.core.ruler.push('convert_headings', convertHeadings); 6 | }; 7 | 8 | export const markdown = new MarkdownIt({ 9 | html: true, 10 | linkify: true, 11 | typographer: true, 12 | highlight: (str, lang) => { 13 | if (lang && hljs.getLanguage(lang)) { 14 | try { 15 | return `
${
16 |           hljs.highlight(str, { language: lang, ignoreIllegals: true }).value
17 |         }
`; 18 | } catch {} 19 | } 20 | 21 | return ''; 22 | } 23 | }); 24 | 25 | const convertHeadings = state => { 26 | state.tokens.forEach(function(token, i) { 27 | if (token.type === 'heading_open' || token.type === 'heading_close') { 28 | const rawToken = token.tag.split(''); 29 | rawToken[1] = parseInt(rawToken[1], 10) + 2; 30 | token.tag = rawToken.join(''); 31 | } 32 | }); 33 | }; 34 | -------------------------------------------------------------------------------- /tools/models.ts: -------------------------------------------------------------------------------- 1 | export interface File { 2 | name: string; 3 | path: string; 4 | } 5 | 6 | export interface Category { 7 | [key: string]: Array; 8 | } 9 | 10 | export interface ChecklistFrontMatter { 11 | title: string; 12 | summary?: string; 13 | description?: string; 14 | } 15 | 16 | export interface FrontMatter { 17 | data: ChecklistFrontMatter; 18 | content: string; 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/app", 5 | "types": ["node"] 6 | }, 7 | "files": ["src/main.ts"], 8 | "include": ["src/**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "forceConsistentCasingInFileNames": true, 7 | "noImplicitOverride": true, 8 | "noImplicitReturns": true, 9 | "noFallthroughCasesInSwitch": true, 10 | "downlevelIteration": true, 11 | "importHelpers": true, 12 | "sourceMap": true, 13 | "declaration": false, 14 | "moduleResolution": "node", 15 | "emitDecoratorMetadata": true, 16 | "experimentalDecorators": true, 17 | "target": "ES2022", 18 | "module": "ES2022", 19 | "lib": [ 20 | "es2020", 21 | "dom" 22 | ], 23 | "useDefineForClassFields": false, 24 | "allowSyntheticDefaultImports": true 25 | }, 26 | "angularCompilerOptions": { 27 | "enableI18nLegacyMessageIdFormat": false, 28 | "strictInjectionParameters": true, 29 | "strictInputAccessModifiers": true, 30 | "strictTemplates": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/spec", 5 | "types": ["jasmine", "node"] 6 | }, 7 | "files": ["src/test.ts"], 8 | "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /types/shorthash.d.ts: -------------------------------------------------------------------------------- 1 | interface Shorthash { 2 | unique(str: string): string; 3 | } 4 | 5 | declare var Shorthash: Shorthash; 6 | 7 | declare module "shorthash" { 8 | export = Shorthash; 9 | } 10 | --------------------------------------------------------------------------------