Chivinou [ʃivinu]

Adventures of a Belgian programmer exiled to Missouri
  • rss
  • Home
  • Soft Gallery
    • The Bean Mud Client
    • autosvnbackup.sh
    • Saleslogix
  • Contact Me
  • Welcome

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

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
1 Comment »
Categories
Programming, Saleslogix
Tags
Saleslogix, SlxWeb
Comments rss Comments rss
Trackback Trackback

Required textbox on Saleslogix Web Client

Nicolas Galler | May 13, 2008

Here is a quickie since I have to run... I have been banging my head against the Application Architect for a while, trying to add a "Required Field Validator" to the list of available controls. Unfortunately it looks like at this point this list is very much hard-coded in the client and I don't feel like disassembling it (they have part of the extension points already there so it is possible that this will be available in the not so far future).

In the meantime, there is a quick'n dirty way to add a required field validator to a textbox - edit the Model\QuickForms\Web\QFTextBox.WebControlRenderingTemplate.vm file (under your web project) and add this code after the "/>" (line 25):

#if($qfcontrol.ControlId.EndsWith("_req"))
<asp:RequiredFieldValidator runat="server" Text="*" ControlToValidate="${qfcontrol.ControlId}" id="vld_${qfcontrol.ControlId}" />
#end

What this does is add a RequiredFieldValidator if your text box name ends in "_req". Cheesy, but pretty handy until the "Required" flag appears on the text boxes.

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

Functional C#

Nicolas Galler | April 7, 2008

Sadly this is starting to veer into obfuscation but dang I love those lambda arrows.

_view.ShowConfirm("Are you sure you want to submit the PIF?",
                        p => p.SubmitPifPart(0,
                            doc => p._view.ShowConfirm(TranslateVeraResponse(doc), p2 => p2._view.CloseView(), false)),
                        true);
Comments
No Comments »
Categories
Programming
Tags
.net
Comments rss Comments rss
Trackback Trackback

Saleslogix "Add to ad-hoc group" Smart Part

Nicolas Galler | March 26, 2008

As you may know the "add to group" functionality is not currently implemented in the web client. There is a way to create a new ad-hoc group by selecting records from an existing group but no way to add records to an existing group (not to mention that the interface is a bit hard to use). In our case this was a crucial piece because the customer wanted to rely on the ability to add records to the "SyncSalesLogix" group to have them picked up by the Lotus Notes sync. Fortunately there is enough functionality in the API to build it ourselves. I created an "Add To Adhoc Group" smart part and uploaded it to the MSDN Code Gallery in case it is of interest to anyone else.

It should work with all entities for which the LookupView component works, though I only tested it with Accounts, Contacts and Opportunities. It is available as both source code and bundle-based installation and released under the open source Microsoft Public License (which is actually the only one available for MSDN Code Gallery).

One thing I should mention - it does not work very well for the Admin user. So make sure you test it out with a regular user.


Screenshot of the Add To Adhoc Group view

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

On the Coolness of unit-testing Saleslogix

Nicolas Galler | March 20, 2008

I admit it, I am a unit-testing junkie. I think it all come down to my immense laziness - I will go to great length to avoid the extra work of having to manually test my programs.

Until now the options for unit-testing in Saleslogix were very limited. I did go through the effort of writing a few automated tests in VBScript but for the most part it was not really justifiable - it is hard to maintain the separation of concern essential to unit testing and usually if I go to the trouble of forcing the concepts of encapsulation and polymorphism into vbscript I end up introducing more bugs than what I catch with unit testing.

Not so with the New and Improved Saleslogix. I want to demonstrate how unit testing in the Saleslogix web client can make our work faster, easier, and help us produce better quality code through a simple problem I faced today on the Insert Opportunity screen - the "OnCreate" opportunity rule would crash with a NullReferenceException. This of course has a very selfish goal - the more people we have excited about the benefits of automated unit testing in Saleslogix, the more Sage will be inclined to make their code testable (and maybe encourage them to use automated testing themselves!)

You could troubleshoot the issue by copying the rule to your own assembly, sprinkle it with logging statements, associate it in the architect, redeploying the web site, try the form again, then try and figure out where the error is. Once you have compiled the rule into your own assembly you can actually also open it in the Visual Studio debugger which will save a lot of time but you still have to go through those build, deploy, login, and trigger steps. I don't know about your machine but it takes me about 1 1/2 minute to do a full build and deploy, then the debugger has to compile the site to open it which also takes 2 to 3 minutes, after which it still takes 30 to 50 seconds to log into the site and get to the right page. By that time my coffee is getting cold. And if you want to make a change to the rule and try again, you have to restart from the beginning... argh!

Enter unit testing. When unit testing you will still be loading the NHibernate configuration, dynamic methods etc, but you won't have the overhead of the web server, javascript, painting the screens, or even rendering any output - you are cutting straight to the code you need to test.

So getting back to my problem. I can prepare a tiny method in my test class containing just one line of code: EntityFactory.Create(), right click it and run the test (in this picture it is shown as a call to a builder class, but this is just a wrapper around the EntityFactory):

Of course I had set up the dynamic method to call up my assembly (same thing that you would normally do through the AA interface but since we are just testing right now it is quicker to do it directly):

This fails (of course - as expected) and gives me the traceback I needed. OK, line 145! Let's set a breakpoint and re-run it (note that you would have the same thing if you opened the web site in the debugger - this is just a LOT faster):

Oh well, the option must not be set, let's set it in SQL and re-run the test (if you were testing on the live system you would have to restart the web server to get it to re-read the options at this point):

That's right, 5.14 seconds! How long does it take you to restart IIS and relog into the web client? And perhaps the best part - I can now keep the test in the collection and it will automatically catch any similar problem in the 7.2.3 upgrade.

Hope this will convince some of the power in unit testing. You will want to check this post on how to set your environment up for unit testing: Unit Testing SLX - 7.2.2 Update. If you need any help send me an email or a comment.

Comments
No Comments »
Categories
Programming, Saleslogix
Tags
Saleslogix, SlxWeb, Unit Testing
Comments rss Comments rss
Trackback Trackback

Accessing Saleslogix Groups Programmatically (part 1)

Nicolas Galler | March 19, 2008

In a previous post I examined how to get access the entity data (basically the ORM layer, as well as the dynamic methods piece) using the Saleslogix assemblies from outside of the web client. Obviously this is vital for unit testing, but also has some interesting application for external application. In this next installment I would like to look at the Groups API. In addition to being useful in unit testing and external application I feel the Groups API is poorly documented so a bit of exploring would help.

First off remember that most of the group access is done via a COM component called GroupTranslator. The goal of that component (which I presume was written in Delphi) is to translate the Group blob stored within the database (in the Plugin table) to an XML description and vice versa. It is not terribly reliable and Sage is notoriously slow about releasing fixes for it, but it is what it is. For the general cases it is probably still better than rolling out your own translator.

Next take a look (with Reflector) at the API offered in the 7.2 client. GroupInfo is the main one - it is full of useful static methods for manipulating the groups. Unfortunately they are not documented and some of them look very buggy so we have to thread carefully when dealing with it. It also has a few instance methods but watch out - it makes heavy use of globals so I would avoid messing too much with several groups at the same time. Another one we have to deal with is GroupContext - this has some information about the group that the web user is currently using (sadly it has a few pitfalls as we will see below). Very often when you use a method to retrieve the group's data it seems to set the current group in GroupContext (as a global). So watch out for that. Sometimes you have to break down and examine the group's XML yourself (as returned by the group translator) but I prefer to avoid that - my hope is that eventually the GroupInfo API will be fixed to be more reliable. Here are a few of my favorites:

  • GroupInfo.GetGroupIdFromNameFamilyAndType - don't you hate having to figuring that one out in SQL on the LAN client?
  • GroupInfo.GetGroupInfo - static method to build a group info object, knowing the plugin id.

  • GroupInfo.GetGroupDataReader - I looked at the code and I am pretty sure this won't release the connection correctly, so I would stay away from that one for now (too bad, it sounds yummy, and does not have the global reliance of the next one)
  • GroupInfo.GetGroupDataTable - almost as good as GetGroupDataReader, and does not have the connection problem. Only works when paging is enabled which can only be done using the last 2 overloads. Be careful if you use those because they will mess with the current (global) group context (not sure what the exact effect would be).
  • GroupInfo.GetGroupKeyFieldIDs - not bad to get a group's data. One of the rare data retrieval methods that doesn't affect the current global group. Unfortunately this is currently broken so do not use it.
  • GroupInfo.GetGroupIDs - I am not sure what the difference in purpose is with the previous one. But anyway, this uses the GetGroupDataReader method, so it will leak connection - do not use.
  • GroupInfo.GetGroupList - gets you a list of groups for a specific entity. Watch out retrieving some of the properties like IsAdHoc - some are very very slow. So it will be easier to access the DB directly in most cases I think.
  • GroupInfo.AddAdHocGroupMember (and AddAdHocGroupMembers) - to add to an adhoc group (works fine)
  • GroupInfo.CreateAdhocGroup - create a new adhoc group (this works well)
  • GroupInfo.AddLookupCondition - to add a condition to a dynamic group (didn't try it but it looks OK)
  • GroupInfo.SaveAsNewGroup - save group to database (should work fine)
  • GroupInfo.getGroupSQL - this is a private method but I just had to mention it anyway. For example to retrieve the "where" part of the SQL:

    MethodInfo method = typeof(GroupInfo).GetMethod("getGroupSQL",
        BindingFlags.Instance | BindingFlags.NonPublic);
    String sql = (String)method.Invoke(currentGroup,
       new object[] { "WHERE", currentGroup.GroupXML, false, 1, 1, null });

    Unlike the GroupInfo.GroupSQL property, this one actually works. The first parameter (where I put "WHERE") is the part of the SQL that you want to retrieve. You can use WHERE, FROM, SELECT, ORDERBY. WHERE seems to expand all parameters, even things like :UserID. The second parameter is whether you want to do paging or not. Usually false. The next 2 parameters are related to paging, but make sure you do NOT set them to 0. Last parameter is the column to sort by, but this is ignored unless you are using paging. If you use "ALL" as the SQL part, you will get the whole SQL for the group, but none of the parameters won't be expanded.

  • GroupInfo.GetGroupLayoutsNodes - you can use this to get the columns from the group.
  • GroupInfo.WhereSQL - SQL condition for the group. Equivalent to getGroupSQL("WHERE"). Works well.
  • GroupInfo.FromSQL - the part after the FROM keyword. Equivalent to getGroupSQL("FROM"). Works well.
  • GroupInfo.GroupSQL - access the actual group SQL (same as in the LAN client). Doesn't return the condition correctly (always returns it as "1=2").

As a practical example here is an ugly little wrapper class with a working "GetGroupEntityIds" method:

using System;
using System.Collections.Generic;
using System.Text;
using System.Reflection;
using Sage.SalesLogix.Client.GroupBuilder;
using System.Xml;
using Sage.Platform.Orm;
using System.Data;

namespace SSSWorld.Slx72.Utility
{
    /// <summary>
    /// Helper methods for groups.
    /// </summary>
    public class GroupHelper
    {
        /// <summary>
        /// Return all entity ids on that group.
        /// </summary>
        /// <param name="groupId"></param>
        /// <returns></returns>
        public static String[] GetGroupEntityIds(String groupId)
        {
            String sql = GetGroupKeysSQL(groupId);
            using (SessionScopeWrapper session = new SessionScopeWrapper())
            {
                using (IDbCommand command = session.Connection.CreateCommand())
                {
                    command.CommandText = sql;
                    using (IDataReader reader = command.ExecuteReader())
                    {
                        List<String> ids = new List<String>();
                        while (reader.Read())
                        {
                            ids.Add(reader.GetString(0));
                        }
                        return ids.ToArray();
                    }
                }
            }
        }

        /// <summary>
        /// Retrieve the full group SQL.
        /// </summary>
        /// <returns></returns>
        public static String GetGroupSQL(String groupId)
        {
            GroupInfo groupInfo = GroupInfo.GetGroupInfo(groupId);
            StringBuilder sqlBuilder = new StringBuilder();
            sqlBuilder.Append("SELECT ")
                .Append(GetGroupSQLPart(groupInfo, GroupSqlPart.SELECT));
            BuildGroupFromClause(sqlBuilder, groupInfo);
            return sqlBuilder.ToString();
        }

        /// <summary>
        /// Retrieve the SQL appropriate for reading the group entity ids.
        /// </summary>
        /// <returns></returns>
        public static String GetGroupKeysSQL(String groupId)
        {
            GroupInfo groupInfo = GroupInfo.GetGroupInfo(groupId);
            XmlNodeList layoutNodes = groupInfo.GetGroupLayoutNodes();
            XmlElement layoutNode = (XmlElement)layoutNodes[0].ParentNode;
            String mainTable = layoutNode.GetAttribute("maintable");
            StringBuilder sqlBuilder = new StringBuilder();
            sqlBuilder.Append("SELECT A1.")
                .Append(mainTable)
                .Append("ID ");
            BuildGroupFromClause(sqlBuilder, groupInfo);
            return sqlBuilder.ToString();
        }

        public static String GetGroupSQLPart(GroupInfo groupInfo, GroupSqlPart part)
        {
            MethodInfo getGroupSQL = typeof(GroupInfo).GetMethod("getGroupSQL", BindingFlags.Instance | BindingFlags.NonPublic);
            return (String)getGroupSQL.Invoke(groupInfo, new object[] { part.ToString(), groupInfo.GroupXML, false, 1, 1, null });
        }

        /// <summary>
        /// Which part of the SQL do you want to select
        /// </summary>
        public enum GroupSqlPart
        {
            /// <summary>
            /// After the WHERE (WHERE keyword not included)
            /// </summary>
            WHERE,
            /// <summary>
            /// After the ORDER BY (ORDER BY keyword not included)
            /// </summary>
            ORDERBY,
            /// <summary>
            /// After the SELECT (SELECT keyword not included)
            /// </summary>
            SELECT,
            /// <summary>
            /// After the FROM (FROM keyword not included)
            /// </summary>
            FROM
        }

        #region Private Methods

        /// <summary>
        /// Append the FROM and subsequent clauses to the SQL builder.
        /// </summary>
        /// <param name="sqlBuilder"></param>
        /// <param name="groupInfo"></param>
        private static void BuildGroupFromClause(StringBuilder sqlBuilder, GroupInfo groupInfo)
        {
            sqlBuilder.Append(" FROM ")
                .Append(groupInfo.FromSQL);
            String where = groupInfo.WhereSQL;
            if (!String.IsNullOrEmpty(where))
                sqlBuilder.Append(" WHERE ").Append(where);
            String orderBy = GetGroupSQLPart(groupInfo, GroupSqlPart.ORDERBY);
            if (!String.IsNullOrEmpty(orderBy))
                sqlBuilder.Append(" ORDER BY ").Append(orderBy);

        }

        #endregion
    }
}

Another problem with GroupInfo is that almost all of its methods will want to call GroupContext.GetGroupContext for one reason or another, and GetGroupContext is hard-wired to HttpContext, so not very testing-friendly. This can be fixed in IL though I have not bothered yet.

That's it for now - this turned out to be a lot harder than I thought it would be. It was actually much harder than on the LAN client because of the poor (or rather, non-existent) documentation and the fact that most of the methods shipped do not actually work. I certainly do not want to turn this post into a rant, but I still have to mention how truly appalling that is. The good side of this coin is that we know the Saleslogix devs are hard at work on the next version and from what I have seen it will probably include a major overhaul of the group interface (and the API, presumably) which might explain why they are not focused on fixing this one. Next installment will be how to get to this stuff from outside of the web client but I thought this short overview of the API warranted a post by itself.

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

Prevent Caching of Script Service AJAX calls - the lazy way

Nicolas Galler | February 20, 2008

"REST" style AJAX calls can be cached by the browser if the server is not properly configured. For some services this is a good thing (think, a mapping service), but often it is a hassle.

The way I see it there are 3 ways to get around it:

  • The "proper" way is to configure the caching on the server app. For example in ASP.NET use Response.Cache.SetCacheability(HttpCacheability.NoCache), or use the <%@ OutputCache %> tag.
  • The "fix it from the client" way is to add a random parameter to the Javascript. This way the query is different every time. If the server can accept POST as well as GET you can also use a POST since those are not cached.
  • The "lazy" way is to force a header from the server configuration. In IIS this is super easy:
    1. edit the properties of the directory that contains your script services
    2. go to HTTP Headers
    3. Add a custom header: name is "Cache-Control" and value is "no-cache"
Comments
No Comments »
Categories
Programming
Tags
AJAX, ASP.NET
Comments rss Comments rss
Trackback Trackback

« Previous Entries

About Me

I am a Saleslogix and .NET developer in Missouri.

Post Calendar

January 2009
M T W T F S S
« Dec    
 1234
567891011
12131415161718
19202122232425
262728293031  

Archives

  • December 2008
  • November 2008
  • September 2008
  • August 2008
  • July 2008
  • June 2008
  • May 2008
  • April 2008
  • March 2008
  • February 2008
  • January 2008
  • December 2007
  • November 2007
  • October 2007
  • September 2007
  • August 2007
  • July 2007
  • June 2007
  • May 2007
  • April 2007
  • March 2007
  • February 2007
  • January 2007
  • December 2006
  • November 2006
  • September 2006
  • January 2006
  • April 2005

Categories

  • Experiments
  • Interesting
  • MSCRM
  • Programming
  • Rant
  • Saleslogix
  • Tricks
  • Uncategorized

Meta

  • Log in
  • Entries RSS
  • Comments RSS
  • WordPress.org

Most Useful Posts (in my very subjective opinion)

  • Unit Testing SLX - 7.2.2 Update
  • Enabling log4net logging in SLX 72
  • SLX EntityBoundSmartPart lifecycle

  • Soft Gallery
    • autosvnbackup.sh
    • Saleslogix
    • The Bean Mud Client
rss Comments rss valid xhtml 1.1 design by jide powered by Wordpress get firefox