Doguhan Uluca PRO
Author of the best-selling Angular for Enterprise-Ready Web Apps. Google Developers Expert in Angular. Agile, JavaScript and Cloud expert, Go player.
Doguhan Uluca
Technical Fellow @
set of critical computer applications perceived as vital to running an enterprise
Source: Wikipedia
A way to
What you avoid implementing is more important than what you implement
Source: A wise developer
*Source: Angular Team, Google Analytics, 2018
app.ts
rootRouter
services
pipes
modules
/a: default
/master
/detail
/b/...
/c
childRouter
/d
/e
/f
User Roles
Modules
implement a Login experience
achieved iteratively and incrementally
duluca/lemon-mart/src/app/app-routing.module.ts
{
path: 'user',
loadChildren: () =>
import('./user/user.module')
.then(m => m.UserModule),
},
{
path: 'manager',
loadChildren: () =>
import('./manager/manager.module')
.then(m => m.ManagerModule),
canLoad: [AuthGuard],
},
duluca/lemon-mart/src/app/user/user/user.service.ts
readonly currentUser$ = new BehaviorSubject<IUser>(
this.getItem('user') ||
new User()
)
duluca/lemon-mart/src/app/user/view-user/view-user.component.ts
<div *ngIf="currentUser$ | async as currentUser">
<div>{{currentUser.firstName}}</div>
...
</div>
duluca/lemon-mart/src/app/user/user/user.service.ts
updateResponse.pipe(
tap(
res => {
this.currentUser$.next(res)
this.removeItem('draft-user')
},
err => observableThrowError(err)
)
)
duluca/lemon-mart/user/profile/profile.component.ts
name: this.formBuilder.group({
first: [(user && user.name.first) || '', RequiredTextValidation],
middle: [(user && user.name.middle) || '', OneCharValidation],
last: [(user && user.name.last) || '', RequiredTextValidation],
}),
duluca/lemon-mart/user/profile/profile.component.html
<div fxLayout="row" fxLayout.lt-sm="column"
[formGroup]="userForm.get('name')" fxLayoutGap="10px">
<mat-form-field appearance="outline" fxFlex="40%">
<mat-label>First Name</mat-label>
<input matInput aria-label="First Name" formControlName="first" />
<mat-error *ngIf="userForm.get('name').get('first').hasError('required')">
First Name is required
</mat-error>
<mat-error *ngIf="userForm.get('name').get('first').hasError('minLength')">
Must be at least 2 characters
</mat-error>
<mat-error *ngIf="userForm.get('name').get('first').hasError('maxLength')">
Can't exceed 50 characters
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" fxFlex="20%">
<mat-label>MI</mat-label>
<input matInput aria-label="Middle Initial" formControlName="middle" />
<mat-error *ngIf="userForm.get('name').get('middle').invalid">
Only inital
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" fxFlex="40%">
<mat-label>Last Name</mat-label>
<input matInput aria-label="Last Name" formControlName="last" />
<mat-error *ngIf="userForm.get('name').get('last').hasError('required')">
Last Name is required
</mat-error>
<mat-error *ngIf="userForm.get('name').get('last').hasError('minLength')">
Must be at least 2 characters
</mat-error>
<mat-error *ngIf="userForm.get('name').get('last').hasError('maxLength')">
Can't exceed 50 characters
</mat-error>
</mat-form-field>
</div>
duluca/lemon-mart/user/name-input/name-input.component.ts
export class NameInputComponent implements OnInit {
@Input() initialData: IName
@Input() disable: boolean
@Output() formReady = new EventEmitter<FormGroup>()
formGroup: FormGroup
constructor(private formBuilder: FormBuilder) {}
ngOnInit() {
this.formGroup = this.buildForm(this.initialData)
this.formReady.emit(this.formGroup)
if (this.disable) {
this.formGroup.disable()
}
}
...
}
duluca/lemon-mart/user/name-input/name-input.component.ts
export class NameInputComponent implements OnInit {
...
buildForm(initialData?: IName): FormGroup {
return this.formBuilder.group({
first: [name ? name.first : '', RequiredTextValidation],
middle: [name ? name.middle : '', OptionalTextValidation],
last: [name ? name.last : '', RequiredTextValidation],
})
}
}
duluca/lemon-mart/user/name-input/name-input.component.html
template: `
<form [formGroup]="formGroup">
<div fxLayout="row" fxLayout.lt-sm="column" fxLayoutGap="10px">
<mat-form-field appearance="outline" fxFlex="40%">
<mat-label>First Name</mat-label>
<input matInput aria-label="First Name" formControlName="first" required />
<mat-error *ngIf="formGroup.get('first').hasError('required')">
First Name is required
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" fxFlex="20%">
<mat-label>MI</mat-label>
<input matInput aria-label="Middle" formControlName="middle" />
</mat-form-field>
<mat-form-field appearance="outline" fxFlex="40%">
<mat-label>Last Name</mat-label>
<input matInput aria-label="Last Name" formControlName="last" required />
<mat-error *ngIf="formGroup.get('last').hasError('required')">
Last Name is required
</mat-error>
</mat-form-field>
</div>
</form>
`,
duluca/lemon-mart/user/profile/profile.component.ts
<form [formGroup]="userForm">
<ng-template matStepLabel>Contact Information</ng-template>
<div class="stepContent">
<app-name-input [initialData]="name$ | async"
(formReady)="userForm.setControl('name', $event)">
</app-name-input>
...
</div>
</form>
Master/Detail
Bound or Routed
duluca/lemon-mart/src/app/user/profile/profile.component.html
<mat-step [stepControl]="formGroup">
<form [formGroup]="formGroup" (ngSubmit)="save(formGroup)">
<ng-template matStepLabel>Review</ng-template>
<div class="stepContent">
Review and update your user profile.
<app-view-user [user]="formGroup.value"></app-view-user>
</div>
…
</form>
</mat-step>
duluca/lemon-mart/src/app/manager/manager-routing.module.ts
{
path: 'users',
component: UserManagementComponent,
children: [
{ path: '', component: UserTableComponent, outlet: 'master' },
{
path: 'user',
component: ViewUserComponent,
outlet: 'detail',
resolve: {
user: UserResolve,
},
},
],
canActivate: [AuthGuard],
canActivateChild: [AuthGuard],
data: {
expectedRole: Role.Manager,
},
}
duluca/lemon-mart/src/app/manager/user—table/user-table.component.ts
<a mat-button mat-icon-button
[routerLink]="[
'/manager/users',
{ outlets: { detail: ['user', { userId: row.id }] } }
]"
skipLocationChange>
<mat-icon>visibility</mat-icon>
</a>
duluca/lemon-mart/src/app/user/view-user/view-user.component.ts
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { BehaviorSubject } from 'rxjs'
import { IUser, User } from '../user/user'
@Component({
selector: 'app-view-user',
template: `
<div *ngIf="currentUser$ | async as currentUser">
<mat-card>
<mat-card-header>
<div mat-card-avatar><mat-icon>account_circle</mat-icon></div>
<mat-card-title>{{ currentUser.fullName }}</mat-card-title>
<mat-card-subtitle>{{ currentUser.role }}</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<p><span class="mat-input bold">E-mail</span></p>
<p>{{ currentUser.email }}</p>
<p><span class="mat-input bold">Date of Birth</span></p>
<p>{{ currentUser.dateOfBirth | date: 'mediumDate' }}</p>
</mat-card-content>
<mat-card-actions *ngIf="editMode">
<button mat-button mat-raised-button>Edit</button>
</mat-card-actions>
</mat-card>
</div>
`,
styles: [
`
.bold {
font-weight: bold;
}
`,
],
})
export class ViewUserComponent implements OnInit, OnChanges {
@Input() user: IUser
readonly currentUser$ = new BehaviorSubject(new User())
get editMode() {
return !this.user
}
constructor(private route: ActivatedRoute) {}
ngOnInit() {
if (this.route.snapshot.data.user) {
const snapshotUser = User.Build(this.route.snapshot.data.user)
if (!snapshotUser.dateOfBirth) {
snapshotUser.dateOfBirth = Date.now() // for data mocking purposes only
}
this.currentUser$.next(snapshotUser)
}
}
ngOnChanges(changes: SimpleChanges): void {
this.currentUser$.next(User.Build(changes.user.currentValue))
}
}
duluca/lemon-mart/user/profile/profile.component.ts
<div fxLayout="row" fxLayout.lt-sm="column" class="margin-top"
fxLayoutGap="10px">
<mat-label class="mat-body-1">Select the Limoncu level:
<app-lemon-rater formControlName="level">
</app-lemon-rater>
</mat-label>
</div>
<form [formGroup]="userForm">
<ng-template matStepLabel>Contact Information</ng-template>
<div class="stepContent">
<app-name-input [initialData]="name$ | async"
(formReady)="userForm.setControl('name', $event)">
</app-name-input>
...
</div>
</form>
User Controls
Components
duluca/lemon-mart/src/app/user/user/user.ts
export interface IUser {
id: string
email: string
name: IName
…
address: {
line1: string
line2: string
city: string
state: string
zip: string
}
phones: IPhone[]
}
export interface IName {
first: string
middle?: string
last: string
}
duluca/lemon-mart/src/app/user/user/user.ts
export interface IPhone {
type: string
number: string
id: number
}
export class User implements IUser
duluca/lemon-mart/src/app/user/user/user.service.ts
getUser(id): Observable<IUser> {
return this.httpClient.get<IUser>(`${environment.baseUrl}/v1/user/${id}`)
}
duluca/local-weather-app/src/app/weather/weather.service.ts
private getCurrentWeatherHelper(uriParams: string):
Observable<ICurrentWeather> {
return this.httpClient
.get<ICurrentWeatherData>(
`${environment.baseUrl}api.openweathermap.org/data/2.5/weather?` +
`${uriParams}&appid=${environment.appId}`
)
.pipe(map(data => this.transformToICurrentWeather(data)))
}
duluca/lemon-mart/src/app/auth/role.enum.ts
export enum Role {
None = 'none',
Clerk = 'clerk',
Cashier = 'cashier',
Manager = 'manager',
}
duluca/lemon-mart/src/app/user/user/user.ts
export class User implements IUser {
static Build(user: IUser) {
return new User(
user.id,
user.email,
user.name,
…
)
}
get fullName() {
return this.name ?
`${this.name.first} ${this.name.middle} ${this.name.last}` : ''
}
toJSON() {
return JSON.stringify(...)
}
}
duluca/lemon-mart/src/app/common/base-form.class.ts
export abstract class BaseFormComponent<TFormData> {
@Input() initialData: TFormData
@Input() disable: boolean
@Output() formReady: EventEmitter<AbstractControl>
formGroup: FormGroup
private registeredForms: string[] = []
constructor() {
this.formReady = new EventEmitter<AbstractControl>(true)
}
abstract buildForm(initialData?: TFormData): FormGroup
patchUpdatedData(data) {
this.formGroup.patchValue(data, { onlySelf: false })
}
…
}
duluca/lemon-mart/src/app/common/base-form.class.ts
export class NameInputComponent extends BaseFormComponent<IName>
implements OnInit, OnChanges {
constructor(private formBuilder: FormBuilder) {
super()
}
buildForm(initialData?: IName): FormGroup {
const name = initialData
return this.formBuilder.group({ … })
}
ngOnInit() {
this.formGroup = this.buildForm(this.initialData)
this.formReady.emit(this.formGroup)
}
…
}
Angular Evergreen
VS Code Extension
@duluca
linkedin.com/in/duluca
github.com/duluca
Feedback loop cycle reduced from 20 minutes to 1 minute
~25% reduction in package dependencies
Eliminated variations in versions of dependencies
~25% reduction in software bugs reported
~25% increase in velocity
Before
After
10 projects (with libraries, multiple Angular apps)
50 developers
With 33% of project time used, only 11% of functionality implemented
By Doguhan Uluca
The key ingredients to architecting for scalability are thinking router-first, keeping it simple, mastering the fundamentals and being reactive. A router-first approach to SPA design saving development teams significant waste in duplicative work, and re-architecting of the codebase; enabling better collaboration and achieving sub-second first meaningful paints in your application
Author of the best-selling Angular for Enterprise-Ready Web Apps. Google Developers Expert in Angular. Agile, JavaScript and Cloud expert, Go player.