diff --git a/projects/MyCar/src/app/appointment-scheduler/appointment-scheduler-http.service.ts b/projects/MyCar/src/app/appointment-scheduler/appointment-scheduler-http.service.ts index 99b9e8b..ff228cb 100644 --- a/projects/MyCar/src/app/appointment-scheduler/appointment-scheduler-http.service.ts +++ b/projects/MyCar/src/app/appointment-scheduler/appointment-scheduler-http.service.ts @@ -1,31 +1,81 @@ import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { JwtHelperService } from '@auth0/angular-jwt'; -import { sortBy } from 'lodash-es'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { environment } from '../../environments/environment'; import { AuthTokenService } from '../auth/auth-token.service'; import { AppointmentScheduleResponse } from './appointment-scheduler.models'; +import { DynamicFormData } from './appointment-scheduler.component'; @Injectable() export class AppointmentSchedulerHttpService { + private static fuelEconomyApiMapper(dto): DynamicFormData[] { + return dto.menuItem.map(item => { + return { + formValue: item.value, + viewValue: item.text + } as DynamicFormData + }); + } + + private static fuelEconomyApiHttpOptions = { headers: new HttpHeaders({'Accept': 'application/json'}) }; + constructor( private authTokenService: AuthTokenService, private httpClient: HttpClient, private jwtHelperService: JwtHelperService) { } - public getAvaliableDates(): Observable { + public getScheduleViewModel(): Observable { const token = this.jwtHelperService.decodeToken(this.authTokenService.authToken); return this.httpClient.get(`${environment.apiBaseUrl}/${token.conos[0]}/schedule`, environment.httpOptions).pipe( map(dto => { - dto.daysAvailable = dto.daysAvailable.map(d => new Date(d * 1000).toISOString()); + dto.daysAvailable = dto.daysAvailable.map(d => new Date(d * 1000)); + + const problemDescriptions = []; + for (let p of dto.problemDescriptions) { + for (let viewValue of p.Desc) { + problemDescriptions.push({ + formValue: p.category, + viewValue: viewValue + } as DynamicFormData); + } + } + dto.problemDescriptions = problemDescriptions; + return dto as AppointmentScheduleResponse; }) ); } + + public getVehicleYears(): Observable { + return this.httpClient.get( + `https://www.fueleconomy.gov/ws/rest/vehicle/menu/year`, + AppointmentSchedulerHttpService.fuelEconomyApiHttpOptions) + .pipe( + map(AppointmentSchedulerHttpService.fuelEconomyApiMapper) + ); + } + + public getVehicleMakesByYear(year: string): Observable { + return this.httpClient.get( + `https://www.fueleconomy.gov/ws/rest/vehicle/menu/make?year=${year}`, + AppointmentSchedulerHttpService.fuelEconomyApiHttpOptions) + .pipe( + map(AppointmentSchedulerHttpService.fuelEconomyApiMapper) + ); + } + + public getVehicleModelsByYearAndMake(year: string, make: string): Observable { + return this.httpClient.get( + `https://www.fueleconomy.gov/ws/rest/vehicle/menu/model?year=${year}&make=${make}`, + AppointmentSchedulerHttpService.fuelEconomyApiHttpOptions) + .pipe( + map(AppointmentSchedulerHttpService.fuelEconomyApiMapper) + ); + } } diff --git a/projects/MyCar/src/app/appointment-scheduler/appointment-scheduler-routing.module.ts b/projects/MyCar/src/app/appointment-scheduler/appointment-scheduler-routing.module.ts index aeb39d3..b625a7a 100644 --- a/projects/MyCar/src/app/appointment-scheduler/appointment-scheduler-routing.module.ts +++ b/projects/MyCar/src/app/appointment-scheduler/appointment-scheduler-routing.module.ts @@ -12,6 +12,11 @@ export const appointmentSchedulerRoutes: Routes = [ path: '', component: AppointmentSchedulerComponent, canDeactivate: [CanDeactivateGuard] + }, + { + path: ':vehicleId', + component: AppointmentSchedulerComponent, + canDeactivate: [CanDeactivateGuard] } ]; @NgModule({ diff --git a/projects/MyCar/src/app/appointment-scheduler/appointment-scheduler.component.html b/projects/MyCar/src/app/appointment-scheduler/appointment-scheduler.component.html index fe42f1c..edb1891 100644 --- a/projects/MyCar/src/app/appointment-scheduler/appointment-scheduler.component.html +++ b/projects/MyCar/src/app/appointment-scheduler/appointment-scheduler.component.html @@ -1,4 +1,4 @@ - +
@@ -74,10 +74,11 @@
- + - - {{v.viewValue}} + + -- + {{v.viewValue}} Vehicle is required @@ -86,12 +87,15 @@ - + + -- + {{v.viewValue}} + Vehicle year is required - + {{v.viewValue}} Vehicle make is required @@ -119,10 +123,13 @@ - + {{issue.viewValue}} - cancel + cancel + [matChipInputAddOnBlur]="false"> - - + + {{issue.viewValue}} @@ -146,7 +157,7 @@
- +
diff --git a/projects/MyCar/src/app/appointment-scheduler/appointment-scheduler.component.ts b/projects/MyCar/src/app/appointment-scheduler/appointment-scheduler.component.ts index d9c8ef6..4a428ff 100644 --- a/projects/MyCar/src/app/appointment-scheduler/appointment-scheduler.component.ts +++ b/projects/MyCar/src/app/appointment-scheduler/appointment-scheduler.component.ts @@ -8,13 +8,16 @@ import { import { FormBuilder, FormControl, - FormGroup, ValidationErrors, ValidatorFn, + FormGroup, + ValidationErrors, + ValidatorFn, Validators } from '@angular/forms'; import { ENTER } from '@angular/cdk/keycodes'; import { MatDialog } from '@angular/material'; -import { CanDeactivate } from '@angular/router'; +import { ActivatedRoute, CanDeactivate } from '@angular/router'; +import sortBy from 'lodash-es/sortBy'; import uniqBy from 'lodash-es/uniqBy'; import { Observable, of } from 'rxjs'; @@ -24,19 +27,21 @@ import { StoreInfo } from '../store-info/store-info.models'; import { StoreInfoService } from '../store-info/store-info.module'; import { AppointmentSchedulerHttpService } from './appointment-scheduler-http.service'; import { AppointmentScheduleResponse } from './appointment-scheduler.models'; +import { VehiclesHttpService } from '../my-vehicles/vehicles-http.service'; +import { VehicleOverview } from '../my-vehicles/vehicle.models'; export const AtLeastOne = (validator: ValidatorFn) => ( group: FormGroup, ): ValidationErrors | null => { - const hasAtLeastOne = group && group.controls && Object.keys(group.controls) - .some(k => !validator(group.controls[k])); + const controls = group && group.controls && Object.keys(group.controls); + const hasAtLeastOneControlFailedValidatorFn = controls.some(k => !validator(group.controls[k])); - return hasAtLeastOne ? null : { - atLeastOne: true, - }; + console.log('AtLeastOne validator executed', controls, hasAtLeastOneControlFailedValidatorFn); + + return hasAtLeastOneControlFailedValidatorFn ? null : { atLeastOne: true }; }; -interface DynamicFormData { +export interface DynamicFormData { formValue: string; viewValue: string; } @@ -57,69 +62,89 @@ export enum ScheduleProcess { export class AppointmentSchedulerComponent implements OnInit, CanDeactivate { public scheduleProgress: ScheduleProcess = ScheduleProcess.Entry; - public appointmentSchedulerResponse: AppointmentScheduleResponse; - public storeInfo: StoreInfo = { PhoneNumberToCall: { NumberForWebLink: '+19524322454' } }; - // Stepper config - public isLinear = true; - // Autocomplete inputs config - public readonly chipIsSelectable = true; - public readonly chipIsRemovable = true; - public readonly chipAddsOnBlur = false; public readonly separatorKeysCodes: number[] = [ENTER]; // Step 3: Select date - public dateFormGroup: FormGroup; + /** + * Set the minimum date to now so users cannot schedule appointments in the past + */ public minDate = new Date(); - public maxDate = new Date(); + + /** + * Prevent users from scheduling too far in advance. + */ + public maxDate = new Date().setMonth(this.minDate.getMonth() + 2); + + public dateFormGroup: FormGroup; public dateFilter = (d: Date) => { - // TODO DJC Discuss with dad to see if he can easily do ISO strings - return this.appointmentSchedulerResponse.daysAvailable.includes(d.toISOString()); + const formattedDate = this.dateFormatter(d); + return this.daysAvailable.includes(formattedDate); }; + /** + * A list of days available for the calendar view-model to + * determine if the date is available for scheduling. + */ + private daysAvailable: string[]; + + private dateFormatter(d: Date): string { + const month = d.getUTCMonth() + 1; + return `${month}/${d.getUTCDate()}/${d.getUTCFullYear()}`; + } + // Step 4: Select vehicle public vehicleFormGroup: FormGroup; public isVehicleNewToCustomer: 'New' | 'Existing' = 'Existing'; - // Step 5: Describe vehicle needs - public issuesFormGroup: FormGroup; + private vehicleId: string; - public myVehicles: DynamicFormData[] = [ - {formValue: 'VIN1', viewValue: '2006 Chevrolet Impala 293-GBE'}, - {formValue: 'VIN2', viewValue: '2011 Toyota Avalon 293-GBE'} - ]; + /** + * A user's vehicles if the schedule an appointment for + * an existing vehicle + */ + public myVehicles: DynamicFormData[] = []; + + /** + * Valid vehicle years if scheduling an appointment + * for a new vehicle. + */ + public vehicleYears: DynamicFormData[] = []; + + /** + * Valid vehicle makes if scheduling an appointment + * for a new vehicle. + */ + public vehicleMakes: DynamicFormData[] = []; - public vehicleMakes: DynamicFormData[] = [ - {formValue: 'Chevrolet', viewValue: 'Chevrolet'}, - {formValue: 'Ford', viewValue: 'Ford'}, - {formValue: 'Jeep', viewValue: 'Jeep'} - ]; + /** + * Valid vehicle models if scheduling an appointment + * for a new vehicle. + */ + public vehicleModels: DynamicFormData[] = []; - public vehicleModels: DynamicFormData[] = [ - {formValue: 'Impala', viewValue: 'Impala'}, - {formValue: 'Mustang', viewValue: 'Mustang'}, - {formValue: 'Wrangler', viewValue: 'Wrangler'} - ]; + // Step 5: Describe vehicle needs + public issuesFormGroup: FormGroup; + /** + * Selected issues which the user would like addressed or investigated + */ public issues: DynamicFormData[] = []; - public allIssues: DynamicFormData[] = [ - {formValue: 'Squeaky brakes', viewValue: 'Squeaky brakes'}, - {formValue: 'Tire tread low', viewValue: 'Tire tread low'}, - {formValue: 'Clicking noise in dash', viewValue: 'Clicking noise in dash'}, - {formValue: 'Coolant leaking', viewValue: 'Coolant leaking'}, - {formValue: 'Coolant level low', viewValue: 'Coolant level low'}, - {formValue: 'Coolant freeze protection low', viewValue: 'Coolant freeze protection low'}, - {formValue: 'Coolant flush required', viewValue: 'Coolant flush required'}, - {formValue: 'Coolant is dirty', viewValue: 'Coolant is dirty'} - ]; + /** + * All common issues returned by the API + */ + public allIssues: DynamicFormData[] = []; + /** + * Filtered issues to choose from based on autocomplete input + */ public filteredIssues: Observable; @ViewChild('issueInput', {static: false}) issueInput: ElementRef; @@ -128,28 +153,27 @@ export class AppointmentSchedulerComponent implements OnInit, CanDeactivate !!issue ? this.filterIssues(issue) : [...this.allIssues] ) - ); - - this.storeInfoService.getStoreInfo() + this.filteredIssues = this.commonIssues.valueChanges .pipe( - first() - ) - .subscribe(storeInfo => { - this.storeInfo = storeInfo; - }); + startWith(null), + map((issue: string | null) => !!issue ? this.filterIssues(issue) : [...this.allIssues] ) + ); + + this.vehicleId = this.route.snapshot.paramMap.get('vehicleId'); - this.getAvailableDates(); + // HTTP Requests + this.getStoreInfo(); + this.getScheduleViewModel(); + this.getVehiclesForClient(); + this.getVehicleYears(); } public canDeactivate(): Observable | boolean { @@ -158,31 +182,98 @@ export class AppointmentSchedulerComponent implements OnInit, CanDeactivate { + this.daysAvailable = response.daysAvailable.map(this.dateFormatter); + this.allIssues = response.problemDescriptions; + }); } - private getAvailableDates(): void { - this.appointmentSchedulerHttpService.getAvaliableDates() + private getStoreInfo(): void { + this.storeInfoService.getStoreInfo() .pipe( first() - ).subscribe((response: AppointmentScheduleResponse) => { - this.appointmentSchedulerResponse = response; + ) + .subscribe(storeInfo => { + this.storeInfo = storeInfo; + }); + } + + private getVehiclesForClient(): void { + this.vehiclesHttpService.getVehiclesForClient() + .pipe(first()) + .subscribe((vehicles: VehicleOverview[]) => { + this.myVehicles = vehicles.map((v) => { + return { + formValue: v.vehicleID, + viewValue: `${v.year} ${v.make} ${v.model} (${v.license})` + } + }); + this.myVehicles = sortBy(this.myVehicles, 'viewValue'); + + // If the is scheduling an appointment with an existing vehicle, auto-select the input + if (!!this.vehicleId) { + this.knownVehicle.setValue(this.vehicleId); + } + else { + this.knownVehicle.setValue(null); + } + }); + } + + private getVehicleYears(): void { + this.appointmentSchedulerHttpService.getVehicleYears() + .pipe( + first() + ).subscribe((response: DynamicFormData[]) => { + this.vehicleYears = response; + }); + } + + private getVehicleMakesByYear(): void { + this.appointmentSchedulerHttpService.getVehicleMakesByYear(this.year.value) + .pipe( + first() + ).subscribe((response: DynamicFormData[]) => { + this.vehicleMakes = response; + }); + } + + private getVehicleModelsByYearAndMake(): void { + this.appointmentSchedulerHttpService.getVehicleModelsByYearAndMake(this.year.value, this.make.value) + .pipe( + first() + ).subscribe((response: DynamicFormData[]) => { + this.vehicleModels = response; }); } /* - * Autocomplete chip list methods + * Date filtering and date picker methods */ + public onDatePickerOpened(): void { + this.getScheduleViewModel(); + } + /* + * Autocomplete chip list methods + */ public addFromAutocomplete(event) { let value: DynamicFormData = event.option.value; @@ -195,6 +286,25 @@ export class AppointmentSchedulerComponent implements OnInit, CanDeactivate value1 === value.trim()); + // if (index !== -1) { + // this.issues.splice(index, 1); // 6 + // } + // } + // } else { + // this.commonIssues.updateValueAndValidity(); // 7 + // } + // } public remove(item): void { const index = this.issues.indexOf(item); @@ -202,18 +312,37 @@ export class AppointmentSchedulerComponent implements OnInit, CanDeactivate= 0) { this.issues.splice(index, 1); } + + this.commonIssues.updateValueAndValidity(); // <---- Here it is + this.commonIssues.markAsDirty(); } + /* + * Step 4: Vehicle Selection + */ public onNewOrExistingVehicleChange($event): void { this.isVehicleNewToCustomer = $event.value; this.buildVehicleForm(); } + public onYearChange(): void { + this.getVehicleMakesByYear(); + } + + public onMakeChange(): void { + this.getVehicleModelsByYearAndMake(); + } + + /* + * Final Step: Submit! + */ + public onSubmit(): void { this.scheduleProgress = ScheduleProcess.IsSubmitting; - console.log(this.vehicleFormGroup); - console.log(this.dateFormGroup); + console.log('VehicleFormGroup', this.vehicleFormGroup); + console.log('DateFormGroup', this.dateFormGroup); + console.log('IssuesFormGroup', this.issuesFormGroup); setTimeout(() => { this.scheduleProgress = ScheduleProcess.Success; diff --git a/projects/MyCar/src/app/appointment-scheduler/appointment-scheduler.models.ts b/projects/MyCar/src/app/appointment-scheduler/appointment-scheduler.models.ts index 890c803..87c7ff4 100644 --- a/projects/MyCar/src/app/appointment-scheduler/appointment-scheduler.models.ts +++ b/projects/MyCar/src/app/appointment-scheduler/appointment-scheduler.models.ts @@ -1,26 +1,15 @@ +import { DynamicFormData } from './appointment-scheduler.component'; + export interface AppointmentScheduleResponse { /** * Note: ISO string from Date object */ - daysAvailable: string, + daysAvailable: Date[], /** * Text-based description of how to schedule */ guidelines: string[] // TODO DJC discuss with dad, - problemDescriptions: ProblemDescription[] -} - -export interface ProblemDescription { - - /** - * The human-readable conditions which are likely to resonate with the customer - */ - desc: string[], - - /** - * The back end value for the condition to investigate or address - */ - category: string + problemDescriptions: DynamicFormData[] } diff --git a/projects/MyCar/src/app/auth/auth-token.interceptor.ts b/projects/MyCar/src/app/auth/auth-token.interceptor.ts index e6a6ba6..e71349d 100644 --- a/projects/MyCar/src/app/auth/auth-token.interceptor.ts +++ b/projects/MyCar/src/app/auth/auth-token.interceptor.ts @@ -6,6 +6,7 @@ import { catchError, first, flatMap, map, switchMap } from 'rxjs/operators'; import { AuthTokenService } from './auth-token.service'; import { AuthService } from './auth.service'; +import { environment } from '../../environments/environment'; @Injectable() export class JwtInterceptor implements HttpInterceptor { @@ -17,6 +18,11 @@ export class JwtInterceptor implements HttpInterceptor { constructor(private authService: AuthService, private authTokenService: AuthTokenService, private router: Router) { } intercept(request: HttpRequest, next: HttpHandler): Observable> { + // For requests which are not to our own API, avoid adding the authorization header or refresh token handling. + if (!request.url.startsWith(environment.apiBaseUrl)) { + return next.handle(request); + } + request = this.addAuthHeader(request); return next.handle(request) @@ -77,7 +83,7 @@ export class JwtInterceptor implements HttpInterceptor { public refreshToken(): Observable { if (this.tokenRefreshIsInProgress) { - return timer(5000); // FIXME DJC Determine how to appropriately signal via a mutex to prevent non-reentracy + return timer(2000); // FIXME DJC Determine how to appropriately signal via a mutex to prevent non-reentracy } else { this.tokenRefreshIsInProgress = true; diff --git a/projects/MyCar/src/app/my-vehicles/my-vehicles.component.html b/projects/MyCar/src/app/my-vehicles/my-vehicles.component.html index 205d549..af6d091 100644 --- a/projects/MyCar/src/app/my-vehicles/my-vehicles.component.html +++ b/projects/MyCar/src/app/my-vehicles/my-vehicles.component.html @@ -1,17 +1,21 @@ - -
- +
+
+ fxFlex="0 1 calc(33.3% - 32px)" + fxFlex.lt-md="0 1 calc(50% - 32px)" + fxFlex.lt-sm="100%" + *ngFor="let vehicle of vehicles" + [year]="vehicle.year" + [make]="vehicle.make" + [model]="vehicle.model" + [license]="vehicle.license" + [recommendedServices]="vehicle.recommendedServices" + [recommendedServiceSeverity]="vehicle.aggregateSeverity" + [vehicleId]="vehicle.vehicleID"> -
- +
diff --git a/projects/MyCar/src/app/my-vehicles/my-vehicles.component.scss b/projects/MyCar/src/app/my-vehicles/my-vehicles.component.scss index 26ad2fc..1ed9b9d 100644 --- a/projects/MyCar/src/app/my-vehicles/my-vehicles.component.scss +++ b/projects/MyCar/src/app/my-vehicles/my-vehicles.component.scss @@ -2,4 +2,8 @@ .my-vehicles-container { margin: 12px 12px; } + + ma-vehicle-card { + margin-bottom: 32px; + } } diff --git a/projects/MyCar/src/app/my-vehicles/vehicle-card/vehicle-card.component.html b/projects/MyCar/src/app/my-vehicles/vehicle-card/vehicle-card.component.html index b72a2bb..16fda44 100644 --- a/projects/MyCar/src/app/my-vehicles/vehicle-card/vehicle-card.component.html +++ b/projects/MyCar/src/app/my-vehicles/vehicle-card/vehicle-card.component.html @@ -1,9 +1,9 @@ - - - - + + + + {{year}} {{make}} {{model}} @@ -42,6 +42,8 @@ - - - +
diff --git a/projects/MyCar/src/app/my-vehicles/vehicle-card/vehicle-card.component.scss b/projects/MyCar/src/app/my-vehicles/vehicle-card/vehicle-card.component.scss index df4a71c..9be4b13 100644 --- a/projects/MyCar/src/app/my-vehicles/vehicle-card/vehicle-card.component.scss +++ b/projects/MyCar/src/app/my-vehicles/vehicle-card/vehicle-card.component.scss @@ -4,4 +4,9 @@ .menu-button { opacity: 0.54; } + + button ma-recommended-service-severity { + display: inline-block; + width: 30px; + } }