در Angular امکان تعریف مسیریابیهایی، درون سایر مسیریابیها نیز پیش بینی شدهاست. با استفاده از مفهوم Child Routes، امکان تعریف سلسله مراتب مسیریابیها جهت ساماندهی و مدیریت مسیریابی درون برنامه، وجود دارد. همچنین lazy loading مسیریابیها را نیز سادهتر کرده و کارآیی آغاز برنامه را بهبود میبخشند.
علت نیاز به Child Routes
در مثال این سری، منوی اصلی آن به صورت ذیل تعریف شدهاست:
<ul class="nav navbar-nav">
<li><a [routerLink]="['/home']">Home</a></li>
<li><a [routerLink]="['/products']">Product List</a></li>
<li><a [routerLink]="['/products', 0, 'edit']">Add Product</a></li>
</ul>
سپس از دایرکتیو router-outlet جهت تعریف محل قرارگیری محتوای این مسیریابیها استفاده شدهاست:
<div class="container">
<router-outlet></router-outlet>
</div>
هربار که مسیری تغییر میکند، محتوای router-outlet با محتوای قالب آن کامپوننت جایگزین خواهد شد. اما اگر تعداد المانهای صفحهی ویرایش محصولات بیش از اندازه بودند و خواستیم فیلدهای آنرا به دو برگه (tab) تقسیم کنیم چطور؟ برای اینکار نیاز است تا router-outlet ثانویه و مخصوص این قالب را تعریف کنیم. هربار که کاربری بر روی برگهای کلیک میکند، به کمک Child routes، محتوای آن برگه را در این router-outlet ثانویه نمایش میدهیم. به این ترتیب به کمک Child routes میتوان امکان نمایش محتوای مسیریابی دیگری را درون مسیریابی اصلی، میسر کرد.
کاربردهای Child routes
- امکان تقسیم فرمهای طولانی به چند Tab
- امکان طراحی طرحبندیهای Master/Layout
- قرار دادن قالب یک کامپوننت، درون قالب کامپوننتی دیگر
- بهبود کپسوله سازی ماژولهای برنامه
- جزو الزامات Lazy loading هستند
تنظیم کردن Child Routes
مثال جاری این سری، تنها به همراه یک سری primary routes است؛ مانند صفحهی خوشآمد گویی، نمایش لیست محصولات، افزودن و ویرایش محصولات. قالبهای کامپوننتهای اینها نیز در router-outlet اصلی برنامه نمایش داده میشوند. در ادامه میخواهیم کامپوننت ویرایش محصولات را تغییر داده و تعدادی برگه را به آن اضافه کنیم. برای اینکار، نیاز به تعریف Child routes است تا بتوان قالبهای کامپوننتهای هر برگه را در router-outlet کامپوننت والد که در درون router-outlet اصلی برنامه قرار دارد، نمایش داد.
به همین جهت دو کامپوننت جدید ProductEditInfo و ProductEditTags را نیز به ماژول محصولات اضافه میکنیم:
>ng g c product/ProductEditInfo
>ng g c product/ProductEditTags
این دستورات سبب به روز رسانی فایل src\app\product\product.module.ts، جهت تکمیل قسمت declarations آن نیز خواهند شد.
به علاوه اینترفیس src\app\product\iproduct.ts را نیز جهت افزودن گروه محصولات و همچنین آرایهی برچسبهای یک محصول تکمیل میکنیم:
export interface IProduct {
id: number;
productName: string;
productCode: string;
category: string;
tags?: string[];
}
در این حالت میتوانید فایل app\product\product-data.ts را نیز ویرایش کرده و به هر محصول، تعدادی گروه و برچسب را نیز انتساب دهید؛ که البته ذکر tags آن اختیاری است. در اینجا فایل src\app\product\product.service.ts نیز باید ویرایش شده و متد initializeProduct آن تعاریف []:category: null, tags را نیز پیدا کنند.
در ادامه برای تنظیم Child Routes، فایل src\app\product\product-routing.module.ts را گشوده و آنرا به نحو ذیل تکمیل کنید:
import { ProductEditTagsComponent } from './product-edit-tags/product-edit-tags.component';
import { ProductEditInfoComponent } from './product-edit-info/product-edit-info.component';
const routes: Routes = [
{ path: 'products', component: ProductListComponent },
{
path: 'products/:id', component: ProductDetailComponent,
resolve: { product: ProductResolverService }
},
{
path: 'products/:id/edit', component: ProductEditComponent,
resolve: { product: ProductResolverService },
children: [
{
path: '',
redirectTo: 'info',
pathMatch: 'full'
},
{
path: 'info',
component: ProductEditInfoComponent
},
{
path: 'tags',
component: ProductEditTagsComponent
}
]
}
];
- Child Routes، در داخل آرایهی خاصیت children تنظیمات یک مسیریابی والد، قابل تعریف هستند. برای نمونه در اینجا Child Routes به تنظیمات مسیریابی ویرایش محصولات اضافه شدهاند و کار توسعهی مسیریابی والد خود را انجام میدهند.
- در اولین Child Route تعریف شده، مقدار path به '' تنظیم شدهاست. به این ترتیب مسیریابی پیش فرض آن (در صورت عدم ذکر صریح آنها در URL) به صورت خودکار به مسیریابی info هدایت خواهد شد. بنابراین درخواست مسیر products/:id/edit به دومین Child Route تنظیم شده هدایت میشود.
- دومین Child Route تعریف شده با مسیری مانند products/:id/edit/info تطابق پیدا میکند.
- سومین Child Route تعریف شده با مسیری مانند products/:id/edit/tags تطابق پیدا میکند.
تعیین محل نمایش Child Views
برای نمایش قالب یک Child Route درون قالب والد آن، نیاز به تعریف یک دایرکتیو router-outlet جدید، درون قالب والد است و نحوهی تعریف آن با primary outlet تعریف شدهی در فایل src\app\app.component.html تفاوتی ندارد.
برای پیاده سازی این مفهوم، نیاز است از قالب ویرایش محصولات و یا فایل src\app\product\product-edit\product-edit.component.html که قالب والد این Child Routes است شروع و آنرا به دو Child View تقسیم کنیم. این قالب، تاکنون حاوی فرمی جهت ویرایش و افزودن محصولات است. در ادامه میخواهیم بجای آن چند برگه را نمایش دهیم. به همین جهت این فرم را حذف کرده و با دو برگهی جدید جایگزین میکنیم. در اینجا نحوهی تعریف لینکهای جدید، به Child Routes و همچنین محل قرارگیری router-outlet ثانویه را نیز مشاهده میکنید:
<div class="panel panel-primary">
<div class="panel-heading">
{{pageTitle}}
</div>
<div class="panel-body" *ngIf="product">
<div class="wizard">
<a [routerLink]="['info']">
Basic Information
</a>
<a [routerLink]="['tags']">
Search Tags
</a>
</div>
<router-outlet></router-outlet>
</div>
<div class="panel-footer">
<div class="row">
<div class="col-md-6 col-md-offset-2">
<span>
<button class="btn btn-primary"
type="button"
style="width:80px;margin-right:10px"
[disabled]="!isValid()"
(click)="saveProduct()">
Save
</button>
</span>
<span>
<a class="btn btn-default"
[routerLink]="['/products']">
Cancel
</a>
</span>
<span>
<a class="btn btn-default"
(click)="deleteProduct()">
Delete
</a>
</span>
</div>
</div>
</div>
<div class="has-error" *ngIf="errorMessage">{{errorMessage}}</div>
</div>
تا اینجا اگر برنامه را توسط دستور ng s -o اجرا کنید، صفحهی ویرایش محصول اول، چنین شکلی را پیدا کردهاست:
فعالسازی Child Routes
دو روش برای فعالسازی Child Routes وجود دارند:
الف) با ذکر مسیر مطلق
<a [routerLink]="['/products',product.id,'edit','info']">Info</a>
در این حالت تمام URL segments این مسیر باید به عنوان پارامترهای لینک قید شوند.
ب) با ذکر مسیر نسبی
<a [routerLink]="['info']">Info</a>
این مسیر از URL segment جاری شروع میشود و نباید در حین تعریف آن از / استفاده کرد. اگر از / استفاده شود، معنای ذکر مسیری مطلق را میدهد.
در این حالت اگر تنظیمات والد این مسیریابی تغییر کنند، نیازی به تغییر مسیر نسبی تعریف شده نیست (برخلاف حالت مطلق که بر اساس قید کامل تمام اجزای مسیریابی والد آن کار میکند).
دقیقا همین پارامترها، قابلیت استفادهی در متد this.route.navigate را نیز دارند:
الف) برای حالت ذکر مسیر مطلق:
this.router.navigate(['/products', this.product.id,'edit','info']);
ب) و برای حالت ذکر مسیر نسبی:
this.router.navigate(['info', { relativeTo: this.route }]);
در حالت ذکر مسیر نسبی، نیاز است پارامتر اضافهی دیگری را جهت مشخص سازی مسیریابی والد نیز قید کرد.
تکمیل Child Viewهای برنامه
تا اینجا لینکهایی نسبی را به مسیریابیهای info و tags اضافه کردیم. در ادامه قالبها و کامپوننتهای آنها را تکمیل میکنیم:
الف) تکمیل کامپوننت ProductEditInfoComponent در فایل src\app\product\product-edit\product-edit.component.ts
import { ActivatedRoute } from '@angular/router';
import { NgForm } from '@angular/forms';
import { Component, OnInit, ViewChild } from '@angular/core';
import { IProduct } from './../iproduct';
@Component({
//selector: 'app-product-edit-info',
templateUrl: './product-edit-info.component.html',
styleUrls: ['./product-edit-info.component.css']
})
export class ProductEditInfoComponent implements OnInit {
@ViewChild(NgForm) productForm: NgForm;
errorMessage: string;
product: IProduct;
constructor(private route: ActivatedRoute) { }
ngOnInit(): void {
this.route.parent.data.subscribe(data => {
this.product = data['product'];
if (this.productForm) {
this.productForm.reset();
}
});
}
}
با قالب src\app\product\product-edit\product-edit.component.html که در حقیقت همان فرمی است که از کامپوننت والد حذف کردیم و به اینجا منتقل شدهاست:
<div class="panel-body">
<form class="form-horizontal"
novalidate
#productForm="ngForm">
<fieldset>
<legend>Basic Product Information</legend>
<div class="form-group"
[ngClass]="{'has-error': (productNameVar.touched ||
productNameVar.dirty || product.id !== 0) &&
!productNameVar.valid }">
<label class="col-md-2 control-label"
for="productNameId">Product Name</label>
<div class="col-md-8">
<input class="form-control"
id="productNameId"
type="text"
placeholder="Name (required)"
required
minlength="3"
[(ngModel)] = product.productName
name="productName"
#productNameVar="ngModel" />
<span class="help-block" *ngIf="(productNameVar.touched ||
productNameVar.dirty || product.id !== 0) &&
productNameVar.errors">
<span *ngIf="productNameVar.errors.required">
Product name is required.
</span>
<span *ngIf="productNameVar.errors.minlength">
Product name must be at least three characters.
</span>
</span>
</div>
</div>
<div class="form-group"
[ngClass]="{'has-error': (productCodeVar.touched ||
productCodeVar.dirty || product.id !== 0) &&
!productCodeVar.valid }">
<label class="col-md-2 control-label" for="productCodeId">Product Code</label>
<div class="col-md-8">
<input class="form-control"
id="productCodeId"
type="text"
placeholder="Code (required)"
required
[(ngModel)] = product.productCode
name="productCode"
#productCodeVar="ngModel" />
<span class="help-block" *ngIf="(productCodeVar.touched ||
productCodeVar.dirty || product.id !== 0) &&
productCodeVar.errors">
<span *ngIf="productCodeVar.errors.required">
Product code is required.
</span>
</span>
</div>
</div>
<div class="has-error" *ngIf="errorMessage">{{errorMessage}}</div>
</fieldset>
</form>
</div>
ب) تکمیل کامپوننت ProductEditTagsComponent در فایل src\app\product\product-edit-tags\product-edit-tags.component.ts
import { ActivatedRoute } from '@angular/router';
import { IProduct } from './../iproduct';
import { Component, OnInit } from '@angular/core';
@Component({
//selector: 'app-product-edit-tags',
templateUrl: './product-edit-tags.component.html',
styleUrls: ['./product-edit-tags.component.css']
})
export class ProductEditTagsComponent implements OnInit {
errorMessage: string;
newTags = '';
product: IProduct;
constructor(private route: ActivatedRoute) { }
ngOnInit(): void {
this.route.parent.data.subscribe(data => {
this.product = data['product'];
});
}
// Add the defined tags
addTags(): void {
let tagArray = this.newTags.split(',');
this.product.tags = this.product.tags ? this.product.tags.concat(tagArray) : tagArray;
this.newTags = '';
}
// Remove the tag from the array of tags.
removeTag(idx: number): void {
this.product.tags.splice(idx, 1);
}
}
با قالب src\app\product\product-edit-tags\product-edit-tags.component.html
<div class="panel-body">
<form class="form-horizontal"
novalidate>
<fieldset>
<legend>Product Search Tags</legend>
<div class="form-group"
[ngClass]="{'has-error': (categoryVar.touched ||
categoryVar.dirty || product.id !== 0) &&
!categoryVar.valid }">
<label class="col-md-2 control-label" for="categoryId">Category</label>
<div class="col-md-8">
<input class="form-control"
id="categoryId"
type="text"
placeholder="Category (required)"
required
minlength="3"
[(ngModel)]="product.category"
name="category"
#categoryVar="ngModel" />
<span class="help-block" *ngIf="(categoryVar.touched ||
categoryVar.dirty || product.id !== 0) &&
categoryVar.errors">
<span *ngIf="categoryVar.errors.required">
A category must be entered.
</span>
<span *ngIf="categoryVar.errors.minlength">
The category must be at least 3 characters in length.
</span>
</span>
</div>
</div>
<div class="form-group"
[ngClass]="{'has-error': (tagVar.touched ||
tagVar.dirty || product.id !== 0) &&
!tagVar.valid }">
<label class="col-md-2 control-label" for="tagsId">Search Tags</label>
<div class="col-md-8">
<input class="form-control"
id="tagsId"
type="text"
placeholder="Search keywords separated by commas"
minlength="3"
[(ngModel)]="newTags"
name="tags"
#tagVar="ngModel" />
<span class="help-block" *ngIf="(tagVar.touched ||
tagVar.dirty || product.id !== 0) &&
tagVar.errors">
<span *ngIf="tagVar.errors.minlength">
The search tag must be at least 3 characters in length.
</span>
</span>
</div>
<div class="col-md-1">
<button type="button"
class="btn btn-default"
(click)="addTags()">
Add
</button>
</div>
</div>
<div class="row col-md-8 col-md-offset-2">
<span *ngFor="let tag of product.tags; let i = index">
<button class="btn btn-default"
style="font-size:smaller;margin-bottom:12px"
(click)="removeTag(i)">
{{tag}}
<span class="glyphicon glyphicon-remove"></span>
</button>
</span>
</div>
<div class="has-error" *ngIf="errorMessage">{{errorMessage}}</div>
</fieldset>
</form>
</div>
دریافت اطلاعات جهت Child Routes
روشهای متعددی برای دریافت اطلاعات جهت Child Routes وجود دارند:
الف) میتوان از متد this.productService.getProduct جهت دریافت اطلاعات یک محصول استفاده کرد. اما همانطور که
در قسمت قبل نیز بررسی کردیم، این روش سبب نمایش ابتدایی یک قالب خالی و پس از مدتی، نمایش اطلاعات آن میشود.
ب) میتوان توسط this.route.snapshot.data['product'] اطلاعات را از Route Resolver، پس از پیش واکشی آنها از وب سرور، دریافت کرد.
ج) اگر قسمتهای مختلف Child Routes قرار است با اطلاعاتی یکسان کار کنند که قرار است بین برگههای مختلف آن به اشتراک گذاشته شوند، این اطلاعات را میتوانند از Route Resolver والد خود به کمک this.route.snapshot.data['product'] دریافت کنند.
در این مثال ما هرچند چندین برگهی مختلف را طراحی کردهایم، اما اطلاعات نمایش داده شدهی توسط آنها متعلق به یک شیء محصول میباشند. بنابراین نیاز است بتوان این اطلاعات را بین کامپوننتهای مختلف این Child Routes به اشتراک گذاشت و تنها با یک وهلهی آن کار کرد. به همین جهت با this.route.
parent در هر یک از Child Components تعریف شده کار میکنیم تا بتوان به یک وهلهی شیء محصول، دسترسی یافت.
د) همچنین میتوان از روش this.route.parent.data.subscribe نیز استفاده کرد. البته در اینجا چون صفحهی افزودن محصولات با صفحهی ویرایش محصولات، دارای root URL Segment یکسانی است، نیاز است از این روش استفاده کرد تا بتوان از تغییرات بعدی پارامتر id آن مطلع شد. این مورد روشی است که در کدهای ProductEditInfoComponent مشاهده میکنید.
ngOnInit(): void {
this.route.parent.data.subscribe(data => {
this.product = data['product'];
if (this.productForm) {
this.productForm.reset();
}
});
}
در اینجا data['product'] به key/value تعریف شدهی resolve: { product: ProductResolverService } در تنظیمات مسیریابی اشاره میکند که آنرا
در قسمت قبل تکمیل کردیم.
شبیه به همین روش را در ProductEditTagsComponent نیز بکار گرفتهایم و در آنجا نیز با شیء this.route.parent و دسترسی به اطلاعات دریافتی از Route Resolver، کار میکنیم. به این ترتیب مطمئن خواهیم شد که this.product این دو کامپوننت مختلف، هر دو به یک وهله از شیء product دریافتی از سرور، اشاره میکنند.
به این ترتیب دکمهی Save ذیل هر دو برگه، به درستی عمل کرده و میتواند اطلاعات نهایی یک شیء محصول را ذخیره کند.
رفع مشکلات اعتبارسنجی فرمهای قرار گرفتهی در برگههای مختلف
علت استفادهی از ViewChild در ProductEditInfoComponent
@ViewChild(NgForm) productForm: NgForm;
که به فرم قالب آن اشاره میکند:
<form class="form-horizontal" novalidate
#productForm="ngForm">
این است که بتوان متد this.productForm.reset آنرا پس از هربار دریافت اطلاعات از سرور، فراخوانی کرد. این متد نه تنها اطلاعات آنرا پاک میکند، بلکه خطاهای اعتبارسنجی آنرا نیز به حالت نخست برمیگرداند. بنابراین در این حالت اگر سبب بروز یک خطای اعتبارسنجی، در فرم ویرایش اطلاعات شویم و در همان لحظه صفحهی افزودن یک محصول جدید را درخواست کنیم، کاربر همان خطای اعتبارسنجی قبلی را مجددا مشاهده نکرده و یک فرم از ابتدا آغاز شده را مشاهده میکند.
انجام اینکار برای برگههای دوم به بعد ضروری نیست. از این جهت که با اولین بار نمایش این صفحه، تمام آنها از حافظه خارج میشوند و مجددا بازیابی خواهند شد.
مشکل دوم اعتبارسنجی این فرم چند برگهای این است که هرچند خالی کردن نام یا کد محصول، سبب نمایش خطای اعتبارسنجی میشود، اما سبب غیرفعال شدن دکمهی Save نخواهند شد؛ از این جهت که این دکمه در قالب والد قرار دارد و نه در قالب فرزندان.
در اولین بار نمایش Child Routes، کامپوننت ویرایش اطلاعات در router-outlet آن نمایش داده میشود. در این حالت اگر کاربر بر روی لینک نمایش کامپوننت edit tags کلیک کند، قالب کامپوننت edit info به طور کامل از router-outlet حذف میشود و با قالب کامپوننت edit tags جایگزین میشود. این فرآیند به این معنا است که فرم edit info به همراه تمام اطلاعات اعتبارسنجی آن unload میشوند. به همین ترتیب زمانیکه کاربر درخواست نمایش برگهی ویرایش اطلاعات را میکند، قالب edit tags و اطلاعات اعتبارسنجی آن unload میشوند. به این معنا که در یک router-outlet در هر زمان تنها یک فرم، به همراه اطلاعات اعتبارسنجی آن در دسترس هستند.
راه حلهای ممکن:
الف) بدنهی اصلی فرم را در کامپوننت والد قرار دهیم و سپس هر کدام از فرزندان، المانهای فرمهای مرتبط را ارائه دهند. این روش کار نمیکند چون Angular المانهای فرمهای قرار گرفتهی درون router-outlet را شناسایی نمیکند.
ب) قرار دادن فرمها، به صورت مجزا در هر کامپوننت فرزند (مانند روش فعلی) و سپس اعتبارسنجی دستی در کامپوننت والد.
تغییرات مورد نیاز کامپوننت ProductEditComponent را جهت افزودن اعتبارسنجی فرمهای فرزند آنرا در اینجا ملاحظه میکنید:
export class ProductEditComponent implements OnInit {
private dataIsValid: { [key: string]: boolean } = {};
isValid(path: string): boolean {
this.validate();
if (path) {
return this.dataIsValid[path];
}
return (this.dataIsValid &&
Object.keys(this.dataIsValid).every(d => this.dataIsValid[d] === true));
}
saveProduct(): void {
if (this.isValid(null)) {
this.productService.saveProduct(this.product)
.subscribe(
() => this.onSaveComplete(`${this.product.productName} was saved`),
(error: any) => this.errorMessage = <any>error
);
} else {
this.errorMessage = 'Please correct the validation errors.';
}
}
validate(): void {
// Clear the validation object
this.dataIsValid = {};
// 'info' tab
if (this.product.productName &&
this.product.productName.length >= 3 &&
this.product.productCode) {
this.dataIsValid['info'] = true;
} else {
this.dataIsValid['info'] = false;
}
// 'tags' tab
if (this.product.category &&
this.product.category.length >= 3) {
this.dataIsValid['tags'] = true;
} else {
this.dataIsValid['tags'] = false;
}
}
}
- در اینجا dataIsValid، به صورت key/value تعریف شدهاست که در آن key، مسیر یک برگه و مقدار آن، معتبر بودن یا غیرمعتبر بودن وضعیت اعتبارسنجی آن است.
- سپس متد validate اضافه شدهاست تا کار اعتبارسنجی را انجام دهد. در اینجا از خود شیء this.product که بین دو برگه به اشتراک گذاشته شدهاست برای انجام اعتبارسنجی استفاده میکنیم. از این جهت که برگهها نیز با استفاده از this.route.
parent.data، دقیقا به همین وهله دسترسی دارند. بنابراین هرتغییری که در برگهها بر روی این وهله اعمال شود، به کامپوننت والد نیز منعکس میشود.
- متد isValid، مسیر هر برگه را دریافت میکند و سپس به متغیر dataIsValid مراجعه کرده و وضعیت آن برگه را باز میگرداند. اگر path در اینجا قید نشود، وضعیت تمام برگهها بررسی میشوند؛ مانند if (this.isValid(null)) در متد ذخیره سازی اطلاعات.
- در آخر در فایل product-edit.component.html، وضعیت فعال و غیرفعال دکمهی ثبت را نیز به این متد متصل میکنیم:
<button class="btn btn-primary"
type="button"
style="width:80px;margin-right:10px"
[disabled]="!isValid()"
(click)="saveProduct()">
Save
</button>
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید: angular-routing-lab-04.zip
برای اجرای آن فرض بر این است که پیشتر
Angular CLI را نصب کردهاید. سپس از طریق خط فرمان به ریشهی پروژه وارد شده و دستور npm install را صادر کنید تا وابستگیهای آن دریافت و نصب شوند. در آخر با اجرای دستور ng s -o برنامه ساخته شده و در مرورگر پیش فرض سیستم نمایش داده خواهد شد.