Introduction
I was reading an article recently on ways to add value to a business and one of the key things talked about was automation. The concept is simple, if you find yourself doing something
over and over, find a way to automate it and you save ongoing time, thus adding value to the business. Bringing batches of new users into a system is one of those things that
kill a DevOp's time, so I decided to play with KnockoutJS to see if it could help. Turns out it can! The code is presented and can be downloaded as a C# MVC project,
but can easily be stripped out and used independently. There are enough tutorials out on the Interwebs to explain how to use Knockout - go
Google up the details.
This article is focused on how I used Knockout to provide a simple onboarding validation mechanism, hopefully it can assist someone in a similar situation.
The screenshot below shows the finished project, with red lines major issues that need fixing. I also included some regex code to run a basic validation of email addresses.
This is the example CSV file, note that not all data we require is there, and that is the problem!
Background
The objective of the project is to allow a user to upload a CSV file, parse it client-side (before going to the server), and show the user what data needs to be corrected before
they can upload the file for proper integration.
The workflow is as follows:
- User selects a CSV file (sample provided)
- User clicks a button (weehoo...)
- Client-side code takes the CSV, parses it, and loads it into KnockoutJS array
- KnockoutJS observables do their magic of displaying green/go, red/stop to the user
Using the code
To keep things clean, I left the JavaScript and CSS in their own separate files. The main three files we will be working with are the HTML (index.cshtml), JavaScript (ViewScript.js),
and CSS (KnockoutStyles.css).
The first thing to set-up in KO is the model. In this case, I am capturing basic user information. You will note I have a field for password - in the production version
I didn't bring in the plaintext password, but used a hash (the sample data is different however for illustration purposes).
function userModel() {
this.UserName = ko.observable();
this.Password = ko.observable();
this.FirstName = ko.observable();
this.LastName = ko.observable();
this.Email = ko.observable();
The next step was to define the ViewModel array, and link the binding:
var userVM = {
userModel: ko.observableArray([])
}
};
ko.applyBindings(userVM);
Then adding the KO data-bind markup to the HTML
<table border="1">
<thead>
<tr>
<th class="Pad">Username</th>
<th class="Pad">Password</th>
<th class="Pad">First name</th>
<th class="Pad">Last name</th>
<th class="Pad">Email address</th>
<th class="Pad"> </th>
</tr>
</thead>
<tbody data-bind="foreach: userModel">
<tr data-bind="css: ValidCredentials">
<td class="Pad"><input data-bind="value: UserName" class="Standard"/></td>
<td class="Pad"><input data-bind="value: Password" class="Standard" /></td>
<td class="Pad"><input data-bind="value: FirstName" class="Standard"/></td>
<td class="Pad"><input data-bind="value: LastName" class="Standard"/></td>
<td class="Pad"><input data-bind="value: Email" class="Wide"/> <br /></td>
<td class="Pad"></td>
</tr>
</tbody>
</table>
At the top of the file I added a FILE input control and an anchor link.
<form>
<input type="file" id="UserFile" />
<a href="#" id="lnkUpload">Upload CSV</a>
</form>
The next step was putting some code in the Click
event of the anchor to take (client side) a CSV file the user selected, and parse it into a KO array.
$('#lnkUpload').click(function () {
var FileToRead = document.getElementById('UserFile');
if (FileToRead.files.length > 0) {
var reader = new FileReader();
reader.onload = Load_CSVData;
reader.readAsText(FileToRead.files.item(0));
}
});
There as one gotcha that had me head scratching for a while, I tried to use a jQuery selector to get the var
FileToRead
- this didn't work however so I fell back
to raw JavaScript and everything flowed smoothly again.
The FileReader
"OnLoad
" event that reads the file contents is not synchronous, so I assigned it to another function
(Load_CSVData
) and then called the readAsText
event which feeds back into that function.
Now, something to note here, the
FileReader
will only work
if you are running from a WebServer (or within the IDE using the integrated web server etc) - it does *not* work if you are simply running form txt files on the desktop.
The logic of the Load_CSVData
method is as follows:
- Clear any items from the existing KO observable array
- Load up a local array (
CSVLines
) with the text data passed in from the
FileReader
, separating out each line using
the "Split
" function (delimiter = new line marker) - For each line being loaded, split this further (delimiter = the comma)
- For each item in each CSV line, push the value to as a new model on the VM array
I broke the code below out a bit to make it easier to read. Note that if there is no value in the CSV line I added a blank string to save problems later.
function Load_CSVData(e) {
userVM.userModel.removeAll();
CSVLines = e.target.result.split(/\r\n|\n/);
$.each(CSVLines, function (i, item) {
var element = item.split(",");
var LUserName = (element[0] == undefined) ? "" : element[0].trim();
var LPassword = (element[1] == undefined) ? "" : element[1].trim();
var LFirstName = (element[2] == undefined) ? "" : element[2].trim();
var LLastName = (element[3] == undefined) ? "" : element[3].trim();
var LEmailAddress = (element[4] == undefined) ? "" : element[4].trim();
userVM.userModel.push(new userModel()
.UserName(LUserName)
.Password(LPassword)
.FirstName(LFirstName)
.LastName(LLastName)
.Email(LEmailAddress)
)
});
}
That all works fine, the data is showing perfectly...
The next step is to use the power of the observable pattern to adjust the UI as data is received and changed...
There are a number of improvements to be made:
- Highlight rows that have a problem in red
- Make it obvious when data is required (we will give the input a yellow color)
- Show rows that are ok or almost ok in green
- Show a message if the email address provided is not valid
On each table row, I added in a CSS data-bind that called a computed function. This function checked if the required fields (Username, Password, Email) had values and if not,
set the CSS background color for the row to Red.
.Red {
border: thin dotted #FF6600;
background-color: red;
}
<tr data-bind="css: ValidCredentials">
<td class="Pad"><input...
ValidCredentials
is a computed function:
this.ValidCredentials = ko.computed(function () {
var ValidCreds = (this.UserName() != "" &&
this.Password() != "" && this.Email() != "");
return !ValidCreds ? "Red" : "Green";
}, this);
This worked fine, so I extended the logic a bit further so that not only would the problem rows appear in red, but the input fields that needed extra data would appear in yellow.
There were three fields so I created a separate definition for each (CSS: USR_Required
,
PWD_Required
, EML_Required
).
<td class="Pad"><input data-bind="value: UserName, css: USR_Required" class="Standard"/></td>
<td class="Pad"><input data-bind="value: Password, css: PWD_Required" class="Standard" /></td>
<td class="Pad"><input data-bind="value: FirstName" class="Standard"/></td>
<td class="Pad"><input data-bind="value: LastName" class="Standard"/></td>
<td class="Pad"><input data-bind="value: Email, css: EML_Required" class="Wide"/>
I created three almost identical methods to compute (worth revisiting to refactor!)
this.USR_Required = ko.computed(function () {
var rslt = (this.UserName() != "")
return rslt == true ? "NR" : "Required";
}, this);
this.PWD_Required = ko.computed(function () {
var rslt = (this.Password() != "")
return rslt == true ? "NR" : "Required";
}, this);
this.EML_Required = ko.computed(function () {
var rslt = (this.Email() != "")
return rslt == true ? "NR" : "Required";
}, this);
and added corresponding CSS into my KnockoutStyles.css file:
.Required {
background-color: #FFFF00;
}
.NR {
background-color: #FFFFFF;
}
So far so good, the next step was to add code that runs a simple check on the email address. I borrowed some code from stack for this:
this.InValidEmail = ko.computed(function () {
if (this.Email() == "")
{
return false;
}
var rslt = !validateEmail(this.Email());
return rslt;
}, this);
function validateEmail(email) {
var re = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)
*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.
[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return re.test(email);
}
Almost there, the final thing was to give the user a means of removing a complete line of data if it was not relevant (think about all of those accounts in Active Directory
of people who have left an organization over the years....)
To do this, I
added a "Click" data-bind to the end of each row that called a removeUser
method
and added code to the ViewModel to drop the item from the KO array.
<a href="#" data-bind="click: $parent.removeUser"><span class="White">Remove user</span></a>
function RemoveUserFromList(data) {
userVM.userModel.remove(data);
}
var userVM = {
userModel: ko.observableArray([]),
removeUser: function (data) {
RemoveUserFromList(data)
}
};
That's it...
There is enough there to give a head-start to anyone looking for a similar solution. Possible improvements would be a button that is only enabled when data is corrected,
another button to convert the data to JSON and send to the server, etc.
(PS: if you liked reading this please let me know by rating the article at the top of the page!)