diff --git a/controllers/base.ts b/controllers/base.ts new file mode 100644 index 00000000..4f00760e --- /dev/null +++ b/controllers/base.ts @@ -0,0 +1,68 @@ +abstract class BaseCtrl { + + abstract model: any; + + // Get all + getAll = async (req, res) => { + try { + console.log('GET ALL') + const all = await this.model.find({}); + res.status(200).json(all); + } catch (err) { + return res.status(400).json({ error: err.message }); + } + } + + // Count all + count = async (req, res) => { + try { + const count = await this.model.count(); + res.status(200).json(count); + } catch (err) { + return res.status(400).json({ error: err.message }); + } + } + + // Insert + insert = async (req, res) => { + try { + console.log('show US THIS DAMN REQ BODY '+req.body) + const obj = await new this.model(req.body).save(); + res.status(201).json(obj); + } catch (err) { + return res.status(400).json({ error: err.message }); + } + } + + // Get by id + get = async (req, res) => { + try { + const obj = await this.model.findOne({ _id: req.params.id }); + res.status(200).json(obj); + } catch (err) { + return res.status(500).json({ error: err.message }); + } + } + + // Update by id + update = async (req, res) => { + try { + await this.model.findOneAndUpdate({ _id: req.params.id }, req.body); + res.sendStatus(200); + } catch (err) { + return res.status(400).json({ error: err.message }); + } + } + + // Delete by id + delete = async (req, res) => { + try { + await this.model.findOneAndRemove({ _id: req.params.id }); + res.sendStatus(200); + } catch (err) { + return res.status(400).json({ error: err.message }); + } + } + } + + export default BaseCtrl; \ No newline at end of file diff --git a/controllers/cat.ts b/controllers/cat.ts new file mode 100644 index 00000000..b9166660 --- /dev/null +++ b/controllers/cat.ts @@ -0,0 +1,8 @@ +import Cat from '../models/cat'; +import BaseCtrl from './base'; + +class CatCtrl extends BaseCtrl { + model = Cat; +} + +export default CatCtrl; \ No newline at end of file diff --git a/controllers/user.ts b/controllers/user.ts new file mode 100644 index 00000000..fe27b738 --- /dev/null +++ b/controllers/user.ts @@ -0,0 +1,22 @@ +import * as jwt from 'jsonwebtoken'; + +import User from '../models/user'; +import BaseCtrl from './base'; + +class UserCtrl extends BaseCtrl { + model = User; + + login = (req, res) => { + this.model.findOne({ email: req.body.email }, (err, user) => { + if (!user) { return res.sendStatus(403); } + user.comparePassword(req.body.password, (error, isMatch) => { + if (!isMatch) { return res.sendStatus(403); } + const token = jwt.sign({ user }, process.env.SECRET_TOKEN); // , { expiresIn: 10 } seconds + res.status(200).json({ token }); + }); + }); + } + +} + +export default UserCtrl; \ No newline at end of file diff --git a/models/cat.ts b/models/cat.ts new file mode 100644 index 00000000..6e9cebef --- /dev/null +++ b/models/cat.ts @@ -0,0 +1,11 @@ +import * as mongoose from 'mongoose'; + +const catSchema = new mongoose.Schema({ + name: String, + weight: Number, + age: Number +}); + +const Cat = mongoose.model('Cat', catSchema); + +export default Cat; \ No newline at end of file diff --git a/models/user.ts b/models/user.ts new file mode 100644 index 00000000..1bb16550 --- /dev/null +++ b/models/user.ts @@ -0,0 +1,42 @@ +import * as bcrypt from 'bcryptjs'; +import * as mongoose from 'mongoose'; + +const userSchema = new mongoose.Schema({ + username: String, + email: { type: String, unique: true, lowercase: true, trim: true }, + password: String, + role: String +}); + +// Before saving the user, hash the password +userSchema.pre('save', function(next): void { + const user = this; + if (!user.isModified('password')) { return next(); } + bcrypt.genSalt(10, (err, salt) => { + if (err) { return next(err); } + bcrypt.hash(user.password, salt, (error, hash) => { + if (error) { return next(error); } + user.password = hash; + next(); + }); + }); +}); + +userSchema.methods.comparePassword = function(candidatePassword, callback): void { + bcrypt.compare(candidatePassword, this.password, (err, isMatch) => { + if (err) { return callback(err); } + callback(null, isMatch); + }); +}; + +// Omit the password when returning a user +userSchema.set('toJSON', { + transform: (doc, ret, options) => { + delete ret.password; + return ret; + } +}); + +const User = mongoose.model('User', userSchema); + +export default User; \ No newline at end of file diff --git a/mongo.ts b/mongo.ts new file mode 100644 index 00000000..b5f0ec5e --- /dev/null +++ b/mongo.ts @@ -0,0 +1,15 @@ +import * as mongoose from 'mongoose'; + +async function setMongo() { + const mongodbURI = 'mongodb://localhost:27017/angularfullstack' + mongoose.Promise = global.Promise; + mongoose.set('useCreateIndex', true); + mongoose.set('useNewUrlParser', true); + mongoose.set('useFindAndModify', false); + mongoose.set('useUnifiedTopology', true); + // Connect to MongoDB using Mongoose + await mongoose.connect(mongodbURI); + console.log('Connected to MongoDB'); +} + +export default setMongo; \ No newline at end of file diff --git a/routes.ts b/routes.ts new file mode 100644 index 00000000..95fa1494 --- /dev/null +++ b/routes.ts @@ -0,0 +1,24 @@ +import * as express from 'express'; + +import CatCtrl from './controllers/cat'; +import UserCtrl from './controllers/user'; +function setRoutes(app) { + const router = express.Router(); + const catCtrl = new CatCtrl(); + const userCtrl = new UserCtrl(); + + // Cats + router.route('/cats').get(catCtrl.getAll); + + // Users + router.route('/login').post(userCtrl.login); + router.route('/users').get(userCtrl.getAll); + router.route('/user').post(userCtrl.insert); + router.route('/user/:id').get(userCtrl.get); + + // Apply the routes to our application with the prefix /api + app.use('/api', router); + +} + +export default setRoutes; \ No newline at end of file diff --git a/server.ts b/server.ts new file mode 100644 index 00000000..f773c644 --- /dev/null +++ b/server.ts @@ -0,0 +1,84 @@ +import 'zone.js/dist/zone-node'; + +import { ngExpressEngine } from '@nguniversal/express-engine'; +import * as express from 'express'; +import { join } from 'path'; + +import { AppServerModule } from './src/main.server'; +import { APP_BASE_HREF } from '@angular/common'; +import { existsSync } from 'fs'; + +import setRoutes from './routes'; +import setMongo from './mongo'; +import * as dotenv from 'dotenv'; +// add to use POST ROUTES +const bodyParser = require("body-parser"); +// The Express app is exported so that it can be used by serverless Functions. +export function app() { + const server = express(); + dotenv.config(); + const distFolder = join(process.cwd(), 'dist/browser'); + const indexHtml = existsSync(join(distFolder, 'index.original.html')) + ? 'index.original.html' + : 'index'; + + // Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine) + server.engine( + 'html', + ngExpressEngine({ + bootstrap: AppServerModule, + }) + ); + + server.set('view engine', 'html'); + server.set('views', distFolder); + server.use(bodyParser.urlencoded({ extended: false })) + server.use(bodyParser.json()) + // TODO: implement data requests securely + /* server.get('/api/**', (req, res) => { + res.status(404).send('data requests are not yet supported'); + });*/ + setMongo(); + setRoutes(server) + + // Serve static files from /browser + server.get( + '*.*', + express.static(distFolder, { + maxAge: '1y', + }) + ); + + // All regular routes use the Universal engine + server.get('*', (req, res) => { + console.log(req.url); + res.render(indexHtml, { + req, + providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }], + }); + }); + + return server; +} + +function run() { + const port = process.env.PORT || 4000; + + // Start up the Node server + const server = app(); + server.listen(port, () => { + console.log(`Node Express server listening on http://localhost:${port}`); + }); +} + +// Webpack will replace 'require' with '__webpack_require__' +// '__non_webpack_require__' is a proxy to Node 'require' +// The below code is to ensure that the server is run only when not requiring the bundle. +declare const __non_webpack_require__: NodeRequire; +const mainModule = __non_webpack_require__.main; +const moduleFilename = (mainModule && mainModule.filename) || ''; +if (moduleFilename === __filename || moduleFilename.includes('iisnode')) { + run(); +} + +export * from './src/main.server'; diff --git a/src/app/about/about.component.html b/src/app/about/about.component.html new file mode 100644 index 00000000..b6f0bdb6 --- /dev/null +++ b/src/app/about/about.component.html @@ -0,0 +1,53 @@ +
+

About

+
+ +
+
\ No newline at end of file diff --git a/src/app/about/about.component.scss b/src/app/about/about.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/about/about.component.spec.ts b/src/app/about/about.component.spec.ts new file mode 100644 index 00000000..c5102a05 --- /dev/null +++ b/src/app/about/about.component.spec.ts @@ -0,0 +1,32 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { AboutComponent } from './about.component'; + +describe('Component: About', () => { + let component: AboutComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ AboutComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AboutComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display the page header text', () => { + const el = fixture.debugElement.query(By.css('h4')).nativeElement; + expect(el.textContent).toContain('About'); + }); + +}); \ No newline at end of file diff --git a/src/app/about/about.component.ts b/src/app/about/about.component.ts new file mode 100644 index 00000000..9c7ea801 --- /dev/null +++ b/src/app/about/about.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-about', + templateUrl: './about.component.html', + styleUrls: ['./about.component.scss'] +}) +export class AboutComponent { + + constructor() { } + +} \ No newline at end of file diff --git a/src/app/account/account.component.html b/src/app/account/account.component.html new file mode 100644 index 00000000..26c1e7f0 --- /dev/null +++ b/src/app/account/account.component.html @@ -0,0 +1,44 @@ + + + + +
+

Account settings

+
+
+
+
+ + + +
+ +
+
+
+ + + +
+ +
+
+
+ + + +
+ +
+ +
+
+
\ No newline at end of file diff --git a/src/app/account/account.component.spec.ts b/src/app/account/account.component.spec.ts new file mode 100644 index 00000000..c219f541 --- /dev/null +++ b/src/app/account/account.component.spec.ts @@ -0,0 +1,74 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { FormsModule } from '@angular/forms'; + +import { ToastComponent } from '../shared/toast/toast.component'; +import { AuthService } from '../services/auth.service'; +import { UserService } from '../services/user.service'; +import { AccountComponent } from './account.component'; +import { of, Observable } from 'rxjs'; + +class AuthServiceMock { } + +class UserServiceMock { + mockUser = { + username: 'Test user', + email: 'test@example.com', + role: 'user' + }; + getUser(): Observable { + return of(this.mockUser); + } +} + +describe('Component: Account', () => { + let component: AccountComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ FormsModule ], + declarations: [ AccountComponent ], + providers: [ + ToastComponent, + { provide: AuthService, useClass: AuthServiceMock }, + { provide: UserService, useClass: UserServiceMock }, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AccountComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + component.user = { + username: 'Test user', + email: 'test@example.com' + }; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display the page header text', () => { + const el = fixture.debugElement.query(By.css('h4')).nativeElement; + expect(el.textContent).toContain('Account settings'); + }); + + it('should display the username and email inputs filled', async () => { + await fixture.whenStable(); + const [usernameInput, emailInput] = fixture.debugElement.queryAll(By.css('input')); + expect(usernameInput.nativeElement.value).toContain('Test user'); + expect(emailInput.nativeElement.value).toContain('test@example.com'); + }); + + it('should display the save button enabled', () => { + const saveBtn = fixture.debugElement.query(By.css('button')).nativeElement; + expect(saveBtn).toBeTruthy(); + expect(saveBtn.disabled).toBeFalsy(); + }); + +}); \ No newline at end of file diff --git a/src/app/account/account.component.ts b/src/app/account/account.component.ts new file mode 100644 index 00000000..e1589f4e --- /dev/null +++ b/src/app/account/account.component.ts @@ -0,0 +1,43 @@ +import { Component, OnInit } from '@angular/core'; +import { ToastComponent } from '../shared/toast/toast.component'; +import { AuthService } from '../services/auth.service'; +import { UserService } from '../services/user.service'; +import { User } from '../shared/models/user.model'; + +@Component({ + selector: 'app-account', + templateUrl: './account.component.html' +}) +export class AccountComponent implements OnInit { + + user: User; + isLoading = true; + + constructor(private auth: AuthService, + public toast: ToastComponent, + private userService: UserService) { } + + ngOnInit(): void { + this.getUser(); + } + + getUser(): void { + this.userService.getUser(this.auth.currentUser).subscribe( + data => this.user = data, + error => console.log(error), + () => this.isLoading = false + ); + } + + save(user: User): void { + this.userService.editUser(user).subscribe( + res => { + this.toast.setMessage('account settings saved!', 'success'); + this.auth.currentUser = user; + this.auth.isAdmin = user.role === 'admin'; + }, + error => console.log(error) + ); + } + +} \ No newline at end of file diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts new file mode 100644 index 00000000..966d4c61 --- /dev/null +++ b/src/app/app-routing.module.ts @@ -0,0 +1,34 @@ +// Angular +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +// Services +import { AuthGuardLogin } from './services/auth-guard-login.service'; +// Components +import { CatsComponent } from './cats/cats.component'; +import { AboutComponent } from './about/about.component'; +import { RegisterComponent } from './register/register.component'; +import { LoginComponent } from './login/login.component'; +import { LogoutComponent } from './logout/logout.component'; +import { AccountComponent } from './account/account.component'; +import { NotFoundComponent } from './not-found/not-found.component'; + + +const routes: Routes = [ + // { path: '', redirectTo: '/dashboard', pathMatch: 'full' }, + { path: '', component: AboutComponent }, + { path: 'cats', component: CatsComponent }, + { path: 'register', component: RegisterComponent }, + { path: 'login', component: LoginComponent }, + { path: 'logout', component: LogoutComponent }, + { path: 'account', component: AccountComponent, canActivate: [AuthGuardLogin] }, + + { path: 'notfound', component: NotFoundComponent }, + { path: '**', redirectTo: '/notfound' }, + +]; + +@NgModule({ + imports: [ RouterModule.forRoot(routes) ], + exports: [ RouterModule ] +}) +export class AppRoutingModule {} diff --git a/src/app/app.component.css b/src/app/app.component.css new file mode 100644 index 00000000..c1d87212 --- /dev/null +++ b/src/app/app.component.css @@ -0,0 +1,28 @@ +/* AppComponent's private CSS styles */ +h1 { + font-size: 1.2em; + margin-bottom: 0; +} +h2 { + font-size: 2em; + margin-top: 0; + padding-top: 0; +} +nav a { + padding: 5px 10px; + text-decoration: none; + margin-top: 10px; + display: inline-block; + background-color: #eee; + border-radius: 4px; +} +nav a:visited, a:link { + color: #334953; +} +nav a:hover { + color: #039be5; + background-color: #CFD8DC; +} +nav a.active { + color: #039be5; +} diff --git a/src/app/app.component.html b/src/app/app.component.html new file mode 100644 index 00000000..39ea9551 --- /dev/null +++ b/src/app/app.component.html @@ -0,0 +1,52 @@ + diff --git a/src/app/app.component.ts b/src/app/app.component.ts new file mode 100644 index 00000000..4dbb05c2 --- /dev/null +++ b/src/app/app.component.ts @@ -0,0 +1,19 @@ + +import { AfterViewChecked, ChangeDetectorRef, Component } from '@angular/core'; +import { AuthService } from './services/auth.service'; + +@Component({ + selector: 'app-root', + templateUrl: './app.component.html' +}) +export class AppComponent implements AfterViewChecked { + + constructor(public auth: AuthService, + private changeDetector: ChangeDetectorRef) { } + + // This fixes: https://github.com/DavideViolante/Angular-Full-Stack/issues/105 + ngAfterViewChecked() { + this.changeDetector.detectChanges(); + } + +} \ No newline at end of file diff --git a/src/app/app.module.ts b/src/app/app.module.ts new file mode 100644 index 00000000..c0868a41 --- /dev/null +++ b/src/app/app.module.ts @@ -0,0 +1,77 @@ +// @angular +import { NgModule , CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { FormsModule } from '@angular/forms'; +import { HttpClientModule } from '@angular/common/http'; +import { PLATFORM_ID, APP_ID, Inject } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { JwtModule } from '@auth0/angular-jwt'; + +// module +import { AppRoutingModule } from './app-routing.module'; +import { SharedModule } from './shared/shared.module'; + +// components +import { AppComponent } from './app.component'; +import { CatsComponent } from './cats/cats.component'; +import { AboutComponent } from './about/about.component'; +import { RegisterComponent } from './register/register.component'; +import { LoginComponent } from './login/login.component'; +import { LogoutComponent } from './logout/logout.component'; +import { AccountComponent } from './account/account.component'; +import { NotFoundComponent } from './not-found/not-found.component'; +// Services +import { CatService } from './services/cat.service'; +import { UserService } from './services/user.service'; +import { AuthService } from './services/auth.service'; +import { AuthGuardLogin } from './services/auth-guard-login.service'; + +// nebular +import { NbThemeModule } from '@nebular/theme'; + +@NgModule({ + imports: [ + BrowserModule.withServerTransition({ appId: 'tour-of-heroes' }), + FormsModule, + AppRoutingModule, + HttpClientModule, + SharedModule, + JwtModule.forRoot({ + config: { + tokenGetter: (): string => localStorage.getItem('token'), + // whitelistedDomains: ['localhost:3000', 'localhost:4200'] + } + }), + NbThemeModule.forRoot() + // The HttpClientInMemoryWebApiModule module intercepts HTTP requests + // and returns simulated server responses. + // Remove it when a real server is ready to receive requests. + + ], + declarations: [ + AppComponent, + CatsComponent, + AboutComponent, + RegisterComponent, + LoginComponent, + LogoutComponent, + AccountComponent, + NotFoundComponent + ], + providers : [ AuthService, + AuthGuardLogin, + CatService, + UserService], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + bootstrap: [ AppComponent ] + +}) +export class AppModule { + constructor( + @Inject(PLATFORM_ID) private platformId: Object, + @Inject(APP_ID) private appId: string) { + const platform = isPlatformBrowser(platformId) ? + 'in the browser' : 'on the server'; + console.log(`Running ${platform} with appId=${appId}`); + } +} diff --git a/src/app/app.server.module.ts b/src/app/app.server.module.ts new file mode 100644 index 00000000..a2cdc20c --- /dev/null +++ b/src/app/app.server.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { ServerModule } from '@angular/platform-server'; + +import { AppModule } from './app.module'; +import { AppComponent } from './app.component'; + +@NgModule({ + imports: [ + AppModule, + ServerModule, + ], + providers: [ + // Add server-only providers here. + ], + bootstrap: [AppComponent], +}) +export class AppServerModule {} diff --git a/src/app/cats/cats.component.html b/src/app/cats/cats.component.html new file mode 100644 index 00000000..8aeb13a8 --- /dev/null +++ b/src/app/cats/cats.component.html @@ -0,0 +1,61 @@ + + + + +
+

Current cats ({{cats.length}})

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameAgeWeightActions
There are no cats in the DB. Add a new cat below.
{{cat.name}}{{cat.age}}{{cat.weight}} + + +
+
+ + + + + +
+
+
+
+ + diff --git a/src/app/cats/cats.component.scss b/src/app/cats/cats.component.scss new file mode 100644 index 00000000..c75b098a --- /dev/null +++ b/src/app/cats/cats.component.scss @@ -0,0 +1,6 @@ +.table { + td, + th { + width: 25%; + } + } \ No newline at end of file diff --git a/src/app/cats/cats.component.spec.ts b/src/app/cats/cats.component.spec.ts new file mode 100644 index 00000000..19211e92 --- /dev/null +++ b/src/app/cats/cats.component.spec.ts @@ -0,0 +1,103 @@ +import { TestBed, async, ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { FormsModule, FormBuilder, ReactiveFormsModule } from '@angular/forms'; + +import { ToastComponent } from '../shared/toast/toast.component'; +import { CatService } from '../services/cat.service'; +import { CatsComponent } from './cats.component'; +import { of } from 'rxjs'; + +class CatServiceMock { + mockCats = [ + { name: 'Cat 1', age: 1, weight: 2 }, + { name: 'Cat 2', age: 3, weight: 4.2 }, + ]; + getCats() { + return of(this.mockCats); + } +} + +describe('Component: Cats', () => { + let component: CatsComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ FormsModule, ReactiveFormsModule ], + declarations: [ CatsComponent ], + providers: [ + ToastComponent, FormBuilder, + { provide: CatService, useClass: CatServiceMock } + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CatsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display the page header text', () => { + const el = fixture.debugElement.query(By.css('h4')).nativeElement; + expect(el.textContent).toContain('Current cats (2)'); + }); + + it('should display the text for no cats', () => { + component.cats = []; + fixture.detectChanges(); + const headerEl = fixture.debugElement.query(By.css('h4')).nativeElement; + expect(headerEl.textContent).toContain('Current cats (0)'); + const tdEl = fixture.debugElement.query(By.css('td')).nativeElement; + expect(tdEl.textContent).toContain('There are no cats in the DB. Add a new cat below.'); + }); + + it('should display current cats', () => { + const tds = fixture.debugElement.queryAll(By.css('td')); + expect(tds.length).toBe(8); + expect(tds[0].nativeElement.textContent).toContain('Cat 1'); + expect(tds[1].nativeElement.textContent).toContain('1'); + expect(tds[2].nativeElement.textContent).toContain('2'); + expect(tds[4].nativeElement.textContent).toContain('Cat 2'); + expect(tds[5].nativeElement.textContent).toContain('3'); + expect(tds[6].nativeElement.textContent).toContain('4.2'); + }); + + it('should display the edit and delete buttons', () => { + const [btnEdit1, btnDelete1, btnEdit2, btnDelete2] = fixture.debugElement.queryAll(By.css('button')); + expect(btnEdit1.nativeElement).toBeTruthy(); + expect(btnEdit1.nativeElement.textContent).toContain('Edit'); + expect(btnDelete1.nativeElement).toBeTruthy(); + expect(btnDelete1.nativeElement.textContent).toContain('Delete'); + expect(btnEdit2.nativeElement).toBeTruthy(); + expect(btnEdit2.nativeElement.textContent).toContain('Edit'); + expect(btnDelete2.nativeElement).toBeTruthy(); + expect(btnDelete2.nativeElement.textContent).toContain('Delete'); + }); + + it('should display the edit form', async () => { + component.isEditing = true; + component.cat = { name: 'Cat 1', age: 1, weight: 2 }; + fixture.detectChanges(); + await fixture.whenStable(); + const tds = fixture.debugElement.queryAll(By.css('td')); + expect(tds.length).toBe(1); + const formEl = fixture.debugElement.query(By.css('form')).nativeElement; + expect(formEl).toBeTruthy(); + const [inputName, inputAge, inputWeight] = fixture.debugElement.queryAll(By.css('input')); + expect(inputName.nativeElement.value).toContain('Cat 1'); + expect(inputAge.nativeElement.value).toContain('1'); + expect(inputWeight.nativeElement.value).toContain('2'); + const [btnSave, btnCancel] = fixture.debugElement.queryAll(By.css('button')); + expect(btnSave.nativeElement).toBeTruthy(); + expect(btnSave.nativeElement.textContent).toContain('Save'); + expect(btnCancel.nativeElement).toBeTruthy(); + expect(btnCancel.nativeElement.textContent).toContain('Cancel'); + }); + +}); \ No newline at end of file diff --git a/src/app/cats/cats.component.ts b/src/app/cats/cats.component.ts new file mode 100644 index 00000000..f9efe512 --- /dev/null +++ b/src/app/cats/cats.component.ts @@ -0,0 +1,71 @@ +import { Component, OnInit } from '@angular/core'; + +import { CatService } from '../services/cat.service'; +import { ToastComponent } from '../shared/toast/toast.component'; +import { Cat } from '../shared/models/cat.model'; + +@Component({ + selector: 'app-cats', + templateUrl: './cats.component.html', + styleUrls: ['./cats.component.scss'] +}) +export class CatsComponent implements OnInit { + + cat = new Cat(); + cats: Cat[] = []; + isLoading = true; + isEditing = false; + + + constructor(private catService: CatService, + public toast: ToastComponent) { } + + ngOnInit() { + this.getCats(); + } + + getCats() { + this.catService.getCats().subscribe( + data => this.cats = data, + error => console.log(error), + () => this.isLoading = false + ); + } + + enableEditing(cat: Cat) { + this.isEditing = true; + this.cat = cat; + } + + cancelEditing() { + this.isEditing = false; + this.cat = new Cat(); + this.toast.setMessage('item editing cancelled.', 'warning'); + // reload the cats to reset the editing + this.getCats(); + } + + editCat(cat: Cat) { + this.catService.editCat(cat).subscribe( + () => { + this.isEditing = false; + this.cat = cat; + this.toast.setMessage('item edited successfully.', 'success'); + }, + error => console.log(error) + ); + } + + deleteCat(cat: Cat) { + if (window.confirm('Are you sure you want to permanently delete this item?')) { + this.catService.deleteCat(cat).subscribe( + () => { + this.cats = this.cats.filter(elem => elem._id !== cat._id); + this.toast.setMessage('item deleted successfully.', 'success'); + }, + error => console.log(error) + ); + } + } + +} \ No newline at end of file diff --git a/src/app/login/login.component.html b/src/app/login/login.component.html new file mode 100644 index 00000000..b4b84ca0 --- /dev/null +++ b/src/app/login/login.component.html @@ -0,0 +1,30 @@ + + +
+

Login

+
+
+
+
+ + + +
+ +
+
+
+ + + +
+ +
+ +
+
+
\ No newline at end of file diff --git a/src/app/login/login.component.spec.ts b/src/app/login/login.component.spec.ts new file mode 100644 index 00000000..3ec40144 --- /dev/null +++ b/src/app/login/login.component.spec.ts @@ -0,0 +1,60 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { FormsModule, FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; + +import { ToastComponent } from '../shared/toast/toast.component'; +import { AuthService } from '../services/auth.service'; +import { LoginComponent } from './login.component'; + +class AuthServiceMock { } +class RouterMock { } + +describe('Component: Login', () => { + let component: LoginComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ FormsModule, ReactiveFormsModule ], + declarations: [ LoginComponent ], + providers: [ + FormBuilder, ToastComponent, + { provide: Router, useClass: RouterMock }, + { provide: AuthService, useClass: AuthServiceMock } + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LoginComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display the page header text', () => { + const el = fixture.debugElement.query(By.css('h4')).nativeElement; + expect(el.textContent).toContain('Login'); + }); + + it('should display the username and password inputs', () => { + const [inputUsername, inputPassword] = fixture.debugElement.queryAll(By.css('input')); + expect(inputUsername.nativeElement).toBeTruthy(); + expect(inputPassword.nativeElement).toBeTruthy(); + expect(inputUsername.nativeElement.value).toBeFalsy(); + expect(inputPassword.nativeElement.value).toBeFalsy(); + }); + + it('should display the login button', () => { + const el = fixture.debugElement.query(By.css('button')).nativeElement; + expect(el).toBeTruthy(); + expect(el.textContent).toContain('Login'); + expect(el.disabled).toBeTruthy(); + }); + +}); \ No newline at end of file diff --git a/src/app/login/login.component.ts b/src/app/login/login.component.ts new file mode 100644 index 00000000..811e8e60 --- /dev/null +++ b/src/app/login/login.component.ts @@ -0,0 +1,52 @@ +import { Component, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; + +import { AuthService } from '../services/auth.service'; +import { ToastComponent } from '../shared/toast/toast.component'; + +@Component({ + selector: 'app-login', + templateUrl: './login.component.html' +}) +export class LoginComponent implements OnInit { + + loginForm: FormGroup; + email = new FormControl('', [ + Validators.required, + Validators.minLength(3), + Validators.maxLength(100) + ]); + password = new FormControl('', [ + Validators.required, + Validators.minLength(6) + ]); + + constructor(private auth: AuthService, + private formBuilder: FormBuilder, + private router: Router, + public toast: ToastComponent) { } + + ngOnInit(): void { + if (this.auth.loggedIn) { + this.router.navigate(['/']); + } + this.loginForm = this.formBuilder.group({ + email: this.email, + password: this.password + }); + } + + setClassEmail(): object { + return { 'has-danger': !this.email.pristine && !this.email.valid }; + } + + setClassPassword(): object { + return { 'has-danger': !this.password.pristine && !this.password.valid }; + } + + login(): void { + this.auth.login(this.loginForm.value); + } + +} \ No newline at end of file diff --git a/src/app/logout/logout.component.spec.ts b/src/app/logout/logout.component.spec.ts new file mode 100644 index 00000000..21e2acd6 --- /dev/null +++ b/src/app/logout/logout.component.spec.ts @@ -0,0 +1,44 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AuthService } from '../services/auth.service'; +import { LogoutComponent } from './logout.component'; + +class AuthServiceMock { + loggedIn = true; + logout(): void { + this.loggedIn = false; + } +} + +describe('Component: Logout', () => { + let component: LogoutComponent; + let fixture: ComponentFixture; + let authService: AuthService; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ LogoutComponent ], + providers: [ { provide: AuthService, useClass: AuthServiceMock } ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LogoutComponent); + component = fixture.componentInstance; + authService = fixture.debugElement.injector.get(AuthService); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should logout the user', () => { + authService.loggedIn = true; + expect(authService.loggedIn).toBeTruthy(); + authService.logout(); + expect(authService.loggedIn).toBeFalsy(); + }); + +}); \ No newline at end of file diff --git a/src/app/logout/logout.component.ts b/src/app/logout/logout.component.ts new file mode 100644 index 00000000..999ffd3f --- /dev/null +++ b/src/app/logout/logout.component.ts @@ -0,0 +1,16 @@ +import { Component, OnInit } from '@angular/core'; +import { AuthService } from '../services/auth.service'; + +@Component({ + selector: 'app-logout', + template: '' +}) +export class LogoutComponent implements OnInit { + + constructor(private auth: AuthService) { } + + ngOnInit(): void { + this.auth.logout(); + } + +} \ No newline at end of file diff --git a/src/app/not-found/not-found.component.html b/src/app/not-found/not-found.component.html new file mode 100644 index 00000000..8f73d21e --- /dev/null +++ b/src/app/not-found/not-found.component.html @@ -0,0 +1,7 @@ +
+

404 Not Found

+
+

The page you requested was not found.

+

Go to Homepage.

+
+
\ No newline at end of file diff --git a/src/app/not-found/not-found.component.spec.ts b/src/app/not-found/not-found.component.spec.ts new file mode 100644 index 00000000..24424de0 --- /dev/null +++ b/src/app/not-found/not-found.component.spec.ts @@ -0,0 +1,38 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { NotFoundComponent } from './not-found.component'; + +describe('Component: NotFound', () => { + let component: NotFoundComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ NotFoundComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(NotFoundComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display the page header text', () => { + const el = fixture.debugElement.query(By.css('h4')).nativeElement; + expect(el.textContent).toContain('404 Not Found'); + }); + + it('should display the link for homepage', () => { + const el = fixture.debugElement.query(By.css('a')).nativeElement; + expect(el.getAttribute('routerLink')).toBe('/'); + expect(el.textContent).toContain('Homepage'); + }); + +}); \ No newline at end of file diff --git a/src/app/not-found/not-found.component.ts b/src/app/not-found/not-found.component.ts new file mode 100644 index 00000000..0f0a5ab1 --- /dev/null +++ b/src/app/not-found/not-found.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-not-found', + templateUrl: './not-found.component.html' +}) +export class NotFoundComponent { + + constructor() { } + +} \ No newline at end of file diff --git a/src/app/register/register.component.html b/src/app/register/register.component.html new file mode 100644 index 00000000..021515b7 --- /dev/null +++ b/src/app/register/register.component.html @@ -0,0 +1,51 @@ + + +
+

Register

+
+
+
+
+ + + +
+ +
+
+
+ + + +
+ +
+
+
+ + + +
+ +
+
+
+ + + +
+ +
+ +
+
+
\ No newline at end of file diff --git a/src/app/register/register.component.spec.ts b/src/app/register/register.component.spec.ts new file mode 100644 index 00000000..084923b7 --- /dev/null +++ b/src/app/register/register.component.spec.ts @@ -0,0 +1,62 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; + +import { ToastComponent } from '../shared/toast/toast.component'; +import { UserService } from '../services/user.service'; +import { RegisterComponent } from './register.component'; + +class RouterMock { } +class UserServiceMock { } + +describe('Component: Register', () => { + let component: RegisterComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ FormsModule, ReactiveFormsModule ], + declarations: [ RegisterComponent ], + providers: [ + ToastComponent, + { provide: Router, useClass: RouterMock }, + { provide: UserService, useClass: UserServiceMock } + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(RegisterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display the page header text', () => { + const el = fixture.debugElement.query(By.css('h4')).nativeElement; + expect(el.textContent).toContain('Register'); + }); + + it('should display the username, email and password inputs', () => { + const [inputUsername, inputEmail, inputPassword] = fixture.debugElement.queryAll(By.css('input')); + expect(inputUsername.nativeElement).toBeTruthy(); + expect(inputEmail.nativeElement).toBeTruthy(); + expect(inputPassword.nativeElement).toBeTruthy(); + expect(inputUsername.nativeElement.value).toBeFalsy(); + expect(inputEmail.nativeElement.value).toBeFalsy(); + expect(inputPassword.nativeElement.value).toBeFalsy(); + }); + + it('should display the register button', () => { + const el = fixture.debugElement.query(By.css('button')).nativeElement; + expect(el).toBeTruthy(); + expect(el.textContent).toContain('Register'); + expect(el.disabled).toBeTruthy(); + }); + +}); \ No newline at end of file diff --git a/src/app/register/register.component.ts b/src/app/register/register.component.ts new file mode 100644 index 00000000..d8e3e68e --- /dev/null +++ b/src/app/register/register.component.ts @@ -0,0 +1,69 @@ +import { Component, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; + +import { UserService } from '../services/user.service'; +import { ToastComponent } from '../shared/toast/toast.component'; + +@Component({ + selector: 'app-register', + templateUrl: './register.component.html' +}) +export class RegisterComponent implements OnInit { + + registerForm: FormGroup; + username = new FormControl('', [ + Validators.required, + Validators.minLength(2), + Validators.maxLength(30), + Validators.pattern('[a-zA-Z0-9_-\\s]*') + ]); + email = new FormControl('', [ + Validators.required, + Validators.minLength(3), + Validators.maxLength(100) + ]); + password = new FormControl('', [ + Validators.required, + Validators.minLength(6) + ]); + role = new FormControl('', [ + Validators.required + ]); + + constructor(private formBuilder: FormBuilder, + private router: Router, + public toast: ToastComponent, + private userService: UserService) { } + + ngOnInit(): void { + this.registerForm = this.formBuilder.group({ + username: this.username, + email: this.email, + password: this.password, + role: this.role + }); + } + + setClassUsername(): object { + return { 'has-danger': !this.username.pristine && !this.username.valid }; + } + + setClassEmail(): object { + return { 'has-danger': !this.email.pristine && !this.email.valid }; + } + + setClassPassword(): object { + return { 'has-danger': !this.password.pristine && !this.password.valid }; + } + + register(): void { + this.userService.register(this.registerForm.value).subscribe( + res => { + this.toast.setMessage('you successfully registered!', 'success'); + this.router.navigate(['/login']); + }, + error => this.toast.setMessage('email already exists', 'danger') + ); + } +} \ No newline at end of file diff --git a/src/app/services/auth-guard-login.service.ts b/src/app/services/auth-guard-login.service.ts new file mode 100644 index 00000000..04809307 --- /dev/null +++ b/src/app/services/auth-guard-login.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@angular/core'; +import { CanActivate } from '@angular/router'; +import { AuthService } from './auth.service'; + +@Injectable() +export class AuthGuardLogin implements CanActivate { + + constructor(public auth: AuthService) {} + + canActivate(): boolean { + return this.auth.loggedIn; + } + +} \ No newline at end of file diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts new file mode 100644 index 00000000..ee6b6ae2 --- /dev/null +++ b/src/app/services/auth.service.ts @@ -0,0 +1,67 @@ +import { Injectable, Inject, PLATFORM_ID } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { Router } from '@angular/router'; + +import { JwtHelperService } from '@auth0/angular-jwt'; + +import { UserService } from './user.service'; +import { ToastComponent } from '../shared/toast/toast.component'; +import { User } from '../shared/models/user.model'; + +@Injectable() +export class AuthService { + loggedIn = false; + isAdmin = false; + + currentUser: User = new User(); + + constructor( + private userService: UserService, + private router: Router, + private jwtHelper: JwtHelperService, + public toast: ToastComponent, + @Inject(PLATFORM_ID) private platformId: Object + ) { + if (isPlatformBrowser(this.platformId)) { + const token = localStorage.getItem('token'); + if (token) { + const decodedUser = this.decodeUserFromToken(token); + this.setCurrentUser(decodedUser); + } + } + } + + login(emailAndPassword): void { + this.userService.login(emailAndPassword).subscribe( + (res) => { + localStorage.setItem('token', res.token); + const decodedUser = this.decodeUserFromToken(res.token); + this.setCurrentUser(decodedUser); + this.loggedIn = true; + this.router.navigate(['/']); + }, + (error) => this.toast.setMessage('invalid email or password!', 'danger') + ); + } + + logout(): void { + localStorage.removeItem('token'); + this.loggedIn = false; + this.isAdmin = false; + this.currentUser = new User(); + this.router.navigate(['/']); + } + + decodeUserFromToken(token): object { + return this.jwtHelper.decodeToken(token).user; + } + + setCurrentUser(decodedUser): void { + this.loggedIn = true; + this.currentUser._id = decodedUser._id; + this.currentUser.username = decodedUser.username; + this.currentUser.role = decodedUser.role; + this.isAdmin = decodedUser.role === 'admin'; + delete decodedUser.role; + } +} diff --git a/src/app/services/cat.service.ts b/src/app/services/cat.service.ts new file mode 100644 index 00000000..694390c2 --- /dev/null +++ b/src/app/services/cat.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { Cat } from '../shared/models/cat.model'; + +@Injectable() +export class CatService { + + constructor(private http: HttpClient) { } + + getCats(): Observable { + return this.http.get('http://localhost:4200/api/cats'); + } + + countCats(): Observable { + return this.http.get('/api/cats/count'); + } + + addCat(cat: Cat): Observable { + return this.http.post('/api/cat', cat); + } + + getCat(cat: Cat): Observable { + return this.http.get(`/api/cat/${cat._id}`); + } + + editCat(cat: Cat): Observable { + return this.http.put(`/api/cat/${cat._id}`, cat, { responseType: 'text' }); + } + + deleteCat(cat: Cat): Observable { + return this.http.delete(`/api/cat/${cat._id}`, { responseType: 'text' }); + } + +} \ No newline at end of file diff --git a/src/app/services/user.service.ts b/src/app/services/user.service.ts new file mode 100644 index 00000000..4d474192 --- /dev/null +++ b/src/app/services/user.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { User } from '../shared/models/user.model'; + +@Injectable() +export class UserService { + + constructor(private http: HttpClient) { } + + register(user: User): Observable { + console.log('show up this damn user '+JSON.stringify(user)); + return this.http.post('/api/user', user); + } + + login(credentials): Observable { + return this.http.post('/api/login', credentials); + } + + getUsers(): Observable { + return this.http.get('/api/users'); + } + + countUsers(): Observable { + return this.http.get('/api/users/count'); + } + + addUser(user: User): Observable { + return this.http.post('/api/user', user); + } + + getUser(user: User): Observable { + return this.http.get(`/api/user/${user._id}`); + } + + editUser(user: User): Observable { + return this.http.put(`/api/user/${user._id}`, user, { responseType: 'text' }); + } + + deleteUser(user: User): Observable { + return this.http.delete(`/api/user/${user._id}`, { responseType: 'text' }); + } + +} \ No newline at end of file diff --git a/src/app/shared/loading/loading.component.html b/src/app/shared/loading/loading.component.html new file mode 100644 index 00000000..e47f11a3 --- /dev/null +++ b/src/app/shared/loading/loading.component.html @@ -0,0 +1,6 @@ +
+

Loading...

+
+ +
+
\ No newline at end of file diff --git a/src/app/shared/loading/loading.component.spec.ts b/src/app/shared/loading/loading.component.spec.ts new file mode 100644 index 00000000..88063981 --- /dev/null +++ b/src/app/shared/loading/loading.component.spec.ts @@ -0,0 +1,42 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { LoadingComponent } from './loading.component'; + +describe('Component: Loading', () => { + let component: LoadingComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ LoadingComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LoadingComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should not show the DOM element', () => { + const de = fixture.debugElement.query(By.css('div')); + expect(de).toBeNull(); + }); + + it('should show the DOM element', () => { + component.condition = true; + fixture.detectChanges(); + expect(component).toBeTruthy(); + const de = fixture.debugElement.query(By.css('div')); + const el = de.nativeElement; + expect(de).toBeDefined(); + expect(el.textContent).toContain('Loading...'); + }); + +}); \ No newline at end of file diff --git a/src/app/shared/loading/loading.component.ts b/src/app/shared/loading/loading.component.ts new file mode 100644 index 00000000..b0434c94 --- /dev/null +++ b/src/app/shared/loading/loading.component.ts @@ -0,0 +1,9 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'app-loading', + templateUrl: './loading.component.html' +}) +export class LoadingComponent { + @Input() condition: boolean; +} \ No newline at end of file diff --git a/src/app/shared/models/cat.model.ts b/src/app/shared/models/cat.model.ts new file mode 100644 index 00000000..427cc5ce --- /dev/null +++ b/src/app/shared/models/cat.model.ts @@ -0,0 +1,7 @@ +export class Cat { + // tslint:disable-next-line: variable-name + _id?: string; + name?: string; + weight?: number; + age?: number; + } \ No newline at end of file diff --git a/src/app/shared/models/user.model.ts b/src/app/shared/models/user.model.ts new file mode 100644 index 00000000..2c7b5a79 --- /dev/null +++ b/src/app/shared/models/user.model.ts @@ -0,0 +1,7 @@ +export class User { + // tslint:disable-next-line: variable-name + _id?: string; + username?: string; + email?: string; + role?: string; + } \ No newline at end of file diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts new file mode 100644 index 00000000..7a9ad4dc --- /dev/null +++ b/src/app/shared/shared.module.ts @@ -0,0 +1,34 @@ +import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { HttpClientModule } from '@angular/common/http'; + +import { ToastComponent } from './toast/toast.component'; +import { LoadingComponent } from './loading/loading.component'; + +@NgModule({ + imports: [ + BrowserModule, + FormsModule, + ReactiveFormsModule, + HttpClientModule + ], + exports: [ + // Shared Modules + BrowserModule, + FormsModule, + ReactiveFormsModule, + HttpClientModule, + // Shared Components + ToastComponent, + LoadingComponent + ], + declarations: [ + ToastComponent, + LoadingComponent + ], + providers: [ + ToastComponent + ] +}) +export class SharedModule { } \ No newline at end of file diff --git a/src/app/shared/toast/toast.component.html b/src/app/shared/toast/toast.component.html new file mode 100644 index 00000000..85d962f2 --- /dev/null +++ b/src/app/shared/toast/toast.component.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/src/app/shared/toast/toast.component.scss b/src/app/shared/toast/toast.component.scss new file mode 100644 index 00000000..e81baafa --- /dev/null +++ b/src/app/shared/toast/toast.component.scss @@ -0,0 +1,8 @@ +.alert { + bottom: 0; + left: 25%; + opacity: .9; + position: fixed; + width: 50%; + z-index: 999; + } \ No newline at end of file diff --git a/src/app/shared/toast/toast.component.spec.ts b/src/app/shared/toast/toast.component.spec.ts new file mode 100644 index 00000000..01af4c9f --- /dev/null +++ b/src/app/shared/toast/toast.component.spec.ts @@ -0,0 +1,50 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { ToastComponent } from './toast.component'; + +describe('Component: Toast', () => { + let component: ToastComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ ToastComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ToastComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should not have message set nor DOM element', () => { + expect(component.message.body).toBeFalsy(); + expect(component.message.type).toBeFalsy(); + const de = fixture.debugElement.query(By.css('div')); + expect(de).toBeNull(); + }); + + it('should set the message and create the DOM element', () => { + const mockMessage = { + body: 'test message', + type: 'warning' + }; + component.setMessage(mockMessage.body, mockMessage.type); + expect(component.message.body).toBe(mockMessage.body); + expect(component.message.type).toBe(mockMessage.type); + fixture.detectChanges(); + const de = fixture.debugElement.query(By.css('div')); + const el = de.nativeElement; + expect(de).toBeDefined(); + expect(el.textContent).toContain(mockMessage.body); + expect(el.className).toContain(mockMessage.type); + }); + +}); \ No newline at end of file diff --git a/src/app/shared/toast/toast.component.ts b/src/app/shared/toast/toast.component.ts new file mode 100644 index 00000000..68b92f40 --- /dev/null +++ b/src/app/shared/toast/toast.component.ts @@ -0,0 +1,16 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'app-toast', + templateUrl: './toast.component.html', + styleUrls: ['./toast.component.scss'] +}) +export class ToastComponent { + @Input() message = { body: '', type: '' }; + + setMessage(body, type, time = 3000) { + this.message.body = body; + this.message.type = type; + setTimeout(() => this.message.body = '', time); + } +} \ No newline at end of file diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts new file mode 100644 index 00000000..3612073b --- /dev/null +++ b/src/environments/environment.prod.ts @@ -0,0 +1,3 @@ +export const environment = { + production: true +}; diff --git a/src/environments/environment.ts b/src/environments/environment.ts new file mode 100644 index 00000000..7b4f817a --- /dev/null +++ b/src/environments/environment.ts @@ -0,0 +1,16 @@ +// This file can be replaced during build by using the `fileReplacements` array. +// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. +// The list of file replacements can be found in `angular.json`. + +export const environment = { + production: false +}; + +/* + * For easier debugging in development mode, you can import the following file + * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. + * + * This import should be commented out in production mode because it will have a negative impact + * on performance if an error is thrown. + */ +// import 'zone.js/dist/zone-error'; // Included with Angular CLI. diff --git a/src/favicon.ico b/src/favicon.ico new file mode 100644 index 00000000..1cceb832 Binary files /dev/null and b/src/favicon.ico differ diff --git a/src/index.html b/src/index.html new file mode 100644 index 00000000..0e589b12 --- /dev/null +++ b/src/index.html @@ -0,0 +1,13 @@ + + + + + Tour of Heroes + + + + + + + + diff --git a/src/main.server.ts b/src/main.server.ts new file mode 100644 index 00000000..10150a71 --- /dev/null +++ b/src/main.server.ts @@ -0,0 +1,10 @@ +import { enableProdMode } from '@angular/core'; + +import { environment } from './environments/environment'; + +if (environment.production) { + enableProdMode(); +} + +export { AppServerModule } from './app/app.server.module'; +export { renderModule, renderModuleFactory } from '@angular/platform-server'; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 00000000..00ca40d0 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,14 @@ +import { enableProdMode } from '@angular/core'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { AppModule } from './app/app.module'; +import { environment } from './environments/environment'; + +if (environment.production) { + enableProdMode(); +} + +document.addEventListener('DOMContentLoaded', () => { + platformBrowserDynamic().bootstrapModule(AppModule) + .catch(err => console.error(err)); +}); diff --git a/src/polyfills.ts b/src/polyfills.ts new file mode 100644 index 00000000..cd7cfe97 --- /dev/null +++ b/src/polyfills.ts @@ -0,0 +1,64 @@ +/** + * This file includes polyfills needed by Angular and is loaded before the app. + * You can add your own extra polyfills to this file. + * + * This file is divided into 2 sections: + * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. + * 2. Application imports. Files imported after ZoneJS that should be loaded before your main + * file. + * + * The current setup is for so-called "evergreen" browsers; the last versions of browsers that + * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), + * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. + * + * Learn more in https://angular.io/guide/browser-support + */ + +/*************************************************************************************************** + * BROWSER POLYFILLS + */ + +/** IE10 and IE11 requires the following for NgClass support on SVG elements */ +// import 'classlist.js'; // Run `npm install --save classlist.js`. + +/** + * Web Animations `@angular/platform-browser/animations` + * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. + * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). + */ +// import 'web-animations-js'; // Run `npm install --save web-animations-js`. + +/** + * By default, zone.js will patch all possible macroTask and DomEvents + * user can disable parts of macroTask/DomEvents patch by setting following flags + * because those flags need to be set before `zone.js` being loaded, and webpack + * will put import in the top of bundle, so user need to create a separate file + * in this directory (for example: zone-flags.ts), and put the following flags + * into that file, and then add the following code before importing zone.js. + * import './zone-flags'; + * + * The flags allowed in zone-flags.ts are listed here. + * + * The following flags will work for all browsers. + * + * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch + * requestAnimationFrame + * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick + * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch + * specified eventNames + * + * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js + * with the following flag, it will bypass `zone.js` patch for IE/Edge + * + * (window as any).__Zone_enable_cross_context_check = true; + * + */ + +/*************************************************************************************************** + * Zone JS is required by default for Angular itself. + */ +import 'zone.js/dist/zone'; // Included with Angular CLI. + +/*************************************************************************************************** + * APPLICATION IMPORTS + */ diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 00000000..9576f166 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,10 @@ + + +body { + margin-bottom: 10px; + margin-top: 15px; +} + +.input-group { + margin-bottom: 15px; +} \ No newline at end of file diff --git a/src/test.ts b/src/test.ts new file mode 100644 index 00000000..50193eb0 --- /dev/null +++ b/src/test.ts @@ -0,0 +1,25 @@ +// This file is required by karma.conf.js and loads recursively all the .spec and framework files + +import 'zone.js/dist/zone-testing'; +import { getTestBed } from '@angular/core/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting +} from '@angular/platform-browser-dynamic/testing'; + +declare const require: { + context(path: string, deep?: boolean, filter?: RegExp): { + keys(): string[]; + (id: string): T; + }; +}; + +// First, initialize the Angular testing environment. +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting() +); +// Then we find all the tests. +const context = require.context('./', true, /\.spec\.ts$/); +// And load the modules. +context.keys().map(context); diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 00000000..992b1b2a --- /dev/null +++ b/tsconfig.server.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.app.json", + "compilerOptions": { + "outDir": "./out-tsc/app-server", + "module": "commonjs", + "types": ["node"] + }, + "files": [ + "src/main.server.ts", + "server.ts" + ], + "angularCompilerOptions": { + "entryModule": "./src/app/app.server.module#AppServerModule" + } +}