This screenshot is only a subset of what's been implemented here. On the right are additional links associated with projects and tasks:
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):
<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."
And as you've probably come to expect, I explore a couple new concepts as well:
- Except for concrete models, there entire "model" concept is thrown out the window. The view defines the model!
- 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!
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.
Some rough sketches:
- 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.
- 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.
- 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.
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.
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.
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.
What I do want is:
- What is the task?
- What is its state?
- 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):
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!
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.
So we can define sub-structures, or allowable child records, like this using the Task
entity as an example:
[
{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
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!
With some coding, I get this:
Defined by the template array:
let 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:
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:
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.
Which brings us to how the combobox
es 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:
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;
orderBy?: string;
}
and the prototype concept of a store:
interface KeyStoreMap {
[key: string] : any;
}
export class Store {
stores: KeyStoreMap = {};
public AddLocalStore(key: string, store: any) {
this.stores[key] = store;
}
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:
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:
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:
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;
}
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:
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 {
let newHtml = html.split("{idx}").join(idx.toString());
return newHtml;
}
and lo-and-behold, we have indices now to the store, for example:
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!
It's 10:30 PM, I'm calling it a night!
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:
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(() => {
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!
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:
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 {
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:
break;
case StoreType.RestCall:
break;
case StoreType.LocalStorage:
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!
After entering some values:
we can see that these have been serialized to the local storage (inspecting local storage in Chrome):
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:
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:
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:
public SetProperty(idx: number, property: string, value: any): Store {
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:
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:
public Save(): Store {
switch (this.storeType) {
case StoreType.InMemory:
break;
case StoreType.RestCall:
break;
case StoreType.LocalStorage:
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:
And when there is data, it's not obliterated by refreshing the page:
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:
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!
This yields on page load:
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.
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.
To the Store
class, I'll add a couple callbacks with default "do nothing" handlers:
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!):
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.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);
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:
taskStore.SetProperty(1, "Task", `Random Task #${Math.floor(Math.random() * 100)}`);
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:
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.
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:
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:
public GetRawData(): {}[] {
return jQuery.map(this.data, value => value);
}
private SaveToLocalStorage() {
let json = JSON.stringify(this.GetRawData());
window.localStorage.setItem(this.storeName, json);
}
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:
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):
Now let's reduce the width of the "Why
" textbox and add a "Delete
" button to the template definition:
{
field: "Why",
line: 1,
width: "80%",
control: "textbox",
},
{
text: "Delete",
line: 1,
width: "20%",
control: "button",
}
And adding a Button
method to the TemplateBuilder
:
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:
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:
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:
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:
{
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:
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:
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:
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. - That this removes the entire
div
as opposed to, say, clearing the fields or removing a row from a grid
, this works nicely. - That the
Save
call doesn't have a clue as to how to send a REST call to delete the specific record.
We can mosey on along and after clicking on the delete button for second task, T2, we now see:
and our local storage looks like this:
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:
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:
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);
}
}
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:
<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);
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
:
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:
I created two tasks, note how they are in reverse order because tasks are prepended in the UI:
and we can see that they are appended in the local storage:
This, of course, causes a problem when the page is refreshed:
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!
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:
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:
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:
<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:
and they persist quite nicely in the local storage as well:
To figure out next:
- 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.
- Clicking on the parent (the task in our case) should bring up the specific child records.
- 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? - 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.
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:
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.
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.
And (horrors), similar to ExtJS, we actually have a concrete ParentChildStore
which will have a function for acquiring a unique number 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 {
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:
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:
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();
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!
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!
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:
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:
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:
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!!!
Oops!
The simplest way to deal with this is make the method overridable in the base class, first by refactoring the CreateRecord
function:
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
:
protected GetPrimaryKey(): {} {
return {};
}
Problem solved!
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;
And in the BindElementEvents
function, where we call RecordSelected
, we'll add setting this field in the store:
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);
noteStore.Save();
});
We'll add a call to add the parent-child record:
jQuery("#createTaskNote").on('click', () => {
let idx = eventRouter.Route("CreateRecord", noteStore, 0);
parentChildRelationshipStore.AddRelationship(taskStore, noteStore, idx);
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 {
}
}
And there we have it:
Now we just have to select the correct children for the selected parent. Having already defined a global variable (ugh) for declaring relationships:
var relationships : Relationship = [
{
parent: "Tasks",
children: ["Notes"]
}
];
Where Relationship
is defined as:
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:
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);
let recs = childStore.FindRecords(r => childRecIds.indexOf((<any>r).__ID) != -1);
return { store: childStore, childrenIndices: recs };
}
In the Store
class, we implement:
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.
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);
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:
Clicking on Task 2, where I created 1 note:
Now let's have fun and create another child, Contacts
.
Update the relationship map:
var relationships : Relationship[] = [
{
parent: "Tasks",
children: ["Contacts", "Notes"]
}
];
Update the 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
:
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
:
let contactStore = storeManager.CreateStore("Contacts", StoreType.LocalStorage);
Create the builder
:
let contactBuilder = this.CreateHtmlTemplate
("#contactTemplateContainer", contactTemplate, storeManager, contactStore.storeName);
Assign the callbacks
:
this.AssignStoreCallbacks(contactStore, contactBuilder);
Add the relationship
:
jQuery("#createTaskContact").on('click', () => {
let idx = eventRouter.Route("CreateRecord", contactStore, 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):
taskStore.Load();
noteStore.Load(false);
contactStore.Load(false);
And there we are: we've just added another child entity to Tasks!
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!
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);
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:
- Define the template.
- Define the container.
- Update the relationship map.
- Create the store view.
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.
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.
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:
eventRouter.AddRoute("DeleteRecord", (store, idx) => {
store.DeleteRecord(idx);
store.Save();
});
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[] = [];
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);
parents.forEach(p => {
this.DeleteChildrenOfParent(p, touchedStores);
});
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}`);
}
touchedStores.forEach(s => this.storeManager.GetStore(s).Save());
this.Save();
}
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);
if (recIdx != -1) {
childStore.DeleteRecord(recIdx);
if (touchedStores.indexOf(childStoreName) == -1) {
touchedStores.push(childStoreName);
}
} else {
console.log(`Expected to find record in store ${childStoreName} with __ID = ${childId}`);
}
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);
}
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.
First is the issue that I was creating the Notes
store twice, which is fixed checking if the store
exists:
private CreateStoreViewFromTemplate(
...
): Store {
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);
}
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...
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 };
...
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:
- There should only be one callback for the store.
- 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 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);
}
}
- Grandchild Views need to be removed when Child List changes
- 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:
jel.on('focus', () => {
if (store.selectedRecordIndex != recIdx) {
this.RecordSelected(builder, recIdx);
store.selectedRecordIndex = recIdx;
this.ShowChildRecords(store, recIdx, relationships);
}
});
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:
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);
});
}
});
}
}
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:
When a store is shared between two different parents, the selected record is specific to the parent-child relationship, not the store!
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.
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.
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:
- The correct builder
- 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.
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:
let childRecs = this.FindRecordsOfType<ParentChildRelationshipModel>
(rel => rel.parent == parent && rel.parentId == parentId && rel.child == child);
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:
let builderName = this.GetBuilderName(parentStoreName, child);
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.
This will be addressed in Day 12, The Parent-Child Template Problem.
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:
store.recordCreatedCallback = (idx, record, insert, store, builder) => {
this.CreateRecordView(builder, store, idx, insert);
this.FocusOnFirstField(builder, idx);
};
makes life a lot nicer.
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.
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.
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 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:
case "combobox":
jel.on('change', () => {
let val = this.SetPropertyValue(builder, jel, el, recIdx);
this.SetComboboxColor(jel, val);
});
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:
private CreateRecordView(builder: TemplateBuilder, store: Store,
idx: number, insert: boolean): void {
...
if (tel.item.control == "combobox") {
this.SetComboboxColor(jel, val);
}
}
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.
export class ViewController {
storeManager: StoreManager;
parentChildRelationshipStore: ParentChildStore;
builder: TemplateBuilder;
eventRouter: EventRouter;
store: Store;
childControllers: ViewController[] = [];
selectedRecordIndex: number = -1;
constructor(storeManager: StoreManager,
parentChildRelationshipStore: ParentChildStore, eventRouter: EventRouter) {
this.storeManager = storeManager;
this.parentChildRelationshipStore = parentChildRelationshipStore;
this.eventRouter = eventRouter;
}
Note a couple things here:
- The selected record index is associated with the view controller.
- 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.
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);
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:
private AssignStoreCallbacks(): void {
this.store.recordCreatedCallback = (idx, record, insert, store, onLoad, viewController) => {
viewController.CreateRecordView(this.store, idx, insert, onLoad);
if (!onLoad) {
viewController.FocusOnFirstField(idx);
}
};
this.store.propertyChangedCallback =
(idx, field, value) => this.UpdatePropertyView(idx, field, value);
this.store.recordDeletedCallback = (idx, store, viewController) => {
viewController.RemoveChildRecordsView(store, idx);
viewController.parentChildRelationshipStore.DeleteRelationship(store, idx);
viewController.DeleteRecordView(idx);
}
}
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:
if (parentViewController) {
parentViewController.RegisterChildController(this);
}
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.
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:
(Oh look, sub-tasks!!!)
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 {
let records: {}[] = JSON.parse(json);
records.forEach((record, idx) => data[idx] = record);
} catch (ex) {
console.log(ex);
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:
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;
}
Woohoo!
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;
}
public SetRecord(idx: number, record: {}): Store {
this.CreateRecordIfMissing(idx);
this.data[idx] = record;
return this;
}
protected GetPrimaryKey(): {} {
return {};
}
}
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 an example of creating a sequence for an entity (in this case "Links
") that doesn't exist yet:
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:
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.
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:
Load(storeName: string): Promise<RowRecordMap>;
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 {
let records: {}[] = JSON.parse(json);
records.forEach((record, idx) => data[idx] = record);
} catch (ex) {
console.log(ex);
window.localStorage.removeItem(storeName);
}
}
return new Promise((resolve, reject) => resolve(data));
}
All is well with the world.
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 = {};
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!
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:
private static IRouteResponse Load(LoadStore store)
{
Console.WriteLine($"Load store {store.StoreName}");
return RouteResponse.OK(new string[] {});
}
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.
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:
- The database has a schema that requires management.
- The server-side has a model that 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:
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:
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.
Tomorrow.
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.
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 = {};
records.forEach((record, _) => data[record.__ID] = record);
return data;
}
The Save
function sends the current state of the audit log:
public Save(storeName: string, data: RowRecordMap): void {
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!
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);
}
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;
}
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)
{
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)
{
string sql = $"ALTER TABLE [{storeName}] ADD [{fieldName}] NVARCHAR(255) NULL";
Execute(conn, sql);
}
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())
{
lock (schemaLocker)
{
UpdateSchema(conn, logs);
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;
}
}
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)
{
logs.Select(l => l.StoreName).Distinct().ForEach(sn => CheckForTable(conn, sn));
foreach (var log in logs.Where
(l => !String.IsNullOrEmpty(l.Property)).DistinctBy(l => l, tableFieldComparer))
{
CheckForField(conn, log.StoreName, log.Property);
}
}
Et voilĂ !
At this point, I haven't entered anything for the TODO and Description fields, so the schema doesn't know they exist:
After I fill in the data:
The schema has been modified because these additional columns were part of the audit log!
And we can see the audit log entries logged as well for the changes I just made:
And all the tables that were created on the fly (except for the AuditLogStore
table):
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:
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:
public Log(storeName: string, action: AuditLogAction,
recordIndex: number, property?: string, value?: any): void {
let recIdx = this.InternalCreateRecord();
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:
public Log(storeName: string, action: AuditLogAction,
recordIndex: number, property?: string, value?: any): void {
let recIdx = this.InternalCreateRecord();
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:
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
:
protected GetPrimaryKey(): {} {
return {};
}
to this:
protected GetNextPrimaryKey(): {} {
let id = Object.keys(this.data).length;
return { __ID: id };
}
Good grief. That was not amusing.
Revisiting this mess:
private static void CreateTable(SqlConnection conn, string storeName)
{
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
:
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)
{
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);
}
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.
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 Contacts
- Project Notes
- Project Links
- Project Tasks
- Task Notes
- Task Links
- 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:
- 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.)
- 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.
To illustrate, here's a sample project list (really original naming here):
Click on an entity (such as "01 P
") and you see:
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.
Now here's the entity menu bar:
I'll clicking on Tasks in the menu bar and, assuming "01 P
" is selected, I get its tasks:
Now I'll also select "Sub-Tasks":
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:
I'm finding this UI behavior quite comfortable:
- 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 easily de-select seeing the child entities.
- 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.
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:
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:
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;
selected?: boolean;
}
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:
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:
if (this.selectedRecordIndex != recIdx) {
this.RemoveChildRecordsView(this.store, this.selectedRecordIndex);
this.RecordSelected(recIdx);
this.selectedRecordIndex = recIdx;
this.ShowChildRecords(this.store, recIdx);
this.HideSiblingsOf(templateContainer);
this.eventRouter.Route("MenuBarShowSections", undefined, undefined, this);
} else {
let firstElement = jQuery(e.currentTarget).parent()[0] ==
jQuery(e.currentTarget).parent().parent().children()[0];
if (firstElement) {
this.ShowSiblingsOf(templateContainer);
this.RemoveChildRecordsView(this.store, this.selectedRecordIndex);
this.RecordUnselected(recIdx);
this.selectedRecordIndex = -1;
this.eventRouter.Route("MenuBarHideSections", undefined, undefined, this);
}
}
And that was it!
If you want to run the application using local storage, in AppMain.js, make sure the code reads:
let persistence = new LocalStoragePersistence();
If you want to run the application using a database:
- Create a database called
TaskTracker
. Yeah, that's it, you don't have to define any of the tables, they are created for you. - In the server application, Program.cs, set up your connection string:
private static string connectionString = "[your connection string]";
- 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. - To exit the server, press Ctrl+C (I have a bug shutting down the server!)
- 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:
let persistence = new CloudPersistence("http://127.0.0.1/", userId);
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."
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!