一个完善有效的单选、多选是一项较复杂的工程。在本小节中以实现以下功能为主:
* 单选生效
* 全选生效
* 列表中所有的项都被单选选中时,全选自动选中
* 全选选中时,列表中所有的项自动选中
* 全选取消时,列表中所有的项自动取消
* 列表中所有的项未被单选全部选中时,全选自动取消
在初次接触某项功能时,TDD并不适用。在开发过程中仍需按先实现功能后进行测试的开发步骤进行。
# 单选
使用`<input type="checkbox" />`便能很轻松的生成一个选择框,该选择框的状态有两个:选中、未选中,对应的值为true、false。实现单选的方案有很多种,本文使用笔者认为实现较为简单的一种。
在每个需要进行单选的实体上增加一个字段:`isChecked`,类型设置为boolean,默认值设置为false。这样以来,该字段的值便可以很好的与选择框的值相对应。比如在本例中:
student/index/index.component.html
```
<tr *ngFor="let student of pageStudent.content; index as index">
<td><input type="checkbox" [ngModel]="student.isChecked" ➊/></td>
<td>{{index + 1}}</td>
```
* ➊ 将student中的isChecked字段与checkbox相绑定。
但student中我们并未规定isChecked字段,所以此时IDE会出现了一个警告错误:
![](https://img.kancloud.cn/c1/d6/c1d6e3a9439b816292917bfc61fe7a39_1069x104.png)
修正该错误的方法当然也很简单:打开Student实体类,添加对应的字段即可。
norm/entity/student.ts
```
export class Student {
...
/* 是否选中,辅助实现V层的 选中 功能① */
isChecked = false; ②
```
* ① 注释内容:它的**作用**是什么 > 它是什么
* ② 使用`=`进行赋值操作
> 直接在前台实体中增加isChecked字段会有一个弊端:当真实的后台实体中存在该字段时,会与前台的字段定义产生冲突。在笔者所在的团队中,由于规定后台字段不能以`is`打头,所以原则上不会出现该字段与后台实体冲突的问题。在实际的开发中一旦产生冲突,那么可以考虑通过以下方案之一来解决:方案一,对使用选择框的实体,在传入V层前进行格式化,比如在格式化过程中再增加了个字段`isCheckedClone`,使用该字段来存储真实的后台值;方案二,单元的定义选择组件,将`isChecked`字段设置为可变更的,比如<yzSelect fieldName='你自己定义的字段' />。
## 单元测试
写点功能就测试一下,不会吃亏。找到对应的测试文件,并新建测试用例:
student/index/index.component.spec.ts
```
describe('Student -> IndexComponent', () => {
...
fit('单选', () => {
/* 设置第一个学生的isCheck为true,并重新渲染V层 */
component.pageStudent.content[0].isChecked = true;
fixture.detectChanges();
});
});
```
使用`ng test`启动测试并观察效果:
![](https://img.kancloud.cn/10/19/1019dac36cf076900c144987bead1632_467x131.png)
第一条预期选中,这说明我们上述的思路和代码的书写都是正确的。接下来,继续完善单元测试以达到用代码来保障代码功能的目的。
student/index/index.component.spec.ts
```
describe('Student -> IndexComponent', () => {
...
fit('单选', () => {
const trDebugElement = fixture.debugElement.query(By.css(`table tr:nth-child(2)`));
const checkBoxDebugElement = trDebugElement.query(By.css('input[type=checkBox]'));
const checkBoxElement: HTMLInputElement = checkBoxDebugElement.nativeElement;
console.log(checkBoxElement);
console.log(checkBoxElement.checked); ➊
expect(checkBoxElement.checked).toBe(false); ➋
/* 设置第一个学生的isCheck为true,并重新渲染V层 */
component.pageStudent.content[0].isChecked = true;
fixture.detectChanges();
/* 断言checkBox的值为true */
expect(checkBoxElement.checked).toEqual(true); ①
});
});
```
* ➊ checkBox选中则checked属性为true,未选中则为false
* ➋ 默认未选中,值为false
* ① 选中后checkBox的值为true
但单元测试的结果却不如人意:
![](https://img.kancloud.cn/78/d7/78d729f0d184e6d5a990840b3a571c1a_479x309.png)
这就有点意思了,通过观察界面我们可以确认该checkBox就是选中了,这种状态下checkBox的值应该为true。但最终checkbox的value却为false。
> 这可能是angular的一个bug。笔者开始尝试由官方文档中找到答案,但官方文档并未对checkbox进行ngModel的使用进行单独说明。且通过其它的示例代码,我们确认当前使用ngModel来进行数据绑定是没有问题的。可能开发ngModel的人并没有充分的测试此问题,也可能是开发ngModel的人并不推荐我们在checkBox中使用ngModel(但官方文档却未同步)。问题产生的原因笔者通过万能的google也没有找到答案,但有幸运的找到了另一种写法。
可能是官方并不推荐我们这么做吧,我们在绑定checkBox时换一种写法,由ngModel变更为`checked`:
student/index/index.component.html
```
<tr *ngFor="let student of pageStudent.content; index as index">
<td><input type="checkbox" [checked]="student.isChecked" /></td>
<td>{{index + 1}}</td>
```
单元测试顺利通过。由此我们得出来一个非常重要的结论:**对checkBox进行数据绑定时,应该用checked而非ngModel**。而该checked即为HTMLInputElement的checked属性,在此相当于直接改变了checkbox的checked属性。如此我们又得到了以下结论:**当angular缺失某此功能时,可以直接使用`[xxx]`的方法,设置其原生`xxx`属性**
## 双向绑定
刚刚测试了C层向V层绑定是成功的,那么当V层中checkBox被用户点击后,是否可以自动将值传给对应的student.isChecked呢?
student/index/index.component.spec.ts
```
describe('Student -> IndexComponent', () => {
...
fit('单选点击后绑定到C层', () => {
const trDebugElement = fixture.debugElement.query(By.css(`table tr:nth-child(2)`));
const checkBoxDebugElement = trDebugElement.query(By.css('input[type=checkBox]'));
const checkBoxElement: HTMLInputElement = checkBoxDebugElement.nativeElement;
expect(checkBoxElement.checked).toBe(false);
/* 点击第一个学生对应的checkBox,断言checkBox的值是true,同时对应学生的相应字段值为true */
checkBoxElement.click(); ➊
expect(checkBoxElement.checked).toBeTruthy(); ①
expect(component.pageStudent.content[0].isChecked).toBeTruthy(); ②
});
});
```
* ➊ 点击该checkBox,该方法将自动重新渲染该checkbox,但不会重新渲染整个V层
* ① 断言checkBox的值为true
* ② 断言对应的字段值为true
单元测试结果:
```
Error: Expected false to be truthy.
```
同时提示该错误发生在`② 断言对应的字段值为true`,也就是说`[checked]`属性无法实现双向数据绑定。的确,在前面我们讲过`[xxx]`代表当C层的值单向绑定到V层,而V层的值绑定到C层则需要通过`()`触发C层的相关方法。checkbox的传值也是如此,当checkbox被点击时,会通过`change($event)`向外弹射最新的值。
student/index/index.component.html
```
<tr *ngFor="let student of pageStudent.content; index as index">
<td><input type="checkbox" [checked]="student.isChecked" (change)="onCheckBoxChange($event➋, student➌)"➊ /></td>
<td>{{index + 1}}</td>
```
* ➊ change事件绑定到C层的checkBoxChanged方法
* ➋ $event即为change弹射的值
* ➌ 对应当前学生
对应C层代码为:
student/index/index.component.ts
```
export class IndexComponent implements OnInit {
...
/**
* 单选框被用户点击时
* @param $event 弹射值
* @param student 当前学生
*/
onCheckBoxChange($event, student: Student) {
console.log($event); ①
}
```
* ① 未熟练使用前在控制台打印相关的对象,绝对是件事半功倍的操作
观察控制台发现,原来我们需要的东西在这:
![](https://img.kancloud.cn/82/5c/825cd5e6f85adf1ce73e809913fdc6af_363x427.png)
于是为了更加清晰的了解自己操作的数据对象,在相应的代码中补充类型并进行相应的强制转换:
student/index/index.component.ts
```
export class IndexComponent implements OnInit {
...
/**
* 单选框被用户点击时
* @param $event 弹射值
* @param student 当前学生
*/
onCheckBoxChange($event: Event①, student: Student) {
const checkbox = $event.target as HTMLInputElement②;
student.isChecked = checkbox.checked; ③
}
```
* ① 声明类型为Event
* ② 将类型强制转换为HTMLInputElement
* ③ 使用checkbox的值来设置student的isChecked字段
单元测试如期通过。
# 全选
有了单选的经验,全选初始化便相对简单了。
student/index/index.component.html
```
<table>
<tr>
<th>选择</th> ✘
<th><input type="checkbox" [checked]="isCheckedAll" (change)="onCheckAllBoxChange($event)" /></th> ✚
<th>序号</th>
```
对应增加C层的属性及方法:
student/index/index.component.ts
```
export class IndexComponent implements OnInit {
...
/* 是否全部选中 */
isCheckedAll = false;
...
/**
* 全选框被用户点击时触发
* @param $event checkBox弹射值
*/
onCheckAllBoxChange($event: Event) {
const checkbox = $event.target as HTMLInputElement;
this.isCheckedAll = checkbox.checked;
}
```
## 单元测试
单元测试就是功能点的测试,在此暂不考虑全选与单选的关联信息,仅就多选进行双向数据绑定测试。
### C->V
student/index/index.component.spec.ts
```
describe('Student -> IndexComponent', () => {
...
fit('多选C->V', () => {
/* 获取到 全选 并断言其状态为:未选中 */
const trDebugElement = fixture.debugElement.query(By.css(`table tr:nth-child(1)`));
const checkBoxDebugElement = trDebugElement.query(By.css('input[type=checkBox]'));
const checkBoxElement: HTMLInputElement = checkBoxDebugElement.nativeElement;
expect(component.isCheckedAll).toBeFalsy();
expect(checkBoxElement.checked).toBe(false);
/* 改变C层的值,断言绑定生效 */
component.isCheckedAll = true;
fixture.detectChanges();
expect(component.isCheckedAll).toBeTruthy();
});
});
```
* ★ 因代码与前面测试单元时高度重度,不再重复说明。
### V->C
student/index/index.component.spec.ts
```
describe('Student -> IndexComponent', () => {
...
fit('多选V->C', () => {
/* 获取到 全选 并断言其状态为:未选中 */
const trDebugElement = fixture.debugElement.query(By.css(`table tr:nth-child(1)`));
const checkBoxDebugElement = trDebugElement.query(By.css('input[type=checkBox]'));
const checkBoxElement: HTMLInputElement = checkBoxDebugElement.nativeElement;
expect(component.isCheckedAll).toBeFalsy();
/* 第一次点击 false -> true */
checkBoxElement.click();
expect(component.isCheckedAll).toBeTruthy();
/* 再次点击 true -> false */
checkBoxElement.click();
expect(component.isCheckedAll).toBeFalsy();
});
});
```
* ★ 因代码与前面测试单元时高度重度,不再重复说明。
# 单选、多选联动
既然是联动,则说明两者互相影响。单元会影响多选,多选也会影响单选。那么在进行开发时便可以将此功能的粒度进一步缩小为:单元对多选的影响、多选对单元的影响。
## 多选对单选的影响
当多选选中或是取消选中时,单选应该全部选中或是全部取消选中。也就是说多选的状态的变更应该对单选产生影响,而单元的状态又与C层进行绑定,所以此问题便转换为:多选产生事件时,应该对应student.isChecked字段的值。
student/index/index.component.ts
```
export class IndexComponent implements OnInit {
...
onCheckAllBoxChange($event: Event) {
const checkbox = $event.target as HTMLInputElement;
this.isCheckedAll = checkbox.checked;
this.pageStudent.content.forEach((student) => { ①
student.isChecked = checkbox.checked;
});
}
```
* ① 循环对学生的isChecked字段赋值
### 单元测试
student/index/index.component.spec.ts
```
describe('Student -> IndexComponent', () => {
...
fit('多选V->C', () => {
/* 获取到 全选 并断言其状态为:未选中 */
const trDebugElement = fixture.debugElement.query(By.css(`table tr:nth-child(1)`));
const checkBoxDebugElement = trDebugElement.query(By.css('input[type=checkBox]'));
const checkBoxElement: HTMLInputElement = checkBoxDebugElement.nativeElement;
expect(component.isCheckedAll).toBeFalsy();
/* 第一次点击 false -> true */
checkBoxElement.click();
expect(component.isCheckedAll).toBeTruthy();
component.pageStudent.content
.forEach((student) => {
expect(student.isChecked).toBeTruthy(); ①
});
/* 再次点击 true -> false */
checkBoxElement.click();
expect(component.isCheckedAll).toBeFalsy();
component.pageStudent.content
.forEach((student) => {
expect(student.isChecked).toBeFalsy(); ①
});
});
});
```
* ① 断言每个学生的选中状态与全选的相同
## 单选对多选的影响
用户点击某个单选框时,单选对多选的影响有两种:① 如果当前单元值为false,则应该取消全选,全选值也应该为false ② 如果当前单选值为true,则应该对所有的学生进行遍历,只要有一个学生的isChecked值为false,则全选值为false。
> 还有很多的算法能够满足当前要求。比如建立个选定学生数量计数器,每次单选选中,计数器+1,取消选中-1。然后计算计数器与当前学生的总数值是否相等?相等,则全选选中,不相等则取消选中;或是也可以建立个数组,把选中的学生添加到这个单独的数组中。
则功能代码如下:
student/index/index.component.ts
```
export class IndexComponent implements OnInit {
...
onCheckBoxChange($event: Event, student: Student) {
const checkbox = $event.target as HTMLInputElement;
student.isChecked = checkbox.checked;
if (checkbox.checked) {
let checkedAll = true; ①
this.pageStudent.content.forEach((value) => {
if (!value.isChecked) {
checkedAll = false; ②
}
});
this.isCheckedAll = checkedAll; ③
} else {
this.isCheckedAll = false; ④
}
}
```
* ① 定义临时变量
* ② 如果有学生未选中,则设置该临时变量的值为false。该值可能被多次冗余执行,不过这并不会影响到代码的执行效果
* ③ 设置全选的值
* ④ 如果为取消选中,则直接设置全选的值为false
### 单元测试
student/index/index.component.spec.ts
```
describe('Student -> IndexComponent', () => {
...
fit('点击单选对多选值的影响', () => {
for (let i = 2; i <= 3; i++) {
/* 依次点击2个student的单选 */
const trDebugElement = fixture.debugElement.query(By.css(`table tr:nth-child(${i})`));
const checkBoxDebugElement = trDebugElement.query(By.css('input[type=checkBox]'));
const checkBoxElement: HTMLInputElement = checkBoxDebugElement.nativeElement;
checkBoxElement.click();
/* 按是否为最后一个学生进行不同的断言 */
if (i === 3) {
expect(component.isCheckedAll).toBeTruthy(); ①
checkBoxElement.click(); ②
expect(component.isCheckedAll).toBeFalsy();
} else {
expect(component.isCheckedAll).toBeFalsy(); ③
}
}
});
```
* ① 点击最后一个学生,则全选选中
* ② 再次点击(在全选情况下取消一个),全选取消选中
* ③ 点击非最后的学生,全选不选中
最后进行整个项目的单元测试,以保证当前功能的新增未对历史功能或单元测试功能造成影响。
# 参考文档
| 名称 | 链接 | 预计学习时长(分) |
| --- | --- | --- |
| 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.6.9](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.6.9) | - |
| HTMLInputElement | [https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement) | 5 |
- 序言
- 第一章:Hello World
- 第一节:Angular准备工作
- 1 Node.js
- 2 npm
- 3 WebStorm
- 第二节:Hello Angular
- 第三节:Spring Boot准备工作
- 1 JDK
- 2 MAVEN
- 3 IDEA
- 第四节:Hello Spring Boot
- 1 Spring Initializr
- 2 Hello Spring Boot!
- 3 maven国内源配置
- 4 package与import
- 第五节:Hello Spring Boot + Angular
- 1 依赖注入【前】
- 2 HttpClient获取数据【前】
- 3 数据绑定【前】
- 4 回调函数【选学】
- 第二章 教师管理
- 第一节 数据库初始化
- 第二节 CRUD之R查数据
- 1 原型初始化【前】
- 2 连接数据库【后】
- 3 使用JDBC读取数据【后】
- 4 前后台对接
- 5 ng-if【前】
- 6 日期管道【前】
- 第三节 CRUD之C增数据
- 1 新建组件并映射路由【前】
- 2 模板驱动表单【前】
- 3 httpClient post请求【前】
- 4 保存数据【后】
- 5 组件间调用【前】
- 第四节 CRUD之U改数据
- 1 路由参数【前】
- 2 请求映射【后】
- 3 前后台对接【前】
- 4 更新数据【前】
- 5 更新某个教师【后】
- 6 路由器链接【前】
- 7 观察者模式【前】
- 第五节 CRUD之D删数据
- 1 绑定到用户输入事件【前】
- 2 删除某个教师【后】
- 第六节 代码重构
- 1 文件夹化【前】
- 2 优化交互体验【前】
- 3 相对与绝对地址【前】
- 第三章 班级管理
- 第一节 JPA初始化数据表
- 第二节 班级列表
- 1 新建模块【前】
- 2 初识单元测试【前】
- 3 初始化原型【前】
- 4 面向对象【前】
- 5 测试HTTP请求【前】
- 6 测试INPUT【前】
- 7 测试BUTTON【前】
- 8 @RequestParam【后】
- 9 Repository【后】
- 10 前后台对接【前】
- 第三节 新增班级
- 1 初始化【前】
- 2 响应式表单【前】
- 3 测试POST请求【前】
- 4 JPA插入数据【后】
- 5 单元测试【后】
- 6 惰性加载【前】
- 7 对接【前】
- 第四节 编辑班级
- 1 FormGroup【前】
- 2 x、[x]、{{x}}与(x)【前】
- 3 模拟路由服务【前】
- 4 测试间谍spy【前】
- 5 使用JPA更新数据【后】
- 6 分层开发【后】
- 7 前后台对接
- 8 深入imports【前】
- 9 深入exports【前】
- 第五节 选择教师组件
- 1 初始化【前】
- 2 动态数据绑定【前】
- 3 初识泛型
- 4 @Output()【前】
- 5 @Input()【前】
- 6 再识单元测试【前】
- 7 其它问题
- 第六节 删除班级
- 1 TDD【前】
- 2 TDD【后】
- 3 前后台对接
- 第四章 学生管理
- 第一节 引入Bootstrap【前】
- 第二节 NAV导航组件【前】
- 1 初始化
- 2 Bootstrap格式化
- 3 RouterLinkActive
- 第三节 footer组件【前】
- 第四节 欢迎界面【前】
- 第五节 新增学生
- 1 初始化【前】
- 2 选择班级组件【前】
- 3 复用选择组件【前】
- 4 完善功能【前】
- 5 MVC【前】
- 6 非NULL校验【后】
- 7 唯一性校验【后】
- 8 @PrePersist【后】
- 9 CM层开发【后】
- 10 集成测试
- 第六节 学生列表
- 1 分页【后】
- 2 HashMap与LinkedHashMap
- 3 初识综合查询【后】
- 4 综合查询进阶【后】
- 5 小试综合查询【后】
- 6 初始化【前】
- 7 M层【前】
- 8 单元测试与分页【前】
- 9 单选与多选【前】
- 10 集成测试
- 第七节 编辑学生
- 1 初始化【前】
- 2 嵌套组件测试【前】
- 3 功能开发【前】
- 4 JsonPath【后】
- 5 spyOn【后】
- 6 集成测试
- 7 @Input 异步传值【前】
- 8 值传递与引入传递
- 9 @PreUpdate【后】
- 10 表单验证【前】
- 第八节 删除学生
- 1 CSS选择器【前】
- 2 confirm【前】
- 3 功能开发与测试【后】
- 4 集成测试
- 5 定制提示框【前】
- 6 引入图标库【前】
- 第九节 集成测试
- 第五章 登录与注销
- 第一节:普通登录
- 1 原型【前】
- 2 功能设计【前】
- 3 功能设计【后】
- 4 应用登录组件【前】
- 5 注销【前】
- 6 保留登录状态【前】
- 第二节:你是谁
- 1 过滤器【后】
- 2 令牌机制【后】
- 3 装饰器模式【后】
- 4 拦截器【前】
- 5 RxJS操作符【前】
- 6 用户登录与注销【后】
- 7 个人中心【前】
- 8 拦截器【后】
- 9 集成测试
- 10 单例模式
- 第六章 课程管理
- 第一节 新增课程
- 1 初始化【前】
- 2 嵌套组件测试【前】
- 3 async管道【前】
- 4 优雅的测试【前】
- 5 功能开发【前】
- 6 实体监听器【后】
- 7 @ManyToMany【后】
- 8 集成测试【前】
- 9 异步验证器【前】
- 10 详解CORS【前】
- 第二节 课程列表
- 第三节 果断
- 1 初始化【前】
- 2 分页组件【前】
- 2 分页组件【前】
- 3 综合查询【前】
- 4 综合查询【后】
- 4 综合查询【后】
- 第节 班级列表
- 第节 教师列表
- 第节 编辑课程
- TODO返回机制【前】
- 4 弹出框组件【前】
- 5 多路由出口【前】
- 第节 删除课程
- 第七章 权限管理
- 第一节 AOP
- 总结
- 开发规范
- 备用