├── projects ├── examples │ ├── src │ │ ├── assets │ │ │ └── .gitkeep │ │ ├── app │ │ │ ├── app.component.scss │ │ │ ├── pager │ │ │ │ ├── pager.component.scss │ │ │ │ └── pager.component.ts │ │ │ ├── shopping-cart │ │ │ │ ├── shopping-cart.component.scss │ │ │ │ └── shopping-cart.component.ts │ │ │ ├── products │ │ │ │ ├── products.component.scss │ │ │ │ └── products.component.ts │ │ │ ├── products-with-facade │ │ │ │ ├── products-with-facade.component.scss │ │ │ │ └── products-with-facade.component.ts │ │ │ ├── app.component.html │ │ │ ├── app.component.ts │ │ │ └── products.facade.ts │ │ ├── ui │ │ │ ├── button │ │ │ │ ├── button.ui-component.html │ │ │ │ ├── button.ui-component.scss │ │ │ │ └── button.ui-component.ts │ │ │ ├── topbar │ │ │ │ ├── topbar.ui-component.html │ │ │ │ ├── topbar.ui-component.scss │ │ │ │ └── topbar.ui-component.ts │ │ │ ├── product │ │ │ │ ├── product.ui-component.html │ │ │ │ ├── product.ui-component.ts │ │ │ │ └── product.ui-component.scss │ │ │ ├── breadcrumb │ │ │ │ ├── breadcrumb.ui-component.html │ │ │ │ ├── breadcrumb.ui-component.ts │ │ │ │ └── breadcrumb.ui-component.scss │ │ │ └── sidebar │ │ │ │ ├── sidebar.ui-component.html │ │ │ │ ├── sidebar.ui-component.ts │ │ │ │ └── sidebar.ui-component.scss │ │ ├── styles.scss │ │ ├── favicon.ico │ │ ├── types │ │ │ ├── breadcrumb-item.type.ts │ │ │ ├── shopping-cart-entry.ts │ │ │ ├── create-category.dto.ts │ │ │ ├── update-category.dto.ts │ │ │ ├── category.type.ts │ │ │ ├── create-product.dto.ts │ │ │ ├── update-product.dto.ts │ │ │ └── product.type.ts │ │ ├── variables.scss │ │ ├── index.html │ │ ├── services │ │ │ ├── shopping-cart-signal-state.ts │ │ │ ├── product.service.ts │ │ │ └── category.service.ts │ │ ├── main.ts │ │ └── backend │ │ │ └── db.json │ ├── tsconfig.app.json │ ├── tsconfig.spec.json │ └── karma.conf.js └── ngx-signal-state │ ├── src │ ├── lib │ │ ├── types.ts │ │ ├── signal-state.ts │ │ └── signal-state.spec.ts │ └── public-api.ts │ ├── ng-package.json │ ├── tsconfig.lib.prod.json │ ├── tsconfig.spec.json │ ├── tsconfig.lib.json │ ├── package.json │ └── karma.conf.js ├── .husky └── commit-msg ├── .vscode ├── extensions.json ├── launch.json └── tasks.json ├── .commitlintrc.js ├── .editorconfig ├── .releaserc ├── .github └── workflows │ ├── ci.yml │ └── cd.yml ├── .gitignore ├── assets └── ngx-signal-state.svg ├── tsconfig.json ├── package.json ├── CHANGELOG.md ├── angular.json └── README.md /projects/examples/src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /projects/examples/src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /projects/examples/src/app/pager/pager.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /projects/examples/src/app/shopping-cart/shopping-cart.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /projects/examples/src/ui/button/button.ui-component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /projects/examples/src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit 5 | -------------------------------------------------------------------------------- /projects/examples/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplifiedcourses/ngx-signal-state/HEAD/projects/examples/src/favicon.ico -------------------------------------------------------------------------------- /projects/examples/src/ui/topbar/topbar.ui-component.html: -------------------------------------------------------------------------------- 1 | Shopping cart ({{shoppingcartAmount}}) 2 | -------------------------------------------------------------------------------- /projects/examples/src/types/breadcrumb-item.type.ts: -------------------------------------------------------------------------------- 1 | export type BreadcrumbItem = Readonly<{ 2 | label: string; 3 | route: string[]; 4 | }>; 5 | -------------------------------------------------------------------------------- /projects/examples/src/types/shopping-cart-entry.ts: -------------------------------------------------------------------------------- 1 | export type ShoppingCartEntry = Readonly<{ 2 | productId: number; 3 | amount: number 4 | }> 5 | -------------------------------------------------------------------------------- /projects/ngx-signal-state/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import { Signal } from '@angular/core'; 2 | 3 | export type PickedState = { [P in keyof T]: Signal }; 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": ["angular.ng-template"] 4 | } 5 | -------------------------------------------------------------------------------- /projects/examples/src/types/create-category.dto.ts: -------------------------------------------------------------------------------- 1 | export type CreateCategoryDto = { 2 | name: string; 3 | description: string; 4 | productsCount?: number; 5 | }; 6 | -------------------------------------------------------------------------------- /projects/examples/src/types/update-category.dto.ts: -------------------------------------------------------------------------------- 1 | export type UpdateCategoryDto = Partial<{ 2 | name: string; 3 | description: string; 4 | productsCount?: number; 5 | }>; 6 | -------------------------------------------------------------------------------- /projects/examples/src/types/category.type.ts: -------------------------------------------------------------------------------- 1 | export type Category = Readonly<{ 2 | id: number; 3 | name: string; 4 | description: string; 5 | productsCount?: number; 6 | }>; 7 | -------------------------------------------------------------------------------- /projects/examples/src/variables.scss: -------------------------------------------------------------------------------- 1 | $shoppie__gridunit: 8px; 2 | $shoppie-color__light-grey: #f1f1f1; 3 | $shoppie-color__grey: #ccc; 4 | $shoppie-color__accent: #267373; 5 | $shoppie-color__white: #fff; 6 | -------------------------------------------------------------------------------- /projects/ngx-signal-state/src/public-api.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Public API Surface of ngx-signal-state 3 | */ 4 | 5 | export {SignalState} from './lib/signal-state'; 6 | export {PickedState} from './lib/types'; 7 | -------------------------------------------------------------------------------- /projects/ngx-signal-state/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/ngx-signal-state", 4 | "lib": { 5 | "entryFile": "src/public-api.ts" 6 | } 7 | } -------------------------------------------------------------------------------- /projects/examples/src/types/create-product.dto.ts: -------------------------------------------------------------------------------- 1 | export type CreateProductDto = { 2 | name: string; 3 | description: string; 4 | price: number; 5 | advice: string; 6 | categoryId?: number; 7 | quantity?: number; 8 | }; 9 | -------------------------------------------------------------------------------- /.commitlintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | '@commitlint/config-conventional' 4 | ], 5 | rules: { 6 | 'body-max-line-length': [0, 'always'], 7 | 'footer-max-line-length': [0, 'always'] 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /projects/examples/src/types/update-product.dto.ts: -------------------------------------------------------------------------------- 1 | export type UpdateProductDto = Partial<{ 2 | name: string; 3 | description: string; 4 | price: number; 5 | advice: string; 6 | categoryId: number; 7 | quantity?: number; 8 | }>; 9 | -------------------------------------------------------------------------------- /projects/examples/src/app/products/products.component.scss: -------------------------------------------------------------------------------- 1 | .product-overview { 2 | &__content-right-items { 3 | display: flex; 4 | flex-wrap: wrap; 5 | } 6 | &__content-right-item { 7 | margin: 20px; 8 | } 9 | 10 | } 11 | -------------------------------------------------------------------------------- /projects/examples/src/types/product.type.ts: -------------------------------------------------------------------------------- 1 | export type Product = Readonly<{ 2 | id: number; 3 | name: string; 4 | description: string; 5 | price: number; 6 | advice: string; 7 | categoryId: number; 8 | quantity?: number; 9 | }>; 10 | -------------------------------------------------------------------------------- /projects/examples/src/app/products-with-facade/products-with-facade.component.scss: -------------------------------------------------------------------------------- 1 | .product-overview { 2 | &__content-right-items { 3 | display: flex; 4 | flex-wrap: wrap; 5 | } 6 | &__content-right-item { 7 | margin: 20px; 8 | } 9 | 10 | } 11 | -------------------------------------------------------------------------------- /projects/examples/src/ui/product/product.ui-component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

{{product?.name}}

4 |
{{product?.price}}
5 | 6 |
7 | -------------------------------------------------------------------------------- /projects/ngx-signal-state/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.lib.json", 4 | "compilerOptions": { 5 | "declarationMap": false 6 | }, 7 | "angularCompilerOptions": { 8 | "compilationMode": "partial" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /projects/examples/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 7 | 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /projects/examples/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /projects/examples/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "include": [ 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /projects/ngx-signal-state/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "include": [ 11 | "**/*.spec.ts", 12 | "**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /projects/examples/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Examples 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "master" 4 | ], 5 | "plugins": [ 6 | "@semantic-release/release-notes-generator", 7 | "@semantic-release/changelog", 8 | [ 9 | "@semantic-release/npm", 10 | { 11 | "pkgRoot": "dist/ngx-signal-state" 12 | } 13 | ], 14 | "@semantic-release/git", 15 | "@semantic-release/github" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /projects/examples/src/ui/topbar/topbar.ui-component.scss: -------------------------------------------------------------------------------- 1 | @import '../../variables'; 2 | 3 | :host { 4 | display: flex; 5 | justify-content: flex-end; 6 | align-items: center; 7 | min-height: 30px; 8 | background: $shoppie-color__accent; 9 | color: $shoppie-color__white; 10 | padding: $shoppie__gridunit; 11 | 12 | button { 13 | margin-left: $shoppie__gridunit * 2; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /projects/ngx-signal-state/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/lib", 6 | "declaration": true, 7 | "declarationMap": true, 8 | "inlineSources": true, 9 | "types": [] 10 | }, 11 | "exclude": [ 12 | "**/*.spec.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /projects/examples/src/ui/breadcrumb/breadcrumb.ui-component.html: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /projects/examples/src/ui/sidebar/sidebar.ui-component.html: -------------------------------------------------------------------------------- 1 | 13 | 14 | 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | jobs: 7 | ci: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout ✅ 11 | uses: actions/checkout@v2 12 | - name: Setup 🏗 13 | uses: actions/setup-node@v2 14 | with: 15 | node-version: lts/* 16 | cache: 'npm' 17 | - name: Install ⚙️ 18 | run: npm ci 19 | - name: Build 🛠 20 | run: npm run build:ci 21 | - name: Test 📋 22 | run: npm run test:ci 23 | -------------------------------------------------------------------------------- /projects/examples/src/ui/button/button.ui-component.scss: -------------------------------------------------------------------------------- 1 | @import '../../variables'; 2 | 3 | .shoppie-btn { 4 | border: 1px solid $shoppie-color__accent; 5 | padding: $shoppie__gridunit $shoppie__gridunit * 2; 6 | background: $shoppie-color__white; 7 | border-radius: $shoppie__gridunit; 8 | text-decoration: none; 9 | color: $shoppie-color__accent; 10 | 11 | &:hover { 12 | background: $shoppie-color__light-grey; 13 | } 14 | 15 | &:disabled { 16 | border-color: $shoppie-color__grey; 17 | color: $shoppie-color__grey; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "ng serve", 7 | "type": "chrome", 8 | "request": "launch", 9 | "preLaunchTask": "npm: start", 10 | "url": "http://localhost:4200/" 11 | }, 12 | { 13 | "name": "ng test", 14 | "type": "chrome", 15 | "request": "launch", 16 | "preLaunchTask": "npm: test", 17 | "url": "http://localhost:9876/debug.html" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /projects/examples/src/ui/sidebar/sidebar.ui-component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { RouterModule } from '@angular/router'; 4 | import { Category } from '../../types/category.type'; 5 | 6 | @Component({ 7 | selector: 'si-sidebar', 8 | standalone: true, 9 | imports: [CommonModule, RouterModule], 10 | templateUrl: './sidebar.ui-component.html', 11 | styleUrls: ['./sidebar.ui-component.scss'], 12 | }) 13 | export class SidebarUiComponent { 14 | @Input() public categories: Category[] | null = null; 15 | } 16 | -------------------------------------------------------------------------------- /projects/ngx-signal-state/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngx-signal-state", 3 | "version": "0.0.3", 4 | "license": "MIT", 5 | "author": "Brecht Billiet", 6 | "description": "Opinionated Microsized Simple State management library for Angular", 7 | "peerDependencies": { 8 | "@angular/core": ">=16.0.0", 9 | "@angular/common": ">=16.0.0", 10 | "rxjs": ">=6.0.0" 11 | }, 12 | "dependencies": { 13 | "tslib": "^2.3.0" 14 | }, 15 | "keywords": ["Angular", "State management", "Signals"], 16 | "repository": "https://github.com/simplifiedcourses/ngx-signal-state", 17 | "sideEffects": false 18 | } 19 | -------------------------------------------------------------------------------- /projects/examples/src/ui/topbar/topbar.ui-component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { RouterModule } from '@angular/router'; 4 | import { ButtonUiComponent } from '../button/button.ui-component'; 5 | 6 | @Component({ 7 | selector: 'si-topbar', 8 | standalone: true, 9 | imports: [CommonModule, ButtonUiComponent, RouterModule], 10 | templateUrl: './topbar.ui-component.html', 11 | styleUrls: ['./topbar.ui-component.scss'], 12 | }) 13 | export class TopbarUiComponent { 14 | @Input() public shoppingcartAmount: number|null = 0; 15 | } 16 | -------------------------------------------------------------------------------- /projects/examples/src/ui/breadcrumb/breadcrumb.ui-component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { RouterModule } from '@angular/router'; 4 | import { BreadcrumbItem } from '../../types/breadcrumb-item.type'; 5 | 6 | @Component({ 7 | selector: 'si-breadcrumb', 8 | standalone: true, 9 | imports: [CommonModule, RouterModule], 10 | templateUrl: './breadcrumb.ui-component.html', 11 | styleUrls: ['./breadcrumb.ui-component.scss'], 12 | }) 13 | export class BreadcrumbUiComponent { 14 | @Input() public breadcrumbItems: BreadcrumbItem[] = []; 15 | } 16 | -------------------------------------------------------------------------------- /projects/examples/src/ui/button/button.ui-component.ts: -------------------------------------------------------------------------------- 1 | import { Component, HostBinding, ViewEncapsulation } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | 4 | @Component({ 5 | // eslint-disable-next-line @angular-eslint/component-selector 6 | selector: 'a[siButton], button[siButton]', 7 | standalone: true, 8 | imports: [CommonModule], 9 | templateUrl: './button.ui-component.html', 10 | encapsulation: ViewEncapsulation.None, 11 | styleUrls: ['./button.ui-component.scss'], 12 | }) 13 | export class ButtonUiComponent { 14 | @HostBinding('class.shoppie-btn') public readonly class = true; 15 | } 16 | -------------------------------------------------------------------------------- /projects/examples/src/ui/breadcrumb/breadcrumb.ui-component.scss: -------------------------------------------------------------------------------- 1 | @import '../../variables'; 2 | 3 | .breadcrumb { 4 | display: flex; 5 | margin: 0; 6 | padding: 0; 7 | padding-bottom: $shoppie__gridunit * 2; 8 | 9 | li { 10 | list-style-type: none; 11 | 12 | a { 13 | padding-right: $shoppie__gridunit; 14 | color: $shoppie-color__accent; 15 | } 16 | 17 | i { 18 | padding-right: $shoppie__gridunit; 19 | } 20 | 21 | span { 22 | white-space: nowrap; 23 | display: flex; 24 | overflow: hidden; 25 | max-width: 300px; 26 | text-overflow: ellipsis; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.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 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /projects/examples/src/ui/product/product.ui-component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, Output } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { RouterModule } from '@angular/router'; 4 | import { Product } from '../../types/product.type'; 5 | import { ButtonUiComponent } from '../button/button.ui-component'; 6 | 7 | @Component({ 8 | selector: 'si-product', 9 | standalone: true, 10 | imports: [CommonModule, RouterModule, ButtonUiComponent], 11 | templateUrl: './product.ui-component.html', 12 | styleUrls: ['./product.ui-component.scss'], 13 | }) 14 | export class ProductUiComponent { 15 | @Input() public product: Product | null = null; 16 | @Output() public add = new EventEmitter() 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | cd: 8 | concurrency: ci-${{ github.ref }} 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout ✅ 12 | uses: actions/checkout@v2 13 | with: 14 | persist-credentials: false 15 | - name: Setup 🏗 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version: lts/* 19 | cache: 'npm' 20 | - name: Install ⚙️ 21 | run: npm ci 22 | - name: Build 🛠 23 | run: npm run build:ci 24 | - name: Test 📋 25 | run: npm run test:ci 26 | - name: Publish 📢 27 | env: 28 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 29 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 30 | run: npx semantic-release 31 | -------------------------------------------------------------------------------- /assets/ngx-signal-state.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /projects/examples/src/ui/sidebar/sidebar.ui-component.scss: -------------------------------------------------------------------------------- 1 | @import '../../variables'; 2 | 3 | :host { 4 | display: flex; 5 | width: 100%; 6 | flex-direction: column; 7 | background: $shoppie-color__light-grey; 8 | 9 | .sidebar { 10 | &__bottom { 11 | padding: $shoppie__gridunit * 2; 12 | display: flex; 13 | flex-direction: column; 14 | text-align: center; 15 | 16 | a { 17 | color: $shoppie-color__accent; 18 | } 19 | } 20 | 21 | &__nav { 22 | list-style-type: none; 23 | width: 100%; 24 | display: flex; 25 | margin: 0; 26 | padding: 0; 27 | flex-direction: column; 28 | 29 | a { 30 | display: flex; 31 | color: $shoppie-color__accent; 32 | text-decoration: none; 33 | padding: $shoppie__gridunit $shoppie__gridunit * 2; 34 | } 35 | 36 | &-item { 37 | &--active { 38 | background: #fff; 39 | } 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "type": "npm", 7 | "script": "start", 8 | "isBackground": true, 9 | "problemMatcher": { 10 | "owner": "typescript", 11 | "pattern": "$tsc", 12 | "background": { 13 | "activeOnStart": true, 14 | "beginsPattern": { 15 | "regexp": "(.*?)" 16 | }, 17 | "endsPattern": { 18 | "regexp": "bundle generation complete" 19 | } 20 | } 21 | } 22 | }, 23 | { 24 | "type": "npm", 25 | "script": "test", 26 | "isBackground": true, 27 | "problemMatcher": { 28 | "owner": "typescript", 29 | "pattern": "$tsc", 30 | "background": { 31 | "activeOnStart": true, 32 | "beginsPattern": { 33 | "regexp": "(.*?)" 34 | }, 35 | "endsPattern": { 36 | "regexp": "bundle generation complete" 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /projects/examples/src/services/shopping-cart-signal-state.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ShoppingCartEntry } from '../types/shopping-cart-entry'; 3 | import { SignalState } from 'ngx-signal-state'; 4 | 5 | export type ShoppingCartState = { 6 | entries: ShoppingCartEntry[] 7 | } 8 | 9 | @Injectable( 10 | { 11 | providedIn: 'root' 12 | } 13 | ) 14 | export class ShoppingCartSignalState extends SignalState { 15 | constructor() { 16 | super(); 17 | this.initialize({ 18 | entries: [] 19 | }) 20 | } 21 | 22 | public addToCart(entry: ShoppingCartEntry): void { 23 | const entries = [...this.snapshot.entries, entry]; 24 | this.patch({ entries }); 25 | } 26 | 27 | public deleteFromCart(id: number): void { 28 | const entries = this.snapshot.entries.filter(entry => entry.productId !== id); 29 | this.patch({ entries }); 30 | } 31 | 32 | public updateAmount(id: number, amount: number): void { 33 | const entries = this.snapshot.entries.map(item => item.productId === id ? { ...item, amount } : item); 34 | this.patch({ entries }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /projects/examples/src/ui/product/product.ui-component.scss: -------------------------------------------------------------------------------- 1 | @import '../../variables'; 2 | 3 | :host { 4 | display: flex; 5 | width: 200px; 6 | 7 | .product { 8 | position: relative; 9 | display: flex; 10 | padding: $shoppie__gridunit; 11 | border-radius: $shoppie__gridunit * 2; 12 | 13 | justify-content: space-between; 14 | align-items: center; 15 | 16 | h2 { 17 | overflow: hidden; 18 | text-overflow: ellipsis; 19 | } 20 | 21 | &__price { 22 | position: absolute; 23 | right: -10px; 24 | top: -10px; 25 | width: 50px; 26 | height: 50px; 27 | border-radius: 50%; 28 | background: $shoppie-color__accent; 29 | display: flex; 30 | color: $shoppie-color__white; 31 | justify-content: center; 32 | align-items: center; 33 | } 34 | 35 | h2 { 36 | padding-top: $shoppie__gridunit; 37 | word-wrap: break-word; 38 | white-space: normal; 39 | padding-bottom: $shoppie__gridunit; 40 | } 41 | 42 | flex-direction: column; 43 | 44 | &__image-placeholder { 45 | background: $shoppie-color__light-grey; 46 | width: 100%; 47 | min-height: 50px; 48 | } 49 | 50 | border: 1px solid $shoppie-color__accent; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /projects/examples/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, computed, inject } from '@angular/core'; 2 | import { RouterLink, RouterOutlet } from '@angular/router'; 3 | import { ShoppingCartSignalState } from '../services/shopping-cart-signal-state'; 4 | import { SignalState } from 'ngx-signal-state'; 5 | import { ShoppingCartEntry } from '../types/shopping-cart-entry'; 6 | 7 | type ViewModel = { 8 | amount: number 9 | } 10 | @Component({ 11 | selector: 'app-root', 12 | templateUrl: './app.component.html', 13 | imports: [RouterOutlet, RouterLink], 14 | standalone: true, 15 | styleUrls: ['./app.component.scss'] 16 | }) 17 | export class AppComponent extends SignalState<{ entries: ShoppingCartEntry[] }>{ 18 | private readonly shoppingCartState = inject(ShoppingCartSignalState) 19 | private readonly viewModel = computed(() => { 20 | return { 21 | amount: this.state().entries.reduce((amount: number, item) => amount + item.amount, 0) 22 | } 23 | }) 24 | constructor() { 25 | super(); 26 | this.initialize({ 27 | entries: this.shoppingCartState.snapshot.entries 28 | }) 29 | this.connect({ 30 | ...this.shoppingCartState.pick(['entries']) 31 | }) 32 | } 33 | protected get vm(): ViewModel { 34 | return this.viewModel(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "noImplicitOverride": true, 10 | "noPropertyAccessFromIndexSignature": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "sourceMap": true, 14 | "paths": { 15 | "my-lib": [ 16 | "dist/my-lib" 17 | ], 18 | "ngx-signal-state": [ 19 | "dist/ngx-signal-state" 20 | ], 21 | "signal-state": [ 22 | "dist/signal-state" 23 | ] 24 | }, 25 | "declaration": false, 26 | "downlevelIteration": true, 27 | "experimentalDecorators": true, 28 | "moduleResolution": "node", 29 | "importHelpers": true, 30 | "target": "ES2022", 31 | "module": "ES2022", 32 | "useDefineForClassFields": false, 33 | "lib": [ 34 | "ES2022", 35 | "dom" 36 | ] 37 | }, 38 | "angularCompilerOptions": { 39 | "enableI18nLegacyMessageIdFormat": false, 40 | "strictInjectionParameters": true, 41 | "strictInputAccessModifiers": true, 42 | "strictTemplates": true 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /projects/examples/src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from '@angular/platform-browser'; 2 | import { AppComponent } from './app/app.component'; 3 | import { provideRouter, Routes, withEnabledBlockingInitialNavigation } from '@angular/router'; 4 | import { PagerComponent } from './app/pager/pager.component'; 5 | import { ProductsComponent } from './app/products/products.component'; 6 | import { ShoppingCartComponent } from './app/shopping-cart/shopping-cart.component'; 7 | import { provideHttpClient } from '@angular/common/http'; 8 | import { ProductsWithFacadeComponent } from './app/products-with-facade/products-with-facade.component'; 9 | 10 | const appRoutes:Routes = [ 11 | { 12 | path: '', 13 | redirectTo: 'pager', 14 | pathMatch: 'full' 15 | }, 16 | { 17 | path: 'pager', 18 | component: PagerComponent 19 | }, 20 | { 21 | path: 'products', 22 | component: ProductsComponent 23 | }, 24 | { 25 | path: 'products-with-facade', 26 | component: ProductsWithFacadeComponent 27 | }, 28 | { 29 | path: 'shopping-cart', 30 | component: ShoppingCartComponent 31 | } 32 | ]; 33 | bootstrapApplication(AppComponent, { 34 | providers: [ 35 | provideHttpClient(), 36 | provideRouter(appRoutes, withEnabledBlockingInitialNavigation())] 37 | }) 38 | -------------------------------------------------------------------------------- /projects/examples/src/services/product.service.ts: -------------------------------------------------------------------------------- 1 | import { inject, Injectable } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { Observable } from 'rxjs'; 4 | import { UpdateProductDto } from '../types/update-product.dto'; 5 | import { Product } from '../types/product.type'; 6 | import { CreateProductDto } from '../types/create-product.dto'; 7 | 8 | @Injectable({ providedIn: 'root' }) 9 | export class ProductService { 10 | private readonly httpClient = inject(HttpClient); 11 | private apiUrl = 'http://localhost:3000/products'; 12 | 13 | public createProduct(product: CreateProductDto): Observable { 14 | return this.httpClient.post(this.apiUrl, product); 15 | } 16 | 17 | public getProducts(): Observable { 18 | return this.httpClient.get(this.apiUrl); 19 | } 20 | 21 | public getProductById(id: number): Observable { 22 | return this.httpClient.get(`${this.apiUrl}/${id}`); 23 | } 24 | 25 | public removeProduct(id: number): Observable { 26 | return this.httpClient.delete(`${this.apiUrl}/${id}`); 27 | } 28 | 29 | public updateProduct(id: number, dto: UpdateProductDto): Observable { 30 | return this.httpClient.put(`${this.apiUrl}/${id}`, dto); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /projects/examples/src/services/category.service.ts: -------------------------------------------------------------------------------- 1 | import { inject, Injectable } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { Observable } from 'rxjs'; 4 | import { Category } from '../types/category.type'; 5 | import { UpdateCategoryDto } from '../types/update-category.dto'; 6 | import { CreateCategoryDto } from '../types/create-category.dto'; 7 | 8 | @Injectable({ providedIn: 'root' }) 9 | export class CategoryService { 10 | private readonly httpClient = inject(HttpClient); 11 | private apiUrl = 'http://localhost:3000/categories'; 12 | 13 | public createCategory(dto: CreateCategoryDto): Observable { 14 | return this.httpClient.post(this.apiUrl, dto); 15 | } 16 | 17 | public getCategories(): Observable { 18 | return this.httpClient.get(this.apiUrl); 19 | } 20 | 21 | public getCategoryById(id: number): Observable { 22 | return this.httpClient.get(`${this.apiUrl}/${id}`); 23 | } 24 | 25 | public removeCategory(id: number): Observable { 26 | return this.httpClient.delete(`${this.apiUrl}/${id}`); 27 | } 28 | 29 | public updateCategory( 30 | id: number, 31 | dto: UpdateCategoryDto 32 | ): Observable { 33 | return this.httpClient.put(`${this.apiUrl}/${id}`, dto); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /projects/examples/src/app/products.facade.ts: -------------------------------------------------------------------------------- 1 | import { inject, Injectable, Signal } from '@angular/core'; 2 | import { ProductService } from '../services/product.service'; 3 | import { CategoryService } from '../services/category.service'; 4 | import { ShoppingCartSignalState, ShoppingCartState } from '../services/shopping-cart-signal-state'; 5 | import { Observable } from 'rxjs'; 6 | import { Product } from '../types/product.type'; 7 | import { Category } from '../types/category.type'; 8 | import { ShoppingCartEntry } from '../types/shopping-cart-entry'; 9 | import { PickedState } from '../../../ngx-signal-state/src/lib/types'; 10 | 11 | @Injectable({providedIn: 'root'}) 12 | export class ProductsFacade { 13 | private readonly productService = inject(ProductService); 14 | private readonly categoryService = inject(CategoryService); 15 | private readonly shoppingCartState = inject(ShoppingCartSignalState) 16 | 17 | public get shoppingCartSnapshot() { 18 | return this.shoppingCartState.snapshot; 19 | } 20 | public pickFromShoppingCartState(keys: (keyof ShoppingCartState)[]): PickedState { 21 | return this.shoppingCartState.pick(keys); 22 | } 23 | 24 | public getProducts(): Observable { 25 | return this.productService.getProducts(); 26 | } 27 | 28 | public getCategories(): Observable { 29 | return this.categoryService.getCategories(); 30 | } 31 | 32 | public addToCart(entry: ShoppingCartEntry): void { 33 | this.shoppingCartState.addToCart(entry) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /projects/examples/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 | jasmine: { 17 | // you can add configuration options for Jasmine here 18 | // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html 19 | // for example, you can disable the random execution with `random: false` 20 | // or set a specific seed with `seed: 4321` 21 | }, 22 | clearContext: false // leave Jasmine Spec Runner output visible in browser 23 | }, 24 | jasmineHtmlReporter: { 25 | suppressAll: true // removes the duplicated traces 26 | }, 27 | coverageReporter: { 28 | dir: require('path').join(__dirname, '../../coverage/examples'), 29 | subdir: '.', 30 | reporters: [ 31 | { type: 'html' }, 32 | { type: 'text-summary' } 33 | ] 34 | }, 35 | reporters: ['progress', 'kjhtml'], 36 | browsers: [ 37 | 'Chrome', 38 | 'ChromeHeadlessCI' 39 | ], 40 | customLaunchers: { 41 | ChromeHeadlessCI: { 42 | base: 'ChromeHeadless', 43 | flags: ['--no-sandbox'] 44 | } 45 | }, 46 | restartOnFileChange: true 47 | }); 48 | }; 49 | -------------------------------------------------------------------------------- /projects/ngx-signal-state/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 | jasmine: { 17 | // you can add configuration options for Jasmine here 18 | // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html 19 | // for example, you can disable the random execution with `random: false` 20 | // or set a specific seed with `seed: 4321` 21 | }, 22 | clearContext: false // leave Jasmine Spec Runner output visible in browser 23 | }, 24 | jasmineHtmlReporter: { 25 | suppressAll: true // removes the duplicated traces 26 | }, 27 | coverageReporter: { 28 | dir: require('path').join(__dirname, '../../coverage/ngx-signal-state'), 29 | subdir: '.', 30 | reporters: [ 31 | { type: 'html' }, 32 | { type: 'text-summary' } 33 | ] 34 | }, 35 | reporters: ['progress', 'kjhtml'], 36 | browsers: [ 37 | 'Chrome', 38 | 'ChromeHeadlessCI' 39 | ], 40 | customLaunchers: { 41 | ChromeHeadlessCI: { 42 | base: 'ChromeHeadless', 43 | flags: ['--no-sandbox'] 44 | } 45 | }, 46 | restartOnFileChange: true 47 | }); 48 | }; 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simplified", 3 | "version": "0.0.0-development", 4 | "license": "MIT", 5 | "scripts": { 6 | "ng": "ng", 7 | "start": "ng serve", 8 | "api": "json-server projects/examples/src/backend/db.json", 9 | "build": "ng build", 10 | "watch": "ng build --watch --configuration development", 11 | "test": "ng test", 12 | "prepare": "husky install", 13 | "build:app": "ng build examples --configuration=production --base-href=/ngx-signal-state/", 14 | "build:lib": "ng build ngx-signal-state --configuration=production", 15 | "build:ci": "npm run build:lib && npm run build:app", 16 | "postbuild:lib": "copyfiles README.md dist/ngx-signal-state", 17 | "test:lib": "ng test ngx-signal-state --no-watch --no-progress --browsers=ChromeHeadlessCI", 18 | "test:ci": "npm run test:lib", 19 | "semantic-release": "semantic-release" 20 | }, 21 | "private": true, 22 | "dependencies": { 23 | "@angular/animations": "^18.0.1", 24 | "@angular/common": "^18.0.1", 25 | "@angular/compiler": "^18.0.1", 26 | "@angular/core": "^18.0.1", 27 | "@angular/forms": "^18.0.1", 28 | "@angular/platform-browser": "^18.0.1", 29 | "@angular/platform-browser-dynamic": "^18.0.1", 30 | "@angular/router": "^18.0.1", 31 | "rxjs": "~7.8.0", 32 | "tslib": "^2.3.0", 33 | "zone.js": "~0.14.6" 34 | }, 35 | "devDependencies": { 36 | "@angular-devkit/build-angular": "^18.0.2", 37 | "@angular/cli": "~18.0.2", 38 | "@angular/compiler-cli": "^18.0.1", 39 | "@commitlint/cli": "^18.2.0", 40 | "@commitlint/config-conventional": "^18.1.0", 41 | "@semantic-release/changelog": "^6.0.3", 42 | "@semantic-release/git": "^10.0.1", 43 | "@types/jasmine": "~4.3.0", 44 | "copyfiles": "^2.4.1", 45 | "husky": "^8.0.3", 46 | "jasmine-core": "~4.6.0", 47 | "json-server": "^0.17.0", 48 | "karma": "~6.4.0", 49 | "karma-chrome-launcher": "~3.2.0", 50 | "karma-coverage": "~2.2.0", 51 | "karma-jasmine": "~5.1.0", 52 | "karma-jasmine-html-reporter": "~2.0.0", 53 | "ng-packagr": "^18.0.0", 54 | "semantic-release": "^22.0.5", 55 | "semantic-release-cli": "^5.4.4", 56 | "typescript": "~5.4.5" 57 | }, 58 | "repository": { 59 | "type": "git", 60 | "url": "https://github.com/simplifiedcourses/ngx-signal-state.git" 61 | } 62 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [1.2.0](https://github.com/simplifiedcourses/ngx-signal-state/compare/v1.1.4...v1.2.0) (2024-06-05) 2 | 3 | 4 | ### Features 5 | 6 | * **ng upgrade:** upgraded to angular 17 ([f6b8d3a](https://github.com/simplifiedcourses/ngx-signal-state/commit/f6b8d3a3ccc1589a687705c65ee55bf50d0bb966)) 7 | * **ng upgrade:** upgraded to angular 18 ([76847b5](https://github.com/simplifiedcourses/ngx-signal-state/commit/76847b51aafb617ca89817e026af7882474f31b7)) 8 | 9 | ## [1.1.4](https://github.com/simplifiedcourses/ngx-signal-state/compare/v1.1.3...v1.1.4) (2024-05-29) 10 | 11 | 12 | ### Bug Fixes 13 | 14 | * **pager:** use this.snapshot ([9b26661](https://github.com/simplifiedcourses/ngx-signal-state/commit/9b2666114f87452c8024f6c57216fe363bc2667f)) 15 | 16 | ## [1.1.3](https://github.com/simplifiedcourses/ngx-signal-state/compare/v1.1.2...v1.1.3) (2024-05-29) 17 | 18 | 19 | ### Bug Fixes 20 | 21 | * **pager:** use PagerInputState ([6aed6bb](https://github.com/simplifiedcourses/ngx-signal-state/commit/6aed6bbc7bc58246e56a770f22269746427c4078)) 22 | * typo ([d42b908](https://github.com/simplifiedcourses/ngx-signal-state/commit/d42b908948d803f5edff9b79a28e3108aad834bf)) 23 | 24 | ## [1.1.2](https://github.com/simplifiedcourses/ngx-signal-state/compare/v1.1.1...v1.1.2) (2024-04-19) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * **connect:** fix issue with connect effect ([82b9bcd](https://github.com/simplifiedcourses/ngx-signal-state/commit/82b9bcdc3162b63988159993b57595e805969a99)) 30 | 31 | ## [1.1.1](https://github.com/simplifiedcourses/ngx-signal-state/compare/v1.1.0...v1.1.1) (2023-11-01) 32 | 33 | 34 | ### Bug Fixes 35 | 36 | * **ngx-signal-state:** removed carrot from peer deps ([168b26f](https://github.com/simplifiedcourses/ngx-signal-state/commit/168b26f11b15dbb6d8f2800d252057b07c59c2a5)), closes [#9](https://github.com/simplifiedcourses/ngx-signal-state/issues/9) 37 | 38 | # [1.1.0](https://github.com/simplifiedcourses/ngx-signal-state/compare/v1.0.0...v1.1.0) (2023-10-29) 39 | 40 | 41 | ### Features 42 | 43 | * **projects:** added docs on facade and exposed PickedState type ([c604605](https://github.com/simplifiedcourses/ngx-signal-state/commit/c6046050eb4ec687e32036d435bb673601da9261)) 44 | 45 | # 1.0.0 (2023-10-26) 46 | 47 | 48 | ### Bug Fixes 49 | 50 | * **ngx-signal-state:** fixed bad peer dependency ([0f709d6](https://github.com/simplifiedcourses/ngx-signal-state/commit/0f709d62acf456aa1540bfcfdff607393be260df)) 51 | -------------------------------------------------------------------------------- /projects/examples/src/app/shopping-cart/shopping-cart.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, computed, inject, Signal } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { SignalState } from 'ngx-signal-state'; 4 | import { ShoppingCartEntry } from '../../types/shopping-cart-entry'; 5 | import { Product } from '../../types/product.type'; 6 | import { BreadcrumbItem } from '../../types/breadcrumb-item.type'; 7 | import { BreadcrumbUiComponent } from '../../ui/breadcrumb/breadcrumb.ui-component'; 8 | import { ShoppingCartSignalState } from '../../services/shopping-cart-signal-state'; 9 | import { ProductService } from '../../services/product.service'; 10 | 11 | type ShoppingCartSmartComponentState = { 12 | entries: ShoppingCartEntry[]; 13 | products: Product[]; 14 | } 15 | 16 | type ViewModel = { 17 | shoppingCartEntriesWithProductInfo: (ShoppingCartEntry & { price: number, name: string })[]; 18 | amount: number; 19 | totalPrice: number; 20 | } 21 | 22 | @Component({ 23 | selector: 'app-shopping-cart', 24 | standalone: true, 25 | imports: [CommonModule, BreadcrumbUiComponent], 26 | template: ` 27 | 28 |

My shopping cart

29 |
30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 |
ProductAmountPrice/item
{{entry.name}} 42 | 48 | {{entry.price}} €
Total{{vm.amount}}{{vm.totalPrice}} €
58 |
59 | `, 60 | styleUrls: ['./shopping-cart.component.scss'] 61 | }) 62 | export class ShoppingCartComponent extends SignalState { 63 | private readonly shoppingCartSignalState = inject(ShoppingCartSignalState); 64 | private readonly productService = inject(ProductService) 65 | 66 | public readonly breadcrumbItems: BreadcrumbItem[] = [ 67 | { 68 | label: 'Home', 69 | route: [''], 70 | }, 71 | { 72 | label: 'Shopping-cart', 73 | route: ['/payment', 'shopping-cart'], 74 | }, 75 | ]; 76 | 77 | 78 | constructor() { 79 | super(); 80 | this.initialize({ 81 | entries: this.shoppingCartSignalState.snapshot.entries, 82 | products: [], 83 | }); 84 | this.connectObservables({ 85 | products: this.productService.getProducts() 86 | }) 87 | this.connect({ 88 | ...this.shoppingCartSignalState.pick(['entries']), 89 | }) 90 | } 91 | 92 | private readonly viewModel: Signal = computed(() => { 93 | const { entries, products } = this.state(); 94 | if (products.length === 0) { 95 | return { 96 | totalPrice: 0, 97 | amount: 0, 98 | shoppingCartEntriesWithProductInfo: [] 99 | } 100 | } 101 | const shoppingCartEntriesWithProductInfo = entries.map(entry => { 102 | const product: Product | undefined = products.find(p => p.id === entry.productId) 103 | if (!product) { 104 | throw new Error('product not found') 105 | } 106 | return { ...entry, price: product.price, name: product.name } 107 | }) 108 | return { 109 | shoppingCartEntriesWithProductInfo, 110 | totalPrice: shoppingCartEntriesWithProductInfo 111 | .reduce((totalPrice: number, item) => totalPrice + (item?.amount * item.price), 0), 112 | amount: entries 113 | .reduce((amount: number, item) => amount + item.amount, 0) 114 | } 115 | }) 116 | 117 | protected get vm(): ViewModel { 118 | return this.viewModel(); 119 | } 120 | 121 | public updateAmount(event: Event, productId: number): void { 122 | this.shoppingCartSignalState.updateAmount(productId, Number((event.target as HTMLInputElement).value)); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "ngx-signal-state": { 7 | "projectType": "library", 8 | "root": "projects/ngx-signal-state", 9 | "sourceRoot": "projects/ngx-signal-state/src", 10 | "prefix": "lib", 11 | "architect": { 12 | "build": { 13 | "builder": "@angular-devkit/build-angular:ng-packagr", 14 | "options": { 15 | "project": "projects/ngx-signal-state/ng-package.json" 16 | }, 17 | "configurations": { 18 | "production": { 19 | "tsConfig": "projects/ngx-signal-state/tsconfig.lib.prod.json" 20 | }, 21 | "development": { 22 | "tsConfig": "projects/ngx-signal-state/tsconfig.lib.json" 23 | } 24 | }, 25 | "defaultConfiguration": "production" 26 | }, 27 | "test": { 28 | "builder": "@angular-devkit/build-angular:karma", 29 | "options": { 30 | "codeCoverage": true, 31 | "tsConfig": "projects/ngx-signal-state/tsconfig.spec.json", 32 | "polyfills": [ 33 | "zone.js", 34 | "zone.js/testing" 35 | ], 36 | "karmaConfig": "projects/ngx-signal-state/karma.conf.js" 37 | } 38 | } 39 | } 40 | }, 41 | "examples": { 42 | "projectType": "application", 43 | "schematics": { 44 | "@schematics/angular:component": { 45 | "style": "scss" 46 | } 47 | }, 48 | "root": "projects/examples", 49 | "sourceRoot": "projects/examples/src", 50 | "prefix": "app", 51 | "architect": { 52 | "build": { 53 | "builder": "@angular-devkit/build-angular:browser", 54 | "options": { 55 | "outputPath": "dist/examples", 56 | "index": "projects/examples/src/index.html", 57 | "main": "projects/examples/src/main.ts", 58 | "polyfills": [ 59 | "zone.js" 60 | ], 61 | "tsConfig": "projects/examples/tsconfig.app.json", 62 | "inlineStyleLanguage": "scss", 63 | "assets": [ 64 | "projects/examples/src/favicon.ico", 65 | "projects/examples/src/assets" 66 | ], 67 | "styles": [ 68 | "projects/examples/src/styles.scss" 69 | ], 70 | "scripts": [] 71 | }, 72 | "configurations": { 73 | "production": { 74 | "budgets": [ 75 | { 76 | "type": "initial", 77 | "maximumWarning": "500kb", 78 | "maximumError": "1mb" 79 | }, 80 | { 81 | "type": "anyComponentStyle", 82 | "maximumWarning": "2kb", 83 | "maximumError": "4kb" 84 | } 85 | ], 86 | "outputHashing": "all" 87 | }, 88 | "development": { 89 | "buildOptimizer": false, 90 | "optimization": false, 91 | "vendorChunk": true, 92 | "extractLicenses": false, 93 | "sourceMap": true, 94 | "namedChunks": true 95 | } 96 | }, 97 | "defaultConfiguration": "production" 98 | }, 99 | "serve": { 100 | "builder": "@angular-devkit/build-angular:dev-server", 101 | "configurations": { 102 | "production": { 103 | "buildTarget": "examples:build:production" 104 | }, 105 | "development": { 106 | "buildTarget": "examples:build:development" 107 | } 108 | }, 109 | "defaultConfiguration": "development" 110 | }, 111 | "extract-i18n": { 112 | "builder": "@angular-devkit/build-angular:extract-i18n", 113 | "options": { 114 | "buildTarget": "examples:build" 115 | } 116 | }, 117 | "test": { 118 | "builder": "@angular-devkit/build-angular:karma", 119 | "options": { 120 | "polyfills": [ 121 | "zone.js", 122 | "zone.js/testing" 123 | ], 124 | "tsConfig": "projects/examples/tsconfig.spec.json", 125 | "inlineStyleLanguage": "scss", 126 | "assets": [ 127 | "projects/examples/src/favicon.ico", 128 | "projects/examples/src/assets" 129 | ], 130 | "styles": [ 131 | "projects/examples/src/styles.scss" 132 | ], 133 | "scripts": [], 134 | "karmaConfig": "projects/examples/karma.conf.js" 135 | } 136 | } 137 | } 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /projects/examples/src/app/pager/pager.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, computed, EventEmitter, Input, Output, Signal } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { SignalState } from 'ngx-signal-state'; 4 | 5 | type ViewModel = Readonly<{ 6 | itemFrom: number; 7 | itemTo: number; 8 | total: number; 9 | previousDisabled: boolean; 10 | nextDisabled: boolean; 11 | showItemsPerPage: boolean; 12 | itemsPerPageOptions: number[]; 13 | }>; 14 | 15 | type PagerInputState = { 16 | itemsPerPage: number; 17 | total: number; 18 | pageIndex: number; 19 | } 20 | type PagerState = PagerInputState & { 21 | showItemsPerPage: boolean; 22 | itemsPerPageOptions: number[]; 23 | } 24 | 25 | @Component({ 26 | selector: 'si-pager', 27 | standalone: true, 28 | imports: [CommonModule], 29 | template: ` 30 | Showing {{ vm.itemFrom }} 31 | to 32 | {{ vm.itemTo }} 33 | of {{ vm.total }} entries 34 | 37 | 40 | 43 | 46 | 47 | 55 | `, 56 | }) 57 | export class PagerUiComponent extends SignalState { 58 | @Input() 59 | public set itemsPerPage(v: number) { 60 | this.patch({ itemsPerPage: v }) 61 | } 62 | 63 | @Input() 64 | public set total(v: number) { 65 | this.patch({ total: v }) 66 | } 67 | 68 | @Input() 69 | public set pageIndex(v: number) { 70 | this.patch({ pageIndex: v }) 71 | }; 72 | 73 | @Output() public readonly pageIndexChange = new EventEmitter(); 74 | @Output() public readonly itemsPerPageChange = new EventEmitter(); 75 | 76 | constructor() { 77 | super(); 78 | this.initialize({ 79 | itemsPerPage: 0, 80 | total: 0, 81 | pageIndex: 0, 82 | showItemsPerPage: false, 83 | itemsPerPageOptions: [5, 10, 20] 84 | }); 85 | } 86 | 87 | private readonly viewModel: Signal = computed(() => { 88 | const { total, pageIndex, itemsPerPage, showItemsPerPage, itemsPerPageOptions } = this.state(); 89 | return { 90 | total, 91 | previousDisabled: pageIndex === 0, 92 | nextDisabled: pageIndex >= Math.ceil(total / itemsPerPage) - 1, 93 | itemFrom: pageIndex * itemsPerPage + 1, 94 | showItemsPerPage, 95 | itemsPerPageOptions, 96 | itemTo: 97 | pageIndex < Math.ceil(total / itemsPerPage) - 1 98 | ? pageIndex * itemsPerPage + itemsPerPage 99 | : total, 100 | } 101 | }) 102 | 103 | public get vm(): ViewModel { 104 | return this.viewModel(); 105 | } 106 | 107 | public toggleShowItemsPerPage(): void { 108 | this.patch({ showItemsPerPage: !this.snapshot.showItemsPerPage }); 109 | } 110 | 111 | public goToStart(): void { 112 | this.pageIndexChange.emit(0); 113 | } 114 | 115 | public next(): void { 116 | this.pageIndexChange.emit(this.snapshot.pageIndex + 1); 117 | } 118 | 119 | public previous(): void { 120 | this.pageIndexChange.emit(this.snapshot.pageIndex - 1); 121 | } 122 | 123 | public goToEnd(): void { 124 | this.pageIndexChange.emit(Math.ceil(this.snapshot.total / this.snapshot.itemsPerPage) - 1); 125 | } 126 | 127 | public itemsPerPageChanged(option: any): void { 128 | this.itemsPerPageChange.emit(+option?.target?.value) 129 | } 130 | } 131 | 132 | @Component({ 133 | selector: 'app-pager', 134 | standalone: true, 135 | imports: [CommonModule, PagerUiComponent], 136 | template: ` 137 |

Pager example

138 |

This page shows how to deal with local component state for a dumb component and a simple smart component

139 | 146 | `, 147 | styleUrls: ['./pager.component.scss'] 148 | }) 149 | export class PagerComponent extends SignalState { 150 | constructor() { 151 | super(); 152 | this.initialize({ 153 | pageIndex: 0, 154 | itemsPerPage: 5, 155 | total: 100 156 | }) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /projects/examples/src/app/products/products.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, computed, inject, Signal } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { SignalState } from 'ngx-signal-state'; 4 | import { Category } from '../../types/category.type'; 5 | import { Product } from '../../types/product.type'; 6 | import { BreadcrumbItem } from '../../types/breadcrumb-item.type'; 7 | import { interval, map } from 'rxjs'; 8 | import { SidebarUiComponent } from '../../ui/sidebar/sidebar.ui-component'; 9 | import { BreadcrumbUiComponent } from '../../ui/breadcrumb/breadcrumb.ui-component'; 10 | import { ProductUiComponent } from '../../ui/product/product.ui-component'; 11 | import { PagerUiComponent } from '../pager/pager.component'; 12 | import { ProductService } from '../../services/product.service'; 13 | import { CategoryService } from '../../services/category.service'; 14 | import { FormsModule } from '@angular/forms'; 15 | import { ShoppingCartSignalState } from '../../services/shopping-cart-signal-state'; 16 | 17 | type ProductOverviewState = { 18 | pageIndex: number; 19 | query: string; 20 | itemsPerPage: number; 21 | categories: Category[]; 22 | products: Product[]; 23 | filteredProducts: Product[]; 24 | pagedProducts: Product[]; 25 | time: number; 26 | } 27 | 28 | type ViewModel = 29 | Pick 30 | & { 31 | total: number; 32 | } 33 | 34 | @Component({ 35 | selector: 'app-products', 36 | standalone: true, 37 | imports: [CommonModule, FormsModule, SidebarUiComponent, BreadcrumbUiComponent, ProductUiComponent, PagerUiComponent], 38 | template: ` 39 |
40 |
41 | 42 |
43 |

Product overview

44 |
45 | 49 |
50 |
51 |
52 | 59 |
60 | 67 | 68 | {{vm.time|date: 'hh:mm:ss'}} 69 |
70 |
71 | 72 | `, 73 | styleUrls: ['./products.component.scss'] 74 | }) 75 | export class ProductsComponent extends SignalState { 76 | private readonly productService = inject(ProductService); 77 | private readonly categoryService = inject(CategoryService); 78 | private readonly shoppingCartState = inject(ShoppingCartSignalState) 79 | protected readonly breadcrumbItems: BreadcrumbItem[] = [ 80 | { 81 | label: 'Home', 82 | route: [''], 83 | }, 84 | { 85 | label: 'Products', 86 | route: ['/products'], 87 | }, 88 | ]; 89 | 90 | private readonly filteredProducts = this.selectMany(['products', 'query'], 91 | ({ products, query }) => { 92 | return products.filter(p => p.name.toLowerCase().indexOf(query.toLowerCase()) > -1) 93 | }) 94 | 95 | private readonly pagedProducts = this.selectMany(['filteredProducts', 'pageIndex', 'itemsPerPage'], 96 | ({ filteredProducts, pageIndex, itemsPerPage }) => { 97 | const offsetStart = (pageIndex) * itemsPerPage; 98 | const offsetEnd = (pageIndex + 1) * itemsPerPage; 99 | return filteredProducts.slice(offsetStart, offsetEnd); 100 | }) 101 | 102 | private readonly viewModel: Signal = computed(() => { 103 | const { categories, filteredProducts, pagedProducts, pageIndex, itemsPerPage, query, time } = this.state() 104 | return { 105 | total: filteredProducts.length, 106 | query: query, 107 | categories, 108 | itemsPerPage, 109 | pageIndex, 110 | products: pagedProducts, 111 | time 112 | } 113 | }); 114 | 115 | protected get vm(): ViewModel { 116 | return this.viewModel(); 117 | } 118 | 119 | constructor() { 120 | super(); 121 | this.initialize({ 122 | pageIndex: 0, 123 | itemsPerPage: 5, 124 | query: '', 125 | categories: [], 126 | products: [], 127 | filteredProducts: [], 128 | pagedProducts: [], 129 | time: new Date().getTime() 130 | }); 131 | this.connectObservables({ 132 | products: this.productService.getProducts(), 133 | categories: this.categoryService.getCategories(), 134 | time: interval(1000).pipe(map(() => new Date().getTime())), 135 | }) 136 | this.connect({ 137 | filteredProducts: this.filteredProducts, 138 | pagedProducts: this.pagedProducts, 139 | }) 140 | } 141 | 142 | 143 | protected setQuery(query: string): void { 144 | this.patch({ pageIndex: 0, query }) 145 | } 146 | 147 | protected pageIndexChange(pageIndex: number): void { 148 | this.patch({ pageIndex }); 149 | } 150 | 151 | protected itemsPerPageChange(itemsPerPage: number): void { 152 | this.patch({ pageIndex: 0, itemsPerPage }) 153 | } 154 | 155 | protected addToCard(product: Product): void { 156 | this.shoppingCartState.addToCart({ productId: product.id, amount: 1 }); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /projects/examples/src/app/products-with-facade/products-with-facade.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, computed, inject, Signal } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { SignalState } from 'ngx-signal-state'; 4 | import { Category } from '../../types/category.type'; 5 | import { Product } from '../../types/product.type'; 6 | import { BreadcrumbItem } from '../../types/breadcrumb-item.type'; 7 | import { interval, map } from 'rxjs'; 8 | import { SidebarUiComponent } from '../../ui/sidebar/sidebar.ui-component'; 9 | import { BreadcrumbUiComponent } from '../../ui/breadcrumb/breadcrumb.ui-component'; 10 | import { ProductUiComponent } from '../../ui/product/product.ui-component'; 11 | import { PagerUiComponent } from '../pager/pager.component'; 12 | import { FormsModule } from '@angular/forms'; 13 | import { ProductsFacade } from '../products.facade'; 14 | import { ShoppingCartState } from '../../services/shopping-cart-signal-state'; 15 | 16 | type ProductOverviewState = { 17 | pageIndex: number; 18 | query: string; 19 | itemsPerPage: number; 20 | categories: Category[]; 21 | products: Product[]; 22 | filteredProducts: Product[]; 23 | pagedProducts: Product[]; 24 | time: number; 25 | } & Pick 26 | 27 | type ViewModel = 28 | Pick 29 | & { 30 | total: number; 31 | } 32 | 33 | @Component({ 34 | selector: 'app-products', 35 | standalone: true, 36 | imports: [CommonModule, FormsModule, SidebarUiComponent, BreadcrumbUiComponent, ProductUiComponent, PagerUiComponent], 37 | template: ` 38 |
39 |
40 | 41 |
42 |

Product overview

43 |
44 | 48 |
49 |
50 |
51 | 58 |
59 | 66 | 67 | {{vm.time|date: 'hh:mm:ss'}} 68 |
{{vm.entries|json}}
69 |
70 |
71 | 72 | `, 73 | styleUrls: ['./products-with-facade.component.scss'] 74 | }) 75 | export class ProductsWithFacadeComponent extends SignalState { 76 | private readonly productsFacade = inject(ProductsFacade) 77 | protected readonly breadcrumbItems: BreadcrumbItem[] = [ 78 | { 79 | label: 'Home', 80 | route: [''], 81 | }, 82 | { 83 | label: 'Products', 84 | route: ['/products'], 85 | }, 86 | ]; 87 | 88 | private readonly filteredProducts = this.selectMany(['products', 'query'], 89 | ({ products, query }) => { 90 | return products.filter(p => p.name.toLowerCase().indexOf(query.toLowerCase()) > -1) 91 | }) 92 | 93 | private readonly pagedProducts = this.selectMany(['filteredProducts', 'pageIndex', 'itemsPerPage'], 94 | ({ filteredProducts, pageIndex, itemsPerPage }) => { 95 | const offsetStart = (pageIndex) * itemsPerPage; 96 | const offsetEnd = (pageIndex + 1) * itemsPerPage; 97 | return filteredProducts.slice(offsetStart, offsetEnd); 98 | }) 99 | 100 | private readonly viewModel: Signal = computed(() => { 101 | const { categories, entries, filteredProducts, pagedProducts, pageIndex, itemsPerPage, query, time } = this.state() 102 | return { 103 | total: filteredProducts.length, 104 | query: query, 105 | categories, 106 | itemsPerPage, 107 | pageIndex, 108 | products: pagedProducts, 109 | time, 110 | entries 111 | } 112 | }); 113 | 114 | protected get vm(): ViewModel { 115 | return this.viewModel(); 116 | } 117 | 118 | constructor() { 119 | super(); 120 | this.initialize({ 121 | pageIndex: 0, 122 | itemsPerPage: 5, 123 | query: '', 124 | categories: [], 125 | products: [], 126 | filteredProducts: [], 127 | pagedProducts: [], 128 | time: new Date().getTime(), 129 | entries: this.productsFacade.shoppingCartSnapshot.entries 130 | }); 131 | this.connectObservables({ 132 | products: this.productsFacade.getProducts(), 133 | categories: this.productsFacade.getCategories(), 134 | time: interval(1000).pipe(map(() => new Date().getTime())), 135 | }) 136 | this.connect({ 137 | filteredProducts: this.filteredProducts, 138 | pagedProducts: this.pagedProducts, 139 | ...this.productsFacade.pickFromShoppingCartState(['entries']) 140 | }) 141 | } 142 | 143 | 144 | protected setQuery(query: string): void { 145 | this.patch({ pageIndex: 0, query }) 146 | } 147 | 148 | protected pageIndexChange(pageIndex: number): void { 149 | this.patch({ pageIndex }); 150 | } 151 | 152 | protected itemsPerPageChange(itemsPerPage: number): void { 153 | this.patch({ pageIndex: 0, itemsPerPage }) 154 | } 155 | 156 | protected addToCard(product: Product): void { 157 | this.productsFacade.addToCart({ productId: product.id, amount: 1 }); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /projects/ngx-signal-state/src/lib/signal-state.ts: -------------------------------------------------------------------------------- 1 | import { 2 | computed, 3 | effect, 4 | Injectable, 5 | Signal, 6 | signal, 7 | untracked, 8 | WritableSignal, 9 | } from '@angular/core'; 10 | import { Observable, startWith, switchMap } from 'rxjs'; 11 | import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; 12 | import { PickedState } from './types'; 13 | 14 | type Triggers = Partial<{ [P in keyof T]: WritableSignal }>; 15 | type Signals = { [P in keyof T]: WritableSignal }; 16 | type SpecificKeysOfObj = { [P in keyof T]: T[P] }; 17 | export const notInitializedError = 18 | 'Signal state is not initialized yet, call the initialize() method before using any other methods'; 19 | @Injectable() 20 | export class SignalState> { 21 | 22 | private signals: Signals | undefined; 23 | private readonly triggers: Triggers = {}; 24 | public readonly state = computed(() => { 25 | const signals = this.throwOrReturnSignals(); 26 | return Object.keys(signals).reduce((obj, key: keyof T) => { 27 | obj[key] = signals[key](); 28 | return obj; 29 | }, {} as Partial) as T; 30 | }); 31 | 32 | /** 33 | * Initializes the state with default values 34 | * @param state: The complete initial state 35 | */ 36 | public initialize

(state: T): void { 37 | const signals: Partial> = {}; 38 | (Object.keys(state) as P[]).forEach((key) => 39 | signals[key] = signal(state[key]) 40 | ); 41 | this.signals = signals as Signals; 42 | } 43 | 44 | /** 45 | * Selects a single piece of the state as a computed Signal and optionally maps it through a 46 | * mapping function 47 | * @param key: The key we want to use to extract a piece of state as a signal 48 | * @param mappingFunction: (Optional) The callback function that will map to the computed signal 49 | */ 50 | public select(key: K): Signal; 51 | public select( 52 | key: K, 53 | mappingFunction: (state: T[K]) => P 54 | ): Signal

; 55 | public select( 56 | key: K, 57 | mappingFunction?: (state: T[K]) => P 58 | ): Signal { 59 | return computed(() => { 60 | const state = this.throwOrReturnSignals()[key]() as T[K]; 61 | return mappingFunction ? (mappingFunction(state) as P) : (state as T[K]); 62 | }); 63 | } 64 | 65 | /** 66 | * Selects multiple pieces of the state as a computed Signal and optionally maps it to a new signal 67 | * @param keys: The keys we want to use to extract pieces of state as a signal 68 | * @param mappingFunction: (Optional) The callback function that will map to the computed signal 69 | */ 70 | public selectMany(keys: (keyof T)[]): Signal>; 71 | public selectMany

( 72 | keys: (keyof T)[], 73 | mappingFunction: (obj: SpecificKeysOfObj) => P 74 | ): Signal

; 75 | public selectMany

( 76 | keys: (keyof T)[], 77 | mappingFunction?: (obj: SpecificKeysOfObj) => P 78 | ): Signal

> { 79 | return computed(() => { 80 | const signals = this.throwOrReturnSignals(); 81 | const state = keys.reduce((obj, key) => { 82 | obj[key] = signals[key](); 83 | return obj; 84 | }, {} as Partial>) as SpecificKeysOfObj; 85 | return mappingFunction ? (mappingFunction(state) as P) : (state as SpecificKeysOfObj); 86 | }); 87 | } 88 | 89 | /** 90 | * This method is used to pick pieces of state from somewhere else 91 | * It will return an object that contains properties as signals. 92 | * Used best in combination with the connect method 93 | * @param keys: The keys that are related to the pieces of state we want to pick 94 | */ 95 | public pick

( 96 | keys: (keyof T)[] 97 | ): PickedState { 98 | const signals = this.throwOrReturnSignals(); 99 | return keys.reduce((obj, key) => { 100 | obj[key] = signals[key]; 101 | return obj; 102 | }, {} as Partial>) as PickedState; 103 | } 104 | 105 | /** 106 | * Connects a partial state object where every property is a Signal. 107 | * It will connect all these signals to the state 108 | * This will automatically feed the state whenever one of the signals changes 109 | * It will use an Angular effect to calculate it 110 | * @param partial: The partial object holding the signals where we want to listen to 111 | */ 112 | public connect(partial: Partial<{ [P in keyof T]: Signal }>): void { 113 | this.throwOrReturnSignals(); 114 | Object.keys(partial).forEach((key: keyof T) => { 115 | effect( 116 | () => { 117 | const v = partial[key] as Signal; 118 | this.patch({ [key]: v() } as Partial); 119 | }, 120 | // This will update the state, so we need to allow signal writes 121 | { allowSignalWrites: true } 122 | ); 123 | }); 124 | } 125 | 126 | /** 127 | * Connects a partial state object where every property is an RxJS Observable 128 | * It will connect all these observables to the state and clean up automatically 129 | * For every key a trigger will be registered that can be called by using the 130 | * `trigger()` method. The trigger will retrigger the producer function of the Observable in question 131 | * @param object 132 | */ 133 | public connectObservables(partial: Partial<{ [P in keyof T]: Observable }>): void { 134 | this.throwOrReturnSignals(); 135 | Object.keys(partial).forEach((key: keyof T) => { 136 | this.triggers[key] ||= signal(0); 137 | const obs$ = partial[key] as Observable; 138 | toObservable(this.triggers[key] as WritableSignal) 139 | .pipe( 140 | startWith(), 141 | switchMap(() => obs$), 142 | takeUntilDestroyed(), 143 | ) 144 | .subscribe((v: Partial[keyof Partial]) => { 145 | this.patch({ [key]: v } as Partial); 146 | }); 147 | }); 148 | } 149 | 150 | /** 151 | * Retriggers the producer function of the Observable that is connected to this key 152 | * This only works in combination with the `connectObservables()` method. 153 | * @param key 154 | */ 155 | public trigger(key: keyof T): void { 156 | if (!this.triggers[key]) { 157 | throw new Error( 158 | 'There is no trigger registered for this key! You need to connect an observable. ' + 159 | 'Please use connectObservables to register the triggers', 160 | ); 161 | } 162 | (this.triggers[key] as WritableSignal).update((v) => v + 1); 163 | } 164 | 165 | /** 166 | * Patches the state with a partial object. 167 | * This will loop through all the state signals and update 168 | * them one by one 169 | * @param partial: The partial state that needs to be updated 170 | */ 171 | public patch

(partial: Partial): void { 172 | const signals = this.throwOrReturnSignals(); 173 | (Object.keys(partial) as P[]).forEach((key: P) => { 174 | signals[key].set(partial[key] as T[P]); 175 | }); 176 | } 177 | 178 | /** 179 | * Returns the state as a snapshot 180 | * This will read through all the signals in an untracked manner 181 | */ 182 | public get snapshot(): T { 183 | return untracked(() => this.state()); 184 | } 185 | 186 | private throwOrReturnSignals(): Signals { 187 | if (!this.signals) { 188 | throw new Error(notInitializedError); 189 | } 190 | return this.signals as Signals; 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /projects/ngx-signal-state/src/lib/signal-state.spec.ts: -------------------------------------------------------------------------------- 1 | import { notInitializedError, SignalState } from './signal-state'; 2 | import { Component, signal } from '@angular/core'; 3 | import { TestBed } from '@angular/core/testing'; 4 | import { BehaviorSubject, Subject, tap } from 'rxjs'; 5 | 6 | type TestState = { 7 | firstName: string; 8 | lastName: string; 9 | }; 10 | const initialState = { 11 | firstName: 'Brecht', 12 | lastName: 'Billiet', 13 | }; 14 | const patchedState = { 15 | firstName: 'Brecht2', 16 | lastName: 'Billiet2', 17 | }; 18 | 19 | @Component({ 20 | template: '', 21 | }) 22 | class MyComponent extends SignalState { 23 | public firstName = signal(initialState.firstName); 24 | public lastName = signal(initialState.lastName); 25 | 26 | public constructor() { 27 | super(); 28 | this.initialize(initialState); 29 | this.connect({ 30 | firstName: this.firstName, 31 | lastName: this.lastName, 32 | }); 33 | } 34 | } 35 | 36 | @Component({ 37 | template: '', 38 | }) 39 | class WithObservablesComponent extends SignalState { 40 | public firstName$$ = new BehaviorSubject(patchedState.firstName); 41 | public lastName$$ = new BehaviorSubject(patchedState.lastName); 42 | 43 | public constructor() { 44 | super(); 45 | this.initialize({ ...initialState, producerFirstName: 0 }); 46 | this.connectObservables({ 47 | firstName: this.firstName$$.pipe(tap(() => this.patch({ producerFirstName: this.snapshot.producerFirstName + 1 }))), 48 | lastName: this.lastName$$, 49 | }); 50 | } 51 | } 52 | 53 | describe('signal state', () => { 54 | describe('on initialize()', () => { 55 | it('should initialize the state correctly', () => { 56 | const state = new SignalState(); 57 | 58 | state.initialize(initialState); 59 | expect(state.snapshot).toEqual(initialState); 60 | expect(state.state()).toEqual(initialState); 61 | }); 62 | }); 63 | 64 | describe('on select()', () => { 65 | describe('when not initialized', () => { 66 | it('should throw an error', () => { 67 | const state = new SignalState(); 68 | expect(() => { 69 | state.select('firstName')(); 70 | }).toThrowError(notInitializedError); 71 | }); 72 | }); 73 | it('should select the correct piece of state', () => { 74 | const state = new SignalState(); 75 | state.initialize(initialState); 76 | expect(state.select('firstName')()).toEqual(initialState.firstName); 77 | expect(state.select('lastName')()).toEqual(initialState.lastName); 78 | state.patch(patchedState); 79 | expect(state.select('firstName')()).toEqual(patchedState.firstName); 80 | expect(state.select('lastName')()).toEqual(patchedState.lastName); 81 | }); 82 | }); 83 | describe('on selectMany()', () => { 84 | describe('when not initialized', () => { 85 | it('should throw an error', () => { 86 | const state = new SignalState(); 87 | expect(() => { 88 | state.selectMany(['firstName', 'lastName'])(); 89 | }).toThrowError(notInitializedError); 90 | }); 91 | }); 92 | it('should select the correct pieces of state', () => { 93 | const state = new SignalState(); 94 | state.initialize(initialState); 95 | expect(state.selectMany(['firstName', 'lastName'])()).toEqual(initialState); 96 | state.patch(patchedState); 97 | expect(state.selectMany(['firstName', 'lastName'])()).toEqual(patchedState); 98 | }); 99 | }); 100 | describe('on pick()', () => { 101 | describe('when not initialized', () => { 102 | it('should throw an error', () => { 103 | const state = new SignalState(); 104 | expect(() => { 105 | state.pick(['firstName', 'lastName']); 106 | }).toThrowError(notInitializedError); 107 | }); 108 | }); 109 | it('should return an object with the correct pieces of state as signals', () => { 110 | const state = new SignalState(); 111 | state.initialize(initialState); 112 | const picked = state.pick(['firstName', 'lastName']); 113 | expect(picked.firstName()).toEqual(initialState.firstName); 114 | expect(picked.lastName()).toEqual(initialState.lastName); 115 | state.patch(patchedState); 116 | expect(picked.firstName()).toEqual(patchedState.firstName); 117 | expect(picked.lastName()).toEqual(patchedState.lastName); 118 | }); 119 | }); 120 | describe('on connect()', () => { 121 | describe('when not initialized', () => { 122 | it('should throw an error', () => { 123 | const state = new SignalState(); 124 | expect(() => { 125 | state.connect({ 126 | firstName: signal('firstName'), 127 | lastName: signal('lastName'), 128 | }); 129 | }).toThrowError(notInitializedError); 130 | }); 131 | }); 132 | it('should listen to the passed signals and patch the state', () => { 133 | TestBed.configureTestingModule({ 134 | declarations: [MyComponent], 135 | }).compileComponents(); 136 | const fixture = TestBed.createComponent(MyComponent); 137 | const component = fixture.componentRef.instance; 138 | fixture.detectChanges(); 139 | expect(component.state().firstName).toEqual(component.firstName()); 140 | expect(component.state().lastName).toEqual(component.lastName()); 141 | component.firstName.set('Brecht3'); 142 | component.lastName.set('Billiet3'); 143 | fixture.detectChanges(); 144 | expect(component.state().firstName).toEqual('Brecht3'); 145 | expect(component.state().lastName).toEqual('Billiet3'); 146 | }); 147 | }); 148 | describe('on patch()', () => { 149 | describe('when not initialized', () => { 150 | it('should throw an error', () => { 151 | const state = new SignalState(); 152 | expect(() => { 153 | state.patch({ lastName: '', firstName: '' }); 154 | }).toThrowError(notInitializedError); 155 | }); 156 | }); 157 | it('should patch the state', () => { 158 | const state = new SignalState(); 159 | state.initialize(initialState); 160 | state.patch(patchedState); 161 | expect(state.snapshot).toEqual(patchedState); 162 | expect(state.state()).toEqual(patchedState); 163 | }); 164 | }); 165 | describe('on connectObservables()', () => { 166 | describe('when not initialized', () => { 167 | it('should throw an error', () => { 168 | const state = new SignalState(); 169 | const lastName$$ = new Subject(); 170 | const firstName$$ = new Subject(); 171 | expect(() => { 172 | state.connectObservables({ lastName: lastName$$, firstName: firstName$$ }); 173 | }).toThrowError(notInitializedError); 174 | }); 175 | }); 176 | it('should subscribe to the passed observables and pass the state', () => { 177 | TestBed.configureTestingModule({ 178 | declarations: [WithObservablesComponent], 179 | }).compileComponents(); 180 | const fixture = TestBed.createComponent(WithObservablesComponent); 181 | const component = fixture.componentRef.instance; 182 | fixture.detectChanges(); 183 | expect(component.state().firstName).toEqual('Brecht2'); 184 | expect(component.state().lastName).toEqual('Billiet2'); 185 | component.lastName$$.next('Billiet3'); 186 | component.firstName$$.next('Brecht3'); 187 | fixture.detectChanges(); 188 | expect(component.state().firstName).toEqual('Brecht3'); 189 | expect(component.state().lastName).toEqual('Billiet3'); 190 | }); 191 | it('should re-execute the producer function when the trigger method is called', () => { 192 | TestBed.configureTestingModule({ 193 | declarations: [WithObservablesComponent], 194 | }).compileComponents(); 195 | const fixture = TestBed.createComponent(WithObservablesComponent); 196 | const component = fixture.componentRef.instance; 197 | fixture.detectChanges(); 198 | expect(component.state().producerFirstName).toEqual(1); 199 | component.trigger('firstName'); 200 | fixture.detectChanges(); 201 | component.trigger('firstName'); 202 | fixture.detectChanges(); 203 | component.trigger('firstName'); 204 | fixture.detectChanges(); 205 | expect(component.state().producerFirstName).toEqual(4); 206 | }); 207 | }); 208 | }); 209 | -------------------------------------------------------------------------------- /projects/examples/src/backend/db.json: -------------------------------------------------------------------------------- 1 | { 2 | "products": [ 3 | { 4 | "name": "Apple iPhone 14 256GB Midnight + Apple USB-C Charger 20", 5 | "price": 1690, 6 | "description": "With the Apple iPhone 14 256GB Midnight + Apple USB-C Charger 20W bundle, you can fast charge your new iPhone. The Apple iPhone 14 is a real all-rounder. With the improved standard and wide-angle lens, you can take even sharper photos than its predecessor, the Apple iPhone 13. In addition, the TrueDepth selfie camera has autofocus. This means it'll focus on your face faster. And the image remains sharp if you move during video calls, for example. Even when there's not a lot of light. Thanks to the powerful A15 Bionic chip and 4GB RAM, you can quickly edit all your photos and multitask any way you want. You can store your photos and apps on the 256GB storage. With the special Action Mode, all your videos remain stable when you record something while you move around a lot. On the 6.1-inch OLED screen, you can watch all your favorite movies and series in high quality. Want more screen space? Choose the iPhone 14 Plus.", 7 | "advice": "We advice you to try this product", 8 | "id": 1, 9 | "categoryId": 1, 10 | "quantity": 22 11 | }, 12 | { 13 | "name": "Apple AirPods 2 with charging case", 14 | "price": 139, 15 | "description": "With Apple AirPods 2 with Charging Case, you can address Siri without touching your earbuds. This now works via voice commands, like with the iPhone. The earbuds turn on automatically when you put them in your ears and pause when you take them out. You can charge the charging case with the included Lightning to USB charging cable. With a full battery, you can use the AirPods to listen to music for 5 hours. When you add the battery of the charging case, you can listen to your favorite songs for 24 hours.", 16 | "advice": "", 17 | "categoryId": 3, 18 | "id": 2, 19 | "quantity": 7 20 | }, 21 | { 22 | "name": "Bowers & Wilkins PX7 S2 Black", 23 | "price": 422, 24 | "description": "With the Bowers & Wilkins PX7 S2, you can enjoy high-end sound quality, even in the most noisy areas. These headphones have high-end noise canceling, which reduces the ambient noise a lot. Do you start a call with someone? The music pauses automatically when you take off the headphones. If you put the headphones back on your head, the music continues. In the Bowers & Wilkins Headphones app, you can customize the sound reproduction via an equalizer.", 25 | "advice": "", 26 | "id": 3, 27 | "categoryId": 3, 28 | "quantity": 40 29 | }, 30 | { 31 | "name": "Samsung QLED 55Q80A", 32 | "price": 779, 33 | "description": "With the Samsung QLED 55Q80A (2021), you watch colorful and high-contrast images. This television has a high brightness and rich color representation thanks to the QLED screen. As a result, subtle color tones in images of a blue sky or cloud field are clearly visible as well. Combined with the Full Array Local Dimming, the QLED screen provides a strong contrast. That means there's a significant difference between the darkest parts of the image and the brightest parts, so shadows are truly dark and light objects stand out against a dark background. For example, a clear moon against a black sky. Thanks to Adaptive Picture, the TV adjusts the image to your viewing situation. Are you watching a dark movie during the day with the curtains open? ", 34 | "advice": "", 35 | "id": 5, 36 | "categoryId": 4, 37 | "quantity": 6 38 | }, 39 | { 40 | "name": "Samsung Neo QLED 8K 75QN900B (2022) + Soundbar", 41 | "price": 7769, 42 | "description": "With the Samsung Neo QLED 8K 75QN900B (2022) and HW-Q990B Soundbar, you can create your own home cinema. Thanks to the 8K resolution, every detail is razor sharp. For example, you can easily distinguish people in the audience at a soccer match or the hair of a tiger in a nature documentary. The smart Neo AI Quantum Processor 8K upscales the image to the 8K resolution when you watch images in 4K. Neo QLED technology provides strong contrast and vivid colors. Thousands of LED lights are individually controlled, which makes dark areas of the screen truly dark and bright areas very bright. The quantum dots provide a bright color reproduction and a wide color gamut. So you see every subtle hue in for example a blue sky. Connect all your peripherals to the Slim One Connect Box with a sleek design.", 43 | "advice": "", 44 | "id": 6, 45 | "categoryId": 4, 46 | "quantity": 6 47 | }, 48 | { 49 | "name": "OnePlus Nord 2T 256GB Gray 5G", 50 | "price": 469, 51 | "description": "The OnePlus Nord 2T 256GB Gray 5G is a powerful mid-range smartphone that's very fast with average use. For example, you can quickly switch between your apps like Instagram, Spotify, and YouTube. There are 3 cameras at the back which can take decent photos. With the wide-angle lens, you can fit tall buildings or your whole family on the photo. You can store your photos on the 256GB storage, together with all your apps, music, and movies. The 4500mAh battery lasts the whole day with average use. You'll also get a 80W fast charger. This allows you to fully charge the Nord 2T within half an hour. You'll never have to go out with an empty battery. On the 6.43-inch Full HD screen, you can see many details of your videos, movies, and series. This screen refreshes 90 times per second, so the movements look smooth when you scroll.", 52 | "advice": "", 53 | "categoryId": 1, 54 | "id": 7, 55 | "quantity": 3 56 | }, 57 | { 58 | "name": "Apple iPhone SE 2022 64GB Black", 59 | "price": 559, 60 | "description": "The Apple iPhone SE 2022 64GB Black has a powerful A15 Bionic Chip. As a result, you can multitask without the device slowing down and you can effortlessly use the most demanding apps. You can also connect via 5G with this iPhone. That way, you have a fast and stable connection in busy places as well. You can only take advantage of this with a SIM card that has a 5G mobile data plan. Don't have one? The iPhone SE 2022 is also suitable for 4G. On the 64GB storage, you have limited space for your favorite apps and photos. Do you want more storage space? Choose the 128 or 256GB version. Thanks to Smart HDR 4 and Deep Fusion, you can take better photos with the 12-megapixel camera than with its predecessor. You unlock the device with your fingerprint via the Touch ID home button.", 61 | "advice": "", 62 | "categoryId": 1, 63 | "id": 11, 64 | "quantity": 3 65 | }, 66 | { 67 | "name": "Logitech M330 Silent Wireless Mouse Black", 68 | "price": 29.99, 69 | "description": "You'll no longer be distracted by clicking sounds with the Logitech M330 Silent Wireless Mouse. This mouse has silent buttons with rubber switches that muffle the sound. A click will make 90% less noise compared to a standard mouse while maintaining the familiar clicking motion. Connect the USB nano receiver to your laptop or PC and immediately connect wirelessly to the mouse. Constantly changing the batteries is a thing of the past, because the mouse will work up to 24 months on a single battery. Aren't using the mouse? You don't have to switch it off; the M330 will automatically switch to sleep mode until you use it again.", 70 | "advice": "", 71 | "id": 12, 72 | "quantity": 3 73 | }, 74 | { 75 | "name": "Apple iPhone 14 256GB Midnight + Apple USB-C Charger 20", 76 | "price": 1690, 77 | "description": "With the Apple iPhone 14 256GB Midnight + Apple USB-C Charger 20W bundle, you can fast charge your new iPhone. The Apple iPhone 14 is a real all-rounder. With the improved standard and wide-angle lens, you can take even sharper photos than its predecessor, the Apple iPhone 13. In addition, the TrueDepth selfie camera has autofocus. This means it'll focus on your face faster. And the image remains sharp if you move during video calls, for example. Even when there's not a lot of light. Thanks to the powerful A15 Bionic chip and 4GB RAM, you can quickly edit all your photos and multitask any way you want. You can store your photos and apps on the 256GB storage. With the special Action Mode, all your videos remain stable when you record something while you move around a lot. On the 6.1-inch OLED screen, you can watch all your favorite movies and series in high quality. Want more screen space? Choose the iPhone 14 Plus.", 78 | "advice": "We advice you to try this product", 79 | "id": 11, 80 | "categoryId": 1, 81 | "quantity": 22 82 | }, 83 | { 84 | "name": "Apple AirPods 2 with charging case", 85 | "price": 139, 86 | "description": "With Apple AirPods 2 with Charging Case, you can address Siri without touching your earbuds. This now works via voice commands, like with the iPhone. The earbuds turn on automatically when you put them in your ears and pause when you take them out. You can charge the charging case with the included Lightning to USB charging cable. With a full battery, you can use the AirPods to listen to music for 5 hours. When you add the battery of the charging case, you can listen to your favorite songs for 24 hours.", 87 | "advice": "", 88 | "categoryId": 3, 89 | "id": 12, 90 | "quantity": 7 91 | }, 92 | { 93 | "name": "Bowers & Wilkins PX7 S2 Black", 94 | "price": 422, 95 | "description": "With the Bowers & Wilkins PX7 S2, you can enjoy high-end sound quality, even in the most noisy areas. These headphones have high-end noise canceling, which reduces the ambient noise a lot. Do you start a call with someone? The music pauses automatically when you take off the headphones. If you put the headphones back on your head, the music continues. In the Bowers & Wilkins Headphones app, you can customize the sound reproduction via an equalizer.", 96 | "advice": "", 97 | "id": 13, 98 | "categoryId": 3, 99 | "quantity": 40 100 | }, 101 | { 102 | "name": "Samsung QLED 55Q80A", 103 | "price": 779, 104 | "description": "With the Samsung QLED 55Q80A (2021), you watch colorful and high-contrast images. This television has a high brightness and rich color representation thanks to the QLED screen. As a result, subtle color tones in images of a blue sky or cloud field are clearly visible as well. Combined with the Full Array Local Dimming, the QLED screen provides a strong contrast. That means there's a significant difference between the darkest parts of the image and the brightest parts, so shadows are truly dark and light objects stand out against a dark background. For example, a clear moon against a black sky. Thanks to Adaptive Picture, the TV adjusts the image to your viewing situation. Are you watching a dark movie during the day with the curtains open? ", 105 | "advice": "", 106 | "id": 15, 107 | "categoryId": 4, 108 | "quantity": 6 109 | }, 110 | { 111 | "name": "Samsung Neo QLED 8K 75QN900B (2022) + Soundbar", 112 | "price": 7769, 113 | "description": "With the Samsung Neo QLED 8K 75QN900B (2022) and HW-Q990B Soundbar, you can create your own home cinema. Thanks to the 8K resolution, every detail is razor sharp. For example, you can easily distinguish people in the audience at a soccer match or the hair of a tiger in a nature documentary. The smart Neo AI Quantum Processor 8K upscales the image to the 8K resolution when you watch images in 4K. Neo QLED technology provides strong contrast and vivid colors. Thousands of LED lights are individually controlled, which makes dark areas of the screen truly dark and bright areas very bright. The quantum dots provide a bright color reproduction and a wide color gamut. So you see every subtle hue in for example a blue sky. Connect all your peripherals to the Slim One Connect Box with a sleek design.", 114 | "advice": "", 115 | "id": 16, 116 | "categoryId": 4, 117 | "quantity": 6 118 | }, 119 | { 120 | "name": "OnePlus Nord 2T 256GB Gray 5G", 121 | "price": 469, 122 | "description": "The OnePlus Nord 2T 256GB Gray 5G is a powerful mid-range smartphone that's very fast with average use. For example, you can quickly switch between your apps like Instagram, Spotify, and YouTube. There are 3 cameras at the back which can take decent photos. With the wide-angle lens, you can fit tall buildings or your whole family on the photo. You can store your photos on the 256GB storage, together with all your apps, music, and movies. The 4500mAh battery lasts the whole day with average use. You'll also get a 80W fast charger. This allows you to fully charge the Nord 2T within half an hour. You'll never have to go out with an empty battery. On the 6.43-inch Full HD screen, you can see many details of your videos, movies, and series. This screen refreshes 90 times per second, so the movements look smooth when you scroll.", 123 | "advice": "", 124 | "categoryId": 1, 125 | "id": 17, 126 | "quantity": 3 127 | }, 128 | { 129 | "name": "Apple iPhone SE 2022 64GB Black", 130 | "price": 559, 131 | "description": "The Apple iPhone SE 2022 64GB Black has a powerful A15 Bionic Chip. As a result, you can multitask without the device slowing down and you can effortlessly use the most demanding apps. You can also connect via 5G with this iPhone. That way, you have a fast and stable connection in busy places as well. You can only take advantage of this with a SIM card that has a 5G mobile data plan. Don't have one? The iPhone SE 2022 is also suitable for 4G. On the 64GB storage, you have limited space for your favorite apps and photos. Do you want more storage space? Choose the 128 or 256GB version. Thanks to Smart HDR 4 and Deep Fusion, you can take better photos with the 12-megapixel camera than with its predecessor. You unlock the device with your fingerprint via the Touch ID home button.", 132 | "advice": "", 133 | "categoryId": 1, 134 | "id": 111, 135 | "quantity": 3 136 | }, 137 | { 138 | "name": "Logitech M330 Silent Wireless Mouse Black", 139 | "price": 29.99, 140 | "description": "You'll no longer be distracted by clicking sounds with the Logitech M330 Silent Wireless Mouse. This mouse has silent buttons with rubber switches that muffle the sound. A click will make 90% less noise compared to a standard mouse while maintaining the familiar clicking motion. Connect the USB nano receiver to your laptop or PC and immediately connect wirelessly to the mouse. Constantly changing the batteries is a thing of the past, because the mouse will work up to 24 months on a single battery. Aren't using the mouse? You don't have to switch it off; the M330 will automatically switch to sleep mode until you use it again.", 141 | "advice": "", 142 | "id": 112, 143 | "quantity": 3 144 | } 145 | ], 146 | "categories": [ 147 | { 148 | "name": "smartphones", 149 | "description": "Smartphones are used to make phone calls and send text messages but they can also be used for accessing the internet and check your emails, search the internet and much more. There are many different brands of smartphones e.g.dd", 150 | "id": 1 151 | }, 152 | { 153 | "name": "Headphones", 154 | "description": "Headphones are a pair of small loudspeaker drivers worn on or around the head over a user's ears. They are electroacoustic transducers, which convert an electrical signal to a corresponding sound.", 155 | "id": 3 156 | }, 157 | { 158 | "name": "Televisions", 159 | "description": "A television set (also known as a television receiver or televisor or simply a television, TV set, TV receiver or TV) is a machine with a screen or set of lenses. Televisions receive broadcasting signals and change them into pictures and sound.", 160 | "id": 4 161 | } 162 | ] 163 | } 164 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ngx-signal-state: Opinionated Microsized Simple State management for Angular Signals 2 | 3 | ![ngx-signal-state.svg](https://github.com/simplifiedcourses/ngx-signal-state/blob/master/assets/ngx-signal-state.svg?raw=true) 4 | 5 | | Principle | | Description | 6 | | -------------- | --- | ----------------------------------------------------------- | 7 | | Simple | Yes | Only a handful methods, no complex ngrx structures | 8 | | Small | Yes | Minified and compressed: 2KB | 9 | | Opinionated | Yes | Structured and opinionated way of state management | 10 | | No boilerplate | Yes | No selectors, reducers, actions, action types, effects, ... | 11 | | Easy to learn | Yes | Provides everything, but still very small | 12 | | Battle tested | Yes | Tested with big clients | 13 | | Type-safe | Yes | High focus on type-safety | 14 | | Examples | Yes | Working on tons of examples as we speak | 15 | 16 | ### Why not just use Signals? 17 | 18 | * [x] ngx-signal-state is more opinionated 19 | * [x] Advanced selecting logic, `select()`, `selectMany()` 20 | * [x] Forces us to treat components as state machines 21 | * [x] Clean api 22 | * [x] Because we can patch multiple signals in one command 23 | * [x] Connect functionality 24 | * [x] Plays well with Observables too 25 | * [x] Retrigger producer functions of connected observables 26 | * [x] Pick functionality of external states 27 | * [x] Easy snapshot 28 | * [x] State initialization in one place 29 | 30 | ## The principles 31 | 32 | This state management library has 2 important goals: 33 | 34 | * **Simplifying** state management: **KISS always!!** 35 | * **Opinionated** state management 36 | 37 | The principles are: 38 | 39 | * Every ui component is treated as a state machine 40 | * Every smart component is treated as a state machine 41 | * Features (Angular lazy loaded chunks) can have state machines shared for that feature 42 | * Application-wide there can be multiple global state machines 43 | * State machines can be provided on all levels of the injector tree 44 | * We can pick pieces of state from other state machines and add a one-way communication between them 45 | 46 | **The best practice here is to keep the state as low as possible.** 47 | 48 | ## Getting started 49 | 50 | ### Starting with ngx-signal-state 51 | 52 | We can start by installing `ngx-signal-state` with **npm** or **yarn**. 53 | After that we can import `SignalState` like this: 54 | 55 | ```typescript 56 | import { SignalState } from "ngx-signal-state"; 57 | ``` 58 | 59 | ### Creating a state machine for a component 60 | 61 | Creating a state machine for a component is simple. We just have to create a specific type 62 | for the state and extend our component from `SignalState` ; 63 | 64 | ```typescript 65 | type MyComponentState = { 66 | firstName: string; 67 | lastName: string; 68 | }; 69 | 70 | export class MyComponent extends SignalState { 71 | } 72 | ``` 73 | 74 | ### Initializing the state machine 75 | 76 | We can not consume `SignalState` functionality before we have initialized the state 77 | in the constructor with the `initialize()` method: 78 | 79 | ```typescript 80 | export class MyComponent extends SignalState { 81 | constructor(props) { 82 | super(props); 83 | this.initialize({ 84 | firstName: 'Brecht', 85 | lastName: 'Billiet', 86 | }); 87 | } 88 | } 89 | ``` 90 | 91 | ### Getting the state as signals 92 | 93 | There are 3 ways to get the state as a signal. 94 | 95 | * `this.state` will return the state as a signal. 96 | * `this.select('propertyName')` will return a signal for the property that we provide. 97 | * `this.selectMany(['firstName', 'lastName'])`will return a signal with multiple pieces of state in it. 98 | 99 | ```typescript 100 | export class MyComponent extends SignalState { 101 | ... 102 | // Fetch the entire state as a signal 103 | state = this.state; 104 | 105 | // Only select one property and return it as a signal 106 | firstName = this.select('firstName'); 107 | 108 | // Select multiple properties as a signal 109 | firstAndLastName = this.selectMany(['firstName', 'lastName']) 110 | } 111 | ``` 112 | 113 | It's possible to add mapping functions as the second argument of the `select()` and `selectMany()` methods: 114 | 115 | ```typescript 116 | export class MyComponent extends SignalState { 117 | ... 118 | // Pass a mapping function 119 | firstName = this.select('firstName', firstname => firstname + '!!'); 120 | 121 | // Pass a mapping function 122 | fullName = this.selectMany(['firstName', 'lastName'], ({ firstName, lastName }) => `${firstName} ${lastName}`) 123 | } 124 | ``` 125 | 126 | ### Getting the state as a snapshot 127 | 128 | Sometimes we want an untracked snapshot. For that we can use the `snapshot` getter that will not 129 | keep track of its consumers. 130 | 131 | ```typescript 132 | export class MyComponent extends SignalState { 133 | ... 134 | protected save(): void { 135 | // Pick whatever we want from the snapshot of the state 136 | const { firstName, lastName } = this.snapshot; 137 | console.log(firstName, lastName); 138 | } 139 | } 140 | ``` 141 | 142 | ### Patching state 143 | 144 | Setting multiple signals at the same time can be a drag. The `SignalState` offers a `patch()` method where we can pass a partial of the entire state. 145 | 146 | ```typescript 147 | export class MyComponent extends SignalState { 148 | ... 149 | protected userChange(user: User): void { 150 | this.patch({ firstName: user.firstName, lastName: user.lastName }); 151 | } 152 | } 153 | ``` 154 | 155 | ### Connecting signals to the state 156 | 157 | Sometimes we want to calculate pieces of state and connect those to our state machine. 158 | Any signal that we have can be connected to the state. Some examples are: 159 | 160 | * Pieces of other state machines (global state) 161 | * Signals that are provided by Angular (Input signals, query signals, ...) 162 | * Calculated pieces of signals that are calculated by the `selectMany()` method 163 | 164 | To connect signals to the state we can use the `connect()` method where we pass a partial object where 165 | every property is a signal. In the following example we can see that we have a state for a component that has products with 166 | client-side pagination and client-side filtering. We keep `products` as state that we will load from the backend, but have 2 167 | calculated pieces of state: `filteredProducts` and `pagedProducts` . `filteredProducts` is calculated based on the `products` and `query` . 168 | `pagedProducts` is calculated based on `filteredProducts` , `pageIndex` and `itemsPerPage` . It should be clear how pieces of state are 169 | being calculated based on other pieces of state. In the `connect()` method we can connect these signals and the state machine would 170 | get automatically updated: 171 | 172 | ```typescript 173 | export class MyComponent extends SignalState { 174 | constructor(props) { 175 | super(props); 176 | this.initialize({ 177 | pageIndex: 0, 178 | itemsPerPage: 5, 179 | query: '', 180 | products: [], 181 | filteredProducts: [], 182 | pagedProducts: [], 183 | }); 184 | // Calculate the filtered products and store them in a signal 185 | const filteredProducts = this.selectMany(['products', 'query'], ({ products, query }) => { 186 | return products.filter((p) => p.name.toLowerCase().indexOf(query.toLowerCase()) > -1); 187 | }); 188 | 189 | // Calculate the paged products and store them in a signal 190 | const pagedProducts = this.selectMany(['filteredProducts', 'pageIndex', 'itemsPerPage'], 191 | ({ 192 | filteredProducts, 193 | pageIndex, 194 | itemsPerPage 195 | }) => { 196 | const offsetStart = pageIndex * itemsPerPage; 197 | const offsetEnd = (pageIndex + 1) * itemsPerPage; 198 | return filteredProducts.slice(offsetStart, offsetEnd); 199 | }); 200 | // Connect the calculated signals 201 | this.connect({ 202 | filteredProducts, 203 | pagedProducts, 204 | }); 205 | } 206 | } 207 | ``` 208 | 209 | ### Connecting Observables to the state 210 | 211 | While it is handy to connect signals to the state, it is also handy to connect Observables to the state. 212 | These Observables can be derived from form `valueChanges` , `activatedRoute` or even `http` Observables. 213 | The `connectObservables()` method will do 4 things for us: 214 | 215 | * Subscribe to the observable and feed the results to the local state machine 216 | * Only execute the producer function once ==> no more multicasting issues 217 | * Clean up after itself ==> No memory leaks 218 | * Register a trigger that can be called later with the `trigger()` method to re-execute the producer function of the Observable 219 | 220 | ```typescript 221 | export class MyComponent extends SignalState { 222 | constructor(props) { 223 | super(props); 224 | ... 225 | this.connectObservables({ 226 | // Only execute the call once 227 | products: this.productService.getProducts(), 228 | // Adds a timer 229 | time: interval(1000).pipe(map(() => new Date().getTime())), 230 | }) 231 | } 232 | } 233 | ``` 234 | 235 | ### Retriggering Observables 236 | 237 | Sometimes we want to re-execute the producer function of an observable that is connected to the state. The most recurring example is the 238 | execution of an ajax call. In this example we see how we can refetch users with the `trigger()` method. 239 | 240 | ```typescript 241 | export class MyComponent extends SignalState { 242 | constructor(props) { 243 | super(props); 244 | ... 245 | // Connect products and register a trigger behind the scenes 246 | this.connectObservables({ 247 | // Only execute the call once 248 | products: this.productService.getProducts(), 249 | }) 250 | } 251 | 252 | protected refreshProducts(): void { 253 | // Results in new a `this.productService.getProducts()` call 254 | this.trigger('products'); 255 | } 256 | } 257 | ``` 258 | 259 | ### Picking state 260 | 261 | Every component should be treated as a state machine. Every state class should be treated as a state machine. 262 | However, sometimes we want to pick state from other state machines. The principle of picking state is that we listen 263 | to that state in a one way communication. If we pick a state we will get notified of updates, but when we do changes to our 264 | local state it will not reflect in the state we are listening to: 265 | 266 | ```typescript 267 | export class AppComponent extends SignalState { 268 | private readonly shoppingCartState = inject(ShoppingCartSignalState) 269 | ... 270 | 271 | constructor() { 272 | super(); 273 | this.initialize({ 274 | // set initial values 275 | entries: this.shoppingCartState.snapshot.entries, 276 | paid: this.shoppingCartState.snapshot.paid 277 | }); 278 | this.connect({ 279 | // listen to pieces of state in the shoppingCartState and connect it to our local state 280 | ...this.shoppingCartState.pick(['entries', 'paid']) 281 | }) 282 | } 283 | } 284 | ``` 285 | 286 | ### Creating a state class 287 | 288 | We should treat all our components as state machines, but sometimes we also need to share state. 289 | For that we can create simple state classes. This is an example of a **shopping cart** state: 290 | 291 | ```typescript 292 | export type ShoppingCartState = { 293 | entries: ShoppingCartEntry[]; 294 | }; 295 | 296 | @Injectable({ 297 | // Our provide anywhere in the injector tree 298 | providedIn: 'root', 299 | }) 300 | export class ShoppingCartSignalState extends SignalState { 301 | constructor() { 302 | super(); 303 | // initialize the state 304 | this.initialize({ 305 | entries: [], 306 | }); 307 | } 308 | 309 | public addToCart(entry: ShoppingCartEntry): void { 310 | // Update the state in an immutable way 311 | const entries = [...this.snapshot.entries, entry]; 312 | this.patch({ entries }); 313 | } 314 | 315 | public deleteFromCart(id: number): void { 316 | // Update the state in an immutable way 317 | const entries = this.snapshot.entries.filter((entry) => entry.productId !== id); 318 | this.patch({ entries }); 319 | } 320 | 321 | public updateAmount(id: number, amount: number): void { 322 | // Update the state in an immutable way 323 | const entries = this.snapshot.entries.map((item) => (item.productId === id ? { ...item, amount } : item)); 324 | this.patch({ entries }); 325 | } 326 | } 327 | ``` 328 | 329 | This state is provided in the root of the application, so it will be a singleton. 330 | However, we can also provide any signal state machine on all levels of the application by using the `providers` property: 331 | 332 | * root 333 | * feature 334 | * smart component 335 | * ui component 336 | 337 | ## Facade pattern 338 | 339 | When creating largescale applications, it's a good idea to abstract the feature libs from the rest of the application. 340 | In `projects/examples/src/app/products-with-facade/products-with-facade.component.ts`, we can find an example where all the 341 | data access logic and global state management is abstracted behind a facade. 342 | We should take those rules into account: 343 | - A global state machine should never be injected into a smart component directly 344 | - We never want to expose all the state 345 | - We don't want to patch global state directly in a smart component 346 | - The facade should expose data-access methods 347 | - The facade should not contain logic 348 | - Every feature lib should only contain one facade 349 | 350 | A slimmed down version looks like this: 351 | ```typescript 352 | export class ProductsWithFacadeComponent extends SignalState { 353 | private readonly productsFacade = inject(ProductsFacade) 354 | ... 355 | 356 | constructor() { 357 | super(); 358 | this.initialize({ 359 | ... 360 | entries: this.productsFacade.shoppingCartSnapshot.entries 361 | }); 362 | this.connectObservables({ 363 | products: this.productsFacade.getProducts(), 364 | categories: this.productsFacade.getCategories(), 365 | ... 366 | }) 367 | this.connect({ 368 | filteredProducts: this.filteredProducts, 369 | pagedProducts: this.pagedProducts, 370 | ...this.productsFacade.pickFromShoppingCartState(['entries']) 371 | }) 372 | } 373 | 374 | ... 375 | protected addToCard(product: Product): void { 376 | this.productsFacade.addToCart({ productId: product.id, amount: 1 }); 377 | } 378 | } 379 | ``` 380 | 381 | Let's create the facade: 382 | 383 | ```typescript 384 | @Injectable({ providedIn: 'root' }) 385 | export class ProductsFacade { 386 | private readonly productService = inject(ProductService); 387 | private readonly categoryService = inject(CategoryService); 388 | private readonly shoppingCartState = inject(ShoppingCartSignalState) 389 | 390 | public get shoppingCartSnapshot() { 391 | // For pragmatic reasons, expose the snapshot 392 | return this.shoppingCartState.snapshot; 393 | } 394 | 395 | public pickFromShoppingCartState(keys: (keyof ShoppingCartState)[]): PickedState { 396 | // For pragmatic reasons, expose the pick method 397 | return this.shoppingCartState.pick(keys); 398 | } 399 | 400 | public getProducts(): Observable { 401 | return this.productService.getProducts(); 402 | } 403 | 404 | public getCategories(): Observable { 405 | return this.categoryService.getCategories(); 406 | } 407 | 408 | // Don't expose the patch method 409 | public addToCart(entry: ShoppingCartEntry): void { 410 | this.shoppingCartState.addToCart(entry); 411 | } 412 | } 413 | ``` 414 | 415 | The goal of the facade is abstracting away tools and keeping the smart component ignorant. 416 | 417 | ## Examples 418 | 419 | Examples of the use of this library can be found in `projects/examples` . 420 | To start the backend api run `npm run api` and to start the demo application run `npm start` . 421 | 422 | ## Angular Version Compatibility 423 | * 1.0.0 requires Angular ^16.0.0 424 | 425 | ## Collaborate 426 | 427 | Do you want to collaborate on this with me? 428 | Reach out at [brecht@simplified.courses](mailto://brecht@simplified.courses)! 429 | --------------------------------------------------------------------------------