- The Basics
- Components And Data Binding Deep Dive
- Directives Deep Dive
- Services And Dependency Injection
- Routing
- Observables
- Forms
- Http
The project can be created with
ng new course-project
Even if at the moment the recenter Bootstrap version it the 5, the 3 will be used as in the curse to avoid annoying incompatibilities.
npm i --save bootstrap@3
and add it to the angular.json:
"projects": {
"course-project": {
...
"architect": {
"build": {
...
"options": {
...
"styles": [
"node_modules/bootstrap/dist/css/bootstrap.min.css",
"src/styles.css"
],
ng generate component <component>
// or
ng g c <component>
// to ignore test files
ng g c <component> --skipTest true
// to create the component in a sub path
ng g c <base_path>/<component>
The components are placed inside the other components that will primary use them, the shared folder is created for the once that will be used across multiples.
src
├── app
│ ├── app.component.css
│ ├── app.component.html
│ ├── app.component.ts
│ ├── app.module.ts
│ ├── app-routing.module.ts
│ ├── header
│ │ ├── header.component.css
│ │ ├── header.component.html
│ │ └── header.component.ts
│ ├── recipes
│ │ ├── recipe-detail
│ │ │ ├── recipe-detail.component.css
│ │ │ ├── recipe-detail.component.html
│ │ │ └── recipe-detail.component.ts
│ │ ├── recipe-list
│ │ │ ├── recipe-item
│ │ │ │ ├── recipe-item.component.css
│ │ │ │ ├── recipe-item.component.html
│ │ │ │ └── recipe-item.component.ts
│ │ │ ├── recipe-list.component.css
│ │ │ ├── recipe-list.component.html
│ │ │ └── recipe-list.component.ts
│ │ ├── recipe.model.ts
│ │ ├── recipes.component.css
│ │ ├── recipes.component.html
│ │ └── recipes.component.ts
│ ├── shared
│ │ └── ingredient.model.ts
│ └── shopping-list
│ ├── shopping-edit
│ │ ├── shopping-edit.component.css
│ │ ├── shopping-edit.component.html
│ │ └── shopping-edit.component.ts
│ ├── shopping-list.component.css
│ ├── shopping-list.component.html
│ └── shopping-list.component.ts
├── assets
├── environments
│ ├── environment.prod.ts
│ └── environment.ts
├── favicon.ico
├── index.html
├── main.ts
├── polyfills.ts
├── styles.css
└── test.ts
Definition:
export class Recipe {
constructor(
public name: string,
public description: string,
public imagePath: string
) {}
}
Usage:
@Component({
selector: 'app-recipe-list',
templateUrl: './recipe-list.component.html',
styleUrls: ['./recipe-list.component.css'],
})
export class RecipeListComponent implements OnInit {
recipes: Recipe[] = [
new Recipe("A Test Recipe", "Just a test", "https://placedog.net/500/280")
];
constructor() {}
ngOnInit(): void {}
}
<a href="#" class="list-group-item clearfix" *ngFor="let recipe of recipes">
<div class="pull-left">
<h4 class="list-group-item-heading">{{ recipe.name }}</h4>
<p class="list-group-item-text">{{ recipe.description }}</p>
</div>
<span class="pull-right">
<img
[src]="recipe.imagePath"
alt="{{ recipe.name }}"
class="img-responsive"
style="max-height: 50px"
/>
</span>
</a>
A first simple navigation form the HeaderComponent
, can be accomplished
emitting a value corresponding to the wanted page:
<!-- ... -->
<li><a href="#" (click)="onSelect('recipe')">Recipes</a></li>
<li><a href="#" (click)="onSelect('shopping-list')">Shopping List</a></li>
<!-- ... -->
export class HeaderComponent implements OnInit {
@Output() featureSelected = new EventEmitter<string>();
// ...
onSelect(feature: string) {
this.featureSelected.emit(feature);
}
}
And using an ngIf
statement to display the selected page in the AppComponent
:
<app-header (featureSelected)="onNavigate($event)"></app-header>
<div class="container">
<div class="row">
<div class="col-md-12">
<app-recipes *ngIf="loadedFeature === 'recipe'"></app-recipes>
<app-shopping-list *ngIf="loadedFeature === 'shopping-list'"></app-shopping-list>
</div>
</div>
</div>
export class AppComponent {
loadedFeature = 'recipe';
title = 'course-project';
onNavigate(feature: string) {
this.loadedFeature= feature
}
}
It is possible to pass the content of each recipe form the RecipeListComponent
to the 'RecipeItem' looping over the elements and passing the current value:
<!-- ... -->
<app-recipe-item *ngFor="let curRecipe of recipes" [recipe]="curRecipe"></app-recipe-item>
<!-- ... -->
In the RecipeItemComponent
it then possible to catch this value
using @Input
and displaying the data in the view:
export class RecipeItemComponent implements OnInit {
@Input() recipe: Recipe = {} as Recipe;
constructor() {}
ngOnInit(): void {}
}
<a
href="#"
class="list-group-item clearfix"
>
<div class="pull-left">
<h4 class="list-group-item-heading">{{ recipe.name }}</h4>
<p class="list-group-item-text">{{ recipe.description }}</p>
</div>
<span class="pull-right">
<img [src]="recipe.imagePath" alt="{{ recipe.name }}" class="img-responsive" style="max-height: 50px" />
</span>
</a>
Starting from the RecipeItemComponent
can be emitted an event on select:
<a
href="#"
class="list-group-item clearfix"
(click)="onSelected()"
>
<!-- ... -->
</a>
export class RecipeItemComponent implements OnInit {
// ...
@Output() recipeSelected = new EventEmitter<void>();
// ...
onSelected() {
this.recipeSelected.emit();
}
}
intercepts and resent in the RecipeListComponent
:
<!-- ... -->
<app-recipe-item
*ngFor="let curRecipe of recipes"
[recipe]="curRecipe"
(recipeSelected)="onRecipeSelected(curRecipe)"
></app-recipe-item>
<!-- ... -->
export class RecipeListComponent implements OnInit {
@Output() recipeWasSelected = new EventEmitter<Recipe>();
// ...
onRecipeSelected(recipe: Recipe) {
this.recipeWasSelected.emit(recipe);
}
}
intercepts and resent in the RecipesComponent
:
<div class="row">
<div class="col-md-5">
<app-recipe-list (recipeWasSelected)="selectedRecipe = $event"></app-recipe-list>
</div>
<div class="col-md-7">
<app-recipe-detail
*ngIf="selectedRecipe; else infoText"
[recipe]="selectedRecipe"
></app-recipe-detail>
<ng-template #infoText>
<p>Please select a Recipe!</p>
</ng-template>
</div>
</div>
export class RecipesComponent implements OnInit {
selectedRecipe: Recipe | undefined;
// ...
}
finally showed in the RecipeDetailComponent
:
export class RecipeDetailComponent implements OnInit {
@Input() recipe: Recipe = {} as Recipe;
// ...
}
<div class="row">
<div class="col-xs-12">
<img
[src]="recipe.imagePath"
[alt]="recipe.name"
class="img-responsive"
style="max-height: 300px"
/>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<h1>{{recipe.name}}</h1>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle">
Manage <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a href="#">Add To Shopping List</a></li>
<li><a href="#">Edit Recipe</a></li>
<li><a href="#">Delete Recipe</a></li>
</ul>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-12">
{{recipe.description}}
</div>
</div>
<div class="row">
<div class="col-xs-12">
Ingredients
</div>
</div>
It is possible to read the content of an input from ShoppingEditComponent
:
<!-- ... -->
<div class="col-sm-5 form-group">
<label for="name">Name</label>
<input
type="text"
id="name"
class="form-control"
#nameInput
/>
</div>
<div class="col-sm-2 form-group">
<label for="amount">Amount</label>
<input
type="number"
id="amount"
class="form-control"
#amountInput
/>
</div>
<!-- ... -->
export class ShoppingEditComponent implements OnInit {
@ViewChild('nameInput') nameInputReference: ElementRef = {} as ElementRef;
@ViewChild('amountInput') amountInputReference: ElementRef = {} as ElementRef;
@Output() ingredientAdded = new EventEmitter<Ingredient>();
constructor() {}
ngOnInit(): void {}
onAddItem() {
const name = this.nameInputReference.nativeElement.value;
const amount = this.amountInputReference.nativeElement.value;
const ingredient = new Ingredient(name, amount);
this.ingredientAdded.emit(ingredient);
}
}
and send it to the ShoppingListComponent
:
export class ShoppingListComponent implements OnInit {
ingredients: Ingredient[] = [/* ... */];
// ...
onIngredientAdded(ingredient: Ingredient) {
this.ingredients.push(ingredient);
}
}
It is possible to create a directive using:
ng generate directive directive-name
# or
ng g d directive-name
In order to create a directive to open and close the drop-downs the typescript code is:
export class DropdownDirective {
// property on the DOM element
@HostBinding('class.open') isOpen: boolean = false;
// element on witch the directive is placed
constructor(private elRef: ElementRef) {
console.log(elRef)
}
// click listener on the page
@HostListener('document:click', ['$event']) toggleOpen(event: Event) {
this.isOpen = this.elRef.nativeElement.contains(event.target) ? !this.isOpen : false;
}
// click listener on the element, (doesn't close if clicked on other place)
// @HostListener('click') toggle() {
// this.isOpen = !this.isOpen;
// }
}
In the html it is enough to use:
<!-- recipe-detail -->
<div class="btn-group" appDropdown>
<!-- header -->
<li class="dropdown" appDropdown>
The service:
export class RecipeService {
// data
private recipes: Recipe[] = [
new Recipe('Firs Recipe', 'Just first test', 'https://placedog.net/500/280'),
new Recipe('Second Recipe', 'Just second test', 'https://placedog.net/600/380'),
];
// new hook to pass the data through components
recipeSelected = new EventEmitter<Recipe>();
getRecipes(): Recipe[] {
return this.recipes.slice(); // returns a copy
}
}
The top provider (automatically injected to children):
@Component({
// ...
providers: [RecipeService]
})
export class RecipesComponent implements OnInit {
selectedRecipe: Recipe | undefined;
constructor(private recipeService: RecipeService) {}
ngOnInit(): void {
// new subscriber here
this.recipeService.recipeSelected.subscribe((recipe: Recipe) => {
this.selectedRecipe = recipe;
});
}
}
Loading recipes:
@Component({
// ..
})
export class RecipeListComponent implements OnInit {
recipes: Recipe[] = [];
constructor(private recipeService: RecipeService) {}
ngOnInit(): void {
this.recipes = this.recipeService.getRecipes();
}
// all other not needed anymore
}
Emit selected recipe event:
@Component({
// ...
})
export class RecipeItemComponent implements OnInit {
// ...
constructor(private recipeService: RecipeService) {}
// ...
onSelected() {
// new emitter mechanisms
this.recipeService.recipeSelected.emit(this.recipe);
}
}
and remove html emit:
<!-- recipe-list.component.html -->
<div class="row">
<div class="col-xs-12">
<button class="btn btn-success">New Recipe</button>
</div>
</div>
<hr />
<div class="row">
<div class="col-xs-12">
<app-recipe-item
*ngFor="let curRecipe of recipes"
[recipe]="curRecipe"
<!-- (recipeSelected)="onRecipeSelected(curRecipe)" -->
></app-recipe-item>
</div>
</div>
<!-- recipes.component.html -->
<div class="row">
<div class="col-md-5">
<app-recipe-list
<!-- (recipeWasSelected)="selectedRecipe = $event" -->
></app-recipe-list>
</div>
<div class="col-md-7">
<app-recipe-detail
*ngIf="selectedRecipe; else infoText"
[recipe]="selectedRecipe"
></app-recipe-detail>
<ng-template #infoText>
<p>Please select a Recipe!</p>
</ng-template>
</div>
</div>
The new service:
export class ShoppingListService {
private ingredients: Ingredient[] = [new Ingredient('First', 3), new Ingredient('Second', 5)];
public ingredientsChanged = new EventEmitter<Ingredient[]>();
getIngredients(): Ingredient[] {
return this.ingredients.slice(); // returns a copy
}
addIngredient(ingredient: Ingredient): void {
this.ingredients.push(ingredient);
this.ingredientsChanged.emit(this.ingredients.slice());
}
}
it is provided globally because it will be used also in other parts of the application:
@NgModule({
// ...
providers: [ShoppingListService],
// ...
})
export class AppModule {}
showing the ingredients:
@Component({
// ...
})
export class ShoppingListComponent implements OnInit {
ingredients: Ingredient[] = [];
constructor(private shoppingListService: ShoppingListService) {}
ngOnInit(): void {
this.ingredients = this.shoppingListService.getIngredients();
this.shoppingListService.ingredientsChanged.subscribe((ingredients: Ingredient[]) => {
this.ingredients = ingredients;
});
}
}
adding an ingredient:
@Component({
// ...
})
export class ShoppingEditComponent implements OnInit {
@ViewChild('nameInput') nameInputReference: ElementRef = {} as ElementRef;
@ViewChild('amountInput') amountInputReference: ElementRef = {} as ElementRef;
constructor(private shoppingListService: ShoppingListService) {}
ngOnInit(): void {}
onAddItem() {
const name = this.nameInputReference.nativeElement.value;
const amount = this.amountInputReference.nativeElement.value;
const ingredient = new Ingredient(name, amount);
this.shoppingListService.addIngredient(ingredient);
}
}
clean the html from the not used event listener:
<!-- shopping-list.component.html -->
<div class="row">
<div class="col-xs-12">
<app-shopping-edit
<!-- (ingredientAdded)="onIngredientAdded($event)" -->
></app-shopping-edit>
<hr />
<ul class="list-group">
<a
href="#"
class="list-group-item" style="cursor: pointer"
*ngFor="let ingredient of ingredients"
>
{{ingredient.name}} ({{ingredient.amount}})
</a>
</ul>
</div>
</div>
New Recipe Structure:
export class Recipe {
constructor(
public name: string,
public description: string,
public imagePath: string,
public ingredients: Ingredient[],
) {}
}
show ingredients under current recipe and trigger the sent:
<!-- recipe-detail.component.html -->
<!-- ... -->
<div class="row">
<div class="col-xs-12">
<div class="btn-group" appDropdown>
<button type="button" class="btn btn-primary dropdown-toggle">
Manage <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a
(click)="onAddToShoppingList()"
style="cursor: pointer"
>Add To Shopping List</a></li>
<li><a href="#">Edit Recipe</a></li>
<li><a href="#">Delete Recipe</a></li>
</ul>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-12">
{{recipe.description}}
</div>
</div>
<div class="row">
<div class="col-xs-12">
<ul class="list-group">
<li
class="list-group-item"
*ngFor="let ingredient of recipe.ingredients"
>
{{ingredient.name}} ({{ingredient.amount}})
</li>
</ul>
</div>
</div>
component usage:
@Component({
// ...
})
export class RecipeDetailComponent implements OnInit {
// ...
constructor(private recipeService: RecipeService) {}
// ...
onAddToShoppingList() {
this.recipeService.addIngredientToShoppingList(this.recipe.ingredients);
}
}
Services changes:
@Injectable() // allow to inject other services in in
export class RecipeService {
// ...
constructor(private shoppingListService: ShoppingListService){ }
// ...
addIngredientToShoppingList(ingredients: Ingredient[]){
this.shoppingListService.addIngredients(ingredients);
}
}
export class ShoppingListService {
// ...
addIngredients(ingredients: Ingredient[]): void {
this.ingredients.push(...ingredients); // spread operator
this.ingredientsChanged.emit(this.ingredients.slice());
}
}
The basic routing setup is done by creating a routing module:
const routes: Routes = [
{
path: '', // use only path: '' gives and error because it is always math
redirectTo: '/recipes',
pathMatch: 'full' // this restricts the match to strict equal only
},
{ path: 'recipes', component: RecipesComponent },
{ path: 'shopping-list', component: ShoppingListComponent },
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
})
export class AppRoutingModule {}
adding it to the main app module:
@NgModule({
declarations: [/* ... */],
imports: [/* ... */, AppRoutingModule],
// ...
})
export class AppModule {}
and setting the routing mechanism:
<!-- app.component.html -->
<app-header></app-header>
<!-- <app-header (featureSelected)="onNavigate($event)"></app-header> -->
<div class="container">
<div class="row">
<div class="col-md-12">
<router-outlet></router-outlet>
<!-- <app-recipes *ngIf="loadedFeature === 'recipe'"></app-recipes> -->
<!-- <app-shopping-list *ngIf="loadedFeature === 'shopping-list'"></app-shopping-list> -->
</div>
</div>
</div>
@Component({
// ...
})
export class AppComponent {
// loadedFeature = 'recipe';
title = 'course-project';
// onNavigate(feature: string) {
// this.loadedFeature = feature;
// }
}
The actual navigation can be set in the header:
<!-- app.component.html -->
<!-- ... -->
<ul class="nav navbar-nav">
<li routerLinkActive="active"><a routerLink="/recipes">Recipes</a></li>
<li routerLinkActive="active"><a routerLink="/shopping-list">Shopping List</a></li>
<!-- <li><a href="#" (click)="onSelect('recipe')">Recipes</a></li> -->
<!-- <li><a href="#" (click)="onSelect('shopping-list')">Shopping List</a></li> -->
</ul>
<!-- ... -->
in order to fix the page reloading problems it is necessary to remove
all the href="#"
placed in the application.
Moreover can be added the style="cursor: pointer;"
.
Children routes can be simply added in the routing component:
const routes: Routes = [
// ...
{
path: 'recipes',
component: RecipesComponent,
children: [
{ path: '', component: RecipeStartComponent },
{ path: ':id', component: RecipeDetailComponent },
],
},
// ...
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
})
export class AppRoutingModule {}
then must be added the routing mechanism in the parent component:
<!-- recipes.component.html -->
<div class="row">
<div class="col-md-5">
<app-recipe-list
></app-recipe-list>
<!-- (recipeWasSelected)="selectedRecipe = $event" -->
<!-- ></app-recipe-list> -->
</div>
<div class="col-md-7">
<router-outlet></router-outlet>
<!-- <app-recipe-detail -->
<!-- *ngIf="selectedRecipe; else infoText" -->
<!-- [recipe]="selectedRecipe" -->
<!-- ></app-recipe-detail> -->
<!-- <ng-template #infoText> -->
<!-- <p>Please select a Recipe!</p> -->
<!-- </ng-template> -->
</div>
</div>
and retrieve the current element from the route
@Component({
// ...
})
export class RecipeDetailComponent implements OnInit {
// @Input() recipe: Recipe = {} as Recipe;
recipe: Recipe = {} as Recipe;
id: number = 0;
constructor(private recipeService: RecipeService, private route: ActivatedRoute) {}
ngOnInit(): void {
// does not reacts to changes
// const id = this.route.snapshot.params['id'];
this.route.params.subscribe((params: Params) => {
this.id = +params['id'];
this.recipe = this.recipeService.getRecipe(this.id);
});
// since it is an angular observer the unsuscribe can be omitted
}
onAddToShoppingList() {
this.recipeService.addIngredientToShoppingList(this.recipe.ingredients);
}
}
update the service:
@Injectable() // allow to inject other services in in
export class RecipeService {
// ...
getRecipe(index: number): Recipe {
return this.recipes[index]
}
// ...
}
and the related views to read the current route:
<!-- recipe-list.component.html -->
<div class="row">
<div class="col-xs-12">
<button class="btn btn-success">New Recipe</button>
</div>
</div>
<hr />
<div class="row">
<div class="col-xs-12">
<app-recipe-item
*ngFor="let curRecipe of recipes; let i = index"
[recipe]="curRecipe"
[index]="i"
></app-recipe-item>
<!-- (recipeSelected)="onRecipeSelected(curRecipe)" -->
<!-- ></app-recipe-item> -->
</div>
</div>
update the old mechanism in the recipe item:
<!-- recipe-item.component.html -->
<a
style="cursor: pointer;"
[routerLink]="[index]"
routerLinkActive="active"
class="list-group-item clearfix"
<!-- (click)="onSelected()" not needed -->
>
<div class="pull-left">
<h4 class="list-group-item-heading">{{ recipe.name }}</h4>
<p class="list-group-item-text">{{ recipe.description }}</p>
</div>
<span class="pull-right">
<img [src]="recipe.imagePath" alt="{{ recipe.name }}" class="img-responsive" style="max-height: 50px" />
</span>
</a>
@Component({
// ...
})
export class RecipeItemComponent implements OnInit {
/* @Input() */ recipe: Recipe = {} as Recipe;
@Input() index: number = 0;
constructor(/* private recipeService: RecipeService */) {}
ngOnInit(): void {}
// onSelected() { not needed anymoer
// this.recipeService.recipeSelected.emit(this.recipe);
// }
}
New routes:
const routes: Routes = [
// ...
{
path: 'recipes',
component: RecipesComponent,
children: [
{ path: '', component: RecipeStartComponent },
{ path: 'new', component: RecipeEditComponent },
{ path: ':id', component: RecipeDetailComponent },
{ path: ':id/edit', component: RecipeEditComponent },
],
},
// ...
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
})
export class AppRoutingModule {}
getting the editing mode:
@Component({
// ...
})
export class RecipeEditComponent implements OnInit {
id: number | undefined;
editMode: boolean = false;
constructor(private route: ActivatedRoute) {}
ngOnInit(): void {
this.route.params.subscribe((params: Params) => {
this.editMode = params['id'] != null;
if (this.editMode) {
this.id = +params['id'];
}
});
// since it is an angular observer the unsuscribe can be omitted
}
}
navigate to a new recipe creation page:
<!-- recipe-list.component.html -->
<div class="row">
<div class="col-xs-12">
<button
class="btn btn-success"
(click)="onNewRecipe()"
>New Recipe</button>
</div>
</div>
<!-- ... -->
@Component({
// ...
})
export class RecipeListComponent implements OnInit {
// ...
constructor(
// ...
private router: Router,
private route: ActivatedRoute,
) {}
onNewRecipe() {
this.router.navigate(['new'], { relativeTo: this.route });
}
}
navigating to the edit page:
<!-- recipe-detail.component.html -->
<!-- ... -->
<li><a style="cursor: pointer;" (click)="onEditRecipe()">Edit Recipe</a></li>
<!-- ... -->
@Component({
// ...
})
export class RecipeDetailComponent implements OnInit {
// ...
constructor(
private recipeService: RecipeService,
private router: Router,
private route: ActivatedRoute,
) {}
// ...
onEditRecipe() {
// both approaches are valid
// this.router.navigate(['edit'], { relativeTo: this.route });
this.router.navigate(['../', this.id, 'edit'], { relativeTo: this.route });
}
// ...
}
Observables allow to replace the event emitter with a better pattern, the Subject:
export class ShoppingListService {
// ...
// public ingredientsChanged = new EventEmitter<Ingredient[]>();
public ingredientsChanged = new Subject<Ingredient[]>();
// ...
addIngredient(ingredient: Ingredient): void {
this.ingredients.push(ingredient);
// this.ingredientsChanged.emit(this.ingredients.slice());
this.ingredientsChanged.next(this.ingredients.slice());
}
addIngredients(ingredients: Ingredient[]): void {
this.ingredients.push(...ingredients); // spread operator
// this.ingredientsChanged.emit(this.ingredients.slice());
this.ingredientsChanged.next(this.ingredients.slice());
}
}
the subscription syntax remains the same, but it is a good practice to store the subscription and unsubscribe to it:
@Component({
// ...
})
export class ShoppingListComponent implements OnInit, OnDestroy {
// ...
private shoppingListSubscription: Subscription | undefined;
// ...
ngOnInit(): void {
this.ingredients = this.shoppingListService.getIngredients();
this.shoppingListSubscription = this.shoppingListService.ingredientsChanged.subscribe(
// ...
);
}
ngOnDestroy(): void {
if (this.shoppingListSubscription) {
this.shoppingListSubscription.unsubscribe();
}
}
}
To work with the forms it is necessary to import the FormsModule:
@NgModule({
declarations: [
// ...
],
imports: [
// ...
FormsModule
],
// ...
})
export class AppModule { }
The form can be updated in the html in this way:
<!-- shopping-edit.component.html -->
<!-- added ngSubmit and form parameter type -->
<form (ngSubmit)="onAddItem(f)" #f="ngForm">
<div class="row">
<div class="col-sm-5 form-group">
<label for="name">Name</label>
<input
type="text"
id="name"
class="form-control"
<!-- added name and ngModel to access from typescript -->
name="name"
ngModel
required
/>
<!-- #nameInput -->
<!-- /> -->
</div>
<div class="col-sm-2 form-group">
<label for="amount">Amount</label>
<input
type="number"
id="amount"
class="form-control"
<!-- added name and ngModel to access from typescript -->
name="amount"
ngModel
required
<!-- not null or negative amounts -->
[pattern]="'^[1-9]+[0-9]*$'"
<!-- or pattern="^[1-9]+[0-9]*$" -->
/>
<!-- #amountInput -->
<!-- /> -->
</div>
</div>
<div class="row">
<div class="col-xs-12">
<button
class="btn btn-success"
type="submit"
[disabled]="!f.valid"
>Add</button>
<!-- removed on click, added ngSubmit instaed -->
<!-- (click)="onAddItem()" -->
<!-- >Add</button> -->
<button class="btn btn-danger" type="button">Delete</button>
<button class="btn btn-primary" type="button">Clear</button>
</div>
</div>
</form>
and in the component:
@Component({
// ...
})
export class ShoppingEditComponent implements OnInit {
// not necessary anymore
// @ViewChild('nameInput') nameInputReference: ElementRef = {} as ElementRef;
// @ViewChild('amountInput') amountInputReference: ElementRef = {} as ElementRef;
constructor(private shoppingListService: ShoppingListService) {}
ngOnInit(): void {}
onSubmit(form: NgForm) { // added input parameter
// const name = this.nameInputReference.nativeElement.value;
// const amount = this.amountInputReference.nativeElement.value;
const {name, amount} = form.value;
const ingredient = new Ingredient(name, amount);
this.shoppingListService.addIngredient(ingredient);
}
}
It is possible to load an item in the template form in this way:
<!-- shopping-list.component.html -->
<!-- ... -->
<a
class="list-group-item" style="cursor: pointer"
*ngFor="let ingredient of ingredients; let i = index"
<!-- new method -->
(click)="onEditItem(i)"
>
{{ingredient.name}} ({{ingredient.amount}})
</a>
<!-- ... -->
needed changed in the service
export class ShoppingListService {
// ...
public startedEditing = new Subject<number>();
// ...
getIngredient(index: number): Ingredient {
return this.ingredients[index];
}
// ...
updateIngredient(index: number, newIngredient: Ingredient): void {
this.ingredients[index] = newIngredient;
this.ingredientsChanged.next(this.ingredients.slice());
}
// ...
}
changes on the list
@Component({
// ...
})
export class ShoppingListComponent implements OnInit, OnDestroy {
// ...
private shoppingListSubscription: Subscription | undefined;
// ...
ngOnInit(): void {
// ...
this.shoppingListSubscription = this.shoppingListService.ingredientsChanged.subscribe(
(ingredients: Ingredient[]) => {
this.ingredients = ingredients;
},
);
}
onEditItem(index: number) {
this.shoppingListService.startedEditing.next(index);
}
ngOnDestroy(): void {
if (this.shoppingListSubscription) {
this.shoppingListSubscription.unsubscribe();
}
}
}
changes in the item edit:
@Component({
// ...
})
export class ShoppingEditComponent implements OnInit, OnDestroy {
@ViewChild('f') shoppingListForm: NgForm | undefined;
private startedEditingSubscription: Subscription;
private editMode: boolean = false;
private editedItemIndex: number | undefined;
private editedItem: Ingredient | undefined;
constructor(private shoppingListService: ShoppingListService) {
this.startedEditingSubscription = shoppingListService.startedEditing.subscribe(
(index: number) => {
this.editedItemIndex = index;
this.editedItem = shoppingListService.getIngredient(index);
this.editMode = true;
if (this.shoppingListForm) {
this.shoppingListForm.setValue({
name: this.editedItem.name,
amount: this.editedItem.amount,
});
}
},
);
}
ngOnInit(): void { }
onSubmit(form: NgForm) {
const { name, amount } = form.value;
const ingredient = new Ingredient(name, amount);
if (this.editMode && this.editedItemIndex !== undefined) {
this.shoppingListService.updateIngredient(this.editedItemIndex, ingredient);
} else {
this.shoppingListService.addIngredient(ingredient);
}
// reset the form
this.editMode = false;
form.reset();
}
ngOnDestroy(): void {
this.startedEditingSubscription.unsubscribe();
}
}
in the end the delete and clear functionalities can be updated in this way:
export class ShoppingListService {
// ...
updateIngredient(index: number, newIngredient: Ingredient): void {
this.ingredients[index] = newIngredient;
this.ingredientsChanged.next(this.ingredients.slice());
}
deleteIngredient(index: number): void {
this.ingredients.splice(index, 1);
this.ingredientsChanged.next(this.ingredients.slice());
}
// ...
}
<!-- shopping-edit.component.html -->
<!-- ... -->
<div class="row">
<div class="col-xs-12">
<button
class="btn btn-success"
type="submit"
[disabled]="!f.valid"
>{{editMode ? 'Update' : 'Add'}}</button>
<!-- (click)="onAddItem()" -->
<!-- >Add</button> -->
<button
class="btn btn-danger"
type="button"
*ngIf="editMode"
(click)="onDelete()"
>Delete</button>
<button
class="btn btn-primary"
type="button"
(click)="onClear()"
>Clear</button>
</div>
</div>
<!-- ... -->
@Component({
// ...
})
export class ShoppingEditComponent implements OnInit, OnDestroy {
// ...
onClear() {
this.shoppingListForm?.reset();
this.editMode = false;
}
onDelete() {
if (this.editedItemIndex !== undefined) {
this.shoppingListService.deleteIngredient(this.editedItemIndex);
}
this.onClear();
}
// ...
}
The reactive Module must be imported in the app module:
@NgModule({
declarations: [
// ...
],
imports: [
// ...
ReactiveFormsModule
],
// ...
})
export class AppModule {}
The form looks like:
<!-- recipe-edit.component.html -->
<div class="row">
<div class="col-xs-12">
<form [formGroup]="recipeForm" (ngSubmit)="onSubmit()">
<div class="row">
<div class="col-xs-12">
<button type="submit" class="btn btn-success">Save</button>
<button type="submit" class="btn btn-danger">Cancel</button>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<div class="form-group">
<label for="name">Name</label>
<input
type="text"
id="name"
class="form-control"
formControlName="name" />
</div>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<div class="form-group">
<label for="imagePath">Image Url</label>
<input
type="text"
id="imagePath"
class="form-control"
formControlName="imagePath" />
</div>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<img src="" class="img=responsive" />
</div>
</div>
<div class="row">
<div class="col-xs-12">
<div class="form-group">
<label for="description">Description</label>
<textarea
type="text"
id="description"
class="form-control"
rows="6"
formControlName="description" >
</textarea>
</div>
</div>
</div>
<div class="row">
<div
class="col-xs-12"
formArrayName="ingredients"
>
<div
class="row"
*ngFor="let ingredientControl of controls; let i = index"
[formGroupName]="i"
style="margin-top: 10px;"
>
<div class="col-xs-8">
<input
type="text"
class="form-control"
formControlName="name"
/>
</div>
<div class="col-xs-2">
<input
type="number"
class="form-control"
formControlName="amount"
/>
</div>
<div class="col-xs-2">
<button
type="button"
class="btn btn-danger"
>X</button>
</div>
</div>
<hr/>
<div class="row">
<div class="col-xs-12">
<button
type="button"
class="btn btn-success"
(click)="onAddIngredient()"
>Add Ingredient</button>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
the logic is:
@Component({
// ...
})
export class RecipeEditComponent implements OnInit {
id: number | undefined;
editMode: boolean = false;
recipeForm: FormGroup;
constructor(private route: ActivatedRoute, private recipeService: RecipeService) {
this.recipeForm = this.initForm();
}
ngOnInit(): void {
this.route.params.subscribe((params: Params) => {
this.editMode = params['id'] != null;
if (this.editMode) {
this.id = +params['id'];
// reinitialize the form
this.recipeForm = this.initForm();
}
});
// since it is an angular observer the unsuscribe can be omitted
}
private initForm(): FormGroup {
let recipeName = '';
let recipeImagePath = '';
let recipeDescription = '';
let recipeIngredients = new FormArray([]);
if (this.editMode && this.id !== undefined) {
const recipe = this.recipeService.getRecipe(this.id);
recipeName = recipe.name;
recipeImagePath = recipe.imagePath;
recipeDescription = recipe.description;
if (recipe.ingredients) {
for (let ingredient of recipe.ingredients) {
recipeIngredients.push(
new FormGroup({
name: new FormControl(ingredient.name),
amount: new FormControl(ingredient.amount),
}),
);
}
}
}
return new FormGroup({
name: new FormControl(recipeName),
imagePath: new FormControl(recipeImagePath),
description: new FormControl(recipeDescription),
ingredients: recipeIngredients,
});
}
get controls() {
return (<FormArray>this.recipeForm.get('ingredients')).controls;
}
onAddIngredient() {
(<FormArray>this.recipeForm.get('ingredients')).push(
new FormGroup({
name: new FormControl(),
amount: new FormControl(),
}),
);
}
onSubmit() {
console.log(this.recipeForm);
}
}
The validation can be added in the typescript code:
@Component({
// ...
})
export class RecipeEditComponent implements OnInit {
// ...
private initForm(): FormGroup {
// ...
if (this.editMode && this.id !== undefined) {
// ...
if (recipe.ingredients) {
for (let ingredient of recipe.ingredients) {
recipeIngredients.push(
new FormGroup({
name: new FormControl(ingredient.name, Validators.required),
amount: new FormControl(ingredient.amount, [
Validators.required,
Validators.pattern(/^[1-9]+[0-9]*$/),
]),
}),
);
}
}
}
// ...
}
// ...
onAddIngredient() {
(<FormArray>this.recipeForm.get('ingredients')).push(
new FormGroup({
name: new FormControl(null, Validators.required),
amount: new FormControl(null, [
Validators.required,
Validators.pattern(/^[1-9]+[0-9]*$/),
]),
}),
);
}
// ...
}
it fan be used in to disable the save button:
<!-- recipe-edit.component.html -->
<!-- ... -->
<button
type="submit"
class="btn btn-success"
[disabled]="!recipeForm.valid"
>Save</button>
<!-- ... -->
and the default classes can be used to style the errors:
input.ng-invalid.ng-touched,
textarea.ng-invalid.ng-touched {
border: 1px solid red;
}
Once finished the preparation the recipes must added or updated in the service:
@Injectable()
export class RecipeService {
// ...
recipeChanged = new Subject<Recipe[]>();
// ...
addRecipe(recipe: Recipe) {
this.recipes.push(recipe);
this.recipeChanged.next(this.recipes.slice())
}
updateRecipe(index: number, newRecipe: Recipe) {
this.recipes[index] = newRecipe;
this.recipeChanged.next(this.recipes.slice())
}
deleteRecipe(index: number) {
this.recipes.splice(index, 1);
this.recipeChanged.next(this.recipes.slice());
}
}
in the edit component:
@Component({
// ...
})
export class RecipeEditComponent implements OnInit {
// ...
constructor(
private route: ActivatedRoute,
private router: Router,
private recipeService: RecipeService,
) {
this.recipeForm = this.initForm();
}
// ...
onDeleteIngredient(index: number) {
(<FormArray>this.recipeForm.get('ingredients')).removeAt(index);
}
onCancel() {
this.router.navigate(['../'], { relativeTo: this.route });
}
onSubmit() {
// const recipe = new Recipe(
// this.recipeForm.value['name'],
// this.recipeForm.value['description'],
// this.recipeForm.value['imagePath'],
// this.recipeForm.value['ingredients'],
// );
// or since the format is the same
const recipe = this.recipeForm.value;
if (this.editMode && this.id !== undefined) {
this.recipeService.updateRecipe(this.id, recipe);
} else {
this.recipeService.addRecipe(recipe);
}
// console.log(this.recipeForm);
this.onCancel();
}
}
<!-- recipe-edit.component.html -->
<!-- ... -->
<button
class="btn btn-danger"
(click)="onCancel()"
>Cancel</button>
<!-- ... -->
<button
type="button"
class="btn btn-danger"
(click)="onDeleteIngredient(i)"
>X</button>
<!-- ... -->
to correctly display the new list, also the recipe list component must be updated:
@Component({
// ...
})
export class RecipeListComponent implements OnInit, OnDestroy {
// ...
recipeChangedSubscription: Subscription;
constructor( /* ... */) {
this.recipeChangedSubscription = this.recipeService.recipeChanged.subscribe(
(recipes: Recipe[]) => {
this.recipes = recipes;
},
);
}
ngOnInit(): void {
// still needed for first load
this.recipes = this.recipeService.getRecipes();
}
// ...
ngOnDestroy(): void {
this.recipeChangedSubscription.unsubscribe();
}
}
and for the deletion must be updated the recipe edit component:
@Component({
// ...
})
export class RecipeDetailComponent implements OnInit {
// ...
onDeleteRecipe() {
this.recipeService.deleteRecipe(this.id);
this.router.navigate(['/recipes'])
}
}
<!-- recipe-detail.component.html -->
<!-- ... -->
<li><a style="cursor: pointer;" (click)="onDeleteRecipe()">Delete Recipe</a></li>
<!-- ... -->
The image preview can be correctly displayed in this way:
<!-- recipe-edit.component.html -->
<!-- ... -->
<div class="row">
<div class="col-xs-12">
<div class="form-group">
<label for="imagePath">Image Url</label>
<input
type="text"
id="imagePath"
class="form-control"
formControlName="imagePath"
<!-- refenrece -->
#imagePath
/>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<!-- bind src to reference -->
<img [src]="imagePath.value" class="img=responsive" />
</div>
</div>
<!-- ... -->
Every time the page is changed, the recipe service is recreated from scratch
since it is injected in the Recipes component.
To avoid this behaviour it must be injected at the app module level:
@Component({
selector: 'app-recipes',
templateUrl: './recipes.component.html',
styleUrls: ['./recipes.component.css'],
// providers: [RecipeService],
})
export class RecipesComponent implements OnInit {
// ...
}
@NgModule({
declarations: [
// ...
],
// ...
providers: [ShoppingListService, RecipeService],
// ...
})
export class AppModule { }
In the course is used firebase, but I prefer something local like json-server:
npm install -g json-server
json-server --watch db.json
# server listening at http://localhost:3000
the content of db.json
must be:
{
"recipes": []
}
To use the http client must be imported the
import { HttpClientModule } from '@angular/common/http';
@NgModule({
declarations: [
// ...
],
imports: [HttpClientModule, /* ... */ ],
// ...
})
export class AppModule { }
I needed to add an id to the model:
export class Recipe {
public id: number | undefined;
constructor(
public name: string,
public description: string,
public imagePath: string,
public ingredients: Ingredient[],
) { }
}
In the header can be managed the sotring:
@Component({
// ...
})
export class HeaderComponent implements OnInit {
// ...
constructor(private dataStorageService: DataStorageService) { }
onSaveData() {
this.dataStorageService.storeRecipes();
}
}
<!-- header.component.html -->
<!-- ... -->
<li><a style="cursor: pointer;" (click)="onSaveData()">Save Data</a></li>
<!-- ... -->
the storage functionality can be implement in this way:
@Injectable({ providedIn: 'root' })
export class DataStorageService {
private baseUrl = 'http://localhost:3000/recipes';
constructor(private http: HttpClient, private recipeService: RecipeService) { }
storeRecipes() {
const recipes = this.recipeService.getRecipes();
for (let i = 0; i < recipes.length; i++) {
const curRecipe = recipes[i];
if (curRecipe.id) {
this.http
.put<Recipe>(`${this.baseUrl}/${curRecipe.id}`, curRecipe)
.subscribe(recipe => {
console.log(recipe);
this.recipeService.updateRecipe(i, recipe);
});
} else {
this.http.post<Recipe>(`${this.baseUrl}`, curRecipe).subscribe(recipe => {
console.log(recipe);
this.recipeService.updateRecipe(i, recipe);
});
}
}
}
}
As for the storing, the fetching starts from the header:
@Component({
// ...
})
export class HeaderComponent implements OnInit {
// ...
constructor(private dataStorageService: DataStorageService) { }
onLoadData() {
this.dataStorageService.fetchRecipes();
}
}
<!-- header.component.html -->
<!-- ... -->
<li><a style="cursor: pointer;" (click)="onLoadData()">Fetch Data</a></li>
<!-- ... -->
In the service is is needed a new set method:
@Injectable()
export class RecipeService {
private recipes: Recipe[] = [ ];
// ...
setRecipes(recipes: Recipe[]) {
this.recipes = recipes
this.recipeChanged.next(this.recipes.slice());
}
// ...
}
the recipes can then be loaded in this way:
@Injectable({ providedIn: 'root' })
export class DataStorageService {
// ...
fetchRecipes() {
this.http
.get<Recipe[]>(`${this.baseUrl}`)
// prevend undefined ingredients
.pipe(
map(recipes => {
return recipes.map(recipe => {
return {
...recipe,
ingredients: recipe.ingredients ? recipe.ingredients : [],
};
});
}),
)
.subscribe(recipes => {
console.log(recipes);
this.recipeService.setRecipes(recipes);
});
}
}
In order to avoid error on accessing recipes on /recipes/:id
id the data
is not yet loaded, it is possible to add a resolver that automatically
fetch them.
First of all the DataStorageService must be updated:
@Injectable({ providedIn: 'root' })
export class DataStorageService {
// ...
fetchRecipes() {
return this.http.get<Recipe[]>(`${this.baseUrl}`).pipe(
map(recipes => {
return recipes.map(recipe => {
return {
...recipe,
ingredients: recipe.ingredients ? recipe.ingredients : [],
};
});
}),
tap(recipes => {
this.recipeService.setRecipes(recipes);
}),
);
}
}
and the header component accordantly:
@Component({
// ...
})
export class HeaderComponent implements OnInit {
// ...
onLoadData() {
this.dataStorageService.fetchRecipes().subscribe(recipes => {
console.log('recipes fetched.');
});
}
}
now it is possible to add a resolver:
@Injectable({ providedIn: 'root' })
export class RecipeResolverService implements Resolve<Recipe[]> {
constructor(
private dataStorageService: DataStorageService,
private recipeService: RecipeService,
) { }
resolve(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
): Recipe[] | Observable<Recipe[]> | Promise<Recipe[]> {
const recipes = this.recipeService.getRecipes();
if (recipes.length === 0) {
return this.dataStorageService.fetchRecipes();
} else {
return recipes;
}
}
}
and improve the routing:
const routes: Routes = [
// ...
{
path: 'recipes',
component: RecipesComponent,
children: [
// ...
{ path: ':id', component: RecipeDetailComponent, resolve: [RecipeResolverService] },
// ...
],
},
// ...
];
@NgModule({
// ...
})
export class AppRoutingModule { }