├── src
├── assets
│ ├── .gitkeep
│ ├── 58552daa-30f6-46fa-a808-f1a1d7667561.jpg
│ ├── 7ef3b9dd-5a95-4415-af37-6871d6ff0262.jpg
│ ├── 9aa113b4-1e4e-4cde-bf9d-8358fc78ea4f.jpg
│ ├── bdcbe438-ac85-4acf-8949-5627fd5b57df.jpg
│ ├── d4666802-fd84-476f-9eea-c8dd29cfb633.jpg
│ ├── delivery-options.json
│ └── products.json
├── environments
│ ├── environment.prod.ts
│ └── environment.ts
├── favicon.ico
├── app
│ ├── components
│ │ ├── checkout
│ │ │ ├── checkout.component.scss
│ │ │ ├── checkout.component.ts
│ │ │ ├── checkout.component.html
│ │ │ └── checkout.component.spec.ts
│ │ ├── order-confirmation
│ │ │ ├── order-confirmation.component.html
│ │ │ ├── order-confirmation.component.ts
│ │ │ └── order-confirmation.component.spec.ts
│ │ ├── store-front
│ │ │ ├── store-front.component.scss
│ │ │ ├── store-front.component.ts
│ │ │ ├── store-front.component.html
│ │ │ └── store-front.component.spec.ts
│ │ └── shopping-cart
│ │ │ ├── shopping-cart.component.html
│ │ │ ├── shopping-cart.component.ts
│ │ │ └── shopping-cart.component.spec.ts
│ ├── models
│ │ ├── cart-item.model.ts
│ │ ├── ingredient.model.ts
│ │ ├── delivery-option.model.ts
│ │ ├── shopping-cart.model.ts
│ │ └── product.model.ts
│ ├── app.component.ts
│ ├── services
│ │ ├── storage.service.ts
│ │ ├── caching.service.ts
│ │ ├── products.service.ts
│ │ ├── delivery-options.service.ts
│ │ ├── tests
│ │ │ ├── products.service.spec.ts
│ │ │ ├── delivery-options.service.spec.ts
│ │ │ └── shopping-cart.service.spec.ts
│ │ └── shopping-cart.service.ts
│ ├── app.routing.ts
│ ├── route-gaurds
│ │ ├── populated-cart.route-gaurd.ts
│ │ └── populated-cart.route-gaurd.spec.ts
│ └── app.module.ts
├── typings.d.ts
├── tsconfig.app.json
├── main.ts
├── tsconfig.spec.json
├── styles.scss
├── test.ts
├── index.html
└── polyfills.ts
├── e2e
├── models
│ └── product.model.ts
├── tsconfig.e2e.json
├── app.e2e-spec.ts
└── store-front.page-object.ts
├── .editorconfig
├── .vscode
└── tasks.json
├── tsconfig.json
├── .travis.yml
├── .gitignore
├── gulpFile.js
├── protractor.conf.js
├── tslint.json
├── .angular-cli.json
├── karma.conf.js
├── README.md
├── package.json
├── dev_cert.crt
└── dev_cert.key
/src/assets/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/environments/environment.prod.ts:
--------------------------------------------------------------------------------
1 | export const environment = {
2 | production: true
3 | };
4 |
--------------------------------------------------------------------------------
/src/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jonsamwell/angular-simple-shopping-cart/HEAD/src/favicon.ico
--------------------------------------------------------------------------------
/e2e/models/product.model.ts:
--------------------------------------------------------------------------------
1 | export class Product {
2 | public name: string;
3 | public price: number;
4 | }
5 |
--------------------------------------------------------------------------------
/src/app/components/checkout/checkout.component.scss:
--------------------------------------------------------------------------------
1 | .checkout_row {
2 | .product_image {
3 | max-height: 200px;
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/models/cart-item.model.ts:
--------------------------------------------------------------------------------
1 | export class CartItem {
2 | public productId: string;
3 | public quantity: number = 0;
4 | }
5 |
--------------------------------------------------------------------------------
/src/typings.d.ts:
--------------------------------------------------------------------------------
1 | /* SystemJS module definition */
2 | declare var module: NodeModule;
3 | interface NodeModule {
4 | id: string;
5 | }
6 |
--------------------------------------------------------------------------------
/src/assets/58552daa-30f6-46fa-a808-f1a1d7667561.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jonsamwell/angular-simple-shopping-cart/HEAD/src/assets/58552daa-30f6-46fa-a808-f1a1d7667561.jpg
--------------------------------------------------------------------------------
/src/assets/7ef3b9dd-5a95-4415-af37-6871d6ff0262.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jonsamwell/angular-simple-shopping-cart/HEAD/src/assets/7ef3b9dd-5a95-4415-af37-6871d6ff0262.jpg
--------------------------------------------------------------------------------
/src/assets/9aa113b4-1e4e-4cde-bf9d-8358fc78ea4f.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jonsamwell/angular-simple-shopping-cart/HEAD/src/assets/9aa113b4-1e4e-4cde-bf9d-8358fc78ea4f.jpg
--------------------------------------------------------------------------------
/src/assets/bdcbe438-ac85-4acf-8949-5627fd5b57df.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jonsamwell/angular-simple-shopping-cart/HEAD/src/assets/bdcbe438-ac85-4acf-8949-5627fd5b57df.jpg
--------------------------------------------------------------------------------
/src/assets/d4666802-fd84-476f-9eea-c8dd29cfb633.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jonsamwell/angular-simple-shopping-cart/HEAD/src/assets/d4666802-fd84-476f-9eea-c8dd29cfb633.jpg
--------------------------------------------------------------------------------
/src/app/app.component.ts:
--------------------------------------------------------------------------------
1 | import { Component } from "@angular/core";
2 |
3 | @Component({
4 | selector: "app-root",
5 | template: ""
6 | })
7 | export class AppComponent {}
8 |
--------------------------------------------------------------------------------
/e2e/tsconfig.e2e.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "../out-tsc/e2e",
5 | "module": "commonjs",
6 | "target": "es5",
7 | "types":[
8 | "jasmine",
9 | "node"
10 | ]
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/app/models/ingredient.model.ts:
--------------------------------------------------------------------------------
1 | export class Ingredient {
2 | public name: string;
3 | public percentage: number;
4 |
5 | public updateFrom(src: Ingredient): void {
6 | this.name = src.name;
7 | this.percentage = src.percentage;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "../out-tsc/app",
5 | "module": "es2015",
6 | "baseUrl": "",
7 | "types": []
8 | },
9 | "exclude": [
10 | "test.ts",
11 | "**/*.spec.ts"
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # Editor configuration, see http://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | charset = utf-8
6 | indent_style = space
7 | indent_size = 2
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
11 | [*.md]
12 | max_line_length = off
13 | trim_trailing_whitespace = false
14 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | // See https://go.microsoft.com/fwlink/?LinkId=733558
3 | // for the documentation about the tasks.json format
4 | "version": "0.1.0",
5 | "command": "tsc",
6 | "isShellCommand": true,
7 | "args": ["-p", "./src/tsconfig.app.json"],
8 | "showOutput": "silent",
9 | "problemMatcher": "$tsc"
10 | }
11 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import { enableProdMode } from "@angular/core";
2 | import { platformBrowserDynamic } from "@angular/platform-browser-dynamic";
3 |
4 | import { AppModule } from "./app/app.module";
5 | import { environment } from "./environments/environment";
6 |
7 | if (environment.production) {
8 | enableProdMode();
9 | }
10 |
11 | platformBrowserDynamic().bootstrapModule(AppModule);
12 |
--------------------------------------------------------------------------------
/src/app/models/delivery-option.model.ts:
--------------------------------------------------------------------------------
1 | export class DeliveryOption {
2 | public id: string;
3 | public name: string;
4 | public description: string;
5 | public price: number;
6 |
7 | public updateFrom(src: DeliveryOption): void {
8 | this.id = src.id;
9 | this.name = src.name;
10 | this.description = src.description;
11 | this.price = src.price;
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/tsconfig.spec.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "../out-tsc/spec",
5 | "module": "commonjs",
6 | "target": "es5",
7 | "baseUrl": "",
8 | "types": [
9 | "jasmine",
10 | "node"
11 | ]
12 | },
13 | "files": [
14 | "test.ts"
15 | ],
16 | "include": [
17 | "**/*.spec.ts",
18 | "**/*.d.ts"
19 | ]
20 | }
21 |
--------------------------------------------------------------------------------
/src/environments/environment.ts:
--------------------------------------------------------------------------------
1 | // The file contents for the current environment will overwrite these during build.
2 | // The build system defaults to the dev environment which uses `environment.ts`, but if you do
3 | // `ng build --env=prod` then `environment.prod.ts` will be used instead.
4 | // The list of which env maps to which file can be found in `.angular-cli.json`.
5 |
6 | export const environment = {
7 | production: false
8 | };
9 |
--------------------------------------------------------------------------------
/src/app/components/order-confirmation/order-confirmation.component.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Thank you for your order, it will be dispatched shortly!
4 |
5 |
6 |
7 |
8 |
13 |
--------------------------------------------------------------------------------
/src/app/services/storage.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from "@angular/core";
2 | import "rxjs/add/operator/share";
3 | import { Observable } from "rxjs/Observable";
4 |
5 | export abstract class StorageService {
6 | public abstract get(): Storage;
7 | }
8 |
9 | // tslint:disable-next-line:max-classes-per-file
10 | @Injectable()
11 | export class LocalStorageServie extends StorageService {
12 | public get(): Storage {
13 | return localStorage;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compileOnSave": false,
3 | "compilerOptions": {
4 | "outDir": "./dist/out-tsc",
5 | "baseUrl": "src",
6 | "sourceMap": true,
7 | "declaration": false,
8 | "moduleResolution": "node",
9 | "emitDecoratorMetadata": true,
10 | "experimentalDecorators": true,
11 | "target": "es5",
12 | "typeRoots": [
13 | "node_modules/@types"
14 | ],
15 | "lib": [
16 | "es2016",
17 | "dom"
18 | ]
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/components/store-front/store-front.component.scss:
--------------------------------------------------------------------------------
1 | .product-list {
2 | list-style-type: none;
3 | margin: 0;
4 | }
5 |
6 | .product-container {
7 | padding: 2rem 3rem 0 0;
8 | position: relative;
9 | border: 1px solid #EFEFEF;
10 | margin: 10px 0;
11 |
12 | &:hover {
13 | box-shadow: 0 0 5px rgba(50,50,50,0.3);
14 | }
15 |
16 | .product_image {
17 | max-height: 250px;
18 | }
19 |
20 | .product_price {
21 | float: right;
22 | margin-top: 1rem;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/e2e/app.e2e-spec.ts:
--------------------------------------------------------------------------------
1 | import { StoreFrontPageObject } from "./store-front.page-object";
2 |
3 | describe("Store front", () => {
4 | let page: StoreFrontPageObject;
5 |
6 | beforeEach(() => {
7 | page = new StoreFrontPageObject();
8 | });
9 |
10 | it("should display products", async () => {
11 | await page.navigateTo();
12 | const products = await page.getProducts();
13 | expect(products.length).toEqual(5);
14 | expect(products[0].name).toEqual("Greens");
15 | expect(products[0].price).toEqual(3.5);
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/src/app/components/order-confirmation/order-confirmation.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, OnInit } from "@angular/core";
2 | import { ShoppingCartService } from "../../services/shopping-cart.service";
3 |
4 | @Component({
5 | selector: "app-order-confirmation",
6 | templateUrl: "./order-confirmation.component.html"
7 | })
8 | export class OrderConfirmationComponent implements OnInit {
9 | public constructor(private shoppingCartService: ShoppingCartService) {}
10 |
11 | public ngOnInit(): void {
12 | this.shoppingCartService.empty();
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/app/services/caching.service.ts:
--------------------------------------------------------------------------------
1 | import "rxjs/add/operator/share";
2 | import { Observable } from "rxjs/Observable";
3 |
4 | export abstract class CachcingServiceBase {
5 | protected cache(getter: () => Observable,
6 | setter: (val: Observable) => void,
7 | retreive: () => Observable): Observable {
8 | const cached = getter();
9 | if (cached !== undefined) {
10 | return cached;
11 | } else {
12 | const val = retreive().share();
13 | setter(val);
14 | return val;
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/app/models/shopping-cart.model.ts:
--------------------------------------------------------------------------------
1 | import { CartItem } from "app/models/cart-item.model";
2 |
3 | export class ShoppingCart {
4 | public items: CartItem[] = new Array();
5 | public deliveryOptionId: string;
6 | public grossTotal: number = 0;
7 | public deliveryTotal: number = 0;
8 | public itemsTotal: number = 0;
9 |
10 | public updateFrom(src: ShoppingCart) {
11 | this.items = src.items;
12 | this.deliveryOptionId = src.deliveryOptionId;
13 | this.grossTotal = src.grossTotal;
14 | this.deliveryTotal = src.deliveryTotal;
15 | this.itemsTotal = src.itemsTotal;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/app/models/product.model.ts:
--------------------------------------------------------------------------------
1 | import { Ingredient } from "app/models/ingredient.model";
2 |
3 | export class Product {
4 | public id: string;
5 | public name: string;
6 | public description: string;
7 | public price: number;
8 | public ingredients: Ingredient[];
9 |
10 | public updateFrom(src: Product): void {
11 | this.id = src.id;
12 | this.name = src.name;
13 | this.description = src.description;
14 | this.price = src.price;
15 | this.ingredients = src.ingredients.map((i) => {
16 | let ingredient = new Ingredient();
17 | ingredient.updateFrom(i);
18 | return ingredient;
19 | });
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: required
2 | dist: trusty
3 |
4 | language: node_js
5 |
6 | node_js:
7 | - '7'
8 |
9 | before_install:
10 | - export CHROME_BIN=/usr/bin/google-chrome
11 | - export DISPLAY=:99.0
12 | - sh -e /etc/init.d/xvfb start
13 | - sudo apt-get update
14 | - sudo apt-get install -y libappindicator1 fonts-liberation
15 | - wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
16 | - sudo dpkg -i google-chrome*.deb
17 |
18 | before_deploy:
19 | - npm run build
20 |
21 | deploy:
22 | provider: pages
23 | skip_cleanup: true
24 | github_token: $GITHUB_TOKEN # Set in travis-ci.org dashboard
25 | local_dir: dist/
26 | on:
27 | branch: master
28 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See http://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # compiled output
4 | /dist
5 | /tmp
6 | /out-tsc
7 |
8 | # dependencies
9 | /node_modules
10 |
11 | # IDEs and editors
12 | /.idea
13 | .project
14 | .classpath
15 | .c9/
16 | *.launch
17 | .settings/
18 | *.sublime-workspace
19 |
20 | # IDE - VSCode
21 | .vscode/*
22 | !.vscode/settings.json
23 | !.vscode/tasks.json
24 | !.vscode/launch.json
25 | !.vscode/extensions.json
26 |
27 | # misc
28 | /.sass-cache
29 | /connect.lock
30 | /coverage
31 | /libpeerconnection.log
32 | npm-debug.log
33 | testem.log
34 | /typings
35 |
36 | # e2e
37 | /e2e/*.js
38 | /e2e/*.map
39 |
40 | # System Files
41 | .DS_Store
42 | Thumbs.db
43 |
--------------------------------------------------------------------------------
/src/styles.scss:
--------------------------------------------------------------------------------
1 | h1, h2, h3, h4, h5, h6 {
2 | font-family: 'Montserrat', sans-serif;
3 | }
4 |
5 | p {
6 | font-family: 'BioRhyme', serif;
7 | }
8 |
9 | .top-bar {
10 | background-color: rgba(51,51,51,0.9);
11 | color: white;
12 | box-shadow: 0 0 10px rgba(51,51,51,0.6);
13 | margin-bottom: 1rem;
14 |
15 | ul {
16 | background-color: transparent;
17 | }
18 | }
19 |
20 | .text--red {
21 | color: #b12704;
22 | }
23 |
24 | .text--bold {
25 | font-weight: bold;
26 | }
27 |
28 | .button {
29 | border-radius: 4px;
30 | vertical-align: bottom;
31 |
32 | &.success {
33 | background-color: #5D8B00;
34 | padding: 1rem 3rem;
35 | color: #fff;
36 |
37 | &:hover {
38 | color: #fff;
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/assets/delivery-options.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": "f488db46-9380-45e2-b15e-4ace7bdb7c89",
4 | "name": "Drone",
5 | "description": "Get your package within an hour and have it flown in by a drone!",
6 | "price": 19.99
7 | },
8 | {
9 | "id": "c7f6535c-c56b-4e57-94dc-0e95b936b00b",
10 | "name": "Express",
11 | "description": "The quickest of the normal delivery service",
12 | "price": 9.99
13 | },
14 | {
15 | "id": "caa93bc4-d69a-4788-aff6-4a6fb538ace8",
16 | "name": "Standard",
17 | "description": "Standard shipping can take up to 4 days",
18 | "price": 5.99
19 | },
20 | {
21 | "id": "c272fc43-28e4-4ffd-ae61-0cfdec565f6f",
22 | "name": "Pick-up",
23 | "description": "Pick it up tomorrow from you local post office",
24 | "price": 0
25 | }
26 | ]
27 |
--------------------------------------------------------------------------------
/gulpFile.js:
--------------------------------------------------------------------------------
1 | var gulp = require('gulp');
2 | var replace = require('gulp-replace');
3 | var htmlmin = require('gulp-htmlmin');
4 |
5 | gulp.task('js:minify', function () {
6 | gulp.src(["./dist/main.*.js", "./dist/polyfills.*.js", "./dist/inline.*.js"])
7 | .pipe(replace(/\/\*([\s\S]*?)\*\/[\s\S]?/g, ""))
8 | .pipe(gulp.dest("./dist"));
9 | });
10 |
11 | gulp.task("html:basehref", function () {
12 | return gulp.src('dist/*.html')
13 | .pipe(replace("", ""))
14 | .pipe(gulp.dest('./dist'));
15 | });
16 |
17 | gulp.task("html:minify", ["html:basehref"], function () {
18 | return gulp.src('dist/*.html')
19 | .pipe(htmlmin({ collapseWhitespace: true }))
20 | .pipe(gulp.dest('./dist'));
21 | });
22 |
23 | gulp.task("default", ["js:minify", "html:minify"]);
24 |
--------------------------------------------------------------------------------
/protractor.conf.js:
--------------------------------------------------------------------------------
1 | // Protractor configuration file, see link for more information
2 | // https://github.com/angular/protractor/blob/master/lib/config.ts
3 |
4 | const { SpecReporter } = require('jasmine-spec-reporter');
5 |
6 | exports.config = {
7 | allScriptsTimeout: 11000,
8 | specs: [
9 | './e2e/**/*.e2e-spec.ts'
10 | ],
11 | capabilities: {
12 | 'browserName': 'chrome'
13 | },
14 | directConnect: true,
15 | baseUrl: 'https://localhost:4200/',
16 | framework: 'jasmine',
17 | jasmineNodeOpts: {
18 | showColors: true,
19 | defaultTimeoutInterval: 30000,
20 | print: function() {}
21 | },
22 | beforeLaunch: function() {
23 | require('ts-node').register({
24 | project: 'e2e/tsconfig.e2e.json'
25 | });
26 | },
27 | onPrepare() {
28 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
29 | }
30 | };
31 |
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "defaultSeverity": "error",
3 | "extends": [
4 | "tslint:recommended"
5 | ],
6 | "rulesDirectory": [
7 | "node_modules/codelyzer"
8 | ],
9 | "rules": {
10 | "trailing-comma": [
11 | false
12 | ],
13 | "directive-selector": [
14 | true,
15 | "attribute",
16 | "app",
17 | "camelCase"
18 | ],
19 | "component-selector": [
20 | true,
21 | "element",
22 | "app",
23 | "kebab-case"
24 | ],
25 | "use-input-property-decorator": true,
26 | "use-output-property-decorator": true,
27 | "use-host-property-decorator": true,
28 | "no-input-rename": true,
29 | "no-output-rename": true,
30 | "use-life-cycle-interface": true,
31 | "use-pipe-transform-interface": true,
32 | "component-class-suffix": true,
33 | "directive-class-suffix": true,
34 | "no-access-missing-member": true,
35 | "templates-use-public": true,
36 | "invoke-injectable": true
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/e2e/store-front.page-object.ts:
--------------------------------------------------------------------------------
1 | import { browser, by, element, promise } from "protractor";
2 | import { Product } from "./models/product.model";
3 |
4 | export class StoreFrontPageObject {
5 | public navigateTo(): promise.Promise {
6 | return browser.get("/");
7 | }
8 |
9 | public async getProducts(): promise.Promise {
10 | const defer = promise.defer();
11 | try {
12 | const results = await element.all(by.css(".product-container"))
13 | .map(async (el) => {
14 | const product = new Product();
15 | product.name = await el.element(by.css(".js-product-name")).getText();
16 | const priceTxt = await el.element(by.css(".js-product-price")).getText();
17 | product.price = parseFloat(priceTxt.replace(/[a-z,£: ]/gi, ""));
18 | return product;
19 | });
20 | defer.fulfill(results);
21 | } catch (er) {
22 | defer.reject(er);
23 | }
24 | return defer.promise;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/app/app.routing.ts:
--------------------------------------------------------------------------------
1 | import { NgModule } from "@angular/core";
2 | import { RouterModule } from "@angular/router";
3 |
4 | import { CheckoutComponent } from "./components/checkout/checkout.component";
5 | import { OrderConfirmationComponent } from "./components/order-confirmation/order-confirmation.component";
6 | import { StoreFrontComponent } from "./components/store-front/store-front.component";
7 | import { PopulatedCartRouteGuard } from "./route-gaurds/populated-cart.route-gaurd";
8 |
9 | @NgModule({
10 | exports: [RouterModule],
11 | imports: [
12 | RouterModule.forRoot([
13 | {
14 | canActivate: [PopulatedCartRouteGuard],
15 | component: CheckoutComponent,
16 | path: "checkout"
17 | },
18 | {
19 | canActivate: [PopulatedCartRouteGuard],
20 | component: OrderConfirmationComponent,
21 | path: "confirmed"
22 | },
23 | {
24 | component: StoreFrontComponent,
25 | path: "**"
26 | }])
27 | ]
28 | })
29 | export class AppRoutingModule { }
30 |
--------------------------------------------------------------------------------
/src/test.ts:
--------------------------------------------------------------------------------
1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files
2 |
3 | import "zone.js/dist/long-stack-trace-zone";
4 | import "zone.js/dist/proxy.js";
5 | import "zone.js/dist/sync-test";
6 | import "zone.js/dist/jasmine-patch";
7 | import "zone.js/dist/async-test";
8 | import "zone.js/dist/fake-async-test";
9 | import { getTestBed } from "@angular/core/testing";
10 | import {
11 | BrowserDynamicTestingModule,
12 | platformBrowserDynamicTesting
13 | } from "@angular/platform-browser-dynamic/testing";
14 |
15 | // Unfortunately there"s no typing for the `__karma__` variable. Just declare it as any.
16 | declare const __karma__: any;
17 | declare const require: any;
18 |
19 | // Prevent Karma from running prematurely.
20 | __karma__.loaded = () => {};
21 |
22 | // First, initialize the Angular testing environment.
23 | getTestBed().initTestEnvironment(
24 | BrowserDynamicTestingModule,
25 | platformBrowserDynamicTesting()
26 | );
27 | // Then we find all the tests.
28 | const context = require.context("./", true, /\.spec\.ts$/);
29 | // And load the modules.
30 | context.keys().map(context);
31 | // Finally, start Karma to run the tests.
32 | __karma__.start();
33 |
--------------------------------------------------------------------------------
/src/app/components/shopping-cart/shopping-cart.component.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | shopping_basket
5 | Your Shopping Basket
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | Sub Total ({{itemCount}} items):
14 | {{(cart | async).grossTotal | currency:'GBP':true}}
15 |
16 |
17 |
18 |
19 |
26 |
31 |
32 |
33 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Angular 4 Simple Shopping Cart
7 |
8 |
9 |
10 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
30 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/src/app/route-gaurds/populated-cart.route-gaurd.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from "@angular/core";
2 | import { CanActivate, Router } from "@angular/router";
3 | import { Observable } from "rxjs/Observable";
4 | import { Observer } from "rxjs/Observer";
5 | import { Subscription } from "rxjs/Subscription";
6 | import { ShoppingCartService } from "../services/shopping-cart.service";
7 |
8 | @Injectable()
9 | export class PopulatedCartRouteGuard implements CanActivate {
10 | public constructor(private router: Router,
11 | private shoppingCartService: ShoppingCartService) { }
12 |
13 | public canActivate(): Observable {
14 | return new Observable((observer: Observer) => {
15 | const cartSubscription = this.shoppingCartService
16 | .get()
17 | .subscribe((cart) => {
18 | if (cart.items.length === 0) {
19 | observer.next(false);
20 | this.router.navigate(["/"]);
21 | } else {
22 | observer.next(true);
23 | }
24 | });
25 | return () => cartSubscription.unsubscribe();
26 | });
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/.angular-cli.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
3 | "project": {
4 | "name": "cmc-markets-code-test"
5 | },
6 | "apps": [
7 | {
8 | "root": "src",
9 | "outDir": "dist",
10 | "assets": [
11 | "assets",
12 | "favicon.ico"
13 | ],
14 | "index": "index.html",
15 | "main": "main.ts",
16 | "polyfills": "polyfills.ts",
17 | "test": "test.ts",
18 | "tsconfig": "tsconfig.app.json",
19 | "testTsconfig": "tsconfig.spec.json",
20 | "prefix": "app",
21 | "styles": [
22 | "styles.scss"
23 | ],
24 | "scripts": [],
25 | "environmentSource": "environments/environment.ts",
26 | "environments": {
27 | "dev": "environments/environment.ts",
28 | "prod": "environments/environment.prod.ts"
29 | }
30 | }
31 | ],
32 | "e2e": {
33 | "protractor": {
34 | "config": "./protractor.conf.js"
35 | }
36 | },
37 | "lint": [
38 | {
39 | "project": "src/tsconfig.app.json"
40 | },
41 | {
42 | "project": "src/tsconfig.spec.json"
43 | },
44 | {
45 | "project": "e2e/tsconfig.e2e.json"
46 | }
47 | ],
48 | "test": {
49 | "karma": {
50 | "config": "./karma.conf.js"
51 | }
52 | },
53 | "defaults": {
54 | "styleExt": "scss",
55 | "component": {}
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/karma.conf.js:
--------------------------------------------------------------------------------
1 | // Karma configuration file, see link for more information
2 | // https://karma-runner.github.io/0.13/config/configuration-file.html
3 |
4 | module.exports = function (config) {
5 | config.set({
6 | basePath: '',
7 | frameworks: ['jasmine', '@angular/cli'],
8 | plugins: [
9 | require('karma-jasmine'),
10 | require('karma-chrome-launcher'),
11 | require('karma-jasmine-html-reporter'),
12 | require('karma-coverage-istanbul-reporter'),
13 | require('@angular/cli/plugins/karma')
14 | ],
15 | client:{
16 | clearContext: false // leave Jasmine Spec Runner output visible in browser
17 | },
18 | files: [
19 | { pattern: './src/test.ts', watched: false }
20 | ],
21 | preprocessors: {
22 | './src/test.ts': ['@angular/cli']
23 | },
24 | mime: {
25 | 'text/x-typescript': ['ts','tsx']
26 | },
27 | coverageIstanbulReporter: {
28 | reports: [ 'html', 'lcovonly' ],
29 | fixWebpackSourcePaths: true
30 | },
31 | angularCli: {
32 | environment: 'dev'
33 | },
34 | reporters: config.angularCli && config.angularCli.codeCoverage
35 | ? ['progress', 'coverage-istanbul']
36 | : ['progress', 'kjhtml'],
37 | port: 9876,
38 | colors: true,
39 | logLevel: config.LOG_INFO,
40 | autoWatch: true,
41 | browsers: ['Chrome'],
42 | singleRun: true
43 | });
44 | };
45 |
--------------------------------------------------------------------------------
/src/app/services/products.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from "@angular/core";
2 | import { Http } from "@angular/http";
3 | import { Product } from "app/models/product.model";
4 | import "rxjs/add/operator/map";
5 | import { Observable } from "rxjs/Observable";
6 | import { CachcingServiceBase } from "./caching.service";
7 |
8 | let count = 0;
9 |
10 | @Injectable()
11 | export class ProductsDataService extends CachcingServiceBase {
12 | private products: Observable;
13 |
14 | public constructor(private http: Http) {
15 | super();
16 | }
17 |
18 | public all(): Observable {
19 | return this.cache(() => this.products,
20 | (val: Observable) => this.products = val,
21 | () => this.http
22 | .get("./assets/products.json")
23 | .map((response) => response.json()
24 | .map((item) => {
25 | let model = new Product();
26 | model.updateFrom(item);
27 | return model;
28 | })));
29 |
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/app/services/delivery-options.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from "@angular/core";
2 | import { Http } from "@angular/http";
3 | import "rxjs/add/operator/map";
4 | import { Observable } from "rxjs/Observable";
5 | import { DeliveryOption } from "../models/delivery-option.model";
6 | import { CachcingServiceBase } from "./caching.service";
7 |
8 | @Injectable()
9 | export class DeliveryOptionsDataService extends CachcingServiceBase {
10 | private deliveryOptions: Observable;
11 |
12 | public constructor(private http: Http) {
13 | super();
14 | }
15 |
16 | public all(): Observable {
17 | return this.cache(() => this.deliveryOptions,
18 | (val: Observable) => this.deliveryOptions = val,
19 | () => this.http
20 | .get("./assets/delivery-options.json")
21 | .map((response) => response.json()
22 | .map((item) => {
23 | let model = new DeliveryOption();
24 | model.updateFrom(item);
25 | return model;
26 | })));
27 |
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Angular (4) - Shopping Basket Example
2 |
3 |
4 | [](https://travis-ci.org/jonsamwell/angular-simple-shopping-cart)
5 |
6 | See it in action https://jonsamwell.github.io/angular-simple-shopping-cart/
7 |
8 | # Architectural Summary
9 |
10 | * Angular 4 application (scaffolded with angular-cli)
11 | * Built around RxJS Observables
12 | * One way data flow and events based processing
13 | * Immutable shopping cart to increase performance by enabling the OnPush change detention strategy that drastically reduces the change subtree Angular needs to process.
14 | * Unit tested via Karma and Jasmine.
15 | * SinonJS used for test mocking.
16 | * Minimal styling with Foundation CSS used as the base framework and SCSS used to process custom styles.
17 | * Basic example of async e2e test using new (async/await) Typescript syntax.
18 |
19 |
20 | # Setup
21 |
22 | Install the npm dependencies
23 |
24 | ```bash
25 | npm install
26 | ```
27 |
28 | # Build
29 |
30 | ```bash
31 | npm run build
32 | ```
33 |
34 | # Run Tests
35 | ```bash
36 | npm run test
37 | ```
38 |
39 | # Run E2E Tests
40 | ```bash
41 | npm run e2e
42 | ```
43 |
44 | # Serve
45 |
46 | HTTP development server
47 | ```bash
48 | npm run start
49 | ```
50 |
51 | Then navigate to http://localhost:4200/
52 |
53 |
54 |
55 | HTTPS development server (note: the development certificate will have to be added as a trusted CA)
56 | ```bash
57 | npm run start:https
58 | ```
59 |
60 | Then navigate to https://localhost:4200/
61 |
--------------------------------------------------------------------------------
/src/app/components/shopping-cart/shopping-cart.component.ts:
--------------------------------------------------------------------------------
1 | import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from "@angular/core";
2 | import { Product } from "app/models/product.model";
3 | import { ShoppingCart } from "app/models/shopping-cart.model";
4 | import { ProductsDataService } from "app/services/products.service";
5 | import { ShoppingCartService } from "app/services/shopping-cart.service";
6 | import { Observable } from "rxjs/Observable";
7 | import { Subscription } from "rxjs/Subscription";
8 |
9 | @Component({
10 | changeDetection: ChangeDetectionStrategy.OnPush,
11 | selector: "app-shopping-cart",
12 | templateUrl: "./shopping-cart.component.html"
13 | })
14 | export class ShoppingCartComponent implements OnInit, OnDestroy {
15 | public products: Observable;
16 | public cart: Observable;
17 | public itemCount: number;
18 |
19 | private cartSubscription: Subscription;
20 |
21 | public constructor(private productsService: ProductsDataService,
22 | private shoppingCartService: ShoppingCartService) {
23 | }
24 |
25 | public emptyCart(): void {
26 | this.shoppingCartService.empty();
27 | }
28 |
29 | public ngOnInit(): void {
30 | this.products = this.productsService.all();
31 | this.cart = this.shoppingCartService.get();
32 | this.cartSubscription = this.cart.subscribe((cart) => {
33 | this.itemCount = cart.items.map((x) => x.quantity).reduce((p, n) => p + n, 0);
34 | });
35 | }
36 |
37 | public ngOnDestroy(): void {
38 | if (this.cartSubscription) {
39 | this.cartSubscription.unsubscribe();
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "angular-simple-shopping-cart",
3 | "version": "1.0.0",
4 | "license": "MIT",
5 | "engines": {
6 | "node": ">= 7.0.0",
7 | "npm": ">= 3.10.8"
8 | },
9 | "scripts": {
10 | "build": "ng build -prod -aot && gulp",
11 | "start": "ng serve",
12 | "start:https": "ng serve --ssl true --ssl-key ./dev_cert.key --ssl-cert ./dev_cert.crt",
13 | "test": "ng test --watch false",
14 | "e2e": "ng e2e"
15 | },
16 | "private": true,
17 | "dependencies": {
18 | "@angular/common": "^4.0.0",
19 | "@angular/compiler": "^4.0.0",
20 | "@angular/core": "^4.0.0",
21 | "@angular/forms": "^4.0.0",
22 | "@angular/http": "^4.0.0",
23 | "@angular/platform-browser": "^4.0.0",
24 | "@angular/platform-browser-dynamic": "^4.0.0",
25 | "@angular/router": "^4.1.2",
26 | "core-js": "^2.4.1",
27 | "intl": "^1.2.5",
28 | "node-sass": "^4.5.2",
29 | "rxjs": "^5.1.0",
30 | "zone.js": "^0.8.4"
31 | },
32 | "devDependencies": {
33 | "@angular/cli": "1.0.2",
34 | "@angular/compiler-cli": "^4.0.0",
35 | "@types/jasmine": "2.5.38",
36 | "@types/node": "~6.0.60",
37 | "@types/sinon": "^2.2.1",
38 | "codelyzer": "~2.0.0",
39 | "gulp": "^3.9.1",
40 | "gulp-htmlmin": "^3.0.0",
41 | "gulp-replace": "^0.5.4",
42 | "jasmine-core": "~2.5.2",
43 | "jasmine-spec-reporter": "~3.2.0",
44 | "karma": "~1.4.1",
45 | "karma-chrome-launcher": "~2.0.0",
46 | "karma-cli": "~1.0.1",
47 | "karma-coverage-istanbul-reporter": "^0.2.0",
48 | "karma-jasmine": "~1.1.0",
49 | "karma-jasmine-html-reporter": "^0.2.2",
50 | "protractor": "~5.1.0",
51 | "sinon": "^2.2.0",
52 | "ts-node": "~2.0.0",
53 | "tslint": "~4.5.0",
54 | "typescript": "~2.2.0"
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/app/components/store-front/store-front.component.ts:
--------------------------------------------------------------------------------
1 | import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core";
2 | import { Product } from "app/models/product.model";
3 | import { ShoppingCart } from "app/models/shopping-cart.model";
4 | import { ProductsDataService } from "app/services/products.service";
5 | import { ShoppingCartService } from "app/services/shopping-cart.service";
6 | import { Observable } from "rxjs/Observable";
7 | import { Observer } from "rxjs/Observer";
8 |
9 | @Component({
10 | changeDetection: ChangeDetectionStrategy.OnPush,
11 | selector: "app-store-front",
12 | styleUrls: ["./store-front.component.scss"],
13 | templateUrl: "./store-front.component.html"
14 | })
15 | export class StoreFrontComponent implements OnInit {
16 | public products: Observable;
17 |
18 | public constructor(private productsService: ProductsDataService,
19 | private shoppingCartService: ShoppingCartService) {
20 | }
21 |
22 | public addProductToCart(product: Product): void {
23 | this.shoppingCartService.addItem(product, 1);
24 | }
25 |
26 | public removeProductFromCart(product: Product): void {
27 | this.shoppingCartService.addItem(product, -1);
28 | }
29 |
30 | public productInCart(product: Product): boolean {
31 | return Observable.create((obs: Observer) => {
32 | const sub = this.shoppingCartService
33 | .get()
34 | .subscribe((cart) => {
35 | obs.next(cart.items.some((i) => i.productId === product.id));
36 | obs.complete();
37 | });
38 | sub.unsubscribe();
39 | });
40 | }
41 |
42 | public ngOnInit(): void {
43 | this.products = this.productsService.all();
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/dev_cert.crt:
--------------------------------------------------------------------------------
1 | -----BEGIN CERTIFICATE-----
2 | MIIEwzCCAqugAwIBAgIJAOX3+8pAnEiEMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV
3 | BAMTCWxvY2FsaG9zdDAeFw0xNzA1MDIwNDIxNDlaFw0yNzA0MzAwNDIxNDlaMBQx
4 | EjAQBgNVBAMTCWxvY2FsaG9zdDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
5 | ggIBAPb4Ckc3zIUQzFp/1l6swlsn0FM5KEvs7AO7yYIXHGhkZb2SOJBhmp1kSecb
6 | yM1wvUyYNDuXzlxVKZtp+BKtrES6q7PA17t2KGeCMXPqVD0fyCXJvkvaEQapBDwG
7 | I7fZJIDJdBGUhe66OfQulN+TM3PevtESUVfpY8DQXoTr/XdeT6ekqHcxVdDW6hvY
8 | coHKD5JUTvsguC3XK04RAoTO31OoLfrEKChnurR26+duQ4wojgN4B4RTEqr9cuVZ
9 | p8ygE7TSdcy/bXaX0RLz5/OwFJW8vK809H87BpZgyn+HN64UxgM5cTBIa2104JTJ
10 | W6V08Ewnyp3Cc4Tmd26XRtqKj8z0hpuHWb/Z6JS7FRAbRrnGxvcGHgHWOPeOpFgc
11 | H+pLTXd+NM25qZPKFyzxyOuk2tyOAaRvZ1rcEhLx/3owCC4b2KRItqPHOW+6UITk
12 | CmfYAsXkuLdtJIJ3jAoTWIlFcT4v3khgpoH7sd/N0UscOEAFemLuVoryC8F7MQNj
13 | PxeZpCSF2nMYhf/BGehibAgmJlzbvlDbbup0Q4NbyKwjbA4GlNKMXCP3+96mhNe1
14 | mLfQv1D9+5e2qgk07VlVuIemFz8s7xsAe2k88XfrNltts2GiAL/S6nvd1p1jAz3P
15 | smxCQAQEeyZF7IGilaKPohaHRaoHm0L6FtBSsh/h+pI8eGmNAgMBAAGjGDAWMBQG
16 | A1UdEQQNMAuCCWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAgEAH1v4t+uLPJEj
17 | cQX9z9JTrG1egwdVr6dP06ksAchdbXdwbfNWpJbNG3JqfShjNoSBicnZRnY8lbfn
18 | rcrauIlybOyhd2b9rUTzwi1nasGFd+94DJcwn5nYphHbcP+Ff9f2AlN7+GfmOagw
19 | QFd5Km7k2c+evFiEzCF5DQfNktBuZ5kLNJAOmAqAcMB1hpE+y7igZKAi1Bhcq8LR
20 | e1IqmegbK6KNO0/jBDH5R7xBA7R14wryjy1Fql4YO3z+2RVG4KZo/FOQZ3PW8tw6
21 | JH1tlkfe/aKkCio79G/68hgGyjKc0ba4QmvK9i/vGO0U577aKYKiGuYkrRsUyKvK
22 | n5cncdHMHaNaY9dly5cdKZLjvIMSqfx66D8JOk3PopCFr67JFkTahQ782NFOh3Yf
23 | AgvNAsFIkgUQ0Df8cMGZfkRlkf26i1sQqp52ENVWHOlkNTWNB9Zjf31phWivJbtJ
24 | keURAS7sSZzusVY/7vQBzyyCVUiu77lFI+joITu2O6rcGT6iHepr902fwcuK0DlR
25 | xKVDbL4xaxVhKcvV0ZtOefPpQTJcWOmlJzc+O/oxDpN2jnWLNivXoIV/v2cYiBzI
26 | wX+zg/5EMpChdGENDBEZ/+1GSYTSJAxs4nS9+XVanLHvr282kQwwZtMQlFMpjV46
27 | rHsC/b4sNHX3VR83FC7fsGwBx9Ke4oc=
28 | -----END CERTIFICATE-----
--------------------------------------------------------------------------------
/src/app/app.module.ts:
--------------------------------------------------------------------------------
1 | import { NgModule } from "@angular/core";
2 | import { FormsModule } from "@angular/forms";
3 | import { HttpModule } from "@angular/http";
4 | import { BrowserModule } from "@angular/platform-browser";
5 |
6 | import { AppComponent } from "./app.component";
7 | import { AppRoutingModule } from "./app.routing";
8 | import { CheckoutComponent } from "./components/checkout/checkout.component";
9 | import { OrderConfirmationComponent } from "./components/order-confirmation/order-confirmation.component";
10 | import { ShoppingCartComponent } from "./components/shopping-cart/shopping-cart.component";
11 | import { StoreFrontComponent } from "./components/store-front/store-front.component";
12 | import { PopulatedCartRouteGuard } from "./route-gaurds/populated-cart.route-gaurd";
13 | import { DeliveryOptionsDataService } from "./services/delivery-options.service";
14 | import { ProductsDataService } from "./services/products.service";
15 | import { ShoppingCartService } from "./services/shopping-cart.service";
16 | import { LocalStorageServie, StorageService } from "./services/storage.service";
17 |
18 | @NgModule({
19 | bootstrap: [AppComponent],
20 | declarations: [
21 | AppComponent,
22 | ShoppingCartComponent,
23 | StoreFrontComponent,
24 | CheckoutComponent,
25 | OrderConfirmationComponent
26 | ],
27 | imports: [
28 | BrowserModule,
29 | FormsModule,
30 | HttpModule,
31 | AppRoutingModule
32 | ],
33 | providers: [
34 | ProductsDataService,
35 | DeliveryOptionsDataService,
36 | PopulatedCartRouteGuard,
37 | LocalStorageServie,
38 | { provide: StorageService, useClass: LocalStorageServie },
39 | {
40 | deps: [StorageService, ProductsDataService, DeliveryOptionsDataService],
41 | provide: ShoppingCartService,
42 | useClass: ShoppingCartService
43 | }
44 | ]
45 | })
46 | export class AppModule { }
47 |
--------------------------------------------------------------------------------
/src/app/components/store-front/store-front.component.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Pick your favourite juices...
4 |
5 | -
7 |
8 |
9 |

11 |
12 |
13 |
14 | {{product.name}}
15 | {{product.price | currency:'GBP':true}}
16 |
17 |
{{product.description}}
18 |
19 | ingredients:
20 |
21 |
22 | {{ingredient.name}} ({{ingredient.percentage}}%)
23 |
24 |
25 |
26 |
27 |
30 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
43 |
44 |
--------------------------------------------------------------------------------
/src/app/services/tests/products.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { inject, TestBed } from "@angular/core/testing";
2 | import { HttpModule, Response, ResponseOptions, XHRBackend } from "@angular/http";
3 | import { MockBackend } from "@angular/http/testing";
4 | import { Ingredient } from "app/models/ingredient.model";
5 | import { Product } from "app/models/product.model";
6 | import { ProductsDataService } from "app/services/products.service";
7 |
8 | describe("ProductsService", () => {
9 | beforeEach(() => {
10 | TestBed.configureTestingModule({
11 | imports: [
12 | HttpModule
13 | ],
14 | providers: [
15 | ProductsDataService,
16 | { provide: XHRBackend, useClass: MockBackend }
17 | ]
18 | });
19 | });
20 |
21 | it("should be injectable", inject([ProductsDataService], (service: ProductsDataService) => {
22 | expect(service).toBeTruthy();
23 | }));
24 |
25 | describe("all()", () => {
26 | it("should call the correct http endpoint",
27 | inject([ProductsDataService, XHRBackend],
28 | (service: ProductsDataService, mockBackend: MockBackend) => {
29 |
30 | mockBackend.connections.subscribe((connection) => {
31 | expect(connection.request.url).toEqual("./assets/products.json");
32 | connection.mockRespond(new Response(new ResponseOptions({
33 | body: JSON.stringify(createProducts(2))
34 | })));
35 | });
36 |
37 | service.all()
38 | .subscribe((products) => {
39 | expect(products.length).toBe(2);
40 | expect(products[0].id).toBe("0");
41 | expect(products[1].id).toBe("1");
42 | });
43 | }));
44 | });
45 | });
46 |
47 | function createProducts(count: number): Product[] {
48 | const products = new Array();
49 | for (let i = 0; i < count; i += 1) {
50 | const product = new Product();
51 | product.id = i.toString();
52 | product.name = `name ${i}`;
53 | product.description = `description ${i}`;
54 | product.price = i;
55 | product.ingredients = new Array();
56 | products.push(product);
57 | }
58 |
59 | return products;
60 | }
61 |
--------------------------------------------------------------------------------
/src/app/services/tests/delivery-options.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { inject, TestBed } from "@angular/core/testing";
2 | import { HttpModule, Response, ResponseOptions, XHRBackend } from "@angular/http";
3 | import { MockBackend } from "@angular/http/testing";
4 | import { MockConnection } from "@angular/http/testing";
5 | import { DeliveryOption } from "app/models/delivery-option.model";
6 | import { DeliveryOptionsDataService } from "app/services/delivery-options.service";
7 |
8 | describe("DeliveryOptionsDataService", () => {
9 | beforeEach(() => {
10 | TestBed.configureTestingModule({
11 | imports: [
12 | HttpModule
13 | ],
14 | providers: [
15 | DeliveryOptionsDataService,
16 | { provide: XHRBackend, useClass: MockBackend }
17 | ]
18 | });
19 | });
20 |
21 | it("should be injectable", inject([DeliveryOptionsDataService], (service: DeliveryOptionsDataService) => {
22 | expect(service).toBeTruthy();
23 | }));
24 |
25 | describe("all()", () => {
26 | it("should call the correct http endpoint",
27 | inject([DeliveryOptionsDataService, XHRBackend],
28 | (service: DeliveryOptionsDataService, mockBackend: MockBackend) => {
29 |
30 | mockBackend.connections.subscribe((connection: MockConnection) => {
31 | expect(connection.request.url).toEqual("./assets/delivery-options.json");
32 | connection.mockRespond(new Response(new ResponseOptions({
33 | body: JSON.stringify(createDeliveryOptions(2))
34 | })));
35 | });
36 |
37 | service.all()
38 | .subscribe((options) => {
39 | expect(options.length).toBe(2);
40 | expect(options[0].id).toBe("0");
41 | expect(options[1].id).toBe("1");
42 | });
43 | }));
44 |
45 | });
46 | });
47 |
48 | function createDeliveryOptions(count: number): DeliveryOption[] {
49 | const options = new Array();
50 | for (let i = 0; i < count; i += 1) {
51 | const option = new DeliveryOption();
52 | option.id = i.toString();
53 | option.name = `name ${i}`;
54 | option.description = `description ${i}`;
55 | option.price = i;
56 | options.push(option);
57 | }
58 |
59 | return options;
60 | }
61 |
--------------------------------------------------------------------------------
/src/app/components/order-confirmation/order-confirmation.component.spec.ts:
--------------------------------------------------------------------------------
1 | import { async, inject, TestBed } from "@angular/core/testing";
2 | import { CartItem } from "app/models/cart-item.model";
3 | import { Product } from "app/models/product.model";
4 | import { ShoppingCart } from "app/models/shopping-cart.model";
5 | import { DeliveryOptionsDataService } from "app/services/delivery-options.service";
6 | import { ProductsDataService } from "app/services/products.service";
7 | import { ShoppingCartService } from "app/services/shopping-cart.service";
8 | import { LocalStorageServie, StorageService } from "app/services/storage.service";
9 | import { Observable } from "rxjs/Observable";
10 | import { Observer } from "rxjs/Observer";
11 | import * as sinon from "sinon";
12 | import { OrderConfirmationComponent } from "./order-confirmation.component";
13 |
14 | class MockShoppingCartService {
15 | public emptyCalled: boolean = false;
16 |
17 | public empty(): void {
18 | this.emptyCalled = true;
19 | }
20 | }
21 |
22 | describe("OrderConfirmationComponent", () => {
23 | beforeEach(async(() => {
24 | TestBed.configureTestingModule({
25 | declarations: [
26 | OrderConfirmationComponent
27 | ],
28 | providers: [
29 | { provide: ProductsDataService, useValue: sinon.createStubInstance(ProductsDataService) },
30 | { provide: DeliveryOptionsDataService, useValue: sinon.createStubInstance(DeliveryOptionsDataService) },
31 | { provide: StorageService, useClass: LocalStorageServie },
32 | { provide: ShoppingCartService, useClass: MockShoppingCartService }
33 | ]
34 | }).compileComponents();
35 | }));
36 |
37 | it("should create the component", async(() => {
38 | const fixture = TestBed.createComponent(OrderConfirmationComponent);
39 | const component = fixture.debugElement.componentInstance;
40 | expect(component).toBeTruthy();
41 | }));
42 |
43 | it("should call empty on shopping cart service when initialised",
44 | async(inject([ShoppingCartService], (service: MockShoppingCartService) => {
45 | const fixture = TestBed.createComponent(OrderConfirmationComponent);
46 | fixture.detectChanges();
47 | expect(service.emptyCalled).toBeTruthy();
48 | })));
49 | });
50 |
--------------------------------------------------------------------------------
/src/polyfills.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This file includes polyfills needed by Angular and is loaded before the app.
3 | * You can add your own extra polyfills to this file.
4 | *
5 | * This file is divided into 2 sections:
6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main
8 | * file.
9 | *
10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that
11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
13 | *
14 | * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html
15 | */
16 |
17 | /***************************************************************************************************
18 | * BROWSER POLYFILLS
19 | */
20 |
21 | /** IE9, IE10 and IE11 requires all of the following polyfills. **/
22 | import "core-js/es6/symbol";
23 | import "core-js/es6/object";
24 | import "core-js/es6/function";
25 | import "core-js/es6/parse-int";
26 | import "core-js/es6/parse-float";
27 | import "core-js/es6/number";
28 | import "core-js/es6/math";
29 | import "core-js/es6/string";
30 | import "core-js/es6/date";
31 | import "core-js/es6/array";
32 | import "core-js/es6/regexp";
33 | import "core-js/es6/map";
34 | import "core-js/es6/set";
35 |
36 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */
37 | // import "classlist.js"; // Run `npm install --save classlist.js`.
38 |
39 | /** IE10 and IE11 requires the following to support `@angular/animation`. */
40 | // import "web-animations-js"; // Run `npm install --save web-animations-js`.
41 |
42 |
43 | /** Evergreen browsers require these. **/
44 | import "core-js/es6/reflect";
45 | import "core-js/es7/reflect";
46 |
47 |
48 | /** ALL Firefox browsers require the following to support `@angular/animation`. **/
49 | // import "web-animations-js"; // Run `npm install --save web-animations-js`.
50 |
51 |
52 |
53 | /***************************************************************************************************
54 | * Zone JS is required by Angular itself.
55 | */
56 | import "zone.js/dist/zone"; // Included with Angular CLI.
57 |
58 |
59 |
60 | /***************************************************************************************************
61 | * APPLICATION IMPORTS
62 | */
63 |
64 | /**
65 | * Date, currency, decimal and percent pipes.
66 | * Needed for: All but Chrome, Firefox, Edge, IE11 and Safari 10
67 | */
68 | import "intl"; // Run `npm install --save intl`.
69 |
--------------------------------------------------------------------------------
/src/app/components/checkout/checkout.component.ts:
--------------------------------------------------------------------------------
1 | import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from "@angular/core";
2 | import { CartItem } from "app/models/cart-item.model";
3 | import { DeliveryOption } from "app/models/delivery-option.model";
4 | import { Product } from "app/models/product.model";
5 | import { ShoppingCart } from "app/models/shopping-cart.model";
6 | import { DeliveryOptionsDataService } from "app/services/delivery-options.service";
7 | import { ProductsDataService } from "app/services/products.service";
8 | import { ShoppingCartService } from "app/services/shopping-cart.service";
9 | import { Observable } from "rxjs/Observable";
10 | import { Subscription } from "rxjs/Subscription";
11 |
12 | interface ICartItemWithProduct extends CartItem {
13 | product: Product;
14 | totalCost: number;
15 | }
16 |
17 | @Component({
18 | selector: "app-checkout",
19 | styleUrls: ["./checkout.component.scss"],
20 | templateUrl: "./checkout.component.html"
21 | })
22 | export class CheckoutComponent implements OnInit, OnDestroy {
23 | public deliveryOptions: Observable;
24 | public cart: Observable;
25 | public cartItems: ICartItemWithProduct[];
26 | public itemCount: number;
27 |
28 | private products: Product[];
29 | private cartSubscription: Subscription;
30 |
31 | public constructor(private productsService: ProductsDataService,
32 | private deliveryOptionService: DeliveryOptionsDataService,
33 | private shoppingCartService: ShoppingCartService) {
34 | }
35 |
36 | public emptyCart(): void {
37 | this.shoppingCartService.empty();
38 | }
39 |
40 | public setDeliveryOption(option: DeliveryOption): void {
41 | this.shoppingCartService.setDeliveryOption(option);
42 | }
43 |
44 | public ngOnInit(): void {
45 | this.deliveryOptions = this.deliveryOptionService.all();
46 | this.cart = this.shoppingCartService.get();
47 | this.cartSubscription = this.cart.subscribe((cart) => {
48 | this.itemCount = cart.items.map((x) => x.quantity).reduce((p, n) => p + n, 0);
49 | this.productsService.all().subscribe((products) => {
50 | this.products = products;
51 | this.cartItems = cart.items
52 | .map((item) => {
53 | const product = this.products.find((p) => p.id === item.productId);
54 | return {
55 | ...item,
56 | product,
57 | totalCost: product.price * item.quantity };
58 | });
59 | });
60 | });
61 | }
62 |
63 | public ngOnDestroy(): void {
64 | if (this.cartSubscription) {
65 | this.cartSubscription.unsubscribe();
66 | }
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/app/components/checkout/checkout.component.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | shopping_basket
5 | Checkout
6 |
7 |
8 |
9 |
10 | Order Total
11 | {{(cart | async).grossTotal | currency:'GBP':true}}
12 |
13 |
14 |
15 |
Please select a delivery option...
17 |
Purchase Order
20 |
21 |
22 |
23 |
24 |
25 |
Delivery 1 of 1
26 |
Dispatching to the UK....
27 |
29 |
30 |

32 |
33 |
34 |
{{item.product.name}}
35 |
{{item.product.description}}
36 |
37 |
38 |
{{item.quantity}} x {{item.product.price | currency:'GBP':true}}
39 |
40 |
41 |
{{item.totalCost | currency:'GBP':true}}
42 |
43 |
44 |
45 |
46 |
Delivery Options
47 |
48 |
50 |
51 |
56 |
57 |
58 |
59 |
60 |
61 |
{{option.price | currency:'GBP':true}}
62 |
63 |
64 |
{{option.description}}
65 |
66 |
67 |
68 |
69 |
70 |
76 |
77 |
--------------------------------------------------------------------------------
/dev_cert.key:
--------------------------------------------------------------------------------
1 | -----BEGIN RSA PRIVATE KEY-----
2 | MIIJJwIBAAKCAgEA9vgKRzfMhRDMWn/WXqzCWyfQUzkoS+zsA7vJghccaGRlvZI4
3 | kGGanWRJ5xvIzXC9TJg0O5fOXFUpm2n4Eq2sRLqrs8DXu3YoZ4Ixc+pUPR/IJcm+
4 | S9oRBqkEPAYjt9kkgMl0EZSF7ro59C6U35Mzc96+0RJRV+ljwNBehOv9d15Pp6So
5 | dzFV0NbqG9hygcoPklRO+yC4LdcrThEChM7fU6gt+sQoKGe6tHbr525DjCiOA3gH
6 | hFMSqv1y5VmnzKATtNJ1zL9tdpfREvPn87AUlby8rzT0fzsGlmDKf4c3rhTGAzlx
7 | MEhrbXTglMlbpXTwTCfKncJzhOZ3bpdG2oqPzPSGm4dZv9nolLsVEBtGucbG9wYe
8 | AdY4946kWBwf6ktNd340zbmpk8oXLPHI66Ta3I4BpG9nWtwSEvH/ejAILhvYpEi2
9 | o8c5b7pQhOQKZ9gCxeS4t20kgneMChNYiUVxPi/eSGCmgfux383RSxw4QAV6Yu5W
10 | ivILwXsxA2M/F5mkJIXacxiF/8EZ6GJsCCYmXNu+UNtu6nRDg1vIrCNsDgaU0oxc
11 | I/f73qaE17WYt9C/UP37l7aqCTTtWVW4h6YXPyzvGwB7aTzxd+s2W22zYaIAv9Lq
12 | e93WnWMDPc+ybEJABAR7JkXsgaKVoo+iFodFqgebQvoW0FKyH+H6kjx4aY0CAwEA
13 | AQKCAgBhR+0Mho86Lxa/4zE208gvDezCi3YzCh0hj1vdsWrQOBPXa3x6aufzbWiq
14 | 70fWnL0EKcQRYUe5GRatkD1WZjDAVeOCh8iyn+VkeGUwarJJ7XXyZJhf2oLwY602
15 | U5jIN3FohXIB5sYm9hYT+DFOK/aNgsUZJ6UBAv73GVzR8P9DgNPRjkuJv9Y00CMh
16 | Ws4oD8a4dhsyUn4aGHKHaq3aUH6pvkp2R8Qlvk5N/bYI0GctE2B1P1d+qRZmYjVa
17 | 4Ej3kmqQxIIAZ7sfhselNow8cjR5kwGj5jEU2NRMcc3yE/o7yRUCeiL6yNwAHpTE
18 | JtjBwOFSri3inJDSXdHXryEKvp/LbsK01iQslhsLZ6qZZUYCMS3T192aQPYoPc3c
19 | I1YHfY32joSphAj5iipH9hA6TpQbNh9DnO8h5VgWfEKqtliHRJVcMSAUFI+6dsTG
20 | H0OEZXmLsLkSQenLor/Rd6WiF/Z6B6DDABww/lDma0kXoyi6UwMhEGDcszAxZo1J
21 | NadR75ewqEn4UgiLlxe/htjxG5v6aa+1WzJ3TdPuGEP5lgS/0nf9J1GxKt5X1PvP
22 | c7cNlzryu76KJDLhGBid/sk4cSdD2Vnm6a5rCZvhArL45wiNzTQkQf1C/cgbdZ7d
23 | eJkG99Z8kwn7jlqntfssMz0+pNmS14zsyBY3CwNlOtHQj2DKYQKCAQEA/L7p7/MY
24 | 8NODsEVgrKlAwb1EFWwe+T9zNfI/MsQqVvsli39sj55o/oyRe/bhWIXLIYDjKztK
25 | vNTSvHi1qD+K6aLIfNUGIiVZ5CyXVe90avzmKcOBjXSoMZ5O8lqmJjLAH3htS3X3
26 | Jv4CYye9ma4cSYVDHF0GsFckEDKcwDpnx+8C50s8zP+mRz9t3Xo8zc8Rls1fxODh
27 | UU22XjUJumFpi+LkG++8jL4OUIbqEeL+uFFpuiQ3dOAkhM2XjHTDTAEE6xKudVp3
28 | pNC/YbxAngNMG819dxBYMavipTsyJzIXqEYqnF/6tLHoAvxwqfOFGi2tyn6BznlP
29 | uuLyqCmj5jpgRQKCAQEA+iYVwvwekpy44CZHBS+/5IA+QNZb0lmMZjOFW9rp4F/i
30 | lB7nKXGZ5AH7wg7s2PJ88tH0pJCrc+X+zg3NyuC6oNM6qjEJZ0eF5n2bG26U+CAS
31 | gFARTRPjEuSJOS14XNnbkVZKTMz3sQiBtwafNXGFgXROL4yuDhwju4K7YC8vGeAM
32 | Y00DELM/CB2CasJ99aEQclk+zmwiqMYkS5xSq4osS+PYkdqu1WKJ0yeBNv9w2/i/
33 | ZyHXfP3BPGik2GAqjMOfDfgmBRnbJwdMVHtyi133JE2QTYmBuOOauRPjPjWA1NPB
34 | Sz4zNbIOYu3aI91+wIVXCPFbEViM0/Ww1p5pV4csqQKCAQBoR9z9w69mrd6HvBhH
35 | JQ4y4YRV0mZ1MFi4yVqJ96YAfV7gT5LbLuDjJdxg6VvQymMlT6hrDeuoPac1XBiX
36 | cqA+BYvy9XGyZPbRzhQiwMmn2vCcCq5JTviWgFrSY9RprkbWtTljCSkQTX1uq9bu
37 | sYe6TeGCsl8wIsQeasOCDJcFRvhLth8/9bsFaoZJ+0VbJTR8o///m0lb4lR8SiXZ
38 | YJfLv7GeVSvWZhRB5WhuONof8ndM9eRrtI9cu/brXMG3ejQtSWfxw4HZ3scX7DQ8
39 | /d9JGV/K4FODKwg4ZFQtF93q8AhkvLUUGNNBaCaT+IXSZ0ZtZgToy+S5lynHeGbH
40 | dw6BAoIBAB+Wt6DL3cB98gq8SrOo9/6PA02ExEun60bssqaK2oXvFdnGnUJqihh0
41 | 96nl4Jr05Dp1sQMnEb+tB6RVseswveCZTAs57goQyiP1MKUiLcW3px50/fpRzJcS
42 | LRH/X/e5uzR7RR61s4GzpU3LlEdXcpiKa2Utyr0VaJ3BQJBA5R3LYUUY8I4nVIpd
43 | z55TuTxPfpgyFPBUT3woqWSy9O2coUNkHnEswG9J5kW382VSlJnyq5kGeQPbt489
44 | V7PLURQ9j9Rfxc1XGomvLkBs5mYbE56N/O8Nskf61gsRK90rPH2j0AEEdcsOFSsB
45 | Bk8JJXyCWh9S+0ERgZcyq4YusvbOpyECggEAOl9aUXnL2656N7eIDk8o6FJcYo84
46 | 1RBaSQMdleuEnl4luQAsVjHRNZFOgN7iEcKArAq+cr3HGxgQUSWM0wZVXQ8ae5cp
47 | H9P7SeoyZYKEJAZO1MAQPzVqKrjlD4gl0Joy10EKltOiHExFZfKu7uTngiZPZfeA
48 | EnTeX1KVk0/3IoOrJyPzkH5cFGDguBkX3jFPF/nogLryu9HLwqfdnHaftCVB3+9t
49 | fXRRNnAZvkEnDO1L2w+HlLc9qfN06dCExjmZOQCw3aGuDo/HJ1xRmW4wBHswXKVg
50 | 09eP0VZtEV9JWMCucee1+riJsn/e2iCRczcG9IcKvLHbZTscSYqHzVZw3w==
51 | -----END RSA PRIVATE KEY-----
--------------------------------------------------------------------------------
/src/app/route-gaurds/populated-cart.route-gaurd.spec.ts:
--------------------------------------------------------------------------------
1 | import { inject, TestBed } from "@angular/core/testing";
2 | import { MockBackend } from "@angular/http/testing";
3 | import { Router, RouterModule } from "@angular/router";
4 | import { CartItem } from "app/models/cart-item.model";
5 | import { ShoppingCart } from "app/models/shopping-cart.model";
6 | import { DeliveryOptionsDataService } from "app/services/delivery-options.service";
7 | import { ProductsDataService } from "app/services/products.service";
8 | import { ShoppingCartService } from "app/services/shopping-cart.service";
9 | import { LocalStorageServie, StorageService } from "app/services/storage.service";
10 | import { Observable } from "rxjs/Observable";
11 | import { Observer } from "rxjs/Observer";
12 | import * as sinon from "sinon";
13 | import { SinonSpy } from "sinon";
14 | import { PopulatedCartRouteGuard } from "./populated-cart.route-gaurd";
15 |
16 | class MockShoppingCartService {
17 | public unsubscriveCalled: boolean = false;
18 | private subscriptionObservable: Observable;
19 | private subscriber: Observer;
20 | private cart: ShoppingCart = new ShoppingCart();
21 |
22 | public constructor() {
23 | this.subscriptionObservable = new Observable((observer: Observer) => {
24 | this.subscriber = observer;
25 | observer.next(this.cart);
26 | return () => this.unsubscriveCalled = true;
27 | });
28 | }
29 |
30 | public get(): Observable {
31 | return this.subscriptionObservable;
32 | }
33 |
34 | public dispatchCart(cart: ShoppingCart): void {
35 | this.cart = cart;
36 | if (this.subscriber) {
37 | this.subscriber.next(cart);
38 | }
39 | }
40 | }
41 |
42 | describe("PopulatedCartRouteGuard", () => {
43 | beforeEach(() => {
44 | TestBed.configureTestingModule({
45 | imports: [
46 | RouterModule
47 | ],
48 | providers: [
49 | PopulatedCartRouteGuard,
50 | { provide: ShoppingCartService, useClass: MockShoppingCartService },
51 | { provide: Router, useValue: sinon.createStubInstance(Router) }
52 | ]
53 | });
54 | });
55 |
56 | it("should be injectable", inject([PopulatedCartRouteGuard], (routeGaurd: PopulatedCartRouteGuard) => {
57 | expect(routeGaurd).toBeTruthy();
58 | }));
59 |
60 | describe("canActivate", () => {
61 | it("should return true if there are items in the cart",
62 | inject([Router, ShoppingCartService, PopulatedCartRouteGuard], (router: Router,
63 | shoppingCartService: MockShoppingCartService,
64 | gaurd: PopulatedCartRouteGuard) => {
65 | const newCart = new ShoppingCart();
66 | const cartItem = new CartItem();
67 | cartItem.quantity = 1;
68 | newCart.items = [cartItem];
69 | shoppingCartService.dispatchCart(newCart);
70 |
71 | gaurd.canActivate()
72 | .subscribe((result) => expect(result).toBeTruthy());
73 | }));
74 |
75 | it("should return false and redirect to '/' if there are no items in the cart",
76 | inject([Router, PopulatedCartRouteGuard], (router: Router, gaurd: PopulatedCartRouteGuard) => {
77 | gaurd.canActivate()
78 | .subscribe((result) => expect(result).toBeFalsy());
79 |
80 | sinon.assert.calledOnce(router.navigate as SinonSpy);
81 | sinon.assert.calledWithExactly(router.navigate as SinonSpy, ["/"]);
82 | }));
83 | });
84 | });
85 |
--------------------------------------------------------------------------------
/src/assets/products.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": "9aa113b4-1e4e-4cde-bf9d-8358fc78ea4f",
4 | "price": 3.50,
5 | "name": "Greens",
6 | "description": "Looking for simple, clean and green? Four of the most nutrient dense leafy greens create the base in our low-sugar Greens 1.",
7 | "ingredients": [
8 | {
9 | "name": "cucumber",
10 | "percentage": 50
11 | },
12 | {
13 | "name": "celery",
14 | "percentage": 20
15 | },
16 | {
17 | "name": "apple",
18 | "percentage": 20
19 | },
20 | {
21 | "name": "lemon",
22 | "percentage": 10
23 | }
24 | ]
25 | },
26 | {
27 | "id": "bdcbe438-ac85-4acf-8949-5627fd5b57df",
28 | "price": 2.75,
29 | "name": "Citrus",
30 | "description": "This enzyme rich juice is filled with phytonutrients and bromelin which helps to reduce inflammation. Drink it before a meal to get digestive juices flowing.",
31 | "ingredients": [
32 | {
33 | "name": "pineapple",
34 | "percentage": 50
35 | },
36 | {
37 | "name": "apple",
38 | "percentage": 20
39 | },
40 | {
41 | "name": "mint",
42 | "percentage": 20
43 | },
44 | {
45 | "name": "lemon",
46 | "percentage": 10
47 | }
48 | ]
49 | },
50 | {
51 | "id": "58552daa-30f6-46fa-a808-f1a1d7667561",
52 | "price": 3,
53 | "name": "Roots",
54 | "description": "Beets help your body to release stomach acid which aids digestion! Drink this juice when you want a snack that's both pretty and nutritious!",
55 | "ingredients": [
56 | {
57 | "name": "apple",
58 | "percentage": 50
59 | },
60 | {
61 | "name": "beetroot",
62 | "percentage": 20
63 | },
64 | {
65 | "name": "ginger",
66 | "percentage": 20
67 | },
68 | {
69 | "name": "lemon",
70 | "percentage": 10
71 | }
72 | ]
73 | },
74 | {
75 | "id": "d4666802-fd84-476f-9eea-c8dd29cfb633",
76 | "price": 1.99,
77 | "name": "Orange",
78 | "description": "Orange juice with a twist to boost you health!",
79 | "ingredients": [
80 | {
81 | "name": "orange",
82 | "percentage": 50
83 | },
84 | {
85 | "name": "lemon",
86 | "percentage": 20
87 | },
88 | {
89 | "name": "apple",
90 | "percentage": 20
91 | },
92 | {
93 | "name": "tumeric",
94 | "percentage": 10
95 | }
96 | ]
97 | },
98 | {
99 | "id": "7ef3b9dd-5a95-4415-af37-6871d6ff0262",
100 | "price": 2.50,
101 | "name": "Coconut",
102 | "description": "Cinnamon lovers - this is your blend! Two nutritional powerhouses combine in a delicious, satiating elixir. Both cinnamon and coconut have been shown to reduce blood sugar. Raw coconut meat is a great source of medium chain fatty acids, which can lower bad cholesterol. Coconut also contains significant levels of fiber and manganese, a mineral that helps you metabolize fat and protein.",
103 | "ingredients": [
104 | {
105 | "name": "Coconut",
106 | "percentage": 70
107 | },
108 | {
109 | "name": "Cinnamon",
110 | "percentage": 20
111 | },
112 | {
113 | "name": "water",
114 | "percentage": 10
115 | }
116 | ]
117 | }
118 | ]
119 |
--------------------------------------------------------------------------------
/src/app/components/shopping-cart/shopping-cart.component.spec.ts:
--------------------------------------------------------------------------------
1 | import { async, inject, TestBed } from "@angular/core/testing";
2 | import { CartItem } from "app/models/cart-item.model";
3 | import { Product } from "app/models/product.model";
4 | import { ShoppingCart } from "app/models/shopping-cart.model";
5 | import { DeliveryOptionsDataService } from "app/services/delivery-options.service";
6 | import { ProductsDataService } from "app/services/products.service";
7 | import { ShoppingCartService } from "app/services/shopping-cart.service";
8 | import { LocalStorageServie, StorageService } from "app/services/storage.service";
9 | import { Observable } from "rxjs/Observable";
10 | import { Observer } from "rxjs/Observer";
11 | import * as sinon from "sinon";
12 | import { ShoppingCartComponent } from "./shopping-cart.component";
13 |
14 | class MockShoppingCartService {
15 | public unsubscriveCalled: boolean = false;
16 | public emptyCalled: boolean = false;
17 | private subscriptionObservable: Observable;
18 | private subscriber: Observer;
19 | private cart: ShoppingCart = new ShoppingCart();
20 |
21 | public constructor() {
22 | this.subscriptionObservable = new Observable((observer: Observer) => {
23 | this.subscriber = observer;
24 | observer.next(this.cart);
25 | return () => this.unsubscriveCalled = true;
26 | });
27 | }
28 |
29 | public get(): Observable {
30 | return this.subscriptionObservable;
31 | }
32 |
33 | public empty(): void {
34 | this.emptyCalled = true;
35 | }
36 |
37 | public dispatchCart(cart: ShoppingCart): void {
38 | this.cart = cart;
39 | if (this.subscriber) {
40 | this.subscriber.next(cart);
41 | }
42 | }
43 | }
44 |
45 | describe("ShoppingCartComponent", () => {
46 | beforeEach(async(() => {
47 | TestBed.configureTestingModule({
48 | declarations: [
49 | ShoppingCartComponent
50 | ],
51 | providers: [
52 | { provide: ProductsDataService, useValue: sinon.createStubInstance(ProductsDataService) },
53 | { provide: DeliveryOptionsDataService, useValue: sinon.createStubInstance(DeliveryOptionsDataService) },
54 | { provide: StorageService, useClass: LocalStorageServie },
55 | { provide: ShoppingCartService, useClass: MockShoppingCartService }
56 | ]
57 | }).compileComponents();
58 | }));
59 |
60 | it("should create the component", async(() => {
61 | const fixture = TestBed.createComponent(ShoppingCartComponent);
62 | const component = fixture.debugElement.componentInstance;
63 | expect(component).toBeTruthy();
64 | }));
65 |
66 | it("should render gross total of shopping cart",
67 | async(inject([ShoppingCartService], (service: MockShoppingCartService) => {
68 | const fixture = TestBed.createComponent(ShoppingCartComponent);
69 | fixture.detectChanges();
70 | const compiled = fixture.debugElement.nativeElement;
71 | expect(compiled.querySelector(".js-cart-total").textContent).toContain("£0.00");
72 |
73 | const newCart = new ShoppingCart();
74 | newCart.grossTotal = 1.5;
75 | service.dispatchCart(newCart);
76 | fixture.detectChanges();
77 | expect(compiled.querySelector(".js-cart-total").textContent).toContain("£1.50");
78 | })));
79 |
80 | it("should empty the cart when empty shopping cart button pressed",
81 | async(inject([ShoppingCartService], (service: MockShoppingCartService) => {
82 | const newCart = new ShoppingCart();
83 | const cartItem = new CartItem();
84 | cartItem.quantity = 1;
85 | newCart.grossTotal = 1.5;
86 | newCart.items = [cartItem];
87 | service.dispatchCart(newCart);
88 | const fixture = TestBed.createComponent(ShoppingCartComponent);
89 | fixture.detectChanges();
90 | fixture.debugElement.nativeElement.querySelector(".js-btn-empty-cart").click();
91 | expect(service.emptyCalled).toBeTruthy();
92 | })));
93 | });
94 |
--------------------------------------------------------------------------------
/src/app/services/shopping-cart.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from "@angular/core";
2 | import { StorageService } from "app/services/storage.service";
3 | import { Observable } from "rxjs/Observable";
4 | import { Observer } from "rxjs/Observer";
5 | import { CartItem } from "../models/cart-item.model";
6 | import { DeliveryOption } from "../models/delivery-option.model";
7 | import { Product } from "../models/product.model";
8 | import { ShoppingCart } from "../models/shopping-cart.model";
9 | import { DeliveryOptionsDataService } from "../services/delivery-options.service";
10 | import { ProductsDataService } from "../services/products.service";
11 |
12 | const CART_KEY = "cart";
13 |
14 | @Injectable()
15 | export class ShoppingCartService {
16 | private storage: Storage;
17 | private subscriptionObservable: Observable;
18 | private subscribers: Array> = new Array>();
19 | private products: Product[];
20 | private deliveryOptions: DeliveryOption[];
21 |
22 | public constructor(private storageService: StorageService,
23 | private productService: ProductsDataService,
24 | private deliveryOptionsService: DeliveryOptionsDataService) {
25 | this.storage = this.storageService.get();
26 | this.productService.all().subscribe((products) => this.products = products);
27 | this.deliveryOptionsService.all().subscribe((options) => this.deliveryOptions = options);
28 |
29 | this.subscriptionObservable = new Observable((observer: Observer) => {
30 | this.subscribers.push(observer);
31 | observer.next(this.retrieve());
32 | return () => {
33 | this.subscribers = this.subscribers.filter((obs) => obs !== observer);
34 | };
35 | });
36 | }
37 |
38 | public get(): Observable {
39 | return this.subscriptionObservable;
40 | }
41 |
42 | public addItem(product: Product, quantity: number): void {
43 | const cart = this.retrieve();
44 | let item = cart.items.find((p) => p.productId === product.id);
45 | if (item === undefined) {
46 | item = new CartItem();
47 | item.productId = product.id;
48 | cart.items.push(item);
49 | }
50 |
51 | item.quantity += quantity;
52 | cart.items = cart.items.filter((cartItem) => cartItem.quantity > 0);
53 | if (cart.items.length === 0) {
54 | cart.deliveryOptionId = undefined;
55 | }
56 |
57 | this.calculateCart(cart);
58 | this.save(cart);
59 | this.dispatch(cart);
60 | }
61 |
62 | public empty(): void {
63 | const newCart = new ShoppingCart();
64 | this.save(newCart);
65 | this.dispatch(newCart);
66 | }
67 |
68 | public setDeliveryOption(deliveryOption: DeliveryOption): void {
69 | const cart = this.retrieve();
70 | cart.deliveryOptionId = deliveryOption.id;
71 | this.calculateCart(cart);
72 | this.save(cart);
73 | this.dispatch(cart);
74 | }
75 |
76 | private calculateCart(cart: ShoppingCart): void {
77 | cart.itemsTotal = cart.items
78 | .map((item) => item.quantity * this.products.find((p) => p.id === item.productId).price)
79 | .reduce((previous, current) => previous + current, 0);
80 | cart.deliveryTotal = cart.deliveryOptionId ?
81 | this.deliveryOptions.find((x) => x.id === cart.deliveryOptionId).price :
82 | 0;
83 | cart.grossTotal = cart.itemsTotal + cart.deliveryTotal;
84 | }
85 |
86 | private retrieve(): ShoppingCart {
87 | const cart = new ShoppingCart();
88 | const storedCart = this.storage.getItem(CART_KEY);
89 | if (storedCart) {
90 | cart.updateFrom(JSON.parse(storedCart));
91 | }
92 |
93 | return cart;
94 | }
95 |
96 | private save(cart: ShoppingCart): void {
97 | this.storage.setItem(CART_KEY, JSON.stringify(cart));
98 | }
99 |
100 | private dispatch(cart: ShoppingCart): void {
101 | this.subscribers
102 | .forEach((sub) => {
103 | try {
104 | sub.next(cart);
105 | } catch (e) {
106 | // we want all subscribers to get the update even if one errors.
107 | }
108 | });
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/src/app/components/checkout/checkout.component.spec.ts:
--------------------------------------------------------------------------------
1 | import { async, inject, TestBed } from "@angular/core/testing";
2 | import { HttpModule } from "@angular/http";
3 | import { CartItem } from "app/models/cart-item.model";
4 | import { DeliveryOption } from "app/models/delivery-option.model";
5 | import { Product } from "app/models/product.model";
6 | import { ShoppingCart } from "app/models/shopping-cart.model";
7 | import { DeliveryOptionsDataService } from "app/services/delivery-options.service";
8 | import { ProductsDataService } from "app/services/products.service";
9 | import { ShoppingCartService } from "app/services/shopping-cart.service";
10 | import { LocalStorageServie, StorageService } from "app/services/storage.service";
11 | import { Observable } from "rxjs/Observable";
12 | import { Observer } from "rxjs/Observer";
13 | import * as sinon from "sinon";
14 | import { CheckoutComponent } from "./checkout.component";
15 |
16 | const PRODUCT_1 = new Product();
17 | PRODUCT_1.name = "Product 1";
18 | PRODUCT_1.id = "1";
19 | PRODUCT_1.price = 1;
20 | PRODUCT_1.description = "desc1";
21 |
22 | const PRODUCT_2 = new Product();
23 | PRODUCT_2.name = "Product 2";
24 | PRODUCT_2.id = "2";
25 | PRODUCT_2.price = 2;
26 | PRODUCT_2.description = "desc2";
27 |
28 | const DELIVERY_OPT_1 = new DeliveryOption();
29 | DELIVERY_OPT_1.name = "Delivery Option 1";
30 | DELIVERY_OPT_1.id = "1";
31 | DELIVERY_OPT_1.price = 1;
32 |
33 | const DELIVERY_OPT_2 = new DeliveryOption();
34 | DELIVERY_OPT_2.name = "Delivery Option 2";
35 | DELIVERY_OPT_2.id = "2";
36 | DELIVERY_OPT_2.price = 2;
37 |
38 | class MockProductDataService extends ProductsDataService {
39 | public all(): Observable {
40 | return Observable.from([[PRODUCT_1, PRODUCT_2]]);
41 | }
42 | }
43 |
44 | // tslint:disable-next-line:max-classes-per-file
45 | class MockDeliveryOptionsDataService extends DeliveryOptionsDataService {
46 | public all(): Observable {
47 | return Observable.from([[DELIVERY_OPT_1, DELIVERY_OPT_2]]);
48 | }
49 | }
50 |
51 | // tslint:disable-next-line:max-classes-per-file
52 | class MockShoppingCartService {
53 | public unsubscriveCalled: boolean = false;
54 | public emptyCalled: boolean = false;
55 |
56 | private subscriptionObservable: Observable;
57 | private subscriber: Observer;
58 | private cart: ShoppingCart = new ShoppingCart();
59 |
60 | public constructor() {
61 | this.subscriptionObservable = new Observable((observer: Observer) => {
62 | this.subscriber = observer;
63 | observer.next(this.cart);
64 | return () => this.unsubscriveCalled = true;
65 | });
66 | }
67 |
68 | public get(): Observable {
69 | return this.subscriptionObservable;
70 | }
71 |
72 | public empty(): void {
73 | this.emptyCalled = true;
74 | }
75 |
76 | public dispatchCart(cart: ShoppingCart): void {
77 | this.cart = cart;
78 | if (this.subscriber) {
79 | this.subscriber.next(cart);
80 | }
81 | }
82 | }
83 |
84 | describe("CheckoutComponent", () => {
85 | beforeEach(async(() => {
86 | TestBed.configureTestingModule({
87 | declarations: [
88 | CheckoutComponent
89 | ],
90 | imports: [
91 | HttpModule
92 | ],
93 | providers: [
94 | { provide: ProductsDataService, useClass: MockProductDataService },
95 | { provide: DeliveryOptionsDataService, useClass: MockDeliveryOptionsDataService },
96 | { provide: StorageService, useClass: LocalStorageServie },
97 | { provide: ShoppingCartService, useClass: MockShoppingCartService }
98 | ]
99 | }).compileComponents();
100 | }));
101 |
102 | it("should create the component", async(() => {
103 | const fixture = TestBed.createComponent(CheckoutComponent);
104 | const component = fixture.debugElement.componentInstance;
105 | expect(component).toBeTruthy();
106 | }));
107 |
108 | it("should display all the products in the cart",
109 | async(inject([ShoppingCartService], (service: MockShoppingCartService) => {
110 | const newCart = new ShoppingCart();
111 | const cartItem = new CartItem();
112 | cartItem.productId = PRODUCT_1.id;
113 | cartItem.quantity = 2;
114 | newCart.grossTotal = 3;
115 | newCart.items = [cartItem];
116 | service.dispatchCart(newCart);
117 | const fixture = TestBed.createComponent(CheckoutComponent);
118 | fixture.detectChanges();
119 |
120 | const component = fixture.debugElement.componentInstance;
121 | const compiled = fixture.debugElement.nativeElement;
122 | const productElements = compiled.querySelectorAll(".checkout_row");
123 |
124 | expect(productElements.length).toEqual(1);
125 | expect(productElements[0].querySelector(".js-product-name").textContent).toEqual(PRODUCT_1.name);
126 | expect(productElements[0].querySelector(".js-product-desc").textContent).toContain(PRODUCT_1.description);
127 | expect(productElements[0].querySelector(".js-product-costs").textContent)
128 | .toContain(`${cartItem.quantity} x £${PRODUCT_1.price}`);
129 | expect(productElements[0].querySelector(".js-product-total").textContent)
130 | .toContain(PRODUCT_1.price * cartItem.quantity);
131 | })));
132 |
133 | it("should display all the delivery options",
134 | async(inject([ShoppingCartService], (service: MockShoppingCartService) => {
135 | const fixture = TestBed.createComponent(CheckoutComponent);
136 | fixture.detectChanges();
137 |
138 | const component = fixture.debugElement.componentInstance;
139 | const compiled = fixture.debugElement.nativeElement;
140 | const deliveryOptions = compiled.querySelectorAll(".delivery-option");
141 |
142 | expect(deliveryOptions.length).toEqual(2);
143 | expect(deliveryOptions[0].querySelector(".js-option-name").textContent).toEqual(DELIVERY_OPT_1.name);
144 | expect(deliveryOptions[0].querySelector(".js-option-price").textContent).toContain(DELIVERY_OPT_1.price);
145 | expect(deliveryOptions[1].querySelector(".js-option-name").textContent).toEqual(DELIVERY_OPT_2.name);
146 | expect(deliveryOptions[1].querySelector(".js-option-price").textContent).toContain(DELIVERY_OPT_2.price);
147 | })));
148 | });
149 |
--------------------------------------------------------------------------------
/src/app/components/store-front/store-front.component.spec.ts:
--------------------------------------------------------------------------------
1 | import { async, inject, TestBed } from "@angular/core/testing";
2 | import { HttpModule } from "@angular/http";
3 | import { CartItem } from "app/models/cart-item.model";
4 | import { Product } from "app/models/product.model";
5 | import { ShoppingCart } from "app/models/shopping-cart.model";
6 | import { DeliveryOptionsDataService } from "app/services/delivery-options.service";
7 | import { ProductsDataService } from "app/services/products.service";
8 | import { ShoppingCartService } from "app/services/shopping-cart.service";
9 | import { LocalStorageServie, StorageService } from "app/services/storage.service";
10 | import { Observable } from "rxjs/Observable";
11 | import { Observer } from "rxjs/Observer";
12 | import * as sinon from "sinon";
13 | import { ShoppingCartComponent } from "../shopping-cart/shopping-cart.component";
14 | import { StoreFrontComponent } from "./store-front.component";
15 |
16 | const PRODUCT_1 = new Product();
17 | PRODUCT_1.name = "Product 1";
18 | PRODUCT_1.id = "1";
19 | PRODUCT_1.price = 1;
20 | PRODUCT_1.description = "desc1";
21 |
22 | const PRODUCT_2 = new Product();
23 | PRODUCT_2.name = "Product 2";
24 | PRODUCT_2.id = "2";
25 | PRODUCT_2.price = 2;
26 | PRODUCT_2.description = "desc2";
27 |
28 | // tslint:disable-next-line:max-classes-per-file
29 | class MockProductDataService extends ProductsDataService {
30 | public all(): Observable {
31 | return Observable.from([[PRODUCT_1, PRODUCT_2]]);
32 | }
33 | }
34 |
35 | // tslint:disable-next-line:max-classes-per-file
36 | class MockShoppingCartService {
37 | public unsubscriveCalled: boolean = false;
38 | public emptyCalled: boolean = false;
39 |
40 | private subscriptionObservable: Observable;
41 | private subscriber: Observer;
42 | private cart: ShoppingCart = new ShoppingCart();
43 |
44 | public constructor() {
45 | this.subscriptionObservable = new Observable((observer: Observer) => {
46 | this.subscriber = observer;
47 | observer.next(this.cart);
48 | return () => this.unsubscriveCalled = true;
49 | });
50 | }
51 |
52 | public addItem(product: Product, quantity: number): void {}
53 |
54 | public get(): Observable {
55 | return this.subscriptionObservable;
56 | }
57 |
58 | public empty(): void {
59 | this.emptyCalled = true;
60 | }
61 |
62 | public dispatchCart(cart: ShoppingCart): void {
63 | this.cart = cart;
64 | if (this.subscriber) {
65 | this.subscriber.next(cart);
66 | }
67 | }
68 | }
69 |
70 | describe("StoreFrontComponent", () => {
71 | beforeEach(async(() => {
72 | TestBed.configureTestingModule({
73 | declarations: [
74 | ShoppingCartComponent,
75 | StoreFrontComponent
76 | ],
77 | imports: [
78 | HttpModule
79 | ],
80 | providers: [
81 | { provide: ProductsDataService, useClass: MockProductDataService },
82 | { provide: DeliveryOptionsDataService, useValue: sinon.createStubInstance(DeliveryOptionsDataService) },
83 | { provide: StorageService, useClass: LocalStorageServie },
84 | { provide: ShoppingCartService, useClass: MockShoppingCartService }
85 | ]
86 | }).compileComponents();
87 | }));
88 |
89 | it("should create the component", async(() => {
90 | const fixture = TestBed.createComponent(StoreFrontComponent);
91 | const component = fixture.debugElement.componentInstance;
92 | expect(component).toBeTruthy();
93 | }));
94 |
95 | it("should display all the products", async(() => {
96 | const fixture = TestBed.createComponent(StoreFrontComponent);
97 | fixture.detectChanges();
98 |
99 | const component = fixture.debugElement.componentInstance;
100 | const compiled = fixture.debugElement.nativeElement;
101 | const productElements = compiled.querySelectorAll(".product-container");
102 | expect(productElements.length).toEqual(2);
103 |
104 | expect(productElements[0].querySelector(".js-product-name").textContent).toEqual(PRODUCT_1.name);
105 | expect(productElements[0].querySelector(".js-product-price").textContent).toContain(PRODUCT_1.price);
106 | expect(productElements[0].querySelector(".js-product-desc").textContent).toContain(PRODUCT_1.description);
107 |
108 | expect(productElements[1].querySelector(".js-product-name").textContent).toEqual(PRODUCT_2.name);
109 | expect(productElements[1].querySelector(".js-product-price").textContent).toContain(PRODUCT_2.price);
110 | expect(productElements[1].querySelector(".js-product-desc").textContent).toContain(PRODUCT_2.description);
111 | }));
112 |
113 | it("should not display the remove item button when the item is not in the cart", async(() => {
114 | const fixture = TestBed.createComponent(StoreFrontComponent);
115 | fixture.detectChanges();
116 |
117 | const component = fixture.debugElement.componentInstance;
118 | const compiled = fixture.debugElement.nativeElement;
119 | const productElements = compiled.querySelectorAll(".product-container");
120 | expect(productElements.length).toEqual(2);
121 |
122 | expect(productElements[0].querySelector(".js-product-name").textContent).toEqual(PRODUCT_1.name);
123 | expect(productElements[0].querySelector(".js-product-price").textContent).toContain(PRODUCT_1.price);
124 | expect(productElements[0].querySelector(".js-product-desc").textContent).toContain(PRODUCT_1.description);
125 | expect(productElements[0].querySelectorAll(".js-btn-remove").length).toEqual(0);
126 | }));
127 |
128 | it("should add the product to the cart when add item button is clicked",
129 | async(inject([ShoppingCartService], (service: MockShoppingCartService) => {
130 | const fixture = TestBed.createComponent(StoreFrontComponent);
131 | fixture.detectChanges();
132 |
133 | const addItemSpy = sinon.spy(service, "addItem");
134 |
135 | const component = fixture.debugElement.componentInstance;
136 | const compiled = fixture.debugElement.nativeElement;
137 | const productElements = compiled.querySelectorAll(".product-container");
138 |
139 | productElements[0].querySelector(".js-btn-add").click();
140 | sinon.assert.calledOnce(addItemSpy);
141 | sinon.assert.calledWithExactly(addItemSpy, PRODUCT_1, 1);
142 | })));
143 |
144 | it("should remove the product from the cart when remove item button is clicked",
145 | async(inject([ShoppingCartService], (service: MockShoppingCartService) => {
146 | const newCart = new ShoppingCart();
147 | const cartItem = new CartItem();
148 | cartItem.productId = PRODUCT_1.id;
149 | cartItem.quantity = 1;
150 | newCart.grossTotal = 1.5;
151 | newCart.items = [cartItem];
152 | service.dispatchCart(newCart);
153 | const fixture = TestBed.createComponent(StoreFrontComponent);
154 | fixture.detectChanges();
155 |
156 | const addItemSpy = sinon.spy(service, "addItem");
157 |
158 | const component = fixture.debugElement.componentInstance;
159 | const compiled = fixture.debugElement.nativeElement;
160 | const productElements = compiled.querySelectorAll(".product-container");
161 |
162 | productElements[0].querySelector(".js-btn-remove").click();
163 | sinon.assert.calledOnce(addItemSpy);
164 | sinon.assert.calledWithExactly(addItemSpy, PRODUCT_1, -1);
165 | })));
166 | });
167 |
--------------------------------------------------------------------------------
/src/app/services/tests/shopping-cart.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { inject, TestBed } from "@angular/core/testing";
2 | import { HttpModule, XHRBackend } from "@angular/http";
3 | import { MockBackend } from "@angular/http/testing";
4 | import { DeliveryOption } from "app/models/delivery-option.model";
5 | import { Product } from "app/models/product.model";
6 | import { ShoppingCart } from "app/models/shopping-cart.model";
7 | import { DeliveryOptionsDataService } from "app/services/delivery-options.service";
8 | import { ProductsDataService } from "app/services/products.service";
9 | import "rxjs/add/observable/from";
10 | import { Observable } from "rxjs/Observable";
11 | import * as sinon from "sinon";
12 | import { ShoppingCartService } from "../shopping-cart.service";
13 | import { LocalStorageServie, StorageService } from "../storage.service";
14 |
15 | const PRODUCT_1 = new Product();
16 | PRODUCT_1.name = "Product 1";
17 | PRODUCT_1.id = "1";
18 | PRODUCT_1.price = 1;
19 |
20 | const PRODUCT_2 = new Product();
21 | PRODUCT_2.name = "Product 2";
22 | PRODUCT_2.id = "2";
23 | PRODUCT_2.price = 2;
24 |
25 | const DELIVERY_OPT_1 = new DeliveryOption();
26 | DELIVERY_OPT_1.name = "Delivery Option 1";
27 | DELIVERY_OPT_1.id = "1";
28 | DELIVERY_OPT_1.price = 1;
29 |
30 | class MockProductDataService extends ProductsDataService {
31 | public all(): Observable {
32 | return Observable.from([[PRODUCT_1, PRODUCT_2]]);
33 | }
34 | }
35 |
36 | // tslint:disable-next-line:max-classes-per-file
37 | class MockDeliveryOptionsDataService extends DeliveryOptionsDataService {
38 | public all(): Observable {
39 | return Observable.from([[DELIVERY_OPT_1]]);
40 | }
41 | }
42 |
43 | describe("ShoppingCartService", () => {
44 | let sandbox: sinon.SinonSandbox;
45 |
46 | beforeEach(() => {
47 | sandbox = sinon.sandbox.create();
48 |
49 | TestBed.configureTestingModule({
50 | imports: [
51 | HttpModule
52 | ],
53 | providers: [
54 | { provide: ProductsDataService, useClass: MockProductDataService },
55 | { provide: DeliveryOptionsDataService, useClass: MockDeliveryOptionsDataService },
56 | { provide: StorageService, useClass: LocalStorageServie },
57 | ShoppingCartService
58 | ]
59 | });
60 | });
61 |
62 | afterEach(() => {
63 | sandbox.restore();
64 | });
65 |
66 | it("should be injectable", inject([ShoppingCartService], (service: ShoppingCartService) => {
67 | expect(service).toBeTruthy();
68 | }));
69 |
70 | describe("get()", () => {
71 | it("should return an Observable",
72 | inject([ShoppingCartService], (service: ShoppingCartService) => {
73 | const obs = service.get();
74 | expect(obs).toEqual(jasmine.any(Observable));
75 | }));
76 |
77 | it("should return a ShoppingCart model instance when the observable is subscribed to",
78 | inject([ShoppingCartService], (service: ShoppingCartService) => {
79 | const obs = service.get();
80 | obs.subscribe((cart) => {
81 | expect(cart).toEqual(jasmine.any(ShoppingCart));
82 | expect(cart.items.length).toEqual(0);
83 | expect(cart.deliveryOptionId).toBeUndefined();
84 | expect(cart.deliveryTotal).toEqual(0);
85 | expect(cart.itemsTotal).toEqual(0);
86 | expect(cart.grossTotal).toEqual(0);
87 | });
88 | }));
89 |
90 | it("should return a populated ShoppingCart model instance when the observable is subscribed to",
91 | inject([ShoppingCartService], (service: ShoppingCartService) => {
92 | const shoppingCart = new ShoppingCart();
93 | shoppingCart.deliveryOptionId = "deliveryOptionId";
94 | shoppingCart.deliveryTotal = 1;
95 | shoppingCart.itemsTotal = 2;
96 | shoppingCart.grossTotal = 3;
97 | sandbox.stub(localStorage, "getItem")
98 | .returns(JSON.stringify(shoppingCart));
99 |
100 | const obs = service.get();
101 | obs.subscribe((cart) => {
102 | expect(cart).toEqual(jasmine.any(ShoppingCart));
103 | expect(cart.deliveryOptionId).toEqual(shoppingCart.deliveryOptionId);
104 | expect(cart.deliveryTotal).toEqual(shoppingCart.deliveryTotal);
105 | expect(cart.itemsTotal).toEqual(shoppingCart.itemsTotal);
106 | expect(cart.grossTotal).toEqual(shoppingCart.grossTotal);
107 | });
108 | }));
109 | });
110 |
111 | describe("empty()", () => {
112 | it("should create empty cart and persist",
113 | inject([ShoppingCartService], (service: ShoppingCartService) => {
114 |
115 | const stub = sandbox.stub(localStorage, "setItem");
116 | const obs = service.empty();
117 |
118 | sinon.assert.calledOnce(stub);
119 | }));
120 |
121 | it("should dispatch empty cart",
122 | inject([ShoppingCartService], (service: ShoppingCartService) => {
123 | let dispatchCount = 0;
124 |
125 | const shoppingCart = new ShoppingCart();
126 | shoppingCart.grossTotal = 3;
127 | sandbox.stub(localStorage, "getItem")
128 | .returns(JSON.stringify(shoppingCart));
129 |
130 | service.get()
131 | .subscribe((cart) => {
132 | dispatchCount += 1;
133 |
134 | if (dispatchCount === 1) {
135 | expect(cart.grossTotal).toEqual(shoppingCart.grossTotal);
136 | }
137 |
138 | if (dispatchCount === 2) {
139 | expect(cart.grossTotal).toEqual(0);
140 | }
141 | });
142 |
143 | service.empty();
144 | expect(dispatchCount).toEqual(2);
145 | }));
146 | });
147 |
148 | describe("addItem()", () => {
149 | beforeEach(() => {
150 | let persistedCart: string;
151 | const setItemStub = sandbox.stub(localStorage, "setItem")
152 | .callsFake((key, val) => persistedCart = val);
153 | sandbox.stub(localStorage, "getItem")
154 | .callsFake((key) => persistedCart);
155 | });
156 |
157 | it("should add the item to the cart and persist",
158 | inject([ShoppingCartService], (service: ShoppingCartService) => {
159 | service.addItem(PRODUCT_1, 1);
160 |
161 | service.get()
162 | .subscribe((cart) => {
163 | expect(cart.items.length).toEqual(1);
164 | expect(cart.items[0].productId).toEqual(PRODUCT_1.id);
165 | });
166 | }));
167 |
168 | it("should dispatch cart",
169 | inject([ShoppingCartService], (service: ShoppingCartService) => {
170 | let dispatchCount = 0;
171 |
172 | service.get()
173 | .subscribe((cart) => {
174 | dispatchCount += 1;
175 |
176 | if (dispatchCount === 2) {
177 | expect(cart.grossTotal).toEqual(PRODUCT_1.price);
178 | }
179 | });
180 |
181 | service.addItem(PRODUCT_1, 1);
182 | expect(dispatchCount).toEqual(2);
183 | }));
184 |
185 | it("should set the correct quantity on products already added to the cart",
186 | inject([ShoppingCartService], (service: ShoppingCartService) => {
187 | service.addItem(PRODUCT_1, 1);
188 | service.addItem(PRODUCT_1, 3);
189 |
190 | service.get()
191 | .subscribe((cart) => {
192 | expect(cart.items[0].quantity).toEqual(4);
193 | });
194 | }));
195 | });
196 |
197 | describe("setDeliveryOption()", () => {
198 | beforeEach(() => {
199 | let persistedCart: string;
200 | const setItemStub = sandbox.stub(localStorage, "setItem")
201 | .callsFake((key, val) => persistedCart = val);
202 | sandbox.stub(localStorage, "getItem")
203 | .callsFake((key) => persistedCart);
204 | });
205 |
206 | it("should add the delivery option to the cart and persist",
207 | inject([ShoppingCartService], (service: ShoppingCartService) => {
208 | service.setDeliveryOption(DELIVERY_OPT_1);
209 |
210 | service.get()
211 | .subscribe((cart) => {
212 | expect(cart.deliveryOptionId).toEqual(DELIVERY_OPT_1.id);
213 | });
214 | }));
215 |
216 | it("should dispatch cart",
217 | inject([ShoppingCartService], (service: ShoppingCartService) => {
218 | let dispatchCount = 0;
219 |
220 | service.get()
221 | .subscribe((cart) => {
222 | dispatchCount += 1;
223 |
224 | if (dispatchCount === 2) {
225 | expect(cart.deliveryTotal).toEqual(DELIVERY_OPT_1.price);
226 | }
227 | });
228 |
229 | service.setDeliveryOption(DELIVERY_OPT_1);
230 | expect(dispatchCount).toEqual(2);
231 | }));
232 | });
233 |
234 | describe("totals calculation", () => {
235 | beforeEach(() => {
236 | let persistedCart: string;
237 | const setItemStub = sandbox.stub(localStorage, "setItem")
238 | .callsFake((key, val) => persistedCart = val);
239 | sandbox.stub(localStorage, "getItem")
240 | .callsFake((key) => persistedCart);
241 | });
242 |
243 | it("should calculate the shopping cart totals correctly",
244 | inject([ShoppingCartService], (service: ShoppingCartService) => {
245 | service.addItem(PRODUCT_1, 2);
246 | service.addItem(PRODUCT_2, 1);
247 | service.addItem(PRODUCT_1, 1);
248 | service.setDeliveryOption(DELIVERY_OPT_1);
249 |
250 | service.get()
251 | .subscribe((cart) => {
252 | expect(cart.items.length).toEqual(2);
253 | expect(cart.deliveryTotal).toEqual(DELIVERY_OPT_1.price);
254 | expect(cart.itemsTotal).toEqual((PRODUCT_1.price * 3) + PRODUCT_2.price);
255 | expect(cart.grossTotal).toEqual((PRODUCT_1.price * 3) + PRODUCT_2.price + DELIVERY_OPT_1.price);
256 | });
257 | }));
258 | });
259 | });
260 |
--------------------------------------------------------------------------------