Programming, technology, and CRM – from a Belgian programmer exiled to Missouri
  • rss
  • Home
  • Contact Me
  • Welcome

Useful web development tools

Nicolas Galler | October 25, 2009

What are the tools you use every day… or at least on a very regular basis for web development? I sat down and made a list… leaving out the obvious Visual Studio and the stuff that was more specific to .NET (Reflector, windbg etc):

  • IETester – great for testing display of different IE version without switching between 3 vms
  • jsmin to minimize / combine javascript
  • Firebug and/or IE8 developer tools… duh! Can’t get very far without one of those any more.
  • IE7 developer tools – they tend to crash a lot on SlxWeb so I only use them when I need to troubleshoot an IE7-specific problem
  • Sizer – very useful to see what the page looks like at various resolutions
  • Fiddler – to snoop http… very neat
  • Tamper Data – Firefox extension to troubleshoot HTTP headers etc, sometimes I use it instead of Fiddler, or when I am on Linux. Fiddler is a lot more user friendly but it does not let you edit the headers on the fly.
  • Wireshark – a TCP capture tool… for when Fiddler is not enough! Thankfully I don’t have to use that one very often.
  • ScreenRuler for Gnome: this is Linux-specific but I am sure Windows equivalents exist – it pops a virtual ruler on the screen. Very handy when trying to align controls

And a few others that are not strictly web tools, but still extremely useful in web development:

  • KDiff3 – a diff tool. There are a number of other diff tools. WinMerge is a bit easier to use but I had trouble with some files with it (something to do with the newline styles, I think).
  • Notepad2 – a notepad replacement with syntax highlighting and other features. I use it a lot to peek at source files because it loads about 1000 times faster than Visual Studio (and even a lot faster than Vim)
  • Source control is a must: I use Git (actually msysgit, for Windows) for SlxWeb development and Subversion for most other projects. Subversion is a lot easier to use but does not work well for SlxWeb.
  • VirtualBox – I tried VMWare and VirtualPC as well, but the free versions of VMWare are very limited, and I found VirtualPC to have very poor performance.
Comments
2 Comments »
Categories
Programming
Comments rss Comments rss
Trackback Trackback

Saleslogix bandwidth saver – store viewstate in Session

Nicolas Galler | September 12, 2009

As I try to optimize the bandwidth on the site using caching, compression etc it still remains very high.  There are substantial improvements on that front in the upcoming SP2 but that does not mean those of us stuck in the present are completely helpless.  If you use a tool such as fiddler to analyze the traffic on a postback (for example, when you change the account type – which does not actually result in any visible change on the UI) you may see something like (note, this is uncompressed):

Request Count:     1
Bytes Sent:     17,790
Bytes Received: 184,648

That’s a pretty big chunk – both on the way in and out.

For comparison here is gmail when I browse from message to message (uncompressed):

Request Count:     1
Bytes Sent:     1,295
Bytes Received: 7,521

Well part of it is just the nature of the ASP.NET UpdatePanel – they practically resend the whole page every time so it is bound to take a good bit more space.  But another culprit is the ViewState – this is something that ASP.NET uses to “remember” what is displayed on the browser (since fundamentally, the web is stateless – if you say you want to view the 3rd row in the grid, the server has to have some way to remember what this 3rd row is pointing to).  What happens is the server takes all (or most) of the values on the page, and their corresponding data sources, serializes them to text, and stick them in a hidden field on the page as something like this:

/wEPDwUENTM4MQ9kFgJmD2QWAgIBD2QWDgICDw8WAh4EVGV4dAUOTmljb2xhcyBHYWxsZXJkZAIGDw8WBB4IQ3NzQ2xhc3MFBk5hdkJhch4/wEPDwUENTM4MQ9kFgJmD2QWAgIBD2QWDgICDw8WAh4EVGc+bDAwJFRhYkNvbnRyb2wkZWxlb …  11000 more characters in between … 9nZE+NljKHHLWlju6N/3/hWmlJpeFXEXyFTQgICZGQCDA9kFgQCAQ9kFgJmD2QWAgIBD2QWAmYPZBYgAgEPDxYEHglNYXhMZW5ndGgCgAEfAAUZQ2VhWmlJpeFX

So you have 11 to 30 kb of “stuff” to pass back and forth on each and every post, not to mention the valuable CPU it takes to serialize and deserialize it.  Usually it is rather neat to have it on the browser rather than on the server because it means if your session is torn (for example because the web server restarted) you will keep this viewstate.  However this is rather moot on Saleslogix since the user is kicked out in that event anyway… so why not leave it on the server in the first place?  Turns out Microsoft thought of that too and they provided us with a way to store the ViewState in the session.  Saves the bandwidth and the CPU time to handle it (session values are stored “as-is” in memory so it is pretty fast).  There are posts describing the process here and here – I am including both because I took an in-between approach.  Once I did that I reduced the ViewState to this (it still needs a key to be able to look up the viewstate in the session):

/wEPZwUPOGNjMDFjZGYxNWZjYmViQpgJqyUrZohSJ3XG/uZnVdKp1Vk=

and the data transmitted on a simple postback to this:

Request Count:     1
Bytes Sent:     7,740
Bytes Received: 158,740

It yields a small but perceptible speed improvement on postbacks (the improvement is rather negligible on page changes though – when you switch from contact to account, for example).  One downside is it will require a bit more memory on the server side but even at an extra 600 kb per user (enough for 20 of the biggest viewstates) it is only a few hundred megs.

The steps were as follows:

  • Created a page adapter called “SessionViewStatePageAdapter” under the App_Code folder – see http://msdn.microsoft.com/en-us/library/system.web.ui.sessionpagestatepersister.aspx for the code, mine looks like this:
public class SessionViewStatePageAdapter : PageAdapter
{
    public override System.Web.UI.PageStatePersister GetStatePersister()
    {
        return new SessionPageStatePersister(this.Page);
    }
}
  • Add an “App_Browser” folder to the site (it shows up as an option in Visual Studio) and drop a file “Default.browser” with this content:
<browsers>
    <browser refID="Default">
      <controlAdapters>
        <adapter controlType="System.Web.UI.Page" adapterType="SessionViewStatePageAdapter"/>
      </controlAdapters>
    </browser>
</browsers>
  • Add this tag to the web.config (under system.web), otherwise only part of the ViewState will be affected:
<browserCaps>
      <case>RequiresControlStateInSession=true</case>
</browserCaps>
  • In this state the server will get confused if the user opens more than 9 windows (not including the “in-page” popups) so I increased that to 20 by adding this under system.web in web.config:
<sessionPageState historySize="20"/>

All in all a decent improvement for a minimal effort.

Comments
No Comments »
Categories
Programming, Saleslogix
Comments rss Comments rss
Trackback Trackback

A bit of exploration with SalesLogix, Custom portals, and ASP.NET MVC

Nicolas Galler | July 4, 2009

Ever tried this menu?

image

It basically lets you set up a new deployment – that is, a way to export your defined entities, dynamic methods, and interfaces.  But you don’t get much when you try the “Portal Wizard” – the site it creates is almost empty, and because there will be no authentication set up, you can’t add pages to it right away either.  On the plus side, it gives you a good building base and is very flexible.

I decided to give it a try and combine it with a quick shot at ASP.NET MVC (which, in case you did not know, is an alternative to regular ASP.NET published by Microsoft – if you like the simplicity of Rails or Django you will recognize some of those frameworks in there).  I thought this might be a good option for some external sites that don’t need the full overhead of the regular Saleslogix web client.

As a little experimentation I decided to create a quick contact lookup site, it is actually slightly useful to me as the site is rudimentary enough to be used from a “dumb” phone (any phone that supports HTML) and as it does not have the long start up time of the regular web client it can be used to look up a contact basic info very quickly.

The only real trick was finding the right “modules” to configure in web.config.  I also had to enable the integrated pipeline (from IIS7) from ASP.NET MVC (technically you can use it with the classic pipeline but the URLs don’t look as good), but fortunately this had no impact on Saleslogix (as long as you make it 32 bit).  So here are the steps in (more or less) details.

The first step is obviously to download ASP.NET MVC from http://www.asp.net/mvc. 

Next, I started up Visual Studio and created a new ASP.NET MVC site.  This created a sample application with a few demo pages which I removed (remove everything under Views\Account and Views\Home, and remove the AccountController).  I did test it before removing to make sure it worked!

At the same time (actually right before) I created a new portal in the Application Architect (using the “Service Host” template, but I don’t think it really makes a difference) and deployed it.  I copied over my ASP.NET MVC site, keeping the following files from the Saleslogix portal:

  • hibernate.xml
  • log4net.config
  • ServiceHosts.xml
  • dynamicmethods.xml
  • application.xml
  • connection.config

Remember that those files will be re-generated every time we deploy the custom portal. The rest of the files come from the ASP.NET MVC template, we’ll eventually add those to the support files section of the portal so that we can re-deploy everything at once.

Next came the tricky part ;) I added the following modules to web.config (under system.webServer/modules – remember, we use the IIS7 section, not the IIS6 one):

<add name="ProcessModule" type="Sage.Platform.Application.UI.Web.AppManagerModule, Sage.Platform.Application.UI.Web" />
<!-- NHibernate Session scope -->
<add name="SessionScopeModule" type="Sage.Platform.Framework.SessionScopeWebModule, Sage.Platform" />

<add name="SlxAuthenticationResetModule" type="MvcApplication1.Utility.SlxAuthenticationResetModule, MvcApplication1"/>

The ProcessModule is probably the most important – it initializes the good old ApplicationContext.Current object.  SessionScopeModule works on maintaining an active NHibernate section for each web request, it is important for lazy-loading requests to work correctly (i.e. when you try to access myAccount.Contacts). The last one is not essential, it is a simple module I added to prevent drops when the session is lost (I am sure you have seen the “Authorization Token cannot be null” error a number of times – this module deals with it, does not work on the “regular” web client though because there are a few other things going on there).  While I was in there I also set up the authentication, we use a basic Forms authentication here:

<authentication mode="Forms">
    <forms loginUrl="~/Home/LogOn" timeout="2880" />
</authentication>

I went in global.asax and changed the default route to Home/Search as well (which does not exist yet):

routes.MapRoute(
  "Default",                                              // Route name
  "{controller}/{action}/{id}",                           // URL with parameters
  new { controller = "Home", action = "Search", id = "" }  // Parameter defaults
);

Now HomeController.cs is where most of the fun happens, starting with the LogOn function.  Here we must make sure that we validate the user and also save his credentials into the “token” that is going to be used to build connection strings.  Fortunately this is quite simple:

public ActionResult LogOn()
{
    return View();
}

[AcceptVerbs(HttpVerbs.Post)]
public ActionResult LogOn(String username, String password, string returnUrl)
{
    SLXWebAuthenticationProvider auth = (SLXWebAuthenticationProvider)ApplicationContext.Current.Services.Get<IAuthenticationProvider>();
    if (auth == null)
    {
        throw new InvalidOperationException("Can't access Authentication Provider");
    }
    auth.AuthenticateWithContext(username, password);
    try
    {
        using (TransactionScope tx = new TransactionScope())
        {
            IUser user = EntityFactory.GetRepository<IUser>().FindByProperty("UserName", username).First();
            // now this will only succeed if the connection was successful
            FormsAuthentication.SetAuthCookie(user.Id.ToString(), true);
        }
    }
    catch (Exception)
    {
        auth.Invalidate();
        ViewData.ModelState.AddModelError("message", "Authentication Failed");
        return View();
    }
    if (!String.IsNullOrEmpty(returnUrl))
    {
        return Redirect(returnUrl);
    }
    else
    {
        return RedirectToAction("Search", "Home");
    }
}

The AcceptVerbs attribute on the second overload ensures that this is the function called when the form is posted, while the parameter-less overload is called on initial requests.  The “return View()” means return the view with the same name.  As for the view I just copied the one from Account/LogOn.aspx to the Home folder.  Note that the “Remember Me” box actually keeps you logged in here, not like the Remember Me box of the Saleslogix client.  It’s handy for a cell phone so you don’t have to retype your password every time.  That part only works with the “SlxAuthenticationResetModule” I mentioned above though.

The “Search” action (in HomeController.cs) is extremely simple but it does illustrate the access to the EntityFactory… the “Authorize” attribute ensures that they get to log in first.  We just get a (short) list of matching contact, stick it in the session, and send the control to the Contact/List action.  If there is only one match we redirect to the detail screen.  I only pass the Id, to make it simpler, but since it will be cached by NHibernate there is no performance hit.

[Authorize]
public ActionResult Search()
{
    return View();
}

[Authorize]
[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Search(String search)
{
    Session["List"] = null;
    IList<IContact> contacts = FindContact(search);
    if (contacts.Count == 1)
    {
        return RedirectToAction("Details", "Contact", new { id = contacts.First().Id });
    }
    else if (contacts.Count > 1)
    {
        Session["List"] = contacts;
        return RedirectToAction("List", "Contact");
    }
    else
    {
        ViewData["Message"] = "No match!";
        return View();
    }
}

private IList<IContact> FindContact(string search)
{
    var repo = EntityFactory.GetRepository<IContact>();
    using (var session = new SessionScopeWrapper(false))
    {
        var query = session.CreateQuery("from Sage.SalesLogix.Entities.Contact where LastName like ?")
            .SetString(0, search + "%")
            .SetMaxResults(15);

        return query.List<IContact>();
    }
}

Add a view under Home/Search.aspx to make this complete.  This is the code I used for it:

    <% using (Html.BeginForm("Search", "Home")){ %>
    <p>
        Search: <%= Html.TextBox("search") %>
    </p>
    <% if (ViewData["Message"] != null){ %>
       <p>
        <%= ViewData["Message"] %>
       </p>
    <% } %>
    <p>
        <input type="submit" value="Search" />
    </p>
    <% }  %>

ContactController.cs is even simpler… for the List, we just pick it up from the session, and display it.  For the Details, we retrieve the contact by id.  For the sake of completeness here is the code:

[Authorize]
public ActionResult List()
{
    IList<IContact> list = Session["List"] as IList<IContact>;
    if (list == null)
        return RedirectToAction("Search", "Home");
    return View(list);
}

[Authorize]
public ActionResult Details(String id)
{
    IContact contact = EntityFactory.GetById<IContact>(id);
    if (contact == null)
        return RedirectToAction("List");
    return View(contact);
}

Now one very neat thing is the way the data binding works.  Try adding a view under contact and select “strongly typed view”.  Under the View Data Class enter “Sage.Entity.Interfaces.IContact” (you have to type it in as the dropdown will not list interfaces).  Then in the view you can use things like “Model.Mobile” – and the intellisense will show the auto-complete suggestion for you!  This is my details view… very basic of course:

 <h2><%= Html.Encode(Model.FullName) %></h2>

<fieldset>
    <legend>Fields</legend>
    <p>
        Work Phone:
        <%= Html.Encode(Model.WorkPhone) %>
    </p>
    <p>
        Mobile:
        <%= Html.Encode(Model.Mobile) %>
    </p>
    <p>
        Company:
        <%= Html.Encode(Model.AccountName) %>
    </p>
    <p>
        Email:
        <%= Html.Encode(Model.Email) %>
    </p>
</fieldset>
<% if (Session["List"] != null){ %>
<p>
    <%=Html.ActionLink("Back to List", "List")%>
</p>
<% } %>
<p>
    <%=Html.ActionLink("Back to Search", "Search", "Home") %>
</p>

And my list view (in List.aspx) – here for the type I entered “IEnumerable<Sage.Entity.Interfaces.IContact>”.  This can be entered after the fact under the “Page” directive also.  Here is the full listing:

<%@ Page Title="" Language="C#" MasterPageFile="~/Views/Shared/Site.Master"
    Inherits="System.Web.Mvc.ViewPage<IEnumerable<Sage.Entity.Interfaces.IContact>>"
    ContentType="text/html" %>

<asp:Content ID="Content1" ContentPlaceHolderID="TitleContent" runat="server">
    List
</asp:Content>

<asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">

    <table border="1">
    <tr>
        <th>Name</th>
        <th>Company</th>
    </tr>
    <% foreach (var contact in Model){ %>
    <tr>
        <td><%= Html.ActionLink(contact.FullName, "Details", new{ id = contact.Id.ToString() }) %></td>
        <td><%= contact.AccountName %></td>
    </tr>
    <% } %>
    </table>
    <p>
        <%= Html.ActionLink("Back to Search", "Search", "Home") %>
    </p>
</asp:Content>

By the way, little detail here, don’t forget to add ContentType="text/html" otherwise some phones will return an “File format unknown” error (my Nokia did).  All done!  This is what it looks like on the phone:

image

Hey, nobody said it was going to be a work of art!

All in all an interesting little experiment.  I will probably come back and use that info in the near future as we have to do a small portal-type site outside of the web client.  One neat thing by the way is that this can also be used even if the customer is still on the LAN client.  If interested feel free to download the code.

Comments
3 Comments »
Categories
Programming, Saleslogix
Comments rss Comments rss
Trackback Trackback

Tips & Tricks of the Web Client

Nicolas Galler | May 15, 2009

This is a collection of random tricks, pitfalls etc that I am encountering on the current web client project. I will update within the next few weeks with other tidbits I find out.

General Tips

  1. Do not use quickforms.  Ever.  Seriously.  For real.  Well, there are exceptions:
    • A form with really trivial layout, no dynamically set controls, and no validation logic
    • When trying to figure out the basic syntax to declare a Saleslogix control
    • When making a trivial change to an existing form
    • If you have limited knowledge of HTML and really, really don’t want to learn: this it is NOT an exception because you will need to know HTML in order to troubleshoot the quickform!
  2. In case of an error, start with the event log.  Especially for lookups.
  3. Use CSS to your advantage when laying out the forms!  The code in quickform smartparts can afford to be sloppy and repetitive because it’s automatically generated, but we can’t! 
  4. In order to add a linked entity, use code like:
    ISEOppUtility oppUtility = EntityFactory.Create<ISEOppUtility>();
    // link to the parent entity ...
    oppUtility.Opportunity = parentEntity;
    // ... and add to the parent's collection.  The link is bidirectional.
    parentEntity.SEOppUtilities.Add(oppUtility);
    oppUtility.Save();
    // save the parent... in theory this should cascade and 
    // save the child entity... but that does not always work
    parentEntity.Save();
    // ensure that all views get refreshed - this is not always necessary
    PageWorkItem.Services.Get<IPanelRefreshService>().RefreshAll();
  5. Do not try to rename anything in the App Architect – it will mix stuff up. It is quicker and safer to do it on the backend XML instead.
  6. Run the web site on .NET 3.5 using the following steps:
    • Global replace of 1.0.61025.0 to 3.5.0.0 in web.config (do NOT replace in the other files)
    • Add "dependentAssembly" tag under runtime/assemblyBinding:
      <dependentAssembly>
              <assemblyIdentity name="System.Web.Extensions" culture="neutral" publicKeyToken="31bf3856ad364e35"/>
              <bindingRedirect oldVersion="1.0.61025.0"
                               newVersion="3.5.0.0"/>
            </dependentAssembly>
    • Use this for compilation tag (under system.web):
    •     <compilation debug="true" defaultLanguage="C#">
            <assemblies>
              <!--
              Cannot add System.Core - it conflicts with LinqBridge which is required by SLX
              <add assembly="System.Core, Version=3.5.0.0, Culture=neutral, PublicKeyToken=B77A5C561934E089"/>
              -->
              <add assembly="System.Web.Extensions, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35"/>
              <add assembly="System.Data.DataSetExtensions, Version=3.5.0.0, Culture=neutral, PublicKeyToken=B77A5C561934E089"/>
              <add assembly="System.Xml.Linq, Version=3.5.0.0, Culture=neutral, PublicKeyToken=B77A5C561934E089"/>
            </assemblies>
          </compilation>
    • As you cannot add System.Core not all features will be available. Most importantly System.Linq.Expressions.
  7. Do not use <script> tags for JavaScript on smart parts because they will get lost after a postback. Use ScriptManager.RegisterClientScriptBlock instead. Unfortunately this means a large performance hit if the script is big as the client will have to download and parse it every time. Alternatively, disable the top-level UpdatePanel, and use smaller updatepanels to do the job (this will make the page a LOT more responsive on slow links but requires a bit of work to make sure everything is still getting refreshed as it should)

Data Access

  1. Events on property changes have 2 serious drawbacks:
    • “BeforeUpdate” events do not fire at all
    • “AfterUpdate” events only fire when the entity itself is saved, thus they are not terribly useful as you might as well put that code in the OnUpdate event of the entity itself. Also, because it is processed after the entity is actually saved, you cannot easily modify the entity at this point (you have to resave it, but because it is already inside of a Save call, it may cause NHibernate to crash)
  2. Events on an “extension” entity do not always trigger correctly on “cascading” save. Basically if you have a rule on ContactExt.BeforeUpdate, and you make a change to the ContactExt field, then call Account.Save, the changes get saved, but the event rule does not necessarily get called. It is a bit confusing, but you will recognize it when it happens.
  3. Do not Delete and Save an entity within the same transaction (this includes Save / Delete that are made as part of a cascade operation)

Lookups

  1. You can have a lookup exclude the entities that were already selected using code like:

    lueAddUtility.LookupExclusions = parentEntity.SEOppUtilities.ToArray();

  2. To invoke a lookup programmatically the following code works, sloppy as it may look:
    ScriptManager.RegisterClientScriptBlock(this, GetType(),
                "ShowLookup",
                // we need a slight delay here to give the lookup a chance to initialize
                "$(document).ready(function() { setTimeout(function() { " + lueAddUtility.ClientID + "_luobj.show() }, 500) })",
                true);

    It is also possible to set up a lookup as "Button only" which creates the lookup button… good for "associate a record" buttons.

  3. It is possible to use custom HQL in a lookup, by doing a PreFilter that points to a non-string property (this may well break in a later version).  EG (taking advantage of the fact that TYPE is defined as an enum and not a string, this creates a condition to select remote, network and concurrent users only):
    <SalesLogix:LookupPreFilter PropertyName="Type" CondOperator="Not Equal to" FilterValue="'' and User.Type in ('N', 'M', 'C')" />

    Note the “Not Equal to” bit is case sensitive.

  4. If adding a LookupPreFilter on a field that is not a string (including enums), you have to add the single quotes explicitely.
  5. I am not sure if you are supposed to use CondOperator or OperatorCode in the LookupPreFilters. The documentation seems to mention CondOperator, but Saleslogix uses OperatorCode… the possible values for “CondOperator” are in the help file, but in addition to the ones mentioned there one can also use “Not Equal to”.
  6. Lookups get cached the first time they are accessed… if the definition changes on the smart part, Log Off, and log back in. Closing IE or restarting IIS is not necessary. To some extent this may make it hard to modify lookups on the fly. Interesting tidbit: if LookupExclusion is not null then the cache won’t be used. So if you want to set up the LookupPreFilter dynamically you may have to make sure you set up a LookupExclusion (this may be an empty array). Will probably break in a later version.
  7. Lookups with a displaymode of DropDownList behave completely differently and may as well be considered different controls. None of the above comments apply to them.

Databinding

  1. Remember that the Saleslogix databinding works with events (i.e. when TextChanged fires is when the property will be populated on the entity). This has 2 important consequences:
    • You can’t bind to a property that does not have a corresponding change event (e.g. DropDownList.SelectedValue)
    • Within a Change handler, it is hard to know whether the properties have all been brought in yet or not. Say you want to change the CurrentEntity.Price when the lookup returns – if txtPrice fires a TextChanged event after the LookupResultValueChanged event did, then the property will be overwritten. To be safe it has to be written both to the entity and to the control.
  2. On the property itself there is a way to characterize the datatype (for some property types). For example for a “Double” type property there is a way to indicate that the property refers to a percentage. This is probably useful in some situation but other times it messes up with the rounding and the display. So I turned it off (a few years ago I wrote a little piece of Javascript to format percentages in textboxes and it still works pretty well for me).
  3. There is a client-side component for the databinding (responsible for undoing the changes when the user hits the close button, for example). When messing with the DOM be careful not to get this one confused (do not move controls too far afar in the document – if they get under a different “workspace” some of the bindings won’t work).
  4. It is possible for a smart part to interfere with the databinding of other smart parts. For example calling Page.DataBind is a NO-NO. If you notice that some values are getting cleared when they shouldn’t (often happens with the values that are loaded from ControlState, e.g. GridView.DataKeys), either check the other smartparts on the page or use a work around (e.g. instead of using grid.DataKeys we can use a CommandArgument).
Comments
1 Comment »
Categories
Programming, Saleslogix
Comments rss Comments rss
Trackback Trackback

IE8 “Define with Wiktionary” Accelerator

Nicolas Galler | April 26, 2009

This is an “unofficial” IE8 accelerator to get a definition of the highlighted word via Wiktionary. I like Wiktionary the best because it offers the definitions for all languages (I commonly need French, English and Spanish and would rather not have to click a separate link for each). At this time the accelerator just opens the window, does not show the preview, so it just saves me the time of having to alt-tab and paste. The preview is, of course, a bit more work (you have to condense the text to be able to fit in a 320×240 window), though it would be quite interesting to have.

The accelerators are very easy to define (for simple web services at least) – the documentation is at OpenService Accelerators Developer Guide.

To install, click the big button below (requires IE8, duh):

PS: almost forgot to link to the source! You have to use a little piece of Javascript to install it though – either check out the source from this page or the documentation from the MS site.

Comments
No Comments »
Categories
Programming, Uncategorized
Comments rss Comments rss
Trackback Trackback

Automatic Assembly Version Number

Nicolas Galler | August 6, 2008

Under Properties\AssemblyInfo.cs you can set the version number – this shows up under the file’s details in Windows:

image

There is a nifty msbuild task that was distributed by Microsoft a while back called "AssemblyInfoTask" (I think it came with one of the Team Foundation Server releases) that can automatically set it to the current date which is very handy to quickly find out which version of a particular DLL is at a customer site.  However there are a few issues with the strategy:

  • because the version number is a 16-bit number it has some issues with assemblies built after 2007 (there is a work around for it – as you can see I only have month and day)
  • it updates AssemblyInfo.cs (no easy way to customize that) which is usually under version control… so this causes a lot of unnecessary commits and conflicts (trivial to resolve, but still annoying)
  • since it updates AssemblyInfo.cs every time it runs and not just when the version number needs to change it will cause the assembly to be rebuilt completely every time which makes the build process slower.

Alternatively there is a task in the "MSBuild Community Task" that can be used to generate a VersionInfo.cs file – just as good as AssemblyInfo but because this only contains the version it does not need to be checked into source control alleviating the 2nd issue above (at this point you comment out the AssemblyVersion attribute from the AssemblyInfo.cs file).

Another one from MSBuild Community Task is "SvnInfo" – it can be used to retrieve the Subversion revision number (there are other, equivalent tasks for other version control system).  Using this is just as good if not better than the date so we avoid the 1st issue (though this will come back when my SVN number gets above 65536!  But I have a long way to go for that).

There was still one issue which is the file would always be generated thus preventing the incremental build from shortening the build process as it is supposed to (in C or Java we can somewhat get away with that since the objects are built on a file-by-file basis but in C# they are always built into assemblies).

Therefore I created a very very simple task (don’t laugh, it is my first msbuild custom task!) that generates the file but replaces it only if the number is updated:

using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.Build.Utilities;
using System.IO;

namespace SSSWorld.MsBuild
{
    /// <summary>
    /// Task used to generate a VersionInfo file with the AssemblyVersion and AssemblyFileVersion
    /// attributes.
    /// Contains logic to avoid overwriting the file if it exists and the content is identical.
    /// </summary>
    public class GenerateVersionInfo : Task
    {
        public UInt16 MajorNumber { get; set; }
        public UInt16 MinorNumber { get; set; }
        public UInt16 RevisionNumber { get; set; }
        public UInt16 BuildNumber { get; set; }
        public String OutputFileName { get; set; }

        public GenerateVersionInfo()
        {
            MajorNumber = 0;
            MinorNumber = 0;
            RevisionNumber = 0;
            BuildNumber = 0;
            OutputFileName = null;
        }

        public override bool Execute()
        {
            String code = GenerateCode();
            String existingCode = null;
            if (File.Exists(OutputFileName))
                existingCode = File.ReadAllText(OutputFileName);
            if (!code.Equals(existingCode))
            {
                File.WriteAllText(OutputFileName, code);
                Log.LogMessage("Generated VersionInfo file {0}", OutputFileName);
            }
            else
            {
                Log.LogMessage("VersionInfo file {0} is up to date", OutputFileName);
            }

            return true;
        }

        private String GenerateCode()
        {
            StringBuilder buf = new StringBuilder();

            buf.AppendLine("// Code generated automatically by SSSWorld.MsBuild.GenerateVersionInfo")
                .Append("[assembly: System.Reflection.AssemblyVersion(\"")
                .Append(MajorNumber).Append(".")
                .Append(MinorNumber).Append(".")
                .Append(RevisionNumber).Append(".")
                .Append(BuildNumber == 0 ? "*" : BuildNumber.ToString())
                .AppendLine("\")]")
                .Append("[assembly: System.Reflection.AssemblyFileVersion(\"")
                .Append(MajorNumber).Append(".")
                .Append(MinorNumber).Append(".")
                .Append(RevisionNumber).Append(".")
                .Append(BuildNumber)
                .AppendLine("\")]");
            return buf.ToString();
        }
    }
}

And I adapted the AssemblyInfoTask target file to call on this task:

<?xml version="1.0" encoding="utf-8"?>
<!-- This targets file includes all the necessary information to automatically increment build numbers as part of
     a regular build process. To use it simply include it in your project file after any other includes. The typical
     include line looks like this:
     
     <Import Project="...\SSSWorld\msbuild\AssemblyVersion.target"/>
     
     and make sure you have a Properties\VersionInfo.cs file in your project (it will be overwritten by this task
     and should NOT be checked in version control)
  -->
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <UsingTask AssemblyFile="SSSWorld.MsBuild.dll" TaskName="SSSWorld.MsBuild.GenerateVersionInfo"/>

  <!-- These properties can be overridden to specify the major/minor version number -->
  <PropertyGroup>
    <MajorNumber Condition="'$(MajorNumber)'==''">1</MajorNumber>
    <MinorNumber Condition="'$(MinorNumber)'==''">0</MinorNumber>
    <!-- Default will be to get the SVN revision number as revision number -->
    <RevisionNumber  Condition="'$(RevisionNumber)'==''">0</RevisionNumber>
  </PropertyGroup>

  <!-- Re-define CoreCompileDependsOn to ensure the assemblyinfo files are updated before compilation. -->
  <PropertyGroup>
    <CoreCompileDependsOn>
      $(CoreCompileDependsOn);
      GenerateVersionInfoFile
    </CoreCompileDependsOn>
  </PropertyGroup>

  <Import Project="$(MSBuildExtensionsPath)\MSBuildCommunityTasks\MSBuild.Community.Tasks.Targets"/>    

  <Target Name="GenerateVersionInfoFile">
    <SvnInfo LocalPath="." Condition="$(RevisionNumber) == 0">
      <Output TaskParameter="Revision" PropertyName="RevisionNumber"/>
    </SvnInfo>
    <GenerateVersionInfo OutputFileName="Properties\VersionInfo.cs"
      MajorNumber="$(MajorNumber)"
      MinorNumber="$(MinorNumber)"
      RevisionNumber="$(RevisionNumber)"/>
  </Target>
</Project>

What then needs to be done to get the version number into any project is to:

  • Remove AssemblyVersion and AssemblyFileVersion attribute from AssemblyInfo.cs
  • Add a VersionInfo.cs under the Properties folder of the project (does not have to contain anything as it will be overwritten automatically – and make sure it is NOT checked into source control)
  • Manually edit the project file and add the import line… Make sure the DLL is in the same directoy as the target file.
  • If desired, the Major and Minor version numbers can be specified in the project file by adding a “MajorNumber” or “MinorNumber” property under the <PropertyGroup> element (typically near the very top of the project file)

Optionally the following command can also be run to prevent the "unsafe project" warning dialog (replace with actual path of target file):

reg add HKLM\Software\Microsoft\VisualStudio\9.0\MSBuild\SafeImports /v AssemblyVersion /t REG_SZ /d "E:\Projects\SSSWorld\msbuild\AssemblyVersion.target"

Note that the key name is HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Microsoft\VisualStudio\9.0\MSBuild\SafeImports on Windows64.

Instead of using SvnInfo to get the revision number it can be obtained using the <Time> task, also part of the MSBuild Community Task.  Just make sure that the number is below 65536 as it needs to fit in 16 bits.

It is very simple but I have uploaded it to http://code.msdn.microsoft.com/AssemblyVersion in case it is useful to anybody else.  It was interesting at least to see how easy it was to create and use an msbuild custom task – I was relieved to find that it was not necessary to sign the assembly and install it to the GAC, and that you can use it right away.

Comments
2 Comments »
Categories
Programming
Comments rss Comments rss
Trackback Trackback

Saving an Attachment to Saleslogix

Nicolas Galler | June 4, 2008

This is an example of how much simpler, cleaner and more powerful the code in the new client is.  Here is a method that will take an arbitrary entity (well, arbitrary to a point – it has to be one of the "TALCO" entities) and write an attachment under it.

/// <summary>
/// Create a new attachment for the designated file.
/// </summary>
/// <param name="entityType">(Case sensitive) entity type that the attachment is going to be related to (eg Account, Opportunity)</param>
/// <param name="entityId">PK</param>
/// <param name="path">Absolute path to file to be attached.  
///  It will be copied to the attachment path.</param>
public object SaveAttachment(string entityType, object entityId, string path, string description)
{
    Type entityTypeActual = Type.GetType("Sage.Entity.Interfaces.I" + entityType + ",Sage.Entity.Interfaces");
    if (entityTypeActual == null)
        throw new InvalidOperationException("Invalid entity type " + entityType);
    object parentEntity = EntityFactory.GetById(entityTypeActual, entityId);
    if(parentEntity == null)
        throw new InvalidOperationException("Invalid entity id or type: " + entityType + "/" + entityId);
    IAttachment attachment = EntityFactory.Create<IAttachment>();
    switch (entityType)
    {
        case "Account":
            attachment.AccountId = (String)((IAccount)parentEntity).Id;
            break;
        case "Opportunity":
            attachment.AccountId = (String)((IOpportunity)parentEntity).Account.Id;
            attachment.OpportunityId = (String)((IOpportunity)parentEntity).Id;
            break;
        case "Contact":
            attachment.AccountId = (String)((IContact)parentEntity).Account.Id;
            attachment.ContactId = (String)((IContact)parentEntity).Id;
            break;
        case "Ticket":
            attachment.AccountId = (String)((ITicket)parentEntity).Account.Id;
            attachment.ContactId = (String)((ITicket)parentEntity).Contact.Id;
            attachment.TicketId = (String)((ITicket)parentEntity).Id;
            break;
        case "Lead":
            attachment.LeadId = (String)((ILead)parentEntity).Id;
            break;
        default:
            throw new InvalidOperationException("Unsupported entity type " + entityType);
    }

    attachment.AttachDate = DateTime.Now;
    attachment.Description = description ?? System.IO.Path.GetFileName(path);
    // save the attachment so that the Id property is populated
    attachment.Save();
    // copy the path to the attachment folder and save the attachment record
    // this will also populate the user
    attachment.UpdateFileAttachment(path);
    return attachment.Id;
}

And this is a unit test for it:

/// <summary>
///A test for SaveAttachment
///</summary>
[TestMethod()]
public void SaveAttachmentTest1()
{
    AttachmentWriter target = new AttachmentWriter();
    using (ISession sess = new SessionScopeWrapper())
    {
        IAccount acc = sess.CreateQuery("from Sage.SalesLogix.Entities.Account order by newid()").List<IAccount>()[0];
        string entityId = (string)acc.Id;
        string entityType = "Account";
        string path = "dynamicmethods.xml";
        string description = "dynamicmethods";
        object attachId = target.SaveAttachment(entityType, entityId, path, description);
        IAttachment attach = EntityFactory.GetById<IAttachment>(attachId);
        Assert.IsNotNull(attach);
        try
        {
            String attachPath = Path.Combine(Sage.SalesLogix.Attachment.Rules.GetAttachmentPath(),
                attach.FileName);
            Assert.IsTrue(File.Exists(attachPath), "File was not copied: " + attach.FileName);
            File.Delete(attachPath);
        }
        finally
        {
            attach.Delete();
        }
    }
}
Comments
No Comments »
Categories
Programming, Saleslogix
Tags
Attachment, Saleslogix
Comments rss Comments rss
Trackback Trackback

Cascading Picklist inside a ListView – A few more findings on ASP.NET DataBinding

Nicolas Galler | May 28, 2008

Cascading picklists (or dependent dropdowns, however you want to call them) are a pretty common occurrence and there is a common pattern in ASP.NET to address it, it goes like this:

<asp:DropDownList runat="server" ID="cboType"
     AppendDataBoundItems="true" AutoPostBack="true" DataSourceID="dsTypes">
  <asp:ListItem Text="" Value="" />
</asp:DropDownList>
<asp:DropDownList runat="server" ID="cboSubType"
     AppendDataBoundItems="true" AutoPostBack="true" DataSourceID="dsSubTypes">
  <asp:ListItem Text="" Value="" />
</asp:DropDownList>
<asp:ObjectDataSource runat="server" ID="dsSubTypes" TypeName="CustomerDao" SelectMethod="GetSubTypes">
<SelectParameters>
  <asp:ControlParameter ControlID="cboType" Name="type" Type="String" PropertyName="SelectedValue" />
</SelectParameters>
</asp:ObjectDataSource>
<asp:ObjectDataSource runat="server" ID="dsTypes" TypeName="CustomerDao" SelectMethod="GetTypes"/>

What happens if you want to have the dropdown displayed in an editable list of data though, like so:

image

You wish you could write something like this:

<asp:DropDownList OnDataBound="cboSubType_DataBound" runat="server" ID="cboSubType"
  SelectedValue='<%# Bind("SubTypes") %>'
  AppendDataBoundItems="false" DataSourceID="dsSubTypes">
    <asp:ListItem Text="" Value="" />
</asp:DropDownList>

But in reality this will give 2 kinds of error:

  • ‘cboSubType’ has a SelectedValue which is invalid because it does not exist in the list of items
  • Databinding methods such as Eval(), XPath(), and Bind() can only be used in the context of a databound control

An additional problem is that if you disable the ViewState for the page (as I tend to routinely do) the "SelectedValue" property of the DropDownList is not correctly updated anymore.

In order to tackle these problems I took inspiration in the code listed at http://webswapp.com/codesamples/viewsource.aspx?file=~/codesamples/aspnet20/dependentlists/datalist.aspx (there are a few other neat tricks on the page so I recommend the read!).

The solution I came up with boils down to 2 elements:

  • In the DataBound event of the DropDownList, retrieve the value (either from the postback data, or from the current data) and select it.
DropDownList cboSubType = (DropDownList)sender;
ListViewDataItem parentItem = (ListViewDataItem)cboSubType.NamingContainer;
cboSubType.ClearSelection();
String prevValue = null;
if (IsPostBack)
{
    prevValue = Request.Form[cboSubType.UniqueID];
}
else
{
    if (parentItem.DataItem != null)
    {
        prevValue = ((Customer)parentItem.DataItem).SubType;
    }
}
ListItem li = cboSubType.Items.FindByValue(prevValue);
if (li != null)
    li.Selected = true; 
  • In the ItemUpdating event of the listview I explicitely retrieve the value (from the postback because the SelectedValue may not be valid at that point) and set it in the updated values:
DropDownList cboSubType = (DropDownList)lstCustomers.Items[e.ItemIndex].FindControl("cboSubType");
e.NewValues["SubType"] = Request.Form[cboSubType.UniqueID];

This is not quite as bad as the solution I started with (which involved checking something like Request.Form[lstContacts.UniqueId + "$ctrl00$cboSubType"]) but I still think I must be missing something obvious.  Oh well, maybe the light will come on at some point, until then I got my stuff working.

The code is here in case it is of use to anyone (or in case I need a refresher in 2 months, hah!).

Comments
No Comments »
Categories
Programming
Tags
ASP.NET
Comments rss Comments rss
Trackback Trackback

Copy Configuration Files using Visual Studio test tool

Nicolas Galler | May 21, 2008

I started using the bundled Visual Studio 2008 test tool to gradually replace NUnit tests (the main reason is to reduce the number of dependencies on my co-worker’s machines).  Generally this has been very smooth (there aren’t many differences from one system to the other really) except for one snag I ran into.  In retrospect it was a simple case of needing to read the documentation but I figured I should make a note to myself anyway especially since Google was no help in this case.

When you run unit tests using the built-in Visual Studio 2008 test tool you may notice that it will not copy the files that you have marked as "Copy To Output Directory" to the test run directory.  This is because it is actually controlled within the test settings, which is kind of a nice thing, because you can alternate between different configuration.  To tweak it go under Test -> Edit Test Run Configuration -> Local Test Run and go to the Deployment section.

One other thing to note (different from the way TestDriven runs things) is that tests are run from a TestResults directory instead of the bin\Debug directory.  This is to allow you to keep the history of test run (up to 25, by default).

Comments
No Comments »
Categories
Programming
Tags
Unit Testing, Visual Studio
Comments rss Comments rss
Trackback Trackback

Customize the QuickForm DataGrid (toolbar buttons and double-click to edit)

Nicolas Galler | May 19, 2008

Note (updated on 2009/05/22): do not use this. It is somewhat interesting as an example of how to mess with the web client internals but is too brittle for production code. It will also make your upgrade harder and make it harder for other devs to understand your code. Later I will make a post about how to achieve this same behavior using an unobtrusive, external control that respects the grid’s public API.

If there is one thing that can be said about the datagrid used in the Saleslogix Web Client, it is that it is ugly.  And clunky to use.  OK that makes 2 things, but they both had to be said!  Where is the nice "Add/Edit/Delete" menu that we have on the network client grid?  Instead you have the "Edit" column which is part of the basic ASP.NET datagrid.  Yuk.

Anyway, supposedly there is a revamp in the next version, so I don’t want to spend a major amount of time customizing the current one, but meanwhile I have to have something slightly more usable.  My goals are as follows:

  • Add an "Add" button within the caption of the datagrid (otherwise you have to put it on top of it)
  • Add a double-click action to edit an item
  • Add a "Delete" button within the caption of the datagrid

The Add/Delete buttons will let me emulate a datagrid toolbar which will be useful when the datagrid is embedded within a form, as opposed to being the only control on a tab.

The double-click action is just nicer/better looking than the edit column without being too hard to code.  If I had more time I would try and integrate a third-party control like Telerik or make use of the YUI datagrid but we can’t spend forever on this one.  Another nicety would have been the ability to integrate a control list within the datagrid but again, I got stuck on that one and decided not to waste more time.

Step 1: Add a "ShowAddButton" property to the QFDataGrid

This is not the most straightforward process because the code is pretty closed up, but here is how I did it:

  • Fired up ILDASM, opened the "Sage.SalesLogix.QuickForms.QFControls" assembly, and dumped it to an IL file
  • Edited the IL file and added my property… it looks a bit scary but in reality I just copy/pasted from the "ExpandableRows" property and changed the names:
  .field private bool _showAddButton

  .method public hidebysig specialname instance bool
          get_ShowAddButton() cil managed
  {
    // Code size       7 (0x7)
    .maxstack  8
    IL_0000:  ldarg.0
    IL_0001:  ldfld      bool Sage.SalesLogix.QuickForms.QFControls.QFDataGrid::_showAddButton
    IL_0006:  ret
  } // end of method QFDataGrid::get_ShowAddButton

  .method public hidebysig specialname instance void
          set_ShowAddButton(bool 'value') cil managed
  {
    // Code size       19 (0x13)
    .maxstack  8
    IL_0000:  ldarg.0
    IL_0001:  ldarg.1
    IL_0002:  stfld      bool Sage.SalesLogix.QuickForms.QFControls.QFDataGrid::_showAddButton
    IL_0007:  ldarg.0
    IL_0008:  ldstr      "ShowAddButton"
    IL_000d:  callvirt   instance void [Sage.Platform.QuickForms]Sage.Platform.QuickForms.Controls.QuickFormsControlBase::NotifyPropertyChanged(string)
    IL_0012:  ret
  } // end of method QFDataGrid::set_ShowAddButton

  .property instance bool ShowAddButton()
  {
    .custom instance void [System]System.ComponentModel.BindableAttribute::.ctor(bool) = ( 01 00 00 00 00 )
    .custom instance void Sage.SalesLogix.QuickForms.QFControls.Localization.SRCategoryAttribute::.ctor(string) = ( 01 00 11 43 41 54 45 47 4F 52 59 5F 42 45 48 41
                                                                                                                    56 49 4F 52 00 00 )
    .set instance void Sage.SalesLogix.QuickForms.QFControls.QFDataGrid::set_ShowAddButton(bool)
    .get instance bool Sage.SalesLogix.QuickForms.QFControls.QFDataGrid::get_ShowAddButton()
  } // end of property 
  • Compiled using ilasm /dll Sage.SalesLogix.QuickForms.QFControls.il
  • Copied the resulting DLL to the PF\Saleslogix\Architect\Saleslogix directory (backup the existing one just in case!)
  • Restarted AA and admired my new property:

ShowAddButton Property

Step 2: Customize the template to show my add button

In the Model\QuickForms\Web you can edit the QFDataGrid.WebControlRenderingTemplate.vm file which controls how the datagrid is rendered to a web form.  Of course, this will all be invalidated by an upgrade etc but we are just playing here.

  • Add the code to show the button (do a search for "mainContentHeader" and replace that entire <div> with the following <table>):
#if((${qfcontrol.Caption} != "") && ($qfcontrol.Visible == true))
<table class="mainContentHeaderTable">
  <tr>
  <td>
    <asp:Label runat="server" Text="<%$ resources: grdFamily.Caption %>" ></asp:Label>
  </td>
  <td class="mainContentHeaderToolsRight">
  #if($qfcontrol.ShowAddButton)
      <asp:ImageButton runat="server" AlternateText="Add Record" id="${qfcontrol.ControlId}_btnAdd"
        ImageUrl="$generator.getImageResourceURL("Plus_16x16")"
        OnClick="${qfcontrol.ControlId}_btnAdd_Click" Text="Add"/>
  #end
  </td>
  </tr>
</table>
#end
  • Add the code for the button handler (I stole it from the "InsertChildAction" template).  Note that in order for this to work you will need to have added an Edit column (see next section for more on that).  This can be anywhere within the server script of the same template:
#if($qfcontrol.ShowAddButton)
protected void ${qfcontrol.ControlId}_btnAdd_Click(object sender, EventArgs e)
{
  if (DialogService != null)
  {
    DialogService.SetSpecs(${editcolumn.DialogSpecs.Top}, ${editcolumn.DialogSpecs.Left},
        ${editcolumn.DialogSpecs.Height}, ${editcolumn.DialogSpecs.Width},
      "${editcolumn.DialogSpecs.SmartPart}",
      #if($editcolumn.DialogSpecs.TitleOverride != "")
        GetLocalResourceObject("${editcolumn.DialogSpecs.ResourceKey}.DialogTitleOverride").ToString()
      #else
        string.Empty
      #end,
      ${editcolumn.DialogSpecs.CenterDialog.ToString().ToLower()});

    Type entityType = typeof(${qfcontrol.QuickFormDefinition.DefaultNamespace}.${qfcontrol.QuickFormDefinition.EntityTypeName});
    Type childType = typeof(#if($qfcontrol.BoundEntityTypeName != "")
        ${qfcontrol.BoundEntityTypeName}
      #else
        ${editcolumn.DialogSpecs.GetQualifiedEntityType()}
      #end );

    DialogService.EntityType = childType;
    // note that the "SetChildIsertInfo" is not a typo.  Well, it is, but not here.
    DialogService.SetChildIsertInfo(
      childType, entityType,
      // we'll assume that the parent relationship is simply the parent type name... eg Contact -> Account
      childType.GetProperty(entityType.Name.Substring(1)),
      entityType.GetProperty("${qfcontrol.BoundCollectionPropertyName}"));
    DialogService.ShowDialog();
  }
}
#end
  • We get something like this:

image

It is not a beauty, that’s for sure.  But that’s what we get with the built-in dialog service, so if the users can live with the rest of the web app surely they can live with this.

Step 3: the Edit button

The edit button is a bit easier because there is no need to add a separate property – we can just piggyback on the "HasEditColumn" property so we only have to modify the template file.

I added this code in the "RowDataBound" method:

#if($qfcontrol.HasEditColumn)
  if(e.Row.RowType == DataControlRowType.DataRow)
  {
    e.Row.Attributes.Add("ondblclick",
      Page.ClientScript.GetPostBackEventReference(${qfcontrol.ControlID}, "Edit$" + e.Row.RowIndex.ToString()));
  }
#end

And optionally add this to comment out the code that creates the edit column (since we don’t need it anymore…):

#macro(doEditCol $col)
## #if(!$IsPrintView && !$qfcontrol.RenderVertical)<asp:ButtonField CommandName="Edit"
##  #if($col.Text != "")Text="<%$ resources: ${qfcontrol.ControlId}.${col.ColumnId}.Text %>"#end
##  #if($col.DataField != "")DataTextField="${col.DataField}"#end
##  #if($col.MultiCurrencyDependent)AccessibleHeaderText="MultiCurrencyDependent"#end
##  #addCommon($col) >
##      #addStyle($col)
##  </asp:ButtonField>
## #end
#end

So you just add your edit column (same as normal) and this cause will call it to be invoked on double click instead of click of the column itself.

Step 4: adding a "Selected" handler and delete button

In order for the Delete button to work we’ll have to be able to select a row.  Of course you need to do that without a postback otherwise it will be agonizingly slow, but this is not too bad.

Since this is going to be used only when the Delete button is shown I added a bit of logic to control that and combined the 2 (technically would be a bit nicer to keep them decoupled but I am getting tired):

  • Handler in the server code (in the RowDataBound handler):
#if($showDeleteButton)
    e.Row.Attributes.Add("onclick",
      "${qfcontrol.ControlID}_selectGridRow(this, " + e.Row.RowIndex.ToString() + ")");
#end
  • In the toolbar (next to the Add button code):
#if($qfcontrol.HasDeleteColumn)
    #set($showDeleteButton = true)
    <asp:ImageButton runat="server" AlternateText="Delete Selected" id="${qfcontrol.ControlId}_btnDelete"
      ImageUrl="$generator.getImageResourceURL("Delete_16x16")" UseSubmitBehavior="False"
      OnClientClick="return ${qfcontrol.ControlID}_confirmDelete();"
      OnClick="${qfcontrol.ControlId}_btnDelete_Click" />
#end
  • Change the doDeleteCol macro:
#macro(doDeleteCol $col)
  #if(!$showDeleteButton && !$IsPrintView && !$qfcontrol.RenderVertical)
    <asp:ButtonField CommandName="Delete"
    #if($col.Text != "")Text="<%$ resources: ${qfcontrol.ControlId}.${col.ColumnId}.Text %>" #end
    #if($col.DataField != "")DataTextField="${col.DataField}" #end
    #if($col.MultiCurrencyDependent)AccessibleHeaderText="MultiCurrencyDependent"#end
    #addCommon($col) >
      #addStyle($col)
    </asp:ButtonField>
  #end
#end
  • Add a hidden field to store the selected value, and a handler to toggle it:
#if($showDeleteButton)
<script type="text/javascript">
// supporting script for the one-click select needed by the delete button
function ${qfcontrol.ControlID}_selectGridRow(row, rowIndex){
  var hid = $get("<%=${qfcontrol.ControlID}_hidSelectedId.ClientID%>");
  if(/rowSelected/.test(row.className)){
    hid.value = "";
    hid.selectedRow = null;
  } else {
    if(hid.selectedRow)
      hid.selectedRow.className = hid.selectedRow.className.replace(/rowSelected/, "");
    row.className += " rowSelected";
    hid.selectedRow = row;
    hid.value = rowIndex;
  }
}

function ${qfcontrol.ControlID}_confirmDelete(){
  if(!$get('<%=${qfcontrol.ControlID}_hidSelectedId.ClientID%>').value){
    alert('Please select a row to delete first.');
    return false
  }
  return confirm('Are you sure you wish to delete this record?');
}
</script>
<asp:HiddenField runat="server" id="${qfcontrol.ControlID}_hidSelectedId"/>
#end
  • And finally (phew), add the server code handler next to the add button handler:
#if($showDeleteButton)
// this retrieves the selected grid index from the hidden field and deletes the corresponding record.
// we assume the user has already been prompted for confirmation on the client side.
protected void ${qfcontrol.ControlId}_btnDelete_Click(object sender, EventArgs e)
{
  String childId = (String)${qfcontrol.ControlID}.DataKeys[Int32.Parse(${qfcontrol.ControlID}_hidSelectedId.Value)].Value;
  ${qfcontrol.BoundEntityTypeName} childEntity = #if($qfcontrol.DataKeyNames != "Id")
    (${qfcontrol.BoundEntityTypeName})Sage.Platform.EntityFactory.GetByCompositeId(typeof($qfcontrol.BoundEntityTypeName), "${qfcontrol.DataKeyNames}".Split(','), id.Split(','));
  #else
    Sage.Platform.EntityFactory.GetById<${qfcontrol.BoundEntityTypeName}>(childId);
  #end
  if(childEntity != null){
    ${qfcontrol.QuickFormDefinition.DefaultNamespace}.${qfcontrol.QuickFormDefinition.EntityTypeName} mainentity =
      this.BindingSource.Current as
        ${qfcontrol.QuickFormDefinition.DefaultNamespace}.${qfcontrol.QuickFormDefinition.EntityTypeName};
    mainentity.${qfcontrol.BoundCollectionPropertyName}.Remove(childEntity);
    if((childEntity.PersistentState & Sage.Platform.Orm.Interfaces.PersistentState.New) <= 0)
            {
      childEntity.Delete();
            }
  }
}
#end

Final result

Grid Image

Conclusion

All in all I think this is a good example of how to customize the stock controls.  However since you are still at the mercy of the dialog service there isn’t that much to be gained, especially in light of the amount of work (and the fact that you have to hack it up in the IL which is never all that fun).  If you look at the double-click action alone though this is a pretty big usability improvement and very easy to implement, so it might be worth just doing that part?  Not to mention that it doesn’t require you getting your hands into the IL grease.

Another thing that became (even more) evident to me while developing this is how frustrating the development with QuickForms is.  The feedback cycle is SO long between the time you make a tweak on your form and the time you can actually see it in the web client that it is very, very hard to bear.  Not to mention the number of time AA crashed on me or failed to deploy the content without giving me any error.  There is a lot of work to be done there and in the meantime it may be quicker to simply do it as custom smart parts.

I do like the Velocity templates.  They are primitive but simple and effective. I can’t say I am a fan of programming in notepad though – I think Visual Studio has spoiled me.

I believe another approach could be used to add our own custom controls to the control selection list in AA, which may be a better option for future maintenance (and maybe less development headache since we can move a lot of the work from the template to the custom control). 

And a final note…

Despite the presentation this is not intended to be a step by step guide on “how to get this in your datagrid”. First of all I doubt many will be willing to modify the IL. I also glanced over a few details, and I made a few more changes on the production system to make things smoother. This is more of a “look this CAN be done but omg it is painful” type of post. But if you are really interested in the finer details feel free to contact me.

Comments
2 Comments »
Categories
Programming, Saleslogix
Tags
Saleslogix, SlxWeb
Comments rss Comments rss
Trackback Trackback

« Previous Entries

Categories

  • Experiments (4)
  • Interesting (1)
  • MSCRM (1)
  • Programming (60)
  • Rant (3)
  • Saleslogix (34)
  • Tricks (8)
  • Uncategorized (24)

Post History

  • 2010
    • January (3)
    • March (1)
  • 2009
    • March (2)
    • April (1)
    • May (3)
    • June (3)
    • July (1)
    • September (3)
    • October (2)
    • December (5)
  • 2008
    • January (9)
    • February (4)
    • March (9)
    • April (1)
    • May (5)
    • June (8)
    • July (1)
    • August (2)
    • September (1)
    • November (1)
    • December (3)
  • 2007
    • January (3)
    • February (7)
    • March (1)
    • April (3)
    • May (6)
    • June (2)
    • July (1)
    • August (2)
    • September (5)
    • October (3)
    • November (5)
    • December (4)
  • 2006
    • January (2)
    • September (1)
    • November (3)
    • December (4)
  • 2005
    • April (1)

Meta

  • Log in
  • Entries RSS
  • Comments RSS
  • WordPress.org
rss Comments rss valid xhtml 1.1 design by jide powered by Wordpress get firefox