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

小程序typescript

Image 1

This screenshot is only a subset of what's been implemented here. On the right are additional links associated with projects and tasks:

此屏幕快照只是此处已实现的一部分。 右侧是与项目和任务关联的其他链接:

Image 2

目录 (Table Of Contents)

介绍 (Introduction)

I thought it would be fun and hopefully interesting to document the creation of a client-side TypeScript application from concept to implementation. So I chose something that I've been wanting to do for a while - a project-task manager that is tailored to my very specific requirements. But I also wanted this implementation to be highly abstract, which means metadata for the UI layout and parent-child entity relationships. In other words, at the end of the day, the physical index.html page looks like this (a snippet):

我认为记录从概念到实现的客户端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>

Where the real work is done on the client-side in creating the container content. So what this article covers is one way to go about creating such an implementation as a general purpose parent-child entity editor but in the context of a specific project-task manager, and you get to see the evolution from concept to working application that took place over 15 "days."

在客户端上完成容器内容创建工作的地方。 因此,本文介绍的是一种创建通用的父子实体编辑器这样的实现的方法,但是要在特定的项目任务管理器的上下文中进行,您会看到从概念到工作应用程序的演变,放置超过15天。

And as you've probably come to expect, I explore a couple new concepts as well:

正如您可能已经期望的那样,我也探索了两个新概念:

  1. Except for concrete models, there entire "model" concept is thrown out the window. The view defines the model!

    除具体模型外,整个“模型”概念都被抛在了窗外。 视图定义了模型!
  2. Table and column generation as needed on the fly. Yup.

    快速生成表和列。 对。

By the way, days are not contiguous -- while each day documents the work I did, it does not mean that I worked on this each and every day. Particularly Day 12 encompasses a physical timespan of 3 days (and no, not 8 hour days!) Also, you should realize that each day includes updating the article itself!

顺便说一下,日子不是连续的-虽然每天都记录我所做的工作,但这并不意味着我每天都在做这项工作。 特别是,第12天的物理时间跨度为3天(而不是8小时)!此外,您应该意识到每天都在更新文章本身!

Also, yes, this could be implemented with grids but the default HTML grid functionality is atrocious and I didn't want to bring in other third party libraries for these articles. My favorite is jqWidgets, pretty much none other will do (even though it's large) so maybe at some point, I'll demonstrate how to tie all this stuff in to their library.

此外,是的,可以使用网格来实现,但是默认HTML网格功能非常糟糕,我不想为这些文章引入其他第三方库。 我最喜欢的是jqWidgets,几乎没人会做(即使它很大),所以也许在某个时候,我将演示如何将所有这些东西绑定到它们的库中。

第一天-一般概念 (Day 1 - General Concepts)

Some rough sketches:

一些粗略的草图:

Image 3

Image 4
  • It becomes clear looking at the layout that it is really more of a template for my own requirements and that the actual "task item bar" on the left as well as the specific fields for each task item should be completely user definable in terms of label, content, and control.

    通过查看布局可以清楚地看到,它实际上更多地是满足我自己的需求的模板,并且左侧的实际“任务栏”以及每个任务项的特定字段应完全由用户定义,内容和控制权。
  • This pretty much means that we're looking at a NoSQL database structure with loose linkages between tasks and task items. The "root" of everything still remains the task, but the task items and their fields is really quite arbitrary.

    这几乎意味着我们正在研究一种NoSQL数据库结构,该结构在任务和任务项之间存在松散的联系。 一切的“根”仍然是任务,但任务项及其字段实际上是任意的。
  • So we need to be able to define the structure and its field content as one "database" of how the user wants to organize the information.

    因此,我们需要能够将结构及其字段内容定义为用户希望如何组织信息的一个“数据库”。
  • Certain fields end up being arrays (like URL links) that are represented discretely, while other fields (like notes) may be shown discretely as a scrollable collection of distinct textarea entries or more as a "document" where the user simply scrolls through a single textarea.

    某些字段最终以离散形式表示的数组(如URL链接),而其他字段(如注释)则可能离散地显示为不同文本区域条目的可滚动集合,或者更多地显示为“文档”,用户可以在其中滚动浏览单个文本区域。
  • Searching - the user should be able to search on any field or a specific area, such as "notes."

    搜索-用户应该能够在任何字段或特定区域(例如“注释”)上进行搜索。
  • Any field can be either a singleton (like a date/time on a communication) or a collection, like a list of contacts for that communication.

    任何字段都可以是单例(例如通信中的日期/时间)或集合(例如该通信的联系人列表)。
  • So what we end up doing first is defining an arbitrary schema with enough metadata to describe the layout and controls of the fields in the schema as well as actions on schema elements, for example, the task item bar can be represented as schema elements but they are buttons, not user input controls.

    因此,我们最终要做的是定义一个具有足够元数据的任意模式,以描述模式中字段的布局和控件以及对模式元素的操作,例如,任务项栏可以表示为模式元素,但是它们是按钮,而不是用户输入控件。
  • We don't want to go overboard with this! The complexity with this approach is that the page is not static -- the entire layout has to be generated from the metadata, so the question is, server-side generation or client-side?

    我们不想为此太费力! 这种方法的复杂性在于页面不是静态的-整个布局必须从元数据生成,所以问题是服务器端生成还是客户端端?
  • Personally, I prefer client-side. The server should be minimally involved with layout -- the server should serve content, as in data, not layout. This approach also facilitates development of the UI without needing a server and keeps all the UI code on the client rather than spreading it across both JavaScript and C# on the back-end. And no, I'm not interested in using node.js on the back-end.

    就个人而言,我更喜欢客户端。 服务器应最少参与布局-服务器应按数据而不是布局提供内容。 这种方法还可以在不需要服务器的情况下促进UI的开发,并将所有UI代码保留在客户端上,而不是将其分布在后端JavaScript和C#上。 不,我对在后端使用node.js不感兴趣。

第2天-元数据的一些结构概念 (Day 2 - Some Structure Concepts for the Metadata)

We should be able to have fairly simple structures. Let's define a few, all of which are of course customizable but we'll define some useful defaults.

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

状态 (Status)

I like to have a fairly specific status and get frustrated when I can't put that information in a simple dropdown that lets me see at a glance what's going on with the task. So, I like things like:

我喜欢有一个相当具体的状态,当我不能将这些信息放在一个简单的下拉菜单中时,我会感到沮丧,这让我一眼就能看到任务的进展。 所以,我喜欢这样的事情:

  • To do

    去做
  • Working on

    正在努力
  • Testing

    测试中
  • QA

    质量检查
  • Production (Completed)

    生产(已完成)
  • Waiting for 3rd Party

    等待第三

  • Waiting for Coworker

    等待同事
  • Waiting on Management

    等待管理
  • Stuck

    卡住

Notice that I don't have a priority next to the task. I really don't give a sh*t about priorities -- there's usually a lot of things going on and I work on what I'm in the mood for and what I can work on. Of course, if you like priorities, you can add them to the UI.

请注意,我在任务旁边没有优先级。 我真的不关心优先事项-通常会有很多事情在进行,而我会根据自己的心情和可以做的事情进行工作。 当然,如果您喜欢优先级,可以将它们添加到UI。

Notice that I also don't categorize tasks into, for example, sprints, platforms, customers, etc. Again, if you want those things, you can add them.

请注意,我也没有将任务分类为例如sprint,平台,客户等。同样,如果需要这些东西,可以添加它们。

What I do want is:

我想要的是:

  1. What is the task?

    任务是什么?
  2. What is its state?

    它的状态是什么?
  3. One line description of why it's in that state.

    一行描述为什么它处于这种状态。

So this is what I want to see (of course, what you want to see is going to be different):

这就是我想要看到的(当然,您想要看到的将有所不同):

Image 5

How would we define this layout in JSON so that you can create whatever needs your needs? Pretty much, this means figuring out how to meet my needs first!

我们如何在JSON中定义此布局,以便您可以创建需要的内容? 几乎,这意味着首先要弄清楚如何满足我的需求!

This might be the definition of the high level task list:

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

[
  {
    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%"
    }
  }
]

These fields are all inline editable but we also want to support drilling into a field to view its sub-records. Not all fields have sub-records (like Status), but this is determined by the metadata structure, so Status could have sub-records. Any time the user focuses on a control with sub-structures, the button bar will update and the "show on select" sub-structures will display the sub-records.

这些字段都是内联可编辑的,但我们还希望支持钻取字段以查看其子记录。 并非所有字段都具有子记录(如Status ),但这由元数据结构确定,因此Status可能具有子记录。 每当用户将注意力集中在带有子结构的控件上时,按钮栏将更新,并且“在选定时显示”子结构将显示子记录。

So we can define sub-structures, or allowable child records, like this using the Task entity as an example:

因此,我们可以使用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"}
]

Note that all sub-structures are defined in their singular form and we have complete flexibility as to the label used to represent the link. These "show on parent select" will always be visible unless the user collapses that section, and they are rendered in the order they appear in the list above. Where they render is determined by other layout information.

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

Other things to think about:

需要考虑的其他事项:

  • Sub-tasks (easy to do)

    子任务(容易做)
  • Task dependencies

    任务依赖性

第3天-模板 (Day 3 - Templates)

So, the more I think about this, the more I realize that this is really a very generalized entity creator/editor with not quite dynamic relationships, much as I've written about in my Relationship Oriented Programming articles. So it seems natural that allowable relationships should be definable as well. But what I'd prefer to do at this point is some prototyping to get a sense of how some of these ideas can come to fruition. So let's start with the JSON above and write a function that turns it into an HTML template that can then be repeatedly applied as necessary. And at the same time, I'll be learning the nuances of TypeScript!

因此,我思考的越多,我就越意识到我确实是一个非常通用的实体创建者/编辑者,它并没有很动态的关系,就像我在面向关系的编程文章中所写的那样。 因此,允许的关系也应该定义也是很自然的。 但是,在这一点上,我更愿意做一些原型制作,以了解其中的一些想法是如何实现的。 因此,让我们从上面的JSON开始,编写一个将其转换为HTML模板的函数,然后可以根据需要重复应用该模板。 同时,我将学习TypeScript的细微差别!

With some coding, I get this:

通过一些编码,我得到了:

Image 6

Defined by the template array:

由模板数组定义:

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",
  }
];

and the support of interfaces to define the template object model and a Builder class to put together the HTML:

支持定义模板对象模型的接口和将HTML放在一起的Builder类:

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;
  }
}

This leaves only the logic for constructing the template:

这只剩下构造模板的逻辑:

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;
}

So the top-level code just does this:

因此,顶层代码就是这样做的:

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

If I chain the template:

如果我链接模板:

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

I get:

我得到:

Image 7

Cool. May not be the prettiest thing, but the basics are what I'm looking for.

凉。 可能不是最漂亮的东西,但我正在寻找基本知识。

Now personally what bugs me to no end is that the template object reminds me of ExtJs: basically a collection of arbitrary keys to define the layout of the UI. Maybe it's unavoidable, and I certainly am not going down the route that ExtJs uses which is to create custom IDs that change every time the page is refreshed. Talk about killing the ability to do test automation at the UI level. It is ironic though, in writing something like this, I begin to actually have a better understanding of the design decisions that ExtJs made.

现在,对我个人而言,无休止的是,模板对象使我想起了ExtJ:基本上是定义UI布局的任意键的集合。 也许这是不可避免的,而且我当然不会沿用ExtJs创建每次刷新页面时都会更改的自定义ID的路线。 谈论杀死在UI级别执行测试自动化的功能。 具有讽刺意味的是,在编写类似这样的内容时,我实际上开始对ExtJ做出的设计决策有了更好的理解。

Which brings us to how the comboboxes are actually populated. So yeah, there's a concept of a "store" in ExtJs, and manipulating the store automatically (or that's the theory) updates the UI. That's too much for me right now, but I do want the ability to use an existing object or fetch (and potentially cache) the object from a REST call. So let's put something simple together. Here's my states:

这使我们了解了combobox的实际填充方式。 是的,在ExtJs中有一个“商店”的概念,并且自动操作商店(或者说是理论)会更新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' },
];

With a little refactoring:

进行一些重构:

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

and the prototype concept of a 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];
  }
}

I now do this:

我现在这样做:

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

and the template builder does this:

模板构建器执行以下操作:

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

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

  this.SelectEnd();

  return this;
}

Resulting in:

导致:

Image 8

That was easy enough.

那很容易。

So what's involved with persisting the actual task data and restoring it? Seems like the store concept can be extended to save state, and one of the states I want to support is localStorage. This also seems complicated as I'm already dealing with an array of objects! And again, I realize why in ExtJS stores are always arrays of things, even if the store represents a singleton -- because it's easier! So let's refactor the Store class. First, we want something that defines the store types, like this:

那么,持久存储实际任务数据并还原它涉及什么呢? 似乎可以将存储概念扩展为保存状态,而我要支持的状态之一是localStorage 。 这似乎也很复杂,因为我已经在处理一系列对象! 再一次,我意识到为什么在ExtJS商店中总是存储事物数组,即使商店代表单身人士也是如此-因为它更容易! 因此,让我们重构Store类。 首先,我们需要定义商店类型的东西,例如:

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

And then, we want something that manages the configuration of the store:

然后,我们需要一些东西来管理商店的配置:

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

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

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

And finally, we'll refactor the Store class so it looks like this:

最后,我们将重构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;
  }
}

which is used like this:

像这样使用:

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

Next, the template that we created earlier:

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

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

Needs to know what store to use for the template items, so we do this instead:

需要知道要用于模板项目的商店,所以我们这样做:

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

Frankly, I have no idea whether this is a good idea or not, but let's go for it for now and see how it holds up.

坦白说,我不知道这是否是一个好主意,但让我们暂时来看一下它是如何保持的。

Next we need to refactor this code jQuery("#template").html(html + html + html); so that we're not blindly copying the HTML template but instead we have a way of building the template so that it knows what object index in the store's data to update when the field changes. Dealing with decoupling sorting from the store's representation of the data will be an interesting thing to figure out. Later. More to the point, that particular line of code will probably be tossed completely when we implement loading the tasks from localStorage. For the moment, in the template builder, let's add a custom attribute storeIdx to our two controls:

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

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

and:

和:

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

And now we do this:

现在我们这样做:

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);

with a little help from:

在以下方面的一点帮助下:

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

  return newHtml;
}

and lo-and-behold, we have indices now to the store, for example:

而且,现在我们有到商店的索引,例如:

Image 9

Sigh. Note that the resulting HTML has the storeIdx attribute as all lowercase. This seems to be a jQuery thing that I'll investigate later. Next, we need to create onchange handlers for updating the store when the value changes. This must be done with "late binding" because the HTML is created dynamically from a template. Again I see why ExtJS ends up assigning arbitrary ID's to elements -- how do we identify the element to which to bind the onchange handler? Personally, I prefer using a separate attribute to uniquely identify the binding point, and probably a GUID for the attribute value. Who knows what that will do to performance if there's hundreds of elements that must be bound, but honestly, I'm not going to worry about that!

叹。 请注意,生成HTML的storeIdx属性均为小写。 这似乎是jQuery的事情,我将在以后进行调查。 接下来,我们需要创建onchange处理程序以在值更改时更新商店。 这必须通过“后期绑定”完成,因为HTML是从模板动态创建的。 再一次,我明白了为什么ExtJS最终为元素分配了任意ID-我们如何确定绑定onchange处理程序的元素? 就个人而言,我更喜欢使用单独的属性来唯一标识绑定点,并且可能使用GUID作为属性值。 谁知道如果必须绑定数百个元素会对性能产生什么影响,但是老实说,我不会为此担心!

It's 10:30 PM, I'm calling it a night!

现在是晚上10:30,我叫它一个晚上!

第4天-后期装订 (Day 4 - Late Binding)

So here, we are with the task of implementing late binding. First, a couple refactorings to the template builder to set up the bindGuid attribute with a unique identifier which we'll use to determine the binding, again using the input and select elements as examples:

因此,在这里,我们的任务是实现后期绑定。 首先,对模板构建器进行一些重构,以使用唯一的标识符来设置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;
}

These all get put into an array:

这些都放入一个数组中:

elements: TemplateElement[] = [];

which the binding process on the document being ready wires up:

准备就绪的文档上的装订过程连接起来:

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());
      });
    });
  });
});

There's a "not good" piece of code in the above snippet: taskStore.SetProperty. The hard-wiring to the taskStore is refactored out later so the binding is not specific to just the Task store!

上面的代码片段中有一段“不好的”代码: taskStore.SetPropertytaskStore将重构到taskStore的硬连线,因此绑定不仅特定于Task store!

Notice here we also use the record index to qualify the record. We do this because with this code jQuery("#template").html(task1 + task2 + task3); there are multiple elements with the same GUID because we've cloned the HTML template three times. Probably not ideal, but I'll live with that for now. In the meantime, the store I've created for the tasks:

注意这里我们也使用记录索引来限定记录。 我们这样做是因为使用此代码jQuery("#template").html(task1 + task2 + task3); 有多个具有相同GUID的元素,因为我们已经将HTML模板克隆了三遍。 可能不太理想,但我暂时会接受。 同时,我为任务创建的商店:

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

manages setting the property value for the record at the specified index, and creating empty records as necessary:

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

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;
}

At the moment, this is implemented in the StoreConfiguration class. Seems awkward yet it's the StoreConfiguration class that maintains the data, whereas the Store class is really a "store manager", so probably Store should be called StoreManager and StoreConfiguration should be called Store! Gotta love refactoring to make the names of things clearer. So from hereon, that's what they'll be called. Rather a PITA to do without the "rename" feature when working with C# code!

目前,这是在StoreConfiguration类中实现的。 似乎很尴尬,但是维护数据的是StoreConfiguration类,而Store类实际上是一个“商店管理器”,因此Store应该被称为StoreManagerStoreConfiguration应该被称为Store ! 一定喜欢重构,以使事物的名称更清晰。 因此从现在开始,这就是它们的名字。 当使用C#代码时,PITA应该没有“重命名”功能!

After entering some values:

输入一些值后:

Image 10

we can see that these have been serialized to the local storage (inspecting local storage in Chrome):

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

Image 11

Cool, however notice that record 0 does not have a status, as I didn't change it from the default. What to do about that? This isn't an easy problem because we have a disconnect between the number of template instances we've created and the store data. So we need a mechanism to deal with that and set defaults. The simplest answer is to brute force that right now. At least it's explicit:

很酷,但是请注意,记录0没有状态,因为我没有更改默认值。 怎么办呢? 这不是一个容易的问题,因为我们在创建的模板实例数与商店数据之间没有联系。 因此,我们需要一种机制来处理该问题并设置默认值。 最简单的答案是立即进行暴力破解。 至少它是明确的:

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

So now, the task store is initialized with defaults:

因此,现在,任务存储已使用默认值初始化:

Image 12

Ultimately, this only pushed the problem into the "ignored" bucket, as it's also dependent on the order of the status array. But no matter, let's push on and now that we have something in the store, let's load the UI with the store data! We also have the question of whether the store should be updated per keypress or only when the onchange event fires, which occurs when the element loses focus. Another "ignore for now" issue. Furthermore, we have an excellent demonstration of "don't implement code with side-effects!" in this function:

最终,这只会将问题推入“忽略的”存储桶,因为它还取决于状态数组的顺序。 但是无论如何,让我们继续前进,既然商店中已有东西,让我们用商店数据加载UI! 我们还有一个问题,是应该在每次按键时更新存储,还是仅在触发元素失去焦点的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;
}

As updating the physical storage in the case of the local storage obliterates anything we've saved! I've created a bit of a conundrum -- if the records don't exist in the local storage, I want to set the defaults, but if they do exist, I don't want to set the defaults! So first, let's get rid of the side-effect and move the updating of the physical storage to the onchange handler:

由于在本地存储的情况下更新物理存储会消除我们已保存的所有信息! 我已经创建了一个难题-如果记录在本地存储中不存在,我想设置默认值,但是如果它们确实存在,则我不想设置默认值! 因此,首先,让我们摆脱副作用,并将物理存储的更新移至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);
});

Next, this gets removed:

接下来,将其删除:

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

and instead is replaced with the ability to set a default value if it doesn't exist, after the store has been loaded:

而是在商店加载替换为设置默认值(如果不存在)的功能:

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

which is implemented as:

实现为:

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

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

  return this;
}

And the Save function:

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;
}

However, this has the annoying effect of potentially making REST calls to save each record, even if nothing changed. Another "ignore this for now" issue, but we'll definitely need to implement a "field dirty" flag! For local storage, we have no choice, the entire structure must be saved, so for now we're good to go. When there's no local storage, we get the desired defaults:

但是,这会产生令人讨厌的效果,即使没有任何更改,也可能会进行REST调用以保存每个记录。 另一个“暂时忽略此问题”问题,但我们肯定需要实现“字段脏”标志! 对于本地存储,我们别无选择,必须保存整个结构,所以现在我们可以开始了。 如果没有本地存储,我们将获得所需的默认值:

Image 13

And when there is data, it's not obliterated by refreshing the page:

并且当有数据时,刷新页面不会使数据消失:

Image 14

Of course, the UI doesn't update because we need the binding to work the other way as well! A brute force implementation looks like this:

当然,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));
  }
}

Oooh, notice the template literal: let jel = jQuery(`[bindGuid = '${guid}'][storeIdx = '${i}']`); -- I'll have to refactor the code and use that more often!

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

This yields on page load:

产生页面加载:

Image 15

Cool, I can now create and save three tasks! Calling it quits for Day 4, back soon to work on reverse binding and better handling of defaults as well as getting rid of this silly "3 tasks" thing and making tasks more dynamic.

不错,我现在可以创建和保存三个任务! 调用它在第4天退出,很快就可以进行反向绑定和更好地处理默认值,并摆脱了这个愚蠢的“三项任务”,使任务更具动态性。

第5天-商店回调 (Day 5 - Store Callbacks)

So that brute force approach above needs to be fixed, but I don't want the store to know anything about how the records fields map to UI elements, so I think what I'd like to do is provide callbacks for record and property level updates using the good ol' Inversion of Control principle. Possibly something like this should be done for the different store types as well so the application can override behavior per store. Later.

因此,上面的那种蛮力方法需要解决,但是我不希望商店对记录字段如何映射到UI元素一无所知,所以我想我想做的就是为记录和属性级别提供回调使用良好的控制反转原则进行更新。 可能还应该针对不同的商店类型执行类似的操作,以便应用程序可以覆盖每个商店的行为。 后来。

To the Store class, I'll add a couple callbacks with default "do nothing" handlers:

Store类中,我将添加几个带有默认“不执行任何操作”处理程序的回调:

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

and in the Load function, we'll call the recordChangedCallback for every record loaded (probably not what we want to do in the long run!):

Load函数中,我们将为每个已加载的记录调用recordChangedCallback (从长远来看可能不是我们想要做的!):

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

This gets wired in to the taskStore -- notice it's implemented so that it passes in the template builder, which is sort of like a view, so we can acquire all the field definitions in the "view" template:

这将连接到taskStore -请注意,它已实现,因此它可以传入模板构建器中,这有点像视图,因此我们可以获取“ view ”模板中的所有字段定义:

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

and the handler looks a lot like the brute force approach above.

处理程序看起来很像上面的蛮力方法。

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);
  }
}

This is a fairly generic approach. Let's do something similar for changing just a property and testing that by setting a record's property value via the 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;
}

Wired up like this:

像这样连线:

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

And implemented like this:

并实现如下:

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);
}

Now we can set a property for a record in a store and it's reflected in the UI:

现在,我们可以为商店中的记录设置属性,并将其反映在UI中:

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

Image 16

So let's look at adding and deleting tasks. Some of you are either laughing or groaning because I've backed myself into another corner with this "record index" concept, which makes deleting and inserting tasks a total nightmare because the storeIdx will go out of sync with the record it's managing. So it's time to throw out this whole concept in favor of a smarter way to handle records. At the moment, I've declared the store's data as an array of name:value pairs:

因此,让我们看一下添加和删除任务。 你们中有些人在笑或because吟,是因为我使用“记录索引”概念将自己推到了另一个角落,这使删除和插入任务成为了一场噩梦,因为storeIdx将与其管理的记录不同步。 因此,是时候抛弃整个概念,而采用一种更聪明的方式处理记录了。 目前,我已将商店的数据声明为name:value对的数组:

data: {}[] = [];

but it's time for something smarter -- a way to uniquely identify a record without using a row index, and a way to get that unique identifier associated with the UI elements. The irony here is that a numeric index is a fine way to do this, we just need to map the index to the physical record rather than assume a 1:1 correlation. We also no longer need the CreateNecessaryRecords method but instead we create only this single stub key:value object if the "index" is missing in the index-record map.

但是现在是时候变得更聪明了–一种无需使用行索引即可唯一标识记录的方法,以及一种获取与UI元素相关联的唯一标识符的方法。 具有讽刺意味的是,数字索引是实现此目的的一种好方法,我们只需要将索引映射到物理记录即可,而不是假定1:1的相关性。 我们也不再需要CreateNecessaryRecords方法,而是如果索引记录映射中缺少“索引”,则仅创建此单个存根key:value对象。

So instead, I now have:

因此,我现在有:

private data: RowRecordMap = {};

It's private because I don't want anyone touching this structure, which is declared like this:

它是private因为我不希望任何人触摸此结构,该结构声明如下:

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

The most significant refactoring involved the record change callback:

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

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

Pretty much nothing else changes because instead of the index being an array index, it's now a dictionary key and is therefore used in the same way. Here we assume that on an initial load, the record index (from 0 to n-1) corresponds 1:1 with the indices created by the template builder. One other important change is that to save to local storage, we don't want to save the key:value model, just the values, as the keys (the row index lookup) is completely arbitrary:

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

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

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

删除任务 (Deleting a Task)

More refactoring! To make this work, each template that we're cloning needs to be wrapped in its own div so we can remove it. Currently, the HTML looks like this:

更多重构! 为了使这项工作有效,我们要克隆的每个模板都需要包装在自己的div以便我们将其删除。 目前,HTML如下所示:

Image 17

Where the red box is one template instance. Instead, we want this (the code change to make this work was trivial so I'm not going to show it):

其中红色框是一个模板实例。 相反,我们想要这样做(使这项工作变得很简单的代码更改,所以我不再展示):

Image 18

Now let's reduce the width of the "Why" textbox and add a "Delete" button to the template definition:

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

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

And adding a Button method to the TemplateBuilder:

并向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;
}

We get this:

我们得到这个:

Image 19

Snazzy. Now we have to wire up the event! Uh, ok, how will this work? Well first, we need to wire up the click event:

时髦 现在,我们必须为活动加油! 嗯,好的,这将如何工作? 首先,我们需要连接click事件:

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;
}

And we can verify that it works by looking at the console log:

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

Image 20

事件路由器 (Event Router)

Given that this is all constructed by metadata, we need an event router which can route events to arbitrary but predefined functions in the code. This should be quite flexible but only if the code supports the behaviors we need.

鉴于所有这些都是由元数据构造的,因此我们需要一个事件路由器,该事件路由器可以将事件路由到代码中任意但预定义的函数。 这应该非常灵活,但前提是代码支持我们所需的行为。

So let's add a route property to the template:

因此,我们向模板添加一个route属性:

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

Note that I don't call the route "deleteTask", because deleting a record should be handled in a very general purpose manner. The event router start is very simple:

请注意,我不将路由称为“ 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);
  }
}

The delete record handler is initialized:

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

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

A callback and the DeleteRecord function is added to the store:

回调和DeleteRecord函数将添加到存储中:

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

The delete record callback is initialized:

删除记录回调已初始化:

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

The router is invoked when the button is clicked:

单击该按钮时将调用路由器:

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;

and the div wrapping the record is removed:

并且包裹记录的div被删除:

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

Ignoring:

忽略:

  • The "templateIdx" attribute name for now, which obviously has to be specified somehow to support more than one template entity type.

    现在,“ templateIdx ”属性名称显然必须以某种方式指定以支持多个模板实体类型。

  • That this removes the entire div as opposed to, say, clearing the fields or removing a row from a grid, this works nicely.

    这样可以删除整个div ,而不是清除字段或从grid删除一行,效果很好。

  • That the Save call doesn't have a clue as to how to send a REST call to delete the specific record.

    Save调用不知道如何发送REST调用以删除特定记录。

We can mosey on along and after clicking on the delete button for second task, T2, we now see:

我们可以继续前进,在单击第二项任务T2的删除按钮后,我们现在看到:

Image 21

and our local storage looks like this:

我们的本地存储如下所示:

Image 22

Now let's refactor the load process so that the callback dynamically creates the template instances, which will be a precursor to inserting a new task. First, the recordCreatedCallback is renamed to recordCreatedCallback, which is a much better name! Then, we're going to remove this prototyping code:

现在让我们重构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);

because our template "view" is going to be created dynamically as records are loaded. So now the CreateRecordView function looks like this:

因为我们的模板“ 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);
  }
}

插入任务 (Inserting Tasks)

And because in testing, I obliterated all my tasks, I now have to implement a Create Task button! The events for all elements in the template will also need to be wired up every time we create a task! First, the HTML:

而且由于在测试中,我淘汰了所有任务,因此现在必须实现“创建任务”按钮! 每次创建任务时,模板中所有元素的事件也都需要连接! 首先,HTML:

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

Then wiring up the event partly using the event router:

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

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

and the route definition:

以及路线定义:

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

and the implementation in the 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;
}

Notice how we obtain a "unique" record "index", and how we can specify whether to insert at the beginning or append to the end, not of the data records (these are order independent) but the flag gets passed on to the "view" that handles where the template should be created, so once again we refactor CreateRecordView:

请注意,我们如何获取“唯一”记录“ index”,以及如何指定是在数据记录的开头还是末尾插入,而不是数据记录(这些记录与顺序无关),而是将标志传递给“视图”来处理应在何处创建模板,因此我们再次重构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);
  }
}

I'm not going to show you the BindSpecificRecord function because it's almost identical to the binding that occurs in the document ready event, and so all that common code needs to be refactored before I show it to you! One odd behavior that I'm saving for the next day is that when the template is created this way, the combobox doesn't default to "TODO" - will have to figure out why. Regardless, starting from a blank slate:

我不会向您展示BindSpecificRecord函数,因为它几乎与文档就绪事件中发生的绑定相同,因此在向您展示之前,需要重构所有通用代码! 我第二天要保存的一个奇怪行为是,以这种方式创建模板时, combobox未默认设置为“ TODO”-必须弄清楚原因。 无论如何,从空白开始:

Image 23

I created two tasks, note how they are in reverse order because tasks are prepended in the UI:

我创建了两个任务,请注意它们的顺序相反,因为任务是在UI中前置的:

Image 24

and we can see that they are appended in the local storage:

我们可以看到它们被附加在本地存储中:

Image 25

This, of course, causes a problem when the page is refreshed:

当然,这会在刷新页面时引起问题:

Image 26

The order got changed! Hmmm...

订单改变了! 嗯...

Now, from demos I've seen of Vue and other frameworks, doing what has taken 5 days to accomplish here is probably a 30 minute exercise in Vue. However, the point here is that I'm actually building the framework and the application together, and quite frankly, having a lot of fun doing it! So that's all that counts! End of Day 5, and I can finally create, edit, and delete tasks!

现在,从我看过的有关Vue和其他框架的演示中,在Vue上花了30分钟才能完成花5天才能完成的任务。 但是,这里的要点是,我实际上是在一起构建框架和应用程序,而且坦率地说,这样做很有趣! 这就是全部! 第5天结束时,我终于可以创建,编辑和删除任务了!

第六天-基本关系 (Day 6 - Basic Relationships)

So this is one of those "rubber meets the road" moments. I'm going to add a couple relationships. Software is not monogamous! I'd like to add contacts and notes that are child entities of the task. My "tasks" are usually integration level tasks (they probably should be called projects instead of tasks!), like "add this credit card processor", which means that I have a bunch of people that I'm talking to, and I want to be able to find them as related to the task. Same with notes, I want to make notes of conversations, discoveries and so forth related to the task. Why this will be a "rubber meets the road" moment is because I currently have no mechanism for identifying and relating together two entities, such as a task and a note. It'll also mean dealing with some hardcoded tags, like here:

因此,这就是那些“橡胶遇上道路”的时刻之一。 我将添加一些关系。 软件不是一夫一妻制! 我想添加联系人和笔记,它们是任务的子实体。 我的“任务”通常是集成级别的任务(它们可能应该称为项目而不是任务!),例如“添加此信用卡处理器”,这意味着我有很多人在与我交谈,我想要以便找到与任务相关的内容。 与笔记相同,我想记录与任务相关的对话,发现等。 之所以会成为“橡皮筋”,是因为我目前没有机制来识别和关联两个实体,例如任务和便笺。 这也意味着处理一些硬编码标签,例如:

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

The function needs to be general purpose and therefore the div associated with the entity has to be figured out, not hard-coded. So this makes more sense:

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

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

Also, the store event callbacks are general purpose, so we can do this:

另外,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();
  }
}

This also needs to be fixed:

这也需要解决:

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

because the index number is not sufficient to determine the associated entity unless it's also qualified by the container name:

因为索引号不足以确定关联的实体,除非它也通过容器名称来限定:

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

But of course, this assumes that the UI will have unique container names. This leads us to the HTML that defines the layout -- templates must be in containers:

但是,当然,这假定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>

At this point, I can create tasks and notes:

在这一点上,我可以创建任务和注释:

Image 27

and they persist quite nicely in the local storage as well:

而且它们在本地存储中的持久性也很好:

Image 28

To figure out next:

接下来要弄清楚:

  1. Some unique ID field in the record that is persisted. Normally this would be the primary key, but we're not saving the data to a database and I'd like the unique ID to be decoupled from the database's PK, particularly if the user is working disconnected from the Internet, which we should be able to fairly easily support.

    记录中保留的某些唯一ID字段。 通常,这将是主键,但是我们不会将数据保存到数据库中,我希望将唯一ID与数据库的PK分离,尤其是如果用户正在与Internet断开连接时,我们应该这样做能够相当轻松地支持。
  2. Clicking on the parent (the task in our case) should bring up the specific child records.

    单击父项(在本例中为任务)应调出特定的子记录。
  3. Do we have separate stores (like "Task-Note" and "Task-Contact") for each parent-child relationship or do we create a "metastore" with parent-child entity names and this unique ID? Or do we create a hierarchical structure where, say, a task has child elements such as notes?

    对于每个父子关系,我们是否有单独的存储区(例如“ Task-Note ”和“ Task-Contact ”),或者我们是否创建了一个具有父子实体名称和唯一ID的“ metastore ”? 还是我们创建一个层次结构,例如任务中包含诸如笔记之类的子元素?

  4. How do we indicate to the user the selected parent that will be associated with the child entities?

    我们如何向用户指示将与子实体相关联的选定父代?

Regard #4, I like an unobtrusive approach like this, where the green left border indicates the record that's been selected.

关于#4,我喜欢这种不引人注目的方法,其中绿色的左边框表示已选择的记录。

Image 29

The trick here is that we want to remove the selection only for the entity records associated with the selection:

这里的技巧是我们只想删除与选择相关联的实体记录的选择:

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

This way, we can select a record for each entity type:

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

Image 30

Regarding #3, a hierarchical structure is out of the question, as it potentially creates a highly denormalized dataset. Consider that a task (or if I want to add projects at some point, a project) may have the same contact information. If I update the contact, do I want find all the occurrences in an arbitrary hierarchy where that contact exists and update each and every one of them? What if I delete a contact because that person no longer works at that company? Heck no. And separate parent-child stores is rejected because of the number of local storage items (or database tables) that it requires. Particularly when it comes to database tables, the last thing I want to do is create parent-child tables on the fly. So a single meta-store that manages the mappings of all parent-child relationships seems most reasonable at the moment, the major consideration is the performance when the "table" contains potentially thousands (or magnitudes more) of relationships. At this point, such a scenario doesn't need to be considered.

关于#3,层次结构是不可能的,因为它可能会创建高度非规范化的dataset 。 考虑一个任务(或者如果我想在某个时候添加项目,一个项目)可能具有相同的联系信息。 如果更新联系人,是否要查找该联系人所在的任意层次结构中的所有事件并更新每个事件? 如果由于该人不再在该公司工作而删除联系人该怎么办? 哎呀 并且由于其所需的本地存储项(或数据库表)的数量而拒绝了单独的父子存储。 尤其是涉及数据库表时,我要做的最后一件事就是动态创建父子表。 因此,目前管理所有父子关系的单个元存储似乎是最合理的,主要考虑因素是“ table ”包含潜在的数千个(或更多数量级)关系时的性能。 在这一点上,不需要考虑这种情况。

Here, we have our first concrete model:

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

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

Notice that the parent and child IDs are numbers. The maximum number is 21024, the problem though is that the Number type is a 64-bit floating point value, so it's not the range but the precision that is of concern. I'm guessing that finding parent-child relationships by a number ID rather than, say, a GUID ID, will be faster and that I don't have to worry about precision too much at this point.

请注意,父ID和子ID是数字。 最大数量为2 1024 ,但问题是Number类型是一个64位浮点值,因此不是范围,而是精度。 我猜想通过数字ID而不是GUID ID查找父子关系会更快,并且我现在不必太担心精度。

And (horrors), similar to ExtJS, we actually have a concrete ParentChildStore which will have a function for acquiring a unique number ID:

而且(恐怖)类似于ExtJS,我们实际上有一个具体的ParentChildStore ,它将具有获取唯一数字ID的功能:

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

export class ParentChildStore extends Store {
}

The parent-child store is created a little bit differently:

父子商店的创建方式略有不同:

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

And we can access a concrete store type using this function, note the comments:

我们可以使用此功能访问具体的商店类型,请注意以下注释:

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;
}

In C#, I would write something like GetStore<T>(string storeName) where T : Store and the downcast to T would work fine, but I have no idea how to do this in TypeScript. While I need a persistable counter, like a sequence, to get the next ID, let's look at the CreateRecord function first:

在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;
}

It's the assignment of the empty object that needs to set an ID, but I don't want to code that in the store -- I prefer to have that decoupled, so I'll implement it as a call to the StoreManager which will then invoke a callback to the application, so the unique record identifier can be something that the application manages. We could even do a "per store" callback, but that's unnecessary at this point. So now the store calls:

这是需要设置ID的空对象的分配,但是我不想在商店中对它进行编码-我更喜欢将其解耦,因此我将其实现为对StoreManager的调用,然后调用应用程序的回调,因此唯一记录标识符可以由应用程序管理。 我们甚至可以执行“每商店”回调,但是在这一点上这是不必要的。 因此,现在商店致电:

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

The definition for the callback is crazy looking, in that it defaults to returning {}:

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

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

and for testing, let's just implement a basic counter:

为了进行测试,让我们实现一个基本的计数器:

storeManager = new StoreManager();

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

and we can see that this creates the primary key key-value pair when I create a task!

我们可以看到在创建任务时,这会创建主键键值对!

Image 31

So this is the end of Day 6. I still need to persist the sequence, probably a "Sequence" store that allows me to define different sequences, and of course, create the parent-child records and the UI behavior. Getting there!

因此,这是第6天的结束。我仍然需要保留序列,可能是“ Sequence ”存储区,该存储区允许我定义不同的序列,当然,还需要创建父子记录和UI行为。 到达那里!

第7天-序列存储和亲子关系存储 ( Day 7 - Sequence Store and the Parent-Child Relationship Store)

So a sequence store seems like a good idea. Again, this can be a concrete model and store. The model:

因此,序列存储似乎是个好主意。 同样,这可以是一个具体的模型和存储。 该模型:

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

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

The Sequence store:

Sequence存储:

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;
  }
}

and the FindRecordOfType function:

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;
}

We can write a simple test:

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

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");

and in the local storage, we see:

在本地存储中,我们看到:

Image 32

so we can now assign sequences to each of the stores:

因此,我们现在可以为每个商店分配序列:

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

Except that creating the sequence results in infinite recursion, because the sequence record is trying to get its own primary key!!!

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

Image 33

Oops!

糟糕!

The simplest way to deal with this is make the method overridable in the base class, first by refactoring the CreateRecord function:

解决此问题的最简单方法是,通过重构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;
}

Defining the default behavior:

定义默认行为:

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

and overriding it in the SequenceStore:

并在SequenceStore覆盖它:

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

Problem solved!

问题解决了!

建立协会 (Making the Association)

To make the association between parent and child record, we'll add a field to hold the selected record index in the store:

为了在父记录和子记录之间建立关联,我们将添加一个字段来保存存储中所选记录的索引:

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

And in the BindElementEvents function, where we call RecordSelected, we'll add setting this field in the store:

BindElementEvents函数中,我们称为RecordSelected ,我们将在商店中添加此字段的设置:

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

In the event handler for the button responsible for create a task note:

在负责创建任务说明的按钮的事件处理程序中:

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

We'll add a call to add the parent-child record:

我们将添加一个调用以添加父子记录:

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

With the implementation:

随着执行:

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?
  }
}

And there we have it:

我们终于得到它了:

Image 34

Now we just have to select the correct children for the selected parent. Having already defined a global variable (ugh) for declaring relationships:

现在,我们只需要为选定的父级选择正确的子级即可。 已经定义了用于声明关系的全局变量(ugh):

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

Where Relationship is defined as:

Relationship定义为:

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

We can now tie in to the same "selected" event handler to acquire the specific child relationships, remove any previous ones, and show just the specific ones for the selected record. We also don't want to go through this process every time a field in the record is selected.

现在,我们可以绑定到同一“选定的”事件处理程序,以获取特定的子关系,删除任何先前的关系,并仅显示选定记录的特定关系。 我们也不想每次选择记录中的一个字段时都要经过此过程。

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

In the ParentChildStore, we can define:

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 };
}

In the Store class, we implement:

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;
}

This returns the record indices, which we need to populate the template {idx} value so we know what record is being edited.

这将返回记录索引,我们需要填充该索引以填充模板{idx}值,这样我们才能知道正在编辑的记录。

This lovely function has the job of finding the children and populating the templates (some refactoring occurred here, for example, mapping a store to its builder):

这个可爱的功能的作用是找到子代并填充模板(此处发生了一些重构,例如,将商店映射到其构建器):

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);
      });
    });
  }
}

And it works! Clicking on Task 1, where I created 2 notes:

而且有效! 单击任务1,在其中创建了2个注释:

Image 35

Clicking on Task 2, where I created 1 note:

单击任务2,在其中创建了1个笔记:

Image 36

联络人 (Contacts)

Now let's have fun and create another child, Contacts.

现在,让我们玩得开心,并创建另一个孩子Contacts

Update the relationship map:

更新关系图:

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

Update the HTML:

更新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>

Create the contact template:

创建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" }
];

Create the store:

Create the store :

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

Create the builder:

Create the builder :

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

Assign the callbacks:

Assign the callbacks :

this.AssignStoreCallbacks(contactStore, contactBuilder);

Add the relationship:

Add the relationship :

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

Load the contacts but don't render them on the view (prevent the callback in other words):

Load the contacts but don't render them on the view (prevent the callback in other words):

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

And there we are: we've just added another child entity to Tasks!

And there we are: we've just added another child entity to Tasks!

Image 37

Now, having gone through that exercise, with the exception of the HTML to hold the contacts and the contact template itself, all the rest of the stuff we manually did can be handled with a function call, which will be Day 8. We also have to deal with deleting the relationship entry when a child is deleted, and deleting all the child relationships when a parent is deleted. Goodnight!

Now, having gone through that exercise, with the exception of the HTML to hold the contacts and the contact template itself, all the rest of the stuff we manually did can be handled with a function call, which will be Day 8. We also have to deal with deleting the relationship entry when a child is deleted, and deleting all the child relationships when a parent is deleted. 晚安!

Day 8 - Simplifying the Create View Steps (Day 8 - Simplifying the Create View Steps)

First, let's create a function that takes all those discrete setup steps and rolls them into one call with a lot of parameters:

First, let's create a function that takes all those discrete setup steps and rolls them into one call with a lot of parameters:

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;
}

This "simplifies" the creation process to four steps:

This "simplifies" the creation process to four steps:

  1. Define the template.

    Define the template.
  2. Define the container.

    Define the container.
  3. Update the relationship map.

    Update the relationship map.
  4. Create the store view.

    Create the store view.

Step 4 is now written as:

Step 4 is now written as:

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);

OK, a lot of parameters, but it's a highly repeatable pattern.

OK, a lot of parameters, but it's a highly repeatable pattern.

Next, we want to delete any relationships. The relationship needs to be deleted before the record is deleted because we need access to the __ID field, so we have to reverse the way the callback is handled in the Store to:

Next, we want to delete any relationships. The relationship needs to be deleted before the record is deleted because we need access to the __ID field, so we have to reverse the way the callback is handled in the Store to:

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

which will also allow for recursively deleting the entire hierarchy of an element when the element is deleted.

which will also allow for recursively deleting the entire hierarchy of an element when the element is deleted.

Then, in the callback handler:

Then, in the callback handler:

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

But we also have to save the store now in the route handler because the callback, which was performing the save, is being called before the record is deleted:

But we also have to save the store now in the route handler because the callback, which was performing the save, is being called before the record is deleted:

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

and the implementation in the ParentChildStore:

and the implementation in the 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();
}

with a helper function:

with a helper function:

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);
}

Day 9: Bugs (Day 9: Bugs)

So in creating a more rich relationship model:

So in creating a more rich relationship model:

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

in which Notes are children of both Projects and Tasks, a couple bugs came up.

in which Notes are children of both Projects and Tasks , a couple bugs came up.

Bug: Create a Store Only Once (Bug: Create a Store Only Once)

First is the issue that I was creating the Notes store twice, which is fixed checking if the store exists:

First is the issue that I was creating the Notes store twice, which is fixed checking if the store exists:

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: Associate the Builder with the Correct Parent-Child Context (Bug: Associate the Builder with the Correct Parent-Child Context)

Second, the builder has to be parent-child aware so that "Create Task Note" uses the Task-Note builder, not the Project-Note builder. This was easy enough (though sort of kludgy) to fix:

Second, the builder has to be parent-child aware so that " Create Task Note " uses the Task-Note builder, not the Project-Note builder. This was easy enough (though sort of kludgy) to fix:

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

And...

And...

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: Associate the CRUD Operations with the Correct Builder Context (Bug: Associate the CRUD Operations with the Correct Builder Context)

The third problem is more insidious, in the call to AssignStoreCallbacks:

The third problem is more insidious, in the call to 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);
  }
}

The problem here is that the builder is the one associated with the store when the store is first created. The bug is that because this is the Notes store for the Project-Notes builder, adding a Task-Note adds the note to the Project-Notes instead! Two things need to happen:

The problem here is that the builder is the one associated with the store when the store is first created. The bug is that because this is the Notes store for the Project-Notes builder, adding a Task-Note adds the note to the Project-Notes instead! Two things need to happen:

  1. There should only be one callback for the store.

    There should only be one callback for the store.
  2. But the builder must be specific to the "context" of the CRUD operation.

    But the builder must be specific to the "context" of the CRUD operation.

The fix for this is to pass into the store the "context" for the CRUD operations. At the moment, I'm just passing in the TemplateBuilder instance because I'm too lazy to create a Context class and I'm not sure it's needed:

The fix for this is to pass into the store the "context" for the CRUD operations. At the moment, I'm just passing in the TemplateBuilder instance because I'm too lazy to create a Context class and I'm not sure it's needed:

The upshot of it is that the CRUD callbacks now get the builder context which they pass along to the handler:

The upshot of it is that the CRUD callbacks now get the builder context which they pass along to the handler:

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);
  }
}

Two Bugs, Same Solution (Two Bugs, Same Solution)

  • Grandchild Views need to be removed when Child List changes

    Grandchild Views need to be removed when Child List changes
  • Deleting a Parent should remove Child Template Views

    Deleting a Parent should remove Child Template Views

If I create two projects with different tasks and task notes, where the task note is the grandchild, when I select a different project, the project children update (the project tasks) but the task notes remain on-screen, which leads to a lot of confusion. The function ShowChildRecords is great, but we need to remove grandchild records as the child context has changed. So this piece of code:

If I create two projects with different tasks and task notes, where the task note is the grandchild, when I select a different project, the project children update (the project tasks) but the task notes remain on-screen, which leads to a lot of confusion. The function ShowChildRecords is great, but we need to remove grandchild records as the child context has changed. So this piece of code:

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

gets an additional function call:

gets an additional function call:

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

which is implemented as:

which is implemented as:

// 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: The Selected Record is Parent-Child Dependent (Bug: The Selected Record is Parent-Child Dependent)

Note: The following thought process is WRONG! I'm keeping this in here because it was something I thought was wrong and only on further reflection did I realize it was not wrong. Unit tests would validate my belief that the writeup here is incorrect!

Note: The following thought process is WRONG! I'm keeping this in here because it was something I thought was wrong and only on further reflection did I realize it was not wrong. Unit tests would validate my belief that the writeup here is incorrect!

So here goes in the wrong thinking:

So here goes in the wrong thinking:

When a store is shared between two different parents, the selected record is specific to the parent-child relationship, not the store!

When a store is shared between two different parents, the selected record is specific to the parent-child relationship, not the store!

Question: Is Parent-Child Sufficient to Describe the Uniqueness and Entity? (Question: Is Parent-Child Sufficient to Describe the Uniqueness and Entity?)

No. For example, if I have a parent-child relationship B-C, and a hierarchy of A-B-C and D-B-C, the specific context of the records in C is associated with its relationship to B's records. And while B's context is in relationship to A's records, the selected record for the store depends on whether the entity path is A-B-C or D-B-C. Please realize that "A" and "D" different entity types, not different records of the same entity.

No. For example, if I have a parent-child relationship BC, and a hierarchy of ABC and DBC, the specific context of the records in C is associated with its relationship to B's records. And while B's context is in relationship to A's records, the selected record for the store depends on whether the entity path is ABC or DBC. Please realize that "A" and "D" different entity types , not different records of the same entity.

Even the template builder name is not a 2-level parent-child relationship. This works so far because the relationships are all uniquely defined with two levels of hierarchy. But insert another top level to the hierarchy and the template builder name's relationship to the builder (and the specific templateContainerID with which the builder is associated) fails.

Even the template builder name is not a 2-level parent-child relationship. This works so far because the relationships are all uniquely defined with two levels of hierarchy. But insert another top level to the hierarchy and the template builder name's relationship to the builder (and the specific templateContainerID with which the builder is associated) fails.

(Solution)

This means that if we don't want to keep fixing up the code, we have to have a general purpose solution to the issue of identifying:

This means that if we don't want to keep fixing up the code, we have to have a general purpose solution to the issue of identifying:

  1. The correct builder

    The correct builder
  2. The selected record

    The selected record

as they are associated with the entity type hierarchy, no matter how deep. Keep in mind that the parent-child relationship model is still valid because it is associating relationships between parent and child entity instances whereas the builder and UI management is working often with the entity type hierarchy.

as they are associated with the entity type hierarchy, no matter how deep. Keep in mind that the parent-child relationship model is still valid because it is associating relationships between parent and child entity instances whereas the builder and UI management is working often with the entity type hierarchy.

Why This is Not a Bug (Why This is Not a Bug)

First, when we load the records of parent-child relationship, it is qualified by the parent ID, which is unique:

First, when we load the records of parent-child relationship, it is qualified by the parent ID, which is unique:

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

and in the GetChildInfo function:

and in the GetChildInfo function:

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

But What is a Bug is This (But What is a Bug is This)

In the above two items, "the correct builder" and "the selected record", the correct builder must be determined by the entity type hierarchy which needs the full path to determine the template container, but the selected record is associated with the instance and so is not actually the issue.

In the above two items, "the correct builder" and "the selected record", the correct builder must be determined by the entity type hierarchy which needs the full path to determine the template container, but the selected record is associated with the instance and so is not actually the issue.

The code identifies the appropriate builder, which includes the HTML container template name, using:

The code identifies the appropriate builder, which includes the HTML container template name, using:

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

which is determined by:

which is determined by:

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

So here, we see that the builder associated with B-C does not have enough information to determine the template container for A-B-C vs. D-B-C. And that's where the real bug is. The upshot of this is that it's very important to distinguish between type and instance.

So here, we see that the builder associated with BC does not have enough information to determine the template container for ABC vs. DBC. And that's where the real bug is. The upshot of this is that it's very important to distinguish between type and instance .

This will be addressed in Day 12, The Parent-Child Template Problem.

This will be addressed in Day 12, The Parent-Child Template Problem.

Nicety: Focus on First Field when Adding a Record (Nicety: Focus on First Field when Adding a Record)

Trying to avoid unnecessary clicks, this:

Trying to avoid unnecessary clicks, this:

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

when called here:

when called here:

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

makes life a lot nicer.

makes life a lot nicer.

Day 10: A Few More Niceties (Day 10: A Few More Niceties)

So I've also added links at the project and task level so I can reference internal and online links that are related to the project:

So I've also added links at the project and task level so I can reference internal and online links that are related to the project:

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

And the related HTML and template were created as well.

And the related HTML and template were created as well.

This is How Life Should Work (This is How Life Should Work)

Just now, I also decided I wanted to add "Title" to the Contact. So all I did was add this line to the contactTemplate:

Just now, I also decided I wanted to add " Title " to the Contact. So all I did was add this line to the contactTemplate :

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

Done. What didn't have to happen was that I didn't have to change some model definition of the client-side. And of course, I didn't have to implement a DB-schema migration, and I didn't have to change some EntityFramework or Linq2SQL entity model in C#. Frankly, when I add server-side database support, I still don't want to do any of that stuff! I should be able to touch one place and one place only: the template that describes what fields I want to see and where they are. Everything else should just figure out how to adjust.

做完了 What didn't have to happen was that I didn't have to change some model definition of the client-side. And of course, I didn't have to implement a DB-schema migration, and I didn't have to change some EntityFramework or Linq2SQL entity model in C#. Frankly, when I add server-side database support, I still don't want to do any of that stuff! I should be able to touch one place and one place only: the template that describes what fields I want to see and where they are. Everything else should just figure out how to adjust.

Day 11: Colorizing Status (Day 11: Colorizing Status)

This is a bit of a hack, but I want to visually indicate the status of a project and task by colorizing the dropdown:

This is a bit of a hack, but I want to visually indicate the status of a project and task by colorizing the dropdown:

Image 38

This didn't take all day, it's just the time I had available.

This didn't take all day, it's just the time I had available.

Implemented by handling the change, focus, and blur events -- when the dropdown gets focus, it goes back to white so the entire selection list doesn't have the background color of the current status:

Implemented by handling the change , focus , and blur events -- when the dropdown gets focus, it goes back to white so the entire selection list doesn't have the background color of the current status:

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;

and when the record view is created:

and when the record view is created:

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

Day 12 - The Parent-Child Template Problem (Day 12 - The Parent-Child Template Problem)

So this:

So this:

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

is a hack. The global variables are also a hack, as is storing the selected record index in the store -- it should be associated with the view controller for that store, not the store! Hacks should be revisited or not even implemented in the first place! The whole problem here is that the element events are not coupled with an object that retains information about the "event trigger", if you will, and therefore determining the builder associated with the event became a hack. What's needed here is a container for the binder, template ID, etc., that is bound to the specific UI events for that builder - in other words, a view controller.

is a hack. The global variables are also a hack, as is storing the selected record index in the store -- it should be associated with the view controller for that store , not the store! Hacks should be revisited or not even implemented in the first place! The whole problem here is that the element events are not coupled with an object that retains information about the "event trigger", if you will, and therefore determining the builder associated with the event became a hack. What's needed here is a container for the binder, template ID, etc., that is bound to the specific UI events for that builder - in other words, a view controller.

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;
}

Note a couple things here:

Note a couple things here:

  1. The selected record index is associated with the view controller.

    The selected record index is associated with the view controller.
  2. A view controller manages its list of child controllers. This ensures that in scenarios like A-B-C and D-B-C, the controllers for B and C are distinct with regards to the roots A and D.

    A view controller manages its list of child controllers. This ensures that in scenarios like ABC and DBC, the controllers for B and C are distinct with regards to the roots A and D.

Now, when a "Create..." button is clicked, the view controller passes in to the store the view controller instance:

Now, when a " Create... " button is clicked, the view controller passes in to the store the view controller instance:

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

which has the correct builder and therefore template container for entity that is being created, and while the callback is created only once per store:

which has the correct builder and therefore template container for entity that is being created, and while the callback is created only once per store:

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

passing "through" the view controller ensures that the correct template container is used:

passing "through" the view controller ensures that the correct template container is used:

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);
  }
}

Now to create the page, we do this instead:

Now to create the page, we do this instead:

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);

etc. Notice how when we create the Contacts view controller, which is a child of Projects, we pass in the parent controller, which registers the child with its parent:

etc. Notice how when we create the Contacts view controller, which is a child of Projects , we pass in the parent controller, which registers the child with its parent:

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

The child collection is used to create and remove views using the correct view controller:

The child collection is used to create and remove views using the correct view controller:

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

The global variables are eliminated because they are contained now in the view controller. If at runtime, a new view controller needs to be instantiated, this would be done by the parent view controller and it can pass in singletons such as the store manager and event router, and parent-child relationship store.

The global variables are eliminated because they are contained now in the view controller. If at runtime, a new view controller needs to be instantiated, this would be done by the parent view controller and it can pass in singletons such as the store manager and event router, and parent-child relationship store.

Day 13 - Audit Log (Day 13 - Audit Log)

Persisting to local storage is not really a viable long-term solution. While it may be useful for off-line work, we need a centralized server for the obvious - so that more than one person can access the data and so that I can access the same data from different machines. This involves a bunch of work:

Persisting to local storage is not really a viable long-term solution. While it may be useful for off-line work, we need a centralized server for the obvious - so that more than one person can access the data and so that I can access the same data from different machines. This involves a bunch of work:

Image 39

(Oh look, sub-tasks!!!)

(Oh look, sub-tasks!!!)

Store Persistence Inversion of Control (Store Persistence Inversion of Control)

So far, we have only local storage persistence, so we'll wrap the functions in this class:

So far, we have only local storage persistence, so we'll wrap the functions in this class:

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);
  }
}

Load, save, and update are then just calls into the abstracted persistence implementation:

Load , save , and update are then just calls into the abstracted persistence implementation:

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;
}

Image 40

Woohoo!

Woohoo!

Audit Log (Audit Log)

Logging the CRUD operations is actually an audit log, so we might as well call it that. This is a concrete store backed by a concrete model:

Logging the CRUD operations is actually an audit log, so we might as well call it that. This is a concrete store backed by a concrete model:

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 {};
  }
}

where the actions are:

where the actions are:

export enum AuditLogAction {
  Create,
  Update,
  Delete
}

Here's the log where I modified the project name, created a contact, then deleted the contact:

Here's the log where I modified the project name, created a contact, then deleted the contact:

Image 41

Here's an example of creating a sequence for an entity (in this case "Links") that doesn't exist yet:

Here's an example of creating a sequence for an entity (in this case " Links ") that doesn't exist yet:

Image 42

This was the result of this code change in the store regarding the function SetRecord, which is why it's overridden in the AuditLogStore.

This was the result of this code change in the store regarding the function SetRecord , which is why it's overridden in the 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;
}

So this is where we're at now:

So this is where we're at now:

Image 43

Day 14 - Server-Side Persistence (Day 14 - Server-Side Persistence)

I'm implementing the server in .NET Core so I can run it on non-Windows devices as it is really just a proxy for database operations. Plus I'm not going to use EntityFramework or Linq2Sql. And while I considered using a NoSQL database, I wanted the flexibility to create queries on the database that include table joins, and that's sort of a PITA -- not every NoSQL database engine implements the ability and I don't really want to deal with the $lookup syntax in MongoDB that I wrote about here.

I'm implementing the server in .NET Core so I can run it on non-Windows devices as it is really just a proxy for database operations. Plus I'm not going to use EntityFramework or Linq2Sql. And while I considered using a NoSQL database, I wanted the flexibility to create queries on the database that include table join s, and that's sort of a PITA -- not every NoSQL database engine implements the ability and I don't really want to deal with the $lookup syntax in MongoDB that I wrote about here .

Async Client-Side Calls (Async Client-Side Calls)

But we have a bigger problem -- AJAX calls are by nature asynchronous and I've not accounted for any asynchronous behaviors in the TypeScript application. If you were thinking about that while reading this article, you are probably giggling. So for the moment (I haven't decided if I want to make Load async as well), I've modified the store's Load function like this:

But we have a bigger problem -- AJAX calls are by nature asynchronous and I've not accounted for any asynchronous behaviors in the TypeScript application. If you were thinking about that while reading this article, you are probably giggling. So for the moment (I haven't decided if I want to make Load async as well), I've modified the store's Load function like this:

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;
}

The signature of the function in the IStorePersistence interface has to be modified to:

The signature of the function in the IStorePersistence interface has to be modified to:

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

And the LocalStoragePersistence class' Load function now looks like this:

And the LocalStoragePersistence class' Load function now looks like this:

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));
}

All is well with the world.

All is well with the world.

The CloudPersistence class then looks like this:

The CloudPersistence class then looks like this:

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;
  }
}

The concern here is that the Save and Update functions with their asynchronous AJAX calls may be not be received in the same order they are sent. This code needs to be refactored to ensure that the Asynchronous JavasScript and XML (AJAX!) is actually performed in the correct order by queuing the requests and processing them serially, waiting for the response from the server before sending the next one. Another day!

The concern here is that the Save and Update functions with their asynchronous AJAX calls may be not be received in the same order they are sent. This code needs to be refactored to ensure that the Asynchronous JavasScript and XML (AJAX!) is actually performed in the correct order by queuing the requests and processing them serially, waiting for the response from the server before sending the next one. Another day!

Server-Side Handlers (Server-Side Handlers)

On the server side (I'm not going to go into my server implementation at the moment), I register this route:

On the server side (I'm not going to go into my server implementation at the moment), I register this route:

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

and implement a route handler that returns a dummy empty array:

and implement a route handler that returns a dummy empty array:

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

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

Somewhat ironically, I also had to add:

Somewhat ironically, I also had to add:

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

because the TypeScript page is being served by one address (localhost with a port that Visual Studio assigns) and my server is sitting on localhost:80. It's interesting to watch what happens without this header -- the server gets the request but the browser blocks (throws an exception) processing the response. Sigh.

because the TypeScript page is being served by one address (localhost with a port that Visual Studio assigns) and my server is sitting on localhost:80. It's interesting to watch what happens without this header -- the server gets the request but the browser blocks (throws an exception) processing the response. 叹。

Model-less SQL (Model-less SQL)

Now we get to a decision. Typically the database schema is created as a "known schema", using some sort of model / schema synchronization, or a migrator like FluentMigrator, or just hand-coded. Personally, I have come to loathe this whole approach because it usually means:

Now we get to a decision. Typically the database schema is created as a "known schema", using some sort of model / schema synchronization, or a migrator like FluentMigrator , or just hand-coded. Personally, I have come to loathe this whole approach because it usually means:

  1. The database has a schema that requires management.

    The database has a schema that requires management.
  2. The server-side has a model that requires management.

    The server-side has a model that requires management.
  3. The client-side has a model that also requires management.

    The client-side has a model that also requires management.

My God! What ever happened to the DRY (Don't Repeat Yourself) principle when it comes to schemas and models? So I'm going to conduct an experiment. As you've noticed, there is no real model of anything on the client-side except for the couple concrete types for the audit and sequence "tables." My so-called model is actually hidden in the view templates, for example:

天哪! What ever happened to the DRY (Don't Repeat Yourself) principle when it comes to schemas and models? So I'm going to conduct an experiment. As you've noticed, there is no real model of anything on the client-side except for the couple concrete types for the audit and sequence "tables." My so-called model is actually hidden in the view templates, for example:

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" }
];

Oh look, the template for the view specifies the fields in which the view is interested. In the local storage implementation, that was quite sufficient. This would all be fine and dandy in a SQL database if I basically had a table like this:

Oh look, the template for the view specifies the fields in which the view is interested. In the local storage implementation, that was quite sufficient. This would all be fine and dandy in a SQL database if I basically had a table like this:

ID
StoreName
PropertyName
Value

Rant on. But I don't want that -- I want concrete tables with concrete columns! So I'm going to do something you are going to kick and scream about - create the tables and necessary columns on the fly, as required, so that the view templates are the "master" for defining the schema. Yes, you read that correctly. Just because the whole world programs in a way that duplicates the schema, code-behind model, and client-side model, doesn't mean I have to. Sure there's a performance hit, but we're not dealing with bulk updates here, we're dealing with asynchronous user-driven updates. The user is never going to notice and more importantly to me, I will never again have to write migrations or create tables and schemas or create C# classes that mirror the DB schema. Unless I'm doing some specific business logic on the server side, in which case the C# classes can be generated from the database schema. There was some work in F# ages ago that I encountered where the DB schema could be used to tie in Intellisense to F# objects, but sadly that has never happened in C#, and using dynamic objects has a horrid performance and no Intellisense. So, there is still a major disconnect in programming language support that "knows" the DB schema. Rant off.

Rant on. But I don't want that -- I want concrete tables with concrete columns! So I'm going to do something you are going to kick and scream about - create the tables and necessary columns on the fly, as required, so that the view templates are the "master" for defining the schema. 是的,你没看错。 Just because the whole world programs in a way that duplicates the schema, code-behind model, and client-side model, doesn't mean I have to. Sure there's a performance hit, but we're not dealing with bulk updates here, we're dealing with asynchronous user-driven updates. The user is never going to notice and more importantly to me, I will never again have to write migrations or create tables and schemas or create C# classes that mirror the DB schema. Unless I'm doing some specific business logic on the server side, in which case the C# classes can be generated from the database schema. There was some work in F# ages ago that I encountered where the DB schema could be used to tie in Intellisense to F# objects, but sadly that has never happened in C#, and using dynamic objects has a horrid performance and no Intellisense. So, there is still a major disconnect in programming language support that "knows" the DB schema. Rant off.

Tomorrow.

明天。

Day 15 - Creating the Schema on the Fly (Day 15 - Creating the Schema on the Fly)

Before getting into this, one minor detail is needed - a user ID that is associated with the AJAX calls so data can be separated by user. For testing, we'll use:

Before getting into this, one minor detail is needed - a user ID that is associated with the AJAX calls so data can be separated by user. For testing, we'll use:

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

There is no login or authentication right now, but it's useful to put this into the coding now rather than later.

There is no login or authentication right now, but it's useful to put this into the coding now rather than later.

So now, our cloud persistence Load function looks like this:

So now, our cloud persistence Load function looks like this:

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;
}

Send the Audit Log (Send the Audit Log)

The Save function sends the current state of the audit log:

The Save function sends the current state of the audit log:

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();
}

Note how the log is cleared once we have sent it!

Note how the log is cleared once we have sent it!

Save the Audit Log (Save the Audit Log)

A special function is required to actually send the audit log itself because it is not in the form "action-property-value", it is a concrete entity:

A special function is required to actually send the audit log itself because it is not in the form " action-property-value ", it is a concrete entity:

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

Load the Current Schema (Load the Current Schema)

On the server side, we load what we know about the schema:

On the server side, we load what we know about the schema:

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;
}

Create Stores (Tables) and Columns on the Fly (Create Stores (Tables) and Columns on the Fly)

Then we have to create the stores on the fly as needed:

Then we have to create the stores on the fly as needed:

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);
}

Save the Audit Log (Save the Audit Log)

And finally, we process the audit log on save:

And finally, we process the audit log on save:

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;
  }
}

Update the Schema on the Fly (Update the Schema on the Fly)

Notice the call to UpdateSchema! This is where the magic happens, that if a field in the table hasn't been encountered before, we create it on the fly!

Notice the call to UpdateSchema ! This is where the magic happens, that if a field in the table hasn't been encountered before, we create it on the fly!

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);
  }
}

Et voilà!

等等!

Image 44

At this point, I haven't entered anything for the TODO and Description fields, so the schema doesn't know they exist:

At this point, I haven't entered anything for the TODO and Description fields, so the schema doesn't know they exist:

Image 45

After I fill in the data:

After I fill in the data:

Image 46

The schema has been modified because these additional columns were part of the audit log!

The schema has been modified because these additional columns were part of the audit log!

Image 47

And we can see the audit log entries logged as well for the changes I just made:

And we can see the audit log entries logged as well for the changes I just made:

Image 48

And all the tables that were created on the fly (except for the AuditLogStore table):

And all the tables that were created on the fly (except for the AuditLogStore table):

Image 49

Day 16 - More Bugs (Day 16 - More Bugs)

Bug in How Entity __ID is Working (Bug in How Entity __ID is Working)

After a page refresh, I discovered that the sequencer was creating the next number (let's say we're at a count of 2) as "21", then "211", then "2111". This is a problem with the fact that there is no type information, so on a page refresh, the "number" was coming in as a string and this line of code:

After a page refresh, I discovered that the sequencer was creating the next number (let's say we're at a count of 2 ) as " 21 ", then " 211 ", then " 2111 ". This is a problem with the fact that there is no type information, so on a page refresh, the "number" was coming in as a string and this line of code:

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

ended up appending the character 1, not incrementing the count. As long as I didn't refresh the page in my testing, everything worked fine. Refresh the page and new parent-child relationships stopped working! The workaround, lacking type information to serialize the count as a number in JSON rather than as a string, is:

ended up appending the character 1, not incrementing the count. As long as I didn't refresh the page in my testing, everything worked fine. Refresh the page and new parent-child relationships stopped working! The workaround, lacking type information to serialize the count as a number in JSON rather than as a string , is:

// 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;

The next problem was that the audit log wasn't passing the correct client-side "primary key" (the __ID field), which occurred after deleting records. This code:

The next problem was that the audit log wasn't passing the correct client-side "primary key" (the __ID field), which occurred after deleting records. This code:

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);

worked fine as long as the record index (the indexer into the store's data) was in sync with the sequence counter. When they became out of sync, after deleting records and doing a page refresh, again the new entities being created were saved with an __ID starting at 1 again! The sequence count was ignored. The fix was to get the client-side __ID, as this is the primary key to the record on the server, which is not the primary key if the table:

worked fine as long as the record index (the indexer into the store's data) was in sync with the sequence counter. When they became out of sync, after deleting records and doing a page refresh, again the new entities being created were saved with an __ID starting at 1 again! The sequence count was ignored. The fix was to get the client-side __ID , as this is the primary key to the record on the server, which is not the primary key if the table:

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);

After making that change, persisting changes to the sequencer stopped working because it didn't even have an __ID, so my thinking was wrong there -- it definitely needs and __ID so that the SetRecord function works and after creating a relationship, the appropriate fields in the parent-child store get updated correctly:

After making that change, persisting changes to the sequencer stopped working because it didn't even have an __ID , so my thinking was wrong there -- it definitely needs and __ID so that the SetRecord function works and after creating a relationship, the appropriate fields in the parent-child store get updated correctly:

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;
}

The fix involved changing this override in the SequenceStore:

The fix involved changing this override in the SequenceStore :

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

to this:

对此:

// 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 };
}

Good grief. That was not amusing.

真是的 That was not amusing.

Revisiting this mess:

Revisiting this mess:

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);
}

It would probably behoove me to create a concrete model for the ParentChildRelationships store as right now it's being created on the fly and lacking type information, the parentId and childId fields are being created in nvarchar:

It would probably behoove me to create a concrete model for the ParentChildRelationships store as right now it's being created on the fly and lacking type information, the parentId and childId fields are being created in nvarchar :

Image 50

I can certainly appreciate the need to have an actual model definition for each server-side table and client-side usage, but I really don't want to go down that route! However, it would actually be useful to create an index on the (UserId, __ID) field pair as the update and delete operations always use this pair to identify the record:

I can certainly appreciate the need to have an actual model definition for each server-side table and client-side usage, but I really don't want to go down that route! However, it would actually be useful to create an index on the (UserId, __ID) field pair as the update and delete operations always use this pair to identify the record:

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);
}

Forgot to Register the Common Fields Bug (Forgot to Register the Common Fields Bug)

Another bug surfaced which I missed in the console log -- when creating a table, the in-memory schema on the server side wasn't updating the fields UserId and __ID after creating the table. The fix was straight forward, though I don't like the decoupling between the call to CreateTable and adding in the two fields that CreateTable creates:

Another bug surfaced which I missed in the console log -- when creating a table, the in-memory schema on the server side wasn't updating the fields UserId and __ID after creating the table. The fix was straight forward, though I don't like the decoupling between the call to CreateTable and adding in the two fields that CreateTable creates:

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" });
  }
}

I probably didn't notice this for ages because I hadn't dropped all the tables to create a clean slate in quite a while, at least until I modified the code above to create the indexes! Sigh. I really need to create unit tests.

I probably didn't notice this for ages because I hadn't dropped all the tables to create a clean slate in quite a while, at least until I modified the code above to create the indexes! 叹。 I really need to create unit tests.

Bonus Day 17 - Entity Menu Bar (Bonus Day 17 - Entity Menu Bar)

Originally, I wanted a side-menu bar that would determine what child entities were visible. While this still seemed like a good idea, I really wasn't sure how it would work. I did know one thing though -- the screen gets quite cluttered with a lot of projects and the views for the children and sub-children, which now includes:

Originally, I wanted a side-menu bar that would determine what child entities were visible. While this still seemed like a good idea, I really wasn't sure how it would work. I did know one thing though -- the screen gets quite cluttered with a lot of projects and the views for the children and sub-children, which now includes:

  • Project Bugs

    Project Bugs
  • Project Contacts

    Project Contacts
  • Project Notes

    Project Notes
  • Project Links

    Project Links
  • Project Tasks

    Project Tasks
  • Task Notes

    Task Notes
  • Task Links

    Task Links
  • Sub-Tasks

    Sub-Tasks

Not only is the screen cluttered but it's also difficult to see what project is selected, and as the project list grows bigger, vertical scrolling will take place which is an added annoyance to seeing the children of a project and potentially their grandchildren, etc. What I needed was a way to focus on a specific project and then de-focus when switching projects. And I wanted it to be easy to focus and de-focus the project without adding additional buttons like "Show Project Details" and "Back to Project List", or some such silliness, especially since this would cascade for children of children, like "Show Task Details" and "Back to Tasks." So after staring at the UI for a good hour in contemplation (I kid you not, though I did have an interesting conversation at the Farm Store during this time with a total stranger, and I was at the Farm Store because the winds had created an 8 hour power outage on Friday, and did you really read this and did you really click on the Hawthorne Valley Farm Store link?) I opted for the following behavior:

Not only is the screen cluttered but it's also difficult to see what project is selected, and as the project list grows bigger, vertical scrolling will take place which is an added annoyance to seeing the children of a project and potentially their grandchildren, etc. What I needed was a way to focus on a specific project and then de-focus when switching projects. And I wanted it to be easy to focus and de-focus the project without adding additional buttons like " Show Project Details " and " Back to Project List ", or some such silliness, especially since this would cascade for children of children, like " Show Task Details " and " Back to Tasks ." So after staring at the UI for a good hour in contemplation (I kid you not, though I did have an interesting conversation at the Farm Store during this time with a total stranger, and I was at the Farm Store because the winds had created an 8 hour power outage on Friday, and did you really read this and did you really click on the Hawthorne Valley Farm Store link?) I opted for the following behavior:

  • Clicking on any control of a specific entity's record will hide all other sibling entities. This removes all siblings so I know exactly what entity I'm working with, and workings regardless of where I am in the entity hierarchy.

    Clicking on any control of a specific entity's record will hide all other sibling entities. This removes all siblings so I know exactly what entity I'm working with, and workings regardless of where I am in the entity hierarchy.
  • Clicking on the first control (which I would think is almost always an edit box but that remains to be seen) de-selects that entity and shows all siblings again. (Deleting an entity will do the same thing.)

    Clicking on the first control (which I would think is almost always an edit box but that remains to be seen) de-selects that entity and shows all siblings again. (Deleting an entity will do the same thing.)
  • Now, here's the fun part -- depending on what entities you've selected in the menu bar, only those children are shown when you "focus" on a parent entity.

    Now, here's the fun part -- depending on what entities you've selected in the menu bar, only those children are shown when you "focus" on a parent entity.
  • De-selecting the focused entity will hide child entities that have been selected in the menu bar.

    De-selecting the focused entity will hide child entities that have been selected in the menu bar.

To illustrate, here's a sample project list (really original naming here):

To illustrate, here's a sample project list (really original naming here):

Image 51

Click on an entity (such as "01 P") and you see:

Click on an entity (such as " 01 P ") and you see:

Image 52

That's it! The siblings have been hidden. Click on the first control, in this case the edit box containing the text "01 P", and it becomes de-selected and all the siblings are shown again. As stated above, this works anywhere in the hierarchy.

而已! The siblings have been hidden. Click on the first control, in this case the edit box containing the text " 01 P ", and it becomes de-selected and all the siblings are shown again. As stated above, this works anywhere in the hierarchy.

Now here's the entity menu bar:

Now here's the entity menu bar:

Image 53

I'll clicking on Tasks in the menu bar and, assuming "01 P" is selected, I get its tasks:

I'll clicking on Tasks in the menu bar and, assuming " 01 P " is selected, I get its tasks:

Image 54

Now I'll also select "Sub-Tasks":

Now I'll also select " Sub-Tasks ":

Image 55

Notice the "Create Sub-Task" button, which is actually a bug because I shouldn't be able to create a child without a parent being selected. But regardless, notice that I haven't selected a task. As soon as I select a task, its sub-tasks appear:

Notice the " Create Sub-Task " button, which is actually a bug because I shouldn't be able to create a child without a parent being selected. But regardless, notice that I haven't selected a task. As soon as I select a task, its sub-tasks appear:

Image 56

I'm finding this UI behavior quite comfortable:

I'm finding this UI behavior quite comfortable:

  • I can select just the entity I want to work with.

    I can select just the entity I want to work with.
  • I can select just the child entities I want to see in the selected entity.

    I can select just the child entities I want to see in the selected entity.
  • I can easily de-select seeing the child entities.

    I can easily de-select seeing the child entities.
  • I can easily go back to seeing the entire list of siblings.

    I can easily go back to seeing the entire list of siblings.
  • I can easily see what entities in the hierarchy I've selected to see when I select the parent entity.

    I can easily see what entities in the hierarchy I've selected to see when I select the parent entity.

To accomplish all this, in the HTML I added:

To accomplish all this, in the HTML I added:

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

and in the application initialization:

and in the application initialization:

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");

The menu bar and menu items are defined in TypeScript as:

The menu bar and menu items are defined in TypeScript as:

import { MenuBarItem } from "./MenuBarItem"

export interface MenuBar extends Array<MenuBarItem> { }

and:

和:

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

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

The more interesting part of this is how MenuBarViewController interacts with the ViewController -- I really should rename that to be the EntityViewController! Notice in the constructor a couple event routes being defined:

The more interesting part of this is how MenuBarViewController interacts with the ViewController -- I really should rename that to be the EntityViewController ! Notice in the constructor a couple event routes being defined:

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));
}

The two key handlers are:

The two key handlers are:

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);
  });
}

Now, in the entity view controller, I changed jel.on('focus', (e) => { to: jel.on('click', (e) => for when the user focuses/clicks on an entity's control. Clicking on an entity's control has the added behavior now of showing and hiding siblings as well as child entities based on the menu bar selection:

Now, in the entity view controller, I changed jel.on('focus', (e) => { to: jel.on('click', (e) => for when the user focuses/clicks on an entity's control. Clicking on an entity's control has the added behavior now of showing and hiding siblings as well as child entities based on the menu bar selection:

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);
  }
}

And that was it!

就是这样!

Running the Application (Running the Application)

If you want to run the application using local storage, in AppMain.js, make sure the code reads:

If you want to run the application using local storage, in AppMain.js , make sure the code reads:

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

If you want to run the application using a database:

If you want to run the application using a database:

  1. Create a database called TaskTracker. Yeah, that's it, you don't have to define any of the tables, they are created for you.

    Create a database called TaskTracker . Yeah, that's it, you don't have to define any of the tables, they are created for you.

  2. In the server application, Program.cs, set up your connection string: private static string connectionString = "[your connection string]";

    In the server application, Program.cs , set up your connection string: private static string connectionString = "[your connection string]";

  3. Open a command window "as administrator" and cd to the root of the server application, then type "run". This builds .NET Core application and launches the server.

    Open a command window "as administrator" and cd to the root of the server application, then type " run ". This builds .NET Core application and launches the server.

  4. To exit the server, press Ctrl+C (I have a bug shutting down the server!)

    To exit the server, press Ctrl+C (I have a bug shutting down the server!)

  5. If you need to change the IP address or port, do so in the TypeScript (see above) and in the server application.

    If you need to change the IP address or port, do so in the TypeScript (see above) and in the server application.

And enable the cloud persistence:

And enable the cloud persistence:

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

结论 (Conclusion)

So this article is huge. You should probably read it one day at a time! And it's also crazy -- this is metadata driven, view defines the model, schema generated on the fly, bizarre approach to building an application. There's a lot to do still to make this even more interesting such as storing the template view definitions and HTML in the database specific to the user, giving the user the flexibility to customize the entire presentation. The UI is ugly as sin, but it actually does the job quite nicely for what I wanted to accomplish -- organizing projects, tasks, contacts, links, bugs, and notes in a way that is actually useful to, well, me! Other serious warts exist, such as all fields are created as nvarchar since we don't have type information!

So this article is huge. You should probably read it one day at a time! And it's also crazy -- this is metadata driven, view defines the model, schema generated on the fly, bizarre approach to building an application. There's a lot to do still to make this even more interesting such as storing the template view definitions and HTML in the database specific to the user, giving the user the flexibility to customize the entire presentation. The UI is ugly as sin, but it actually does the job quite nicely for what I wanted to accomplish -- organizing projects, tasks, contacts, links, bugs, and notes in a way that is actually useful to, well, me! Other serious warts exist, such as all fields are created as nvarchar since we don't have type information!

I hope you had fun reading this, maybe some of the ideas here are interesting if not jarring, and I'll expect to follow up with some more interesting features in the future, such as synchronizing the local store with the cloud store, which really is broken right now because the audit trail is cleared whenever a "store save" is done. Oops! Another thing I want to take a look at is the fact that I'm loading all the user's "store" data on client startup - it would be more interesting to load only the child data relevant to the selected project. Basically, a mechanism to say "if I don't have these records, get them now."

I hope you had fun reading this, maybe some of the ideas here are interesting if not jarring, and I'll expect to follow up with some more interesting features in the future, such as synchronizing the local store with the cloud store, which really is broken right now because the audit trail is cleared whenever a "store save" is done. 糟糕! Another thing I want to take a look at is the fact that I'm loading all the user's "store" data on client startup - it would be more interesting to load only the child data relevant to the selected project. Basically, a mechanism to say "if I don't have these records, get them now."

Lastly, if you're interested in watching how this project develops, I'll be posting updates to the repo on GitHub.

Lastly, if you're interested in watching how this project develops, I'll be posting updates to the repo on GitHub .

Well, anyways, that's all for now folks!

Well, anyways, that's all for now folks!

翻译自: https://www.codeproject.com/Articles/5250141/16-Days-A-TypeScript-Application-from-Concept-to

小程序typescript

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值