├── .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 |
--------------------------------------------------------------------------------