CodeStash Article Listings
Table of Contents
Last time we talked about what
CodeStash actually
is, and some of the high level aspects of the web site and showed some screen
shots. This time we are going to take a deep dive into each of the pages that
make up the CodeStash
web site. So lets carry on to look at these pages, which are all discussed
below.
The general approach I am going to take while going through the seperate pages
of the CodeStash web
site, is that I am going to follow the same pattern eaxch time, where the
pattern will be as follows:
- I will show a screen shot of the given page (as we saw in the 1st
article)
- I will then talk about how the page works and what functionality it is
trying to provide
- I will then show the most relevant parts of the Controller
- I will then show the most relevant parts of the CsHtml file
- I will then show the most relevant parts of the JavaScript file
The idea behind
CodeStash was to
offer a flexible login model, which would allow users to either register or use
some existing OpenId provider (such as Google/AOL etc etc) to login.
I also wanted the OpenId login to work in conjunction with the standard ASP .NET
forms authentication mechanism. There really is not that much information
available about how to do that, especially if the web is not you main skill
(which is the case with me).
There is an excellent starting project on how to integrate OpenId and standard
ASP .NET forms authentication into an ASP MVC 3 application, this was used as
the basis for
CodeStash which is
available at :
http://weblogs.asp.net/haithamkhedre/archive/2011/03/13/openid-authentication-with-asp-net-mvc3-dotnetopenauth-and-openid-selector.aspx
In order to carry out the OpenId authentication I have used the excellent free
.NET library "DotNetOpenAuth.dll".
I have expanded apon this quite a lot, and what you now see in
CodeStash works as
follows:
Any standard ASP .NET MVC Controller action that requires OpenId
authorization will simply use the standard [Authorize] attribute which is enough
to make it link in within this OpenId/forms authentication code.
This starter project provides the ability to do:
- Login using OpenId
- Register as a new user (without using an existing OpenId login)
The 1st step of the login process is that the user logs in to their OpenId
provider, once that is done, the user is given an OpenId token by their
provider. The next step is that the user information is associated with the
obtained OpenId token, and stored as a standard ASP .NET Membership user. At
this point the newly created ASP .NET Membership users details are also used to
create a authentication cookie is stored for the forms authentication mechanism,
and the user is then considered to be authorized and will be allowed access to
the rest of the
CodeStash web site.
When the user is considered to be authorized by the OpenId provider a new ASP
.NET Membership user is created, along with a encrypted
CodeStash token,
which ideally would be emailed to the user, that however has not been done. So a
compromise is that we show the authenticated user a standard ASP .NET MVC View
with the encrypted
CodeStash token
shown, and ask them to write it down ready to enter into the VS2010 addin host
app, as described in Pete's upcoming articles.
Once this initial login is done and a ASP .NET Membership user has been
created and subsequent login by the user will only require the user to click
their OpenId provider button in future, and the OpenId token will be obtained
and the user will be able to login using 1 simple click.
The OpenId library that I am using "DotNetOpenAuth.dll"
can also be configured through a custom config section which is shown below.
<configSections>
<section name="dotNetOpenAuth" type="DotNetOpenAuth.Configuration.DotNetOpenAuthSection" requirePermission="false" allowLocation="true" />
</configSections>
-->
<dotNetOpenAuth>
<openid>
<relyingParty>
<security requireSsl="false" />
<behaviors>
-->
<add type="DotNetOpenAuth.OpenId.Behaviors.AXFetchAsSregTransform, DotNetOpenAuth" />
</behaviors>
</relyingParty>
</openid>
<messaging>
<untrustedWebRequest>
<whitelistHosts>
-->
<add name="localhost" />
</whitelistHosts>
</untrustedWebRequest>
</messaging>
-->
<reporting enabled="true" />
</dotNetOpenAuth>
So that is the general idea behind it, are you ready to dive a little deeper?
Come on it will be fun.
The login process is conducted in the AccountController
, and works as
follows:
- Page that needs authorization (using the standard
AuthorizeAttribute
) is requested.
- Redirected to GET
AccountController Logon
action, if the
user is not authenticated.
- Logon view is shown, which has all the OpenID provider links on them,
where the HTML form is set to POST to the
AccountController
POST Logon
action.
- User picks an OpenID provider, and clicks it, which calls the
JavaScript, which really just results in the OpenID provider string being
stored and a POST request being made to the
AccountController Logon
action.
- The
AccountController
s POST Logon
action does two things:
- It adds on a
ClaimRequest
, where it asks the OpenID provider to
include Email/FullName in the data that will be included with a
successful response from the OpenID provider.
- Then redirects the OpenID provider web site (via magic of
DotNetOpenAuth.dll), where the user enters their details.
- If the user enters valid credentials at the OpenID provider's web site,
they are redirected to the default Logon action on
AccountController
(via
magic of DotNetOpenAuth.dll), at which point, it will examine the result
from the OpenID provider IAuthenticationResponse
response, which is
available by calling the OpenIdRelyingParty type's GetResponse()
method. If
the response is found to be AuthenticationStatus.Authenticated
, the user is
deemed validated, and then more details about the user can be requested from
the OpenID provider's response, which is accomplished using
response.GetUntrustedExtension<ClaimsResponse>
/
response.GetExtension<ClaimsResponse>
, where ClaimsResponse
is the response
that matches the ClaimsResponse
that we asked the OpenID provider to include
in the AccountController
's POST Logon action in Step 5. We can then use the
ClaimsResponse
to obtain the user's Email and FullName from the OpenID
provider's ClaimsResponse
.
And here is the most relevant parts of the AccountController's OpenId
methods
using System;
using System.Web.Mvc;
using System.Web.Security;
using CodeStash.Models.Security;
using CodeStash.Services;
using DotNetOpenAuth.Messaging;
using DotNetOpenAuth.OpenId;
using DotNetOpenAuth.OpenId.Extensions.AttributeExchange;
using DotNetOpenAuth.OpenId.Extensions.SimpleRegistration;
using DotNetOpenAuth.OpenId.RelyingParty;
namespace CodeStash.Controllers
{
public class AccountController : Controller
{
private static OpenIdRelyingParty openid = new OpenIdRelyingParty();
private IFormsAuthenticationService formsService;
private IMembershipService membershipService;
private ILoggerService loggerService;
public AccountController( IFormsAuthenticationService formsService,
IMembershipService membershipService,
ILoggerService loggerService)
{
this.formsService = formsService;
this.membershipService = membershipService;
this.loggerService = loggerService;
}
public ActionResult LogOn()
{
loggerService.Info("LogOn GET");
return View();
}
[HttpPost]
[ValidateAntiForgeryToken(Salt = "LogOn")]
public ActionResult LogOn(LogOnModel model, string returnUrl)
{
if (ModelState.IsValid)
{
if (membershipService.ValidateUser(model.UserName, model.Password))
{
formsService.SignIn(model.UserName, model.RememberMe);
if (Url.IsLocalUrl(returnUrl))
{
loggerService.Info(string.Format("LogOn : Sucessful redirecting to {0}", returnUrl));
return Redirect(returnUrl);
}
else
{
Session["EncryptedPasswordForUserToWriteDown"] = null;
loggerService.Error("LogOn : UnSucessful logon redirecting to Home");
return RedirectToAction("Index", "Home");
}
}
else
{
loggerService.Error("LogOn : The user name or password provided is incorrect.");
ModelState.AddModelError("", "The user name or password provided is incorrect.");
}
}
return View(model);
}
[ValidateInput(false)]
public ActionResult Authenticate(string returnUrl)
{
var response = openid.GetResponse();
if (response == null)
{
Identifier id;
if (Identifier.TryParse(Request.Form["openid_identifier"], out id))
{
try
{
var request = openid.CreateRequest(Request.Form["openid_identifier"]);
var claim = new ClaimsRequest
{
Email = DemandLevel.Require,
Nickname = DemandLevel.Require,
FullName = DemandLevel.Request,
};
var fetch = new FetchRequest();
fetch.Attributes.AddRequired(WellKnownAttributes.Name.First);
fetch.Attributes.AddRequired(WellKnownAttributes.Name.Last);
request.AddExtension(claim);
request.AddExtension(fetch);
return request.RedirectingResponse.AsActionResult();
}
catch (ProtocolException ex)
{
ViewBag.Message = ex.Message;
return View("LogOn");
}
}
ViewBag.Message = "Invalid identifier";
return View("LogOn");
}
switch (response.Status)
{
case AuthenticationStatus.Authenticated:
LogOnModel lm = new LogOnModel();
lm.OpenID = response.ClaimedIdentifier;
var claim = response.GetExtension<ClaimsResponse>();
var fetch = response.GetExtension<FetchResponse>();
var nick = response.FriendlyIdentifierForDisplay;
var email = string.Empty;
if (claim != null)
{
nick = string.IsNullOrEmpty(claim.Nickname) ? claim.FullName : claim.Nickname;
email = claim.Email;
}
if (string.IsNullOrEmpty(nick) && fetch != null &&
fetch.Attributes.Contains(WellKnownAttributes.Name.First) &&
fetch.Attributes.Contains(WellKnownAttributes.Name.Last))
{
nick = fetch.GetAttributeValue(WellKnownAttributes.Name.First) + " " +
fetch.GetAttributeValue(WellKnownAttributes.Name.Last);
}
MembershipUser user = membershipService.GetUserByOpenId(lm.OpenID);
Tuple<MembershipCreateStatus, String> resultsAndPassword = null;
if (user == null)
{
resultsAndPassword = membershipService.CreateUser(nick, email, lm.OpenID);
Session["EncryptedPasswordForUserToWriteDown"] = resultsAndPassword.Item2;
user = membershipService.GetUserByOpenId(lm.OpenID);
}
else
{
Session["EncryptedPasswordForUserToWriteDown"] = user.GetPassword();
}
if (user != null)
{
lm.UserName = user.UserName;
formsService.SignIn(user.UserName, false);
Session["User"] = user;
return RedirectToAction("Index", "Home");
}
else
{
return View("LogOn", lm);
}
case AuthenticationStatus.Canceled:
ViewBag.Message = "Canceled at provider";
return View("LogOn");
case AuthenticationStatus.Failed:
ViewBag.Message = response.Exception.Message;
return View("LogOn");
}
return new EmptyResult();
}
}
}
The login mechanism works in conjunction with the standard ASP MVC AuthorizeAttribute
action filter, which can be seen in any number of controller actions within CodeStash, an example
of which is shown below
[Authorize]
[RenderTagCloud]
public ActionResult About()
{
return View();
}
As we dicsussed earlier if there is no forms authentication token found and a
controllers action is marked with the [Authorize]
attribute, the
user will be redirected to the login page, until such a time that they have
logged in.
And this is what the Login view looks like, where the user can choose to
login using
- OpenId authentication, where they will not really need to enter anything
just click their OpenId provider (providing they have logged in before and
the forms authentication cookie is still around)
- SStandard username/password (from registration process)
There are a couple of things to note there, such as:
- The browser's address bar shows a query string which includes a
ReturnUrl
which is set to ReturnUrl=/Site/Data
, which
just happens to be the controller/action URL of the page that we tried to
load that required authorization before it could be viewed. This
ReturnUrl
query string parameter is a standard feature of Forms
Authentication, which is what we will eventually use to store just the
Authentication cookie.
- There are quite a few image buttons that can be clicked. Each of these
images represents an OpenID compliant site that you could use to login with.
For example, I have a Google account, so I may choose to use my Google
credentials. I should point out that I got the bulk of the content for the
Logon.aspx page from a blog somewhere, but I can not recall where from, so
apologies for not mentioning the source directly in this article.
If I proceed to use my Google account by clicking on the Google image, the
current browser session will be navigated to Google, where I can enter my normal
login credentials, as shown below:
Here is the most relevant parts of the Login views markup
@model CodeStash.Models.Security.LogOnModel
@{ ViewBag.Title = "Log On";
}
@section SpecificPageHeadStuff
{
@Html.ScriptTag(Url.Content("~/Scripts/Controllers/Account/accountFunctions.js"))
@Html.ScriptTag(Url.Content("~/Scripts/jquery.validate.min.js"))
@Html.ScriptTag(Url.Content("~/Scripts/jquery.validate.unobtrusive.min.js"))
}
@using CodeStash.ExtensionsMethods
<h2>
Log On</h2>
<p>
You may login by choosing your OpenId Provider, or by entering your CodeStash username
and password. Or you can @Html.ActionLink("Register", "Register")
if you don't have an account.
</p>
<form action="Authenticate?ReturnUrl=@HttpUtility.UrlEncode(Request.QueryString["ReturnUrl"])"
method="post" id="openid_form">
<input type="hidden" name="action" value="verify" />
<div class="logonBox">
<fieldset>
<legend>Login using OpenID</legend>
<div class="openid_choice">
<p>
Please click your account provider:</p>
<div id="openid_btns">
</div>
</div>
<div id="openid_input_area">
@Html.TextBox("openid_identifier")
<input type="submit" value="Log On" />
</div>
<noscript>
<p>
OpenID is service that allows you to log-on to many different websites using a single
indentity. Find out <a href="http://openid.net/what/">more about OpenID</a> and
<a href="http://openid.net/get/">how to get an OpenID enabled account</a>.</p>
</noscript>
<div>
@if (Model != null)
{
if (String.IsNullOrEmpty(Model.UserName))
{
<div class="editor-label">
@Html.LabelFor(model => model.OpenID)
</div>
<div class="editor-field">
@Html.DisplayFor(model => model.OpenID)
</div>
<p class="button">
@Html.ActionLink("New User ,Register", "Register", new { OpenID = Model.OpenID })
</p>
}
}
</div>
</fieldset>
</div>
</form>
@Html.ValidationSummary(true, "Login was unsuccessful. Please correct the errors and try again.")
@using (Html.BeginForm("Logon", "Account", FormMethod.Post, new { id = "LogonForm" }))
{
@Html.AntiForgeryToken("LogOn")
<div class="logonBox">
<fieldset>
<legend>Or Login Normally</legend>
<div class="editor-label">
@Html.LabelFor(m => m.UserName)
</div>
<div class="editor-field">
@Html.TextBoxFor(m => m.UserName, new { style = "width:300px" })
@Html.ValidationMessageFor(m => m.UserName)
</div>
<div class="editor-label">
@Html.LabelFor(m => m.Password)
</div>
<div class="editor-field">
@Html.PasswordFor(m => m.Password, new { style = "width:300px" })
@Html.ValidationMessageFor(m => m.Password)
</div>
<div class="editor-label">
@Html.CheckBoxFor(m => m.RememberMe)
<label for="RememberMe" class="centerAlignedText">
Remember me?</label>
</div>
<p>
<br />
<span class="btn"><a id="LogOnBtn" href="#">LogOn</a><span></span></span> <span class="clear">
</span>
<br />
</p>
</fieldset>
</div>
}
One of the most important parts of the Login views markup is the actual form tag(s) markup. The Login view actually has 2 seperate
form tags which deal with the different types of Login
OpenId Login Form Tag
This form ensures that the AccountController Authenticate
action is called,
which we saw in the AccountController
's code above.
<form action="Authenticate?ReturnUrl=@HttpUtility.UrlEncode(Request.QueryString["ReturnUrl"])"
method="post" id="openid_form">
</form>
Standard Forms Authentication Login Form Tag
This form ensures that the AccountController
Login POST action is called,
which we saw in the AccountController
's code above.
@using (Html.BeginForm("Logon", "Account", FormMethod.Post, new { id = "LogonForm" }))
{
}
This is the page you would use to register a new user which would use
standard forms authentication ie: username/password authentication
As forms authentication is so well known in ASP .NET development, I won't
bore you with too many details I will just give you the bear bones details
So this is what the register portion of the AccountController
looks like
using System;
using System.Web.Mvc;
using System.Web.Security;
using CodeStash.Models.Security;
using CodeStash.Services;
using DotNetOpenAuth.Messaging;
using DotNetOpenAuth.OpenId;
using DotNetOpenAuth.OpenId.Extensions.AttributeExchange;
using DotNetOpenAuth.OpenId.Extensions.SimpleRegistration;
using DotNetOpenAuth.OpenId.RelyingParty;
namespace CodeStash.Controllers
{
public class AccountController : Controller
{
private static OpenIdRelyingParty openid = new OpenIdRelyingParty();
private IFormsAuthenticationService formsService;
private IMembershipService membershipService;
private ILoggerService loggerService;
public AccountController( IFormsAuthenticationService formsService,
IMembershipService membershipService,
ILoggerService loggerService)
{
this.formsService = formsService;
this.membershipService = membershipService;
this.loggerService = loggerService;
}
public ActionResult Register(string OpenID)
{
loggerService.Info("Register : New user registration selected");
ViewBag.PasswordLength = membershipService.MinPasswordLength;
ViewBag.OpenID = OpenID;
return View();
}
[HttpPost]
[ValidateAntiForgeryToken(Salt = "Register")]
public ActionResult Register(RegisterModel model)
{
if (ModelState.IsValid)
{
MembershipCreateStatus createStatus = membershipService.CreateUser(
model.UserName, model.Password, model.Email, model.OpenID);
if (createStatus == MembershipCreateStatus.Success)
{
formsService.SignIn(model.UserName, false );
loggerService.Info("Register : Sucess creating new user");
Session["EncryptedPasswordForUserToWriteDown"] = null;
return RedirectToAction("Index", "Home");
}
else
{
ModelState.AddModelError("", AccountValidation.ErrorCodeToString(createStatus));
}
}
else
{
loggerService.Info("Register : There were some error registering, " +
"possibly due to missing/incorrect registration settings being supplied");
}
ViewBag.PasswordLength = membershipService.MinPasswordLength;
return View(model);
}
}
}
Where we have the following Register view markup
@model CodeStash.Models.Security.RegisterModel
@{
ViewBag.Title = "Register";
}
@section SpecificPageHeadStuff
{
@Html.ScriptTag(Url.Content("~/Scripts/Controllers/Account/accountFunctions.js"))
@Html.ScriptTag(Url.Content("~/Scripts/jquery.validate.min.js"))
@Html.ScriptTag(Url.Content("~/Scripts/jquery.validate.unobtrusive.min.js"))
}
@using CodeStash.ExtensionsMethods
<h2>Create a New Account</h2>
<p>
Use the form below to create a new account.
</p>
<p>
Passwords are required to be a minimum of @ViewBag.PasswordLength characters in length.
</p>
@using (Html.BeginForm("Register", "Account", FormMethod.Post, new { id = "RegisterForm" }))
{
@Html.AntiForgeryToken("Register")
@Html.ValidationSummary(true,
"Account creation was unsuccessful. Please correct the errors and try again.")
<div>
<fieldset>
<legend>Account Information</legend>
@if (ViewData["OpenID"] != null)
{
<div class="editor-label">
@Html.Label("OpenID")
</div>
<div class="editor-label">
@Html.Label((string)ViewBag.OpenID)
</div>
}
<div class="editor-label">
@Html.LabelFor(m => m.UserName)
</div>
<div class="editor-field">
@Html.TextBoxFor(m => m.UserName, new { style = "width:300px" })
@Html.ValidationMessageFor(m => m.UserName)
</div>
<div class="editor-label">
@Html.LabelFor(m => m.Email)
</div>
<div class="editor-field">
@Html.TextBoxFor(m => m.Email, new { style = "width:300px" })
@Html.ValidationMessageFor(m => m.Email)
</div>
<div class="editor-label">
@Html.LabelFor(m => m.Password)
</div>
<div class="editor-field">
@Html.PasswordFor(m => m.Password, new { style = "width:300px" })
@Html.ValidationMessageFor(m => m.Password)
</div>
<div class="editor-label">
@Html.LabelFor(m => m.ConfirmPassword)
</div>
<div class="editor-field">
@Html.PasswordFor(m => m.ConfirmPassword, new { style = "width:300px" })
@Html.ValidationMessageFor(m => m.ConfirmPassword)
</div>
<p>
<br />
<span class="btn"><a id="RegisterBtn"
href="#">Register</a><span></span></span>
<span class="clear"></span>
<br />
</p>
</fieldset>
</div>
}
And too be honest that this pretty much all there is to the register process,
oh apart from this bit of Web.Config configuration that states that forms
authentication is being used
<authentication mode="Forms">
<forms loginUrl="~/Account/LogOn" timeout="2880" />
</authentication>
OpenId JavaScripts
There is also a set of javascripts for the OpenId selector, which is based on
a freely available library which is downloadable from :
http://code.google.com/p/openid-selector/
This will give you the following 4 components which are registered in the
master page
- openid-en.js
- openid-jquery.js
- openid-shadow.css
- openid.css
The master page (Views\Shared\_Layout.cshtml) provides all the common
CSS/Javascript that CodeStash
web site uses.
If you are not currently logged in you will see a master page like this
Once you are logged in you will see a master page like this
Shown below is the relevant markup for the Master page
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
@using Telerik.Web.Mvc.UI
@using CodeStash.ExtensionsMethods
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>@ViewBag.Title</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
@Html.CssTag(Url.Content("~/Content/openid-shadow.css"))
@Html.CssTag(Url.Content("~/Content/openid.css"))
@Html.CssTag(Url.Content("~/Content/themes/base/jquery-ui.css"))
@Html.CssTag(Url.Content("~/Content/themes/base/jquery.ui.dialog.css"))
@Html.CssTag(Url.Content("~/Content/Highlighting/jquery.snippet.css"))
@Html.CssTag(Url.Content("~/Content/site.css"))
@Html.ScriptTag(Url.Content("~/Scripts/jquery-1.6.4.min.js"))
@Html.ScriptTag(Url.Content("~/Scripts/modernizr-1.7.min.js"))
@Html.ScriptTag(Url.Content("~/Scripts/jquery.tools.min.js"))
@Html.ScriptTag(Url.Content("~/Scripts/jquery.tmpl.js"))
@Html.ScriptTag(Url.Content("~/Scripts/openid-jquery.js"))
@Html.ScriptTag(Url.Content("~/Scripts/openid-en.js"))
@Html.ScriptTag(Url.Content("~/Scripts/jquery-ui-1.8.16.min.js"))
@Html.ScriptTag(Url.Content("~/Scripts/Common/commonFunctions.js"))
@Html.ScriptTag(Url.Content("~/Scripts/Highlighting/jquery.snippet.js"))
@( Html.Telerik().StyleSheetRegistrar().DefaultGroup(group => group
.DefaultPath("~/Content/Telerik")
.Add("telerik.common.css")
.Add("telerik.Black.min.css"))
)
<script type="text/javascript">
$(document).ready(function () {
openid.init( });
</script>
@RenderSection("SpecificPageHeadStuff", false)
</head>
<body>
<div id="header">
</div>
<div id="main-wrapper">
<img id="logo" src="../../Content/Images/Logo.png" />
<img id="logoFiles" src="../../Content/Images/files.png" />
<div id="main">
<div id="sidebar">
<div class="gadget">
<h2>Settings</h2>
<div class="clr">
</div>
<ul class="sb_menu">
<li>@Html.Partial("_LogOnPartial")</li>
<li><a href="http://www.codeproject.com/Team">Team Settings</a></li>
<li><a href="http://www.codeproject.com/Account/ChangePassword">Change Password</a></li>
<li><a href="http://www.codeproject.com/Settings">Change Settings</a></li>
</ul>
</div>
<div class="gadget">
<h2>Actions</h2>
<div class="clr">
</div>
<ul class="sb_menu">
<li><a href="http://www.codeproject.com/Search/CreateSearch">Search</a></li>
<li><a href="http://www.codeproject.com/CodeSnippet">Add Code Snippet</a></li>
<li><a href="http://www.codeproject.com/CodeSnippet/OpenFromWeb">Open From Web</a></li>
</ul>
</div>
@Html.Partial("_TagCloudPartial")
</div>
<div id="mainbar">
@RenderBody()
</div>
<div class="clr">
</div>
</div>
</div>
</body>
@(Html.Telerik().ScriptRegistrar()
.Globalization(true)
.jQuery(false).DefaultGroup(group => group
.DefaultPath("~/Scripts/Telerik")
.Add("telerik.common.min.js")
.Add("telerik.grid.min.js")
.Add("telerik.textbox.min.js")
.Add("telerik.calendar.min.js")
.Add("telerik.datepicker.min.js")
.Add("telerik.grid.filtering.min.js"))
)
</html>
There is not too much to say about this markup except that it utilises the
hashing HtmlHelper
extentions methods that we saw last time that
hash the CSS/JavaScript files, and it also registeres the free Telerik MVC
contributions controls which are used within Search, which we will see later.
The TagCloud is your typical arrangement of hyperlinks, where the cloud groups
all the saved code snippets into their corresponding Categories (Category in the
database), and will only show the top ranking sums of these snippets as a set of
hyperlinks available on the Master Page (provided the user has logged in).
Here is what the TagCloud looks like. Obviously some of the categories shown
below are just dummy ones I have added to illlustrate the point
The rendering of the tab cloud is largely done thanks to a specialized
ActionFilter
called RenderTagCloudAttribute
which should
ONLY be used on full page views. Though there is nothing to
prevent it be used on controller actions that don't return full page views.
Here is the code from the RenderTagCloudAttribute
using System.Security.Principal;
using System.Web.Mvc;
using CodeStash.Controllers;
namespace CodeStash.Filters
{
public class RenderTagCloudAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
IPrincipal user = ((Controller)filterContext.Controller).User;
if (user != null && user.Identity.IsAuthenticated)
{
if (filterContext.Controller is BaseTagCloudEnabledController)
{
((BaseTagCloudEnabledController)filterContext.Controller).RenderAndCalculateTagCloud();
}
if (filterContext.Controller is BaseTagCloudEnabledAsyncController)
{
((BaseTagCloudEnabledAsyncController)filterContext.Controller).RenderAndCalculateTagCloud();
}
}
}
}
}
There is also a specialized BaseTagCloudEnabledController
and BaseTagCloudEnabledAsyncController
which
can be inherited from that provide some base functionality for the Tag Cloud. Here is the relevant code
public abstract class BaseTagCloudEnabledAsyncController : AsyncController
{
private readonly ITagCloudService tagCloudService;
public BaseTagCloudEnabledAsyncController(ITagCloudService tagCloudService)
{
this.tagCloudService = tagCloudService;
}
public void RenderAndCalculateTagCloud()
{
Random rand = new Random();
IEnumerable<TagCategoryModel> tags = this.tagCloudService.CreateTagCloud();
if (tags.Any())
{
if (tags.Count() < 10)
ViewData["TagCloud"] = tags;
else
ViewData["TagCloud"] = tags.Take(10);
}
else
ViewData["TagCloud"] = new List<TagCategoryModel>();
ViewData["Rand"] = rand;
}
}
It can be seen that this code makes use of a tagCloudService
which works as follows:
using System.Collections.Generic;
using System.Linq;
using CodeStash.Common.DataAccess.EntityFramework;
using CodeStash.Common.DataAccess.Repository;
using CodeStash.Common.DataAccess.UnitOfWork;
using CodeStash.Models.TagCloud;
namespace CodeStash.Services
{
public class TagCloudService : ITagCloudService
{
private readonly IUnitOfWork unitOfWork;
private readonly IRepository<CodeSnippet> codeSnippetRepository;
private readonly IRepository<CodeCategory> codeCategoryRepository;
public TagCloudService(IUnitOfWork unitOfWork,
IRepository<CodeSnippet> codeSnippetRepository,
IRepository<CodeCategory> codeCategoryRepository)
{
this.unitOfWork = unitOfWork;
this.codeSnippetRepository = codeSnippetRepository;
this.codeCategoryRepository = codeCategoryRepository;
}
public IEnumerable<TagCategoryModel> CreateTagCloud()
{
using (unitOfWork)
{
codeSnippetRepository.EnrolInUnitOfWork(unitOfWork);
codeCategoryRepository.EnrolInUnitOfWork(unitOfWork);
int totalCodeSnippets = codeSnippetRepository.FindAll().Count();
var categories = codeCategoryRepository.FindAll("CodeSnippets").AsEnumerable();
var tagCategories =
(from c in categories
orderby c.CodeCategoryName
select new TagCategoryModel
{
CategoryId = c.CodeCategoryId,
CategoryName = string.Format("{0}", c.CodeCategoryName.Trim()),
CountOfCategory = c.CodeSnippets.Count(),
TotalArticles = totalCodeSnippets
});
return (from x in tagCategories
where x.CountOfCategory > 0
orderby x.CountOfCategory descending
select x).ToList();
}
}
}
}
The master page _Layout.cshtml
has this line @Html.Partial("_TagCloudPartial")
in it which renders the Tag Cloud partial view which is shown below
@if (ViewData["TagCloud"] != null)
{
IEnumerable<CodeStash.Models.TagCloud.TagCategoryModel> cats =
(IEnumerable<CodeStash.Models.TagCloud.TagCategoryModel>)ViewData["TagCloud"];
if (cats.Any())
{
<div class="gadget">
<h2>Tag Cloud</h2>
<div class="clr">
</div>
<div id="tagCloud">
@foreach (var t in cats)
{
Random rand = (Random)ViewData["Rand"];
string[] colors = new string[] { "#21587D", "#3181B7",
"#1273B5", "#0D5382", "#2C5E7F", "#347096" };
<span>
@Html.ActionLink(string.Format("{0} ",t.CategoryName),
"DisplaySnippetsForCategory","CodeSnippet",
new { category= t.CategoryName},
new
{
style=string.Format("color : {0}",colors[rand.Next(colors.Length)]),
@class = CodeStash.Utils.WebSiteUtils.GetTagClass(t.CountOfCategory, t.TotalArticles)
})
</span>
}
</div>
</div>
}
}
It can be seen that this view simple renders the data that is stored in the ViewData
The profile page allows a logged in CodeStash
web site user to adjust their personal settings. At the moment this is limited
to 2 settings but this may expand in the future
- Maximum snippets to display : This settings limits the displaying of
snippets to a maximum number of items. If this number is exceeded a better
search should be conducted
- Snippet highlighting CSS : This picks what CSS styles to use for the
snippet highlighting
public class SettingsController : BaseTagCloudEnabledController
{
private readonly ILoggerService loggerService;
private readonly IMembershipService membershipService;
public SettingsController(
ILoggerService loggerService,
IMembershipService membershipService,
ITagCloudService tagCloudService)
: base(tagCloudService)
{
this.loggerService = loggerService;
this.membershipService = membershipService;
}
[Authorize]
[RenderTagCloud]
[HttpGet]
public ActionResult Index()
{
if (User.Identity.IsAuthenticated)
{
MembershipUser user =
membershipService.GetUserByUserName(User.Identity.Name);
UserSettingsProfileModel profile =
UserSettingsProfileModel.GetUserProfile(User.Identity.Name);
ChangeSettingsModel vm = new ChangeSettingsModel();
....
....
return View(vm);
}
else
{
return RedirectToAction("Index", "Home");
}
}
[Authorize]
[HttpPost]
[AjaxOnly]
[ValidateAntiForgeryToken(Salt = "ChangeSettings")]
public ActionResult ChangeSettings(ChangeSettingsModel vm)
{
try
{
if (ModelState.IsValid)
{
MembershipUser user =
membershipService.GetUserByUserName(User.Identity.Name);
UserSettingsProfileModel profile =
UserSettingsProfileModel.GetUserProfile(User.Identity.Name);
....
....
profile.Save();
....
....
}
else
{
ViewData["successfulEdit"] = false;
....
....
}
}
catch
{
....
....
ViewData["successfulEdit"] = false;
}
}
}
It can seen that the SettingsController
simply renders a default view and also allows the updating
of the custom UserSettingsProfileModel
which is as follows
public class UserSettingsProfileModel : ProfileBase
{
[SettingsAllowAnonymous(false)]
public bool IsOpenIdLoggedInUser
{
get { return (bool)base["IsOpenIdLoggedInUser"]; }
set { base["IsOpenIdLoggedInUser"] = value; }
}
[SettingsAllowAnonymous(false)]
public int HighlightingCSSId
{
get { return (int)base["HighlightingCSSId"]; }
set { base["HighlightingCSSId"] = value; }
}
[SettingsAllowAnonymous(false)]
public int MaxSnippetsToDisplay
{
get { return (int)base["MaxSnippetsToDisplay"]; }
set { base["MaxSnippetsToDisplay"] = value; }
}
public static UserSettingsProfileModel GetUserProfile(string username)
{
return Create(username) as UserSettingsProfileModel;
}
public static UserSettingsProfileModel GetUserProfile()
{
return Create(Membership.GetUser().UserName) as UserSettingsProfileModel;
}
}
It can also be seen in the SettingsController
code that it makes use of a IMembershipService
class. That is simply
a helper class that implements the following interface, and deals with communicating with the standard ASP .NET Membership database
public interface IMembershipService
{
int MinPasswordLength { get; }
bool ValidateUser(string userName, string password);
MembershipCreateStatus CreateUser(string userName, string password, string email, string OpenID);
Tuple<MembershipCreateStatus, String> CreateUser(string userName, string email, string OpenID);
bool ChangePassword(string userName, string oldPassword, string newPassword);
MembershipUser GetUserByOpenId(string OpenID);
MembershipUser GetUserByUserName(string UserName);
}
Ok so now lets move on to look at the pages markup, which is pretty simple
and the most important parts look like this
@model CodeStash.Models.Settings.ChangeSettingsModel
@{
ViewBag.Title = "Change Settings";
Layout = "~/Views/Shared/_Layout.cshtml";
}
@using CodeStash.ExtensionsMethods
@section SpecificPageHeadStuff
{
@Html.CssTag(Url.Content("~/Content/Controllers/Settings/settings.css"))
@Html.ScriptTag(Url.Content("~/Scripts/Controllers/Settings/settings.js"))
}
<div id="dialog-message" style="display:none;">
<p id="dialog-message-content">
</p>
</div>
<div id="ChangeSettingsPanel">
@Html.Partial("ChangeSettingsPartial",Model)
</div>
There is basically a Partial view that is rendered that shows all the markup for the actual settinsgs for the user. You can probably
imagine that markup from the settings screen shot above.
So lets just look at the JavaScript side of things now shall we. The settings JavaScript is shown in its entirety below
$(document).ready(function () {
InitBinding();
});
function InitBinding() {
console.log($('#successfulEdit').val());
$("#Submit").click(function (e) {
e.preventDefault();
CallPostRequestForChangeSetting();
});
}
function CallPostRequestForChangeSetting() {
var formData = $("#ChangeSettingsForm").serialize();
$.post("/Settings/ChangeSettings", formData, function (response) {
$("#AjaxSettingsContents").replaceWith(response);
InitBinding();
var successfulAdd = $('#successfulEdit').val();
if (successfulAdd == "True") {
showOkDialog('Sucessfully saved your settings', 180, 'Information');
}
else {
showOkDialog('Could not update your user settings', 180, 'Error');
}
});
return false;
}
This page allows logged in users to create teams. The basic idea is that the first user to create a team,
will be the team owner, whom can then alter the team, by adding/removing team members
If we look at the teams related schema entries it should not be that hard to
see how this screen hangs together
The page basically offers these steps to allow a new team of users to be
created.
Creating A New Team And Give It A Name
All you need to do here is type a new team name and click the "Create
Team" button which will create a brand new team which you can then assign
team members to. Alternatively you may pick existing teams that you are the
owner of, these are shown in the select
just below the textbox
where
you can type a new team name.
Searching For Users To Add To Team
Once you have either created a new team, or picked an existing one you want
to add team members to, you must search for team members, this is done using the
section of the team settings page as shown below.
- Where you MUST enter the team members email
- Where you can choose whether that team members login is OpenId or not.
This will help find the exact user for the team
So it starts by entering the team members email, and then hitting the
"Search For Users" button, after that some more of the team settings page
is revealed which shows the matched user(s) with that email. These are shown in
a select
list, from which you can pick them and click the "Assign User
To Team" button.
Manipulating The Team Members
Once you have added members to the selected (or new) team, you will see them
all presented in the bottom area of the team settings page. If you decide you
want to remove a particular member from a team, you can either
- Click the red cross at the top of that particular team member
- Drag and drop the team member to the trash can shown (if you let go when
its not on the trash can, it will fly back into its old position)
Here is the most relevant parts of the team pages markup
@model CodeStash.Models.Team.TeamModel
@{
ViewBag.Title = "Team Settings";
Layout = "~/Views/Shared/_Layout.cshtml";
}
@section SpecificPageHeadStuff
{
@Html.CssTag(Url.Content("~/Content/Controllers/Team/team.css"))
@Html.ScriptTag(Url.Content("~/Scripts/Controllers/Team/team.js"))
}
@using CodeStash.ExtensionsMethods
<div id="dialog-message" style="display:none;">
<p id="dialog-message-content">
</p>
</div>
<input type="hidden" id="CurrentDraggablePosition" />
<div id="TeamPanel">
<div class="headedPanel">
<div class="stepPanel">
<br />
<h3>
Step 1 : Create New Team, Or Pick Existing Team</h3>
<p>
<strong>Create A New Team</strong>
<br />
@Html.TextBoxFor(x => x.TeamName)
<span class="btn"><a id="CreateTeam">Create Team</a><span></span></span>
...
...
...
<select id="OwnedTeams" style="display: none" class="selectBox">
</select>
</p>
</div>
</div>
<div id="AssignToTeamPanel" class="headedPanel" style="display:none">
<div class="stepPanel">
<br />
<h3>
Step 2 : Pick Your Team Members, And Assign To Selected Team</h3>
<p>
<strong>Search For User To Add To Selected Team</strong>
<br />
@Html.TextBoxFor(x => x.Email)
<span class="btn"><a id="SearchForUsers">Search For Users</a><span></span></span>
...
...
...
<input id="IsOpenIdLogin" type="checkbox" />
@Html.LabelFor(x => x.IsOpenIdLogin, "Search For OpenId Registered Users")
...
...
...
<div id="FoundUsersPanel" style="display: none">
<strong>Users That Matched Your Search</strong>
...
...
...
<select id="FoundUsers" class="selectBox">
</select>
<div style="position: absolute;margin-top: -30px;margin-left: 205px;width: 170px;">
<span class="btn"><a id="AssignUser">Assign User To Team</a><span></span></span>
...
...
...
</div>
</p>
</div>
</div>
<div id="TeamMembersPanel" class="headedPanel" style="display:none">
<div class="stepPanel">
<br />
<h3>Step 3 : Remove People From The Selected Team (If You Must)</h3>
<br />
<div id="teamMemberContainer"></div>
<script id="teamMemberTemplate" type="text/x-jQuery-tmpl">
<div class="ui-widget-content draggable">
<input type="hidden" class="draggableHidden" value="${UserId}" />
<span class="username">${UserName}</span>
<img src="../../Content/images/people.png" class="TeamPeopleIcon" />
<img src="../../Content/images/delete.png" class="TeamDeleteIcon" />
<div>
<a href="mailto:${Email}">${UserName}</a>
</div>
</div>
<div class="tooltip">
You can drag ${UserName} to the bin to remove them from the current team
</div>
</script>
<div class="ui-widget-header droppable">
</div>
</div>
</div>
</div>
It can be seen that the bottom portion of the screen makes use of the cool
jQuery
Template idea. Which I like a lot
Ok so lets continue on to look at the most relevant parts of the team pages JavaScript
$(document).ready(function () {
GetOwnedTeams();
$('.TeamDeleteIcon').live('click', function () {
var id = $(this).parent().find('.draggableHidden').val();
DeleteMemberFromTeam(id, undefined);
});
$('#CreateTeam').click(function () {
var newTeamName = $('#TeamName').val();
if (newTeamName == undefined || newTeamName == '') {
$('#TeamName').addClass('Error');
}
else {
$('#TeamName').removeClass('Error');
$.post("/Team/SaveNewTeam", { teamName: newTeamName },
function (data) {
....
....
....
},
"json");
}
});
$(".droppable").droppable({
hoverClass: "ui-state-active",
drop: function (event, ui) {
$(this).addClass("ui-state-highlight");
var fullDragUI = $(ui.draggable.context);
var id = fullDragUI.find('.draggableHidden').val();
DeleteMemberFromTeam(id, ui.draggable);
}
});
$('#AssignUser').click(function () {
$.post("/Team/AssignMemberToTeam", { teamId: $('#OwnedTeams').val(), teamMemberId: $('#FoundUsers').val() },
function (data) {
if (data.Success != undefined && data.Success) {
GetTeamMembersForSpecificTeam($('#OwnedTeams').val());
}
else {
showOkDialog(data.Message, 180, 'Error');
}
},
"json");
});
$('#OwnedTeams').change(function () {
GetTeamMembersForSpecificTeam($('#OwnedTeams').val());
});
$('#SearchForUsers').click(function () {
var emailToSearchFor = $('#Email').val();
if (emailToSearchFor == undefined || emailToSearchFor == '') {
$('#Email').addClass('Error');
}
else {
$.post("/Team/SearchForUsers", { email: $('#Email').val(), isOpenIdlogin: $('#IsOpenIdLogin').is(':checked') },
function (data) {
if (data.Success != undefined && data.Success) {
....
....
....
}
else {
showOkDialog(data.Message, 180, 'Error');
}
},
"json");
}
});
});
function DeleteMemberFromTeam(userId, draggable) {
showYesNoDialog('Are you sure you want to delete this user from the selected team?', 180, 'Confirm',
function () {
$.post("/Team/DeleteMemberFromTeam", { teamId: $('#OwnedTeams').val(), teamMemberId: userId },
....
....
....
},
"json");
},
function () {
AnimateDraggableBack(draggable);
}
);
}
function GetTeamMembersForSpecificTeam(teamId) {
$("#teamMemberContainer").empty();
$.post("/Team/GetTeamMembersForSpecificTeam", { teamId: teamId },
function (data) {
if (data.Success != undefined && data.Success) {
if (data.Message.length > 0) {
$("#teamMemberTemplate").tmpl(data.Message).appendTo("#teamMemberContainer");
$("#TeamMembersPanel").show();
}
else {
$("#TeamMembersPanel").hide();
}
}
else {
$("#TeamMembersPanel").hide();
showOkDialog(data.Message, 180, 'Error');
}
$(".draggable").tooltip({ effect: 'slide' });
$(".draggable").draggable({
revert: 'invalid',
stop: function () {
$(this).draggable('option', 'revert', 'invalid');
},
start: function (event, ui) {
$('#CurrentDraggablePosition').val(ui.position);
},
drag: function (event, ui) {
$(".draggable").each(function () {
$(this).tooltip().hide();
});
}
});
},
"json");
}
function GetOwnedTeams() {
$.post("/Team/GetOwnedTeams",
function (data) {
if (data.Success != undefined && data.Success) {
....
....
....
}
else {
$('#OwnedTeams').hide();
$('#AssignToTeamPanel').hide();
}
},
"json");
}
function AnimateDraggableBack(element) {
if (element != undefined) {
element.animate({
left: $('#CurrentDraggablePosition').val(value).left,
top: $('#CurrentDraggablePosition').val(value).top
}, 600, "easeOutElastic");
}
}
I left the drag and drop stuff in there as that is some of the more interesting jQuery that makes use of the excellent
jQuery UI library, which is freekin ace.
And now finally lets look at the main controller methods, which I hope you
can see being called from the JavaScript above
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web.Mvc;
using System.Web.Security;
using CodeStash.Common.DataAccess.EntityFramework;
using CodeStash.Common.DataAccess.Repository;
using CodeStash.Common.DataAccess.UnitOfWork;
using CodeStash.Common.Encryption;
using CodeStash.Filters;
using CodeStash.Models.Security;
using CodeStash.Services;
using CodeStash.Services.Contracts;
namespace CodeStash.Controllers
{
public class TeamController : BaseTagCloudEnabledController
{
private readonly IMembershipService membershipService;
private readonly IMembershipDataProvider membershipDataProvider;
private readonly ILoggerService loggerService;
private readonly IRepository<OwnedTeam> ownedTeamRepository;
private readonly IRepository<CreatedTeam> createdTeamRepository;
private readonly IUnitOfWork unitOfWork;
public TeamController(IMembershipService membershipService,
IMembershipDataProvider membershipDataProvider,
ILoggerService loggerService,
ITagCloudService tagCloudService,
IRepository<OwnedTeam> ownedTeamRepository,
IRepository<CreatedTeam> createdTeamRepository,
IUnitOfWork unitOfWork)
: base(tagCloudService)
{
....
....
....
}
[Authorize]
[RenderTagCloud]
[HttpGet]
public ActionResult Index()
{
return View();
}
[Authorize]
[HttpPost]
[AjaxOnly]
public ActionResult SaveNewTeam(string teamName)
{
....
....
....
}
[Authorize]
[HttpPost]
[AjaxOnly]
public ActionResult GetOwnedTeams()
{
....
....
....
}
[Authorize]
[HttpPost]
[AjaxOnly]
public ActionResult GetTeamMembersForSpecificTeam(int teamId)
{
....
....
....
}
[Authorize]
[HttpPost]
[AjaxOnly]
public ActionResult AssignMemberToTeam(int teamId, string teamMemberId)
{
....
....
....
}
[Authorize]
[HttpPost]
[AjaxOnly]
public ActionResult DeleteMemberFromTeam(int teamId, string teamMemberId)
{
....
....
....
}
[Authorize]
[HttpPost]
[AjaxOnly]
public ActionResult SearchForUsers(string email, bool isOpenIdlogin)
{
....
....
....
}
}
}
This page allows you to enter a url to an existing web based snippet and have it highlighted.
Essentially all that is required is that the source is parsed and a code snippet
is extracted and highlighted if possible.
Here is the most relevant code from the CodeSnippetController
[Authorize]
[RenderTagCloud]
[HttpGet]
public ActionResult OpenFromWeb()
{
OpenFromWebViewModel vm = new OpenFromWebViewModel();
vm.HighlightingCSS = CodeSnippetUtils.GetUsersSavedHighlightingCSS(User.Identity);
AddLanguagesToOpenFromWebVm(vm);
return View(vm);
}
[Authorize]
[RenderTagCloud]
[HttpPost]
[AjaxOnly]
[ValidateAntiForgeryToken(Salt = "OpenFromWeb")]
public ActionResult OpenFromWeb(OpenFromWebViewModel vm)
{
try
{
OpenFromWebViewModel newVm = new OpenFromWebViewModel();
newVm.HighlightingCSS = CodeSnippetUtils.GetUsersSavedHighlightingCSS(User.Identity);
newVm.ActualCode = CodeSnippetUtils.ReadContentFromWebUrl(vm.FileName);
AddLanguagesToOpenFromWebVm(newVm);
newVm.LanguageId = vm.LanguageId;
newVm.CodeHasBeenParsed = ModelState.IsValid && !string.IsNullOrWhiteSpace(newVm.ActualCode);
return PartialView("OpenFromWebPartial", newVm);
}
catch(Exception ex)
{
OpenFromWebViewModel newVm = new OpenFromWebViewModel();
newVm.HighlightingCSS = CodeSnippetUtils.GetUsersSavedHighlightingCSS(User.Identity);
AddLanguagesToOpenFromWebVm(newVm);
newVm.CodeHasBeenParsed = false;
return PartialView("OpenFromWebPartial", newVm);
}
}
Where this code make use of this utility code
public static string ReadContentFromWebUrl(string url)
{
try
{
System.Net.HttpWebRequest fr = (System.Net.HttpWebRequest)System.Net.HttpWebRequest.Create(new Uri(url));
if ((fr.GetResponse().ContentLength > 0))
{
System.IO.StreamReader str = new System.IO.StreamReader(fr.GetResponse().GetResponseStream());
return str.ReadToEnd();
}
return "";
}
catch (System.Net.WebException ex)
{
return "";
}
}
The most relevant parts of the markup are shown below
@model CodeStash.Models.Snippet.OpenFromWebViewModel
@{
ViewBag.Title = "Open From Web";
Layout = "~/Views/Shared/_Layout.cshtml";
}
@using CodeStash.ExtensionsMethods
@section SpecificPageHeadStuff
{
@Html.CssTag(Url.Content("~/Content/Controllers/CodeSnippet/openFromWeb.css"))
@Html.ScriptTag(Url.Content("~/Scripts/Controllers/CodeSnippet/openFromWeb.js"))
}
<div id="dialog-message" style="display:none;">
<p id="dialog-message-content">
</p>
</div>
<div id="AddSnippetPanel">
<h2>Open From Web</h2>
@Html.Partial("OpenFromWebPartial",Model)
</div>
And here is the relevant JavaScript
$(document).ready(function () {
InitBinding();
});
function InitBinding() {
$("#Submit").click(function (e) {
e.preventDefault();
CallPostRequestForOpen();
});
}
function CallPostRequestForOpen() {
var formData = $("#OpenFromWebForm").serialize();
$.post("/CodeSnippet/OpenFromWeb", formData, function (response) {
$("#AjaxContents").replaceWith(response);
InitBinding();
var successfulParse = $('#successfulParse').val();
if (successfulParse == "True") {
showOkDialog('Your parsed snippet is shown below', 180, 'Error');
SwitchHightlighting($("#HighlightingCSSToUse").val());
}
else {
showOkDialog('Could not parse your code snippet', 180, 'Error');
}
});
return false;
}
The add snippet is basically just a simple INSERT statement at the end of the
day. It looks like this:
This page allow logged in users to create new code snippet
The view looks like this
@model CodeStash.Models.Snippet.AddSnippetViewModel
@using CodeStash.ExtensionsMethods
<div id="AjaxAddContents">
@using (Html.BeginForm("Add", "CodeSnippet", FormMethod.Post, new { id = "AddForm" }))
{
@Html.AntiForgeryToken("Add")
<h2>Add A New Code Snippet</h2>
<p>Please fill in the details below, and when ready click the "Add" button at the bottom of the page</p>
<input id="successfulAdd" type="hidden" value="@ViewData["successfulAdd"]" />
<input id="addedSnippetId" type="hidden" value="@ViewData["addedSnippetId"]" />
<div class="headedPanel">
<strong>Title</strong>
<div>
@Html.TextBoxFor((x) => x.Title)
@Html.ValidationMessageFor((x) => x.Title)
</div>
</div>
<div class="headedPanel">
<strong>Description</strong>
<div>
@Html.TextBoxFor((x) => x.Description)
@Html.ValidationMessageFor((x) => x.Description)
</div>
</div>
<div class="headedPanel">
<strong>Category</strong>
<div>
Search for existing category
<br />
<input id="SearchCategoryText" type="text" />
<span class="btn"><a id="SearchForExistingCategories">Search</a><span></span></span>
<span class="clear"></span>
<select id="FoundCategories" style="display: none" class="selectBox">
</select>
<br />
Or fill in new category
<br />
@Html.TextBoxFor((x) => x.NewCodeCategoryName)
@Html.ValidationMessageFor((x) => x.NewCodeCategoryName)
</div>
</div>
<div class="headedPanel">
<strong>Grouping</strong>
<div>
Search for existing grouping
<br />
<input id="SearchGroupingText" type="text" />
<span class="btn"><a id="SearchForExistingGrouping">Search</a><span></span></span>
<span class="clear"></span>
<select id="FoundGrouping" style="display: none" class="selectBox">
</select>
<br />
Or fill in new grouping
<br />
@Html.TextBoxFor((x) => x.NewGroupingName)
@Html.ValidationMessageFor((x) => x.NewGroupingName)
</div>
</div>
<div class="headedPanel">
<strong>Language</strong>
<div>
@Html.ComboFor(x => x.LanguageId,
x => x.LanguageList,
x => x.LanguageId,
x => x.Language1,
new Dictionary<string,object> {
{ "style", "width:400px"},
{ "class", "selectBox" }})
@Html.ValidationMessageFor((x) => x.LanguageId)
</div>
</div>
<div class="headedPanel">
<strong>Code Snippet Visibility</strong>
<div>
@Html.ComboFor(x => x.Visibility,
x => x.VisibilityList,
x => x.Id,
x => x.VisibilityDescription,
new Dictionary<string, object> {
{ "style", "width:400px"},
{ "class", "selectBox" }})
@Html.ValidationMessageFor((x) => x.Visibility)
</div>
</div>
<div class="headedPanel">
<div>
<strong>Tags</strong> Enter tags seperated by ";"
<div>
@Html.TextBoxFor((x) => x.Tags)
@Html.ValidationMessageFor((x) => x.Tags)
</div>
</div>
</div>
<div class="headedPanel">
<strong>Actual Code</strong>
<div>
@Html.TextAreaFor((x) => x.ActualCode, new { id = "editTextFieldsCode" })
<div id="actualCodeError">@Html.ValidationMessageFor((x) => x.ActualCode)</div>
</div>
</div>
<span class="btn"><a id="AddSubmit">Add</a><span></span></span>
<span class="clear"></span>
<br />
<br />
}
</div>
And the most relevant part of the JavaScript looks like this, where the form gets submitted to the CodeSnippet
controller's Add
action
function CallPostRequestForAddConfirmSnippet() {
var formData = $("#AddForm").serialize();
$.post("/CodeSnippet/Add", formData, function (response) {
$("#AjaxAddContents").replaceWith(response);
InitBinding();
var successfulAdd = $('#successfulAdd').val();
if (successfulAdd == "True") {
var addedSnippetId = $('#addedSnippetId').val();
window.location.href = '/CodeSnippet/DisplaySnippetsForAddAndEdit'
+ '?codeSnippetId=' + addedSnippetId;
}
else {
showOkDialog('Could not save your code snippet', 180, 'Error');
}
});
return false;
}
Where the CodeSnippet
controller's Add
action looks like this
[Authorize]
[HttpPost]
[ValidateInput(false)] [AjaxOnly]
[ValidateAntiForgeryToken(Salt = "Add")]
public ActionResult Add(AddSnippetViewModel vm)
{
try
{
if (ModelState.IsValid)
{
CodeSnippet addedSnippet = AddOrUpdateSnippet(vm, false);
ViewData["successfulAdd"] = true;
ViewData["addedSnippetId"] = addedSnippet.CodeSnippetId;
RefreshAddModelStaticData(vm);
return PartialView("AddSnippetPartial", vm);
}
else
{
ViewData["successfulAdd"] = false;
ViewData["addedSnippetId"] = 0;
RefreshAddModelStaticData(vm);
return PartialView("AddSnippetPartial", vm);
}
}
catch
{
RefreshAddModelStaticData(vm);
ViewData["successfulAdd"] = false;
return PartialView("AddSnippetPartial", vm);
}
}
The only other thing to note is that the System.DataAnnotations
namespace is used to allow the validation
of the form to occur. In fact DataAnnotations
are used throughout CodeStash
Here is an example of how these look
public class AddSnippetViewModel : ISnippetViewModel
{
#region Ctor
public AddSnippetViewModel()
{
LanguageList = new List<Language>();
VisibilityList = new List<Visibility>();
}
#endregion
#region Public Properties
public List<Language> LanguageList { get; set; }
[Required(ErrorMessage = "You must enter a value for Language")]
public int LanguageId { get; set; }
public int CodeCategoryId { get; set; }
[Required(ErrorMessage = "You must enter a value for Category")]
public string NewCodeCategoryName { get; set; }
public int GroupId { get; set; }
[StringLength(100, MinimumLength = 0,
ErrorMessage = "Grouping must be between 0-100 characters in length")]
public string NewGroupingName { get; set; }
[StringLength(100, MinimumLength=0,
ErrorMessage = "Tags must be between 0-100 characters in length")]
public string Tags { get; set; }
[Required(ErrorMessage = "You must enter a value for Description")]
public string Description { get; set; }
[Required(ErrorMessage = "You must enter a value for Title")]
public string Title { get; set; }
[Required(ErrorMessage = "You must enter a value for ActualCode")]
public string ActualCode { get; set; }
public List<Visibility> VisibilityList { get; set; }
[Required(ErrorMessage = "You must enter a value for Visibility")]
public int Visibility { get; set; }
public Guid AspNetMembershipUserId { get; set; }
#endregion
}
When a snippet is successfullt added it will be displayed, along with any
other snippets in its group (if it was created in a group)
Seaching for snippets is obviously one of the core features of CodeStash,
and as I have already stated CodeStash
supports numerous methods of searching for snippets such as
- By Tag
- By Keyword
- By Language
All of these can be further limited by a visibility modifier. The search
screen is as shown below:
It can be seen that this page simply allows you to set up your desired
search. I will dive into the nuts and bolts of how search works in a
minute, but for now let us just look at another screen shot or 2.
When you click the button that starts a asychronous search, which causes a
progress wheel to be shown
When the search finishes, the results page is shown which shows a DataGrid
(or a message stating "no results could be found")
It can be seen (remember you can click these images to see a bigger version)
that there is a DataGrid of results (I am using the TTelerik ASP MVC Extensions
DataGrid (which is
free)) which has 2 columns with hyperlinks in it, these are as follows:
- Popup : Will show a popup dialog which shows the snippet in it
- Show : Will actually display the snippets using the Display Snippets
page which is shown below
Here is an example of what will be shown when you click one a hyperlink in
the "Popup" column of the grid
If the user clicks the "Show" hyperlink they are directed to the Display Snippets
page,which will show the selected snippet and any other snippet that is part of
that group (if the selected snippet for viewing is actually part of a group)
So that's the screen shots of how "Search" hangs together so lets now get into the guts of it
I think the first thing we should start with is how the search page allows
users to search, which is a pretty simple form that uses the following markup
@model CodeStash.Models.Search.CreateSearchViewModel
@using CodeStash.ExtensionsMethods
@{
ViewBag.Title = "Create Search";
Layout = "~/Views/Shared/_Layout.cshtml";
}
@section SpecificPageHeadStuff
{
@Html.CssTag(Url.Content("~/Content/Controllers/Search/search.css"))
@Html.ScriptTag(Url.Content("~/Scripts/Controllers/Search/search.js"))
}
<div id="dialog-message" style="display:none;">
<p id="dialog-message-content">
</p>
</div>
<div id="SearchPanel">
@using (Html.BeginForm("CreateSearch", "Search", FormMethod.Post, new { id = "CreateSearchForm" }))
{
@Html.AntiForgeryToken("CreateSearch")
<div id="allFormData">
<input id="successfulCreate" type="hidden" value="@ViewData["successfulCreate"]" />
<h2>Create Your Search</h2>
<p>You can create your search by choosing and filling one of the sections below, and then cliking the
submit button, where you will be redirected to the search results.</p>
<div class="headedPanel">
<div class="stepPanel">
<label for="searchType_ByTag">ByTag</label>
@Html.RadioButtonFor(x => x.SearchType, "ByTag", new { id = "searchType_ByTag" })
<label for="searchType_ByKeyWord">ByKeyWord</label>
@Html.RadioButtonFor(x => x.SearchType, "ByKeyWord", new { id = "searchType_ByKeyWord" })
<label for="searchType_ByLanguage">ByLanguage</label>
@Html.RadioButtonFor(x => x.SearchType, "ByLanguage", new { id = "searchType_ByLanguage" })
<br />
<br />
<strong>Tag</strong>
<div>
@Html.TextBoxFor((x) => x.SearchForTag, new { @class = "textbox" })
@Html.ValidationMessageFor((x) => x.SearchForTag)
</div>
<strong>Key Word</strong>
<div>
@Html.TextBoxFor((x) => x.SearchForKeyWord, new { @class = "textbox" })
@Html.ValidationMessageFor((x) => x.SearchForKeyWord)
</div>
<strong>Language</strong>
<div>
@Html.ComboFor(x => x.LanguageId,
x => x.LanguageList,
x => x.LanguageId,
x => x.Language1,
new Dictionary<string,object> {
{ "style", "width:400px"},
{ "class", "selectBox" }})
@Html.ValidationMessageFor((x) => x.LanguageId)
</div>
</div>
</div>
<div class="headedPanel">
<div class="stepPanel">
<br />
<strong>Visibility<strong>
<div>
@Html.ComboFor(x => x.Visibility,
x => x.VisibilityList,
x => x.Id,
x => x.VisibilityDescription,
new Dictionary<string, object> {
{ "style", "width:400px"},
{ "class", "selectBox" }})
@Html.ValidationMessageFor((x) => x.Visibility)
</div>
</div>
</div>
<div style="margin-top:20px;margin-left:25px">
<span class="btn"><a id="SearchSubmit">Submit</a><span></span></span>
<span class="clear"></span>
<br />
<br />
</div>
</div>
<div id="loader" style="display:none">
@Html.Partial("_Loader")
</div>
}
</div>
That form is pretty simple it basically just provides the form elements to allow the user to enter their required search,
which is posted to the SearchController
's CreateSearch
action, which we will see in a minute
Let's now turn our attention to the JavaScript for this page, which doesn't
do too much apart from submit the form to the SearchController
's
Search
action
$(document).ready(function () {
InitBinding();
});
function InitBinding() {
$("#SearchSubmit").click(function (e) {
e.preventDefault();
$("#CreateSearchForm").submit();
$('#allFormData').hide();
$('#loader').show();
});
if ($('successfulCreate').length > 0) {
var successfulCreate = $('#successfulCreate').val();
if (successfulCreate == "False") {
showOkDialog('Your search is invalid', 180, 'Error');
}
}
}
Ok the JavaScript does do a bit more to deal with the
Telerik ASP MVC Extensions
DataGrid, but
we will see the extra bits in a minute
So let's now see what happens inside the SearchController
's
CreateSearch
action. There are several things to not here which are as follows:
- Since the search could take a while I took the decision to do this
asynchronously using Task Parallel Library
- Since I am doing some work asynchronously this controller inherits from
AsyncController
Suprisingly enough the SearchController
is not that bad here are
the most relevant parts
public class SearchController : BaseTagCloudEnabledAsyncController
{
public SearchController(
ILoggerService loggerService,
IMembershipService membershipService,
ITagCloudService tagCloudService,
IRepository<Language> languageRepository,
IRepository<Visibility> visibilityRepository,
IRepository<CodeCategory> categoryRepository,
IRepository<Grouping> groupingRepository,
IRepository<CodeSnippet> codeSnippetRepository,
IRepository<CodeTag> codeTagRepository,
IRepository<CreatedTeam> createdTeamRepository,
IUnitOfWork unitOfWork)
: base(tagCloudService)
{
.....
}
[Authorize]
[RenderTagCloud]
[HttpPost]
[ValidateAntiForgeryToken(Salt = "CreateSearch")]
public void CreateSearchAsync(CreateSearchViewModel vm)
{
AsyncManager.OutstandingOperations.Increment();
if (ModelState.IsValid)
{
ViewData["successfulCreate"] = true;
try
{
MembershipUser user = membershipService.GetUserByUserName(User.Identity.Name);
Guid userId = Guid.Parse(user.ProviderUserKey.ToString());
SearchTaskState searchTaskState = new SearchTaskState(ViewData, vm, userId);
Task<ShowSearchResultsViewModel> searchTask = CreateSearchTask(searchTaskState);
searchTask.Start();
searchTask.ContinueWith((ant) =>
{
SetValidSearchAsyncResult(AsyncManager, ant.Result);
AsyncManager.OutstandingOperations.Decrement();
}, TaskContinuationOptions.OnlyOnRanToCompletion);
searchTask.ContinueWith((ant) =>
{
SetInvalidAsyncResult(AsyncManager, vm);
AsyncManager.OutstandingOperations.Decrement();
}, TaskContinuationOptions.OnlyOnFaulted);
}
catch (AggregateException ex)
{
SetInvalidAsyncResult(AsyncManager, vm);
AsyncManager.OutstandingOperations.Decrement();
}
}
else
{
SetInvalidAsyncResult(AsyncManager, vm);
AsyncManager.OutstandingOperations.Decrement();
}
}
public ActionResult CreateSearchCompleted(bool wasValid, object vm)
{
if (!wasValid)
{
return View("CreateSearch", vm);
}
else
{
return View("ShowSearchResults", vm);
}
}
private void SetInvalidAsyncResult(AsyncManager asyncManager, CreateSearchViewModel vm)
{
ViewData["successfulCreate"] = false;
using (unitOfWork)
{
RefreshModelStaticData(vm);
}
asyncManager.Parameters["wasValid"] = false;
asyncManager.Parameters["vm"] = vm;
}
private void SetValidSearchAsyncResult(AsyncManager asyncManager, ShowSearchResultsViewModel vm)
{
AsyncManager.Parameters["wasValid"] = true;
AsyncManager.Parameters["vm"] = vm;
}
private Task<ShowSearchResultsViewModel> CreateSearchTask(SearchTaskState searchTaskState)
{
return new Task<ShowSearchResultsViewModel>((state)=>
{
SearchTaskState taskState = (SearchTaskState)state;
ShowSearchResultsViewModel searchResultsVm;
using (unitOfWork)
{
.....
.....
.....
.....
Tuple<int, List<CodeSnippet>> results =
SearchUtils.FilterByVisibility(
unitOfWork,
createdTeamRepository,
codeTagRepository,
languageRepository,
codeSnippetRepository,
taskState.Vm.SearchType,
searchValue.ToLower(),
1,
1,
false,
visibility,
taskState.UserId,
tags);
Session["searchResults"] = results.Item2;
searchResultsVm =
new ShowSearchResultsViewModel(
taskState.Vm.SearchType,
languageRepository.FindBy(x => x.LanguageId == taskState.Vm.LanguageId).Single(),
visibilityRepository.FindBy(x => x.Id == taskState.Vm.Visibility).Single(),
searchValue,
results.Item1,
results.Item2);
}
return searchResultsVm;
}, searchTaskState);
}
private string GetSearchValueBasedOnSearchType(CreateSearchViewModel vm)
{
string searchValue = "";
switch (vm.SearchType)
{
case SearchType.ByKeyWord:
searchValue = vm.SearchForKeyWord;
break;
case SearchType.ByTag:
searchValue = vm.SearchForTag;
break;
case SearchType.ByLanguage:
languageRepository.EnrolInUnitOfWork(unitOfWork);
searchValue = languageRepository.FindBy(x => x.LanguageId == vm.LanguageId).Single().Language1;
break;
}
return searchValue;
}
}
It can be seen that this controller actually returns a new view once the search resulst are obtained which is called ShowSearchResultsView
which can be seen below,
this is the one we saw a screen shot of earlier with the
Telerik ASP MVC Extensions
DataGrid.
@model CodeStash.Models.Search.ShowSearchResultsViewModel
@using Telerik.Web.Mvc.UI
@using CodeStash.ExtensionsMethods
@{
ViewBag.Title = "Create Search";
Layout = "~/Views/Shared/_Layout.cshtml";
}
@section SpecificPageHeadStuff
{
@Html.CssTag(Url.Content("~/Content/Controllers/Search/search.css"))
@Html.ScriptTag(Url.Content("~/Scripts/Controllers/Search/search.js"))
}
<div id="dialog-message" style="display: none;">
<p id="dialog-message-content">
</p>
</div>
<div id="SearchPanel">
<h2>Showing Search Results</h2>
<p>Your search was</p>
<ul>
<li>Visibility : '@Model.Visibility.VisibilityDescription'</li>
@switch (Model.SearchType)
{
case CodeStash.Common.Enums.SearchType.ByKeyWord:
<li>Search Type : 'ByKeyWord', where your search term was : '@Model.SearchValue'</li>
break;
case CodeStash.Common.Enums.SearchType.ByLanguage:
<li>Search Type : 'ByLanguage', where your search language was : '@Model.Language.LanguageCode'</li>
break;
case CodeStash.Common.Enums.SearchType.ByTag:
<li>Search Type : 'ByTag', where your search term was : '@Model.SearchValue'</li>
break;
}
</ul>
@(Html.Telerik().Grid<CodeStash.Common.DataAccess.EntityFramework.CodeSnippet>()
.ScriptFilesPath("~/Scripts/Telerik")
.HtmlAttributes(new { style = "margin:0px" })
.Name("SearchResultsGrid")
.DataBinding(dataBinding => dataBinding
//Ajax binding
.Ajax()
//The action method which will return JSON
.Select("_AjaxBinding", "Search")
)
.Columns(columns =>
{
columns.Bound(o => o.CodeSnippetId).Title("Id").HeaderHtmlAttributes(new { @class = "id-column" }).ReadOnly(true);
columns.Bound(o => o.Description).ReadOnly(true);
columns.Bound(o => o.Title).ReadOnly(true);
columns.Bound(o => o.CodeSnippetId).ClientTemplate("<a href=\"#\" class=\"popup-button\">View Details</a>")
.Title("Popup").Filterable(false).Sortable(false).Width(130).ReadOnly(true);
columns.Bound(o => o.CodeSnippetId).ClientTemplate("<a href=\"#\" class=\"show-button\">View Details</a>")
.Title("Show").Filterable(false).Sortable(false).Width(130).ReadOnly(true);
})
.ClientEvents(e => e.OnRowDataBound("SearchResultsGrid_onRowDataBound"))
.Pageable()
.Scrollable(scroll => scroll.Height(200))
.Filterable()
.Sortable(sorting => sorting
.SortMode(GridSortMode.SingleColumn)
.OrderBy(o => o.Add(p => p.CodeSnippetId).Ascending()))
)
</div>
It can be seen that this makes use of the
Telerik ASP MVC Extensions (which are completely free, thanks Telerik).
This grid supports various different models, but the model that I have gone
for is the Ajax updating model, which allows the DataGrid to update using a
server side method which is called when paging occurs. This server side
controller method is as shown below.
[GridAction]
public ActionResult _AjaxBinding()
{
return View(new GridModel((IEnumerable)Session["searchResults"]));
}
In order for this free Telerik DataGrid to work properly we need to do
a few things in the master page "_Layout.cshtml
", which is shown
below
@( Html.Telerik().StyleSheetRegistrar().DefaultGroup(group => group
.DefaultPath("~/Content/Telerik")
.Add("telerik.common.css")
.Add("telerik.Black.min.css"))
)
The last peice to search is the rest of the JavaScript which I purposely did not show you early, this is now shown below.
It can be seen that the JavaScript takes care of binding the 2 hyperlink grid
columns and also has jQuery Ajax calls to either redirect or fetch a single
snippets code when one of the DataGrid hyper link columns is clicked.
function SearchResultsGrid_onRowDataBound(e) {
var dataItem = e.dataItem;
var snippetId = dataItem.CodeSnippetId;
$(e.row).find("a.popup-button")
.click(function (e) {
ShowSnippetPopup(snippetId);
});
$(e.row).find("a.show-button")
.click(function (e) {
window.location.href = '/CodeSnippet/DisplaySnippetsForAddAndEdit' +
'?codeSnippetId=' + snippetId + "&wasAddOrEdit=false";
});
}
function ShowSnippetPopup(snippetId) {
$.post("/Search/GetSpecificSnippetData", { snippetId: snippetId },
function (data) {
if (data.Success != undefined && data.Success) {
var codeSnippetContents = '<pre id="preCode_' + snippetId + '" class="' +
data.PreClass + ' searchsnippetPopup">' + data.CodeSnippetCode + '</pre>'
showDialogWidthAndHeightNoCallbackAndNoScroll(codeSnippetContents,
600, 450, 'Displaying Snippet : ' + snippetId);
SwitchHightlighting(data.HighlightingCSSName);
}
else {
showOkDialog(data.Message, 180, 'Error');
}
},
"json");
}
And that is pretty much how search works
The displaying of snippets is handled by the CodeSnippetController
,
where there are 2 main methods that handle the displaying of snippets.
DisplaySnippetsForAddAndEdit
: This action is called after
a successful Add or Edit has been peformed, and will display the Added
snippet or Edited snippet. If that snippet is part of a group all snippets
in that group will also be shown
DisplaySnippetsForCategory
: This action is called when a
user clicks on one of the entries in the Tag Cloud.
namespace CodeStash.Controllers
{
public class CodeSnippetController : BaseTagCloudEnabledController
{
[Authorize]
[RenderTagCloud]
[HttpGet]
public ActionResult DisplaySnippetsForAddAndEdit(int codeSnippetId, bool wasAddOrEdit=true)
{
....
....
....
....
}
[Authorize]
[RenderTagCloud]
[HttpGet]
public ActionResult DisplaySnippetsForCategory(string category)
{
....
....
....
....
}
}
The DisplaySnippets
full page view is shown in either case, where it is passed the following ViewModel
namespace CodeStash.Models.Snippet
{
public class DisplaySnippetsViewModel
{
public static int MAX_SNIPPETS_TO_DISPLAY = 100;
public DisplaySnippetsViewModel(
List<CodeSnippetWrapperViewModel> codeSnippets,
DisplayMode displayMode,
bool isTruncated,
String highlightingCSS)
{
this.CodeSnippets = codeSnippets;
this.DisplayMode = displayMode;
this.IsTruncated = isTruncated;
this.HighlightingCSS = highlightingCSS;
IsGrouped = this.CodeSnippets.Any(x => x.CodeSnippet.GroupId.HasValue);
}
public bool IsGrouped { get; private set; }
public List<CodeSnippetWrapperViewModel> CodeSnippets { get; private set; }
public DisplayMode DisplayMode { get; private set; }
public bool IsTruncated { get; private set; }
public string HighlightingCSS { get; private set; }
}
public class CodeSnippetWrapperViewModel
{
public bool IsSnippetEditable { get; private set; }
public CodeSnippet CodeSnippet { get; private set; }
public CodeSnippetWrapperViewModel(bool isSnippetEditable, CodeSnippet codeSnippet)
{
this.IsSnippetEditable = isSnippetEditable;
this.CodeSnippet = codeSnippet;
}
}
}
Here is the markup for the full page view, see how it shows another partial view DisplaySnippetsPartial
@model CodeStash.Models.Snippet.DisplaySnippetsViewModel
@{
ViewBag.Title = "Grouped Snippets";
Layout = "~/Views/Shared/_Layout.cshtml";
}
@using CodeStash.ExtensionsMethods
@section SpecificPageHeadStuff
{
@Html.CssTag(Url.Content("~/Content/Controllers/DisplaySnippets/displaySnippets.css"))
@Html.ScriptTag(Url.Content("~/Scripts/Controllers/DisplaySnippets/displaySnippets.js"))
}
<div id="dialog-message" style="display:none;">
<p id="dialog-message-content">
</p>
</div>
<div id="DisplaySnippetPanel">
<h2>Code Snippets</h2>
<input id="WasAddOrEdit" type="hidden" value="@ViewData["WasAddOrEdit"]" />
<input id="DisplayMode" type="hidden" value="@Model.DisplayMode" />
<input id="HighlightingCSSToUse" type="hidden" value="@Model.HighlightingCSS" />
@Html.Partial("DisplaySnippetsPartial",Model)
</div>
And here is the most relevant parts of the partial view DisplaySnippetsPartial
@model CodeStash.Models.Snippet.DisplaySnippetsViewModel
<div id="AjaxContents">
<p id="noSnippetsMessage" style="display:none;">There are no CodeSnippets to display</p>
@if (Model.DisplayMode == CodeStash.DisplayMode.SingleSnippet)
{
if (Model.IsGrouped)
{
<p id="snippetsMessage">As the requested snippet is part of a group, displaying all Code Snippets in Group
[@Model.CodeSnippets.First().CodeSnippet.Grouping.Description]</p>
}
else
{
<p id="snippetsMessage">The requested snippet is shown below</p>
}
}
@foreach (CodeStash.Models.Snippet.CodeSnippetWrapperViewModel snippet in Model.CodeSnippets)
{
@Html.Partial("SingleSnippetPartial", snippet);
}
</div>
It can be seen that this partial view also makes use of yet another partial view called SingleSnippetPartial
, which
as its name suggests is responsible for rendering a single snippet. The SingleSnippetPartial
and the accompanying JavaScript provide the following
functions
- Collapse of the current snippet
- Expand of the current snippet
- Edit of the current snippet
- Delete of the current snippet
- Provide shareable link to the current snippet (which when shared allows
non CodeStash
users to view a Read Only version of the snippet)
Anyway here is what the SingleSnippetPartial
markup looks
like
@model CodeStash.Models.Snippet.CodeSnippetWrapperViewModel
<div id="codeSnippet-@Model.CodeSnippet.CodeSnippetId" class="snippet">
<p>
<strong>ID: </strong>@Model.CodeSnippet.CodeSnippetId<br />
<strong>Title: </strong>@Model.CodeSnippet.Title<br />
<strong>Description: </strong>@Model.CodeSnippet.Description<br />
<strong>Category: </strong>@Model.CodeSnippet.CodeCategory.CodeCategoryName<br />
</p>
<div class="highlightedSnippetHeader">
<div class="snippetActionArea link">
<img src="../../Content/images/link.png" width="25px" alt="" />
</div>
<div class="tooltip">Get link for this code snippet</div>
<div class="snippetActionArea collapse">
<img src="../../Content/images/collapse.png" width="25px" alt="" />
</div>
<div class="tooltip">Collapse code snippet</div>
<div class="snippetActionArea expand">
<img src="../../Content/images/expand.png" width="25px" alt=""/>
</div>
<div class="tooltip">Expand code snippet</div>
@if (Model.IsSnippetEditable)
{
<div class="snippetActionArea delete enabled">
<img src="../../Content/images/delete.png" width="25px" alt=""/>
</div>
<div class="tooltip">Delete code snippet</div>
<div class="snippetActionArea edit enabled">
<img src="../../Content/images/edit.png" width="25px" alt=""/>
</div>
<div class="tooltip">Edit code snippet</div>
}
else
{
<div class="snippetActionArea delete disabled">
<img src="../../Content/images/deleteDisabled.png" width="25px" alt=""/>
</div>
<div class="tooltip">Delete code snippet</div>
<div class="snippetActionArea edit disabled">
<img src="../../Content/images/editDisabled.png" width="25px" alt=""/>
</div>
<div class="tooltip">Edit code snippet</div>
}
</div>
<div class="highlightedSnippet">
@if (Model.CodeSnippet.Grouping != null)
{
<input class="isGrouped" type="hidden" value="true" />
<input class="groupId" type="hidden" value="@Model.CodeSnippet.GroupId" />
<input class="groupDescription" type="hidden" value="@Model.CodeSnippet.Grouping.Description" />
}
else
{
<input class="isGrouped" type="hidden" value="false" />
}
<input class="codeSnippetId" type="hidden" value="@Model.CodeSnippet.CodeSnippetId" />
<pre class="@CodeStash.Utils.WebSiteUtils.GetCodeClass(Model.CodeSnippet.Language.LanguageCode)">
@Model.CodeSnippet.ActualCode.Trim()</pre>
</div>
<br />
</div>
Anyway here is the end result of displaying a snippet
It can be seen that there are number of images which act as button which allow the various functions, such as Collapse/Expand/Edit/Delete/Share link to be performed, we will get into this in a minute
when look aat the JavaScript side of thing.
But how about the snippet highlighting, how does that work. Well that is
actually pretty simple, we just need to ensure that the code rendered is wrapped
in a PRE that has a class which is partially made up of the stored snippets
language, since we store that in the database that information is freely
available.
So this would end up with a PRE something like this
<pre class="csharpCode">
That is all that needs to be done on a individual snippet level, this of
course not the full story, there is a 3rd party free JavaScript library that we
make use of that actually does the highlighting.
I searched long and hard for the best way to do syntax highlighting in a
CodeStash prototype I
initially constructed, and I was fortunate enough to find a very good javascript
synytax highlighting library, which is available from :
http://www.steamdev.com/snippet/
This library comes with all the
required files, and shown below are the steps needed to use it. Though it does
come with a very good work through on the web site too, see the web sites
“Usage” section.
CSS
Include a link to the
jquery.snippet.css file. The best place for this is the MasterPage
"_Layout.cshtml"
@Html.CssTag(Url.Content("~/Content/Highlighting/jquery.snippet.css"))
jQuery Snippet Plugin
Include a link the jquery.snippet.js file. The best place for this is the MasterPage
"_Layout.cshtml"
@Html.ScriptTag(Url.Content("~/Scripts/Highlighting/jquery.snippet.js"))
And finally here is the most relevant parts of the JavaScript for snippet highlighting.
It can be seen that is has hooks for all the previously mentioned functions Collapse/Expand/Edit/Delete/Share snippet link
$(document).ready(function () {
InitBinding();
});
function InitBinding() {
SwitchHightlighting($("#HighlightingCSSToUse").val());
$(".link img").click(function () {
var snippetElement = $(this).closest(".highlightedSnippetHeader").next(".highlightedSnippet");
var codeSnippetId = snippetElement.find(".codeSnippetId").val();
showNoButtonsDialog(
'<p><strong>Snippet link</strong><br/><i>' + window.location.origin +
'/Readonly/Display/' + codeSnippetId + '</i><br/><br/>' +
'Copy the link to share it' +
'</p>', 195, 'Information'
);
});
$(".delete.enabled img").click(function () {
var displayMode = $('#DisplayMode').val();
var snippetElement = $(this).closest(".highlightedSnippetHeader").next(".highlightedSnippet");
var isGrouped = snippetElement.find(".isGrouped").val();
var groupDescription = snippetElement.find(".groupDescription").val();
var groupId = snippetElement.find(".groupId").val();
var codeSnippetId = snippetElement.find(".codeSnippetId").val();
if (isGrouped == "true") {
showYesNoGroupSnippetDialog("Code snippet " + codeSnippetId + " is part of group '" + groupDescription +
"'.<br/><br/> Please pick whether to delete just the single selected snippet or ALL snippets in the group",
220, "Confirm Delete",
function (data) {
DeleteSnippetAndShowRemaining(displayMode, codeSnippetId, groupId, false);
},
function (data) {
DeleteSnippetAndShowRemaining(displayMode, codeSnippetId, groupId, true);
}
);
}
else {
showYesNoDialog('Are you sure you want to delete this snippet?', 180, 'Confirm',
function () {
DeleteSnippetAndShowRemaining(displayMode, codeSnippetId, groupId, false);
}
);
}
});
$(".edit.enabled img").click(function () {
var snippetElement = $(this).closest(".highlightedSnippetHeader").next(".highlightedSnippet");
var codeSnippetId = snippetElement.find(".codeSnippetId").val();
window.location.href = '/CodeSnippet/Edit' + '?codeSnippetId=' + codeSnippetId;
});
}
function DeleteSnippetAndShowRemaining(displayMode, snippetId, groupId, deleteAllInGroup) {
$.post("/CodeSnippet/DeleteSnippetAndShowRemaining",
{
'displayMode': displayMode,
'snippetId': snippetId,
'groupId': groupId,
'deleteAllInGroup': deleteAllInGroup
},
function (response) {
....
....
....
....
....
},
'json'
);
}
There is a single line near the top of that jQuery that deals with highlighting the snippets using the users chosen highlighting
snippet theme (which is stored against their Profile in the ASP .NET Membership database. That line in this one
SwitchHightlighting($("#HighlightingCSSToUse").val());
Where the SwitchHightlighting
method looks like this
function SwitchHightlighting(stylename) {
$("pre.c" + "Code").snippet("c", { style: stylename, showNum: false });
$("pre.cpp" + "Code").snippet("cpp", { style: stylename, showNum: false });
$("pre.csharp" + "Code").snippet("csharp", { style: stylename, showNum: false });
$("pre.css" + "Code").snippet("css", { style: stylename, showNum: false });
$("pre.flex" + "Code").snippet("flex", { style: stylename, showNum: false });
$("pre.html" + "Code").snippet("html", { style: stylename, showNum: false });
$("pre.java" + "Code").snippet("java", { style: stylename, showNum: false });
$("pre.javascript" + "Code").snippet("javascript", { style: stylename, showNum: false });
$("pre.javascript_dom" + "Code").snippet("javascript_dom", { style: stylename, showNum: false });
$("pre.perl" + "Code").snippet("perl", { style: stylename, showNum: false });
$("pre.php" + "Code").snippet("php", { style: stylename, showNum: false });
$("pre.python" + "Code").snippet("python", { style: stylename, showNum: false });
$("pre.ruby" + "Code").snippet("ruby", { style: stylename, showNum: false });
$("pre.sql" + "Code").snippet("sql", { style: stylename, showNum: false });
$("pre.xml" + "Code").snippet("xml", { style: stylename, showNum: false });
}
The snippet highlighting library comes with 33 themes to choose from.
This page allow logged in users to edit an existing code snippet (providing its yours). It starts by finding a snippet you want to edit
Then after you click the edit icon, you will be redirected to the edit
snippet page which is as shown below
The edit page works much the same as the Add page, apart from the fact that a
few of the fields are now readonly. And since we just discussed how the Add
Snippet page works, I will leave it up to you imagination how the edit one
works, you are a clever lot I am sure you imagine it.
The whole idea behind CodeStash
is that its a snippet portal, and that you can manage your snippet respository.
As such you can obviously choose to delete a snippet (providing its yours), which starts by finding a snippet you want to delete. Once you find a
snippet you may use the delete icon which will show you a popup asking you to confirm your delete. Now remember that
code snippets can be grouped (you know C#/ASPX/HTML all being in a logical group), as such the delete dialog may ask
you to delete ALL snippets in group, if the current snippet is in a group. Or if there is no groupong you will see a standard
confirm dialog
This is what is shown if the snippet is in a group
This is what is shown if the snippet is NOT in a group
So how does all this work behind the scenes? Its actually suprsingly simple really, if we go back to look at the schema
for a minute
It is pretty clear that each CodeSnippet is capable of being in a group,
though it is not mandatory. So based on that it's really just a question of
getting the fact that the snippet is in a group into the view (I used a hidden
field to do this), and then let the jQuery check whether there is a group or
not, which shows the correct dialog.
@if (Model.CodeSnippet.Grouping != null)
{
<input class="isGrouped" type="hidden"
value="true" />
<input class="groupId" type="hidden"
value="@Model.CodeSnippet.GroupId" />
<input class="groupDescription" type="hidden"
value="@Model.CodeSnippet.Grouping.Description" />
}
else
{
<input class="isGrouped" type="hidden" value="false" />
}
And here is the relevant jQuery
$(".delete.enabled img").click(function () {
var displayMode = $('#DisplayMode').val();
var snippetElement = $(this).closest(".highlightedSnippetHeader").next(".highlightedSnippet");
var isGrouped = snippetElement.find(".isGrouped").val();
var groupDescription = snippetElement.find(".groupDescription").val();
var groupId = snippetElement.find(".groupId").val();
var codeSnippetId = snippetElement.find(".codeSnippetId").val();
if (isGrouped == "true") {
showYesNoGroupSnippetDialog("Code snippet " + codeSnippetId +
" is part of group '" + groupDescription +
"'.<br/><br/> Please pick whether to delete just the single selected snippet or ALL snippets in the group",
220, "Confirm Delete",
function (data) {
DeleteSnippetAndShowRemaining(displayMode, codeSnippetId, groupId, false);
},
function (data) {
DeleteSnippetAndShowRemaining(displayMode, codeSnippetId, groupId, true);
}
);
}
else {
showYesNoDialog('Are you sure you want to delete this snippet?', 180, 'Confirm',
function () {
DeleteSnippetAndShowRemaining(displayMode, codeSnippetId, groupId, false);
}
);
}
});
Where the following controller code is called to do the actual delete
[Authorize]
[HttpPost]
[AjaxOnly]
public JsonResult DeleteSnippetAndShowRemaining(DisplayMode displayMode,
int snippetId, int groupId, bool deleteAllInGroup)
{
....
....
}
This works in much the same way as the NON read only snippets, with the
exception of it using a minimal master page that doesn't have any of the extra
fluff, its a simple view that just shows snippets, and you can no longer
edit/delete the snippet as these functions require you to be logged into to
CodeStash
The
CodeStash Addin that
Pete O'Hanlon has written obviously needs to get its data from somewhere and
also needs a place to store its data. That is obviously the centralised (for the
moment, it may change to cloud hosting/no sql depending on uptake) SQL server
database.
There were a couple of different options available here such as those shown
below, but we wanted an open architecture that was open to any sort of client,
not just windows ones.
- WCF : Felt to strict, and was Windows only.
- Web WCF Api : Ok slightly better, but still meant server has to be
written differently from web site
- Standard controller that supports JSON CRUD. Nice this hits the sweet
spot, as its completely standard JSON, and it all POST/GET based without any
magic. This is what we went for
So now that we know that there is a dedicated controller for the REST API
that the
CodeStash Addin uses
what does it look like. Well its prety simply if you just consider its methods
Lets see those.
namespace CodeStash.Controllers
{
public class RestController : Controller
{
#region Ctor
public RestController(IGetUserForRestService getUserForRestService,
ILoggerService loggerService,
IRepository<CodeSnippet> codeSnippetRepository,
IRepository<CodeCategory> codeCategoryRepository,
IRepository<CodeTag> codeTagRepository,
IRepository<Grouping> groupingRepository,
IRepository<Language> languageRepository,
IRepository<CreatedTeam> createdTeamRepository,
IRepository<Visibility> visibilityRepository,
IUnitOfWork unitOfWork)
{
....
....
}
#endregion
#region REST API
[JSONInput(Param = "input", RootType = typeof(JSONSearchInput))]
public ActionResult Search(JSONSearchInput input)
{
....
....
}
[JSONInput(Param = "input", RootType = typeof(JSONAddSnippetInput))]
public ActionResult AddSnippet(JSONAddSnippetInput input)
{
....
....
}
[JSONInput(Param = "input", RootType = typeof(JSONCredentialInput))]
public ActionResult GetAllLanguages(JSONCredentialInput input)
{
....
....
}
[JSONInput(Param = "input", RootType = typeof(JSONCredentialInput))]
public ActionResult GetAllGroups(JSONCredentialInput input)
{
....
....
}
#endregion
}
}
This is really all there is to it, obviously there are CRUD operation going on in these methods, but that is largely irrelevant.
There are 2 important things to note here. One being that ALL rest API calls MUST provide a JSONCredentialInput
, this means
that calls that supply a correct well formed validated JSONCredentialInput
will be able to use the rest API. The
second one being that it is a standard MVC approach of exposing JSON data, and accepting new JSON
data using model binding.
Accepting model bound JSON data is achieved using the specialised ActionFilter
JSONInputAttribute
which looks like
this
public class JSONInputAttribute : ActionFilterAttribute
{
public string Param { get; set; }
public Type RootType { get; set; }
private Encoding GetEncoding(string contentType)
{
Encoding encoding = Encoding.Default;
switch (contentType)
{
case "application/json; charset=UTF-7":
encoding= Encoding.UTF7;
break;
case "application/json; charset=UTF-8":
encoding= Encoding.UTF8;
break;
case "application/json; charset=unicode":
encoding= Encoding.Unicode;
break;
case "application/json; charset=ascii":
encoding= Encoding.ASCII;
break;
}
return encoding;
}
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
try
{
string json = filterContext.HttpContext.Request.Form[Param];
if (json == "[]" || json == "\",\"" || String.IsNullOrEmpty(json))
{
filterContext.ActionParameters[Param] = null;
}
else
{
Encoding encoding = GetEncoding(filterContext.HttpContext.Request.ContentType);
using (var ms = new MemoryStream(encoding.GetBytes(json)))
{
filterContext.ActionParameters[Param] =
new DataContractJsonSerializer(RootType).ReadObject(ms);
}
}
}
catch
{
filterContext.ActionParameters[Param] = null;
}
}
}
It can be seen that this makes use of the DataContractJsonSerializer
that is due to the fact that
the .NET client makes of the same serialization process. This however doesn't matter and this ActionFilter
is totally capable of accepting JSON from anywhere, it just uses the DataContractJsonSerializer
to hydrate the
JSON data back into .NET objects for the controller. Lets see one of these JSON DTO objects that the Addin uses
[DataContract]
public partial class JSONLanguage
{
public JSONLanguage(int languageId, string language)
{
this.LanguageId = languageId;
this.Language = language;
}
[DataMember]
public int LanguageId { get; set; }
[DataMember]
public string Language { get; set; }
}
And here is sneak peak example of how the addin may call into the REST API. Rest (pardon the pun) Pete will be covering this in more
detail in his article(s) on the Addin.
public JSONLanguagesResult RetrieveLanguages()
{
return GetValue<JSONLanguagesResult>("GetAllLanguages");
}
private T GetValue<T>(string restService) where T : class
{
return Utilities.GetValue<T>(GetDataFromRestService(restService));
}
protected byte[] GetDataFromRestService(string restMethod)
{
JSONCredentialInput input = new JSONCredentialInput(
openId, emailAddress, password );
return CallService(input, CodeStash.Common.Helpers.ConfigurationSettings.RestAddress, restMethod);
}
private byte[] CallService<T>(T input, string address, string methodToCall)
{
values = new NameValueCollection();
Utilities.AddValue(values, "input", input);
WebClient client = new WebClient();
return client.UploadValues(string.Format("{0}{1}", address, methodToCall), values);
}
Where the following utility code is responsible for serializing/derserialization as JSON
using System;
using System.Linq;
using System.Text;
using CodeStash.Common.Encryption;
using System.Runtime.Serialization.Json;
using System.IO;
using System.Collections.Specialized;
using System.Collections.Generic;
namespace CodeStash.Addin.Core
{
public static class Utilities
{
internal static DataContractJsonSerializer jss;
public static string GetStringForWebsiteCall(this string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
if (EncryptionHelper.EncryptionEnabled)
return EncryptionHelper.GetEncryptedValue(value);
return value;
}
internal static T GetValue<T>(Byte[] results) where T : class
{
using (MemoryStream ms = new MemoryStream(results))
{
jss = new DataContractJsonSerializer(typeof(T));
return (T)jss.ReadObject(ms);
}
}
internal static void AddValue(NameValueCollection values, string key, object value)
{
jss = new DataContractJsonSerializer(value.GetType());
using (MemoryStream ms = new MemoryStream())
{
jss.WriteObject(ms, value);
string json = Encoding.UTF8.GetString(ms.ToArray());
values.Add(key, json);
}
}
}
}
Anyway there you have it, I hope you all like the work Pete and I have put together, we have both spent lots of time on this
and we both believe it could be a most useful tool. As we say we would love to hear from you on this one, do you think its useful
would you use it? What imrovements could we make? It really would be good to hear from you all on your thoughts, we have tried to make
it work the way we think would be best for developers, but we have probably missed somethings, so we are kind of reliant on you
guys to tell us that, so please don't hold back if there are things you want us to do for V2.
We have have a few things up our sleeves for V2 but we wanted to see what the
general feeling was for this 1st offering before we set about sweating more
blood and tears over this project.