diff --git a/angular-client/angular.json b/angular-client/angular.json index d595dc8..8e5cad1 100644 --- a/angular-client/angular.json +++ b/angular-client/angular.json @@ -34,7 +34,8 @@ "styles": [ "src/styles.scss" ], - "scripts": [] + "scripts": [], + "statsJson": true }, "configurations": { "production": { diff --git a/angular-client/package-lock.json b/angular-client/package-lock.json index b43a529..a875475 100644 --- a/angular-client/package-lock.json +++ b/angular-client/package-lock.json @@ -15,6 +15,7 @@ "@angular/core": "^19.0.0", "@angular/forms": "^19.0.0", "@angular/material": "^19.0.0", + "@angular/material-luxon-adapter": "^19.0.0", "@angular/platform-browser": "^19.0.0", "@angular/platform-browser-dynamic": "^19.0.0", "@angular/router": "^19.0.0", @@ -27,6 +28,7 @@ "@angular/cli": "^19.0.1", "@angular/compiler-cli": "^19.0.0", "@types/jasmine": "~5.1.0", + "@types/luxon": "^3.4.2", "jasmine-core": "~5.4.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", @@ -534,6 +536,20 @@ "rxjs": "^6.5.3 || ^7.4.0" } }, + "node_modules/@angular/material-luxon-adapter": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/@angular/material-luxon-adapter/-/material-luxon-adapter-19.0.0.tgz", + "integrity": "sha512-sEdw+rfid7XXhVk+4FHMONobvr4OlzGof6PohzGVIp34Nh295pwK7HcgSafq1w8jeJwNPW2obaGa6Zz+uLbW3g==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/core": "^19.0.0 || ^20.0.0", + "@angular/material": "19.0.0", + "luxon": "^3.0.0" + } + }, "node_modules/@angular/platform-browser": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.0.0.tgz", @@ -5069,6 +5085,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/luxon": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", + "integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -9917,6 +9940,16 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.12", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz", diff --git a/angular-client/package.json b/angular-client/package.json index da022c8..9d7339d 100644 --- a/angular-client/package.json +++ b/angular-client/package.json @@ -17,6 +17,7 @@ "@angular/core": "^19.0.0", "@angular/forms": "^19.0.0", "@angular/material": "^19.0.0", + "@angular/material-luxon-adapter": "^19.0.0", "@angular/platform-browser": "^19.0.0", "@angular/platform-browser-dynamic": "^19.0.0", "@angular/router": "^19.0.0", @@ -29,6 +30,7 @@ "@angular/cli": "^19.0.1", "@angular/compiler-cli": "^19.0.0", "@types/jasmine": "~5.1.0", + "@types/luxon": "^3.4.2", "jasmine-core": "~5.4.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", diff --git a/angular-client/src/app/app.config.ts b/angular-client/src/app/app.config.ts index aab26ad..148bc65 100644 --- a/angular-client/src/app/app.config.ts +++ b/angular-client/src/app/app.config.ts @@ -1,12 +1,13 @@ import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; import { provideRouter } from '@angular/router'; -import { routes } from './app.routes'; +import { routes } from './routes/app.routes'; import { provideHttpClient } from '@angular/common/http'; import { Configuration } from '../../build/client'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; +import { provideLuxonDateAdapter } from '@angular/material-luxon-adapter'; export const appConfig: ApplicationConfig = { providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideHttpClient(), {provide: Configuration, useValue: new Configuration({ - })}, provideAnimationsAsync()] + })}, provideAnimationsAsync(), provideLuxonDateAdapter()] }; diff --git a/angular-client/src/app/app.routes.ts b/angular-client/src/app/app.routes.ts deleted file mode 100644 index 92bb2da..0000000 --- a/angular-client/src/app/app.routes.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Routes } from '@angular/router'; -import { PageNotFoundComponent } from './page-not-found/page-not-found.component'; -import { WelcomeComponent } from './welcome/welcome.component'; -import { OwnerListComponent } from './owner-list/owner-list.component'; -import { OwnerAddComponent } from './owner-add/owner-add.component'; - -export const routes: Routes = [ - { path: 'welcome', component: WelcomeComponent }, - { path: 'owners', component: OwnerListComponent }, - { path: 'owners/add', component: OwnerAddComponent }, - { path: '', component: WelcomeComponent }, - { path: '**', component: PageNotFoundComponent } -]; diff --git a/angular-client/src/app/owner-detail/owner-detail.component.html b/angular-client/src/app/owner-detail/owner-detail.component.html new file mode 100644 index 0000000..9ee55e3 --- /dev/null +++ b/angular-client/src/app/owner-detail/owner-detail.component.html @@ -0,0 +1,32 @@ +

Owner Information

+ + + + + + + + + + + + + + + + + +
Name{{owner.firstName}} {{owner.lastName}}
Address{{owner.address}}
City{{owner.city}}
Telephone{{owner.telephone}}
+ +Back +Edit Owner +Add Pet + +

Pets and Visits

+ + @for(pet of owner.pets; track pet.id) { + + + + } +
\ No newline at end of file diff --git a/angular-client/src/app/owner-detail/owner-detail.component.scss b/angular-client/src/app/owner-detail/owner-detail.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/angular-client/src/app/owner-detail/owner-detail.component.spec.ts b/angular-client/src/app/owner-detail/owner-detail.component.spec.ts new file mode 100644 index 0000000..53eaf64 --- /dev/null +++ b/angular-client/src/app/owner-detail/owner-detail.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { OwnerDetailComponent } from './owner-detail.component'; + +describe('OwnerDetailComponent', () => { + let component: OwnerDetailComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [OwnerDetailComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(OwnerDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/angular-client/src/app/owner-detail/owner-detail.component.ts b/angular-client/src/app/owner-detail/owner-detail.component.ts new file mode 100644 index 0000000..a37cacc --- /dev/null +++ b/angular-client/src/app/owner-detail/owner-detail.component.ts @@ -0,0 +1,35 @@ +import { Component, DestroyRef, inject, OnInit } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDividerModule } from '@angular/material/divider'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { Owner, OwnerService } from '../../../build/client'; +import { map, switchMap } from 'rxjs'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { PetListComponent } from '../pet-list/pet-list.component'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +@Component({ + selector: 'app-owner-detail', + imports: [RouterLink, MatButtonModule, MatDividerModule, PetListComponent], + templateUrl: './owner-detail.component.html', + styleUrl: './owner-detail.component.scss' +}) +export class OwnerDetailComponent implements OnInit { + owner = {} as Owner; + private readonly route = inject(ActivatedRoute); + private readonly ownerService = inject(OwnerService); + private readonly snackBar = inject(MatSnackBar); + private readonly destroyRef = inject(DestroyRef); + + ngOnInit(): void { + this.route.params.pipe( + map(params => params["id"]), + switchMap(id => this.ownerService.getOwner(id)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: owner => this.owner = owner, + error: error => this.snackBar.open(error) + }); + } + +} diff --git a/angular-client/src/app/owner-edit/owner-edit.component.html b/angular-client/src/app/owner-edit/owner-edit.component.html new file mode 100644 index 0000000..7be9d79 --- /dev/null +++ b/angular-client/src/app/owner-edit/owner-edit.component.html @@ -0,0 +1,55 @@ +

Edit Owner

+
+ + First Name + + @if (form.controls["firstName"].hasError('required')) {First name is required} + @if (form.controls["firstName"].hasError('minlength')) {First name must be at least 1 character + long} + @if (form.controls["firstName"].hasError('maxlength')) {First name may be at most 30 characters + long} + @if (form.controls["firstName"].hasError('pattern')) {First name must consist of letters + only} + +
+ + Last Name + + @if (form.controls["lastName"].hasError('required')) {Last name is required} + @if (form.controls["lastName"].hasError('minlength')) {Last name must be at least 1 character + long} + @if (form.controls["lastName"].hasError('maxlength')) {Last name may be at most 30 characters + long} + @if (form.controls["lastName"].hasError('pattern')) {Last name must consist of letters + only} + +
+ + Address + + @if (form.controls["address"].hasError('required')) {Address is required} + @if (form.controls["address"].hasError('maxlength')) {Address may be at most 255 characters + long} + +
+ + City + + @if (form.controls["city"].hasError('required')) {City is required} + @if (form.controls["city"].hasError('maxlength')) {City may be at most 80 characters + long} + +
+ + Telephone + + @if (form.controls["telephone"].hasError('required')) {Telephone is required} + @if (form.controls["telephone"].hasError('maxlength')) {Telephone may be at most 20 numbers + long} + @if (form.controls["telephone"].hasError('pattern')) {Telephone must consist of numbers + only} + +
+ Back + +
\ No newline at end of file diff --git a/angular-client/src/app/owner-edit/owner-edit.component.scss b/angular-client/src/app/owner-edit/owner-edit.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/angular-client/src/app/owner-edit/owner-edit.component.spec.ts b/angular-client/src/app/owner-edit/owner-edit.component.spec.ts new file mode 100644 index 0000000..1bc4b60 --- /dev/null +++ b/angular-client/src/app/owner-edit/owner-edit.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { OwnerEditComponent } from './owner-edit.component'; + +describe('OwnerEditComponent', () => { + let component: OwnerEditComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [OwnerEditComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(OwnerEditComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/angular-client/src/app/owner-edit/owner-edit.component.ts b/angular-client/src/app/owner-edit/owner-edit.component.ts new file mode 100644 index 0000000..a2b5e68 --- /dev/null +++ b/angular-client/src/app/owner-edit/owner-edit.component.ts @@ -0,0 +1,54 @@ +import { Component, DestroyRef, inject, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { Owner, OwnerService } from '../../../build/client'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { map, switchMap } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +@Component({ + selector: 'app-owner-edit', + imports: [ReactiveFormsModule, MatFormFieldModule, MatInputModule, MatButtonModule, RouterLink], + templateUrl: './owner-edit.component.html', + styleUrl: './owner-edit.component.scss' +}) +export class OwnerEditComponent implements OnInit { + form: FormGroup; + owner = {} as Owner; + private readonly destroyRef = inject(DestroyRef); + private readonly ownerService = inject(OwnerService); + private readonly snackBar = inject(MatSnackBar); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + constructor(builder: FormBuilder) { + this.form = builder.group({ + firstName: [null, [Validators.minLength(1), Validators.maxLength(30), Validators.pattern(/^[a-zA-Z]*$/), Validators.required]], + lastName: [null, [Validators.minLength(1), Validators.maxLength(30), Validators.pattern(/^[a-zA-Z]*$/), Validators.required]], + address: [null, [Validators.required, Validators.maxLength(255)]], + city: [null, [Validators.required, Validators.maxLength(80)]], + telephone: [null, [Validators.minLength(1), Validators.maxLength(20), Validators.pattern(/^[0-9]*$/), Validators.required]], + }); + } + + ngOnInit(): void { + this.route.params.pipe( + map(params => params["id"]), + switchMap(id => this.ownerService.getOwner(id)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: owner => { this.owner = owner; this.form.patchValue(owner) }, + error: error => this.snackBar.open(error) + }); + } + + onSubmit() { + this.ownerService.updateOwner(this.owner.id!, this.form.value).subscribe({ + next: _ => this.router.navigate(['/owners', this.owner.id!]), + error: error => this.snackBar.open(error) + }); + } +} diff --git a/angular-client/src/app/page-not-found/page-not-found.component.html b/angular-client/src/app/page-not-found/page-not-found.component.html index c683218..fdd0694 100644 --- a/angular-client/src/app/page-not-found/page-not-found.component.html +++ b/angular-client/src/app/page-not-found/page-not-found.component.html @@ -1,5 +1,3 @@ - - Oops! Page not found ! - Not Found - 404 error - pets logo - \ No newline at end of file +

Oops! Page not found !

+

Not Found - 404 error

+pets logo \ No newline at end of file diff --git a/angular-client/src/app/page-not-found/page-not-found.component.ts b/angular-client/src/app/page-not-found/page-not-found.component.ts index 4783a40..947606f 100644 --- a/angular-client/src/app/page-not-found/page-not-found.component.ts +++ b/angular-client/src/app/page-not-found/page-not-found.component.ts @@ -3,7 +3,7 @@ import { MatCardModule } from '@angular/material/card'; @Component({ selector: 'app-page-not-found', - imports: [MatCardModule], + imports: [], templateUrl: './page-not-found.component.html', styleUrl: './page-not-found.component.scss' }) diff --git a/angular-client/src/app/pet-add/pet-add.component.html b/angular-client/src/app/pet-add/pet-add.component.html new file mode 100644 index 0000000..e0800e3 --- /dev/null +++ b/angular-client/src/app/pet-add/pet-add.component.html @@ -0,0 +1,35 @@ +

Add Pet

+
+ + Name + + @if (form.controls["name"].hasError('required')) {Name is required} + @if (form.controls["name"].hasError('minlength')) {Name must be at least 1 character + long} + @if (form.controls["name"].hasError('maxlength')) {Name may be at most 30 characters + long} + @if (form.controls["name"].hasError('pattern')) {Name must begin with a letter} + +
+ + Birth date + + + + @if (form.controls["birthDate"].hasError('required')) {Birth date is required} + @if (form.controls["birthDate"].hasError('matDatepickerParse')) {Invalid date format} + +
+ + Type + + @if (form.controls["type"].hasError('required')) {Type is required} + +
+ Back + +
\ No newline at end of file diff --git a/angular-client/src/app/pet-add/pet-add.component.scss b/angular-client/src/app/pet-add/pet-add.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/angular-client/src/app/pet-add/pet-add.component.spec.ts b/angular-client/src/app/pet-add/pet-add.component.spec.ts new file mode 100644 index 0000000..7da961b --- /dev/null +++ b/angular-client/src/app/pet-add/pet-add.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PetAddComponent } from './pet-add.component'; + +describe('PetAddComponent', () => { + let component: PetAddComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PetAddComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(PetAddComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/angular-client/src/app/pet-add/pet-add.component.ts b/angular-client/src/app/pet-add/pet-add.component.ts new file mode 100644 index 0000000..07d059d --- /dev/null +++ b/angular-client/src/app/pet-add/pet-add.component.ts @@ -0,0 +1,66 @@ +import { Component, DestroyRef, inject, OnInit } from '@angular/core'; +import { Owner, OwnerService, PetFields, PetService, PetType, PettypesService } from '../../../build/client'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { map, switchMap } from 'rxjs'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatSelectModule } from '@angular/material/select'; +import { DateTime } from 'luxon'; + + +@Component({ + selector: 'app-pet-add', + imports: [MatSelectModule, ReactiveFormsModule, MatFormFieldModule, MatInputModule, MatButtonModule, RouterLink, MatDatepickerModule], + templateUrl: './pet-add.component.html', + styleUrl: './pet-add.component.scss' +}) +export class PetAddComponent implements OnInit { + form: FormGroup; + petTypes = [] as PetType[]; + owner = {} as Owner; + private readonly route = inject(ActivatedRoute); + private readonly ownerService = inject(OwnerService); + private readonly petService = inject(PetService); + private readonly petTypesService = inject(PettypesService); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + private readonly snackBar = inject(MatSnackBar); + + constructor(builder: FormBuilder) { + this.form = builder.group({ + name: [null, [Validators.required, Validators.minLength(1), Validators.maxLength(30), Validators.pattern(/^[A-Za-z].*$/)]], + birthDate: [null, Validators.required], + type: [null, Validators.required] + }); + } + + ngOnInit(): void { + this.petTypesService.listPetTypes().pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: types => this.petTypes = types, + error: error => this.snackBar.open(error) + }); + this.route.params.pipe( + map(params => params['id']), + switchMap(id => this.ownerService.getOwner(id)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: owner => this.owner = owner, + error: error => this.snackBar.open(error) + }); + } + + onSubmit() { + const pet = this.form.value as PetFields; + const formDate = this.form.controls['birthDate'].value as DateTime|string; + pet.birthDate = typeof formDate === 'string' ? formDate : formDate.toISODate()!; + this.petService.addPetToOwner(this.owner.id!, pet).subscribe({ + next: _ => this.router.navigate(['/owners', this.owner.id!]), + error: error => this.snackBar.open(error) + }); + } +} diff --git a/angular-client/src/app/pet-edit/pet-edit.component.html b/angular-client/src/app/pet-edit/pet-edit.component.html new file mode 100644 index 0000000..33aa210 --- /dev/null +++ b/angular-client/src/app/pet-edit/pet-edit.component.html @@ -0,0 +1,40 @@ +

Edit Pet

+
+ + Owner + + +
+ + Name + + @if (form.controls["name"].hasError('required')) {Name is required} + @if (form.controls["name"].hasError('minlength')) {Name must be at least 1 character + long} + @if (form.controls["name"].hasError('maxlength')) {Name may be at most 30 characters + long} + @if (form.controls["name"].hasError('pattern')) {Name must begin with a letter} + +
+ + Birth date + + + + @if (form.controls["birthDate"].hasError('required')) {Birth date is required} + @if (form.controls["birthDate"].hasError('matDatepickerParse')) {Invalid date format} + +
+ + Type + + @if (form.controls["type"].hasError('required')) {Type is required} + +
+ Back + +
\ No newline at end of file diff --git a/angular-client/src/app/pet-edit/pet-edit.component.scss b/angular-client/src/app/pet-edit/pet-edit.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/angular-client/src/app/pet-edit/pet-edit.component.spec.ts b/angular-client/src/app/pet-edit/pet-edit.component.spec.ts new file mode 100644 index 0000000..9248d99 --- /dev/null +++ b/angular-client/src/app/pet-edit/pet-edit.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PetEditComponent } from './pet-edit.component'; + +describe('PetEditComponent', () => { + let component: PetEditComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PetEditComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(PetEditComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/angular-client/src/app/pet-edit/pet-edit.component.ts b/angular-client/src/app/pet-edit/pet-edit.component.ts new file mode 100644 index 0000000..f4cbd64 --- /dev/null +++ b/angular-client/src/app/pet-edit/pet-edit.component.ts @@ -0,0 +1,69 @@ +import { Component, DestroyRef, inject } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { Owner, OwnerService, Pet, PetService, PetType, PettypesService } from '../../../build/client'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { forkJoin, map, of, switchMap } from 'rxjs'; +import { DateTime } from 'luxon'; + +@Component({ + selector: 'app-pet-edit', + imports: [MatSelectModule, ReactiveFormsModule, MatFormFieldModule, MatInputModule, MatButtonModule, RouterLink, MatDatepickerModule], + templateUrl: './pet-edit.component.html', + styleUrl: './pet-edit.component.scss' +}) +export class PetEditComponent { + form: FormGroup; + pet?: Pet; + currentOwner?: Owner; + petTypes = [] as PetType[]; + private readonly route = inject(ActivatedRoute); + private readonly petService = inject(PetService); + private readonly petTypesService = inject(PettypesService); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + private readonly snackBar = inject(MatSnackBar); + private readonly ownerService = inject(OwnerService); + + constructor(builder: FormBuilder) { + this.form = builder.group({ + name: [null, [Validators.required, Validators.minLength(1), Validators.maxLength(30), Validators.pattern(/^[A-Za-z].*$/)]], + birthDate: [null, Validators.required], + type: [null, Validators.required], + ownerId: [null, Validators.required] + }); + } + + ngOnInit(): void { + this.petTypesService.listPetTypes().pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: types => this.petTypes = types, + error: error => this.snackBar.open(error) + }); + this.route.params.pipe( + map(params => params['id']), + switchMap(id => this.petService.getPet(id)), + switchMap(pet => forkJoin([of(pet), this.ownerService.getOwner(pet.ownerId!)])), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: ([pet, owner]) => { this.pet = pet; this.form.patchValue(pet); this.currentOwner = owner; }, + error: error => this.snackBar.open(error) + }); + } + + onSubmit() { + const pet = this.form.value as Pet; + const formDate = this.form.controls['birthDate'].value as DateTime|string; + pet.birthDate = typeof formDate === 'string' ? formDate : formDate.toISODate()!; + this.petService.updatePet(this.pet!.id, pet).subscribe({ + next: _ => this.router.navigate(['/owners', this.pet?.ownerId]), + error: error => this.snackBar.open(error) + }); + } + +} diff --git a/angular-client/src/app/pet-list/pet-list.component.html b/angular-client/src/app/pet-list/pet-list.component.html new file mode 100644 index 0000000..ae71283 --- /dev/null +++ b/angular-client/src/app/pet-list/pet-list.component.html @@ -0,0 +1,22 @@ +@if(pet != null) { + + + + + +
+
+
Name
+
{{pet.name}}
+
Birth Date
+
{{pet.birthDate}}
+
Type
+
{{pet.type.name}}
+ Edit Pet + + Add Visit +
+
+ +
+} \ No newline at end of file diff --git a/angular-client/src/app/pet-list/pet-list.component.scss b/angular-client/src/app/pet-list/pet-list.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/angular-client/src/app/pet-list/pet-list.component.spec.ts b/angular-client/src/app/pet-list/pet-list.component.spec.ts new file mode 100644 index 0000000..53c91cf --- /dev/null +++ b/angular-client/src/app/pet-list/pet-list.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PetListComponent } from './pet-list.component'; + +describe('PetListComponent', () => { + let component: PetListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PetListComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(PetListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/angular-client/src/app/pet-list/pet-list.component.ts b/angular-client/src/app/pet-list/pet-list.component.ts new file mode 100644 index 0000000..5845b25 --- /dev/null +++ b/angular-client/src/app/pet-list/pet-list.component.ts @@ -0,0 +1,27 @@ +import { Component, inject, Input } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { Pet, PetService } from '../../../build/client'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatButtonModule } from '@angular/material/button'; +import { VisitListComponent } from '../visit-list/visit-list.component'; + +@Component({ + selector: 'app-pet-list', + imports: [RouterLink, MatButtonModule, VisitListComponent], + templateUrl: './pet-list.component.html', + styleUrl: './pet-list.component.scss' +}) +export class PetListComponent { + @Input() pet ?: Pet; + private readonly petService = inject(PetService); + private readonly snackBar = inject(MatSnackBar); + + deletePet(pet: Pet) { + this.petService.deletePet(pet.id).subscribe({ + next: _response => { + this.pet = undefined; + }, + error: error => this.snackBar.open(error) + }); + } +} diff --git a/angular-client/src/app/routes/app.routes.ts b/angular-client/src/app/routes/app.routes.ts new file mode 100644 index 0000000..41f28c5 --- /dev/null +++ b/angular-client/src/app/routes/app.routes.ts @@ -0,0 +1,12 @@ +import { Routes } from '@angular/router'; +import { PageNotFoundComponent } from '../page-not-found/page-not-found.component'; +import { WelcomeComponent } from '../welcome/welcome.component'; + +export const routes: Routes = [ + { path: 'welcome', component: WelcomeComponent }, + { path: 'owners', loadChildren: () => import('./owner.routes').then(x => x.ownerRoutes) }, + { path: 'pets', loadChildren: () => import('./pet.routes').then(x => x.petRoutes) }, + { path: 'visit', loadChildren: () => import('./visit.routes').then(x => x.visitRoutes) }, + { path: '', component: WelcomeComponent }, + { path: '**', component: PageNotFoundComponent } +]; diff --git a/angular-client/src/app/routes/owner.routes.ts b/angular-client/src/app/routes/owner.routes.ts new file mode 100644 index 0000000..7714c50 --- /dev/null +++ b/angular-client/src/app/routes/owner.routes.ts @@ -0,0 +1,14 @@ +import { Routes } from "@angular/router"; +import { OwnerAddComponent } from "../owner-add/owner-add.component"; +import { OwnerDetailComponent } from "../owner-detail/owner-detail.component"; +import { OwnerEditComponent } from "../owner-edit/owner-edit.component"; +import { OwnerListComponent } from "../owner-list/owner-list.component"; +import { PetAddComponent } from "../pet-add/pet-add.component"; + +export const ownerRoutes: Routes = [ + { path: '', component: OwnerListComponent }, + { path: 'add', component: OwnerAddComponent }, + { path: ':id', component: OwnerDetailComponent }, + { path: ':id/edit', component: OwnerEditComponent }, + { path: ':id/pets/add', component: PetAddComponent }, +]; \ No newline at end of file diff --git a/angular-client/src/app/routes/pet.routes.ts b/angular-client/src/app/routes/pet.routes.ts new file mode 100644 index 0000000..0436d28 --- /dev/null +++ b/angular-client/src/app/routes/pet.routes.ts @@ -0,0 +1,8 @@ +import { Routes } from "@angular/router"; +import { PetEditComponent } from "../pet-edit/pet-edit.component"; +import { VisitAddComponent } from "../visit-add/visit-add.component"; + +export const petRoutes: Routes = [ + { path: ':id/edit', component: PetEditComponent }, + { path: ':id/visits/add', component: VisitAddComponent } +]; \ No newline at end of file diff --git a/angular-client/src/app/routes/visit.routes.ts b/angular-client/src/app/routes/visit.routes.ts new file mode 100644 index 0000000..d4ce568 --- /dev/null +++ b/angular-client/src/app/routes/visit.routes.ts @@ -0,0 +1,6 @@ +import { Routes } from "@angular/router"; +import { VisitEditComponent } from "../visit-edit/visit-edit.component"; + +export const visitRoutes: Routes = [ + {path: ':id/edit', component: VisitEditComponent} +]; \ No newline at end of file diff --git a/angular-client/src/app/visit-add/visit-add.component.html b/angular-client/src/app/visit-add/visit-add.component.html new file mode 100644 index 0000000..50395c5 --- /dev/null +++ b/angular-client/src/app/visit-add/visit-add.component.html @@ -0,0 +1,49 @@ +

New Visit

+@if(currentPet && currentOwner) { +Pet + + + + + + + + + + + + + + + +
NameBirth DateTypeOwner
{{currentPet.name}}{{currentPet.birthDate}}{{currentPet.type.name}}{{currentOwner.firstName}} {{currentOwner.lastName}}
+ +
+ + Date + + + + @if (form.controls["date"].hasError('required')) {Date is required} + @if (form.controls["date"].hasError('matDatepickerParse')) {Invalid date format} + +
+ + Description + + @if (form.controls["description"].hasError('required')) {Description is required} + @if (form.controls["description"].hasError('minlength')) {Description must be at least 1 character + long} + @if (form.controls["description"].hasError('maxlength')) {Description may be at most 255 characters + long} + +
+ Back + +
+ +
+

Previous Visits

+
+ +} \ No newline at end of file diff --git a/angular-client/src/app/visit-add/visit-add.component.scss b/angular-client/src/app/visit-add/visit-add.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/angular-client/src/app/visit-add/visit-add.component.spec.ts b/angular-client/src/app/visit-add/visit-add.component.spec.ts new file mode 100644 index 0000000..bd12e5e --- /dev/null +++ b/angular-client/src/app/visit-add/visit-add.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { VisitAddComponent } from './visit-add.component'; + +describe('VisitAddComponent', () => { + let component: VisitAddComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [VisitAddComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(VisitAddComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/angular-client/src/app/visit-add/visit-add.component.ts b/angular-client/src/app/visit-add/visit-add.component.ts new file mode 100644 index 0000000..66232a3 --- /dev/null +++ b/angular-client/src/app/visit-add/visit-add.component.ts @@ -0,0 +1,63 @@ +import { Component, DestroyRef, inject, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { VisitListComponent } from '../visit-list/visit-list.component'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { Owner, OwnerService, Pet, PetService, VisitFields, VisitService } from '../../../build/client'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { forkJoin, map, of, switchMap } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { DateTime } from 'luxon'; +import { MatInputModule } from '@angular/material/input'; + +@Component({ + selector: 'app-visit-add', + imports: [ReactiveFormsModule, MatInputModule, MatFormFieldModule, MatButtonModule, RouterLink, VisitListComponent, MatDatepickerModule], + templateUrl: './visit-add.component.html', + styleUrl: './visit-add.component.scss' +}) +export class VisitAddComponent implements OnInit { + currentPet?: Pet; + currentOwner?: Owner; + form: FormGroup; + private readonly visitService = inject(VisitService); + private readonly petService = inject(PetService); + private readonly ownerService = inject(OwnerService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly snackBar = inject(MatSnackBar); + private readonly destroyRef = inject(DestroyRef); + + constructor(builder: FormBuilder) { + this.form = builder.group({ + date: [null, Validators.required], + description: [null, [Validators.required, Validators.minLength(1), Validators.maxLength(255)]] + }); + } + + ngOnInit(): void { + this.route.params.pipe( + map(params => params['id']), + switchMap(id => this.petService.getPet(id)), + switchMap(pet => forkJoin([of(pet), this.ownerService.getOwner(pet.ownerId!)])), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: ([pet, owner]) => { this.currentPet = pet; this.currentOwner = owner }, + error: error => this.snackBar.open(error) + }); + } + + onSubmit() { + const formDate = this.form.controls['date'].value as DateTime|string; + const visit: VisitFields = { + date: typeof formDate === 'string' ? formDate : formDate.toISODate()!, + description: this.form.controls['description'].value + }; + this.visitService.addVisitToOwner(this.currentOwner!.id!, this.currentPet!.id, visit).subscribe({ + next: _ => this.router.navigate(['/owners', this.currentOwner!.id!]), + error: error => this.snackBar.open(error) + }); + } +} diff --git a/angular-client/src/app/visit-edit/visit-edit.component.html b/angular-client/src/app/visit-edit/visit-edit.component.html new file mode 100644 index 0000000..0d795f9 --- /dev/null +++ b/angular-client/src/app/visit-edit/visit-edit.component.html @@ -0,0 +1,44 @@ +

Edit Visit

+@if(currentPet && currentOwner) { +Pet + + + + + + + + + + + + + + + +
NameBirth DateTypeOwner
{{currentPet.name}}{{currentPet.birthDate}}{{currentPet.type.name}}{{currentOwner.firstName}} {{currentOwner.lastName}}
+ +
+ + Date + + + + @if (form.controls["date"].hasError('required')) {Date is required} + @if (form.controls["date"].hasError('matDatepickerParse')) {Invalid date format} + +
+ + Description + + @if (form.controls["description"].hasError('required')) {Description is required} + @if (form.controls["description"].hasError('minlength')) {Description must be at least 1 character + long} + @if (form.controls["description"].hasError('maxlength')) {Description may be at most 255 characters + long} + +
+ Back + +
+} \ No newline at end of file diff --git a/angular-client/src/app/visit-edit/visit-edit.component.scss b/angular-client/src/app/visit-edit/visit-edit.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/angular-client/src/app/visit-edit/visit-edit.component.spec.ts b/angular-client/src/app/visit-edit/visit-edit.component.spec.ts new file mode 100644 index 0000000..235c664 --- /dev/null +++ b/angular-client/src/app/visit-edit/visit-edit.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { VisitEditComponent } from './visit-edit.component'; + +describe('VisitEditComponent', () => { + let component: VisitEditComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [VisitEditComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(VisitEditComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/angular-client/src/app/visit-edit/visit-edit.component.ts b/angular-client/src/app/visit-edit/visit-edit.component.ts new file mode 100644 index 0000000..57c8409 --- /dev/null +++ b/angular-client/src/app/visit-edit/visit-edit.component.ts @@ -0,0 +1,66 @@ +import { Component, DestroyRef, inject, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { VisitListComponent } from '../visit-list/visit-list.component'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { DateTime } from 'luxon'; +import { map, switchMap, forkJoin, of } from 'rxjs'; +import { Pet, Owner, VisitService, PetService, OwnerService, VisitFields, Visit } from '../../../build/client'; + +@Component({ + selector: 'app-visit-edit', + imports: [ReactiveFormsModule, MatInputModule, MatFormFieldModule, MatButtonModule, RouterLink, MatDatepickerModule], + templateUrl: './visit-edit.component.html', + styleUrl: './visit-edit.component.scss' +}) +export class VisitEditComponent implements OnInit { + + currentPet?: Pet; + currentOwner?: Owner; + visit?: Visit; + form: FormGroup; + private readonly visitService = inject(VisitService); + private readonly petService = inject(PetService); + private readonly ownerService = inject(OwnerService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly snackBar = inject(MatSnackBar); + private readonly destroyRef = inject(DestroyRef); + + constructor(builder: FormBuilder) { + this.form = builder.group({ + date: [null, Validators.required], + description: [null, [Validators.required, Validators.minLength(1), Validators.maxLength(255)]] + }); + } + + ngOnInit(): void { + this.route.params.pipe( + map(params => params['id']), + switchMap(id => this.visitService.getVisit(id)), + switchMap(visit => forkJoin([of(visit), this.petService.getPet(visit.petId!)])), + switchMap(([visit, pet]) => forkJoin([of(visit), of(pet), this.ownerService.getOwner(pet.ownerId!)])), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: ([visit, pet, owner]) => { this.form.patchValue(visit); this.visit = visit; this.currentPet = pet; this.currentOwner = owner }, + error: error => this.snackBar.open(error) + }); + } + + onSubmit() { + const formDate = this.form.controls['date'].value as DateTime|string; + const visit = { + date: typeof formDate === 'string' ? formDate : formDate.toISODate()!, + description: this.form.controls['description'].value + } as Visit; + this.visitService.updateVisit(this.visit!.id, visit).subscribe({ + next: _ => this.router.navigate(['/owners', this.currentOwner!.id!]), + error: error => this.snackBar.open(error) + }); + } +} diff --git a/angular-client/src/app/visit-list/visit-list.component.html b/angular-client/src/app/visit-list/visit-list.component.html new file mode 100644 index 0000000..1773121 --- /dev/null +++ b/angular-client/src/app/visit-list/visit-list.component.html @@ -0,0 +1,15 @@ +@if (visits.length !== 0) { + + + + + + + + + +
Actions + Edit Visit + +
+} \ No newline at end of file diff --git a/angular-client/src/app/visit-list/visit-list.component.scss b/angular-client/src/app/visit-list/visit-list.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/angular-client/src/app/visit-list/visit-list.component.spec.ts b/angular-client/src/app/visit-list/visit-list.component.spec.ts new file mode 100644 index 0000000..49b366d --- /dev/null +++ b/angular-client/src/app/visit-list/visit-list.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { VisitListComponent } from './visit-list.component'; + +describe('VisitListComponent', () => { + let component: VisitListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [VisitListComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(VisitListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/angular-client/src/app/visit-list/visit-list.component.ts b/angular-client/src/app/visit-list/visit-list.component.ts new file mode 100644 index 0000000..21d7c57 --- /dev/null +++ b/angular-client/src/app/visit-list/visit-list.component.ts @@ -0,0 +1,25 @@ +import { Component, inject, Input } from '@angular/core'; +import { Visit, VisitService } from '../../../build/client'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { RouterLink } from '@angular/router'; +import { MatTableModule } from '@angular/material/table'; +import { MatButtonModule } from '@angular/material/button'; + +@Component({ + selector: 'app-visit-list', + imports: [RouterLink, MatTableModule, MatButtonModule], + templateUrl: './visit-list.component.html', + styleUrl: './visit-list.component.scss' +}) +export class VisitListComponent { + @Input() visits = [] as Visit[]; + columnsToDisplay = ['date', 'description', 'actions']; + private readonly visitService = inject(VisitService); + private readonly snackBar = inject(MatSnackBar); + deleteVisit(visit: Visit) { + this.visitService.deleteVisit(visit.id).subscribe({ + next: _ => this.visits = this.visits.filter(v => v.id !== visit.id), + error: error => this.snackBar.open(error) + }); + } +} diff --git a/angular-client/src/app/welcome/welcome.component.html b/angular-client/src/app/welcome/welcome.component.html index a234aee..5fdfbdd 100644 --- a/angular-client/src/app/welcome/welcome.component.html +++ b/angular-client/src/app/welcome/welcome.component.html @@ -1,5 +1,2 @@ - - Welcome to Petclinic - Welcome - pets logo - \ No newline at end of file +

Welcome to Petclinic

+pets logo \ No newline at end of file diff --git a/angular-client/src/app/welcome/welcome.component.ts b/angular-client/src/app/welcome/welcome.component.ts index 304b2c0..5641919 100644 --- a/angular-client/src/app/welcome/welcome.component.ts +++ b/angular-client/src/app/welcome/welcome.component.ts @@ -3,7 +3,7 @@ import { MatCardModule } from '@angular/material/card'; @Component({ selector: 'app-welcome', - imports: [MatCardModule], + imports: [], templateUrl: './welcome.component.html', styleUrl: './welcome.component.scss' })