GitHub hosted project:
Note: Each article in the present series could has different data schema for the data service. For historic reasons, they live on different source control branches. Therefore the ones in earlier articles may not be synchronized with later ones. Care should be taken when one tries to re-use data services and sample data in other articles.
Note (2014-03-02): there are data schema changes so please replace the early version of the data service, if any, by the current one for the new version to work.
Related Articles
The initiation of an administration web application for the Membership+ system is described in Membership+ Management System, Part I: Bootstrapping. The current article provides some details about globalization, media handling and member personal information managements.
The internet is globally connected network that people from various cultures can share. A web application could make more out of the internet if it is globalized.
There are various ways of realizing globalization in web applications. Many web sites use distinct url to separate contents of different languages. This kind of globalization scheme,is called explicit globalization here. There are advantages in this approach, like it allows independent language oriented teams to build various site domains dedicated to particular languages with very little dependency between each other, etc., which we should not do full enumeration here. One of the disadvantages of the approach is that it is harder and more expensive to maintain the content since the site are separated into language domains from the vary top level, it is harder to do side by side translation and keep the content in sync once parts of the site are changed without higher human resource expenditure.
The approach taken here is called implicit globalization. In an implicitly globalization scheme for web applications, the content urls for all supported languages are kept invariant. The content corresponding to a particular language are sent to the browser of an user according to a language specification variables inside the user's browsing context. One of the obvious variables is the content language preference option of most of modern browsers which is set automatically by the system on the client side and can be changed by the user later on. The selection will be encoded into the Accept-Language
HTTP request header (see here or here, and here) and be sent to the web server. We developed a light weighted approach to globalization based on what we call uni-resource technology. Earlier version of the system was published (see, e.g., GTRM, which is most likely not working if a reader tries to download and install it because the loader of the system is no longer supported at present). The text resource editing and translation part of the most current versions of the system are used internally only at present. However, the included assemblies for the project can be used to retrieve text blocks of various languages from the custom uni-resource text database constructed using the said tools via simple APIs (see the following) and with extremely high speed, which is suitable for web pages that contain numerous small text blocks that could otherwise drag the performance of the site down after being globalized implicitly. One may ask why not simply use the resource system of .Net framework, like what is done to client side applications? There are a few reasons:
The default API for it to select language specific text blocks is based on shared properties of (namely static ones or Thread
related ones), which are not deterministic during the request processing life cycle in a thread pool environment on the web server without an exclusive lock. Such a lock will drastically decrease the performance. This is especially true for sites that adopt the new async
paradigm of the post version 4.0 .Net runtime environments in which an async handler can split a sequential execution flow into sections that run on different threads (see, e.g. here), locking in such a case is not possible.
Non file based .net resources are compiled into the assemblies for the site. It makes it impossible to update the texts during run-time. It could be ok for static text blocks, but a dynamic web application could contain constantly changing contents.
Manager for file based .Net resources is not reliable under heavy loads on a web server, at least in some of the earlier versions of .Net and use cases we had tested. Random exceptions are been found to be thrown. Also those resource files are locked up during the run-time so that updating them is not allowed without stopping the corresponding web application.
It is not portable. For example, MONO on Linux does not support .Net resources; Windows RT has a different way of encoding resources, etc.
Proper caching of web content can increase the performance of a web application significantly. Caching of content can be done on the server side, the client side or a combination of both. The web server that host the web application can be used to cache content not very large or as parts of the whole response that are expensive to re-generate for each request. If a complete web content as whole is large and changes slowly, which is called static content in the following, it is best to cache it on the client side since it will save both memory consumption on the server and the bandwidth which could otherwise be used to transmit frequently changing contents to clients.
Most web browsers can save server side contents on the client side and decide whether or not to use the saved copy of a specific content according to rules specified by Http protocol, using a combination of If-Modified-Since/Last-Modified and If-None-Match/Etag header fields contained in HTTP request/response correspondingly.
Most web server can handle the client side caching of file based static content without a developer paying much attention to it. This is partly because files inside the filesystem of most OS contain standard meta-properties, like the mime type and the last modified time, etc., that a web server can use to control client side caching without knowing the application context. What cannot be handled well are those static contents that are retrieved from database because the required meta-information for those contents do not have a standard. As a matter of fact, some or all of the said meta-information are missing in many systems. Not only the web server, but also the Asp.Net framework do not have access to needed information to handle the client side caching of later kind of static contents. Since .Net ActionFilterAttribute
based classes can not accept argument, the OutputCacheAttribute
can only handle server side cache well. For client side caching of static contents retrieved from databases, it is found that explicit handling at action level is more flexible.
The data schema of Membership+ system (see here) is so designed that most static and large media data have a minimum set of corresponding meta properties that can be used to control client side rendering and caching:
- The
IconImage
property of an entity in the UserAppMembers
data set has two accompanying properties, namely IconMime
and IconLastModified
, that can be used to control client side rendering and caching of the data. The value of IconLastModified
is retrieved from the corresponding file using which the IconImage
data is updated. - The
Photo
property of an entity in the UserDetails data set has two accompanying properties, namely PhotoMime
and PhotoLastModified
, that can be used to control client side rendering and caching of the data. The value of PhotoLastModified
is retrieved from the corresponding file using which the Photo
data is updated.
The front end of the data service and the web application that is responsible for input and update this kind of data are so constructed that the meta properties will be retrieved from the source data (files at the present) automatically (see the implementation part in the following).
This section is for those who are new to the Asp.Net Mvc framework. Others can skip reading it.
Developers who are new to Asp.Net Mvc but who have knowledge in developing desktop or web form applications may find that there seems to be no such a thing as user control in the new framework. This in fact not true, "user controls" are represented by partial views.
There are two ways that one can make use of a partial view:
Direct inclusion of partial views generated from @Html.Partial("ViewName", model)
and @{ Html.RenderPartial("ViewName", model); }
, where the former generates a string and the later returns void so it's invoked differently. The later writes generated html from the partial view to the response output stream directly.
Indirect and more flexible inclusion of partial views through invoking an action in the form of @Html.Action("ActionName", "ControllerName", ... )
and @{ Html.RenderAction("ActionName", "ControllerName", ... ); }
, where as above the former generates a string and the later returns void so it's invoked differently. The later writes generated html from the partial view to the response output stream directly.
Instead of accepting the name of a partial view, it accepts the name of an action and optionally the name of the controller that defines the action. It is the invoked action that is responsible for what partial view to select, how the selected view is initialized and how various outputs can be cached. Typically, the action has the form of
[ChildActionOnly]
public ActionResult ActionName()
{
... partial view selection and initialization ...
return PartialView("PartialViewName", viewmodel);
}
The ChildActionOnly
attribute is used to disallow the action been invoked directly from a request url.
Partial views allows one to re-factor asp.net Mvc views so that some common parts can be used and re-used in multiple web pages to provide increased maintainability and consistency of web pages.
The types and methods inside the ResourceUtils.cs
file under the Resources
sub-directory of the ArchymetaMembershipPlusStores
project are used by web applications to retrieve, implicitly, text resources contained in the uni-resource database.
The language preferences contained in the HTTP Accept-Language
header field are coded following ISO-639 language coding + ISO-3166 country coding (see here ), which is different from how .Net classifies languages. The map method
private static CultureInfo MapCultureInfo(string lan, out float weight)
{
int ipos = lan.IndexOf(';');
string cn = ipos == -1 ? lan.Trim() : lan.Substring(0, ipos).Trim();
if (ipos == -1)
weight = 1.0f;
else
{
if (!float.TryParse(lan.Substring(ipos + 1).Trim(), out weight))
weight = 0.0f;
}
CultureInfo ci = null;
if (cn == "zh" || cn == "zh-chs" || cn == "zh-hans" || cn == "zh-cn")
ci = new CultureInfo("zh-Hans");
else if (cn == "zh-cht" || cn == "zh-hant" || cn == "zh-tw" || cn == "zh-hk"
|| cn == "zh-sg" || cn == "zh-mo")
ci = new CultureInfo("zh-Hant");
else if (cn.StartsWith("zh-"))
ci = new CultureInfo("zh-Hans");
else
{
bool fail = false;
try
{
ci = new CultureInfo(cn);
}
catch
{
fail = true;
}
if (fail && cn.IndexOf('-') != -1)
{
cn = cn.Substring(0, cn.IndexOf('-'));
try
{
ci = new CultureInfo(cn);
}
catch
{
}
}
}
return ci;
}
is used to map the two.
The map is not meant to be complete at the currently stage of development because we do not have enough detailed knowledge about various languages other than English (US) and Chinese at the present. The content of default language "en" inside the uni-resource database included in the project is in fact closer to "en-US". However, as it can be seen that an extention of the mapping list can be easily made later on. Readers of other cultures are welcome to fork the project (on the feature branch codeproject-2) at github.com to supply additional mappings.
When a request arrives, its weighted language preference list, if any, is compared against the list of supported languages specified inside the
<add key="SupportedLanguages" value="en,zh-hans"/>
node under the <appSettings>
node of the Web.config
file. The first match will be regarded as the language to be used in the response. If no match is found, the default language "en" will be used.
A text block and the list of its translations in other language in a uni-resource store are identified by a 16 byte global unique identifier (guid). The various forms of the static method GetString
of the ResourceUtils
class can be used to get text blocks of particular language out of the uni-resource database. The simplest one is
string GetString(string resId, string defval = null)
where the string form of text guid, namely resId
is the hex encoded value of the said guid. The defval
is used specify a default value for the text to be returned in case it can not be found inside of the uni-resource database. This method is used to retrieve small text blocks from the corresponding uni-resource files for the database, namely the ShortResources.didxl
and ShortResources.datal
files under the App_Data
sub-directory of the web application. The BlockResources.didxl
and BlockResources.datal
files, which are used to store large text blocks are not currently included in to project.
Suppose a web page is first written in English, The process of globalizing a web page is quite simple:
Locate any text blocks that need to be globalized.
Add the corresponding text block to the uni-resource database which will generate a hex encoded guid for the text block base on its content.
Replace the original text block by a call to the GetString
method using the hex encoded guid for its first argument and the original text as its second argument.
For example, in a Razor web page, the text "Home Page" becomes
@ResourceUtils.GetString("cfe6e34a4c7f24aad32aa4299562f5b2", "Home Page")
Compared to the English web application described in the previous article, the current system is fully globalized in this and sub-sequent articles by following the above rules.
For those who is interested to fork the project, if they do not have access to the uni-resource input tools or are interested in a particular language only, there is no need to globalize the projects further. In the former case, someone else who has the tools could be called for do the globalization for you. We could choose to re-release the new version of the tool if enough interests are there.
All controllers inside of the web application are derived from the BaseController
class which contains two methods for managing client side cache control.
protected bool CheckClientCache(DateTime? lastModified, string Etag,
out int StatusCode, out string StatusDescr)
{
StatusCode = 200;
StatusDescr = "OK";
if (!EnableClientCache)
return false;
bool Timed = !string.IsNullOrEmpty(Request.Headers["If-Modified-Since"]);
bool HasEtag = !string.IsNullOrEmpty(Request.Headers["If-None-Match"]);
if (!Timed && !HasEtag || lastModified == null && Etag == null)
return false;
DateTime? cacheTime = null;
if (Timed)
cacheTime = DateTime.Parse(
Request.Headers["If-Modified-Since"]).ToUniversalTime();
string OldEtag = HasEtag ? Request.Headers["If-None-Match"] : null;
if (Timed && HasEtag)
{
if (lastModified != null &&
!IsTimeGreater(lastModified.Value.ToUniversalTime(), cacheTime.Value) &&
OldEtag == Etag)
{
StatusCode = 304;
StatusDescr = "Not Modified";
return true;
}
}
else if (Timed)
{
if (lastModified != null &&
!IsTimeGreater(lastModified.Value.ToUniversalTime(), acheTime.Value))
{
StatusCode = 304;
StatusDescr = "Not Modified";
return true;
}
}
else if (HasEtag)
{
if (OldEtag == Etag)
{
StatusCode = 304;
StatusDescr = "Not Modified";
return true;
}
}
return false;
}
for checking if a valid client cache item exists and
protected void SetClientCacheHeader(DateTime? LastModified, string Etag,
HttpCacheability CacheKind, bool ReValidate = true)
{
if (!EnableClientCache || LastModified == null && Etag == null)
return;
HttpCachePolicyBase cp = Response.Cache;
cp.AppendCacheExtension("max-age=" + 3600 * MaxClientCacheAgeInHours);
if (ReValidate)
{
cp.AppendCacheExtension("must-revalidate");
cp.AppendCacheExtension("proxy-revalidate");
}
cp.SetCacheability(CacheKind);
cp.SetOmitVaryStar(false);
if (LastModified != null)
cp.SetLastModified(LastModified.Value);
cp.SetExpires(DateTime.UtcNow.AddHours(MaxClientCacheAgeInHours));
if (Etag != null)
cp.SetETag(Etag);
}
to set up client cache when it is possible. The EnableClientCache
property used in both methods is related to the a configuration setting in Web.config
file:
protected bool EnableClientCache
{
get
{
string sval = ConfigurationManager.AppSettings["EnableClientCache"];
bool bval;
if (string.IsNullOrEmpty(sval) || !bool.TryParse(sval, out bval))
return false;
else
return bval;
}
}
which can control whether or not the enable client side caching. Client side cache could make debugging and testing difficult. A developer can turn off client side caching during development and turn it on when the web application is deployed using the corresponding configuration setting.
The media here refers to image, video and audio data that are rendered on a web page. File based media can be handled relative easily. What is interested here is how do we handle media data retrieved from a non-file data source, like a relational data source emphasized in this article.
Once a user is logged in, the user has an authenticated unique identity that can be visually represented. The simplest one would be just the user's user name. This is what is done for simplicity during the initialization phase of the system described in article I referenced above.
However, it can be made fancier. This is because entities in the UserAppMembers
data set contains a field named IconImage
which can contain a user's selected icon picture for the current application. Since a user's "icon" could be used in various web pages, it is better to define a re-usable partial view for it. The following is the simplest one that contains a user's icon picture
@model MemberAdminMvc5.Models.UserIconModel
@using Microsoft.AspNet.Identity
@using Archymeta.Web.Security.Resources
<div class="UserSmallIcon">
@if (!string.IsNullOrEmpty(Model.IconUrl))
{
<img src="@Url.Content("~/" + Model.IconUrl)" />
}
else
{
<span class="ion-android-contact"></span>
}
<span>@Model.Greetings</span>
</div>
which is contained in the _UserIconPartial.cshtml
file under the Views\Account
sub-directory of the web application. This partial view is used by the user himself/herself. There is another one under the Views\Home
sub-directory of the web application with the same file name, which is displayed to other members of the system. There is very little differences between the two except that the @Model.Greetings
is replaced by @Model.UserLabel
in the later view. The partial view handles the possibility that a user may not have uploaded an icon, in which case a default icon picture is displayed.
Figure: User "icon" partial view.
The view model UserIconModel
is defined as
public class UserIconModel
{
public string UserLabel
{
get;
set;
}
public string Greetings
{
get;
set;
}
public string IconUrl
{
get;
set;
}
}
This partial view is normally included via a @Html.Action(...)
since it is not expected that an object of type UserIconModel
is included and initialized in all kinds view models associated with web pages that have a user "icon". Having a dedicated common action to inject the model dynamically is simply simpler. For example, in the _LoginPartial.cshtml
file under the Views\Shared
sub-directory of the web application, the user "icon" is included via
@{ Html.RenderAction("GenerateUserIcon", "Account"); }
where the GenerateUserIcon method of AccountController is invoked to setting up the partial view:
[ChildActionOnly]
public ActionResult GenerateUserIcon()
{
var m = new Models.UserIconModel();
m.Greetings = User.Identity.GetUserName();
m.UserLabel = m.Greetings;
var ci = User.Identity as System.Security.Claims.ClaimsIdentity;
string strIcon = (from d in ci.Claims
where d.Type == CustomClaims.HasIcon
select d.Value).SingleOrDefault();
bool hasIcon;
if (!string.IsNullOrEmpty(strIcon) &&
bool.TryParse(strIcon, out hasIcon) && hasIcon)
{
m.IconUrl = "Account/GetMemberIcon?id=" +
User.Identity.GetUserId();
}
return PartialView("_UserIconPartial", m);
}
It does not retrieve the image but provides a image url, namely m.IconUrl
that the web page can download next. This url invokes the GetMemberIcon method of the AccountController class that returns the binary image data:
[HttpGet]
public async Task<ActionResult> GetMemberIcon(string id)
{
if (string.IsNullOrEmpty(id))
{
if (!Request.IsAuthenticated)
return new HttpStatusCodeResult(404, "Not Found");
id = User.Identity.GetUserId();
}
var rec = await MembershipContext.GetMemberIcon(id);
if (rec == null<span lang="zh-cn"> || string.IsNullOrEmpty(rec.MimeType)</span>)
return new HttpStatusCodeResult(404, "Not Found");
int status;
string statusstr;
bool bcache = CheckClientCache(rec.LastModified, rec.ETag,
out status, out statusstr);
SetClientCacheHeader(rec.LastModified, rec.ETag,
HttpCacheability.Public);
if (!bcache)
return File(rec.Data, rec.MimeType);
else
{
Response.StatusCode = status;
Response.StatusDescription = statusstr;
return Content("");
}
}
The action is mainly responsible for handling client side cache. It first checks if the image data is cached at the client side by calling the CheckClientCache
method and then tries to set the client side cache directive by calling the SetClientCacheHeader
method described in the previous sub-section. The Etag
of the image data is the base64 encoded MD5 hash value of the data:
public class ContentRecord
{
public byte[] Data
{
get;
set;
}
public string ETag
{
get
{
if (Data == null || Data.Length == 0)
return null;
if (_etag == null)
{
var h = HashAlgorithm.Create("MD5");
_etag = Convert.ToBase64String(h.ComputeHash(Data));
}
return _etag;
}
}
private string _etag = null;
public string MimeType
{
get;
set;
}
public DateTime LastModified
{
get;
set;
}
}
The retrieving of image data is delegated to the GetMemberIcon
method of MembershipContext
class defined in the MembershipPlusAppLayer45
project.
public static async Task<ContentRecord> GetMemberIcon(string id)
{
UserAppMemberServiceProxy umsvc = new UserAppMemberServiceProxy();
var um = await umsvc.LoadEntityByKeyAsync(Cntx, ApplicationContext.App.ID, id);
if (um == null)
return null;
ContentRecord rec = new ContentRecord();
rec.MimeType = um.IconMime;
rec.LastModified = um.IconLastModified.HasValue ?
um.IconLastModified.Value : DateTime.MaxValue;
rec.Data = await umsvc.LoadEntityIconImgAsync(Cntx, ApplicationContext.App.ID, id);
return rec;
}
Since the IconImage
is declared delay loaded, the image data is loaded in two steps: 1) the entity model of type UserAppMember
is loaded to retrieve the meta information about the image data and then the image data is loaded.
A user's icon image is setup or updated inside of the UpdateMemberIcon.cshtml
web page under the Views\Account
sub-directory
@using Microsoft.AspNet.Identity;
@using Archymeta.Web.Security.Resources;
@{
ViewBag.Title = ResourceUtils.GetString("a11249b2e553b45f53a9d1f5d0ac89ba",
"Update User Membership Icon");
}
@section scripts {
<script>
$(function () {
$("#submit").prop("disabled", true);
if (window.File && window.FileReader &&
window.FileList && window.Blob) {
$('#IconImg')[0].addEventListener('change', function (evt) {
var file = evt.target.files[0];
$('#IconLastModified').val(file.lastModifiedDate.toUTCString());
var reader = new FileReader();
reader.onload = function (e) {
$('#imgPreview').attr('src', e.target.result);
$("#submit").prop("disabled", false);
}
reader.readAsDataURL(file);
}, false);
} else {
alert('@ResourceUtils.GetString(
"0274e2eeb63505510d4baab9f70dc418",
"The File APIs are not fully supported in this browser.")');
}
});
</script>
}
<div class="row">
<div class="col-md-12">
</div>
</div>
@using (Html.BeginForm("UpdateMemberIconAsync", "Account",
new { returnUrl = ViewBag.ReturnUrl },
FormMethod.Post, new { enctype = "multipart/form-data" }))
{
@Html.AntiForgeryToken()
<input type="hidden" id="IconLastModified" name="IconLastModified" />
<div class="row">
<div class="col-md-offset-2 col-md-10">
<div style="display:inline-block">
<label for="IconImg">
@ResourceUtils.GetString("4673637028866e44d46b7c9760bf3a4c", "Local Icon File:")
</label>
<input type="file" id="IconImg" name="IconImg" class="form-control" />
<div> </div>
<input type="submit" name="submit" id="submit" class="btn btn-default"
value="@ResourceUtils.GetString("91412465ea9169dfd901dd5e7c96dd9a",
"Upload")" />
</div>
<div style="display:inline-block; margin-left:20px;">
<img id="imgPreview" src="@Url.Content("~/Account/GetMemberIcon?id="+
User.Identity.GetUserId())" style="vertical-align:bottom;" />
</div>
</div>
</div>
}
Figure: User icon image load page.
After initial loading, the page add a JavaScript File API listener to the "IconImg
" input field and the old icon image, if any is displayed on the right hand side of the page. After the user selects an image file, the last modified date of the image file is updated inside of the hidden "IconLastModified
" form field and the new image is displayed on the right for previewing. When the user click the "Upload" button, it invoke the UpdateMemberIconAsync
method of the AccountController
to do the updating.
[HttpPost]
[Authorize]
[ValidateAntiForgeryToken]
[OutputCache(NoStore = true, Duration = 0)]
public async Task<ActionResult> UpdateMemberIconAsync(string returnUrl)
{
if (Request.Files != null && Request.Files.Count > 0)
{
if (!Request.Files[0].ContentType.StartsWith("image"))
throw new Exception("content mismatch!");
string IconMime = Request.Files[0].ContentType;
System.Nullable<DateTime> IconLastModified =
default(System.Nullable<DateTime>);
if (Request.Form.AllKeys.Contains("IconLastModified"))
IconLastModified = DateTime.Parse(Request.Form["IconLastModified"]);
System.IO.Stream strm = Request.Files[0].InputStream;
int size = Request.Files[0].ContentLength;
byte[] data = new byte[size];
strm.Read(data, 0, size);
if (await MembershipContext.UpdateMemeberIcon(User.Identity.GetUserId(),
IconMime, IconLastModified.Value, data))
{
if (string.IsNullOrEmpty(returnUrl))
return RedirectToAction("Index", "Home");
else
return Redirect(returnUrl);
}
}
return View();
}
in which the last modified date, mime type and the image data are retrieved and passed to the UpdateMemeberIcon
method of the MembershipContext
class:
public static async Task<bool> UpdateMemeberIcon(string id, string mineType,
DateTime lastModified, byte[] imgdata)
{
UserAppMemberServiceProxy umsvc = new UserAppMemberServiceProxy();
var um = umsvc.LoadEntityByKey(Cntx, ApplicationContext.App.ID, id);
if (um == null)
return false;
um.IconImg = imgdata;
um.IsIconImgLoaded = true;
um.IsIconImgModified = true;
um.IconMime = mineType;
um.IconLastModified = lastModified;
var result = await umsvc.AddOrUpdateEntitiesAsync(
Cntx,
new UserAppMemberSet(),
new UserAppMember[] { um });
return (result.ChangedEntities[0].OpStatus & (int)EntityOpStatus.Updated) > 0;
}
The photo data of a member is contained inside the Photo
property of the user details entity inside of the UserDetails
data set. The way how it is handled is very similar to the one discussed above about a user's icon image data contained in the UserAppMembers
data set. it will not be repeated here. Interested reader can read the corresponding code to find more about the methods.
The data schema for the Membership+ system was introduced in the Part I. The introduction here provides complementary details about the data sets used to describe a member. There are four kinds of information about a member in the Membership+ system:
- The mandatory common information about a member. It is saved in a record about the member in the
Users
data set. It contains brief information about a member, like the mandatory Username
, hash value of his/her login Password
and the optional FirstName
and LastName
of the member. - The mandatory application specific brief information about a member. It is saved in a record about the member in the
UserAppMembers
data set. A user can choose to put different (or not) membership information in the record, depending on what web application he/she is using. Here amongst others, the member primary email address Email
is a required property but the user icon image IconImg
is optional. - The optional application specific detailed information about a member. It is saved in a record about the member in the
UserDetails
data set. It contains a tentative list of user personal attributes and a user description and a user photo data fields. The list of attributes could be extended, modified or even shrinked, depending on the application scenarios, without to much work being needed. - The optional application specific list of "communication channels" of various type of a member. It is saved in a record about the member in the
Communications
data set. All kinds of addressable destinations that others can use to reach the member, including a member's hom/work address, telephone numbers, electronic messaging channels, including but not limited to e-mail addresses, etc., are included here. The design of the data schema allows a user to have multiple communication channels even within the same category.
A user's account information is related to a subset of attributes saved in the mandatory records described above, namely in type 1 and 2 data sets. A snapshot of the member personal information managment web page is displayed in the following.
Figure: User account information management page.
It has two tab pages "Personal Information" and "Manage Password". Bootstrap css classes are used to realize the tabbing:
<ul class="nav nav-tabs">
<li class="active">
<a href="#personal-info" data-toggle="tab">Personal Information</a>
</li>
<li>
<a href="#password-panel" data-toggle="tab">Manage Password</a>
</li>
</ul>
<div class="tab-content">
<div id="personal-info" class="tab-pane active">
... Personal information part ...
</div>
<div id="password-panel" class="tab-pane">
... Password management part ...
</div>
</div>
The left hand side "Personal information part" is a form that contains a user's membership related attributes that can be posted back to the ChangeAccountInfo
method of the AccountController
, which build a data transfer object and calls the ChangeAccountInfo
method of the MembershipContext
class, to have the changed attributes updated.
public static async Task ChangeAccountInfo(string id, ApplicationUser user)
{
var cntx = Cntx;
UserServiceProxy usvc = new UserServiceProxy();
var u = await usvc.LoadEntityByKeyAsync(cntx, id);
if (u == null)
return;
u.FirstName = user.FirstName;
u.LastName = user.LastName;
if (u.IsFirstNameModified || u.IsLastNameModified)
await usvc.AddOrUpdateEntitiesAsync(cntx, new UserSet(), new User[] { u });
if (!string.IsNullOrEmpty(user.Email))
{
UserAppMemberServiceProxy mbsvc = new UserAppMemberServiceProxy();
var mb = await mbsvc.LoadEntityByKeyAsync(cntx, ApplicationContext.App.ID, id);
if (mb != null)
{
mb.Email = user.Email;
if (mb.IsEmailModified)
await mbsvc.AddOrUpdateEntitiesAsync(cntx, new UserAppMemberSet(),
new UserAppMember[] { mb });
}
}
}
Two potentially involved data sets, namely the Users
and UserAppMembers
ones, are considered since the Email
property of a member is application specific. As it can be seen, it disallows a user to reset an existing value for an Email
address to empty or null one.
Here in order to aviod calling the backend service when there is actually no real updates, an editable properties' "Is<property-name>Modified" companying property is checked first, where "<property-name>" is the name of the corresponding property. This companying property is auto updated whenever the corresponding property of a previously persisted entity is assigned to. The method only perform real updates when any change is detected.
This is standard password updating panel. The password update information contained in the corresponding form is posted to the ChangePassword
method of the AccountController
class where instead of handling the password updating inside of the application logic layer (namely the one defined in the MembershipPlusAppLayer45
project), it uses the one in the Asp.Net user store discussed here via the API implemented by UserManagerEx
class:
IdentityResult result = await UserManager.ChangePasswordAsync(User.Identity.GetUserId(),
model.OldPassword,
model.Password);
This is because changing password is a security related action that is best to be handled by a module more specialized in security, namely our custom implementation of the Asp.Net identity management system.
A user's detailed information is consisted of a subset of attributes and data saved in the optional records described above, namely the UserDetail
s data set. A member does not have to establish a details profile.
The there are two kinds of output from UserDetails.cshtml
, depending on whether or not the user has a details record:
@if (Model.Details == null)
{
.... output content when the details record is absent ...
}
else
{
.... output content when the details record exists ...
}
- When the details record is absent, the page contains two tab pages
- The details creation panel which allows a member to create an empty detailed profile.
- The member communication channel management panel, which is described in the next sub-section.
- Otherwise
- The member attributes and phto update panel in which a member can update his/her personal attributes. There is also a hyperlink pointing to the phto update page for a member.
- The member description update panel. Here a user can write details about himself or herself using a html editor.
- The member communication channel management panel, which is described in the next sub-section.
The common panel of the two, namely the member communcation channel management panel is described separately in next sub-section. This is because client side technologies using jQuery and KnockoutJS is used to handle it, which deserves a more detailed introduction.
The UserDetails
action in AccountController
is quite simple since it delegates the loading to the GetUserDetails
method of MembershipContext
class:
public static async Task<UserDetailsVM> GetUserDetails(string id, bool direct = false)
{
UserDetailServiceProxy udsvc = new UserDetailServiceProxy();
var cntx = Cntx;
cntx.DirectDataAccess = direct;
var details = await udsvc.LoadEntityByKeyAsync(cntx, ApplicationContext.App.ID, id);
UserDetailsVM m = null;
if (details != null)
{
if (!details.IsDescriptionLoaded)
{
details.Description = udsvc.LoadEntityDescription(cntx,
ApplicationContext.App.ID, id);
details.IsDescriptionLoaded = true;
}
m = new UserDetailsVM { Details = details };
m.IsPhotoAvailable = !string.IsNullOrEmpty(details.PhotoMime);
}
else
{
m = new UserDetailsVM();
m.IsPhotoAvailable = false;
}
UserDetailSet uds = new UserDetailSet();
m.Genders = uds.GenderValues;
QueryExpresion qexpr = new QueryExpresion();
qexpr.OrderTks = new List<QToken>(new QToken[] {
new QToken { TkName = "ID" },
new QToken { TkName = "asc" }
});
CommunicationTypeServiceProxy ctsvc = new CommunicationTypeServiceProxy();
var lct = await ctsvc.QueryDatabaseAsync(Cntx, new CommunicationTypeSet(), qexpr);
m.ChannelTypes = lct.ToArray();
CommunicationServiceProxy csvc = new CommunicationServiceProxy();
qexpr = new QueryExpresion();
qexpr.OrderTks = new List<QToken>(new QToken[] {
new QToken { TkName = "TypeID" },
new QToken { TkName = "asc" }
});
qexpr.FilterTks = new List<QToken>(new QToken[] {
new QToken { TkName = "UserID" },
new QToken { TkName = "==" },
new QToken { TkName = "\"" + id + "\"" },
new QToken { TkName = "&&" },
new QToken { TkName = "ApplicationID" },
new QToken { TkName = "==" },
new QToken { TkName = "\"" + ApplicationContext.App.ID + "\"" }
});
var lc = await csvc.QueryDatabaseAsync(Cntx, new CommunicationSet(), qexpr);
foreach (var c in lc)
{
c.Comment = await csvc.LoadEntityCommentAsync(Cntx, c.ID);
c.IsCommentLoaded = true;
c.CommunicationTypeRef = await csvc.MaterializeCommunicationTypeRefAsync(Cntx, c);
m.Channels.Add(new { id = c.ID,
label = c.DistinctString,
addr = c.AddressInfo,
comment = c.Comment,
typeId = c.CommunicationTypeRef.TypeName });
}
return m;
}
This method constructs an instance of the UserDetailsVM
class and updates it with data loaded from three data sets: UserDetails
, CommunicationTypes
and Communications
, which will be used by the UserDetails.cshtml
web page.
The concept of constrained set applies only to data set that depends other data sets. By constrained set we mean a subset of entities of the said data set in which some of their foreign key values are provided as fixed values.
A user's communcation channels is a constrained set of communication channels having their foreign keys ApplicationID
and UserID
fixed and with arbitrary TypeID
. The above code uses generic query API method to get this subset:
CommunicationServiceProxy csvc = new CommunicationServiceProxy();
qexpr = new QueryExpresion();
qexpr.OrderTks = new List<QToken>(new QToken[] {
new QToken { TkName = "TypeID" },
new QToken { TkName = "asc" }
});
qexpr.FilterTks = new List<QToken>(new QToken[] {
new QToken { TkName = "UserID" },
new QToken { TkName = "==" },
new QToken { TkName = "\"" + id + "\"" },
new QToken { TkName = "&&" },
new QToken { TkName = "ApplicationID" },
new QToken { TkName = "==" },
new QToken { TkName = "\"" + ApplicationContext.App.ID + "\"" }
});
var lc = await csvc.QueryDatabaseAsync(Cntx, new CommunicationSet(), qexpr);
There is an equivalent but more specific API method for this type of querying:
var fkeys = new CommunicationSetConstraints
{
ApplicationIDWrap = new ForeignKeyData<string> {
KeyValue = ApplicationContext.App.ID },
TypeIDWrap = null, UserIDWrap = new ForeignKeyData<string> { KeyValue = id }
};
var lc = await csvc.ConstraintQueryAsync(Cntx, new CommunicationSet(), fkeys, null);
which is more concise and sematically more meaningful.
Before a member creates his/her details, the UserDetails page looks like the following
Figure: User details page when the user details record is absent.
After clicking the "Create it now" button, it becomes
Figure: User details page after the user details record is initially created.
The creation is handled by the CreateUserDetails
method of the AccountController
class:
[HttpGet]
[Authorize]
public async Task<ActionResult> CreateUserDetails()
{
await MembershipContext.CreateUserDetails(User.Identity.GetUserId());
return RedirectToAction("UserDetails", "Account");
}
which calls the CreateUserDetails method of MembershipContext class:
public static async Task<bool> CreateUserDetails(string id)
{
UserDetailServiceProxy udsvc = new UserDetailServiceProxy();
var ud = await udsvc.LoadEntityByKeyAsync(Cntx, ApplicationContext.App.ID, id);
if (ud == null)
{
await udsvc.AddOrUpdateEntitiesAsync(Cntx, new UserDetailSet(),
new UserDetail[] {
new UserDetail{
UserID = id,
ApplicationID = ApplicationContext.App.ID,
CreateDate = DateTime.UtcNow
}
});
}
return true;
}
The web page contains two forms that can be submitted independently and a link to the member photo update page.
The left hand side of the user details management page contains a list of member attributes and their current values wrapped in a form.
Figure: User details page.
The updating is handled by the UpdateUserProperties
method of the AccountController
class, which delegate the updating to the UpdateUserProperties
method of the MembershipContext
class.
public static async Task<UserDetailsVM> UpdateUserProperties(string id,
UserDetailsVM model)
{
UserDetailServiceProxy udsvc = new UserDetailServiceProxy();
var cntx = Cntx;
var details = await udsvc.LoadEntityByKeyAsync(cntx, ApplicationContext.App.ID, id);
int chgcnt = 0;
if (details.Gender != model.Gender)
{
details.Gender = model.Gender;
chgcnt++;
}
if (!details.BirthDate.HasValue && model.BirthDate.HasValue ||
details.BirthDate.HasValue && !model.BirthDate.HasValue ||
details.BirthDate.HasValue && model.BirthDate.HasValue &&
details.BirthDate.Value != model.BirthDate.Value)
{
details.BirthDate = model.BirthDate;
chgcnt++;
}
if (chgcnt > 0)
{
details.LastModified = DateTime.UtcNow;
udsvc.AddOrUpdateEntities(Cntx, new UserDetailSet(),
new UserDetail[] { details });
}
return await GetUserDetails(id, true);
}
Here again the original value of related attributes of the entity is checked against the updated one. The update will be carried out only when at least one of the related attributes is modified.
The method calls GetUserDetails
with the second argument set to true
on return. As it is shown above that the value of the second argument is passed to the cntx.DirectDataAccess
property. The value of the property is used to control the caching behaviour of read operations on the data service side, if the value is set to true
, then, instead of returning a old cached copy, if any, the value(s) just updated will be retrieved and returned to the browser so that the browser will display the change without having to wait until the cache expires.
By clicking the the "Update Photo" link at the bottom of the photo image a user is brought to the member photo update page.
Figure: Photo update page.
The way in which images are updated and displayed in the present approach is discussed in sub section 3.3, it will not be repeated here.
The member descirption panel has two tab pages:
- The descirption editor that is attached to a ckeditor WYSIWYG html editor.
- A post-viewer. The post-viewer displays html formatted member discription that had already been saved. So, if one changed the discription, he/she should not expect to see the change immediately before posting it back. However the WYSIWYG nature of the editor makes the previewing un-necessory.
Figure: Member description editor.
The description is post back to the UpdateUserDescription
method of the AccountController
class
[HttpPost]
[Authorize]
[ValidateInput(false)]
[OutputCache(NoStore = true, Duration = 0)]
public async Task<actionresult> UpdateUserDescription(UserDetailsVM m)
{
m = await MembershipContext.UpdateUserDescription(
User.Identity.GetUserId(), m);
return View("UserDetails", m);
}</actionresult>
The [ValidateInput(false)]
attribute disables the normal content validating because the contents posted back are in the form of HTML fragments which will be blocked by normal content validator. What is left as future improvements here is implmenting a custom content validator that can be used to filter the post back content so that it complies with the security standard of an organization.
It calls the UpdateUserDescription
method of the MembershipContext
class. The implementation of the method is quite similar to the one described above. It will not be repeated here.
A user's communication channels is related to a subset of communication related data structures saved in a list records described above, namely in Communications
data set. A member can record zero to any number of them that he/she sees fit.
The list of communication channels for a member is handled differently from the more traditional server side techniques used so far. Here the JavaScript based client side methods supported by jQuery, KnockoutJS are used. KnockoutJS supports MVVM architectural pattern that decouple the tight binding between views and codes, make both separately reusable under different application contexts. For example, many of the KnockoutJS view models provided by the back end data service can be used in application layers with minor modification. It could save great amount of times needed by a developer to create, maintain and keep them in sync with changes in data schema.
Asp.Net Mvc has built in support for most of what is needed to map the two worlds: strongly type .Net data structures and the ones expressed in JSON.
This section also serves the purpose of warming up for a more extended use of the technology in our sub-sequent development.
The UserDetails.cshtml
web page has a scripts section that will be injected into the output html page loaded by the browser:
@section scripts {
...
@Scripts.Render("~/bundles/knockout")
<script src="@Url.Content("~/Scripts/DataService/UserDetailsPage.js")">
</script>
...
<script>
appRoot = '@Url.Content("~/")';
$(function () {
var vm = new UserDetails();
@foreach (var m in Model.ChannelTypes)
{
<text>vm.channelTypes.push(new ChannelType(@m.ID, '@m.TypeName'));</text>
}
@if (Model.Details != null)
{
foreach (var c in Model.Channels)
{
<a name="member-channel-push statement"><text></a>
vm.Channels.push(
new Channel(
JSON.parse(
'@Html.Raw(
Utils.GetDynamicJson(c)
)'
)
)
);
</text>
}
}
...
ko.applyBindings(vm, $('#channels')[0]);
});
</script>
}
First, I am going to provide an overview that gives a reader an overall picture of the process without having everything defined, which is the job of next few sub-sections. In addition to the default ones defined in the Views\Schared\_Layout.cshtml
, it loads KnockoutJS
JavaScript package and UserDetailsPage.js
under the Scripts\DataService
sub-directory of the web application. What it does after the page is loaded on the client side is to create an KnockoutJS view model object var vm = new UserDetails();
then pushes the corresponding elements into channelTypes
and Channels
in an list of JavaScript statements generated on the servier side, namely the @foreach (var m in Model.ChannelTypes) { ... }
and @foreach (var c in Model.Channels) { ... }
server side statements. After the complete object vm
is built, it is bound to the html element inside the page having id = "channels"
inside the web page via the KnockoutJS method ko.applyBindings(vm, $('#channels')[0]);
at the bottom. The said html element is defined in the partial view _PersonalChannelsPartial.cshtml
under the Views\Account
sub-directory, which is included by the UserDetails.cshtml
.
@model Archymeta.Web.MembershipPlus.AppLayer.Models.UserDetailsVM
@using Microsoft.AspNet.Identity;
@using Archymeta.Web.MembershipPlus.AppLayer;
@using Archymeta.Web.Security.Resources;
<table id="channels" class="table personal-channels">
<thead>
<tr>
<th>
@ResourceUtils.GetString("a1fa27779242b4902f7ae3bdd5c6d509", "Type")
</th>
<th>
@ResourceUtils.GetString("dd7bf230fde8d4836917806aff6a6b28", "Address")
</th>
<th>
@ResourceUtils.GetString("0be8406951cdfda82f00f79328cf4efd", "Comment")
</th>
<th colspan="2" style="width:1px; white-space:nowrap;">
@ResourceUtils.GetString("456d0deba6a86c9585590550c797502e", "Operations")
</th>
</tr>
</thead>
<a name="knockout_channels_bind"><tbody data-bind="foreach: Channels"></a>
<tr>
<td>
<span data-bind="text: selectedType"></span>
</td>
<td><div>
<input data-bind="value: addr" class="form-control" />
</div></td>
<td><div>
<input data-bind="value: comment" class="form-control" />
</div></td>
<td style="width:1px;text-align:center;">
-->
<button class="btn btn-default btn-xs"
data-bind="click: function(data, event) {
_updateChannel(data, event) }"
title="...">
<span class="ion-arrow-up-a"></span>
</button>
-->
-->
-->
</td>
<td style="width:1px;text-align:center;">
<button class="btn btn-default btn-xs"
data-bind="click: function(data, event) {
_deleteChannel($parent, data, event) }"
title="...">
<span class="ion-close" style="color:Red"></span>
</button>
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td>
<select class="form-control"
style="min-width:100px;"
data-bind="options: channelTypes,
optionsValue: 'id',
optionsText: 'name',
value: selectedType,
optionsCaption: '..'">
</select>
</td>
<td><div>
<input data-bind="value: newChannel.addr" class="form-control" />
</div></td>
<td><div>
<input data-bind="value: newChannel.comment" class="form-control" />
</div></td>
<td style="width:1px;text-align:center;">
<button class="btn btn-default btn-xs"
data-bind="click: function(data, event) {
_addNewChannel(data, event,
'Please select a type',
'Please enter an address')}"
title="add the new channel ...">
<span class="ion-plus"></span>
</button>
</td>
<td style="width:1px;text-align:center;"> </td>
</tr>
</tfoot>
</table>
which is responsible for present an editable the current communication channel list for the current member. Here the <tbody>
element is bound to the Channels
observableArray vm
to create the channel list. The <tfoot>
element is used to provide a template for new elements to be added to the list, where the <select>
options for the types are bound to the channelTypes
observableArray of vm
and the member attributes are bound to the ones in the newChannel
JSON property of vm
. The following is the outcome:
Figure: Member communication channel editor.
There is an add button on the right hand side of the new element placeholder at the bottom. A delete button on each listed channel and, when the listed channel is modified, a update channel will appear as it is shown on the right hand side of the first channel above. KnockoutJS makes realizing this kind of dynamic behavior very simple and lightweighted (namely, it does not have to involve round trip request/response communications with the server).
The following is two KnockoutJS view models corresponding to CommunicationTypes
and Communications
data sets.
function ChannelType(id, name) {
var self = this;
self.id = id;
self.name = name;
}
function Channel(data) {
var self = this;
self.data = data;
self.addr = ko.observable(data.addr);
self.comment = ko.observable(data.comment);
self.selectedType = ko.observable(data.typeId);
self.isModified = ko.computed(function () {
return self.addr() != self.data.addr ||
self.comment() != self.data.comment;
});
}
These are simplified view models in which only some of the data attributes that are of current interests are defined. Completely mapped and synchronized KnockoutJS view models for any data set can be found under the Scripts\DbViewModels\MembershipPlus
sub-directory of the Membership+ data service.
Here only the editable attributes are associated with KnockoutJS observable
. The constructor of the Channel
view model takes an JSON object which is used to initialize its members accordingly. It has a computed property isModified
that can sense any change in the monitored properties (addr
and comment
observables) and notifies the parts of the view that bind to it of the change. For example the web page has
<!---->
<button class="btn btn-default btn-xs"
data-bind="click: function(data, event) {
_updateChannel(data, event) }"
title="...">
<span class="ion-arrow-up-a"></span>
</button>
<!---->
<!---->
<!---->
That will hide/show the update button based on whether or not any monitored attribute is changed against to the corresponding one loaded from the server.
The root view model for the current case is
function UserDetails() {
var self = this;
self.channelTypes = [];
self.Channels = ko.observableArray([]);
self.newChannel = {
typeId: 0,
addr: ko.observable(''),
comment: ko.observable('')
};
self.selectedType = ko.observable();
}
which contains a list of possible channel types in channelTypes
, an observable array of member communication channels in Channels
, a data place holder for a new communication channel in newChannel
and the current channel type in selectedType
. Channels
is bound to the channel list <tbody>
element (see here) so that for each channel in the observable array, it will produce the corresponding <tr>
sub-tree defined under the <tbody>
element. When the object in the Channels
is changed inside JavaScript code, the UI will be updated automatically.
3.7.3.1 Loading
As it is shown above, the member communication edit panel needs to have the Channels
and channelTypes
initialized to the current member state before operation can be made the change them. There are at least two ways that they can be initialized.
- Let the page load on the client side first, and then using a Ajax calls to a certain API method to retrieve the two lists.
- Generate the list on the server side. The two lists are JavaScript ones interpreted on the client side, the server side can not operate on them directly. The server side software can however acts as a JavaScript code generator that generates JavaScript statements to be interpreted by a browser so that the task can be done indirectly.
The second approach is taken here.
Therefore, for the channelTypes
property we have
@foreach (var m in Model.ChannelTypes)
{
<text>vm.channelTypes.push(new ChannelType(@m.ID, '@m.TypeName'));</text>
}
which produces
vm.channelTypes.push(new ChannelType(1, 'HomeAddress'));
vm.channelTypes.push(new ChannelType(2, 'WorkAddress'));
vm.channelTypes.push(new ChannelType(3, 'DaytimePhone'));
...
...
sequence of JavaScript statements on the client side. It is similar for the Channels
property but it is a little more complicated due to the fact that the Channel
view model is more complex and it only take a structured JSON data as input to its constructor, namely we expect something like
vm.Channels.push(new Channel(channel1);
vm.Channels.push(new Channel(channel2);
...
...
The JSON object channel1, channel1, etc., are constructed on the client side according to a string representation of the JSON object generated on the server, e.g.
var channel1 = JSON.parse('{
"id":"0c58b64e-dd57-4889-a6da-63ac4a4f5308",
"label":"NighttimePhone: 1223-3221 for sysadmin",
"addr":"1223-3221",
"comment":"",
"typeId":"NighttimePhone"
}'))
var channel2 = ...
....
Here, the string form of the JSON object is produced on the server.
The GetUserDetails
method (see here) of MembershipContext
called by the UsersDetails.cshtml
during loading on the server side populates the the Channels
property (of UserDetailsVM
!) with objects of dynamic
type that matches the above expected JSON data structure
foreach (var c in lc)
{
c.Comment = await csvc.LoadEntityCommentAsync(Cntx, c.ID);
c.IsCommentLoaded = true;
c.CommunicationTypeRef = await csvc.MaterializeCommunicationTypeRefAsync(Cntx, c);
m.Channels.Add(new { id = c.ID,
label = c.DistinctString,
addr = c.AddressInfo,
comment = c.Comment,
typeId = c.CommunicationTypeRef.TypeName });
}
Objects of dynamic
type can be easily serialized into string form using the JavaScriptSerializer
inside the System.Web.Script.Serialization
name space. The JSON string generating part of the JavaScript section (see here)
'@Html.Raw(Utils.GetDynamicJson(c))'
of the UserDetails.cshtml
web page calls the GetDynamicJson
method of the Utils
class defined in the MembershipPlusAppLayer45
project
public static string GetDynamicJson(object obj)
{
JavaScriptSerializer ser = new JavaScriptSerializer();
return ser.Serialize(obj);
}
is used to connect server side .net objects and client side JSON objects.
The click event of the add button, which appears only to the right of the new channel template at the bottom, is handled by the following JavaScript method
function _addNewChannel(data, event, typeMsg, addrMsg) {
var typeId = data.selectedType();
var addr = data.newChannel.addr();
if (typeId) {
if (addr != '') {
$.ajax({
url: appRoot + "Account/AddChannel",
type: "POST",
dataType: "json",
contentType: "application/json; charset=utf-8",
data: JSON.stringify({ typeId: typeId,
address: addr, comment:
data.newChannel.comment() }),
success: function (content) {
if (!content.ok) {
alert(content.msg);
} else {
data.selectedType(null);
data.newChannel.comment('');
data.newChannel.addr('');
data.Channels.push(new Channel(content.data));
}
},
error: function (jqxhr, textStatus) {
alert(jqxhr.responseText);
},
complete: function () {
}
});
} else {
alert(addrMsg);
}
} else {
alert(typeMsg);
}
}
It invokes the AddChannel
method of the AccountController
class
[HttpPost]
[Authorize]
[OutputCache(NoStore = true, Duration = 0)]
public async Task<ActionResult> AddChannel(int typeId, string address, string comment)
{
var data = await MembershipContext.AddChannel(User.Identity.GetUserId(),
typeId, address, comment);
return JSON(data);
}
It returns the added channel data as JSON formatted data structure. The invoked AddChannel
method of the MembershipContext
class returns an object of dynamic
type
public static async Task<dynamic> AddChannel(string id, int typeId, string address, string comment)
{
CommunicationServiceProxy csvc = new CommunicationServiceProxy();
Communication c = new Communication();
c.ID = Guid.NewGuid().ToString();
c.TypeID = typeId;
c.UserID = id;
c.ApplicationID = ApplicationContext.App.ID;
c.AddressInfo = address;
c.Comment = comment;
var result = await csvc.AddOrUpdateEntitiesAsync(Cntx, new CommunicationSet(),
new Communication[] { c });
if ((result.ChangedEntities[0].OpStatus & (int)EntityOpStatus.Added) > 0)
{
c = result.ChangedEntities[0].UpdatedItem;
c.CommunicationTypeRef = await csvc.MaterializeCommunicationTypeRefAsync(Cntx, c);
var dc = new {
id = c.ID,
label = c.DistinctString,
addr = c.AddressInfo,
comment = c.Comment,
typeId = c.CommunicationTypeRef.TypeName
};
return new { ok = true, msg = "", data = dc };
}
else
return new {
ok = false,
msg = ResourceUtils.GetString("954122aa46fdc842a03ed8b89acdd125", "Add failed!")
};
}
The returned dynamic object has a flag (ok
) attribute that indicates whether or not the operation is successful, a msg
attribute that provides more information in case of failure, and a data
attribute that hold the dynamic (type of) data corresponding to the newly added communication channel in case the operation succeeds.
When the operaton returns, the following code are executed
if (!content.ok) {
alert(content.msg);
} else {
data.selectedType(null);
data.newChannel.comment('');
data.newChannel.addr('');
data.Channels.push(new Channel(content.data));
}
Namely, if it was not successful, it displays the error message, otherwise, it clears up the newChannel
object first and then add a new channel view model object constructed from the returned JSON data. The KnockoutJS framework will guaratee that the added channel will be added to the view (the web page inside the browser) automatically.
The update button to the right of an existing channel only appears when the said channel is modified. Its click event is handled by the following JavaScript method
function _updateChannel(data, event) {
$.ajax({
url: appRoot + "Account/UpdateChannel",
type: "POST",
dataType: "json",
contentType: "application/json; charset=utf-8",
data: JSON.stringify({ id: data.data.id,
address: data.addr(),
comment: data.comment() }),
success: function (content) {
if (!content.ok) {
alert(content.msg);
} else {
data.data.addr = data.addr();
data.addr('');
data.addr(data.data.addr);
data.data.comment = data.comment();
data.comment('');
data.comment(data.data.comment);
}
},
error: function (jqxhr, textStatus) {
alert(jqxhr.responseText);
},
complete: function () {
}
});
}
It invokes the UpdateChannel
method of the AccountController
class, which in turn invokes the corresponding method in MembershipContext
class, and which we should not describe in more details since it is quite similar with the above case.
The delete button is located at the right of an existing channel. Its click event is handled by the following JavaScript method
function _deleteChannel(parent, data, event) {
$.ajax({
url: appRoot + "Account/DeleteChannel",
type: "POST",
dataType: "json",
contentType: "application/json; charset=utf-8",
data: JSON.stringify({ id: data.data.id }),
success: function (content) {
if (!content.ok) {
alert(content.msg);
} else {
var cnt = parent.Channels().length;
for (var i = 0; i < cnt; i++) {
var c = parent.Channels()[i];
if (c.data.id == data.data.id) {
parent.Channels.remove(c);
break;
}
}
}
},
error: function (jqxhr, textStatus) {
alert(jqxhr.responseText);
},
complete: function () {
}
});
}
It invokes the DeleteChannel
method of the AccountController
class, which in turn invokes the corresponding method in MembershipContext
class, and which we should not describe in more details since, again, it is quite similar with the "add" case.
This completes the implementation details for the scope of this article. Although the lengthy description may give a reader the impression that it is quite involved in getting even the presently very simple functionalities to work, it is not really that much once he/she gets used to the workflow and patterns which can be applied to more complex situations.
Albeit JSON based data and JavaScript coding framework is quite flexible and cheap to start, they are not that easy to maintain. Simple typing error or mis-sync of changed data schema (the ones that developers keep in mind or write on design paper) could lead to potential future runtime errors instead of instant compile time error without setting up an extensive testing plan and documentation. Maintainers or developers of these loosely typed systems therefore inherit higher "technical debt" from their predecessors. Therefore some balanced mix use of strong typed back end and JavaScript based front end seems to be a good way of keeping maintainability and yet having flexibility.
But when they are used to join heterogeneous systems together in thin proxy or adaptation layers backed by strongly typed systems, they could be very useful due to the fact that they allow the so called duck typing which could significantly simplifies the mapping process.
From the current version on the default data service root url is set to http://localhost/membp
inside the <system.serviceModel>
node of the Web.config
file of the web application. A reader can either change it to point to the place where he/she sets up the data service or setup the data service on the local machine and with a web application name (namely membp
).
- 2014-02-25. Article Version 1.0.0, initial publication.
- 2014-03-02. Article Version 1.0.5, Data schema for UserDetails data set is changed. Improved documentation for the data service. Modification to the source code was made.
If a reader has sufficient knowledge about the Git source control system, he/she can follow the Git repository on github.com for the project. The source code for the current article is maintained on the codeproject-2 branch, namely here.