Dorado 5 : 2.事例二:模拟多行表头的DataTable (T37)

情景描述

本事例以财务人员管理薪资信息为原型制作的,打算按照图2.1的形式展现。我们稍微分析一下会发现,按照传统方法使用DataTable很难达到这种效果,主要的障碍在于:

  1. 由于分公司信息来源于数据库,所以不知道有多少个分公司,也就不知道有多少个薪资列表(DataTable)。
  2. 相同的原因Dataset数量不确定,所以不可能通过配置的方式将这些Dataset表示出来。
  3. 薪资列表的几数行显示人员名称,偶数行显示薪资信息,这种记录存放形式与传统方式不同。
  4. 只有薪水信息可以被修改,人员信息不允许修改,是否需要判断几数和偶数行呢?
  5. 人员信息不一定会占据DataTable的所有Cell,那么剩余的空闲薪资Cell不允许编辑,见图2.2。
  6. 薪资列表的Dataset数量不确定,所以通过UpdateCommand提交的Dataset数量不确定。

典型界面


图2.1 薪资列表

图2.2 不允许编辑空闲的薪资Cell

数据库准备

我们需要两张表:
第一张:Mark_Branch分公司表。

第二张:Mark_People人员信息表。

解决思路

下面的文字记录了本事例的实现思路:

  1. 服务器端访问数据库,从Mark_Branch表中得到所有的分公司信息。
  2. 根据分公司信息从Mark_People表中得到员工信息,我们只在意三个字段:员工编号、员工名称、薪水。
  3. 每个分公司下的员工信息都放在一个新建的Dataset中,命名为datasetPeople。
  4. datasetPeople的字段数目使用变量标记出来,使得这个参数可以被配置,并且根据它动态给datasetPeople添加字段,可以按照field0,field1...的规则命名。
  5. datasetPeople的几数行记录放员工信息,偶数行记录放薪水信息,并且使用 一个叫做isPeople的字段标记出来。
  6. 分公司的信息(名称、主键)放在datasetPeople的parameters中。
  7. 客户端需要获得所有服务器端动态生成的datasetPeoplet。
  8. 根据datasetPeople的parameters显示分公司信息。
  9. 根据datasetPeople动态构造DataTable datatablePeople显示员工薪水的列表信息。
  10. 在datasetPeople的afterScroll事件中根据datasetPeople记录的isPeople字段值动态控制datatablePeople的readOnly属性,达到不可编辑员工信息,只可编辑薪水信息的目的。
  11. 在datatablePeople的onGetCellEditor事件中根据datasetPeople的isPeople字段值动态控制editor的readOnly的值,达到不能为不存在的员工添加薪水的目的。
  12. 通过在datatablePeople的afterChange事件中,将所有修改的薪水信息添加到静态AutoSqlDataset中,避免了提交动态生成的Dataset的障碍。


知识点

服务器端动态生成Dataset

首先看一下我们的视图配置,如图2.3。

图2.3 薪资列表的视图模型配置
上图中,datasetBranch是分公司的数据集,我们知道需要根据datasetBranch的记录创建相应的人员薪资的数据集,服务器端的代码如图2.4。

protected void doLoadData() throws Exception {
super.doLoadData();
if (this.getState() == ViewModel.STATE_VIEW) {
Dataset datasetBranch =
(Dataset) this.getDataset("datasetBranch");
datasetBranch.moveFirst();
RecordIterator branchIterator =
datasetBranch.recordIterator();
Record branch;
int fieldCount = 9;// 希望DataTable拥有的列数目
while (branchIterator.hasNext()) {
// 部门变量
branch = branchIterator.nextRecord();
// 获得people的List
List peopleList = getPeopleRecordList(branch);
// 动态构造peopleDataset
CustomDataset peopleDataset =
constructPeopleDataset(branch,fieldCount,
peopleList.size());
// 向peopleDataset中添加记录
setPeopleDatasetRecordSet(fieldCount, peopleList,
peopleDataset);
// 将peopleDataset添加到ViewModel中
this.getDatasets().put(peopleDataset.getId(),
peopleDataset);
}
// 从viewmodel中移除datasetBranch
this.removeDataset("datasetBranch");
}
}

图2.4 薪资列表视图模型的doLoad方法
通过上面的代码注释我们可以看出,该方法的思路是:遍历datasetBranch,利用每个分公司信息与数据库交互获得旗下的所有员工,并把这些员工放置到动态创建的peopleDataset中,再将peopleDataset添加到viewmodel中。做完了这些工作后我们将datasetBranch从viewmodel中删除了,你可能对此感到困惑,然而原因很简单:无论是在服务器端还是客户端,我们不会再使用dataBranch了。这个小动作仅仅是代码优化的结果,删除datasetBranch后,它不会在客户端出现了。图2.4还对应了一些辅助代码。

/**
* 根据传入的分公司参数,获得人员列表
*
* @param Record branch 分公司
* @return List 人员列表
* @throws Exception
*/
private List getPeopleRecordList(Record branch) throws Exception {
String branch_id = branch.getString("branch_id");
AutoSqlDataset datasetPeople = new AutoSqlDataset();
datasetPeople.addField("id");
datasetPeople.addField("name");
datasetPeople.addField("salary");
datasetPeople.setOriginTable("mark_people");
datasetPeople.addBaseMatchRule("dept_id", "like", "%" + branch_id + "%");
datasetPeople.addSortRule("id");
datasetPeople.load();
List employeeList = datasetPeople.toDO();
return employeeList;
}

这里使用了AutoSqlDataset访问数据库,而不是DBStatement,这样做可以省去申请和断开与数据库连接的代码,并且该AutoSqlDataset是临时性的没有添加到viewmodel中。

/**
* 构造出我们需要的的CustomDataset,目前这个Dataset具备的特征为:
* 1. 只读
* 2. 字段数目由用户定义
* 3. 添加了一个叫做title的parameter
* 4. 拥有一个叫做isPeople的字段,用来标识这条记录是否当作DataTable的Header来显示
*
* @param Record branch 分公司
* @param int fieldCount 字段数目
* @param int peopleCount 员工数目
* @return CustomDataset
*/
private CustomDataset constructPeopleDataset(Record branch, int fieldCount, int peopleCount) {
String branch_id = branch.getString("branch_id");
String peopleDatasetId = "datasetPeople_" + branch_id;
CustomDataset peopleDataset = new CustomDataset(this,peopleDatasetId);
peopleDataset.setReadOnly(true);
for (int i = 0; i < fieldCount; i++) {
Field field = peopleDataset.addField("field" + i);
field.setDataType(DataType.STRING);
}
Field isPeople = peopleDataset.addField("isPeople");
isPeople.setDataType(DataType.BOOLEAN);
peopleDataset.parameters().setString("title",
branch.getString("branch_name") + " (" + peopleCount + ")");
return peopleDataset;
}

这个方法中演示了如何在服务器端构造CustomDataset的代码(包括字段、字段类型等),还设置了一个叫做title的Parameter。

/**
* 根据RecordList给CustomDataset添加数据
*
* @param int  fieldCount 字段数目
* @param List  peopleList 信息的记录List
* @param CustomDataset peopleDataset 将要被添加记录的CustomDataset
*/
private void setPeopleDatasetRecordSet(int fieldCount, List peopleList,
CustomDataset peopleDataset) {
int peopleCount = peopleList.size();
for (int i = 0; (i+fieldCount)<=peopleCount; i=i+fieldCount) {
// 显示员工信息的记录
Record peopleRecord = peopleDataset.insertRecord();
peopleRecord.setBoolean("isPeople", true);
// 显示工资信息的记录
Record salaryRecord = peopleDataset.insertRecord();
salaryRecord.setBoolean("isPeople", false);
for (int k = 0; k < fieldCount; k++) {
Map people = (Map) peopleList.get(i + k);
peopleRecord.setString("field" + k,
people.get("id").toString()
"$"
people.get("name").toString());
salaryRecord.setString("field" + k,
people.get("salary").toString());
}
}

int otherPeopleCount = peopleCount % fieldCount;
if (otherPeopleCount > 0) {
Record peopleRecord = peopleDataset.insertRecord();
peopleRecord.setBoolean("isPeople", true);
Record salaryRecord = peopleDataset.insertRecord();
salaryRecord.setBoolean("isPeople", false);
for (int i = 0; i < otherPeopleCount; i++) {
Map people = (Map) peopleList.get(i+(peopleCount-otherPeopleCount));
peopleRecord.setString("field" + i,
people.get("id").toString()
"$"
people.get("name").toString());
salaryRecord.setString("field" + i,
people.get("salary").toString());
}
peopleDataset.setPossibleRecordCount(peopleCount/fieldCount*2 + 2);
} else {
peopleDataset.setPossibleRecordCount(peopleCount/fieldCount*2);
}
peopleDataset.moveFirst();
}

在这个方法中,我们进行了一些数学计算并且通过同时添加两条记录的方式,正确的将peopleList信息添加到peopleDataset中,并保证了记录数永远都是偶数。其中的一段代码是将人员ID和人员姓名通过~$~符号连接起来放到了一个字段中的做法你可能还不理解,先把这个疑问放在脑海中吧,只有当我们阅读了客户端的代码才能揭晓答案。我们还设置了peopleDataset的possibleRecordCount的属性,这是一个好的习惯,并且在客户端计算DataTable的高度时需要使用到。
上面的代码执行后服务器端动态创建的一系列peopleDataset被传递到了客户端,而datasetBranch不会在客户端出现。

VBC:一种面向业务的客户端的代码风格

借助Dorado的强大功能使得我们可以在一张页面中展示一个完整的复杂的业务操作。通常这样的页面会包含大量的Dataset(数据集)和Control(组件),并且需要在事件接口中编写代码。按照传统的代码编写方式,我们在studio的Events Inspetor中找到相应的组件在相关的事件代码框中编写代码。当我们拥有越来越多的组件需要编写越来越多的事件,导致的后果是代码失控了,业务逻辑混乱了。一旦业务发生了变化,通过修改代码达到适应新业务的行为变得非常危险。为了解决这个魔咒我们提出一种面向业务的客户端的代码风格――VBC。VBC的基础理论是:

  1. Dorado为我们提供了完整的可靠的客户端的MVC模型,是VBC的基础,使我们可以将更多的精力关注在业务实现上。
  2. VBC可以描述一个完整的页面级别的业务,描述的结果是业务约束。
  3. 业务约束分为:视图约束和功能约束。
  4. 视图约束是:用户可见的,组件的展现方式。
  5. 功能约束是:为了保障用户顺利完成操作而定义的行为规范,以及业务计算,是业务约束的核心。
  6. 业务约束可以通过Dorado的ViewModel、Dataset和Control的属性和事件被完整的定义。
  7. 业务流的流转是通过Dorado元素的事件激发的。


下图2.5是该事例的客户端的完整代码,具有VBC风格。

/**
* 应用的总入口
*/
function letUsBegin(){
CodePoint.initSaveCommand();

var allPeopleSalaryDatasets =
BusinessRules.getPeopleSalaryDataset();
var branchId,peopleSalaryDatasetId,peopleSalaryDataset,title;
for(var i=0; i<allPeopleSalaryDatasets.length; i++){
peopleSalaryDataset = allPeopleSalaryDatasets[i];

EventManager.addDoradoEvent(peopleSalaryDataset,"afterScroll",
BusinessRules.cantEditPeople
);
EventManager.addDoradoEvent(peopleSalaryDataset,"beforeChange",
BusinessRules.checkNewSalary
);

title = peopleSalaryDataset.parameters().getValue("title");
ViewRules.createTitleLabel( title );
ViewRules.createSalaryTable( peopleSalaryDataset );
}
}
//////////////////////////////视图约束////////////////////////////////
var ViewRules = {};
/**
* 创建一个部门标题label
* @parameter String title 标题信息
*/
ViewRules.createTitleLabel = function( title ){
var label = document.createElement("div");
label.className = "hint";
label.innerText = title;
document.getElementById("salarySpace").appendChild(label);
}
/**
* 创建一个人员薪水的DataTable
* @parameter Dataset datasetPeople 人员Dataset
*/
ViewRules.createSalaryTable = function( datasetPeople ){
var tableId = "datatablePeople_" + datasetPeople.id;
var datatable = DoradoFactory.create("DataTable", null, tableId);
datatable.style.width = "100%";
datatable.style.height =
(datasetPeople.getPossibleRecordCount()*22 + 2*3 )+ "px";
datatable.setDataset(datasetPeople.id);
datatable.setShowHScrollBar(false);
datatable.setShowVScrollBar(false);
datatable.setShowHeader(false);
datatable.setHighlightSelection(false);
datatable.setShowIndicator(false);
document.getElementById("salarySpace").appendChild(datatable);
datatable.activate();
datatable.removeColumn("isPeople");
var columnCount = datatable.getColumnCount();
var column;
for(var i=0; i<columnCount; i++){
column = datatable.getColumn(info);
EventManager.addDoradoEvent(
column, "onRefresh",
ViewRules.drawSalaryTableCell);
EventManager.addDoradoEvent(
column, "onGetCellEditor",
BusinessRules.ifCanEditSalary
);
}
datatable.refresh();
}
/**
* 绘制人员薪水列表的cell,
* 人员薪水DataTable.Column的onRefresh事件
*/
ViewRules.drawSalaryTableCell = function(column,row,cell,value,record){
var peopleName;
if(record.getValue("isPeople")){
cell.className = "HeaderDiv PeopleHeaderDiv";
peopleName = value.split("$")[1];
if(peopleName){
cell.innerText = peopleName;
cell.title = peopleName;
}
}else{
cell.innerText = value;
cell.title = value;
cell.align = "center";
}

return false;
}

/////////////////////////业务约束/////////////////////////////////////
var BusinessRules={};
/**
* 获得所有的人员薪水Dataset
*/
BusinessRules.getPeopleSalaryDataset = function(){
var resultDatasetArray = [];
var allDatasetIdArray = listDatasets();
var datasetId;
for(var i=0; i<allDatasetIdArray.length; i++){
datasetId = allDatasetIdArray[i];
if(datasetId == "datasetPeople"){continue;}
resultDatasetArray.push(window[datasetId]);
}
return resultDatasetArray;
}
/**
* 不能编辑人员信息,
* 人员薪水Dataset的afterScroll事件
*/
BusinessRules.cantEditPeople = function(dataset){
if(!dataset.getCurrent()){return;}
if(dataset.getValue("isPeople") && !dataset.isReadOnly()){
dataset.setReadOnly(true);
dataset.refreshControls();
}else
if(!dataset.getValue("isPeople") && dataset.isReadOnly()){
dataset.setReadOnly(false);
dataset.refreshControls();
}
}
/**
* 是否可以编辑薪水,
* 人员薪水DataTable的onGetCellEditor事件
*/
BusinessRules.ifCanEditSalary = function(column,cell,editor,record){
var headerRecord = record.getPrevRecord();
if(!record.getValue("isPeople") && headerRecord
&& headerRecord.getValue(column.getName())){
editor.setReadOnly(false);
return editor;
}else{
editor.setReadOnly(true);
return editor;
}
}
/**
* 检验新的薪水是否合法
* 人员薪水Dataset的beforeChange事件
*/
BusinessRules.checkNewSalary = function(dataset,record,field,value){
var peopleId,result;
if(isNaN(value)){
return new DoradoException("请输入数字");
}else{
peopleId = record.getPrevRecord().
getValue(field.getName()).split("$")[0];
result = datasetPeople.find(["id"],
[peopleId]);
if(result){
result.setValue("salary",value);
}else{
result = datasetPeople.insertRecord();
result.setValue("id",peopleId);
result.setValue("salary",value);
result.setState("modify");
}
}
}
////////////////////////代码要点////////////////////////////////
var CodePoint = {};
/**
* 对comandUpdateSalary的设置
*/
CodePoint.initSaveCommand = function(){
commandUpdateSalary.setReduceReturnInfo(true);
EventManager.addDoradoEvent(commandUpdateSalary,"onSuccess",
function(command){
datasetPeople.clearData();
}
);
}

图2.5 薪资列表的客户端代码
具有VBC风格的代码具有以下特点:

  1. 使用命名空间管理不同的业务约束。典型的命名空间为ViewRules(视图约束)、BunissesRules(核心业务约束,即功能约束)、CodePoint(支持ViewRules、BunissesRules的代码以及需要重点关注的代码)。
  2. 使用EventManager为Dorado元素添加事件。通常定义一个入口函数在ViewModel的onDatasetsPrepared或onLoad事件中被调用,里面编写了业务描述的初始化代码,Dorado元素的事件也是在这个时候被添加的。
  3. BunissesRules命名空间下一些函数直接对应于某个Dorado元素的某个事件,另一些被相同命名空间下的函数调用。


图2.5中的一些代码值得我们借鉴。

客户端动态生成DataTable

可以在ViewRules.createSalaryTable中看到客户端动态创建DataTable的代码:

ViewRules.createSalaryTable = function( datasetPeople ){
var tableId = "datatablePeople_" + datasetPeople.id;
var datatable = DoradoFactory.create("DataTable", null, tableId);
datatable.style.width = "100%";
datatable.style.height =
(datasetPeople.getPossibleRecordCount()*22 + 2*3 )+ "px";
datatable.setDataset(datasetPeople.id);
datatable.setShowHScrollBar(false);
datatable.setShowVScrollBar(false);
datatable.setShowHeader(false);
datatable.setHighlightSelection(false);
datatable.setShowIndicator(false);
document.getElementById("salarySpace").appendChild(datatable);
datatable.activate();
datatable.removeColumn("isPeople");
var columnCount = datatable.getColumnCount();
var column;
for(var i=0; i<columnCount; i++){
column = datatable.getColumn(info);
EventManager.addDoradoEvent(
column, "onRefresh",
ViewRules.drawSalaryTableCell);
EventManager.addDoradoEvent(
column, "onGetCellEditor",
BusinessRules.ifCanEditSalary
);
}
datatable.refresh();
}

创建DataTable:

var datatable = DoradoFactory.create("DataTable", null, tableId);

将动态创建的DataTable添加到DOM树:

document.getElementById("salarySpace").appendChild(datatable);

DataTable最佳高度的计算公式:

datatable.style.height = (datasetPeople.getPossibleRecordCount()*22
+ 2*3 )+ "px";

使用onRefresh事件控制显示形式

还记得~$~的疑问吗?在ViewRules.drawSalaryTableCell中你可以找到答案了:在Column的onRefresh事件中使用代码控制人员信息的Row只显示人员的名称。

if(record.getValue("isPeople")){
cell.className = "HeaderDiv PeopleHeaderDiv";
peopleName = value.split("$")[1];
if(peopleName){
cell.innerText = peopleName;
cell.title = peopleName;
}
}

利用listDatasets()函数得到页面所有的Dataset

在BusinessRules.getPeopleSalaryDataset函数中得到了所有人员薪资的记录集。

BusinessRules.getPeopleSalaryDataset = function(){
var resultDatasetArray = [];
var allDatasetIdArray = listDatasets();
var datasetId;
for(var i=0; i<allDatasetIdArray.length; i++){
datasetId = allDatasetIdArray[i];
if(datasetId == "datasetPeople"){continue;}
resultDatasetArray.push(window[datasetId]);
}
return resultDatasetArray;
}

控制Dataset部分记录可以被编辑

在Dataset的afterScroll事件中动态设置Dataset的readOnly属性可以达到Dataset部分记录可以被编辑的效果。该事例中我们调用BusinessRules.cantEditPeople达到了这样的感官效果:DataTable的奇数行不可以编辑,偶数行可以被编辑。

BusinessRules.cantEditPeople = function(dataset){
if(!dataset.getCurrent()){return;}
if(dataset.getValue("isPeople") && !dataset.isReadOnly()){
dataset.setReadOnly(true);
dataset.refreshControls();
}else
if(!dataset.getValue("isPeople") && dataset.isReadOnly()){
dataset.setReadOnly(false);
dataset.refreshControls();
}
}

控制DataTable的部分Cell可以被编辑

在DataTable的Cell的onGetCellEditor事件中动态设置editor的readOnly属性可以达到DataTable的部分Cell可以被编辑的效果。在本事例中,薪资列表显示薪水的Row中空闲的Cell是不可以编辑被的,调用的函数是BusinessRules.ifCanEditSalary。

BusinessRules.ifCanEditSalary = function(column,cell,editor,record){
var headerRecord = record.getPrevRecord();
if(!record.getValue("isPeople") && headerRecord
&& headerRecord.getValue(column.getName())){
editor.setReadOnly(false);
return editor;
}else{
editor.setReadOnly(true);
return editor;
}
}

手动修改记录的状态

Dorado的客户端引擎会自动维护Dataset中每条记录的状态。当我们调用var r=dataset.insertRecord()时新纪录r的状态为new;如果顺利的经过dataset.post(),r的状态为insert;如果调用dataset.deleteRecord(),r的状态为delete;如果没有被删除掉而是被使用updateCommand调用了服务器端的持久化方法,r的状态为none;这时修改r,状态变为modify。
在本事例中我们想把用户修改的薪资信息统一的放到datasetPeople中,这时只需要提交一个Dataset,相比提交多的动态生成的Dataset简单很多。如果只是调用datasetPeople.insertRecord()是不能满足我们要求的,所以需要手动将记录的状态改为modify,这样AutoSqlDataset datasetPeople在服务器端会根据提交的记录状态生成Update语句,持久化到数据库中。BusinessRules.checkNewSalary是在动态生成的薪资Dataset的beforeChange事件句柄。

BusinessRules.checkNewSalary = function(dataset,record,field,value){
var peopleId,result;
if(isNaN(value)){
return new DoradoException("请输入数字");
}else{
peopleId = record.getPrevRecord().
getValue(field.getName()).split("$")[0];
result = datasetPeople.find(["id"],
[peopleId]);
if(result){
result.setValue("salary",value);
}else{
result = datasetPeople.insertRecord();
result.setValue("id",peopleId);
result.setValue("salary",value);
result.setState("modify");
}
}
}

UpdateCommand的reduceReturnInfo属性

在本事例中我们使用了CodePoint管理页面上应该引起注意的代码,目前在CodePoint.initSaveCommand中放置的是系统优化的代码。将UpdateCommand的reduceReturnInfo属性设置为true。

commandUpdateSalary.setReduceReturnInfo(true);

默认情况下UpdateCommand的reduceReturnInfo属性为false,这时UpdateCommand在服务器端执行完毕返回客户端时会将客户端提交的记录集一同返回来,然后客户端的Dorado引擎会根据这些数据同步客户端Dataset的记录。如果做大数据量的提交并且不关心服务器端的返回信息通常不建议这样做。