按ER图首先生成基础的教师实体以备用:使用编辑器打开前台,并在shell中来到src/app/norm/entity文件夹,使用`ng g class course`生成课程实体。 ```javascript panjiedeMac-Pro:entity panjie$ ng g class course CREATE src/app/norm/entity/course.spec.ts (154 bytes) CREATE src/app/norm/entity/course.ts (24 bytes) ``` 参考ER图完成字段及构造函数初始化工作: ```javascript import {Teacher} from './Teacher'; /** * 课程 */ export class Course { id: number; name: string; teacher: Teacher; constructor(data?: { id?: number, name?: string, teacher?: Teacher }) { if (data) { if (data.id !== undefined) { this.id = data.id; } if (data.name !== undefined) { this.name = data.name; } if (this.teacher) { this.teacher = data.teacher; } } } } ``` # 组件初始化 本组件为course模块的第一个组件,在新建组件前进行模块的初始化,然后在course模块下完成本组件的初始化。 使用shell进行src/app文件夹,使用命令生成一个带有路由模块的Course模块。 ``` panjiedeMac-Pro:app panjie$ ng g m course --routing CREATE src/app/course/course-routing.module.ts (250 bytes) CREATE src/app/course/course.module.ts (280 bytes) ``` 进入自动生成的course文件夹,进一步生成add组件: ``` panjiedeMac-Pro:app panjie$ cd course/ panjiedeMac-Pro:course panjie$ ng g c add CREATE src/app/course/add/add.component.sass (0 bytes) CREATE src/app/course/add/add.component.html (18 bytes) CREATE src/app/course/add/add.component.spec.ts (607 bytes) CREATE src/app/course/add/add.component.ts (258 bytes) UPDATE src/app/course/course.module.ts (344 bytes) ``` ## V层 src/app/course/add/add.component.html ```html <div class="row justify-content-center"> <div class="col-4"> <h2>添加课程</h2> <form (ngSubmit)="onSubmit()" [formGroup]="formGroup"> <div class="form-group"> <label for="name">名称</label> <input type="text" class="form-control" id="name" placeholder="名称" formControlName="name"> </div> <div class="form-group"> <label>任课教师</label> <app-teacher-select (selected)="course.teacher"></app-teacher-select> </div> <div class="form-group"> <label>绑定班级</label> <div> <label><input type="checkbox"> 班级1</label> <label><input type="checkbox"> 班级2</label> </div> </div> <div class="form-group"> <button>保存</button> </div> </form> </div> </div> ``` ## C层 src/app/course/add/add.component.ts ```typescript import { Component, OnInit } from '@angular/core'; import {FormGroup} from '@angular/forms'; import {Course} from '../../norm/entity/course'; @Component({ selector: 'app-add', templateUrl: './add.component.html', styleUrls: ['./add.component.sass'] }) export class AddComponent implements OnInit { formGroup: FormGroup; course: Course; constructor() { } ngOnInit() { } onSubmit() { } } ``` ## 单元测试 引用ReactiveFormsModule。 src/app/course/add/add.component.spec.ts ```typescript beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [AddComponent], imports: [ ReactiveFormsModule ] }) .compileComponents(); })); ``` ![](https://img.kancloud.cn/c3/38/c338185eaa131bad1102b5a6f2739e12_688x96.png) 解析模板中的app-teacher-select已经学习过了三种方法: 1. 在测试模块中直接引入`AppTeacherSelect`组件。 2. 新建一个`selector`同为`app-teacher-select`的新组件做为AppTeacherSelect组件的替身,并在测试模块中引入该替身组件。 3. 在`TestModule`新建`selector`同为`app-teacher-select`的`AppTeacherSelect`组件,做为原`StudentModule`中的`AppTeacherSelect`组件的替身。 从笔者的实际使用经验来看,第3种方案虽然会面临一定的组件`selector`冲突风险,但仍然不失为当前的"最佳实践"。 ![](https://img.kancloud.cn/40/45/404585830ced4878f4ad100539f8d680_844x666.png) 打开shell并来到项目src/app/test/component文件夹,使用如下命令建立测试专用的同名`AppTeacherSelect`组件。 ``` panjiedeMac-Pro:component panjie$ ng g c TeacherSelect CREATE src/app/test/component/teacher-select/teacher-select.component.sass (0 bytes) CREATE src/app/test/component/teacher-select/teacher-select.component.html (29 bytes) CREATE src/app/test/component/teacher-select/teacher-select.component.spec.ts (678 bytes) CREATE src/app/test/component/teacher-select/teacher-select.component.ts (301 bytes) UPDATE src/app/test/test.module.ts (633 bytes) ``` 并将此组件声明到`TestModule`中的`exports`以将其作为输出项向其它模块输出。 src/app/test/test.module.ts ```typescript exports: [ LoginComponent, TeacherSelectComponent ➊ ], ``` * ➊ 输出(暴露)组件 测试结果: ![](https://img.kancloud.cn/5b/c2/5bc25744aa4b82b89f0f5b9f60127d39_535x240.png) 提示说需要一个FormGroup实例(这说明在C层没有对formGroup进行实例化),同时于报错信息中给出了解决方案,没有比这种报错内容更贴心的了。 ## FormGroupBuild 前面已经学经过使用`new FormGroup`的方法实例化`FormGroup`。 src/app/course/add/add.component.ts ```typescript ngOnInit() { this.formGroup = new FormGroup({ name: new FormControl('') }); } ``` 本节展示另一个更为简单的`FormBuilder`来实例化`FormGroup`,当表单项较多时使用`FormBuilder`能够降低一些创建表单的复杂度,在生产项目中是一种较常用的形式。 src/app/course/add/add.component.ts ```typescript constructor(private formBuilder: FormBuilder) { } ngOnInit() { this.formGroup = new FormGroup({ ✘ name: new FormControl('') ✘ }); ✘ this.formGroup = this.formBuilder.group({ name: [''] }); } ``` ![](https://img.kancloud.cn/8c/98/8c98e0523635a7a3992f91a64bd10682_427x333.png) # 名称验证 按需求,课程的名称不能为空,最少为2个字符。 ## C层 src/app/course/add/add.component.ts ```typescript ngOnInit() { this.formGroup = this.formBuilder.group({ name: ['', [Validators.required, Validators.minLength(2)]] }); } ``` ## V层初始化 src/app/course/add/add.component.html ```html <input type="text" class="form-control" id="name" placeholder="名称" formControlName="name" required✚> <small id="nameRequired" *ngIf="formGroup.get('name').dirty && formGroup.get('name').errors && formGroup.get('name').errors.required" class="form-text text-danger">请输入课程名</small> ✚ <small id="nameMinLength" *ngIf="formGroup.get('name').errors && formGroup.get('name').errors.minlength" class="form-text text-danger">课程名称不得少于2个字符</small> ✚ </div> <button type="submit" [disabled]="formGroup.invalid" ✚>保存</button> ``` ## 单元测试 参考单元测试代码如下: src/app/course/add/add.component.spec.ts ```typescript import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import {AddComponent} from './add.component'; import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; import {TestModule} from '../../test/test.module'; import {FormTest} from '../../testing/FormTest'; import {By} from '@angular/platform-browser'; describe('course -> AddComponent', () => { let component: AddComponent; let fixture: ComponentFixture<AddComponent>; beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [AddComponent], imports: [ ReactiveFormsModule, TestModule ] }) .compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(AddComponent); component = fixture.componentInstance; fixture.detectChanges(); }); fit('should create', () => { expect(component).toBeTruthy(); }); fit('requried校验', () => { // 初次渲染页面时,不显示校验信息 expect(fixture.debugElement.query(By.css('#nameRequired'))).toBeNull(); // 输入了长度为1的名称,显示校验信息 const formTest: FormTest<AddComponent> = new FormTest(fixture); formTest.setInputValue('#name', '1'); fixture.detectChanges(); expect(fixture.debugElement.query(By.css('#nameRequired'))).toBeNull(); // 删除输入,显示required formTest.setInputValue('#name', ''); fixture.detectChanges(); expect(fixture.debugElement.query(By.css('#nameRequired'))).toBeDefined(); }); fit('minLength校验', () => { // 输入长度小于2的,显示 const formTest: FormTest<AddComponent> = new FormTest(fixture); formTest.setInputValue('#name', '1'); expect(fixture.debugElement.query(By.css('#nameMixLength'))).toBeDefined(); }); fit('button校验', () => { // 初始化时,不能点击 let button: HTMLButtonElement = fixture.debugElement.query(By.css('button[type="submit"]')).nativeElement; expect(button.disabled).toBeTruthy(); // 输入合格的内容后可点击 component.formGroup.get('name').setValue('1234'); fixture.detectChanges(); button = fixture.debugElement.query(By.css('button[type="submit"]')).nativeElement; expect(button.disabled).toBeFalsy(); }); }); ``` ## 保存按钮点击测试 由于本组件的表单启用了字段合规性校验功能,所以在进行保存按钮测试时需要表单的值是有效的。 src/app/course/add/add.component.spec.ts ```typescript fit('点击保存按钮', () => { component.formGroup.get('name').setValue('1234'); fixture.detectChanges(); spyOn(component, 'onSubmit'); FormTest.clickButton(fixture, 'button[type="submit"]'); expect(component.onSubmit).toHaveBeenCalled(); }); ``` ![](https://img.kancloud.cn/84/80/8480c73ef07df5dbede66c73d72df9bf_193x97.png) # 参考文档 | 名称 | 链接 | 预计学习时长(分) | | --- | --- | --- | | 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step6.1.1](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step6.1.1) | - |