65.9K
CodeProject 正在变化。 阅读更多。
Home

16天:从概念到实现的TypeScript应用程序

starIconstarIconstarIconstarIconstarIcon

5.00/5 (14投票s)

2019 年 11 月 2 日

CPOL

50分钟阅读

viewsIcon

33609

downloadIcon

203

一个由元数据驱动的视图定义了模型,模式(schema)动态生成,从概念到原型应用仅用了 16 天。

此截图只是已实现功能的子集。右侧是与项目和任务相关的附加链接。

目录

引言

我想,记录一个客户端 TypeScript 应用从概念到实现的过程会很有趣,也很有启发性。所以我选择了一个我一直想做的事情——一个为我特定需求量身定制的项目任务管理器。但我也想让这个实现高度抽象,这意味着 UI 布局和父子实体关系的元数据。换句话说,最终,物理的 index.html 页面看起来会是这样的(片段)。

<div class="row col1">
  <div class="entitySeparator">
    <button type="button" id="createProject" class="createButton">Create Project</button>
    <div id="projectTemplateContainer" class="templateContainer"></div>
  </div>
</div>
<div class="row col2">
  <div class="entitySeparator">
    <button type="button" id="createProjectContact" class="createButton">Create Contact</button>
    <div id="projectContactTemplateContainer" class="templateContainer"></div>
  </div>
    <div class="entitySeparator">
    <button type="button" id="createProjectNote" class="createButton">
    Create Project Note</button>
    <div id="projectNoteTemplateContainer" class="templateContainer"></div>
  </div>
    <div class="entitySeparator">
    <button type="button" id="createProjectLink" class="createButton">Create Link</button>
    <div id="projectLinkTemplateContainer" class="templateContainer"></div>
  </div>
</div>

真正的功劳在于客户端创建容器内容。所以这篇文章介绍了一种创建通用父子实体编辑器的方法,但它是以特定项目任务管理器为背景的,你可以看到从概念到工作应用程序的演变过程,这花了 15 个“天”。

正如你可能期望的那样,我还探索了一些新概念。

  1. 除了具体模型,整个“模型”概念都被抛诸脑后。视图定义了模型!
  2. 按需动态生成表和列。是的。

顺便说一下,这些天不是连续的——虽然每天都记录了我完成的工作,但这并不意味着我每天都在为此工作。特别是第 12 天包含了 3 天的实际时间跨度(而且不是 8 小时工作制!)。另外,你应该意识到,每天都包括更新文章本身!

而且,是的,这可以用表格来实现,但是默认的 HTML 表格功能非常糟糕,而且我不想为这些文章引入其他第三方库。我最喜欢的是 jqWidgets,几乎没有其他库能做到(尽管它很大),所以也许某一天,我会演示如何将所有这些东西与他们的库连接起来。

第 1 天 - 通用概念

一些粗略的草图

  • 从布局可以看出,这实际上更多地是我自己需求的模板,左侧的实际“任务项栏”以及每个任务项的特定字段应该是用户完全可定义的,包括标签、内容和控件。
  • 这基本上意味着我们正在处理一个 NoSQL 数据库结构,任务和任务项之间有松散的链接。“根”仍然是任务,但任务项及其字段确实相当任意。
  • 所以我们需要能够将结构及其字段内容定义为一个“数据库”,用于用户组织信息的方式。
  • 某些字段最终会变成数组(例如 URL 链接),这些数组被离散表示,而其他字段(例如注释)可以离散地显示为一组独立的文本区域条目,或者更像一个“文档”,用户只需滚动浏览单个文本区域。
  • 搜索 - 用户应该能够搜索任何字段或特定区域,例如“注释”。
  • 任何字段都可以是单个字段(例如通信的日期/时间)或集合,例如该通信的联系人列表。
  • 因此,我们首先做的是定义一个任意的模式,其中包含足够的元数据来描述模式中字段的布局和控件,以及模式元素上的操作,例如,任务项栏可以表示为模式元素,但它们是按钮,而不是用户输入控件。
  • 我们不想为此做得太过火!这种方法的复杂性在于页面不是静态的——整个布局必须从元数据生成,所以问题是,服务器端生成还是客户端生成?
  • 我个人更喜欢客户端。服务器应该只涉及最少的布局——服务器应该提供内容,即数据,而不是布局。这种方法还方便了 UI 的开发,而无需服务器,并将所有 UI 代码保留在客户端,而不是分散在 JavaScript 和后端 C# 之间。而且,我也不想在后端使用 node.js。

第 2 天 - 元数据的某些结构概念

我们应该能够拥有相当简单的结构。让我们定义几个,它们当然都是可定制的,但我们将定义一些有用的默认值。

状态

我喜欢有相当特定的状态,并且当我无法将这些信息放入一个简单的下拉列表中时,我就会感到沮丧,这样我就可以一目了然地了解任务的进展情况。所以,我喜欢这样的东西

  • 待办
  • 正在处理
  • 测试
  • QA
  • 生产(完成)
  • 等待第三方
  • 等待同事
  • 等待管理层
  • 卡住了

请注意,我没有在任务旁边设置优先级。我真的不在乎优先级——通常有很多事情在进行,我根据心情和能力来工作。当然,如果你喜欢优先级,你可以将它们添加到 UI 中。

请注意,我也没有将任务进行分类,例如按冲刺、平台、客户等。同样,如果你想要这些东西,你可以添加它们。

我确实想要的是

  1. 任务是什么?
  2. 它的状态是什么?
  3. 一行描述它为何处于该状态。

所以这就是我想要看到的(当然,你想要看到的会不同)

我们如何用 JSON 定义这个布局,以便你可以创建任何需要的东西?基本上,这意味着首先要弄清楚如何满足我的需求!

这可能是高级任务列表的定义

[
  {
    Item:
    {
      Field: "Task",
      Line: 0,
      Width: "80%"
    }
  },
  {
    Item:
    {
      Field: "Status",
      SelectFrom: "StatusList",
      OrderBy: "StatusOrder",
      Line: 0,
      Width: "20%"
    }
  },
  {
    Item:
    {
      Field: "Why",
      Line: 1,
      Width: "100%"
    }
  }
]

这些字段都可以内联编辑,但我们也希望支持钻入一个字段以查看其子记录。并非所有字段都有子记录(例如 Status),但这由元数据结构决定,因此 Status 可以有子记录。任何时候用户聚焦于具有子结构的控件,按钮栏都会更新,“选择时显示”子结构会显示子记录。

所以我们可以这样使用 Task 实体作为例子来定义子结构,或允许的子记录。

[
  {Entity:"Contact", Label:"Contacts"},
  {Entity:"Link", Label:"Links", "ShowOnParentSelect": true},
  {Entity:"KeyPoint", Label: "Key Points"},
  {Entity:"Note" Label: "Notes", "ShowOnParentSelect": true},
  {Entity:"Communication", Label: "Communications"}
]

请注意,所有子结构都以其单数形式定义,我们对用于表示链接的标签有完全的灵活性。“父选择时显示”将始终可见,除非用户折叠该部分,并且它们按照上面列表中出现的顺序进行渲染。它们的渲染位置由其他布局信息决定。

其他需要考虑的事情

  • 子任务(很容易实现)
  • 任务依赖性

第 3 天 - 模板

因此,我越是思考这个问题,就越意识到这实际上是一个非常通用的实体创建器/编辑器,具有不完全动态的关系,正如我在我的面向关系编程文章中所写的那样。因此,允许的关系应该可以定义。但此刻我想做的是一些原型设计,以了解其中一些想法如何实现。所以让我们从上面的 JSON 开始,并编写一个函数,将其转换为 HTML 模板,然后根据需要重复应用。同时,我将学习 TypeScript 的细微差别!

经过一些编码,我得到了这个

由模板数组定义

let template = [ // Task Template
  {
    field: "Task",
    line: 0,
    width: "80%",
    control: "textbox",
  },
  { 
    field: "Status",
    selectFrom: "StatusList",
    orderBy: "StatusOrder",
    line: 0,
    width: "20%",
    control: "combobox",
  },
  {
    field: "Why",
    line: 1,
    width: "100%",
    control: "textbox",
  }
];

并支持接口来定义模板对象模型,以及一个 Builder 类来构建 HTML

interface Item {
  field: string;
  line: number;
  width: string;
  control: string;
  selectedFrom?: string;
  orderBy?: string;
}

interface Items extends Array<Item> { }

class Builder {
  html: string;

  constructor() {
    this.html = "";
  }

  public DivBegin(item: Item): Builder {
    this.html += "<div style='float:left; width:" + item.width + "'>";

    return this;
  }

  public DivEnd(): Builder {
    this.html += "</div>";

    return this;
  }

  public DivClear(): Builder {
    this.html += "<div style='clear:both'></div>";

    return this;
  }

  public TextInput(item: Item): Builder {
    let placeholder = item.field;
    this.html += "<input type='text' placeholder='" + placeholder + "' style='width:100%'>";

    return this;
  }

  public Combobox(item: Item): Builder {
    this.SelectBegin().Option("A").Option("B").Option("C").SelectEnd();

    return this;
  }

  public SelectBegin(): Builder {
    this.html += "<select style='width:100%; height:21px'>";

    return this;
  }

  public SelectEnd(): Builder {
    this.html += "</select>";

    return this;
  }

  public Option(text: string, value?: string): Builder {
    this.html += "<option value='" + value + "'>" + text + "</option>";

    return this;
  }
}

这样就只剩下构建模板的逻辑了

private CreateHtmlTemplate(template: Items) : string {
  let builder = new Builder();
  let line = -1;
  let firstLine = true;

  template.forEach(item => {
    if (item.line != line) {
      line = item.line;

      if (!firstLine) {
        builder.DivClear();
      }

      firstLine = false;
    }

    builder.DivBegin(item);

    switch (item.control) {
      case "textbox":
        builder.TextInput(item);
        break;

      case "combobox":
        builder.Combobox(item);
        break;
      }

    builder.DivEnd();
  });

  builder.DivClear();

  return builder.html;
}

所以顶层代码只做这个

let html = this.CreateHtmlTemplate(template);
jQuery("#template").html(html);

如果我将模板链接起来

jQuery("#template").html(html + html + html);

我得到

酷。可能不是最漂亮的,但基本的东西正是我想要的。

现在,个人来说,让我非常恼火的是,模板对象让我想起了 ExtJs:基本上是一系列任意的键来定义 UI 的布局。也许这是不可避免的,我当然也不会走 ExtJs 的路线,那就是创建自定义 ID,每次刷新页面时都会改变。这简直是扼杀了 UI 级别的测试自动化能力。不过,讽刺的是,在编写这样的东西时,我开始真正更好地理解 ExtJs 所做的设计决策。

这就引出了 comboboxs 实际是如何填充的。是的,ExtJs 中有一个“store”的概念,并且自动操作 store(理论上)会更新 UI。现在这对我来说太多了,但我确实希望能够使用现有的对象或从 REST 调用中获取(并可能缓存)对象。所以让我们一起构建一些简单的东西。这是我的状态

let taskStates = [
  { text: 'TODO'},
  { text: 'Working On' },
  { text: 'Testing' },
  { text: 'QA' },
  { text: 'Done' },
  { text: 'On Production' },
  { text: 'Waiting on 3rd Party' },
  { text: 'Waiting on Coworker' },
  { text: 'Waiting on Management' },
  { text: 'Stuck' },
];

经过一点重构

export interface Item {
  field: string;
  line: number;
  width: string;
  control: string;
  storeName?: string;  // <== this got changed to "storeName"
  orderBy?: string;
}

以及 Store 的原型概念

interface KeyStoreMap {
  [key: string] : any;  // Eventually "any" will be replaced with a more formal structure.
}

export class Store {
  stores: KeyStoreMap = {};

  public AddLocalStore(key: string, store: any) {
  this.stores[key] = store;
  }

  // Eventually will support local stores, REST calls, caching, 
 // computational stores, and using other 
  // existing objects as stores.
  public GetStore(key: string) {
    return this.stores[key];
  }
}

我现在这样做

let store = new Store();
store.AddLocalStore("StatusList", taskStates);
let html = this.CreateHtmlTemplate(template, store);

模板构建器也这样做

public Combobox(item: Item, store: Store) : TemplateBuilder {
  this.SelectBegin();

  store.GetStore(item.storeName).forEach(kv => {
    this.Option(kv.text);
  });

  this.SelectEnd();

  return this;
}

结果是

这很容易。

那么,持久化实际的任务数据并恢复它需要什么?看起来 store 的概念可以扩展以保存状态,而我想要支持的状态之一是 localStorage。这似乎也很复杂,因为我已经处理了一个对象数组!同样,我意识到为什么在 ExtJS 中 store 总是数组,即使 store 代表一个单例——因为这更简单!所以让我们重构 Store 类。首先,我们需要一个定义 store 类型的类,像这样

export enum StoreType {
  Undefined,
  InMemory,
  LocalStorage,
  RestCall,
}

然后,我们需要一个管理 store 配置的类

import { StoreType } from "../enums/StoreType"

export class StoreConfiguration {
  storeType: StoreType;
  cached: boolean;
  data: any;

  constructor() {
    this.storeType = StoreType.Undefined;
    this.data = [];
  }
}

最后,我们将重构 Store 类,使其看起来像这样

import { StoreConfiguration } from "./StoreConfiguration"
import { StoreType } from "../enums/StoreType"
import { KeyStoreMap } from "../interfaces/KeyStoreMap"

export class Store {
  stores: KeyStoreMap = {};

  public CreateStore(key: string, type: StoreType) {
    this.stores[key] = new StoreConfiguration();
  }

  public AddInMemoryStore(key: string, data: object[]) {
    let store = new StoreConfiguration();
    store.storeType = StoreType.InMemory;
    store.data = data;
    this.stores[key] = store;
  }

  // Eventually will support local stores, REST calls, caching, 
 // computational stores, and using other 
  // existing objects as stores.
  public GetStoreData(key: string) {
    return this.stores[key].data;
  }
}

这是这样使用的

let store = new Store();
store.AddInMemoryStore("StatusList", taskStates);
store.CreateStore("Tasks", StoreType.LocalStorage);

接下来,我们之前创建的模板

let html = this.CreateHtmlTemplate(template, store);

需要知道要使用哪个 store 来填充模板项,所以我们改为这样做

let html = this.CreateHtmlTemplate(template, store, "Tasks");

坦白说,我不知道这是否是个好主意,但目前我们就这样,看看它是否能hold住。

接下来我们需要重构这段代码 jQuery("#template").html(html + html + html);,这样我们就不再盲目复制 HTML 模板,而是有一种构建模板的方法,让它知道在 store 数据中哪个对象索引在字段更改时需要更新。处理将排序与 store 的数据表示解耦将是一个有趣的挑战。以后再说。更重要的是,当我们实现从 localStorage 加载任务时,那段代码很可能会被完全抛弃。目前,在模板构建器中,让我们向我们的两个控件添加一个自定义属性 storeIdx

this.html += "<input type='text' placeholder='" + placeholder + "' 
             style='width:100%' storeIdx='{idx}'>";

this.html += "<select style='width:100%; height:21px' storeIdx='{idx}'>";

现在我们这样做

let html = this.CreateHtmlTemplate(template, store, "Tasks");
let task1 = this.SetStoreIndex(html, 0);
let task2 = this.SetStoreIndex(html, 1);
let task3 = this.SetStoreIndex(html, 2);
jQuery("#template").html(task1 + task2 + task3);

在...的帮助下

private SetStoreIndex(html: string, idx: number) : string {
  // a "replace all" function.
  let newHtml = html.split("{idx}").join(idx.toString());

  return newHtml;
}

果然,我们现在有了 store 的索引,例如

唉。请注意,生成的 HTML 具有 storeIdx 属性,全部为小写。这似乎是 jQuery 的一个问题,我以后会调查。接下来,我们需要创建 onchange 处理程序,以便在值更改时更新 store。这必须通过“晚期绑定”来完成,因为 HTML 是动态从模板生成的。我又一次看到了 ExtJS 为什么最终会为元素分配任意 ID——我们如何识别要绑定 onchange 处理程序的元素?我个人更喜欢使用一个单独的属性来唯一标识绑定点,并且可能是该属性值的 GUID。如果必须绑定数百个元素,这会对性能产生什么影响,谁知道呢?但说实话,我不会担心那个!

晚上 10:30 了,我该睡觉了!

第 4 天 - 晚期绑定

所以,在这里,我们的任务是实现晚期绑定。首先,对模板构建器进行一些重构,以设置 bindGuid 属性并带有一个唯一的标识符,我们将使用它来确定绑定,再次以 inputselect 元素为例。

public TextInput(item: Item, entityStore: StoreConfiguration) : TemplateBuilder {
  let placeholder = item.field;
  let guid = Guid.NewGuid();
  this.html += "<input type='text' placeholder='" + placeholder + 
              "' style='width:100%' storeIdx='{idx}' bindGuid='" + guid.ToString() + "'>";
  let el = new TemplateElement(item, guid);
  this.elements.push(el);

 return this;
}

public SelectBegin(item: Item) : TemplateBuilder {
  let guid = Guid.NewGuid();
  this.html += "<select style='width:100%; height:21px' 
               storeIdx='{idx}' bindGuid='" + guid.ToString() + "'>";
  let el = new TemplateElement(item, guid);
  this.elements.push(el);

  return this;
}

这些都放入一个数组中

elements: TemplateElement[] = [];

文档就绪时,绑定过程会连接这些

jQuery(document).ready(() => {
  // Bind the onchange events.
  builder.elements.forEach(el => {
    let jels = jQuery("[bindGuid = '" + el.guid.ToString() + "']");

    jels.each((_, elx) => {
      let jel = jQuery(elx);

      jel.on('change', () => {
        let recIdx = jel.attr("storeIdx");
        console.log("change for " + el.guid.ToString() + " at index " + 
                    recIdx + " value of " + jel.val());
        taskStore.SetProperty(Number(recIdx), el.item.field, jel.val());
      });
    });
  });
});

上面代码片段中有一个“不太好”的代码:taskStore.SetProperty。对 taskStore 的硬编码在以后会被重构掉,这样绑定就不会只针对 Task store!

请注意,这里我们也使用记录索引来限定记录。我们这样做是因为使用这段代码 jQuery("#template").html(task1 + task2 + task3); 中有多个具有相同 GUID 的元素,因为我们克隆了 HTML 模板三次。可能不是理想的,但我现在会忍受。同时,我为任务创建的 store

let taskStore = store.CreateStore("Tasks", StoreType.LocalStorage);

管理在指定索引处为记录设置属性值,并根据需要创建空记录。

public SetProperty(idx: number, property: string, value: any): StoreConfiguration {
  // Create additional records as necessary:
  while (this.data.length - 1 < idx) {
    this.data.push({});
  }

  this.data[idx][property] = value;
  this.UpdatePhysicalStorage(this.data[idx], property, value);

  return this;
}

private UpdatePhysicalStorage(record: any, property: string, value: string) : Store {
  switch (this.storeType) {
    case StoreType.InMemory:
      // Do nothing.
      break;

    case StoreType.RestCall:
      // Eventually send an update but we probably ought to have a PK 
     // with which to associate the change.
      break;

    case StoreType.LocalStorage:
      // Here we just update the whole structure.
      let json = JSON.stringify(this.data);
      window.localStorage.setItem(this.name, json);
      break;	
  }

  return this;
}

目前,这是在 StoreConfiguration 类中实现的。看起来很尴尬,但 StoreConfiguration 类维护数据,而 Store 类实际上是一个“store管理器”,所以也许 Store 应该称为 StoreManager,而 StoreConfiguration 应该称为 Store!喜欢重构以使事物名称更清晰。所以从现在开始,它们将这样命名。在没有 C# 代码的“重命名”功能的情况下,这有点麻烦!

输入一些值后

我们可以看到这些已经被序列化到本地存储(在 Chrome 中检查本地存储)。

酷,但是请注意记录 0 没有状态,因为我没有从默认值更改它。那该怎么办?这不是一个简单的问题,因为我们创建的模板实例数量与 store 数据之间存在脱节。所以我们需要一个机制来处理这个问题并设置默认值。最简单的答案是现在就粗暴地解决它。至少它是明确的。

taskStore.SetProperty(0, "Status", taskStates[0].text);
taskStore.SetProperty(1, "Status", taskStates[0].text);
taskStore.SetProperty(2, "Status", taskStates[0].text);

现在,task store 会用默认值初始化

最终,这只是将问题推到了“忽略”的桶里,因为它也依赖于状态数组的顺序。但没关系,继续前进,既然我们在 store 中有了东西,就用 store 数据加载 UI!我们也有一个问题,store 是否应该在每次按键时更新,还是只在 onchange 事件触发时更新,该事件在元素失去焦点时发生。另一个“暂时忽略”的问题。此外,在这个函数中,我们对“不要实现有副作用的代码!”有一个很好的演示

public SetProperty(idx: number, property: string, value: any): Store {
  // Create additional records as necessary:
  while (this.data.length - 1 < idx) {
    this.data.push({});
  }

  this.data[idx][property] = value;
  this.UpdatePhysicalStorage(this.data[idx], property, value);

  return this;
}

因为更新本地存储这样的物理存储会销毁我们保存的任何东西!我造成了一个小小的困境——如果记录不存在于本地存储中,我想设置默认值,但如果它们确实存在,我不想设置默认值!所以,首先,让我们摆脱副作用,将物理存储的更新移到 onchange 处理程序中。

jel.on('change', () => {
  let recIdx = Number(jel.attr("storeIdx"));
  let field = el.item.field;
  let val = jel.val();

  console.log("change for " + el.guid.ToString() + " at index " + 
              recIdx + " value of " + jel.val());
  taskStore.SetProperty(recIdx, field, val).UpdatePhysicalStorage(recIdx, field, val);
});

接下来,移除这个

taskStore.SetProperty(0, "Status", taskStates[0].text);
taskStore.SetProperty(1, "Status", taskStates[0].text);
taskStore.SetProperty(2, "Status", taskStates[0].text);

并取而代之的是在 store 加载后,如果默认值不存在,则能够设置默认值。

taskStore.Load()
  .SetDefault(0, "Status", taskStates[0].text)
  .SetDefault(1, "Status", taskStates[0].text)
  .SetDefault(2, "Status", taskStates[0].text)
  .Save();

实现如下

public SetDefault(idx: number, property: string, value: any): Store {
  this.CreateNecessaryRecords(idx);

  if (!this.data[idx][property]) {
    this.data[idx][property] = value;
  }

  return this;
}

以及 Save 函数

public Save(): Store {
  switch (this.storeType) {
    case StoreType.InMemory:
      // TODO: throw exception?
      break;

    case StoreType.RestCall:
      // Eventually send an update but we probably ought to have a PK 
     // with which to associate the change.
      break;

    case StoreType.LocalStorage:
      // Here we just update the whole structure.
      this.SaveToLocalStorage();
      break;
  }

  return this;
}

但是,这有一个烦人的副作用,即可能导致每次保存记录时都进行 REST 调用,即使什么都没有改变。另一个“暂时忽略”的问题,但我们肯定需要实现一个“字段已修改”标志!对于本地存储,我们别无选择,必须保存整个结构,所以目前我们很好。当没有本地存储时,我们得到期望的默认值

当有数据时,刷新页面也不会将其销毁。

当然,UI 没有更新,因为我们需要绑定也朝另一个方向工作!一种粗暴的实现如下

for (let i = 0; i < 3; i++) {
  for (let j = 0; j < builder.elements.length; j++) {
    let tel = builder.elements[j];
    let guid = tel.guid.ToString();
    let jel = jQuery(`[bindGuid = '${guid}'][storeIdx = '${i}']`);
    jel.val(taskStore.GetProperty(i, tel.item.field));
  }
}

哦,注意模板字面量let jel = jQuery(`[bindGuid = '${guid}'][storeIdx = '${i}']`); -- 我必须重构代码并更频繁地使用它!

页面加载时会产生这个

酷,我现在可以创建并保存三个任务了!第 4 天结束,很快会回来处理反向绑定和更好的默认值处理,以及摆脱这个愚蠢的“3 个任务”问题,让任务更动态。

第 5 天 - 存储回调

所以上面那种粗暴的方法需要修复,但我不想让 store 知道任何关于记录字段如何映射到 UI 元素的信息,所以我认为我想做的是提供记录和属性级别更新的回调,使用老派的控制反转原则。也许也应该为不同的 store 类型做一些类似的事情,以便应用程序可以覆盖每个 store 的行为。以后再说。

Store 类中,我将添加几个回调,并带有默认的“什么都不做”的处理程序。

recordChangedCallback: (idx: number, record: any, store: Store) => void = () => { }; 
propertyChangedCallback: (idx: number, field: string, 
                         value: any, store: Store) => void = () => { };

Load 函数中,我们将为加载的每个记录调用 recordChangedCallback(可能不是我们想要的长期结果!)。

this.data.forEach((record, idx) => this.recordChangedCallback(idx, record, this));

这被连接到 taskStore——注意它是这样实现的,它传入了模板构建器,这有点像视图,所以我们可以从“view”模板中获取所有字段定义。

taskStore.recordChangedCallback = 
  (idx, record, store) => this.UpdateRecordView(builder, store, idx, record);

处理程序看起来很像上面的粗暴方法。

private UpdateRecordView(builder: TemplateBuilder, 
       store: Store, idx: number, record: any): void {
  for (let j = 0; j < builder.elements.length; j++) {
    let tel = builder.elements[j];
    let guid = tel.guid.ToString();
    let jel = jQuery(`[bindGuid = '${guid}'][storeIdx = '${idx}']`);
    let val = store.GetProperty(idx, tel.item.field);
    jel.val(val);
  }
}

这是一个相当通用的方法。让我们为仅更改属性做一些类似的事情,并通过 Store 设置记录的属性值来测试它。

public SetProperty(idx: number, field: string, value: any): Store {
  this.CreateNecessaryRecords(idx);
  this.data[idx][field] = value;
  this.propertyChangedCallback(idx, field, value, this);  // <== this got added.

  return this;
}

像这样连接起来

taskStore.propertyChangedCallback = 
   (idx, field, value, store) => this.UpdatePropertyView(builder, store, idx, field, value);

并这样实现

private UpdatePropertyView(builder: TemplateBuilder, 
   store: Store, idx: number, field: string, value: any): void {
  let tel = builder.elements.find(e => e.item.field == field);
  let guid = tel.guid.ToString();
  let jel = jQuery(`[bindGuid = '${guid}'][storeIdx = '${idx}']`);
  jel.val(value);
}

现在我们可以设置 Store 中记录的属性,它会反映在 UI 中。

taskStore.SetProperty(1, "Task", `Random Task #${Math.floor(Math.random() * 100)}`);

那么,让我们看看添加和删除任务。有些人要么在笑,要么在抱怨,因为我将自己逼入了另一个“记录索引”概念的死胡同,这使得删除和插入任务成为一场噩梦,因为 storeIdx 将与它管理的记录不同步。所以是时候抛弃整个概念,转而采用一种更智能的方式来处理记录了。目前,我已经将 Store 的数据声明为 name:value 对的数组。

data: {}[] = [];

但现在是时候采用更智能的东西了——一种无需使用行索引即可唯一标识记录的方法,以及一种将该唯一标识符与 UI 元素关联的方法。这里的讽刺之处在于,数字索引是一种很好的方法,我们只需要将索引映射到物理记录,而不是假设 1:1 的对应关系。我们也不再需要 CreateNecessaryRecords 方法,而是仅创建这个单一的存根键值对象,如果“索引”在索引-记录映射中缺失。

所以,现在我有

private data: RowRecordMap = {};

它是 private 的,因为我不希望任何人触碰这个结构,它被声明为这样

export interface RowRecordMap {
  [key: number]: {}
}

最重要的重构涉及记录更改回调。

jQuery.each(this.data, (k, v) => this.recordChangedCallback(k, v, this));

几乎没有其他变化,因为索引不再是数组索引,而是字典键,因此用法相同。这里我们假设在初始加载时,记录索引(从 0 到 n-1)与模板构建器创建的索引具有 1:1 的对应关系。另一个重要变化是,要保存到本地存储,我们不希望保存键值模型,只保存值,因为键(行索引查找)是完全任意的。

public GetRawData(): {}[] {
  return jQuery.map(this.data, value => value);
}

private SaveToLocalStorage() {
  let json = JSON.stringify(this.GetRawData());
  window.localStorage.setItem(this.storeName, json);
}

删除任务

更多重构!为了实现这一点,我们需要将每个克隆的模板包装在其自己的 div 中,以便我们可以删除它。目前,HTML 看起来像这样

红色框是其中一个模板实例。我们想要的是这个(使此工作生效的代码更改很简单,所以我不会展示它)。

现在让我们减小“Why”文本框的宽度,并在模板定义中添加一个“Delete”按钮。

{
  field: "Why",
  line: 1,
  width: "80%",			// <== Changed
  control: "textbox",
},
{
  text: "Delete",		// <== Added all this
  line: 1,
  width: "20%",
  control: "button",
}

并向 TemplateBuilder 添加一个 Button 方法。

public Button(item: Item): TemplateBuilder {
  let guid = Guid.NewGuid();
  this.html += `<button type='button' style='width:100%' 
               storeIdx='{idx}' bindGuid='${guid.ToString()}>${item.text}</button>`;
  let el = new TemplateElement(item, guid);
  this.elements.push(el);

  return this;
}

我们得到这个

时髦。现在我们必须连接事件!嗯,好吧,这会如何工作?嗯,首先,我们需要连接点击事件。

switch (el.item.control) {
  case "button":
    jel.on('click', () => {
      let recIdx = Number(jel.attr("storeIdx"));
      console.log(`click for ${el.guid.ToString()} at index ${recIdx}`);
    });
    break;

  case "textbox":
  case "combobox":
    jel.on('change', () => {
      let recIdx = Number(jel.attr("storeIdx"));
      let field = el.item.field;
      let val = jel.val();

      console.log(`change for ${el.guid.ToString()} at index ${recIdx} 
                  with new value of ${jel.val()}`);
      storeManager.GetStore(el.item.associatedStoreName).SetProperty
                          (recIdx, field, val).UpdatePhysicalStorage(recIdx, field, val);
    });
    break;
}

我们可以通过查看控制台日志来验证它是否有效。

事件路由器

鉴于这一切都是由元数据构建的,我们需要一个事件路由器,它可以将事件路由到代码中任意但预定义好的函数。这应该是相当灵活的,但前提是代码支持我们需要的行为。

所以让我们向模板添加一个 route 属性。

{
  text: "Delete",
  line: 1,
  width: "20%",
  control: "button",
  route: "DeleteRecord",
}

请注意,我没有将路由称为“deleteTask”,因为删除记录应该以一种非常通用的方式处理。事件路由器的启动非常简单。

import { Store } from "../classes/Store"
import { RouteHandlerMap } from "../interfaces/RouteHandlerMap"

export class EventRouter {
  routes: RouteHandlerMap = {};

  public AddRoute(routeName: string, fnc: (store: Store, idx: number) => void) {
    this.routes[routeName] = fnc;
  }

  public Route(routeName: string, store: Store, idx: number): void {
    this.routes[routeName](store, idx);
  }
}

删除记录处理程序已初始化。

let eventRouter = new EventRouter();
eventRouter.AddRoute("DeleteRecord", (store, idx) => store.DeleteRecord(idx));

回调和 DeleteRecord 函数被添加到 store 中。

recordDeletedCallback: (idx: number, store: Store) => void = () => { }; 
...
public DeleteRecord(idx: number) : void {
  delete this.data[idx];
  this.recordDeletedCallback(idx, this);
}

删除记录回调已初始化。

taskStore.recordDeletedCallback = (idx, store) => {
  this.DeleteRecordView(builder, store, idx);
  store.Save();
}

单击按钮时调用路由器。

case "button":
  jel.on('click', () => {
    let recIdx = Number(jel.attr("storeIdx"));
    console.log(`click for ${el.guid.ToString()} at index ${recIdx}`);
    eventRouter.Route(el.item.route, storeManager.GetStore(el.item.associatedStoreName), recIdx);
  });
break;

并且包围记录的 div 被移除。

private DeleteRecordView(builder: TemplateBuilder, store: Store, idx: number): void {
  jQuery(`[templateIdx = '${idx}']`).remove();
}

忽略

  • 目前“templateIdx”属性名称,它显然必须以某种方式指定,以支持一个以上的模板实体类型。
  • 它移除整个 div 而不是,例如,清除字段或从 grid 中移除一行,这工作得很好。
  • Save 调用不知道如何向 REST 发送删除特定记录的调用。

我们可以继续,在点击第二个任务 T2 的删除按钮后,我们现在看到

我们的本地存储看起来像这样

现在让我们重构 load 过程,以便回调动态创建模板实例,这将是插入新任务的前奏。首先,recordCreatedCallback 被重命名为 recordCreatedCallback,这是一个更好的名字!然后,我们将删除这个原型代码

let task1 = this.SetStoreIndex(html, 0);
let task2 = this.SetStoreIndex(html, 1);
let task3 = this.SetStoreIndex(html, 2);
jQuery("#template").html(task1 + task2 + task3);

因为我们的模板“view”将作为记录加载而动态创建。所以现在 CreateRecordView 函数看起来像这样

private CreateRecordView(builder: TemplateBuilder, store: Store, 
                        idx: number, record: {}): void {
  let html = builder.html;
  let template = this.SetStoreIndex(html, idx);
  jQuery("#template").append(template);

  for (let j = 0; j < builder.elements.length; j++) {
    let tel = builder.elements[j];
    let guid = tel.guid.ToString();
    let jel = jQuery(`[bindGuid = '${guid}'][storeIdx = '${idx}']`);
    let val = record[tel.item.field];
    jel.val(val);
  }
}

插入任务

因为在测试中,我删除了我所有的任务,我现在必须实现一个创建任务按钮!模板中所有元素的事件也需要每次创建任务时都被连接起来!首先,HTML

<button type="button" id="createTask">Create Task</button>
<div id="template" style="width:40%"></div>

然后部分使用事件路由器连接事件。

jQuery("#createTask").on('click', () => {
  let idx = eventRouter.Route("CreateRecord", taskStore, 0); // insert at position 0
  taskStore.SetDefault(idx, "Status", taskStates[0].text);
  taskStore.Save();
});

以及路由定义

eventRouter.AddRoute("CreateRecord", (store, idx) => store.CreateRecord(true));

以及 Store 中的实现。

public CreateRecord(insert = false): number {
  let nextIdx = 0;

  if (this.Records() > 0) {
    nextIdx = Math.max.apply(Math, Object.keys(this.data)) + 1;
  }

  this.data[nextIdx] = {};
  this.recordCreatedCallback(nextIdx, {}, insert, this);

  return nextIdx;
}

注意我们如何获得一个“唯一的”记录“索引”,以及我们如何指定是插入到开头还是追加到末尾,不是数据记录(它们与顺序无关),而是标志被传递给处理视图的“视图”,所以我们再次重构 CreateRecordView

private CreateRecordView(builder: TemplateBuilder, store: Store, 
                        idx: number, record: {}, insert: boolean): void {
  let html = builder.html;
  let template = this.SetStoreIndex(html, idx);

  if (insert) {
    jQuery("#template").prepend(template);
  } else {
    jQuery("#template").append(template);
  }

  this.BindSpecificRecord(builder, idx);

  for (let j = 0; j < builder.elements.length; j++) {
    let tel = builder.elements[j];
    let guid = tel.guid.ToString();
    let jel = jQuery(`[bindGuid = '${guid}'][storeIdx = '${idx}']`);
    let val = record[tel.item.field];
    jel.val(val);
  }
}

我不会向你展示 BindSpecificRecord 函数,因为它几乎与文档就绪事件中的绑定相同,所以所有这些通用代码都需要在显示给你之前重构!一个奇怪的行为是我将它留到明天,那就是当以这种方式创建模板时,combobox 默认不是“TODO”——我必须弄清楚原因。无论如何,从空白开始

我创建了两个任务,请注意它们是反向顺序的,因为任务在 UI 中是*预置*的。

我们可以在本地存储中看到它们是*附加*的。

当然,这会导致页面刷新时出现问题。

顺序改变了!嗯……

现在,从我看到的 Vue 和其他框架的演示来看,完成这里需要 5 天的工作在 Vue 中可能只需要 30 分钟。然而,关键在于我实际上是在一起构建框架和应用程序,而且说实话,我玩得很开心!所以这才是最重要的!第 5 天结束,我终于可以创建、编辑和删除任务了!

第 6 天 - 基本关系

所以这是“实地考察”的时刻之一。我将添加几个关系。软件不是一夫一妻制的!我想添加联系人和注释,它们是任务的子实体。我的“任务”通常是集成级别的任务(它们可能应该被称为项目而不是任务!),例如“添加此信用卡处理器”,这意味着我与很多人交谈,我想将他们作为与任务相关联的人找到。注释也是如此,我想将对话、发现等的注释与任务相关联。为什么这会是“实地考察”时刻,是因为我现在没有任何机制来识别和关联两个实体,例如任务和注释。它还将处理一些硬编码的标签,就像这里。

if (insert) {
  jQuery("#template").prepend(template);
} else {
  jQuery("#template").append(template);
}

该函数需要通用,因此必须确定与实体关联的 div,而不是硬编码。所以这更有意义。

if (insert) {
  jQuery(builder.templateContainerID).prepend(template);
} else {
  jQuery(builder.templateContainerID).append(template);
}

此外,store 事件回调是通用的,所以我们可以这样做。

this.AssignStoreCallbacks(taskStore, taskBuilder);
this.AssignStoreCallbacks(noteStore, noteBuilder);
...
private AssignStoreCallbacks(store: Store, builder: TemplateBuilder): void {
  store.recordCreatedCallback = (idx, record, insert, store) => 
                this.CreateRecordView(builder, store, idx, record, insert);
  store.propertyChangedCallback = (idx, field, value, store) => 
                this.UpdatePropertyView(builder, store, idx, field, value);
  store.recordDeletedCallback = (idx, store) => {
    this.DeleteRecordView(builder, store, idx);
    store.Save();
  }
}

这还需要修复。

private DeleteRecordView(builder: TemplateBuilder, store: Store, idx: number): void {
  jQuery(`[templateIdx = '${idx}']`).remove();
}

因为索引号不足以确定关联实体,除非它也由容器名称限定。

private DeleteRecordView(builder: TemplateBuilder, store: Store, idx: number): void {
  let path = `${builder.templateContainerID} > [templateIdx='${idx}']`;
  jQuery(path).remove();
}

但这当然假设 UI 会有唯一的容器名称。这引出了定义布局的 HTML——模板必须在容器中。

<div class="entitySeparator">
  <button type="button" id="createTask" class="createButton">Create Task</button>
  <div id="taskTemplateContainer" class="templateContainer"></div>
</div>
<div class="entitySeparator">
  <button type="button" id="createNote" class="createButton">Create Note</button>
  <div id="noteTemplateContainer" class="templateContainer"></div>
</div>

此刻,我可以创建任务和注释。

它们在本地存储中也相当好地持久化。

接下来要弄清楚

  1. 记录中的一个唯一 ID 字段,它会被持久化。通常这会是主键,但我们没有将数据保存到数据库,而且我希望唯一 ID 与数据库的主键解耦,特别是如果用户在没有互联网的情况下工作,这应该很容易支持。
  2. 点击父级(在这种情况下是任务)应该会显示特定的子记录。
  3. 我们是否有单独的 Store(例如“Task-Note”和“Task-Contact”)用于每个父子关系,或者我们创建一个“metastore”,其中包含父子实体名称和此唯一 ID?或者我们创建一个分层结构,例如,任务有子元素,如注释?
  4. 我们如何向用户指示将被关联到子实体的选定父级?

关于第 4 点,我喜欢这种不显眼的方法,其中绿色的左边框表示已选定的记录。

这里的技巧是,我们只想移除与所选内容关联的实体记录的选择。

private RecordSelected(builder: TemplateBuilder, recIdx: number): void {
  jQuery(builder.templateContainerID).children().removeClass("recordSelected");
  let path = `${builder.templateContainerID} > [templateIdx='${recIdx}']`;
  jQuery(path).addClass("recordSelected");
}

这样,我们可以为每种实体类型选择一条记录。

关于第 3 点,分层结构是不可能的,因为它可能创建高度反范式的 dataset。考虑一个任务(或者如果我以后想添加项目,一个项目)可能具有相同的联系人信息。如果我更新联系人,是否要查找该联系人存在于任意层级的所有位置并更新每一个?如果我因为那个人不再在那家公司工作而删除一个联系人呢?绝对不行。并且单独的父子 Store 被拒绝,因为它需要的本地存储项(或数据库表)的数量。特别是对于数据库表,我最不想要的是动态创建父子表。所以一个单一的元 Store,它管理所有父子关系映射,似乎是最合理的,主要的考虑因素是当“table”包含可能数千(或更多)的关系时的性能。此刻,这种情况不需要考虑。

在这里,我们有了第一个具体模型。

export class ParentChildRelationshipModel {
  parent: string;
  child: string;
  parentId: number;
  childId: number;
}

请注意,父 ID 和子 ID 都是数字。最大数字是 21024,但问题是 Number 类型是 64 位浮点值,所以不是范围,而是精度有问题。我猜想通过数字 ID 而不是 GUID ID 来查找父子关系会更快,而且我此刻不必过多担心精度问题。

而且(天哪),与 ExtJS 类似,我们实际上有一个具体的 ParentChildStore,它有一个获取唯一数字 ID 的函数。

import { Store } from "../classes/Store"

export class ParentChildStore extends Store {
}

父子 Store 的创建方式略有不同。

let parentChildRelationshipStore = 
   new ParentChildStore(storeManager, StoreType.LocalStorage, "ParentChildRelationships");
storeManager.RegisterStore(parentChildRelationshipStore);

并且我们可以使用此函数访问具体 Store 类型,请注意注释。

public GetTypedStore<T>(storeName: string): T {
  // Compiler says: Conversion of type 'Store' to type 'T' may be a mistake because 
  // neither type sufficiently overlaps with the other. If this was intentional, 
  // convert the expression to 'unknown' first.
  // So how do I tell it that T must extended from Store?
  return (<unknown>this.stores[storeName]) as T;
}

在 C# 中,我会写类似 GetStore<T>(string storeName) where T : Store,向下转换到 T 会正常工作,但我在 TypeScript 中不知道如何做到这一点。

虽然我需要一个可持久化的计数器,比如序列,来获取下一个 ID,但让我们先看看 CreateRecord 函数。

public CreateRecord(insert = false): number {
  let nextIdx = 0;

  if (this.Records() > 0) {
    nextIdx = Math.max.apply(Math, Object.keys(this.data)) + 1;
  }

  this.data[nextIdx] = {};        <== THIS LINE IN PARTICULAR
  this.recordCreatedCallback(nextIdx, {}, insert, this);

  return nextIdx;
}

需要设置 ID 的就是空对象的赋值,但我不想在 Store 中编写代码——我更愿意将其解耦,所以我将把它实现为一个对 StoreManager 的调用,它将调用应用程序的回调,因此唯一记录标识符可以是应用程序管理的。

this.data[nextIdx] = this.storeManager.GetPrimaryKey();

回调的定义看起来很疯狂,因为它默认返回 {}

getPrimaryKeyCallback: () => any = () => {};

为了测试,让我们只实现一个简单的计数器。

storeManager = new StoreManager();

// For testing:
let n = 0;
storeManager.getPrimaryKeyCallback = () => {
  return { __ID: ++n };
}

当我创建一个任务时,我们可以看到这创建了主键键值对!

所以这是第 6 天的结束。我仍然需要持久化序列,可能是一个“Sequence” store,允许我定义不同的序列,当然,还需要创建父子记录和 UI 行为。快到了!

第 7 天 - 序列存储和父子关系存储

所以一个序列 Store 看起来是个好主意。同样,这可以是一个具体模型和 Store。模型

export class SequenceModel {
  key: string;
  n: number;

  constructor(key: string) {
    this.key = key;
    this.n = 0;
  }
}

Sequence Store

import { Store } from "../classes/Store"
import { SequenceModel } from "../models/SequenceModel"

export class SequenceStore extends Store {
  GetNext(skey: string): number {
    let n = 0;
    let recIdx = this.FindRecordOfType<SequenceModel>(r => r.key == skey);
    
    if (recIdx == -1) {
      recIdx = this.CreateRecord();
      this.SetProperty(recIdx, "key", skey);
      this.SetProperty(recIdx, "count", 0);
    }

    n = this.GetProperty(recIdx, "count") + 1;
    this.SetProperty(recIdx, "count", n);
    this.Save();

    return n;
  }
}

以及 FindRecordOfType 函数。

public FindRecordOfType<T>(where: (T) => boolean): number {
  let idx = -1;

  for (let k of Object.keys(this.data)) {
    if (where(<T>this.data[k])) {
      idx = parseInt(k);
      break;
    }
  }

  return idx;
}

我们可以写一个简单的测试。

let seqStore = new SequenceStore(storeManager, StoreType.LocalStorage, "Sequences");
storeManager.RegisterStore(seqStore);
seqStore.Load();
let n1 = seqStore.GetNext("c1");
let n2 = seqStore.GetNext("c2");
let n3 = seqStore.GetNext("c2");

在本地存储中,我们看到

所以我们现在可以为每个 Store 分配序列了。

storeManager.getPrimaryKeyCallback = (storeName: string) => {
  return { __ID: seqStore.GetNext(storeName) };

除了创建序列导致无限递归,因为序列记录试图获取自己的主键!!!

糟糕!

解决此问题的最简单方法是使其在基类中可重写,首先重构 CreateRecord 函数。

public CreateRecord(insert = false): number {
  let nextIdx = 0;

  if (this.Records() > 0) {
    nextIdx = Math.max.apply(Math, Object.keys(this.data)) + 1;
  }

  this.data[nextIdx] = this.GetPrimaryKey();
  this.recordCreatedCallback(nextIdx, {}, insert, this);

  return nextIdx;
}

定义默认行为。

protected GetPrimaryKey(): {} {
  return this.storeManager.GetPrimaryKey(this.storeName);
}

并在 SequenceStore 中重写它。

protected GetPrimaryKey(): {} {
  return {};
}

问题解决了!

建立关联

为了建立父子记录之间的关联,我们将添加一个字段来保存 Store 中选定记录的索引。

selectedRecordIndex: number = undefined; // multiple selection not allowed.

BindElementEvents 函数中,当我们调用 RecordSelected 时,我们将添加设置 Store 中此字段的代码。

jel.on('focus', () => {
  this.RecordSelected(builder, recIdx));
  store.selectedRecordIndex = recIdx;
}

在负责创建任务注释的按钮的事件处理程序中。

jQuery("#createTaskNote").on('click', () => {
  let idx = eventRouter.Route("CreateRecord", noteStore, 0); // insert at position 0
  noteStore.Save();
});

我们将添加一个调用来添加父子记录。

jQuery("#createTaskNote").on('click', () => {
  let idx = eventRouter.Route("CreateRecord", noteStore, 0); // insert at position 0
  parentChildRelationshipStore.AddRelationship(taskStore, noteStore, idx); // <=== Added this
  noteStore.Save();
});

实现如下。

AddRelationship(parentStore: Store, childStore: Store, childRecIdx: number): void {
  let parentRecIdx = parentStore.selectedRecordIndex;

  if (parentRecIdx !== undefined) {
    let recIdx = this.CreateRecord();
    let parentID = parentStore.GetProperty(parentRecIdx, "__ID");
    let childID = childStore.GetProperty(childRecIdx, "__ID");
    let rel = new ParentChildRelationshipModel
             (parentStore.storeName, childStore.storeName, parentID, childID);
    this.SetRecord(recIdx, rel);
    this.Save();
  } else {
    // callback that parent record needs to be selected?
    // or throw an exception?
  }
}

这样我们就有了。

现在我们只需要选择正确的子项以匹配选定的父项。已经定义了一个全局变量(ugh)用于声明关系。

var relationships : Relationship = [
  {
    parent: "Tasks",
    children: ["Notes"]
  }
];

其中 Relationship 定义如下。

export interface Relationship {
  parent: string;
  children: string[];
}

我们现在可以接入同一个“选定”事件处理程序来获取特定的子关系,移除任何以前的关系,并只显示选定记录的特定关系。我们也不想每次选择记录中的一个字段时都经历这个过程。

jel.on('focus', () => {
  if (store.selectedRecordIndex != recIdx) {
    this.RecordSelected(builder, recIdx);
    store.selectedRecordIndex = recIdx;
    this.ShowChildRecords(store, recIdx, relationships);
  }
});

ParentChildStore 中,我们可以定义

GetChildInfo(parent: string, parentId: number, child: string): ChildRecordInfo {
  let childRecs = this.FindRecordsOfType<ParentChildRelationshipModel>
   (rel => rel.parent == parent && rel.parentId == parentId && rel.child == child);
  let childRecIds = childRecs.map(r => r.childId);
  let childStore = this.storeManager.GetStore(child);

  // Annoying. VS2017 doesn't have an option for ECMAScript 7
  let recs = childStore.FindRecords(r => childRecIds.indexOf((<any>r).__ID) != -1);

  return { store: childStore, childrenIndices: recs };
}

Store 类中,我们实现。

public FindRecords(where: ({ }) => boolean): number[] {
  let recs = [];

  for (let k of Object.keys(this.data)) {
    if (where(this.data[k])) {
      recs.push(k);
    }
  }

  return recs;
}

这将返回记录索引,我们需要这些索引来填充模板 {idx} 值,这样我们就知道正在编辑哪个记录。

这个漂亮的函数负责查找子项并填充模板(这里发生了一些重构,例如,将 Store 映射到其构建器)。

private ShowChildRecords
 (parentStore: Store, parentRecIdx: number, relationships: Relationship[]): void {
  let parentStoreName = parentStore.storeName;
  let parentId = parentStore.GetProperty(parentRecIdx, "__ID");
  let relArray = relationships.filter(r => r.parent == parentStoreName);

  // Only one record for the parent type should exist.
  if (relArray.length == 1) {
    let rel = relArray[0];

    rel.children.forEach(child => {
      let builder = builders[child].builder;
      this.DeleteAllRecordsView(builder);
      let childRecs = 
         parentChildRelationshipStore.GetChildInfo(parentStoreName, parentId, child);
      let childStore = childRecs.store;

      childRecs.childrenIndices.map(idx => Number(idx)).forEach(recIdx => {
        let rec = childStore.GetRecord(recIdx);
        this.CreateRecordView(builder, childStore, recIdx, rec, false);
      });
    });
  }
}

它起作用了!点击任务 1,我创建了 2 个注释。

点击任务 2,我创建了 1 个注释。

联系人

现在让我们玩玩,再创建一个子项 Contacts

更新关系图。

var relationships : Relationship[] = [
  {
    parent: "Tasks",
    children: ["Contacts", "Notes"]
  }
];

更新 HTML。

<div class="entitySeparator">
  <button type="button" id="createTask" class="createButton">Create Task</button>
  <div id="taskTemplateContainer" class="templateContainer"></div>
</div>
<div class="entitySeparator">
  <button type="button" id="createTaskContact" class="createButton">Create Contact</button>
  <div id="contactTemplateContainer" class="templateContainer"></div>
</div>
<div class="entitySeparator">
  <button type="button" id="createTaskNote" class="createButton">Create Note</button>
  <div id="noteTemplateContainer" class="templateContainer"></div>
</div>

创建 contact template

let contactTemplate = [
  { field: "Name", line: 0, width: "50%", control: "textbox" },
  { field: "Email", line: 0, width: "50%", control: "textbox" },
  { field: "Comment", line: 1, width: "100%", control: "textbox" },
  { text: "Delete", line: 1, width: "20%", control: "button", route: "DeleteRecord" }
];

创建 store

let contactStore = storeManager.CreateStore("Contacts", StoreType.LocalStorage);

创建 builder

let contactBuilder = this.CreateHtmlTemplate
 ("#contactTemplateContainer", contactTemplate, storeManager, contactStore.storeName);

分配 callbacks

this.AssignStoreCallbacks(contactStore, contactBuilder);

添加 relationship

jQuery("#createTaskContact").on('click', () => {
  let idx = eventRouter.Route("CreateRecord", contactStore, 0); // insert at position 0
  parentChildRelationshipStore.AddRelationship(taskStore, contactStore, idx);
  contactStore.Save();
});

加载 contacts 但不渲染它们(换句话说,阻止 callback)。

taskStore.Load();
noteStore.Load(false);
contactStore.Load(false);

这样我们就完成了:我们刚刚向 Tasks 添加了一个新的子实体!

现在,在经历了那个练习之后,除了用于保存 contacts 的 HTML 和 contact template 本身之外,所有剩下的我们手动完成的工作都可以通过一个函数调用来完成,这将是第 8 天。我们还必须处理在删除 child 时删除 relationship 条目,以及在删除 parent 时删除所有 child 关系。晚安!

第 8 天 - 简化创建视图步骤

首先,让我们创建一个函数,它接受所有这些离散的设置步骤,并用大量参数将它们合并到一个调用中。

private CreateStoreViewFromTemplate(
  storeManager: StoreManager,
  storeName: string,
  storeType: StoreType,
  containerName: string,
  template: Items,
  createButtonId: string,
  updateView: boolean = true,
  parentStore: Store = undefined,
  createCallback: (idx: number, store: Store) => void = _ => { }
): Store {
  let store = storeManager.CreateStore(storeName, storeType);
  let builder = this.CreateHtmlTemplate(containerName, template, storeManager, storeName);
  this.AssignStoreCallbacks(store, builder);

  jQuery(document).ready(() => {
    if (updateView) {
      this.BindElementEvents(builder, _ => true);
    }

    jQuery(createButtonId).on('click', () => {
      let idx = eventRouter.Route("CreateRecord", store, 0); // insert at position 0
      createCallback(idx, store);

      if (parentStore) {
        parentChildRelationshipStore.AddRelationship(parentStore, store, idx);
      }

      store.Save();
    });
  });

  store.Load(updateView);

  return store;
}

这“简化”了创建过程为四个步骤。

  1. 定义模板。
  2. 定义容器。
  3. 更新关系图。
  4. 创建 Store 视图。

第 4 步现在这样写。

let taskStore = this.CreateStoreViewFromTemplate(
  storeManager, 
  "Tasks", 
  StoreType.LocalStorage, 
  "#taskTemplateContainer", 
  taskTemplate, 
  "#createTask", 
  true, 
  undefined, 
  (idx, store) => store.SetDefault(idx, "Status", taskStates[0].text));

this.CreateStoreViewFromTemplate(
  storeManager, 
  "Notes", 
  StoreType.LocalStorage, 
  "#noteTemplateContainer", 
  noteTemplate, 
  "#createTaskNote", 
  false, 
  taskStore);

this.CreateStoreViewFromTemplate(
  storeManager, 
  "Contacts", 
  StoreType.LocalStorage, 
  "#contactTemplateContainer", 
  contactTemplate, 
  "#createTaskContact", 
  false, 
  taskStore);

好的,很多参数,但这是一个高度可重复的模式。

接下来,我们要删除任何关系。关系必须在记录删除之前删除,因为我们需要访问 __ID 字段,所以我们必须反转 Store 中回调的处理方式,改为

public DeleteRecord(idx: number) : void {
  this.recordDeletedCallback(idx, this);
  delete this.data[idx];
}

这还将允许在元素被删除时递归删除整个层级。

然后在回调处理程序中。

store.recordDeletedCallback = (idx, store) => {
  parentChildRelationshipStore.DeleteRelationship(store, idx);
  this.DeleteRecordView(builder, idx);
}

但我们也必须在路由处理程序中保存 store,因为回调(它执行了保存)在记录被删除*之前*就被调用了。

eventRouter.AddRoute("DeleteRecord", (store, idx) => {
  store.DeleteRecord(idx);
  store.Save();
});

以及 ParentChildStore 中的实现。

public DeleteRelationship(store: Store, recIdx: number) {
  let storeName = store.storeName;
  let id = store.GetProperty(recIdx, "__ID");
  let touchedStores : string[] = []; // So we save the store only once after this process.

  // safety check.
  if (id) {
    let parents = this.FindRecordsOfType<ParentChildRelationshipModel>
                 (rel => rel.parent == storeName && rel.parentId == id);
    let children = this.FindRecordsOfType<ParentChildRelationshipModel>
                  (rel => rel.child == storeName && rel.childId == id);

    // All children of the parent are deleted.
    parents.forEach(p => {
      this.DeleteChildrenOfParent(p, touchedStores);
    });

    // All child relationships are deleted.
    children.forEach(c => {
      let relRecIdx = 
     this.FindRecordOfType<ParentChildRelationshipModel>((r: ParentChildRelationshipModel) =>
      r.parent == c.parent &&
      r.parentId == c.parentId &&
      r.child == c.child &&
      r.childId == c.childId);
    this.DeleteRecord(relRecIdx);
    });
  } else {
    console.log(`Expected to have an __ID value in store ${storeName} record index: ${recIdx}`);
  }

  // Save all touched stores.
  touchedStores.forEach(s => this.storeManager.GetStore(s).Save());

  this.Save();
}

带有一个辅助函数。

private DeleteChildrenOfParent
 (p: ParentChildRelationshipModel, touchedStores: string[]): void {
  let childStoreName = p.child;
  let childId = p.childId;
  let childStore = this.storeManager.GetStore(childStoreName);
  let recIdx = childStore.FindRecord(r => (<any>r).__ID == childId);

  // safety check.
  if (recIdx != -1) {
    // Recursive deletion of child's children will occur (I think - untested!)
    childStore.DeleteRecord(recIdx);

    if (touchedStores.indexOf(childStoreName) == -1) {
      touchedStores.push(childStoreName);
    }
  } else {
    console.log(`Expected to find record in store ${childStoreName} with __ID = ${childId}`);
  }

  // Delete the parent-child relationship.
  let relRecIdx = 
   this.FindRecordOfType<ParentChildRelationshipModel>((r: ParentChildRelationshipModel) =>
    r.parent == p.parent &&
    r.parentId == p.parentId &&
    r.child == p.child &&
    r.childId == childId);

  this.DeleteRecord(relRecIdx);
}

第 9 天:Bug

因此,通过创建一个更丰富的 relationship 模型。

var relationships : Relationship[] = [
{
  parent: "Projects",
  children: ["Tasks", "Contacts", "Notes"]
},
{
  parent: "Tasks",
  children: ["Notes"]
}
];

其中 NotesProjectsTaskschildren,出现了几个 Bug。

Bug:只创建一个 Store

第一个问题是我创建了两个 Notes Store,通过检查 store 是否存在来修复。

private CreateStoreViewFromTemplate(
...
): Store {

// ?. operator. 
// Supposedly TypeScript 3.7 has it, but I can't select that version in VS2017. VS2019?
let parentStoreName = parentStore && parentStore.storeName || undefined;
let builder = this.CreateHtmlTemplate
             (containerName, template, storeManager, storeName, parentStoreName);
let store = undefined;

if (storeManager.HasStore(storeName)) {
  store = storeManager.GetStore(storeName);
} else {
  store = storeManager.CreateStore(storeName, storeType);
  this.AssignStoreCallbacks(store, builder);
}

Bug:将 Builder 与正确的父子上下文关联。

其次,构建器必须能够感知父子关系,以便“创建任务注释”使用 Task-Note 构建器,而不是 Project-Note 构建器。这很容易(虽然有点笨拙)修复。

private GetBuilderName(parentStoreName: string, childStoreName: string): string {
  return (parentStoreName || "") + "-" + childStoreName;
}

还有……

private CreateHtmlTemplate(templateContainerID: string, template: Items, 
 storeManager: StoreManager, storeName: string, parentStoreName: string): TemplateBuilder {
  let builder = new TemplateBuilder(templateContainerID);
  let builderName = this.GetBuilderName(parentStoreName, storeName);
  builders[builderName] = { builder, template: templateContainerID };
  ...

Bug:将 CRUD 操作与正确的 Builder 上下文关联

第三个问题更加隐蔽,在调用 AssignStoreCallbacks 时。

private AssignStoreCallbacks(store: Store, builder: TemplateBuilder): void {
  store.recordCreatedCallback = 
   (idx, record, insert, store) => this.CreateRecordView(builder, store, idx, insert);
  store.propertyChangedCallback = 
   (idx, field, value, store) => this.UpdatePropertyView(builder, store, idx, field, value);
  store.recordDeletedCallback = (idx, store) => {
    parentChildRelationshipStore.DeleteRelationship(store, idx);
    this.DeleteRecordView(builder, idx);
  }
}

问题在于,在 Store 创建时,构建器与 Store 相关联。Bug 是,因为这是 Project-Notes Builder 的 Notes Store,所以添加 Task-Note 会将注释添加到 Project-Notes 中!需要做两件事。

  1. Store 应该只有一个回调。
  2. 但是构建器必须特定于 CRUD 操作的“上下文”。

修复此问题的方法是将“上下文”传递给 Store,用于 CRUD 操作。目前,我只是传递 TemplateBuilder 实例,因为我太懒了,不想创建一个 Context 类,而且我不确定它是否需要。

结果是,CRUD 回调现在获得构建器上下文,它们将其传递给处理程序。

private AssignStoreCallbacks(store: Store): void {
  store.recordCreatedCallback = 
 (idx, record, insert, store, builder) => this.CreateRecordView(builder, store, idx, insert);
  store.propertyChangedCallback = (idx, field, value, store, builder) => 
              this.UpdatePropertyView(builder, store, idx, field, value);
  store.recordDeletedCallback = (idx, store, builder) => {
    parentChildRelationshipStore.DeleteRelationship(store, idx);
    this.DeleteRecordView(builder, idx);
  }
}

两个 Bug,同一个解决方案

  • 在子列表更改时需要删除孙级视图。
  • 删除父级时应删除子模板视图。

如果我创建两个具有不同任务和任务注释的项目,其中任务注释是孙级,当我选择一个不同的项目时,项目子项会更新(项目任务),但任务注释仍然显示在屏幕上,这会导致很多混淆。ShowChildRecords 函数很好,但我们需要在子上下文改变时删除 grandchild 记录。所以这段代码。

jel.on('focus', () => {
  if (store.selectedRecordIndex != recIdx) {
    this.RecordSelected(builder, recIdx);
    store.selectedRecordIndex = recIdx;
    this.ShowChildRecords(store, recIdx, relationships);
  }
});

得到一个额外的函数调用。

jel.on('focus', () => {
  if (store.selectedRecordIndex != recIdx) {
    this.RemoveChildRecordsView(store, store.selectedRecordIndex);
    this.RecordSelected(builder, recIdx);
    store.selectedRecordIndex = recIdx;
    this.ShowChildRecords(store, recIdx, relationships);
  }
});

实现如下

// Recursively remove all child view records.
private RemoveChildRecordsView(store: Store, recIdx: number): void {
  let storeName = store.storeName;
  let id = store.GetProperty(recIdx, "__ID");
  let rels = relationships.filter(r => r.parent == storeName);

  if (rels.length == 1) {
    let childEntities = rels[0].children;

    childEntities.forEach(childEntity => {
      if (storeManager.HasStore(childEntity)) {
        var info = parentChildRelationshipStore.GetChildInfo(storeName, id, childEntity);
        info.childrenIndices.forEach(childRecIdx => {
          let builderName = this.GetBuilderName(storeName, childEntity);
          let builder = builders[builderName].builder;
          this.DeleteRecordView(builder, childRecIdx);
          this.RemoveChildRecordsView(storeManager.GetStore(childEntity), childRecIdx);
        });
      }
    });
  }
}

Bug:选定的记录依赖于父子关系

注意:以下思路是错误的! 我将其保留在此处,因为它曾是我认为错误的东西,直到进一步反思我才意识到它是正确的。单元测试将验证我在此处写作是错误的信念!

所以这里是错误的思考过程。

当 Store 由两个不同的父级共享时,选定的记录特定于父子关系,而不是 Store!

问题:父子关系是否足以描述唯一性和实体?

不。例如,如果我有一个父子关系 B-C,以及一个 A-B-C 和 D-B-C 的层级结构,C 中的记录的特定上下文与其与 B 记录的关系相关联。虽然 B 的上下文与其与 A 记录的关系相关联,但 Store 的选定记录取决于实体路径是 A-B-C 还是 D-B-C。请注意,“A”和“D”是不同的*实体类型*,而不是同一实体的不同记录。

甚至模板构建器名称也不是两级父子关系。这之所以奏效,是因为关系都用两个层级的层级结构唯一定义。但如果在此层级结构中插入另一个顶层,则模板构建器名称与构建器(以及关联的特定 templateContainerID)的关系就会失效。

解决方案

这意味着,如果我们不想不断修复代码,我们就必须有一个通用的解决方案来识别

  1. 正确的构建器。
  2. 选定的记录。

正如它们与*实体类型*层级结构相关联,无论有多深。请记住,父子关系模型仍然有效,因为它关联的是父子实体*实例*之间的关系,而构建器和 UI 管理通常处理*实体类型*层级结构。

为什么这不是一个 Bug

首先,当我们加载父子关系的记录时,它由唯一的父 ID 限定。

let childRecs = parentChildRelationshipStore.GetChildInfo(parentStoreName, parentId, child);

GetChildInfo 函数中。

let childRecs = this.FindRecordsOfType<ParentChildRelationshipModel>
 (rel => rel.parent == parent && rel.parentId == parentId && rel.child == child);

但这确实是一个 Bug

在上面两项中,“正确的构建器”和“选定的记录”,正确的构建器必须由*实体类型*层级结构决定,它需要完整的路径来确定模板容器,但选定的记录与*实例*相关联,所以它实际上不是问题。

代码使用以下方式标识合适的构建器,包括 HTML 容器模板名称。

let builderName = this.GetBuilderName(parentStoreName, child);

它由以下方式确定。

private GetBuilderName(parentStoreName: string, childStoreName: string): string {
  return (parentStoreName || "") + "-" + childStoreName;
}

所以在这里,我们看到 B-C 的构建器没有足够的信息来确定 A-B-C 与 D-B-C 的模板容器。而这才是真正的 Bug。其结果是,区分*类型*和*实例*非常重要。

这将在第 12 天“父子模板问题”中解决。

细节:添加记录时聚焦第一个字段

为了避免不必要的点击,这个

private FocusOnFirstField(builder: TemplateBuilder, idx: number) {
  let tel = builder.elements[0];
  let guid = tel.guid.ToString();
  jQuery(`[bindGuid = '${guid}'][storeIdx = '${idx}']`).focus();
}

在此处调用时。

store.recordCreatedCallback = (idx, record, insert, store, builder) => {
  this.CreateRecordView(builder, store, idx, insert);
  this.FocusOnFirstField(builder, idx);
};

生活变得更美好了。

第 10 天:一些额外的细节

所以我也添加了项目和任务级别的链接,以便我可以引用与项目相关的内部和在线链接。

var relationships : Relationship[] = [
  {
    parent: "Projects",
    children: ["Tasks", "Contacts", "Links", "Notes"]
  },
  {
    parent: "Tasks",
    children: ["Links", "Notes"]
  }
];

相关的 HTML 和模板也已创建。

生活就该是这样

就在刚才,我突然想给联系人添加“Title”。所以我所做的就是将这行添加到 contactTemplate

{ field: "Title", line: 0, width: "30%", control: "textbox" },

完成。我无需更改的是,我无需更改客户端的某些模型定义。当然,我也不需要实现 DB 模式迁移,也不需要更改 C# 中的 EntityFramework 或 Linq2SQL 实体模型。坦白说,当我添加服务器端数据库支持时,我仍然不想做任何这些事情!我应该只触碰一个地方,也只有一个地方:描述我想看到的字段以及它们在哪里。其他一切都应该自行调整。

第 11 天:给状态上色

这是一个有点hackish 的方法,但我想通过给下拉列表着色来直观地指示项目和任务的状态。

这并没有花一整天,这只是我可用的时间。

通过处理 changefocusblur 事件来实现——当下拉列表获得焦点时,它会变回白色,这样整个选择列表就不会有当前状态的背景颜色。

case "combobox":
  jel.on('change', () => {
    // TODO: Move this very custom behavior out into a view handler
    let val = this.SetPropertyValue(builder, jel, el, recIdx);
    this.SetComboboxColor(jel, val);
  });

  // I can't find an event for when the option list is actually shown, so for now 
  // we reset the background color on focus and restore it on lose focus.
  jel.on('focus', () => {
    jel.css("background-color", "white");
  });

  jel.on('blur', () => {
    let val = jel.val();
    this.SetComboboxColor(jel, val);
  });
  break;

并且在创建记录视图时。

private CreateRecordView(builder: TemplateBuilder, store: Store, 
                        idx: number, insert: boolean): void {
  ...
 // Hack!
  if (tel.item.control == "combobox") {
    this.SetComboboxColor(jel, val);
  }
}

第 12 天 - 父子模板问题

所以这个

private GetBuilderName(parentStoreName: string, childStoreName: string): string {
  return (parentStoreName || "") + "-" + childStoreName;
}

是一个hack。全局变量也是一个hack,存储 Store 中的选定记录索引也是如此——它应该与*该 Store*的视图控制器相关联,而不是 Store!Hack 应该被重新审视,甚至根本不实现!整个问题在于元素事件没有与保留有关“事件触发器”信息的对象耦合,因此确定与事件关联的构建器成了一个hack。这里需要的是一个 Binder、模板 ID 等的容器,它绑定到该构建器的特定 UI 事件——换句话说,一个视图控制器。

export class ViewController {
  storeManager: StoreManager;
  parentChildRelationshipStore: ParentChildStore;
  builder: TemplateBuilder;
  eventRouter: EventRouter;
  store: Store;
  childControllers: ViewController[] = [];
  selectedRecordIndex: number = -1; // multiple selection not allowed at the moment.

  constructor(storeManager: StoreManager, 
             parentChildRelationshipStore: ParentChildStore, eventRouter: EventRouter) {
    this.storeManager = storeManager;
    this.parentChildRelationshipStore = parentChildRelationshipStore;
    this.eventRouter = eventRouter;
}

注意这里有几点。

  1. 选定的记录索引与视图控制器相关联。
  2. 视图控制器管理其子控制器的列表。这确保了在 A-B-C 和 D-B-C 等场景中,B 和 C 的控制器相对于根 A 和 D 是不同的。

现在,当点击“创建...”按钮时,视图控制器会将视图控制器实例传递给 Store。

jQuery(createButtonId).on('click', () => {
  let idx = this.eventRouter.Route("CreateRecord", this.store, 0, this); // insert at position 0

它具有正确的构建器,因此是正在创建的实体的模板容器,并且虽然回调是每个 Store 只创建一次。

if (this.storeManager.HasStore(storeName)) {
  this.store = this.storeManager.GetStore(storeName);
} else {
  this.store = this.storeManager.CreateStore(storeName, storeType);
  this.AssignStoreCallbacks();
}

通过“through”视图控制器可确保使用正确的模板容器。

private AssignStoreCallbacks(): void {
  this.store.recordCreatedCallback = (idx, record, insert, store, onLoad, viewController) => {

    viewController.CreateRecordView(this.store, idx, insert, onLoad);

    // Don't select the first field when called from Store.Load, as this will select the 
    // first field for every record, leaving the last record selected. Plus we're not
    // necessarily ready to load up child records yet since the necessary view controllers
    // haven't been created.
    if (!onLoad) {
      viewController.FocusOnFirstField(idx);
    }
  };

  this.store.propertyChangedCallback = 
    (idx, field, value) => this.UpdatePropertyView(idx, field, value);
  this.store.recordDeletedCallback = (idx, store, viewController) => {
    // A store can be associated with multiple builders: A-B-C and A-D-C, where the store is C
    viewController.RemoveChildRecordsView(store, idx);
    viewController.parentChildRelationshipStore.DeleteRelationship(store, idx);
    viewController.DeleteRecordView(idx);
  }
}

现在要创建页面,我们这样做。

let vcProjects = new ViewController(storeManager, parentChildRelationshipStore, eventRouter);
vcProjects.CreateStoreViewFromTemplate(
  "Projects", 
  StoreType.LocalStorage, 
  "#projectTemplateContainer", 
  projectTemplate, "#createProject", 
  true, 
  undefined, 
  (idx, store) => store.SetDefault(idx, "Status", projectStates[0].text));

new ViewController(storeManager, parentChildRelationshipStore, eventRouter).
  CreateStoreViewFromTemplate(
    "Contacts", 
    StoreType.LocalStorage, 
    "#projectContactTemplateContainer", 
    contactTemplate, 
    "#createProjectContact", 
    false, 
    vcProjects);

等等。注意当我们创建 Contacts 视图控制器时,它是 Projects 的子项,我们将父控制器传递进去,它将子项注册到其父项。

if (parentViewController) {
  parentViewController.RegisterChildController(this);
}

子集合用于使用正确的视图控制器创建和删除视图。

childRecs.childrenIndices.map(idx => Number(idx)).forEach(recIdx => {
  let vc = this.childControllers.find(c => c.store.storeName == child);
  vc.CreateRecordView(childStore, recIdx, false);
});

全局变量被消除,因为它们现在包含在视图控制器中。如果在运行时需要实例化新的视图控制器,则由父视图控制器完成,它可以传递单例,如 Store 管理器和事件路由器,以及父子关系 Store。

第 13 天 - 审计日志

持久化到本地存储并不是一个真正可行的长期解决方案。虽然它可能对离线工作有用,但我们需要一个集中的服务器,以显而易见的原因——以便多个人可以访问数据,并且我可以从不同的机器访问相同的数据。这涉及大量工作。

(哦,看,子任务!!!)

存储持久化控制反转

到目前为止,我们只有本地存储持久化,所以我们将函数包装在这个类中。

export class LocalStoragePersistence implements IStorePersistence {
  public Load(storeName: string): RowRecordMap {
    let json = window.localStorage.getItem(storeName);
    let data = {};

    if (json) {
      try {
        // Create indices that map records to a "key", 
       // in this case simply the initial row number.
        let records: {}[] = JSON.parse(json);
        records.forEach((record, idx) => data[idx] = record);
      } catch (ex) {
        console.log(ex);
        // Storage is corrupt, eek, we're going to remove it!
        window.localStorage.removeItem(storeName);
      }
    }

    return data;
  }

  public Save(storeName: string, data: RowRecordMap): void {
    let rawData = jQuery.map(data, value => value);
    let json = JSON.stringify(rawData);
    window.localStorage.setItem(storeName, json);
  }

  public Update(storeName: string, data:RowRecordMap, record: {}, 
               idx: number, property: string, value: string) : void {
    this.Save(storeName, data);
  }
}

Loadsaveupdate 只是对抽象持久化实现的调用。

public Load(createRecordView: boolean = true, 
           viewController: ViewController = undefined): Store {
  this.data = this.persistence.Load(this.storeName);

  if (createRecordView) {
    jQuery.each(this.data, (k, v) => this.recordCreatedCallback
                                    (k, v, false, this, true, viewController));
  }

  return this;
}

public Save(): Store {
  this.persistence.Save(this.storeName, this.data);

  return this;
}

public UpdatePhysicalStorage(idx: number, property: string, value: string): Store {
  let record = this.data[idx];
  this.persistence.Update(this.storeName, this.data, record, idx, property, value);

  return this;
}

万岁!

审计日志

记录 CRUD 操作实际上是审计日志,所以我们不妨称之为审计日志。这是一个由具体模型支持的具体 Store。

export class AuditLogModel {
  storeName: string;
  action: AuditLogAction;
  recordIndex: number;
  property: string;
  value: string;

  constructor(storeName: string, action: AuditLogAction, recordIndex: number, 
             property: string, value: string) {
    this.storeName = storeName;
    this.action = action;
    this.recordIndex = recordIndex;
    this.property = property;
    this.value = value;
  }

  // Here we override the function because we don't want to log the audit log 
 // that calls SetRecord above.
    public SetRecord(idx: number, record: {}): Store {
    this.CreateRecordIfMissing(idx);
    this.data[idx] = record;

    return this;
  }

  // If we don't override this, calling CreateRecord here causes 
 // an infinite loop if the AuditLogStore doesn't exist yet,
  // because when the audit log store asks for its next sequence number, 
 // and the store doesn't exist,
  // SequenceStore.GetNext is called which calls CreateRecord, 
 // recursing into the Log function again.
  protected GetPrimaryKey(): {} {
    return {};
  }
}

其中的操作是。

export enum AuditLogAction {
  Create,
  Update,
  Delete
}

这是我修改项目名称、创建联系人、然后删除联系人的日志。

这是为尚不存在的实体(在此例中为“Links”)创建序列的示例。

这是 Store 中关于 SetRecord 函数的代码更改的结果,这就是为什么它在 AuditLogStore 中被重写。

public SetRecord(idx: number, record: {}): Store {
  this.CreateRecordIfMissing(idx);
  this.data[idx] = record;

  jQuery.each(record, (k, v) => this.auditLogStore.Log
                     (this.storeName, AuditLogAction.Update, idx, k, v)); 

  return this;
}

所以这是我们现在所处的阶段。

第 14 天 - 服务器端持久化

我正在使用 .NET Core 实现服务器,以便可以在非 Windows 设备上运行它,因为它只是数据库操作的代理。另外,我不会使用 EntityFramework 或 Linq2Sql。虽然我考虑过使用 NoSQL 数据库,但我希望能够创建包含表 join 的数据库查询的灵活性,这有点麻烦——并非所有 NoSQL 数据库引擎都实现了该能力,而且我真的不想处理我在这里中写过的 MongoDB 的 $lookup 聚合器语法。

异步客户端调用

但是我们有一个更大的问题——AJAX 调用本质上是异步的,我还没有考虑到 TypeScript 应用程序中的任何异步行为。如果你在阅读这篇文章时考虑到了这一点,你可能在窃笑。所以目前(我还没有决定是否也要让 Load 异步),我已经像这样修改了 Store 的 Load 函数。

public Load(createRecordView: boolean = true, 
 viewController: ViewController = undefined): Store {
  this.persistence.Load(this.storeName).then(data => {
    this.data = data;

    if (createRecordView) {
      jQuery.each(this.data, (k, v) => this.recordCreatedCallback
                                      (k, v, false, this, true, viewController));
    }
  });

  return this;
}

IStorePersistence 接口中函数的签名必须修改为。

Load(storeName: string): Promise<RowRecordMap>;

LocalStoragePersistence 类的 Load 函数现在看起来像这样。

public Load(storeName: string): Promise<RowRecordMap> {
  let json = window.localStorage.getItem(storeName);
  let data = {};

  if (json) {
    try {
      // Create indices that map records to a "key", in this case simply the initial row number.
      let records: {}[] = JSON.parse(json);
      records.forEach((record, idx) => data[idx] = record);
    } catch (ex) {
      console.log(ex);
      // Storage is corrupt, eek, we're going to remove it!
      window.localStorage.removeItem(storeName);
    }
  }

  return new Promise((resolve, reject) => resolve(data));
}

一切安好。

CloudPersistence 类然后看起来像这样。

export class CloudPersistence implements IStorePersistence {
  baseUrl: string;

  constructor(url: string) {
    this.baseUrl = url;
  }

  public async Load(storeName: string): Promise<RowRecordMap> {
    let records = await jQuery.ajax({ url: this.Url("Load") + `?StoreName=${storeName}` });
    let data = {};

    // Create indices that map records to a "key", in this case simply the initial row number.
    records.forEach((record, idx) => data[idx] = record);

    return data;
  }

  public Save(storeName: string, data: RowRecordMap): void {
    let rawData = jQuery.map(data, value => value);
    let json = JSON.stringify(rawData);
    jQuery.ajax
     ({ url: this.Url("Save") + `?StoreName=${storeName}`, type: "POST", data: json });
  }

  private Url(path: string): string {
    return this.baseUrl + path;
  }
}

这里的问题是,SaveUpdate 函数及其异步 AJAX 调用可能不会以它们发送的相同顺序接收。这段代码需要重构,以确保**异步** JavaScript 和 XML (AJAX!) 实际上按正确的顺序执行,方法是对请求进行排队并串行处理它们,在发送下一个请求之前等待服务器的响应。这是另一天的事!

服务器端处理程序

在服务器端(我目前不打算深入我的服务器实现),我注册了这个路由。

router.AddRoute<LoadStore>("GET", "/load", Load, false);

并实现了一个返回虚拟空数组的路由处理程序。

private static IRouteResponse Load(LoadStore store)
{
  Console.WriteLine($"Load store {store.StoreName}");

  return RouteResponse.OK(new string[] {});
}

有些讽刺的是,我还不得不添加。

context.Response.AppendHeader("Access-Control-Allow-Origin", "*");

因为 TypeScript 页面由一个地址(Visual Studio 分配的端口的 localhost)提供服务,而我的服务器位于 localhost:80。观察没有此标头会发生什么很有趣——服务器收到请求,但浏览器阻止(抛出异常)处理响应。唉。

无模型 SQL

现在我们来做一个决定。通常数据库模式被创建为“已知模式”,使用某种模型/模式同步,或像FluentMigrator这样的迁移工具,或手工编码。我个人已经开始厌恶整个方法,因为它通常意味着。

  1. 数据库具有需要管理的模式。
  2. 服务器端有一个需要管理的模型。
  3. 客户端也有一个需要管理的模型。

天哪!在模式和模型方面,DRY(不要重复自己)原则去哪儿了?所以我要做一个实验。正如你所注意到的,客户端上没有真正的模型,除了审计和序列“表”的几个具体类型。我所谓的模型实际上隐藏在视图模板中,例如。

let contactTemplate = [
  { field: "Name", line: 0, width: "30%", control: "textbox" },
  { field: "Email", line: 0, width: "30%", control: "textbox" },
  { field: "Title", line: 0, width: "30%", control: "textbox" },
  { field: "Comment", line: 1, width: "80%", control: "textbox" },
  { text: "Delete", line: 1, width: "80px", control: "button", route: "DeleteRecord" }
];

哦,看,视图的模板指定了视图感兴趣的字段。在本地存储实现中,这已经足够了。在 SQL 数据库中,这很好,如果我基本上有一个像这样的表。

ID
StoreName
PropertyName
Value

长篇大论。但我不想要那个——我*想要*具体表和具体列!所以我要做一些你们会踢和尖叫的事情——按需动态创建表和必要的列,以便视图模板是定义模式的“主导”。是的,你没看错。仅仅因为全世界都以一种复制模式、代码隐藏模型和客户端模型的方式编程,并不意味着我必须这样做。当然,这有一个性能损失,但我们处理的不是批量更新,而是异步用户驱动的更新。用户永远不会注意到,对我来说更重要的是,我再也不用编写迁移或创建表和模式,或者创建镜像数据库模式的 C# 类。除非我在服务器端做一些特定的业务逻辑,在这种情况下,C# 类可以从数据库模式生成。很久以前,我在 F# 中遇到过一些东西,其中数据库模式可以用于将 Intellisense 绑定到 F# 对象,但这在 C# 中从未发生过,并且使用动态对象性能极差且没有 Intellisense。所以,在“知道”数据库模式的编程语言支持方面,仍然存在一个重大的脱节。长篇大论结束。

明天。

第 15 天 - 动态创建模式(Schema)

在开始之前,需要一个小的细节——一个与 AJAX 调用关联的用户 ID,以便数据可以按用户分开。为了测试,我们将使用。

let userID = new Guid("00000000-0000-0000-0000-000000000000");
let persistence = new CloudPersistence("http://127.0.0.1/", userId);

现在还没有登录或身份验证,但最好现在就把它放进代码里,而不是以后。

所以,现在我们的云持久化 Load 函数看起来像这样。

public async Load(storeName: string): Promise<RowRecordMap> {
  let records = await jQuery.ajax({
    url: this.Url("Load") + 
        this.AddParams({ StoreName: storeName, UserId: this.userId.ToString() }) });
  let data = {};

  // Create indices that map records to a "key", in this case simply the initial row number.
  // Note how we get the record index from record.__ID!!!
  records.forEach((record, _) => data[record.__ID] = record);

  return data;
}

发送审计日志

Save 函数发送审计日志的当前状态。

public Save(storeName: string, data: RowRecordMap): void {
  // For cloud persistence, what we actually want to do here is 
 // send over the audit log, not the entire store contents.
  let rawData = this.auditLogStore.GetRawData();
  let json = JSON.stringify(rawData);
  jQuery.post(this.Url("Save") + 
   this.AddParams({ UserId: this.userId.ToString() }), JSON.stringify({ auditLog: json }));
  this.auditLogStore.Clear();
}

注意日志在发送后是如何被清除的!

保存审计日志

需要一个特殊的函数来实际发送审计日志,因为它不是“action-property-value”的形式,它是一个具体实体。

public SaveAuditLog(logEntry: AuditLogModel): void {
  let json = JSON.stringify(logEntry);
  jQuery.post(this.Url("SaveLogEntry") + 
             this.AddParams({ UserId: this.userId.ToString() }), json);
}

加载当前模式

在服务器端,我们加载我们所知道的模式。

private static void LoadSchema()
{
  const string sqlGetTables = 
   "SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE='BASE TABLE'";
  using (var conn = OpenConnection())
  {
    var dt = Query(conn, sqlGetTables);

    foreach (DataRow row in dt.Rows)
    {
      var tableName = row["TABLE_NAME"].ToString();
      schema[tableName] = new List<string>();
      var fields = LoadTableSchema(conn, tableName);
      schema[tableName].AddRange(fields);
    }
  }
}

private static IEnumerable<string> LoadTableSchema(SqlConnection conn, string tableName)
{
  string sqlGetTableFields = 
   $"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = @tableName";
  var dt = Query(conn, sqlGetTableFields, 
          new SqlParameter[] { new SqlParameter("@tableName", tableName) });
  var fields = (dt.AsEnumerable().Select(r => r[0].ToString()));

  return fields;
}

动态创建存储(表)和列

然后我们必须按需创建 Store。

private static void CheckForTable(SqlConnection conn, string storeName)
{
  if (!schema.ContainsKey(storeName))
  {
    CreateTable(conn, storeName);
    schema[storeName] = new List<string>();
  }
}

private static void CheckForField(SqlConnection conn, string storeName, string fieldName)
{
  if (!schema[storeName].Contains(fieldName))
  {
    CreateField(conn, storeName, fieldName);
    schema[storeName].Add(fieldName);
  }
}

private static void CreateTable(SqlConnection conn, string storeName)
{
  // __ID must be a string because in ParentChildStore.GetChildInfo, 
  // this Javascript: childRecIds.indexOf((<any>r).__ID)
  // Does not match on "1" == 1
  string sql = $"CREATE TABLE [{storeName}] (ID int NOT NULL PRIMARY KEY IDENTITY(1,1), 
              UserId UNIQUEIDENTIFIER NOT NULL, __ID nvarchar(16) NOT NULL)";
  Execute(conn, sql);
}

private static void CreateField(SqlConnection conn, string storeName, string fieldName)
{
  // Here we suffer from a loss of fidelity 
 // as we don't know the field type nor length/precision.
  string sql = $"ALTER TABLE [{storeName}] ADD [{fieldName}] NVARCHAR(255) NULL";
  Execute(conn, sql);
}

保存审计日志

最后,我们在保存时处理审计日志。

private static IRouteResponse Save(SaveStore store)
{
  var logs = JsonConvert.DeserializeObject<List<AuditLog>>(store.AuditLog);

  using (var conn = OpenConnection())
  {
    // Evil!
    lock (schemaLocker)
    {
      UpdateSchema(conn, logs);

      // The CRUD operations have to be in the lock operation 
     // so that another request doesn't update the schema while we're updating the record.
      logs.ForEach(l => PersistTransaction(conn, l, store.UserId));
    }
  }

  return RouteResponse.OK();
}

private static void PersistTransaction(SqlConnection conn, AuditLog log, Guid userId)
{
  switch (log.Action)
  {
    case AuditLog.AuditLogAction.Create:
      CreateRecord(conn, userId, log.StoreName, log.RecordIndex);
      break;

    case AuditLog.AuditLogAction.Delete:
      DeleteRecord(conn, userId, log.StoreName, log.RecordIndex);
      break;

    case AuditLog.AuditLogAction.Update:
      UpdateRecord(conn, userId, log.StoreName, log.RecordIndex, log.Property, log.Value);
      break;
  }
}

动态更新模式

注意对 UpdateSchema 的调用!这就是神奇发生的地方,如果表中的字段是以前没有遇到过的,我们就会动态创建它!

private static void UpdateSchema(SqlConnection conn, List<AuditLog> logs)
{
  // Create any missing tables.
  logs.Select(l => l.StoreName).Distinct().ForEach(sn => CheckForTable(conn, sn));

  // Create any missing fields.
  foreach (var log in logs.Where
         (l => !String.IsNullOrEmpty(l.Property)).DistinctBy(l => l, tableFieldComparer))
  {
    CheckForField(conn, log.StoreName, log.Property);
  }
}

于是乎!

此刻,我还没有输入 **TODO** 和 **Description** 字段的内容,所以模式还不知道它们的存在。

填入数据后。

模式已被修改,因为这些额外的列是审计日志的一部分!

我们还可以看到我刚刚所做的更改所记录的审计日志条目。

以及所有动态创建的表(除了 AuditLogStore 表)。

第 16 天 - 更多 Bug

Entity __ID 工作方式中的 Bug

页面刷新后,我发现序列创建的下一个数字(假设我们计数为 2)变成了“21”,然后是“211”,然后是“2111”。这是因为没有类型信息,所以在页面刷新时,“数字”是以 string 类型传入的,而这行代码。

n = this.GetProperty(recIdx, "count") + 1;

最终附加的是字符 1,而不是递增计数。只要我在测试中没有刷新页面,一切都正常工作。刷新页面后,新的父子关系停止工作了!变通方法是,由于 JSON 中没有类型信息可以将计数序列化为数字而不是 string,那就是。

// Number because this field is being created in the DB 
// as an nvarchar since we don't have field types yet!
n = Number(this.GetProperty(recIdx, "count")) + 1;

下一个问题是审计日志没有传递正确的客户端“主键”(__ID 字段),这发生在删除记录之后。这段代码。

public Log(storeName: string, action: AuditLogAction, 
          recordIndex: number, property?: string, value?: any): void {
  let recIdx = this.InternalCreateRecord(); // no audit log for the audit log!
  let log = new AuditLogModel(storeName, action, recIdx, property, value);

只要记录索引(Store 数据的索引器)与序列计数器同步,就可以正常工作。当它们不同步时,删除记录并刷新页面后,再次创建的新实体将以 1 开头保存!序列计数被忽略了。修复方法是获取客户端 __ID,因为它是服务器上记录的主键,如果表是这样的话,它*不是*主键。

public Log(storeName: string, action: AuditLogAction, 
          recordIndex: number, property?: string, value?: any): void {
  let recIdx = this.InternalCreateRecord(); // no audit log for the audit log!
  let id = this.storeManager.GetStore(storeName).GetProperty(recordIndex, "__ID");
  let log = new AuditLogModel(storeName, action, id, property, value);

进行此更改后,持久化序列更改停止工作,因为它甚至没有 __ID,所以我的想法是错误的——它绝对需要 __ID,以便 SetRecord 函数正常工作,并且在创建关系后,父子 Store 中的相应字段会正确更新。

public SetRecord(idx: number, record: {}): Store {
  this.CreateRecordIfMissing(idx);
  this.data[idx] = record;
  jQuery.each(record, (k, v) => this.auditLogStore.Log
            (this.storeName, AuditLogAction.Update, idx, k, v));

  return this;
}

修复方法是更改 SequenceStore 中的此重写。

protected GetPrimaryKey(): {} {
  return {};
}

改为这样。

// Sequence store has to override this function so that we don't recursively call GetNext
// when CreateRecord is called above. 
// We need __ID so the server knows what record to operate on.
protected GetNextPrimaryKey(): {} {
  let id = Object.keys(this.data).length;
  return { __ID: id };
}

天哪。那真不好玩。

重新审视这个混乱。

private static void CreateTable(SqlConnection conn, string storeName)
{
  // __ID must be a string because in ParentChildStore.GetChildInfo, 
 // this Javascript: childRecIds.indexOf((<any>r).__ID)
  // Does not match on "1" == 1
  string sql = $"CREATE TABLE [{storeName}] (ID int NOT NULL PRIMARY KEY IDENTITY(1,1), 
              UserId UNIQUEIDENTIFIER NOT NULL, __ID nvarchar(16) NOT NULL)";
  Execute(conn, sql);
}

也许创建一个 ParentChildRelationships Store 的具体模型会更好,因为现在它是动态创建的,缺乏类型信息,parentIdchildId 字段正在被创建为 nvarchar

我当然能理解为每个服务器端表和客户端用法拥有实际的模型定义的需求,但我真的不想走那条路!然而,为 (UserId, __ID) 字段对创建一个索引实际上会很有用,因为更新和删除操作总是使用这对来标识记录。

private static void CreateTable(SqlConnection conn, string storeName)
{
  // __ID must be a string because in ParentChildStore.GetChildInfo, 
 // this Javascript: childRecIds.indexOf((<any>r).__ID)
  // Does not match on "1" == 1
  string sql = $"CREATE TABLE [{storeName}] (ID int NOT NULL PRIMARY KEY IDENTITY(1,1), 
              UserId UNIQUEIDENTIFIER NOT NULL, __ID nvarchar(16) NOT NULL)";
  Execute(conn, sql);
  string sqlIndex = $"CREATE UNIQUE INDEX [{storeName}Index] ON [{storeName}] (UserId, __ID)";
  Execute(conn, sqlIndex);
}

忘记注册公共字段 Bug

另一个 Bug 出现了,我在控制台日志中错过了——在创建表时,服务器端的内存模式没有在创建表后更新 UserId__ID 字段。修复很简单,尽管我不喜欢 CreateTable 调用与添加 CreateTable 创建的两个字段之间的耦合。

private static void CheckForTable(SqlConnection conn, string storeName)
{
  if (!schema.ContainsKey(storeName))
  {
    CreateTable(conn, storeName);
    schema[storeName] = new List<string>();
    schema[storeName].AddRange(new string[] { "UserId", "__ID" });
  }
}

我可能已经很长时间没有注意到这个问题了,因为直到我修改了上面的代码来创建索引之前,我都没有删除所有表来创建一个干净的状态!唉。我真的需要创建单元测试。

奖励 第 17 天 - 实体菜单栏

起初,我想要一个侧边菜单栏来确定哪些子实体可见。虽然这仍然是个好主意,但我真的不确定它会如何工作。我只知道一件事——当有很多项目和子项、孙项的视图时,屏幕会变得非常混乱,现在包括。

  • 项目 Bug
  • 项目联系人
  • 项目注释
  • 项目链接
  • 项目任务
  • 任务注释
  • 任务链接
  • 子任务

屏幕不仅混乱,而且还很难看清哪个项目被选中,而且随着项目列表越来越大,会出现垂直滚动,这对查看项目及其子项、甚至孙项的子项来说又是一个烦恼。我需要一种方法来聚焦于特定项目,然后在切换项目时取消聚焦。而且我想让聚焦和取消聚焦项目变得容易,而无需添加额外的按钮,如“显示项目详情”和“返回项目列表”,或者类似的愚蠢的东西,特别是当这对子项的子项也适用时,例如“显示任务详情”和“返回任务”。所以在沉思了一个小时(我没开玩笑,虽然在这段时间里我和一个陌生人在农场商店进行了有趣的谈话,而我在农场商店是因为周五的风造成了 8 小时的停电,你真的读了这个并且真的点击了 Hawthorne Valley Farm Store 链接吗?)我选择了以下行为。

  • 点击特定实体的任何记录控件都会隐藏所有其他同级实体。这会移除所有同级项,所以我确切地知道我正在处理哪个实体,无论我在实体层级结构中的哪个位置,它都能工作。
  • 点击第一个控件(我认为几乎总会是编辑框,但有待观察)会取消选择该实体并重新显示所有同级项。(删除实体也会做同样的事情。)
  • 现在,有趣的部分来了——根据菜单栏中选择的实体,只有那些子项会在你“聚焦”父实体时显示。
  • 取消选择聚焦的实体将隐藏菜单栏中选择的子实体。

为了说明,这是一个示例项目列表(这里命名真的很原创)。

点击一个实体(例如“01 P”)你会看到。

就这样!同级项已被隐藏。点击第一个控件,在本例中是包含文本“01 P”的编辑框,它会被取消选择,所有同级项都会重新显示。如上所述,这在层级结构的任何地方都有效。

现在这是实体菜单栏。

我点击菜单栏中的 Tasks,并假设“01 P”被选中,我看到了它的任务。

现在我也选择“Sub-Tasks”。

注意“Create Sub-Task”按钮,这实际上是一个 Bug,因为在没有选择父级的情况下我不应该能够创建子项。但无论如何,请注意我没有选择任务。一旦我选择了一个任务,它的子任务就会出现。

我发现这种 UI 行为相当舒适。

  • 我可以选择只想处理的实体。
  • 我可以选择只想查看选定实体中的子实体。
  • 我可以轻松取消选择查看子实体。
  • 我可以轻松地返回查看同级项的整个列表。
  • 当我选择父实体时,我可以轻松地看到我选择了哪些层级中的实体来查看。

为了实现这一切,在 HTML 中我添加了。

<div class="row menuBar">
  <div id="menuBar">
  </div>
</div>
  <div class="row entityView">
  ...etc...

并在应用程序初始化中。

let menuBar = [
  { displayName: "Bugs", viewController: vcProjectBugs },
  { displayName: "Contacts", viewController: vcProjectContacts },
  { displayName: "Project Notes", viewController: vcProjectNotes },
  { displayName: "Project Links", viewController: vcProjectLinks },
  { displayName: "Tasks", viewController: vcProjectTasks },
  { displayName: "Task Notes", viewController: vcProjectTaskNotes },
  { displayName: "Task Links", viewController: vcProjectTaskLinks },
  { displayName: "Sub-Tasks", viewController: vcSubtasks }
];

let menuBarView = new MenuBarViewController(menuBar, eventRouter);
menuBarView.DisplayMenuBar("#menuBar");

菜单栏和菜单项在 TypeScript 中定义为。

import { MenuBarItem } from "./MenuBarItem"

export interface MenuBar extends Array<MenuBarItem> { }

import { ViewController } from "../classes/ViewController"

export interface MenuBarItem {
  displayName: string;
  viewController: ViewController;
  id?: string;                // used internally, never set
  selected?: boolean;         // used internally, never set
}

这更有趣的部分是 MenuBarViewController 如何与 ViewController 交互——我真的应该把它重命名为 EntityViewController!注意构造函数中定义的几个事件路由。

export class MenuBarViewController {
  private menuBar: MenuBar;
  private eventRouter: EventRouter;

  constructor(menuBar: MenuBar, eventRouter: EventRouter) {
    this.menuBar = menuBar;
    this.eventRouter = eventRouter;

    this.eventRouter.AddRoute("MenuBarShowSections", 
                    (_, __, vc:ViewController) => this.ShowSections(vc));
    this.eventRouter.AddRoute("MenuBarHideSections", 
                    (_, __, vc: ViewController) => this.HideSections(vc));
}

两个关键处理程序是。

private ShowSections(vc: ViewController): void {
  vc.childControllers.forEach(vcChild => {
    this.menuBar.forEach(item => {
      if (item.selected && vcChild == item.viewController) {
        item.viewController.ShowView();
      }
    });

    this.ShowSections(vcChild);
  });
}

private HideSections(vc: ViewController): void {
  vc.childControllers.forEach(vcChild => {
    this.menuBar.forEach(item => {
      if (item.selected && vcChild == item.viewController) {
        item.viewController.HideView();
      }
    });

    this.HideSections(vcChild);
  });
}

现在,在实体视图控制器中,我将 jel.on('focus', (e) => { 改为:jel.on('click', (e) =>,用于当用户聚焦/点击实体控件时。点击实体控件现在具有显示和隐藏同级项以及根据菜单栏选择显示子项的附加行为。

if (this.selectedRecordIndex != recIdx) {
  this.RemoveChildRecordsView(this.store, this.selectedRecordIndex);
  this.RecordSelected(recIdx);
  this.selectedRecordIndex = recIdx;
  this.ShowChildRecords(this.store, recIdx);

  this.HideSiblingsOf(templateContainer);
  // Show selected child containers as selected by the menubar
  this.eventRouter.Route("MenuBarShowSections", undefined, undefined, this);
} else {
  let firstElement = jQuery(e.currentTarget).parent()[0] == 
                    jQuery(e.currentTarget).parent().parent().children()[0];

  if (firstElement) {
    // If user clicks on the first element of selected record,
    // the deselect the record, show all siblings, and hide all child records.
    this.ShowSiblingsOf(templateContainer);
    this.RemoveChildRecordsView(this.store, this.selectedRecordIndex);
    this.RecordUnselected(recIdx);
    this.selectedRecordIndex = -1;
    // Hide selected child containers as selected by the menubar
    this.eventRouter.Route("MenuBarHideSections", undefined, undefined, this);
  }
}

就这样!

运行应用程序

如果你想使用本地存储运行应用程序,请在 AppMain.js 中,确保代码如下显示。

let persistence = new LocalStoragePersistence();
// let persistence = new CloudPersistence("http://127.0.0.1/", userId);

如果你想使用数据库运行应用程序。

  1. 创建一个名为 TaskTracker 的数据库。是的,就是这样,你不需要定义任何表,它们会自动创建。
  2. 在服务器应用程序 Program.cs 中,设置你的连接字符串:private static string connectionString = "[your connection string]";
  3. 以“管理员身份”打开命令窗口,cd 到服务器应用程序的根目录,然后输入“run”。这将构建 .NET Core 应用程序并启动服务器。
  4. 要退出服务器,请按 Ctrl+C(我的服务器关闭存在 Bug!)
  5. 如果你需要更改 IP 地址或端口,请在 TypeScript(参见上面)*和*服务器应用程序中进行更改。

并启用云持久化。

// let persistence = new LocalStoragePersistence();
let persistence = new CloudPersistence("http://127.0.0.1/", userId);

结论

所以这篇文章非常长。你可能应该一天读一页!而且它也很疯狂——这是一个元数据驱动的、视图定义模型的、动态生成模式的、奇怪的构建应用程序的方法。仍然有很多工作要做,才能让它变得更有趣,例如将模板视图定义和 HTML 存储在用户特定的数据库中,让用户灵活地定制整个演示。UI 非常丑陋,但对于我想要实现的目标——以一种真正有用(对我来说)的方式组织项目、任务、联系人、链接、Bug 和注释——它实际上做得相当好。还有一些严重的缺点,比如所有字段都创建为 nvarchar,因为我们没有类型信息!

我希望你读得很开心,也许这里的一些想法很有趣,即使它们令人震惊,我期待未来能跟进一些更有趣的功能,比如同步本地 Store 和云 Store,这实际上现在是坏的,因为每当进行“Store 保存”时,审计跟踪就会被清除。糟糕!还有一件事我想看看,那就是我在客户端启动时加载所有用户的“Store”数据——如果只加载与选定项目相关的子数据会更有趣。基本上,一种机制来说“如果我没有这些记录,就现在获取它们。”

最后,如果你有兴趣观看这个项目如何发展,我将在 GitHub 上的仓库 上发布更新。

嗯,总之,就这些了,各位!

© . All rights reserved.