smithvoice.com
 Y'herd thisun? 

“Learning how to do in something new what you do without thinking in something old strengthens you in both.”

from this by smith

Regex Ajax Jquery UserControl

TaggedCoding, CSharp, DotNet, ASP.Net

This universe is fun.  Last week I was having a conversation with a budd about a validation regex and just a few days later I had the need to dig it out for myself ... lucky I put it on the sitelett (thanks Universe!).

In my case I needed to make a double listbox picker for Keywords.  Over the years I've just used Telerik controls but this is a lean webapp and I didn't want the extra baggage so I rolled my own.  ASCX with serverside needs plus Jquery/JS Ajax hitting an ASMX web service for adding new keywords on the fly without page reloads.  

I'm sure that I'll be hearing really fast how it's not MVC and no one ever does webforms anymore or EF is so much better than whiskey or WCF's core requirements are more professional than an ASMX that you can code in notepad and XCOPY to a server or I shoulda just used a Jquery.UI widget or my JQuery sucks... listen:  I've done them too, from the betas of many and the alphas of others, and the fact is that MVC is in the same precarious 'deprecated-any-year-now" status as WebForms but WebForms are at least a locked and known stable technology; Plus, watching the trace of an MVCx vs the trace of a webform still doesn't blow me away.  Some projects need a simple solution and this is what was picked for the project and I agreed.  As to my Jquery being smelly... ok ;-), Minifying helps the bulk and as a demo it should be enough to 'get it'.  Feel free to take it and make it as complexicated as you want.

Demo here.  Demo isn't checking for mobile browsers and mobile browsers are not the target of this control's use case.

 Code and some notes below:

First off, what the control does.  

It allows the host page to supply a full List<ListItem> and a list of the integer Ids of any items that have already been chosen.  When both lists are stuffed into the control on page_load or event then the control goes through the full list and if the item value is in the Ids list then that item is put in the right box, else it is put in the left box.

With the lists full, the Jquery picks up, allowing the user to double click on an item in either list to pop it over to the other list or use the add-all/add-single buttons. Moving items automatically orders the lists by text.  

At the bottom of the control are a couple of textboxes and a button - I used asp controls but you can swap them out for html controls since they're not doing anything serverside.  Setting values for Name and/or Description then pressing the button will call the external javasript file (keyworder.js) to do an AJAX call to the web service (listsvc.asmx) to try to add the new keyword to the master list.  

Note that this demo does not add much validation on the client side; In a real app you should add that to your JS. I did this as a demo of the simple power of  returning Asp.Net WS Exceptions back to the client using Jquery.   You can see that any exceptions - framework or custom - are simply thrown, and back on the client you can trap them in a switch or ugly set of Ifs to alert the user.  

This technique is pretty old but it seems a lot of devs haven't yet discovered it or - as I used to do - shy away from it because it seems that bigger 500 errors can't be trapped (such as when the service is down).  If you look at the error trapping you see how that problem is also taken care of with a trap within the trap.  Neat huh?

If the service validation passes, the new keyword is added to the backend datastore and returned to the AJAX caller.  The success trap of the script reads the return and adds the new Option to the right side list because it is assumed that the user added the keyword to use it.

A few important things: 

1 - In this demo, because it is a demo, the backend datastore is nothing but a Cache variable that gets prefilled with two items ("one" and "two").  In the live demo I kill the cache after a minute-ish.  When you hit Save to persist the user choices the list of items chosen is put in a SESSION var so using another browser will show you the added keywords for the duration of the cache but only using another tab of the same browser will show you the users chosen items.

2 - A major point of the control is the logic for saving the chosen keywords so that the serverside code can read them, making things move around wth Javascript is all good fun but a control like this is for data postbacks... that is all done with a hiddenField control and a bunch of Jquery logic doing splitting, parsing and joining on most every clientside event.  Using the Save button, which is on the page, not in the control, reads the id list serverside and puts it in the Session, using the test button at the top of the page is also doing a postback and telling you the piped values in the hiddenField. 

3 - Because this uses an external Jquery JS file, the controlIds are bound to clientside events using vars In The Page Code.  This is a standard technique but it has the limitation of only allowing one instance of the control per page (else the vars are reused).  There are ways to get around this including putting the whole script in the page/control html page which adds more than double the raw bulk per page and kills the option of minifying, or creating arrays of controlIds which adds lookup hits.  If you really need two controls doing the same thing on a single page then most likely they wont both be doing keywords so you'll have to edit the source anyway and may as well alter the var names along the way.  That's just the way I've seen it.

The VS2012 solution was coded against 4.0.  The name of my solution was "demos" so you'll see that namespace in the logical places within the code, you'll have to change those bits to match your own solution namespace.  

**keyword class file (put in folder named "classes"):

namespace demos.classes
{
    public class keyword
    {
        public enum ServiceExceptions
        {
            Success = 0,
            DuplicateNameException = 1,
            InvalidNameException = 2,
            KeywordFormatException = 3,
            DescriptionFormatException = 4,
            BlankKeywordException = 5,
            SQLException = 6,
            GeneralException = 7
        }

        public keyword(int Id, string name, string desc)
        {
            _id = Id;
            _name = name;
            _desc = desc;
        }

        private int _id;
        public int Id
        {
            get { return _id; }
            set { _id = value; }
        }

        private string _name;
        public string Name
        {
            get { return _name; }
            set { _name = value; }
        }

        private string _desc;
        public string Desc
        {
            get { return _desc; }
            set { _desc = value; }
        }
    }
}



**web service file listsvc.asmx (put in a folder named "services"):

<%@ WebService Language="C#" CodeBehind="listsvc.asmx.cs" Class="demos.services.listsvc" %>

**web service file listsvc.asmx.cs (put in a folder named "services"):

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Services;
using System.Text.RegularExpressions;

namespace demos.services
{
    [WebService(Namespace = "http://tempuri.org/")]
    [WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]
    [System.ComponentModel.ToolboxItem(false)]
    [System.Web.Script.Services.ScriptService]
    public class listsvc : System.Web.Services.WebService
    {
        [WebMethod(EnableSession = true)]
        public demos.classes.keyword addnewkeyword(string addName, string addDesc)
        {
            addName = addName.Trim();
            addDesc = addDesc.Trim();
            if (HttpContext.Current.Cache["myvalues"] == null)
            {
                List<demos.classes.keyword> list = new List<classes.keyword>();
                list.Add(new classes.keyword(1, "one", "one desc"));
                list.Add(new classes.keyword(2, "two", "two desc"));
                HttpContext.Current.Cache["myvalues"] = list;
            }
            List<demos.classes.keyword> cachedThings = (List<demos.classes.keyword>)HttpContext.Current.Cache["myvalues"];
            if (addName.Length == 0)
            {
                throw new Exception(demos.classes.keyword.ServiceExceptions.BlankKeywordException.ToString());
            }
            if (!new Regex(@"(?=^.{3,30}$)^[A-Za-z]+((-[A-Za-z]+)|('[A-Za-z]+)|(\040[A-Za-z]+))*$").IsMatch(addName))
            {
                throw new Exception(demos.classes.keyword.ServiceExceptions.KeywordFormatException.ToString());
            }
            if (addDesc.Length > 0 && !new Regex(@"(?=^.{3,50}$)^[A-Za-z]+((-[A-Za-z]+)|('[A-Za-z]+)|(\040[A-Za-z]+))*$").IsMatch(addDesc))
            {
                throw new Exception(demos.classes.keyword.ServiceExceptions.DescriptionFormatException.ToString());
            }

            var count = cachedThings.Count(x => x.Name == addName);
            if (count > 0)
            {
                throw new Exception(demos.classes.keyword.ServiceExceptions.DuplicateNameException.ToString());
            }
            classes.keyword newThing = new classes.keyword(cachedThings.Count + 1, addName, addDesc.Trim());
            cachedThings.Add(newThing);
            HttpContext.Current.Cache["myvalues"] = cachedThings;
            return newThing;
        }
    }
}


**toFromList.ascx (make this webusercontrol in folder "controls"):

<%@ Control Language="C#" AutoEventWireup="True" CodeBehind="toFromList.ascx.cs" Inherits="demos.controls.toFromList" %>
<table style="width: 300px">
    <tr>
        <td>
            <asp:ListBox ID="lsl" runat="server" Width="200px" Height="220px"
                ViewStateMode="Disabled" EnableViewState="False"></asp:ListBox></td>
        <td style="padding-left: 10px; padding-right: 10px">
            <asp:Button ID="b2rs" runat="server" Text=">" Width="40px" OnClientClick="return false;"
                EnableViewState="False" /><br />
            <br />
            <asp:Button ID="b2ra" runat="server" Text=">>" Width="40px" OnClientClick="return false;"
                EnableViewState="False" /><br />
            <br />
            <asp:Button ID="b2ls" runat="server" Text="<" Width="40px" OnClientClick="return false;"
                EnableViewState="False" /><br />
            <br />
            <asp:Button ID="b2la" runat="server" Text="<<" Width="40px" OnClientClick="return false;"
                EnableViewState="False" />
        </td>
        <td>
            <asp:ListBox ID="lsr" runat="server" Width="200px" Height="220px" ViewStateMode="Disabled"
                EnableViewState="False"></asp:ListBox></td>


    </tr>
    <tr>
        <td colspan="3">&nbsp;
        </td>
    </tr>
    <tr>
        <td colspan="3">
            <asp:Label ID="lblPrompt" runat="server" EnableViewState="False" 
                Text="If a fitting keyword is not in the list, you may add a new one."></asp:Label>
            <p></p>
            Name:&nbsp;
            <asp:TextBox ID="txtInput" runat="server" EnableViewState="False"></asp:TextBox>
            &nbsp;Desc:&nbsp;
            <asp:TextBox ID="txtDesc" runat="server" EnableViewState="False"></asp:TextBox>
            <p></p>
            <asp:Button ID="ba2l" runat="server" Text="add to list" OnClientClick="return false;" EnableViewState="False" />
            <asp:HiddenField ID="hdv" runat="server" ViewStateMode="Enabled" EnableViewState="True" />
        </td>
    </tr>
    <tr>
        <td colspan="3">
            <asp:Label ID="lblErr" runat="server" EnableViewState="False"></asp:Label>
        </td>
    </tr>
</table>
<script type="text/javascript">
    var lbx = $("#<%=lsl.ClientID %>");
    var lbxr = $("#<%=lsr.ClientID %>");
    var ler = $("#<%=lblErr.ClientID %>");
    var hid = $("#<%=hdv.ClientID %>");
    var brs = $("#<%=b2rs.ClientID %>");
    var bra = $("#<%=b2ra.ClientID %>");
    var bls = $("#<%=b2ls.ClientID %>");
    var bla = $("#<%=b2la.ClientID %>");
    var bad = $("#<%=ba2l.ClientID %>");
    var nameToAdd = $("#<%=txtInput.ClientID %>");
    var descToAdd = $("#<%=txtDesc.ClientID %>");
</script>

**toFromList.ascx.cs codebehind:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web.UI.WebControls;

namespace demos.controls
{
    public partial class toFromList : System.Web.UI.UserControl
    {
        protected void Page_Load(object sender, EventArgs e)
        {
        }

        public void FillListsList(List<ListItem> fullList, List<int> chosenIds)
        {
            lsl.Items.Clear();
            lsr.Items.Clear();
            fullList = fullList.OrderBy(li => li.Text).ToList<ListItem>();
            foreach (ListItem item in fullList)
            {
                bool bFound = false;
                foreach (int chosen in chosenIds)
                {
                    if (chosen.ToString() == item.Value)
                    {
                        bFound = true;
                        break;
                    }
                }
                if (!bFound)
                {
                    lsl.Items.Add(item);
                }
                else
                {
                    lsr.Items.Add(item);
                }
            }
            hdv.Value = string.Join("|", chosenIds.Select(x => x.ToString()).ToArray());
        }

        public string ChosenIdsPipeDelimited
        {
            get { return hdv.Value; }
        }

        public void FillList(List<ListItem> fullList)
        {
            List<int> chosen = new List<int>();
            if (hdv.Value.Trim().Length > 0)
            {
                int conv;
                List<string> newList = new List<string>(((string)hdv.Value).Split("|".ToCharArray()));
                foreach (string item in newList)
                {
                    if (int.TryParse(item, out conv))
                    {
                        chosen.Add(conv);
                    }
                }
            }
            FillListsList(fullList, chosen);
        }
    }
}


**the demo page (put it at the root level of the web app).  Remember to check your own Jquery file src attribute!

***demopage.aspx:

<%@ Page Language="C#" AutoEventWireup="True" CodeBehind="demopage.aspx.cs" 
Inherits="demos.demopage" EnableEventValidation="false"  %>

<%@ Register Src="~/controls/toFromList.ascx" TagPrefix="uc1" TagName="toFromList" %>


<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title>da dum</title>
    <script src="scripts/jquery-1.11.1.min.js"></script>
    <script src="scripts/keyworder.js"></script>
</head>
<body>
    <form id="form1" runat="server">
        <div>
           <asp:Button ID="butTest" runat="server" Text="test hidden" OnClick="butTest_Click" EnableViewState="false" />
            <asp:Label ID="lblInfo" runat="server" EnableViewState="false"></asp:Label>
            <div style="border: 1px solid blue; max-height: 400px; max-width: 500px; margin-top: 10px; margin-bottom: 10px">
                <div style="margin-left: 10px; padding-top: 10px; padding-bottom: 10px">
                    <uc1:toFromList runat="server" id="toFromList" />
                </div>
            </div>
            <asp:Button ID="butSave" runat="server" Text="Save" OnClick="butSave_Click" EnableViewState="false" />
        </div>
    </form>
</body>
</html>


***demopage.aspx.cs code behind:

using System;
using System.Collections.Generic;
using System.Web;
using System.Web.UI.WebControls;
using demos.classes;

namespace demos
{
    public partial class demopage : System.Web.UI.Page
    {
        protected void Page_Load(object sender, EventArgs e)
        {                       
            if (!IsPostBack)
            {
                LoadListsFromDB();
            }
            else
            {
                LoadListsFromHiddenList();
            }
        }

        private void LoadListsFromHiddenList()
        {
            List<ListItem> items = new List<ListItem>();
            ListItem ite = new ListItem();
            
            if (HttpContext.Current.Cache["myvalues"] == null)
            {
                List<keyword> list = new List<keyword>();
                list.Add(new keyword(1, "one", "one desc"));
                list.Add(new keyword(2, "two", "two desc"));
                HttpContext.Current.Cache["myvalues"] = list;
            }
            List<keyword> cachedThings = (List<keyword>)HttpContext.Current.Cache["myvalues"];

            foreach (var item in cachedThings)
            {
                ite = new ListItem();
                ite.Text = item.Name;
                ite.Value = item.Id.ToString();
                ite.Attributes.Add("title", item.Desc);
                items.Add(ite);
            }
           toFromList.FillList(items);
        }

        protected void butTest_Click(object sender, EventArgs e)
        {
            //serverside event to force the page refresh and verify that 
            //chosen ids have persisted in the hidden field
            lblInfo.Text = "value now (" + DateTime.Now.ToString() + ") " + 
               toFromList.ChosenIdsPipeDelimited;

        }

        protected void butSave_Click(object sender, EventArgs e)
        {
            SavePage();
        }

        private void SavePage()
        {
            //here you would update your database with the chosen ids from the controls hidden field
            //for the demo we're just saving then to a session var

            Session["chosen"] = toFromList.ChosenIdsPipeDelimited;
            //remember this is serverside demo code.  after saving these values, perhaps along 
            //with other control values,
            //the overall form save should reload the return or move to another page.  
            ///For this demo we're reloading the values from the cache
            LoadListsFromDB();
        }


        private void LoadListsFromDB()
        {
            //in a real form you'd get the full list of keywords (or whatever) from an object or method call
            //or even use the same web service that the control's ajax is using
            //this demo  directly gets its data from cache and session variables - just for DEMO sake

            List<ListItem> items = new List<ListItem>();
            ListItem newItem = new ListItem();

            if (HttpContext.Current.Cache["myvalues"] == null)
            {
                List<keyword> list = new List<keyword>();
                list.Add(new keyword(1, "one", "one desc"));
                list.Add(new keyword(2, "two", "two desc"));
                HttpContext.Current.Cache["myvalues"] = list;
            }
            List<demos.classes.keyword> cachedThings = (List<demos.classes.keyword>)HttpContext.Current.Cache["myvalues"];

            foreach (var item in cachedThings)
            {
                newItem = new ListItem();
                newItem.Text = item.Name;
                newItem.Value = item.Id.ToString();
                newItem.Attributes.Add("title", item.Desc);

                items.Add(newItem);
            }


            List<int> chosen = new List<int>();
            if (Session["chosen"] != null)
            {
                int conv;
                List<string> newList = new List<string>(((string)Session["chosen"]).Split("|".ToCharArray()));
                foreach (string item in newList)
                {
                    if (int.TryParse(item, out conv))
                    {
                        chosen.Add(conv);
                    }
                }
            }
            toFromList.FillListsList(items, chosen);
        }
    }
}


** keyworder.js (put in your scripts folder along with your jquery js file) Minifying drops this to 3k

$(function () {
    function unselectAll() {
        $(lbx).val([]);
        $(lbxr).val([]);
    }
    function byText(a, b) {
        return a.text > b.text ? 1 : -1;
    };
    function rearrangeList(list) {
        $(list).find("option").sort(byText).appendTo(list);
    }
    function moveSelectedRight() {
        $(lbx.selector + " option:selected").each(function () {
            $(this).remove().appendTo(lbxr.selector);
            var hidArr = $(hid).val().split('|');
            hidArr.push($(this).val());
            $(hid).val(hidArr.join('|'));
            rearrangeList(lbxr);
            unselectAll();
        });
    }

    function moveSelectedLeft() {
        $(lbxr.selector + " option:selected").each(function () {
            $(this).remove().appendTo(lbx.selector);
            var hidArr = $(hid).val().split('|');
            var test = $(this).val();
            hidArr = jQuery.grep(hidArr, function (value) {
                return value != test;
            });
            $(hid).val(hidArr.join('|'));
            rearrangeList(lbx);
            unselectAll();
        });
    }

    $(brs).click(function () {
        moveSelectedRight();
    });

    $(lbx).dblclick(function () {
        moveSelectedRight();
    });

    //move all right, deal with the hidden value array
    $(bra).click(function () {
        $(lbx.selector + "  option").appendTo(lbxr.selector);
        var hidArr = new Array();
        $(lbxr.selector + " option").each(function () {
            hidArr.push($(this).val());
        });
        $(hid).val(hidArr.join('|'));
        rearrangeList(lbxr);
        unselectAll();
    });

    $(bls).click(function () {
        moveSelectedLeft();
    });

    $(lbxr).dblclick(function () {
        moveSelectedLeft();
    });

    //move all left
    $(bla).click(function () {
        $(lbxr.selector + " option").appendTo(lbx.selector);
        $(hid).val('');
        rearrangeList(lbx);
        unselectAll();
    });

    //ajax call
    $(bad.selector).click(function () {
        $(ler).text('');
        $.ajax({
            type: "POST",
            url: "/services/listsvc.asmx/addnewkeyword",
            data: "{addName:\"" + nameToAdd.val() + "\",addDesc:\"" + descToAdd.val() + "\"}",
            contentType: "application/json; charset=utf-8",
            dataType: "json",
            success: function (msg) {
                var retList = msg.d;
                if (retList.Id != undefined) {
                    $(lbxr).append('<option value="' + retList.Id.toString() + '" title="' + retList.Desc + '">' + retList.Name + '</option>');
                    rearrangeList(lbxr);
                    var hidArr = $(hid).val().split('|');
                    hidArr.push(retList.Id);
                    $(hid).val(hidArr.join('|'));
                    nameToAdd.val('');
                    descToAdd.val('');
                    $(ler).text("new keyword saved successfully").fadeOut(3000, function () {
                        $(this).text('');
                        $(this).show();
                    });
                  
                }
                else {
                    $(ler).text("No records found");
                }
            },
            error: function (XMLHttpRequest, textStatus, errorThrown) {
                var s;
                try {
                    jsonError = JSON.parse(XMLHttpRequest.responseText);
                    s = jsonError.Message;
                    if (s == "DuplicateNameException") {
                        s = "A keyword with that value is already on file.  \
                            Please create another keyword or use the existing " + nameToAdd.val();
                        var bFound = false;
                        $(lbx).each(function () {
                            $('option', this).each(function () {
                                if ($.trim($(this).text().toLowerCase()) == $.trim(nameToAdd.val().toLowerCase())) {
                                    $(this).attr('selected', 'selected');
                                    bFound = true;
                                    return false;
                                };
                            });
                        });

                        if (!bFound) {
                            $(lbxr).each(function () {
                                $('option', this).each(function () {
                                    if ($.trim($(this).text().toLowerCase()) == $.trim(nameToAdd.val().toLowerCase())) {
                                        $(this).attr('selected', 'selected');
                                        bFound = true;
                                        return false;
                                    };
                                });
                            });
                        }
                    }
                     else if (s == "BlankKeywordException") {
                        s = "Keywords can not be blank.  Please try again or use an existing keyword.";
                    }
                     else if (s == "KeywordFormatException") {
                        s = "Keywords must be between 3 and 30 characters. \
                            Values must be all alphabetical but single spaces or single hyphens are \
                            allowed between letters. Please try again or use an existing keyword.";
                    }
                     else if (s == "DescriptionFormatException") {
                        s = "Descriptions are not required but if supplied they must be between 3 and 50 characters. \
                        Values must be all alphabetical but single spaces or single hyphens are \
                        allowed between letters. Please try again.";
                    }
   else{
s = "There was a problem, please try again.";
                    }

                } catch (e) {
                    //catches the uncatchable 500s, like service down or off-network issues
                    if (e.message.toLowerCase() == "invalid character") {
                        s = "There was a problem connecting with the validation system.";
                    }
                    else {
                        s = e.message;
                    }
                }
                $(ler).text(s);
            }
        });
    });
});   



home     who is smith    contact smith     rss feed π
Since 1997 a place for my stuff, and it if helps you too then all the better smithvoice.com