├── .editorconfig ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json └── tasks.json ├── README.md ├── angular.json ├── db.json ├── package.json ├── src ├── app │ ├── albums │ │ ├── album-overview │ │ │ ├── album-details │ │ │ │ ├── album-details.component.scss │ │ │ │ ├── album-details.component.ts │ │ │ │ └── album-details.store.ts │ │ │ ├── album-overview.component.scss │ │ │ ├── album-overview.component.ts │ │ │ └── album-songs │ │ │ │ ├── album-songs.component.scss │ │ │ │ ├── album-songs.component.ts │ │ │ │ └── album-songs.store.ts │ │ ├── album-search │ │ │ ├── album-filter │ │ │ │ ├── album-filter.component.scss │ │ │ │ └── album-filter.component.ts │ │ │ ├── album-list │ │ │ │ ├── album-list.component.scss │ │ │ │ └── album-list.component.ts │ │ │ ├── album-search.component.ts │ │ │ └── album-search.store.ts │ │ ├── album.model.ts │ │ ├── albums.routes.ts │ │ ├── albums.service.ts │ │ └── albums.store.ts │ ├── app.component.scss │ ├── app.component.ts │ ├── app.config.ts │ ├── app.routes.ts │ ├── core │ │ ├── layout │ │ │ ├── footer.component.ts │ │ │ └── header.component.ts │ │ └── not-found │ │ │ ├── not-found.component.scss │ │ │ └── not-found.component.ts │ ├── shared │ │ ├── models │ │ │ └── sort-order.model.ts │ │ ├── state │ │ │ ├── request-status.feature.ts │ │ │ ├── route-params.feature.ts │ │ │ └── storage-sync.feature.ts │ │ └── ui │ │ │ └── progress-bar.component.ts │ └── songs │ │ ├── song.model.ts │ │ └── songs.service.ts ├── assets │ ├── .gitkeep │ └── album-covers │ │ ├── are-you-experienced.jpg │ │ ├── eliminator.jpg │ │ ├── live-at-the-regal.jpg │ │ ├── still-got-the-blues.jpg │ │ ├── texas-flood.jpg │ │ └── unplugged.jpg ├── favicon.ico ├── index.html ├── main.ts └── styles.scss ├── tsconfig.app.json ├── tsconfig.json └── yarn.lock /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": ["angular.ng-template"] 4 | } 5 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NgRx SignalStore Workshop 2 | 3 | Source code for the NgRx SignalStore workshop. 4 | 5 | ## Installation 6 | 7 | This project uses Yarn as a package manager. To install dependencies, run `yarn`. 8 | 9 | ## Starting the application 10 | 11 | - Run `yarn start:app` to start the Angular dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. 12 | - Run `yarn start:server` to start the backend server. The backend server is available at `http://localhost:3000/`. 13 | - Run `yarn start` to run both frontend and backend servers in parallel. 14 | 15 | ## Code scaffolding 16 | 17 | Run `yarn ng generate component component-name` to generate a new component. You can also use `yarn ng generate directive|pipe|service|class|guard|interface|enum|module`. 18 | 19 | ## Build 20 | 21 | Run `yarn build` to build the project. The build artifacts will be stored in the `dist/` directory. 22 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "cli": { 5 | "packageManager": "yarn" 6 | }, 7 | "newProjectRoot": "projects", 8 | "projects": { 9 | "signal-store-workshop": { 10 | "projectType": "application", 11 | "schematics": { 12 | "@schematics/angular:component": { 13 | "style": "scss", 14 | "inlineTemplate": true, 15 | "inlineStyle": true, 16 | "changeDetection": "OnPush", 17 | "skipTests": true, 18 | "flat": true 19 | } 20 | }, 21 | "root": "", 22 | "sourceRoot": "src", 23 | "prefix": "ngrx", 24 | "architect": { 25 | "build": { 26 | "builder": "@angular/build:application", 27 | "options": { 28 | "outputPath": "dist/signal-store-workshop", 29 | "index": "src/index.html", 30 | "browser": "src/main.ts", 31 | "polyfills": [], 32 | "tsConfig": "tsconfig.app.json", 33 | "inlineStyleLanguage": "scss", 34 | "assets": ["src/favicon.ico", "src/assets"], 35 | "styles": [ 36 | "@angular/material/prebuilt-themes/deeppurple-amber.css", 37 | "src/styles.scss" 38 | ], 39 | "scripts": [] 40 | }, 41 | "configurations": { 42 | "production": { 43 | "budgets": [ 44 | { 45 | "type": "initial", 46 | "maximumWarning": "500kb", 47 | "maximumError": "1mb" 48 | }, 49 | { 50 | "type": "anyComponentStyle", 51 | "maximumWarning": "2kb", 52 | "maximumError": "4kb" 53 | } 54 | ], 55 | "outputHashing": "all" 56 | }, 57 | "development": { 58 | "optimization": false, 59 | "extractLicenses": false, 60 | "sourceMap": true 61 | } 62 | }, 63 | "defaultConfiguration": "production" 64 | }, 65 | "serve": { 66 | "builder": "@angular/build:dev-server", 67 | "configurations": { 68 | "production": { 69 | "buildTarget": "signal-store-workshop:build:production" 70 | }, 71 | "development": { 72 | "buildTarget": "signal-store-workshop:build:development" 73 | } 74 | }, 75 | "defaultConfiguration": "development" 76 | }, 77 | "extract-i18n": { 78 | "builder": "@angular/build:extract-i18n", 79 | "options": { 80 | "buildTarget": "signal-store-workshop:build" 81 | } 82 | } 83 | } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /db.json: -------------------------------------------------------------------------------- 1 | { 2 | "albums": [ 3 | { 4 | "id": 1, 5 | "title": "Unplugged", 6 | "artist": "Eric Clapton", 7 | "releaseDate": "1992-08-25", 8 | "genre": "Blues", 9 | "coverImage": "/assets/album-covers/unplugged.jpg" 10 | }, 11 | { 12 | "id": 2, 13 | "title": "Texas Flood", 14 | "artist": "Stevie Ray Vaughan", 15 | "releaseDate": "1983-06-13", 16 | "genre": "Blues", 17 | "coverImage": "/assets/album-covers/texas-flood.jpg" 18 | }, 19 | { 20 | "id": 3, 21 | "title": "Live at the Regal", 22 | "artist": "BB King", 23 | "releaseDate": "1965-11-21", 24 | "genre": "Blues", 25 | "coverImage": "/assets/album-covers/live-at-the-regal.jpg" 26 | }, 27 | { 28 | "id": 4, 29 | "title": "Are You Experienced", 30 | "artist": "Jimi Hendrix", 31 | "releaseDate": "1967-05-12", 32 | "genre": "Rock", 33 | "coverImage": "/assets/album-covers/are-you-experienced.jpg" 34 | }, 35 | { 36 | "id": 5, 37 | "title": "Eliminator", 38 | "artist": "ZZ Top", 39 | "releaseDate": "1983-03-23", 40 | "genre": "Rock", 41 | "coverImage": "/assets/album-covers/eliminator.jpg" 42 | }, 43 | { 44 | "id": 6, 45 | "title": "Still Got the Blues", 46 | "artist": "Gary Moore", 47 | "releaseDate": "1990-03-26", 48 | "genre": "Blues Rock", 49 | "coverImage": "/assets/album-covers/still-got-the-blues.jpg" 50 | } 51 | ], 52 | "songs": [ 53 | { 54 | "id": 101, 55 | "title": "Tears in Heaven", 56 | "duration": "4:35", 57 | "albumId": 1 58 | }, 59 | { 60 | "id": 102, 61 | "title": "Layla", 62 | "duration": "7:10", 63 | "albumId": 1 64 | }, 65 | { 66 | "id": 103, 67 | "title": "Old Love", 68 | "duration": "6:25", 69 | "albumId": 1 70 | }, 71 | { 72 | "id": 104, 73 | "title": "Before You Accuse Me", 74 | "duration": "3:55", 75 | "albumId": 1 76 | }, 77 | { 78 | "id": 105, 79 | "title": "Running on Faith", 80 | "duration": "6:30", 81 | "albumId": 1 82 | }, 83 | { 84 | "id": 106, 85 | "title": "Bell Bottom Blues", 86 | "duration": "5:01", 87 | "albumId": 1 88 | }, 89 | { 90 | "id": 107, 91 | "title": "Hey Hey", 92 | "duration": "3:16", 93 | "albumId": 1 94 | }, 95 | { 96 | "id": 108, 97 | "title": "Rollin' and Tumblin'", 98 | "duration": "4:12", 99 | "albumId": 1 100 | }, 101 | { 102 | "id": 109, 103 | "title": "Lay Down Sally", 104 | "duration": "5:34", 105 | "albumId": 1 106 | }, 107 | { 108 | "id": 110, 109 | "title": "Wonderful Tonight", 110 | "duration": "4:42", 111 | "albumId": 1 112 | }, 113 | { 114 | "id": 111, 115 | "title": "Nobody Knows You When You're Down and Out", 116 | "duration": "3:49", 117 | "albumId": 1 118 | }, 119 | { 120 | "id": 112, 121 | "title": "Signe", 122 | "duration": "3:14", 123 | "albumId": 1 124 | }, 125 | { 126 | "id": 201, 127 | "title": "Love Struck Baby", 128 | "duration": "2:24", 129 | "albumId": 2 130 | }, 131 | { 132 | "id": 202, 133 | "title": "Pride and Joy", 134 | "duration": "3:40", 135 | "albumId": 2 136 | }, 137 | { 138 | "id": 203, 139 | "title": "Texas Flood", 140 | "duration": "5:20", 141 | "albumId": 2 142 | }, 143 | { 144 | "id": 204, 145 | "title": "Tell Me", 146 | "duration": "2:49", 147 | "albumId": 2 148 | }, 149 | { 150 | "id": 205, 151 | "title": "Testify", 152 | "duration": "3:23", 153 | "albumId": 2 154 | }, 155 | { 156 | "id": 206, 157 | "title": "Rude Mood", 158 | "duration": "4:40", 159 | "albumId": 2 160 | }, 161 | { 162 | "id": 207, 163 | "title": "Mary Had a Little Lamb", 164 | "duration": "2:47", 165 | "albumId": 2 166 | }, 167 | { 168 | "id": 208, 169 | "title": "Dirty Pool", 170 | "duration": "5:01", 171 | "albumId": 2 172 | }, 173 | { 174 | "id": 209, 175 | "title": "I'm Cryin'", 176 | "duration": "3:44", 177 | "albumId": 2 178 | }, 179 | { 180 | "id": 210, 181 | "title": "Lenny", 182 | "duration": "4:59", 183 | "albumId": 2 184 | }, 185 | { 186 | "id": 301, 187 | "title": "Every Day I Have the Blues", 188 | "duration": "2:38", 189 | "albumId": 3 190 | }, 191 | { 192 | "id": 302, 193 | "title": "Sweet Little Angel", 194 | "duration": "6:21", 195 | "albumId": 3 196 | }, 197 | { 198 | "id": 303, 199 | "title": "It's My Own Fault", 200 | "duration": "3:29", 201 | "albumId": 3 202 | }, 203 | { 204 | "id": 304, 205 | "title": "How Blue Can You Get", 206 | "duration": "3:29", 207 | "albumId": 3 208 | }, 209 | { 210 | "id": 305, 211 | "title": "Please Love Me", 212 | "duration": "3:01", 213 | "albumId": 3 214 | }, 215 | { 216 | "id": 306, 217 | "title": "You Upset Me Baby", 218 | "duration": "2:22", 219 | "albumId": 3 220 | }, 221 | { 222 | "id": 307, 223 | "title": "Worry, Worry", 224 | "duration": "6:21", 225 | "albumId": 3 226 | }, 227 | { 228 | "id": 308, 229 | "title": "Woke Up This Mornin'", 230 | "duration": "3:46", 231 | "albumId": 3 232 | }, 233 | { 234 | "id": 309, 235 | "title": "You Done Lost Your Good Thing Now", 236 | "duration": "4:15", 237 | "albumId": 3 238 | }, 239 | { 240 | "id": 310, 241 | "title": "Help the Poor", 242 | "duration": "2:36", 243 | "albumId": 3 244 | }, 245 | { 246 | "id": 401, 247 | "title": "Purple Haze", 248 | "duration": "2:50", 249 | "albumId": 4 250 | }, 251 | { 252 | "id": 402, 253 | "title": "Manic Depression", 254 | "duration": "3:46", 255 | "albumId": 4 256 | }, 257 | { 258 | "id": 403, 259 | "title": "Hey Joe", 260 | "duration": "3:30", 261 | "albumId": 4 262 | }, 263 | { 264 | "id": 404, 265 | "title": "Love or Confusion", 266 | "duration": "3:15", 267 | "albumId": 4 268 | }, 269 | { 270 | "id": 405, 271 | "title": "May This Be Love", 272 | "duration": "3:10", 273 | "albumId": 4 274 | }, 275 | { 276 | "id": 406, 277 | "title": "I Don't Live Today", 278 | "duration": "3:54", 279 | "albumId": 4 280 | }, 281 | { 282 | "id": 407, 283 | "title": "The Wind Cries Mary", 284 | "duration": "3:20", 285 | "albumId": 4 286 | }, 287 | { 288 | "id": 408, 289 | "title": "Fire", 290 | "duration": "2:43", 291 | "albumId": 4 292 | }, 293 | { 294 | "id": 409, 295 | "title": "Third Stone from the Sun", 296 | "duration": "6:44", 297 | "albumId": 4 298 | }, 299 | { 300 | "id": 410, 301 | "title": "Foxey Lady", 302 | "duration": "3:18", 303 | "albumId": 4 304 | }, 305 | { 306 | "id": 411, 307 | "title": "Are You Experienced", 308 | "duration": "4:15", 309 | "albumId": 4 310 | }, 311 | { 312 | "id": 412, 313 | "title": "Stone Free", 314 | "duration": "3:36", 315 | "albumId": 4 316 | }, 317 | { 318 | "id": 501, 319 | "title": "Gimme All Your Lovin'", 320 | "duration": "4:00", 321 | "albumId": 5 322 | }, 323 | { 324 | "id": 502, 325 | "title": "Got Me Under Pressure", 326 | "duration": "4:02", 327 | "albumId": 5 328 | }, 329 | { 330 | "id": 503, 331 | "title": "Sharp Dressed Man", 332 | "duration": "4:12", 333 | "albumId": 5 334 | }, 335 | { 336 | "id": 504, 337 | "title": "I Need You Tonight", 338 | "duration": "6:14", 339 | "albumId": 5 340 | }, 341 | { 342 | "id": 505, 343 | "title": "I Got the Six", 344 | "duration": "2:52", 345 | "albumId": 5 346 | }, 347 | { 348 | "id": 506, 349 | "title": "Legs", 350 | "duration": "4:34", 351 | "albumId": 5 352 | }, 353 | { 354 | "id": 507, 355 | "title": "Thug", 356 | "duration": "4:17", 357 | "albumId": 5 358 | }, 359 | { 360 | "id": 508, 361 | "title": "TV Dinners", 362 | "duration": "3:50", 363 | "albumId": 5 364 | }, 365 | { 366 | "id": 509, 367 | "title": "Dirty Dog", 368 | "duration": "4:05", 369 | "albumId": 5 370 | }, 371 | { 372 | "id": 510, 373 | "title": "If I Could Only Flag Her Down", 374 | "duration": "3:40", 375 | "albumId": 5 376 | }, 377 | { 378 | "id": 511, 379 | "title": "Bad Girl", 380 | "duration": "3:14", 381 | "albumId": 5 382 | }, 383 | { 384 | "id": 601, 385 | "title": "Moving On", 386 | "duration": "2:39", 387 | "albumId": 6 388 | }, 389 | { 390 | "id": 602, 391 | "title": "Oh Pretty Woman", 392 | "duration": "4:25", 393 | "albumId": 6 394 | }, 395 | { 396 | "id": 603, 397 | "title": "Walking by Myself", 398 | "duration": "2:56", 399 | "albumId": 6 400 | }, 401 | { 402 | "id": 604, 403 | "title": "Still Got the Blues", 404 | "duration": "6:12", 405 | "albumId": 6 406 | }, 407 | { 408 | "id": 605, 409 | "title": "Texas Strut", 410 | "duration": "4:52", 411 | "albumId": 6 412 | }, 413 | { 414 | "id": 606, 415 | "title": "Too Tired", 416 | "duration": "2:54", 417 | "albumId": 6 418 | }, 419 | { 420 | "id": 607, 421 | "title": "King of the Blues", 422 | "duration": "4:35", 423 | "albumId": 6 424 | }, 425 | { 426 | "id": 608, 427 | "title": "As the Years Go Passing By", 428 | "duration": "7:47", 429 | "albumId": 6 430 | }, 431 | { 432 | "id": 609, 433 | "title": "Midnight Blues", 434 | "duration": "4:59", 435 | "albumId": 6 436 | }, 437 | { 438 | "id": 610, 439 | "title": "That Kind of Woman", 440 | "duration": "4:30", 441 | "albumId": 6 442 | } 443 | ] 444 | } 445 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "signal-store-workshop", 3 | "scripts": { 4 | "ng": "ng", 5 | "start": "run-p start:app start:server", 6 | "start:app": "ng serve", 7 | "start:server": "json-server --watch db.json --port 3000 --delay 1200", 8 | "build": "ng build", 9 | "watch": "ng build --watch --configuration development", 10 | "format": "prettier --write ." 11 | }, 12 | "private": true, 13 | "dependencies": { 14 | "@angular/animations": "18.2.10", 15 | "@angular/cdk": "18.2.11", 16 | "@angular/common": "18.2.10", 17 | "@angular/compiler": "18.2.10", 18 | "@angular/core": "18.2.10", 19 | "@angular/forms": "18.2.10", 20 | "@angular/material": "18.2.11", 21 | "@angular/platform-browser": "18.2.10", 22 | "@angular/platform-browser-dynamic": "18.2.10", 23 | "@angular/router": "18.2.10", 24 | "@ngrx/operators": "18.1.1", 25 | "@ngrx/signals": "18.1.1", 26 | "rxjs": "7.8.1", 27 | "tslib": "2.6.2" 28 | }, 29 | "devDependencies": { 30 | "@angular/build": "18.2.11", 31 | "@angular/cli": "18.2.11", 32 | "@angular/compiler-cli": "18.2.10", 33 | "json-server": "0.17.4", 34 | "npm-run-all": "4.1.5", 35 | "prettier": "3.3.3", 36 | "typescript": "5.5.4" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/app/albums/album-overview/album-details/album-details.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | text-align: center; 3 | } 4 | 5 | mat-spinner { 6 | margin: 0 auto; 7 | } 8 | 9 | h2 { 10 | margin: 0; 11 | } 12 | 13 | h3 { 14 | color: var(--mat-card-subtitle-text-color); 15 | } 16 | 17 | .album-info { 18 | margin-top: 1rem; 19 | display: flex; 20 | flex-direction: column; 21 | gap: 0.5rem; 22 | 23 | > p { 24 | margin: 0; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/albums/album-overview/album-details/album-details.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; 2 | import { DatePipe, NgOptimizedImage } from '@angular/common'; 3 | import { MatProgressSpinner } from '@angular/material/progress-spinner'; 4 | import { AlbumDetailsStore } from './album-details.store'; 5 | 6 | @Component({ 7 | selector: 'ngrx-album-details', 8 | standalone: true, 9 | imports: [NgOptimizedImage, DatePipe, MatProgressSpinner], 10 | template: ` 11 | @if (store.album(); as album) { 12 |

{{ album.title }}

13 |

by {{ album.artist }}

14 | 15 | 22 | 23 |
24 |

25 | Release Date: 26 | {{ album.releaseDate | date }} 27 |

28 |

Genre: {{ album.genre }}

29 |
30 | } @else if (store.isPending()) { 31 | 32 | } 33 | `, 34 | styleUrl: './album-details.component.scss', 35 | changeDetection: ChangeDetectionStrategy.OnPush, 36 | }) 37 | export class AlbumDetailsComponent { 38 | readonly store = inject(AlbumDetailsStore); 39 | } 40 | -------------------------------------------------------------------------------- /src/app/albums/album-overview/album-details/album-details.store.ts: -------------------------------------------------------------------------------- 1 | import { computed, inject } from '@angular/core'; 2 | import { Router } from '@angular/router'; 3 | import { filter, pipe, switchMap, tap } from 'rxjs'; 4 | import { 5 | patchState, 6 | signalStore, 7 | withComputed, 8 | withHooks, 9 | withMethods, 10 | } from '@ngrx/signals'; 11 | import { rxMethod } from '@ngrx/signals/rxjs-interop'; 12 | import { tapResponse } from '@ngrx/operators'; 13 | import { withRouteParams } from '@/shared/state/route-params.feature'; 14 | import { 15 | setFulfilled, 16 | setPending, 17 | withRequestStatus, 18 | } from '@/shared/state/request-status.feature'; 19 | import { AlbumsStore } from '@/albums/albums.store'; 20 | import { AlbumsService } from '@/albums/albums.service'; 21 | 22 | export const AlbumDetailsStore = signalStore( 23 | withRequestStatus(), 24 | withRouteParams({ albumId: (param) => Number(param) }), 25 | withComputed(({ albumId }, albumsStore = inject(AlbumsStore)) => ({ 26 | album: computed(() => 27 | albumId() ? albumsStore.entityMap()[albumId()] : null, 28 | ), 29 | })), 30 | withMethods( 31 | ( 32 | albumDetailsStore, 33 | albumsStore = inject(AlbumsStore), 34 | albumsService = inject(AlbumsService), 35 | router = inject(Router), 36 | ) => ({ 37 | loadAlbumIfNotLoaded: rxMethod( 38 | pipe( 39 | filter((id) => !albumsStore.entityMap()[id]), 40 | tap(() => patchState(albumDetailsStore, setPending())), 41 | switchMap((id) => { 42 | return albumsService.getById(id).pipe( 43 | tapResponse({ 44 | next: (album) => { 45 | patchState(albumDetailsStore, setFulfilled()); 46 | albumsStore.setAlbum(album); 47 | }, 48 | error: () => router.navigateByUrl('/not-found'), 49 | }), 50 | ); 51 | }), 52 | ), 53 | ), 54 | }), 55 | ), 56 | withHooks({ 57 | onInit({ loadAlbumIfNotLoaded, albumId }) { 58 | loadAlbumIfNotLoaded(albumId); 59 | }, 60 | }), 61 | ); 62 | -------------------------------------------------------------------------------- /src/app/albums/album-overview/album-overview.component.scss: -------------------------------------------------------------------------------- 1 | h1 { 2 | text-align: center; 3 | } 4 | 5 | .album-shell { 6 | margin-top: 2rem; 7 | display: flex; 8 | flex-direction: row; 9 | justify-content: space-between; 10 | gap: 2rem; 11 | } 12 | 13 | ngrx-album-songs { 14 | flex: 1; 15 | max-width: 30rem; 16 | } 17 | -------------------------------------------------------------------------------- /src/app/albums/album-overview/album-overview.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectionStrategy, 3 | Component, 4 | computed, 5 | inject, 6 | } from '@angular/core'; 7 | import { Router } from '@angular/router'; 8 | import { MatFabButton } from '@angular/material/button'; 9 | import { MatIcon } from '@angular/material/icon'; 10 | import { ProgressBarComponent } from '@/shared/ui/progress-bar.component'; 11 | import { AlbumDetailsComponent } from './album-details/album-details.component'; 12 | import { AlbumDetailsStore } from './album-details/album-details.store'; 13 | import { AlbumSongsComponent } from './album-songs/album-songs.component'; 14 | import { AlbumSongsStore } from './album-songs/album-songs.store'; 15 | 16 | @Component({ 17 | selector: 'ngrx-album-overview', 18 | standalone: true, 19 | imports: [ 20 | MatFabButton, 21 | MatIcon, 22 | ProgressBarComponent, 23 | AlbumDetailsComponent, 24 | AlbumSongsComponent, 25 | ], 26 | template: ` 27 | 28 | 29 |
30 |

Album Overview

31 | 32 |
33 | 36 | 37 | 38 | 39 | 40 | 43 |
44 |
45 | `, 46 | providers: [AlbumDetailsStore, AlbumSongsStore], 47 | styleUrl: './album-overview.component.scss', 48 | changeDetection: ChangeDetectionStrategy.OnPush, 49 | }) 50 | export default class AlbumOverviewComponent { 51 | readonly #router = inject(Router); 52 | readonly #detailsStore = inject(AlbumDetailsStore); 53 | readonly #songsStore = inject(AlbumSongsStore); 54 | 55 | readonly showProgress = computed( 56 | () => this.#detailsStore.isPending() || this.#songsStore.isPending(), 57 | ); 58 | 59 | goToNextAlbum(): void { 60 | this.#router.navigate(['albums', this.#detailsStore.albumId() + 1]); 61 | } 62 | 63 | goToPreviousAlbum(): void { 64 | this.#router.navigate(['albums', this.#detailsStore.albumId() - 1]); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/app/albums/album-overview/album-songs/album-songs.component.scss: -------------------------------------------------------------------------------- 1 | mat-spinner { 2 | margin: 0 auto; 3 | } 4 | 5 | .song { 6 | display: flex; 7 | flex-direction: row; 8 | align-items: center; 9 | justify-content: space-between; 10 | padding: 1rem; 11 | font-size: 1rem; 12 | width: 100%; 13 | border-radius: 0; 14 | } 15 | 16 | p { 17 | margin: 0; 18 | } 19 | 20 | .song-title { 21 | font-weight: 500; 22 | } 23 | -------------------------------------------------------------------------------- /src/app/albums/album-overview/album-songs/album-songs.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; 2 | import { MatProgressSpinner } from '@angular/material/progress-spinner'; 3 | import { MatCard } from '@angular/material/card'; 4 | import { AlbumSongsStore } from './album-songs.store'; 5 | 6 | @Component({ 7 | selector: 'ngrx-album-songs', 8 | standalone: true, 9 | imports: [MatProgressSpinner, MatCard], 10 | template: ` 11 | @if (store.isPending()) { 12 | 13 | } @else { 14 | @for (song of store.entities(); track song.id) { 15 | 16 |

{{ song.title }}

17 |

{{ song.duration }}

18 |
19 | } 20 | } 21 | `, 22 | styleUrl: './album-songs.component.scss', 23 | changeDetection: ChangeDetectionStrategy.OnPush, 24 | }) 25 | export class AlbumSongsComponent { 26 | readonly store = inject(AlbumSongsStore); 27 | } 28 | -------------------------------------------------------------------------------- /src/app/albums/album-overview/album-songs/album-songs.store.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@angular/core'; 2 | import { MatSnackBar } from '@angular/material/snack-bar'; 3 | import { filter, pipe, switchMap, tap } from 'rxjs'; 4 | import { patchState, signalStore, withHooks, withMethods } from '@ngrx/signals'; 5 | import { rxMethod } from '@ngrx/signals/rxjs-interop'; 6 | import { setAllEntities, withEntities } from '@ngrx/signals/entities'; 7 | import { tapResponse } from '@ngrx/operators'; 8 | import { 9 | setError, 10 | setFulfilled, 11 | setPending, 12 | withRequestStatus, 13 | } from '@/shared/state/request-status.feature'; 14 | import { withRouteParams } from '@/shared/state/route-params.feature'; 15 | import { Song } from '@/songs/song.model'; 16 | import { SongsService } from '@/songs/songs.service'; 17 | 18 | export const AlbumSongsStore = signalStore( 19 | withEntities(), 20 | withRequestStatus(), 21 | withRouteParams({ albumId: (param) => Number(param) }), 22 | withMethods( 23 | ( 24 | store, 25 | songsService = inject(SongsService), 26 | snackBar = inject(MatSnackBar), 27 | ) => ({ 28 | loadSongsByAlbumId: rxMethod( 29 | pipe( 30 | filter(Boolean), 31 | tap(() => patchState(store, setPending())), 32 | switchMap((albumId) => { 33 | return songsService.getByAlbumId(albumId).pipe( 34 | tapResponse({ 35 | next: (songs) => { 36 | patchState(store, setAllEntities(songs), setFulfilled()); 37 | }, 38 | error: (error: { message: string }) => { 39 | patchState(store, setError(error.message)); 40 | snackBar.open(error.message, 'Close', { duration: 5_000 }); 41 | }, 42 | }), 43 | ); 44 | }), 45 | ), 46 | ), 47 | }), 48 | ), 49 | withHooks({ 50 | onInit({ loadSongsByAlbumId, albumId }) { 51 | loadSongsByAlbumId(albumId); 52 | }, 53 | }), 54 | ); 55 | -------------------------------------------------------------------------------- /src/app/albums/album-search/album-filter/album-filter.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | margin-top: 2rem; 4 | max-width: 600px; 5 | } 6 | 7 | .filter-container { 8 | display: flex; 9 | flex-direction: row; 10 | gap: 2rem; 11 | width: 100%; 12 | } 13 | 14 | .query { 15 | flex: 1; 16 | } 17 | 18 | .order { 19 | margin-top: 0.25rem; 20 | } 21 | -------------------------------------------------------------------------------- /src/app/albums/album-search/album-filter/album-filter.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, model } from '@angular/core'; 2 | import { FormsModule } from '@angular/forms'; 3 | import { MatFormField, MatLabel } from '@angular/material/form-field'; 4 | import { MatInput } from '@angular/material/input'; 5 | import { 6 | MatButtonToggle, 7 | MatButtonToggleGroup, 8 | } from '@angular/material/button-toggle'; 9 | import { MatIcon } from '@angular/material/icon'; 10 | import { SortOrder } from '@/shared/models/sort-order.model'; 11 | 12 | @Component({ 13 | selector: 'ngrx-album-filter', 14 | standalone: true, 15 | imports: [ 16 | FormsModule, 17 | MatFormField, 18 | MatInput, 19 | MatLabel, 20 | MatButtonToggle, 21 | MatButtonToggleGroup, 22 | MatIcon, 23 | ], 24 | template: ` 25 |
26 | 27 | Search 28 | 35 | 36 | 37 |
38 | 42 | 43 | arrow_upward 44 | 45 | 46 | arrow_downward 47 | 48 | 49 |
50 |
51 | `, 52 | styleUrl: './album-filter.component.scss', 53 | changeDetection: ChangeDetectionStrategy.OnPush, 54 | }) 55 | export class AlbumFilterComponent { 56 | readonly query = model(''); 57 | readonly order = model('asc'); 58 | 59 | onQueryChange(query: string): void { 60 | this.query.set(query.trim().toLowerCase()); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/app/albums/album-search/album-list/album-list.component.scss: -------------------------------------------------------------------------------- 1 | @use "@angular/material" as mat; 2 | 3 | :host { 4 | display: block; 5 | margin-top: 1rem; 6 | } 7 | 8 | mat-spinner { 9 | margin: 0 auto; 10 | } 11 | 12 | .albums-container { 13 | display: grid; 14 | grid-template-columns: repeat(2, minmax(0, 1fr)); 15 | gap: 1rem; 16 | } 17 | 18 | a { 19 | text-decoration: none; 20 | color: inherit; 21 | } 22 | 23 | mat-card { 24 | @include mat.elevation-transition(); 25 | @include mat.elevation(4); 26 | 27 | padding: 1rem; 28 | display: flex; 29 | flex-direction: row; 30 | flex-wrap: wrap; 31 | justify-content: space-between; 32 | gap: 1rem; 33 | 34 | &:hover { 35 | @include mat.elevation(9); 36 | } 37 | } 38 | 39 | .album-content { 40 | display: flex; 41 | flex-direction: column; 42 | justify-content: space-between; 43 | } 44 | 45 | .album-info { 46 | display: flex; 47 | flex-direction: column; 48 | gap: 0.5rem; 49 | 50 | > p { 51 | margin: 0; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/app/albums/album-search/album-list/album-list.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, input } from '@angular/core'; 2 | import { DatePipe } from '@angular/common'; 3 | import { RouterLink } from '@angular/router'; 4 | import { MatProgressSpinner } from '@angular/material/progress-spinner'; 5 | import { MatCard, MatCardSubtitle, MatCardTitle } from '@angular/material/card'; 6 | import { Album } from '@/albums/album.model'; 7 | 8 | @Component({ 9 | selector: 'ngrx-album-list', 10 | standalone: true, 11 | imports: [ 12 | DatePipe, 13 | RouterLink, 14 | MatProgressSpinner, 15 | MatCard, 16 | MatCardTitle, 17 | MatCardSubtitle, 18 | ], 19 | template: ` 20 | @if (showSpinner()) { 21 | 22 | } @else { 23 | 52 | } 53 | `, 54 | styleUrl: './album-list.component.scss', 55 | changeDetection: ChangeDetectionStrategy.OnPush, 56 | }) 57 | export class AlbumListComponent { 58 | readonly albums = input([]); 59 | readonly showSpinner = input(false); 60 | } 61 | -------------------------------------------------------------------------------- /src/app/albums/album-search/album-search.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; 2 | import { ProgressBarComponent } from '@/shared/ui/progress-bar.component'; 3 | import { AlbumFilterComponent } from './album-filter/album-filter.component'; 4 | import { AlbumListComponent } from './album-list/album-list.component'; 5 | import { AlbumSearchStore } from './album-search.store'; 6 | 7 | @Component({ 8 | selector: 'ngrx-album-search', 9 | standalone: true, 10 | imports: [ProgressBarComponent, AlbumFilterComponent, AlbumListComponent], 11 | template: ` 12 | 13 | 14 |
15 |

Albums ({{ store.totalAlbums() }})

16 | 17 | 23 | 24 | 28 |
29 | `, 30 | providers: [AlbumSearchStore], 31 | changeDetection: ChangeDetectionStrategy.OnPush, 32 | }) 33 | export default class AlbumSearchComponent { 34 | readonly store = inject(AlbumSearchStore); 35 | } 36 | -------------------------------------------------------------------------------- /src/app/albums/album-search/album-search.store.ts: -------------------------------------------------------------------------------- 1 | import { computed, inject } from '@angular/core'; 2 | import { MatSnackBar } from '@angular/material/snack-bar'; 3 | import { filter, pipe, tap } from 'rxjs'; 4 | import { 5 | patchState, 6 | signalStore, 7 | withComputed, 8 | withHooks, 9 | withMethods, 10 | withState, 11 | } from '@ngrx/signals'; 12 | import { rxMethod } from '@ngrx/signals/rxjs-interop'; 13 | import { SortOrder } from '@/shared/models/sort-order.model'; 14 | import { searchAlbums, sortAlbums } from '@/albums/album.model'; 15 | import { AlbumsStore } from '@/albums/albums.store'; 16 | 17 | export const AlbumSearchStore = signalStore( 18 | withState({ 19 | query: '', 20 | order: 'asc' as SortOrder, 21 | }), 22 | withComputed(({ query, order }, albumsStore = inject(AlbumsStore)) => { 23 | const filteredAlbums = computed(() => { 24 | const searchedAlbums = searchAlbums(albumsStore.entities(), query()); 25 | return sortAlbums(searchedAlbums, order()); 26 | }); 27 | 28 | return { 29 | filteredAlbums, 30 | showProgress: albumsStore.isPending, 31 | showSpinner: computed( 32 | () => albumsStore.isPending() && albumsStore.entities().length === 0, 33 | ), 34 | totalAlbums: computed(() => filteredAlbums().length), 35 | }; 36 | }), 37 | withMethods((store, snackBar = inject(MatSnackBar)) => ({ 38 | updateQuery(query: string): void { 39 | patchState(store, { query }); 40 | }, 41 | updateOrder(order: SortOrder): void { 42 | patchState(store, { order }); 43 | }, 44 | _notifyOnError: rxMethod( 45 | pipe( 46 | filter(Boolean), 47 | tap((error) => snackBar.open(error, 'Close', { duration: 5_000 })), 48 | ), 49 | ), 50 | })), 51 | withHooks({ 52 | onInit(store, albumsStore = inject(AlbumsStore)) { 53 | albumsStore.loadAllAlbums(); 54 | store._notifyOnError(albumsStore.error); 55 | }, 56 | }), 57 | ); 58 | -------------------------------------------------------------------------------- /src/app/albums/album.model.ts: -------------------------------------------------------------------------------- 1 | import { SortOrder } from '@/shared/models/sort-order.model'; 2 | 3 | export type Album = { 4 | id: number; 5 | title: string; 6 | artist: string; 7 | genre: string; 8 | releaseDate: string; 9 | coverImage: string; 10 | }; 11 | 12 | export function searchAlbums(albums: Album[], query: string): Album[] { 13 | return albums.filter(({ title }) => title.toLowerCase().includes(query)); 14 | } 15 | 16 | export function sortAlbums(albums: Album[], order: SortOrder): Album[] { 17 | const direction = order === 'asc' ? 1 : -1; 18 | return [...albums].sort((a, b) => direction * a.title.localeCompare(b.title)); 19 | } 20 | -------------------------------------------------------------------------------- /src/app/albums/albums.routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | 3 | const routes: Routes = [ 4 | { 5 | path: '', 6 | loadComponent: () => import('./album-search/album-search.component'), 7 | title: 'Album Search', 8 | }, 9 | { 10 | path: ':albumId', 11 | loadComponent: () => 12 | import('@/albums/album-overview/album-overview.component'), 13 | title: 'Album Overview', 14 | }, 15 | ]; 16 | 17 | export default routes; 18 | -------------------------------------------------------------------------------- /src/app/albums/albums.service.ts: -------------------------------------------------------------------------------- 1 | import { inject, Injectable } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { Observable } from 'rxjs'; 4 | import { Album } from './album.model'; 5 | 6 | const API_URL = 'http://localhost:3000/albums'; 7 | 8 | @Injectable({ providedIn: 'root' }) 9 | export class AlbumsService { 10 | readonly #http = inject(HttpClient); 11 | 12 | getAll(): Observable { 13 | return this.#http.get(API_URL); 14 | } 15 | 16 | getById(id: number): Observable { 17 | return this.#http.get(`${API_URL}/${id}`); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/app/albums/albums.store.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@angular/core'; 2 | import { exhaustMap, pipe, tap } from 'rxjs'; 3 | import { patchState, signalStore, withMethods } from '@ngrx/signals'; 4 | import { rxMethod } from '@ngrx/signals/rxjs-interop'; 5 | import { 6 | setAllEntities, 7 | setEntity, 8 | withEntities, 9 | } from '@ngrx/signals/entities'; 10 | import { tapResponse } from '@ngrx/operators'; 11 | import { 12 | setError, 13 | setFulfilled, 14 | setPending, 15 | withRequestStatus, 16 | } from '@/shared/state/request-status.feature'; 17 | import { withStorageSync } from '@/shared/state/storage-sync.feature'; 18 | import { Album } from '@/albums/album.model'; 19 | import { AlbumsService } from '@/albums/albums.service'; 20 | 21 | export const AlbumsStore = signalStore( 22 | { providedIn: 'root' }, 23 | withEntities(), 24 | withRequestStatus(), 25 | withMethods((store, albumsService = inject(AlbumsService)) => ({ 26 | setAlbum(album: Album): void { 27 | patchState(store, setEntity(album)); 28 | }, 29 | loadAllAlbums: rxMethod( 30 | pipe( 31 | tap(() => patchState(store, setPending())), 32 | exhaustMap(() => { 33 | return albumsService.getAll().pipe( 34 | tapResponse({ 35 | next: (albums) => { 36 | patchState(store, setAllEntities(albums), setFulfilled()); 37 | }, 38 | error: (error: { message: string }) => { 39 | patchState(store, setError(error.message)); 40 | }, 41 | }), 42 | ); 43 | }), 44 | ), 45 | ), 46 | })), 47 | withStorageSync('albumsState'), 48 | ); 49 | -------------------------------------------------------------------------------- /src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | flex-direction: column; 4 | min-height: 100vh; 5 | } 6 | 7 | main { 8 | flex-grow: 1; 9 | } 10 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { RouterOutlet } from '@angular/router'; 3 | import { HeaderComponent } from '@/core/layout/header.component'; 4 | import { FooterComponent } from '@/core/layout/footer.component'; 5 | 6 | @Component({ 7 | selector: 'ngrx-root', 8 | standalone: true, 9 | imports: [RouterOutlet, HeaderComponent, FooterComponent], 10 | template: ` 11 | 12 |
13 | 14 |
15 | 16 | `, 17 | styleUrl: './app.component.scss', 18 | changeDetection: ChangeDetectionStrategy.OnPush, 19 | }) 20 | export class AppComponent {} 21 | -------------------------------------------------------------------------------- /src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationConfig, 3 | provideExperimentalZonelessChangeDetection, 4 | } from '@angular/core'; 5 | import { provideRouter } from '@angular/router'; 6 | import { provideAnimations } from '@angular/platform-browser/animations'; 7 | import { provideHttpClient } from '@angular/common/http'; 8 | import { routes } from './app.routes'; 9 | 10 | export const appConfig: ApplicationConfig = { 11 | providers: [ 12 | provideExperimentalZonelessChangeDetection(), 13 | provideRouter(routes), 14 | provideAnimations(), 15 | provideHttpClient(), 16 | ], 17 | }; 18 | -------------------------------------------------------------------------------- /src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | 3 | export const routes: Routes = [ 4 | { path: '', redirectTo: '/albums', pathMatch: 'full' }, 5 | { path: 'albums', loadChildren: () => import('@/albums/albums.routes') }, 6 | { 7 | path: '**', 8 | loadComponent: () => import('@/core/not-found/not-found.component'), 9 | title: 'Not Found', 10 | }, 11 | ]; 12 | -------------------------------------------------------------------------------- /src/app/core/layout/footer.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'ngrx-footer', 5 | standalone: true, 6 | template: '

© 2024 NgRx Team

', 7 | styles: ` 8 | :host { 9 | display: block; 10 | margin: 0 2rem; 11 | } 12 | 13 | p { 14 | text-align: center; 15 | padding: 1rem; 16 | margin: 0; 17 | font-size: 1rem; 18 | color: rgba(0, 0, 0, 0.6); 19 | border-top: 1px solid rgba(0, 0, 0, 0.2); 20 | } 21 | `, 22 | changeDetection: ChangeDetectionStrategy.OnPush, 23 | }) 24 | export class FooterComponent {} 25 | -------------------------------------------------------------------------------- /src/app/core/layout/header.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { RouterLink } from '@angular/router'; 3 | import { MatToolbar } from '@angular/material/toolbar'; 4 | 5 | @Component({ 6 | selector: 'ngrx-header', 7 | standalone: true, 8 | imports: [MatToolbar, RouterLink], 9 | template: ` 10 | 11 | SignalStore Workshop 12 | 13 | `, 14 | styles: ` 15 | a { 16 | color: inherit; 17 | text-decoration: none; 18 | } 19 | `, 20 | changeDetection: ChangeDetectionStrategy.OnPush, 21 | }) 22 | export class HeaderComponent {} 23 | -------------------------------------------------------------------------------- /src/app/core/not-found/not-found.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | margin-top: 2rem; 3 | display: flex; 4 | flex-direction: column; 5 | align-items: center; 6 | } 7 | -------------------------------------------------------------------------------- /src/app/core/not-found/not-found.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { MatAnchor } from '@angular/material/button'; 3 | import { RouterLink } from '@angular/router'; 4 | import { MatIcon } from '@angular/material/icon'; 5 | 6 | @Component({ 7 | selector: 'ngrx-not-found', 8 | standalone: true, 9 | imports: [RouterLink, MatAnchor, MatIcon], 10 | template: ` 11 |

Oops!

12 |

Something went wrong.

13 | 14 | Take me 15 | home 16 | 17 | `, 18 | styleUrl: './not-found.component.scss', 19 | changeDetection: ChangeDetectionStrategy.OnPush, 20 | }) 21 | export default class NotFoundComponent {} 22 | -------------------------------------------------------------------------------- /src/app/shared/models/sort-order.model.ts: -------------------------------------------------------------------------------- 1 | export type SortOrder = 'asc' | 'desc'; 2 | -------------------------------------------------------------------------------- /src/app/shared/state/request-status.feature.ts: -------------------------------------------------------------------------------- 1 | import { computed } from '@angular/core'; 2 | import { signalStoreFeature, withComputed, withState } from '@ngrx/signals'; 3 | 4 | export type RequestStatus = 5 | | 'idle' 6 | | 'pending' 7 | | 'fulfilled' 8 | | { error: string }; 9 | 10 | export type RequestStatusState = { requestStatus: RequestStatus }; 11 | 12 | export function withRequestStatus() { 13 | return signalStoreFeature( 14 | withState({ requestStatus: 'idle' }), 15 | withComputed(({ requestStatus }) => ({ 16 | isPending: computed(() => requestStatus() === 'pending'), 17 | isFulfilled: computed(() => requestStatus() === 'fulfilled'), 18 | error: computed(() => { 19 | const status = requestStatus(); 20 | return typeof status === 'object' ? status.error : null; 21 | }), 22 | })), 23 | ); 24 | } 25 | 26 | export function setPending(): RequestStatusState { 27 | return { requestStatus: 'pending' }; 28 | } 29 | 30 | export function setFulfilled(): RequestStatusState { 31 | return { requestStatus: 'fulfilled' }; 32 | } 33 | 34 | export function setError(error: string): RequestStatusState { 35 | return { requestStatus: { error } }; 36 | } 37 | -------------------------------------------------------------------------------- /src/app/shared/state/route-params.feature.ts: -------------------------------------------------------------------------------- 1 | import { computed, inject, Signal } from '@angular/core'; 2 | import { ActivatedRoute, Params } from '@angular/router'; 3 | import { toSignal } from '@angular/core/rxjs-interop'; 4 | import { signalStoreFeature, withComputed } from '@ngrx/signals'; 5 | 6 | type RouteParamsConfig = Record unknown>; 7 | 8 | type RouteParamsComputed = { 9 | [Key in keyof Config]: Config[Key] extends infer TransformFn 10 | ? TransformFn extends (...args: any[]) => any 11 | ? Signal> 12 | : never 13 | : never; 14 | }; 15 | 16 | export function withRouteParams( 17 | config: Config, 18 | ) { 19 | return signalStoreFeature( 20 | withComputed(() => { 21 | const routeParams = injectRouteParams(); 22 | 23 | return Object.keys(config).reduce( 24 | (acc, key) => ({ 25 | ...acc, 26 | [key]: computed(() => { 27 | const value = routeParams()[key]; 28 | return config[key](value); 29 | }), 30 | }), 31 | {} as RouteParamsComputed, 32 | ); 33 | }), 34 | ); 35 | } 36 | 37 | function injectRouteParams(): Signal { 38 | const params$ = inject(ActivatedRoute).params; 39 | 40 | return toSignal(params$, { 41 | initialValue: {} as Record, 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /src/app/shared/state/storage-sync.feature.ts: -------------------------------------------------------------------------------- 1 | import { effect, inject, PLATFORM_ID } from '@angular/core'; 2 | import { isPlatformServer } from '@angular/common'; 3 | import { 4 | getState, 5 | patchState, 6 | signalStoreFeature, 7 | withHooks, 8 | } from '@ngrx/signals'; 9 | 10 | export function withStorageSync( 11 | key: string, 12 | storageFactory = () => localStorage, 13 | ) { 14 | return signalStoreFeature( 15 | withHooks({ 16 | onInit(store, platformId = inject(PLATFORM_ID)) { 17 | if (isPlatformServer(platformId)) { 18 | return; 19 | } 20 | 21 | const storage = storageFactory(); 22 | 23 | const stateStr = storage.getItem(key); 24 | if (stateStr) { 25 | patchState(store, JSON.parse(stateStr)); 26 | } 27 | 28 | effect(() => { 29 | const state = getState(store); 30 | storage.setItem(key, JSON.stringify(state)); 31 | }); 32 | }, 33 | }), 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/app/shared/ui/progress-bar.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, input } from '@angular/core'; 2 | import { MatProgressBar } from '@angular/material/progress-bar'; 3 | 4 | @Component({ 5 | selector: 'ngrx-progress-bar', 6 | standalone: true, 7 | imports: [MatProgressBar], 8 | template: ` 9 | @if (showProgress()) { 10 | 11 | } @else { 12 | 13 | } 14 | `, 15 | changeDetection: ChangeDetectionStrategy.OnPush, 16 | }) 17 | export class ProgressBarComponent { 18 | readonly showProgress = input(true); 19 | } 20 | -------------------------------------------------------------------------------- /src/app/songs/song.model.ts: -------------------------------------------------------------------------------- 1 | export type Song = { 2 | id: number; 3 | title: string; 4 | duration: string; 5 | albumId: number; 6 | }; 7 | -------------------------------------------------------------------------------- /src/app/songs/songs.service.ts: -------------------------------------------------------------------------------- 1 | import { inject, Injectable } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { Observable } from 'rxjs'; 4 | import { Song } from './song.model'; 5 | 6 | const API_URL = 'http://localhost:3000/songs'; 7 | 8 | @Injectable({ providedIn: 'root' }) 9 | export class SongsService { 10 | readonly #http = inject(HttpClient); 11 | 12 | getByAlbumId(albumId: number): Observable { 13 | return this.#http.get(API_URL, { params: { albumId } }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngrx/signal-store-workshop/bb0ba1dc87ff1a65dcd179ab3ac4d7135500b836/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/assets/album-covers/are-you-experienced.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngrx/signal-store-workshop/bb0ba1dc87ff1a65dcd179ab3ac4d7135500b836/src/assets/album-covers/are-you-experienced.jpg -------------------------------------------------------------------------------- /src/assets/album-covers/eliminator.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngrx/signal-store-workshop/bb0ba1dc87ff1a65dcd179ab3ac4d7135500b836/src/assets/album-covers/eliminator.jpg -------------------------------------------------------------------------------- /src/assets/album-covers/live-at-the-regal.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngrx/signal-store-workshop/bb0ba1dc87ff1a65dcd179ab3ac4d7135500b836/src/assets/album-covers/live-at-the-regal.jpg -------------------------------------------------------------------------------- /src/assets/album-covers/still-got-the-blues.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngrx/signal-store-workshop/bb0ba1dc87ff1a65dcd179ab3ac4d7135500b836/src/assets/album-covers/still-got-the-blues.jpg -------------------------------------------------------------------------------- /src/assets/album-covers/texas-flood.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngrx/signal-store-workshop/bb0ba1dc87ff1a65dcd179ab3ac4d7135500b836/src/assets/album-covers/texas-flood.jpg -------------------------------------------------------------------------------- /src/assets/album-covers/unplugged.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngrx/signal-store-workshop/bb0ba1dc87ff1a65dcd179ab3ac4d7135500b836/src/assets/album-covers/unplugged.jpg -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngrx/signal-store-workshop/bb0ba1dc87ff1a65dcd179ab3ac4d7135500b836/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SignalStoreWorkshop 6 | 7 | 8 | 9 | 13 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from '@angular/platform-browser'; 2 | import { appConfig } from '@/app.config'; 3 | import { AppComponent } from '@/app.component'; 4 | 5 | bootstrapApplication(AppComponent, appConfig).catch((err) => 6 | console.error(err), 7 | ); 8 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | } 5 | 6 | body { 7 | margin: 0; 8 | font-family: Roboto, "Helvetica Neue", sans-serif; 9 | } 10 | 11 | .container { 12 | max-width: 1100px; 13 | margin: 2rem auto; 14 | } 15 | -------------------------------------------------------------------------------- /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": ["src/main.ts"], 9 | "include": ["src/**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "outDir": "./dist/out-tsc", 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "noImplicitOverride": true, 9 | "noPropertyAccessFromIndexSignature": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "skipLibCheck": true, 13 | "esModuleInterop": true, 14 | "sourceMap": true, 15 | "declaration": false, 16 | "experimentalDecorators": true, 17 | "moduleResolution": "node", 18 | "importHelpers": true, 19 | "target": "ES2022", 20 | "module": "ES2022", 21 | "useDefineForClassFields": false, 22 | "lib": ["ES2022", "dom"], 23 | "paths": { 24 | "@/*": ["./src/app/*"] 25 | } 26 | }, 27 | "angularCompilerOptions": { 28 | "enableI18nLegacyMessageIdFormat": false, 29 | "strictInjectionParameters": true, 30 | "strictInputAccessModifiers": true, 31 | "strictTemplates": true 32 | } 33 | } 34 | --------------------------------------------------------------------------------