├── .gitignore
├── LICENSE
├── README.md
└── swVehicles
├── .editorconfig
├── .gitignore
├── .vscode
├── extensions.json
├── launch.json
├── settings.json
└── tasks.json
├── README.md
├── angular.json
├── package.json
├── src
├── app
│ ├── app.component.css
│ ├── app.component.html
│ ├── app.component.ts
│ ├── app.module.ts
│ ├── cart
│ │ ├── cart-item
│ │ │ ├── cart-item.component.html
│ │ │ └── cart-item.component.ts
│ │ ├── cart-list
│ │ │ └── cart-list.component.ts
│ │ ├── cart-shell
│ │ │ └── cart-shell.component.ts
│ │ ├── cart-total
│ │ │ ├── cart-total.component.html
│ │ │ └── cart-total.component.ts
│ │ ├── cart.service.ts
│ │ └── cart.ts
│ ├── page-not-found.component.ts
│ ├── vehicles
│ │ ├── vehicle-classes
│ │ │ ├── vehicle-class-data.ts
│ │ │ ├── vehicle-class.service.ts
│ │ │ └── vehicle-class.ts
│ │ ├── vehicle-detail
│ │ │ ├── vehicle-detail.component.html
│ │ │ └── vehicle-detail.component.ts
│ │ ├── vehicle-list
│ │ │ ├── vehicle-list.component.html
│ │ │ └── vehicle-list.component.ts
│ │ ├── vehicle-shell
│ │ │ └── vehicle-shell.component.ts
│ │ ├── vehicle.service.ts
│ │ └── vehicle.ts
│ └── welcome
│ │ ├── welcome.component.html
│ │ └── welcome.component.ts
├── assets
│ ├── .gitkeep
│ └── Speeder_Bike.png
├── favicon.ico
├── index.html
├── main.ts
└── styles.css
├── tsconfig.app.json
├── tsconfig.json
└── tsconfig.spec.json
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 | *.lcov
24 |
25 | # nyc test coverage
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29 | .grunt
30 |
31 | # Bower dependency directory (https://bower.io/)
32 | bower_components
33 |
34 | # node-waf configuration
35 | .lock-wscript
36 |
37 | # Compiled binary addons (https://nodejs.org/api/addons.html)
38 | build/Release
39 |
40 | # Dependency directories
41 | node_modules/
42 | jspm_packages/
43 |
44 | # TypeScript v1 declaration files
45 | typings/
46 |
47 | # TypeScript cache
48 | *.tsbuildinfo
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 |
56 | # Microbundle cache
57 | .rpt2_cache/
58 | .rts2_cache_cjs/
59 | .rts2_cache_es/
60 | .rts2_cache_umd/
61 |
62 | # Optional REPL history
63 | .node_repl_history
64 |
65 | # Output of 'npm pack'
66 | *.tgz
67 |
68 | # Yarn Integrity file
69 | .yarn-integrity
70 |
71 | # dotenv environment variables file
72 | .env
73 | .env.test
74 |
75 | # parcel-bundler cache (https://parceljs.org/)
76 | .cache
77 |
78 | # Next.js build output
79 | .next
80 |
81 | # Nuxt.js build / generate output
82 | .nuxt
83 | dist
84 |
85 | # Gatsby files
86 | .cache/
87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js
88 | # https://nextjs.org/blog/next-9-1#public-directory-support
89 | # public
90 |
91 | # vuepress build output
92 | .vuepress/dist
93 |
94 | # Serverless directories
95 | .serverless/
96 |
97 | # FuseBox cache
98 | .fusebox/
99 |
100 | # DynamoDB Local files
101 | .dynamodb/
102 |
103 | # TernJS port file
104 | .tern-port
105 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Deborah Kurata
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # swVehicles
2 | Sample application depicting reactive programming in a CRUD application.
3 |
--------------------------------------------------------------------------------
/swVehicles/.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 |
--------------------------------------------------------------------------------
/swVehicles/.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 |
--------------------------------------------------------------------------------
/swVehicles/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
3 | "recommendations": ["angular.ng-template"]
4 | }
5 |
--------------------------------------------------------------------------------
/swVehicles/.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": "pwa-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 |
--------------------------------------------------------------------------------
/swVehicles/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "files.autoSave": "afterDelay",
3 | "html.format.wrapAttributes": "force-aligned",
4 | // "editor.quickSuggestionsDelay": 1000,
5 | //"editor.hover.enabled": false,
6 | // "editor.parameterHints": false,
7 | }
--------------------------------------------------------------------------------
/swVehicles/.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 |
--------------------------------------------------------------------------------
/swVehicles/README.md:
--------------------------------------------------------------------------------
1 | # swVehicles
2 | Sample application depicting reactive programming in a CRUD application.
3 |
--------------------------------------------------------------------------------
/swVehicles/angular.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
3 | "version": 1,
4 | "newProjectRoot": "projects",
5 | "projects": {
6 | "swVehicles": {
7 | "projectType": "application",
8 | "schematics": {},
9 | "root": "",
10 | "sourceRoot": "src",
11 | "prefix": "swv",
12 | "architect": {
13 | "build": {
14 | "builder": "@angular-devkit/build-angular:browser",
15 | "options": {
16 | "outputPath": "dist/sw-vehicles",
17 | "index": "src/index.html",
18 | "main": "src/main.ts",
19 | "polyfills": [
20 | "zone.js"
21 | ],
22 | "tsConfig": "tsconfig.app.json",
23 | "assets": [
24 | "src/favicon.ico",
25 | "src/assets"
26 | ],
27 | "styles": [
28 | "src/styles.css"
29 | ],
30 | "scripts": []
31 | },
32 | "configurations": {
33 | "production": {
34 | "budgets": [
35 | {
36 | "type": "initial",
37 | "maximumWarning": "500kb",
38 | "maximumError": "1mb"
39 | },
40 | {
41 | "type": "anyComponentStyle",
42 | "maximumWarning": "2kb",
43 | "maximumError": "4kb"
44 | }
45 | ],
46 | "outputHashing": "all"
47 | },
48 | "development": {
49 | "buildOptimizer": false,
50 | "optimization": false,
51 | "vendorChunk": true,
52 | "extractLicenses": false,
53 | "sourceMap": true,
54 | "namedChunks": true
55 | }
56 | },
57 | "defaultConfiguration": "production"
58 | },
59 | "serve": {
60 | "builder": "@angular-devkit/build-angular:dev-server",
61 | "configurations": {
62 | "production": {
63 | "browserTarget": "swVehicles:build:production"
64 | },
65 | "development": {
66 | "browserTarget": "swVehicles:build:development"
67 | }
68 | },
69 | "defaultConfiguration": "development"
70 | },
71 | "extract-i18n": {
72 | "builder": "@angular-devkit/build-angular:extract-i18n",
73 | "options": {
74 | "browserTarget": "swVehicles:build"
75 | }
76 | },
77 | "test": {
78 | "builder": "@angular-devkit/build-angular:karma",
79 | "options": {
80 | "polyfills": [
81 | "zone.js",
82 | "zone.js/testing"
83 | ],
84 | "tsConfig": "tsconfig.spec.json",
85 | "assets": [
86 | "src/favicon.ico",
87 | "src/assets"
88 | ],
89 | "styles": [
90 | "src/styles.css"
91 | ],
92 | "scripts": []
93 | }
94 | }
95 | }
96 | }
97 | },
98 | "cli": {
99 | "analytics": "3068aa38-1af3-41e3-b79c-a4984b7e52bf"
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/swVehicles/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sw-vehicles",
3 | "version": "0.0.0",
4 | "scripts": {
5 | "ng": "ng",
6 | "start": "ng serve",
7 | "build": "ng build",
8 | "watch": "ng build --watch --configuration development",
9 | "test": "ng test"
10 | },
11 | "private": true,
12 | "dependencies": {
13 | "@angular/animations": "^15.2.0",
14 | "@angular/common": "^15.2.0",
15 | "@angular/compiler": "^15.2.0",
16 | "@angular/core": "^15.2.0",
17 | "@angular/forms": "^15.2.0",
18 | "@angular/platform-browser": "^15.2.0",
19 | "@angular/platform-browser-dynamic": "^15.2.0",
20 | "@angular/router": "^15.2.0",
21 | "bootstrap": "^5.2.3",
22 | "rxjs": "~7.8.0",
23 | "tslib": "^2.3.0",
24 | "zone.js": "~0.12.0"
25 | },
26 | "devDependencies": {
27 | "@angular-devkit/build-angular": "^15.2.2",
28 | "@angular/cli": "~15.2.2",
29 | "@angular/compiler-cli": "^15.2.0",
30 | "@types/jasmine": "~4.3.0",
31 | "jasmine-core": "~4.5.0",
32 | "karma": "~6.4.0",
33 | "karma-chrome-launcher": "~3.1.0",
34 | "karma-coverage": "~2.2.0",
35 | "karma-jasmine": "~5.1.0",
36 | "karma-jasmine-html-reporter": "~2.0.0",
37 | "typescript": "~4.9.4"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/swVehicles/src/app/app.component.css:
--------------------------------------------------------------------------------
1 | .nav-link {
2 | font-size: large;
3 | cursor: pointer;
4 | }
5 |
6 | .navbar-light .navbar-nav .nav-link.active {
7 | color: #007ACC
8 | }
9 |
10 | .navbar-brand {
11 | margin-left: 10px;
12 | }
13 |
--------------------------------------------------------------------------------
/swVehicles/src/app/app.component.html:
--------------------------------------------------------------------------------
1 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/swVehicles/src/app/app.component.ts:
--------------------------------------------------------------------------------
1 | import { Component } from '@angular/core';
2 | import { map } from 'rxjs';
3 |
4 | import { CartService } from './cart/cart.service';
5 |
6 | @Component({
7 | selector: 'sw-root',
8 | templateUrl: './app.component.html',
9 | styleUrls: ['./app.component.css']
10 | })
11 | export class AppComponent {
12 | pageTitle = 'Star Wars Vehicle Sales';
13 |
14 | cartCount$ = this.cartService.cartItems$.pipe(
15 | map(items => items.length)
16 | );
17 |
18 | constructor(private cartService: CartService) {}
19 | }
20 |
--------------------------------------------------------------------------------
/swVehicles/src/app/app.module.ts:
--------------------------------------------------------------------------------
1 | import { NgModule } from '@angular/core';
2 | import { BrowserModule } from '@angular/platform-browser';
3 | import { HttpClientModule } from '@angular/common/http';
4 | import { RouterModule } from '@angular/router';
5 |
6 | import { AppComponent } from './app.component';
7 | import { CartShellComponent } from './cart/cart-shell/cart-shell.component';
8 | import { WelcomeComponent } from './welcome/welcome.component';
9 | import { PageNotFoundComponent } from './page-not-found.component';
10 |
11 | @NgModule({
12 | declarations: [
13 | AppComponent
14 | ],
15 | imports: [
16 | BrowserModule,
17 | HttpClientModule,
18 | RouterModule.forRoot([
19 | { path: 'welcome', component: WelcomeComponent },
20 | {
21 | path: 'vehicles',
22 | loadComponent: () =>
23 | import('./vehicles/vehicle-shell/vehicle-shell.component').then(m => m.VehicleShellComponent)
24 | },
25 | {
26 | path: 'cart',
27 | component: CartShellComponent
28 | },
29 | { path: '', redirectTo: 'welcome', pathMatch: 'full' },
30 | { path: '**', component: PageNotFoundComponent }
31 | ])
32 | ],
33 | providers: [],
34 | bootstrap: [AppComponent]
35 | })
36 | export class AppModule { }
37 |
--------------------------------------------------------------------------------
/swVehicles/src/app/cart/cart-item/cart-item.component.html:
--------------------------------------------------------------------------------
1 |
3 |
4 |
16 |
17 |
18 |
19 |
Price (credits):
20 |
{{item.vehicle.cost_in_credits | number:'1.2-2'}}
21 |
22 |
23 |
Class:
24 |
{{item.vehicle.vehicle_class}}
25 |
26 |
27 |
28 |
Quantity:
29 |
30 |
39 |
40 |
41 |
42 |
43 |
Cost:
44 |
{{exPrice$ | async | number:'1.2-2'}}
45 |
46 |
47 |
--------------------------------------------------------------------------------
/swVehicles/src/app/cart/cart-item/cart-item.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, Input } from '@angular/core';
2 | import { AsyncPipe, DecimalPipe, NgFor, NgIf } from '@angular/common';
3 | import { FormsModule } from '@angular/forms';
4 |
5 | import { BehaviorSubject, map, tap } from 'rxjs';
6 | import { CartItem } from '../cart';
7 | import { CartService } from '../cart.service';
8 |
9 | @Component({
10 | selector: 'sw-cart-item',
11 | standalone: true,
12 | imports: [AsyncPipe, DecimalPipe, FormsModule, NgFor, NgIf],
13 | templateUrl: './cart-item.component.html'
14 | })
15 | export class CartItemComponent {
16 | // Use a setter to emit whenever a new item is set
17 | _item!: CartItem;
18 | get item(): CartItem {
19 | return this._item;
20 | }
21 | @Input() set item(item: CartItem) {
22 | this._item = item;
23 | this.itemChangedSubject.next(item);
24 | }
25 |
26 | // Hard-coded quantity
27 | // These could instead come from an inventory system
28 | qtyArr = [1, 2, 3, 4, 5, 6, 7, 8];
29 |
30 | // Item was changed action
31 | private itemChangedSubject = new BehaviorSubject(this.item);
32 | item$ = this.itemChangedSubject.asObservable();
33 |
34 | // When the item changes, recalculate the extended price
35 | exPrice$ = this.item$.pipe(
36 | map(it => it.quantity * Number(it.vehicle.cost_in_credits))
37 | );
38 |
39 | constructor(private cartService: CartService) { }
40 |
41 | onQuantitySelected(quantity: number): void {
42 | // Update the quantity in the item
43 | this.cartService.updateInCart(this.item, quantity);
44 | }
45 |
46 | onRemove(): void {
47 | this.cartService.removeFromCart(this.item);
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/swVehicles/src/app/cart/cart-list/cart-list.component.ts:
--------------------------------------------------------------------------------
1 | import { Component } from '@angular/core';
2 | import { AsyncPipe, NgFor } from '@angular/common';
3 |
4 | import { CartService } from '../cart.service';
5 | import { CartItemComponent } from "../cart-item/cart-item.component";
6 |
7 | @Component({
8 | selector: 'sw-cart-list',
9 | standalone: true,
10 | template: `
11 |
12 |
13 |
14 | `,
15 | imports: [AsyncPipe, NgFor, CartItemComponent]
16 | })
17 | export class CartListComponent {
18 | cartItems$ = this.cartService.cartItems$;
19 |
20 | constructor(private cartService: CartService) { }
21 | }
22 |
--------------------------------------------------------------------------------
/swVehicles/src/app/cart/cart-shell/cart-shell.component.ts:
--------------------------------------------------------------------------------
1 | import { Component } from '@angular/core';
2 |
3 | import { CartListComponent } from "../cart-list/cart-list.component";
4 | import { CartTotalComponent } from "../cart-total/cart-total.component";
5 |
6 | @Component({
7 | selector: 'sw-cart-shell',
8 | standalone: true,
9 | template: `
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | `,
19 | imports: [ CartListComponent, CartTotalComponent]
20 | })
21 | export class CartShellComponent {
22 |
23 | }
24 |
--------------------------------------------------------------------------------
/swVehicles/src/app/cart/cart-total/cart-total.component.html:
--------------------------------------------------------------------------------
1 |
3 |
4 |
9 |
10 |
11 |
12 |
Subtotal:
13 |
{{subTotal$ | async | number:'1.2-2'}}
15 |
16 |
17 |
Delivery:
18 |
{{deliveryFee | number:'1.2-2'}}
21 |
Free
24 |
25 |
26 |
27 |
Estimated Tax:
28 |
{{tax$ | async | number:'1.2-2'}}
30 |
31 |
32 |
33 |
Total:
34 |
{{totalPrice$ | async | number:'1.2-2'}}
36 |
37 |
38 |
39 |
40 |
41 | No items in cart
42 |
--------------------------------------------------------------------------------
/swVehicles/src/app/cart/cart-total/cart-total.component.ts:
--------------------------------------------------------------------------------
1 | import { Component } from '@angular/core';
2 | import { AsyncPipe, DecimalPipe, NgIf } from '@angular/common';
3 |
4 | import { CartService } from '../cart.service';
5 |
6 | @Component({
7 | selector: 'sw-cart-total',
8 | standalone: true,
9 | imports: [AsyncPipe, DecimalPipe, NgIf],
10 | templateUrl: './cart-total.component.html'
11 | })
12 | export class CartTotalComponent {
13 |
14 | cartItems$ = this.cartService.cartItems$;
15 |
16 | subTotal$ = this.cartService.subTotal$;
17 |
18 | deliveryFee$ = this.cartService.deliveryFee$;
19 |
20 | tax$ = this.cartService.tax$;
21 |
22 | totalPrice$ = this.cartService.totalPrice$;
23 |
24 | constructor(private cartService: CartService) { }
25 | }
26 |
--------------------------------------------------------------------------------
/swVehicles/src/app/cart/cart.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from "@angular/core";
2 | import { combineLatest, map, scan, shareReplay, Subject } from "rxjs";
3 | import { Vehicle } from "../vehicles/vehicle";
4 | import { Action, CartItem } from "./cart";
5 |
6 | @Injectable({
7 | providedIn: 'root'
8 | })
9 | export class CartService {
10 |
11 | // Add item action
12 | private itemSubject = new Subject>();
13 | itemAction$ = this.itemSubject.asObservable();
14 |
15 | cartItems$ = this.itemAction$
16 | .pipe(
17 | scan((items, itemAction) =>
18 | this.modifyCart(items, itemAction), [] as CartItem[]),
19 | shareReplay(1)
20 | );
21 |
22 | // Total up the extended price for each item
23 | subTotal$ = this.cartItems$.pipe(
24 | map(items => items.reduce((a, b) => a + (b.quantity * Number(b.vehicle.cost_in_credits)), 0)),
25 | );
26 |
27 | // Delivery is free if spending more than 100,000 credits
28 | deliveryFee$ = this.subTotal$.pipe(
29 | map((t) => (t < 100000 ? 999 : 0))
30 | );
31 |
32 | // Tax could be based on shipping address zip code
33 | tax$ = this.subTotal$.pipe(
34 | map((t) => Math.round(t * 10.75) / 100)
35 | );
36 |
37 | // Total price
38 | totalPrice$ = combineLatest([
39 | this.subTotal$,
40 | this.deliveryFee$,
41 | this.tax$,
42 | ]).pipe(map(([st, d, t]) => st + d + t));
43 |
44 | // Add the vehicle to the cart as an Action
45 | addToCart(vehicle: Vehicle): void {
46 | this.itemSubject.next({
47 | item: { vehicle, quantity: 1 },
48 | action: 'add'
49 | });
50 | }
51 |
52 | // Remove the item from the cart
53 | removeFromCart(cartItem: CartItem): void {
54 | this.itemSubject.next({
55 | item: { vehicle: cartItem.vehicle, quantity: 0 },
56 | action: 'delete'
57 | });
58 | }
59 |
60 | updateInCart(cartItem: CartItem, quantity: number) {
61 | this.itemSubject.next({
62 | item: { vehicle: cartItem.vehicle, quantity },
63 | action: 'update'
64 | });
65 | }
66 |
67 | // Return the updated array of cart items
68 | private modifyCart(items: CartItem[], operation: Action): CartItem[] {
69 | if (operation.action === 'add') {
70 | return [...items, operation.item];
71 | } else if (operation.action === 'update') {
72 | return items.map(item => item.vehicle.name === operation.item.vehicle.name ? operation.item : item)
73 | } else if (operation.action === 'delete') {
74 | return items.filter(item => item.vehicle.name !== operation.item.vehicle.name);
75 | }
76 | return [...items];
77 | }
78 |
79 | }
80 |
--------------------------------------------------------------------------------
/swVehicles/src/app/cart/cart.ts:
--------------------------------------------------------------------------------
1 | import { Observable, of } from "rxjs";
2 | import { Vehicle } from "../vehicles/vehicle";
3 |
4 | export interface Cart {
5 | cartItems: CartItem[]
6 | }
7 |
8 | export interface CartItem {
9 | vehicle: Vehicle;
10 | quantity: number;
11 | }
12 |
13 | // You could move this to a shared file
14 | // and reuse it for every entity in the application
15 | type ActionType = 'add' | 'update' | 'delete';
16 |
17 | export interface Action {
18 | item: T;
19 | action: ActionType;
20 | }
21 |
22 | // Test data for easier debugging
23 | export const testCartItems$: Observable = of([
24 | {
25 | vehicle: {
26 | "name": "Vulture Droid",
27 | "model": "Vulture-class droid starfighter",
28 | "manufacturer": "Haor Chall Engineering, Baktoid Armor Workshop",
29 | "cost_in_credits": "100550",
30 | "crew": 0,
31 | "passengers": 0,
32 | "cargo_capacity": 0,
33 | "vehicle_class": "starfighter",
34 | "films": [
35 | "https://swapi.py4e.com/api/films/4/",
36 | "https://swapi.py4e.com/api/films/6/"
37 | ]
38 | },
39 | quantity: 2
40 | },
41 | {
42 | vehicle: {
43 | "name": "AT-AT",
44 | "model": "All Terrain Armored Transport",
45 | "manufacturer": "Kuat Drive Yards, Imperial Department of Military Research",
46 | "cost_in_credits": "200988",
47 | "crew": 5,
48 | "passengers": 40,
49 | "cargo_capacity": 1000,
50 | "vehicle_class": "assault walker",
51 | "films": [
52 | "https://swapi.py4e.com/api/films/2/",
53 | "https://swapi.py4e.com/api/films/3/"
54 | ]
55 | },
56 | quantity: 1,
57 | }
58 | ]);
59 |
--------------------------------------------------------------------------------
/swVehicles/src/app/page-not-found.component.ts:
--------------------------------------------------------------------------------
1 | import { Component } from '@angular/core';
2 |
3 | @Component({
4 | standalone: true,
5 | template: `
6 | This is not the page you were looking for!
7 | `
8 | })
9 | export class PageNotFoundComponent {
10 |
11 | }
12 |
--------------------------------------------------------------------------------
/swVehicles/src/app/vehicles/vehicle-classes/vehicle-class-data.ts:
--------------------------------------------------------------------------------
1 | import { VehicleClass } from "./vehicle-class";
2 |
3 | export class VehicleClassData {
4 |
5 | // The API used didn't have an easy way to retrieve vehicle classification data
6 | // so they are hard-coded here
7 | static classes: VehicleClass[] = [
8 | {
9 | name: 'wheeled'
10 | },
11 | {
12 | name: 'repulsorcraft'
13 | },
14 | {
15 | name: 'starfighter'
16 | },
17 | {
18 | name: 'airspeeder'
19 | },
20 | {
21 | name: 'speeder'
22 | },
23 | {
24 | name: 'bomber'
25 | },
26 | {
27 | name: 'walker'
28 | },
29 | {
30 | name: 'barge'
31 | },
32 | {
33 | name: 'transport'
34 | },
35 | {
36 | name: 'tank'
37 | }
38 | ];
39 |
40 | }
41 |
--------------------------------------------------------------------------------
/swVehicles/src/app/vehicles/vehicle-classes/vehicle-class.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@angular/core';
2 |
3 | import { shareReplay, of, map } from 'rxjs';
4 | import { VehicleClassData } from './vehicle-class-data';
5 |
6 | @Injectable({
7 | providedIn: 'root'
8 | })
9 | export class VehicleClassService {
10 |
11 | // All vehicle classifications
12 | // Sort on the name
13 | vehicleClassifications$ = of(VehicleClassData.classes).pipe(
14 | map(classes => classes.sort((a, b) => a.name.localeCompare(b.name))),
15 | shareReplay(1)
16 | );
17 |
18 | }
19 |
--------------------------------------------------------------------------------
/swVehicles/src/app/vehicles/vehicle-classes/vehicle-class.ts:
--------------------------------------------------------------------------------
1 | export interface VehicleClass {
2 | name: string;
3 | description?: string;
4 | }
--------------------------------------------------------------------------------
/swVehicles/src/app/vehicles/vehicle-detail/vehicle-detail.component.html:
--------------------------------------------------------------------------------
1 |
2 |
4 |
7 |
8 |
10 |
11 |
Name:
12 |
{{vm.vehicle.name}}
13 |
14 |
17 |
18 |
19 |
20 |
Classification:
21 |
{{vm.vehicle.vehicle_class}}
22 |
23 |
24 |
Price (in credits):
25 |
{{vm.vehicle.cost_in_credits | number:'1.2-2'}}
26 |
27 |
28 |
Crew:
29 |
{{vm.vehicle.crew}}
30 |
31 |
32 |
Passengers:
33 |
{{vm.vehicle.passengers}}
34 |
35 |
36 |
Cargo Capacity:
37 |
{{vm.vehicle.cargo_capacity}}
38 |
39 |
40 |
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/swVehicles/src/app/vehicles/vehicle-detail/vehicle-detail.component.ts:
--------------------------------------------------------------------------------
1 | import { Component } from '@angular/core';
2 | import { AsyncPipe, DecimalPipe, NgFor, NgIf } from '@angular/common';
3 | import { catchError, combineLatest, EMPTY, map, Subject, tap } from 'rxjs';
4 | import { CartService } from 'src/app/cart/cart.service';
5 | import { Vehicle } from '../vehicle';
6 | import { VehicleService } from '../vehicle.service';
7 |
8 | @Component({
9 | selector: 'sw-vehicle-detail',
10 | standalone: true,
11 | imports: [AsyncPipe, NgFor, NgIf, DecimalPipe],
12 | templateUrl: './vehicle-detail.component.html'
13 | })
14 | export class VehicleDetailComponent {
15 | private errorMessageSubject = new Subject();
16 | errorMessage$ = this.errorMessageSubject.asObservable();
17 |
18 | selectedVehicle$ = this.vehicleService.selectedVehicle$.pipe(
19 | catchError(err => {
20 | this.errorMessageSubject.next(err);
21 | return EMPTY;
22 | })
23 | );
24 |
25 | pageTitle$ = this.selectedVehicle$.pipe(
26 | map(vehicle => vehicle ? `Detail for: ${vehicle.name}` : null)
27 | )
28 |
29 | vehicleFilms$ = this.vehicleService.vehicleFilms$.pipe(
30 | catchError(err => {
31 | this.errorMessageSubject.next(err);
32 | return EMPTY;
33 | })
34 | );
35 |
36 | vm$ = combineLatest([
37 | this.selectedVehicle$,
38 | this.vehicleFilms$,
39 | this.pageTitle$
40 | ])
41 | .pipe(
42 | map(([vehicle, films, pageTitle]) =>
43 | ({ vehicle, films, pageTitle }))
44 | );
45 |
46 | constructor(private vehicleService: VehicleService,
47 | private cartService: CartService) { }
48 |
49 | addToCart(vehicle: Vehicle) {
50 | this.cartService.addToCart(vehicle);
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/swVehicles/src/app/vehicles/vehicle-list/vehicle-list.component.html:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
8 |
9 |
17 |
18 |
19 |
26 |
27 |
28 |
29 |
31 | {{errorMessage }}
32 |
33 |
--------------------------------------------------------------------------------
/swVehicles/src/app/vehicles/vehicle-list/vehicle-list.component.ts:
--------------------------------------------------------------------------------
1 | import { Component } from '@angular/core';
2 | import { NgFor, NgClass, NgIf, AsyncPipe } from '@angular/common';
3 | import { combineLatest, EMPTY, Subject } from 'rxjs';
4 | import { catchError, map } from 'rxjs/operators';
5 | import { VehicleClassService } from '../vehicle-classes/vehicle-class.service';
6 | import { VehicleService } from '../vehicle.service';
7 |
8 | @Component({
9 | selector: 'sw-vehicle-list',
10 | standalone: true,
11 | imports: [AsyncPipe, NgClass, NgFor, NgIf],
12 | templateUrl: './vehicle-list.component.html'
13 | })
14 | export class VehicleListComponent {
15 | pageTitle = 'Vehicles';
16 | private errorMessageSubject = new Subject();
17 | errorMessage$ = this.errorMessageSubject.asObservable();
18 |
19 | // Categories for drop down list
20 | vehicleClasses$ = this.vehicleClassService.vehicleClassifications$
21 | .pipe(
22 | catchError(err => {
23 | this.errorMessageSubject.next(err);
24 | return EMPTY;
25 | }));
26 |
27 | // Vehicles filtered by the selected classification
28 | vehicles$ = this.vehicleService.vehicles$
29 | .pipe(
30 | catchError(err => {
31 | this.errorMessageSubject.next(err);
32 | return EMPTY;
33 | }));
34 |
35 | selectedVehicle$ = this.vehicleService.selectedVehicle$.pipe(
36 | catchError(err => {
37 | this.errorMessageSubject.next(err);
38 | return EMPTY;
39 | })
40 | );
41 |
42 | vehicleFilms$ = this.vehicleService.vehicleFilms$.pipe(
43 | catchError(err => {
44 | this.errorMessageSubject.next(err);
45 | return EMPTY;
46 | })
47 | );
48 |
49 | // Make it easier to work with the result in the UI
50 | vm$ = combineLatest([this.vehicles$, this.selectedVehicle$, this.vehicleClasses$]).pipe(
51 | map(([vehicles, selectedVehicle, vehicleClasses]) => ({
52 | vehicles,
53 | selectedVehicle,
54 | vehicleClasses
55 | }))
56 | );
57 |
58 | constructor(private vehicleService: VehicleService, private vehicleClassService: VehicleClassService) { }
59 |
60 | // When a vehicle is selected, emit the selected vehicle name
61 | onSelected(vehicleName: string): void {
62 | this.vehicleService.vehicleSelected(vehicleName);
63 | }
64 |
65 | // When a vehicle classification is selected,
66 | // emit the selected vehicle class
67 | onVehicleClassSelected(vehicleClass: string): void {
68 | this.vehicleService.vehicleClassSelected(vehicleClass);
69 | }
70 |
71 | }
72 |
--------------------------------------------------------------------------------
/swVehicles/src/app/vehicles/vehicle-shell/vehicle-shell.component.ts:
--------------------------------------------------------------------------------
1 | import { Component } from '@angular/core';
2 | import { VehicleListComponent } from "../vehicle-list/vehicle-list.component";
3 | import { VehicleDetailComponent } from "../vehicle-detail/vehicle-detail.component";
4 |
5 | @Component({
6 | selector: 'sw-vehicle-shell',
7 | standalone: true,
8 | template: `
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | `,
18 | imports: [VehicleListComponent, VehicleDetailComponent]
19 | })
20 | export class VehicleShellComponent {
21 |
22 | }
23 |
--------------------------------------------------------------------------------
/swVehicles/src/app/vehicles/vehicle.service.ts:
--------------------------------------------------------------------------------
1 | import { HttpClient, HttpErrorResponse } from '@angular/common/http';
2 | import { Injectable } from '@angular/core';
3 | import {
4 | BehaviorSubject,
5 | catchError,
6 | combineLatest,
7 | EMPTY,
8 | expand,
9 | filter,
10 | forkJoin,
11 | map,
12 | mergeMap,
13 | Observable,
14 | of,
15 | reduce,
16 | shareReplay,
17 | switchMap,
18 | tap,
19 | throwError
20 | } from 'rxjs';
21 | import { Film, Vehicle, VehicleResponse } from './vehicle';
22 |
23 | @Injectable({
24 | providedIn: 'root'
25 | })
26 | export class VehicleService {
27 | private url = 'https://swapi.py4e.com/api/vehicles';
28 |
29 | // Action stream
30 | private vehicleSelectedSubject = new BehaviorSubject('');
31 | vehicleSelected$ = this.vehicleSelectedSubject.asObservable();
32 |
33 | // Action stream
34 | private vehicleClassSubject = new BehaviorSubject('');
35 | vehicleClassSelected$ = this.vehicleClassSubject.asObservable();
36 |
37 | // All pages of vehicles
38 | allVehicles$ = this.http.get(this.url).pipe(
39 | expand(data => data.next ? this.http.get(data.next) : EMPTY),
40 | reduce((acc, data) => acc.concat(data.results), [] as Vehicle[])
41 | );
42 |
43 | // One page of vehicles with their films
44 | // Not currently used in the app
45 | // To try it out, subscribe in the constructor below.
46 | vehiclesWithFilms$ = this.http.get(this.url).pipe(
47 | map(data => data.results as Vehicle[]),
48 | mergeMap(vehicles => forkJoin(vehicles.map(vehicle =>
49 | forkJoin(vehicle.films.map(film => this.http.get(film).pipe(
50 | map(f => f.title),
51 | ))).pipe(
52 | map(films => ({ ...vehicle, films } as Vehicle))
53 | ),
54 | )))
55 | );
56 |
57 | // Vehicles filtered by the selected classification
58 | // NOTE: This particular API does not provide a feature to search by vehicle class
59 | // Rather, the code gets all vehicles, and filters by the class.
60 | vehicles$ = combineLatest([
61 | this.allVehicles$,
62 | this.vehicleClassSelected$.pipe(
63 | // When the classification changes, clear the selected vehicle
64 | tap(() => this.vehicleSelected(''))
65 | )
66 | ])
67 | .pipe(
68 | map(([vehicles, selectedVehicleClass]) =>
69 | vehicles.filter(v =>
70 | selectedVehicleClass ? v.vehicle_class.toLocaleLowerCase().includes(selectedVehicleClass.toLocaleLowerCase()) : true
71 | )),
72 | catchError(this.handleError)
73 | );
74 |
75 | // First page of vehicles
76 | firstPageVehicles$ = this.http.get(this.url).pipe(
77 | map(data => data.results),
78 | catchError(this.handleError)
79 | );
80 |
81 | selectedVehicle$ = this.vehicleSelected$.pipe(
82 | switchMap(vehicleName =>
83 | vehicleName.length ?
84 | this.http.get(`${this.url}?search=${vehicleName}`).pipe(
85 | map(data => data.results[0]),
86 | // Fill in a random price for any missing the price
87 | // (We can't modify the backend in this demo)
88 | map(v => ({
89 | ...v,
90 | cost_in_credits: isNaN(Number(v.cost_in_credits)) ? String(Math.random() * 100000) : v.cost_in_credits
91 | }) as Vehicle),
92 | catchError(this.handleError)
93 | ) : of(null)
94 | ),
95 | shareReplay(1),
96 | );
97 |
98 | vehicleFilms$ = this.selectedVehicle$.pipe(
99 | filter(Boolean),
100 | switchMap(vehicle =>
101 | forkJoin(vehicle.films.map(link =>
102 | this.http.get(link)))
103 | )
104 | );
105 |
106 | constructor(private http: HttpClient) {
107 | }
108 |
109 | vehicleSelected(vehicleName: string) {
110 | this.vehicleSelectedSubject.next(vehicleName);
111 | }
112 |
113 | // When a vehicle classification is selected,
114 | // emit the selected vehicle class
115 | vehicleClassSelected(vehicleClass: string): void {
116 | this.vehicleClassSubject.next(vehicleClass);
117 | }
118 |
119 | private handleError(err: HttpErrorResponse): Observable {
120 | // in a real world app, we may send the server to some remote logging infrastructure
121 | // instead of just logging it to the console
122 | let errorMessage = '';
123 | if (err.error instanceof ErrorEvent) {
124 | // A client-side or network error occurred. Handle it accordingly.
125 | errorMessage = `An error occurred: ${err.error.message}`;
126 | } else {
127 | // The backend returned an unsuccessful response code.
128 | // The response body may contain clues as to what went wrong,
129 | errorMessage = `Server returned code: ${err.status}, error message is: ${err.message
130 | }`;
131 | }
132 | console.error(errorMessage);
133 | return throwError(() => errorMessage);
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/swVehicles/src/app/vehicles/vehicle.ts:
--------------------------------------------------------------------------------
1 | export interface VehicleResponse {
2 | count: number;
3 | next: string;
4 | previous: string;
5 | results: Vehicle[]
6 | }
7 |
8 | export interface Vehicle {
9 | cargo_capacity: number;
10 | crew: number;
11 | name: string;
12 | model: string;
13 | manufacturer: string,
14 | cost_in_credits: string
15 | passengers: number;
16 | vehicle_class: string;
17 | films: string[];
18 | price?: number;
19 | }
20 |
21 | export interface Film {
22 | title: string;
23 | }
24 |
--------------------------------------------------------------------------------
/swVehicles/src/app/welcome/welcome.component.html:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
8 |

11 |
12 |
13 |
Developed by:
14 |
15 |
Deborah Kurata
16 |
17 |
18 |
@deborahkurata
19 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/swVehicles/src/app/welcome/welcome.component.ts:
--------------------------------------------------------------------------------
1 | import { Component } from '@angular/core';
2 |
3 | @Component({
4 | selector: 'sw-welcome',
5 | standalone: true,
6 | imports: [],
7 | templateUrl: './welcome.component.html'
8 | })
9 | export class WelcomeComponent {
10 | public pageTitle = 'Welcome to Star Wars Vehicle Sales';
11 |
12 | }
13 |
--------------------------------------------------------------------------------
/swVehicles/src/assets/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeborahK/Angular-ReactiveDevelopment/baba63b82ada1bce5781d6209707d4961b0b3301/swVehicles/src/assets/.gitkeep
--------------------------------------------------------------------------------
/swVehicles/src/assets/Speeder_Bike.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeborahK/Angular-ReactiveDevelopment/baba63b82ada1bce5781d6209707d4961b0b3301/swVehicles/src/assets/Speeder_Bike.png
--------------------------------------------------------------------------------
/swVehicles/src/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeborahK/Angular-ReactiveDevelopment/baba63b82ada1bce5781d6209707d4961b0b3301/swVehicles/src/favicon.ico
--------------------------------------------------------------------------------
/swVehicles/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SwVehicles
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/swVehicles/src/main.ts:
--------------------------------------------------------------------------------
1 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
2 |
3 | import { AppModule } from './app/app.module';
4 |
5 |
6 | platformBrowserDynamic().bootstrapModule(AppModule)
7 | .catch(err => console.error(err));
8 |
--------------------------------------------------------------------------------
/swVehicles/src/styles.css:
--------------------------------------------------------------------------------
1 | /* You can add global styles to this file, and also import other style files */
2 | @import "~bootstrap/dist/css/bootstrap.min.css";
3 |
4 | div.card-header {
5 | font-size: large;
6 | }
7 |
8 | div.card {
9 | margin-top: 10px;
10 | margin-left: 10px;
11 | }
12 |
13 | .table {
14 | margin-top: 10px;
15 | }
16 |
--------------------------------------------------------------------------------
/swVehicles/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */
2 | {
3 | "extends": "./tsconfig.json",
4 | "compilerOptions": {
5 | "outDir": "./out-tsc/app",
6 | "types": []
7 | },
8 | "files": [
9 | "src/main.ts"
10 | ],
11 | "include": [
12 | "src/**/*.d.ts"
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/swVehicles/tsconfig.json:
--------------------------------------------------------------------------------
1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */
2 | {
3 | "compileOnSave": false,
4 | "compilerOptions": {
5 | "baseUrl": "./",
6 | "outDir": "./dist/out-tsc",
7 | "forceConsistentCasingInFileNames": true,
8 | "strict": true,
9 | "noImplicitOverride": true,
10 | "noPropertyAccessFromIndexSignature": true,
11 | "noImplicitReturns": true,
12 | "noFallthroughCasesInSwitch": true,
13 | "sourceMap": true,
14 | "declaration": false,
15 | "downlevelIteration": true,
16 | "experimentalDecorators": true,
17 | "moduleResolution": "node",
18 | "importHelpers": true,
19 | "target": "ES2022",
20 | "module": "ES2022",
21 | "useDefineForClassFields": false,
22 | "lib": [
23 | "ES2022",
24 | "dom"
25 | ]
26 | },
27 | "angularCompilerOptions": {
28 | "enableI18nLegacyMessageIdFormat": false,
29 | "strictInjectionParameters": true,
30 | "strictInputAccessModifiers": true,
31 | "strictTemplates": true
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/swVehicles/tsconfig.spec.json:
--------------------------------------------------------------------------------
1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */
2 | {
3 | "extends": "./tsconfig.json",
4 | "compilerOptions": {
5 | "outDir": "./out-tsc/spec",
6 | "types": [
7 | "jasmine"
8 | ]
9 | },
10 | "include": [
11 | "src/**/*.spec.ts",
12 | "src/**/*.d.ts"
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------