Hosting WCF Services within SalesLogix
Posted By: nicocrm on June 24th, 2008 in Uncategorized
No Gravatar

The recent releases of WCF brought a whole bunch of goodies: most notable for me are the added support for syndication and script services, as well as a "no-configuration" scenario where we did not need to add a whole page of XML in web.config anymore just to get a basic service running.

I knew that there would be a few problems to getting it to collaborate with the Saleslogix web client app because of its tight integration with the ASP.NET pipeline, but wanted to take a look anyway.

I started with this very simple service file:

<%@ ServiceHost Language="C#" Debug="true" 
    Service="SSSWorld.Scratch.SimpleService" 
    CodeBehind="SimpleService.svc.cs"
    Factory="System.ServiceModel.Activation.WebScriptServiceHostFactory, 
      System.ServiceModel.Web, 
      Version=3.5.0.0, 
      PublicKeyToken=31bf3856ad364e35" %>

The key points is the "WebScriptServiceHostFactory.  Basically this automatically configures the endpoint for WCF without the need to add it to the web.config.

My SimpleService.cs at this point is also very simple:

public class SimpleService : ISimpleService
{
    public String GetData()
    {
        return "hello";
    }
}

And this is the corresponding "ServiceContract" (aka interface):

[ServiceContract]
public interface ISimpleService
{
    [OperationContract]
    [WebGet]
    String GetData();
}

The first problem I ran into appears to have actually been an installation problem.  I kept getting an error "Unable to load System.ServiceModel.Web".  Eventually I re-registered the assembly with the GAC (with gacutil /i /F) and all was good on that side.

Next was an IIS configuration problem.  Apparently integrated Windows authentication needs to be disabled.  So I cut it off (if you still wanted it to work with Saleslogix you could just cut it off for the folder containing the .svc file, I suppose) and finally got a

{"d":"hello"}

back at me.

Next I wanted to be able to access the Saleslogix service, so I added the following code:

if (ApplicationContext.Current == null)
                throw new InvalidOperationException("No Application Context");

Got the expected exception thrown.  I knew that ApplicationContext relies on classic ASP.NET sessions and also has some hard-coded dependencies to HttpContext.Current so I needed to enable the ASP.NET compatibility of WCF.  This is done with 2 changes, first I needed to add an attribute on the SimpleService class:

[AspNetCompatibilityRequirements(RequirementsMode=AspNetCompatibilityRequirementsMode.Required)]

And I also needed the following blurb in web.config (which I was a little bit sad about since I was hoping not to have to modify it, but oh well):

<system.serviceModel>
  <serviceHostingEnvironment aspNetCompatibilityEnabled="true"/>
</system.serviceModel> 

At this point I got my "hello" back.

Now it was time to see how far we could go!  I changed "GetData" into a simple search service – what it would do is simply return a little bit of information about accounts matching the input string (well, just the account name, for now):

public String GetData(String search)
{
    if (ApplicationContext.Current == null)
        throw new InvalidOperationException("No Application Context");
    if (String.IsNullOrEmpty(search))
        return "";
    using (SessionScopeWrapper session = new SessionScopeWrapper(true))
    {
        var matches = session.CreateQuery("from Sage.SalesLogix.Entities.Account a where a.AccountName like ?")
            .SetString(0, search + "%")
            .List<IAccount>();
        if (matches.Count > 0)
        {
            return matches[0].AccountName;
        }
        else
        {
            return "";
        }
    }
}

Go to http://…../SimpleService.svc/GetData?search=Abbott (going through the login page if necessary) and this returns:

{"d":"Abbott Ltd."}

as expected.

Getting pretty close!  Now if you wanted to return a list with some more info about each account, how about this:

  • Create a data contract:
[DataContract]
public class AccountInfo
{
    [DataMember]
    public String AccountName { get; set; }

    [DataMember]
    public String MainPhone { get; set; }

    [DataMember]
    public String City { get; set; }
}
  • Change the declaration:
[OperationContract]
[WebGet]
IList<AccountInfo> GetData(String search);
  • Change the "return matches[0].AccountName" line into something like this:
var q = from m in matches
        select new AccountInfo
        {
            AccountName = m.AccountName,
            MainPhone = m.MainPhone,
            City = m.Address.City
        };
return q.ToList();

Well, you get the idea.  The data can be recovered from a script service on an ASP.NET page, etc.

Now there is one serious limitation – this requires the user to be properly authenticated (because otherwise SalesLogix can’t form the connection string!).  If we need the service to be accessible from an outside client (eg for a mashup or an RSS feed) you would have to either somehow simulate a user login, or forego the whole ApplicationContext, host your service outside of the web client, and maintain the connection string for it separately.  At this point you could either use the technique I outlined in my "Unit Testing" posts to still get access to the NHibernate session (and to the entity business rules!), or just use regular ADO.NET to retrieve the data (probably easier, more reliable, and yielding better performance, unless you need access to the business rules.

Anyway, this was an interesting exploration, I have not decided if I would make anything of it yet.


Sending an email from Saleslogix (or ASP.NET)
Posted By: nicocrm on June 23rd, 2008 in Uncategorized
No Gravatar

As the .NET framework is available to code executing on the server side of the Saleslogix web client it is easy to send an email using System.Net.Mail etc.  Lots of available examples for that so I won’t repeat them.

The problem with this approach (for some scenario, anyway) is that the user does not have a chance to review and edit the text the email (or attach it to Saleslogix for that matter).  Sometimes it would be nicer to just open the email in Outlook and let them complete it, and in some cases it is possible with a little bit of JavaScript, as long as you are able to get your users to adjust their IE security settings to enable unsafe ActiveX (which they have to do for the export to Excel anyway).

My problem was a simple "Quote" screen where the user should be able to print the quote report, have it attached to history and have the opportunity to send an email to the quote contact:

image

This is simple enough to do in the regular Saleslogix client – in the web client there are 2 problems: one is creating and displaying the message in Outlook, the other one is attaching the report (which was exported as a PDF on the server but will not be available from the client side).

In order to resolve the second problem, and since we can give Outlook a URL to attach, I created a "GetReport" service that is placed in an unsecured directory (since it will have to be accessed from Outlook) and simply returns the PDF data.  As a side note, to unsecure a page, place a web.config file in its directory with the following:

<?xml version="1.0"?>

<configuration>
  <location path="GetReport.ashx">
    <system.web>
      <authorization>
        <allow users="*"/>
      </authorization>
    </system.web>
  </location>
</configuration>

Now you can get to it without logging in.  Which is OK for this service since you need to have a report token in order to retrieve anything.

To resolve the first problem I sent some code (with ScriptManager.RegisterClientBlock) that will open Outlook on the client, fill in the subject and recipient, and display the message so the user can finish editing it:

public void ShowReportEmail(Uri reportUrl, string to, string cc, string subject, string body)
{
    StringBuilder script = new StringBuilder();

    var msgData = new { to = to, cc = cc, subject = subject, body = body, reportUrl = reportUrl.ToString() };
    var serializer = new System.Web.Script.Serialization.JavaScriptSerializer();
    script.Append("try {n" +
        "var outlook = new ActiveXObject("Outlook.Application");n" +
        "var msg = outlook.CreateItem(0);n")
        .AppendFormat("var msgData = {0};n", serializer.Serialize(msgData))
        .Append("msg.Subject = msgData.subject;n" +
            "msg.Body = msgData.body;n" +
            "msg.To = msgData.to;n" +
            "msg.Cc = msgData.cc;n" +
            "msg.Attachments.Add(msgData.reportUrl);n" +
            "msg.Display();n" +
            "} catch(e) {n" +
            "  alert('Error accessing outlook: ' + e.description + '.\nPlease consult your administrator on recommended IE settings.');n" +
            "}");

    ScriptManager.RegisterClientScriptBlock(this, GetType(), "EmailReport", script.ToString(), true);
}

The code makes use of the anonymous object syntax so requires VS 2008 to compile (should not require .NET 3.5 to run, though).  You could adapt easily if needed.

This probably works on Outlook 2003 and higher, but I only tested on Outlook 2007.

The end result looks like this… I am not thrilled about the fact that the attachment name looks so crappy but other than that I quite like it:

image

And this is by the way the setting that needs to be enabled in IE… you probably only want to enable that for trusted sites:

image


Random Pitfalls of Saleslogix Web Client
Posted By: nicocrm on June 20th, 2008 in Uncategorized
No Gravatar

Just a couple observations, figured I might as well put them here so I don’t forget.

1. OnCreate May Be Called more than once (Update – I don’t think this is true anymore on 7.5.1)

Specifically, if you are writing an insert page, the OnCreate event of the entity will be called on EVERY POSTBACK.  This surprising behavior just cost me two hours.

2. DataBinding Only Works if an Event is Defined

For example, if you bind to the SelectedValue of a control, but the control does not have a SelectedValueChanged event, the property will never get updated on the entity.


Strongly Typed Databindings in SlxWeb
Posted By: nicocrm on June 13th, 2008 in Uncategorized
No Gravatar

I wanted to share a useful little trick to avoid typos when doing the bindings on a custom smart part.

The default (in the code generated by the AA) looks like this:

Sage.Platform.WebPortal.Binding.WebEntityBinding nmeContactNameNamePrefixBinding = new   Sage.Platform.WebPortal.Binding.WebEntityBinding("Prefix", nmeContactName, "NamePrefix");
BindingSource.Bindings.Add(nmeContactNameNamePrefixBinding);

You can also write it a bit more succinctly as this, but it still requires you to remember how to spell the entity property as well as the control property:

BindingSource.Bindings.Add(new WebEntityBinding("Prefix", nmeContactName, "NamePrefix"));

I like it like this, where the Intellisense can do the autocomplete for me:

this.BindingSource.Bindings.Add(t.CreateBinding(s => s.Cust

To this end I have this tiny helper class:

public class TypedWebEntityBindingGenerator<TEntity>
{
    public WebEntityBinding CreateBinding<TComponent, TProperty, TProperty2>(Expression<Func<TEntity, TProperty>> entityProperty, 
        TComponent component,
        Expression<Func<TComponent, TProperty2>> componentProperty)
    {
        var propFrom = BuildPropertyAccessString((MemberExpression)entityProperty.Body);
        var propTo = (PropertyInfo)((MemberExpression)componentProperty.Body).Member;
        return new WebEntityBinding(propFrom, component, propTo.Name);
    }

    private String BuildPropertyAccessString(MemberExpression memberExpression)
    {
        String b = "";
        
        if (memberExpression.Expression is MemberExpression)
            b = BuildPropertyAccessString((MemberExpression)memberExpression.Expression) + ".";
        return b + ((PropertyInfo)memberExpression.Member).Name;
    }
}

You will have to set up your project to target the .NET 3.5 framework.

This *may* also requires you to enable .NET 3.5 in the web.config.  YMMV.  To enable .NET 3.5 you basically replace all occurrences of 1.0.61025.0 with 3.5.0.0 and add the following snippet under the assemblyBinding element in web.config:

<dependentAssembly>
    <assemblyIdentity name="System.Web.Extensions" culture="neutral" publicKeyToken="31bf3856ad364e35"/>
    <bindingRedirect oldVersion="1.0.61025.0"
                     newVersion="3.5.0.0"/>
</dependentAssembly>

Creating and Displaying a Group Programmatically
Posted By: nicocrm on June 12th, 2008 in Saleslogix
No Gravatar

1. The Players

GroupContext.GetGroupContext() is used to retrieve the class holding the info about what group the user currently has selected for each entity.  It is stored in the session.  GroupContext.GetGroupContext() is currently hard-coded to reference the HTTP context so do not call it outside of the web client.

GroupContext has an "EntityGroupInfo" object for each entity.  This EntityGroupInfo is a sort of cache for the currently selected group as well as a frontend for group selection.  It does not have a direct relationship to the GroupInfo class which actually represents a specific group.

GroupTranslator is the COM object that is responsible for translating XML to Delphi blob and vice versa.

2. Creating a Group

Saving a new group is relatively easy.  There is a GroupInfo.Save method which saves a group and returns the group id.  It does not work outside of the web client though so I just used the GroupTranslator directly.

try
{
    String groupXml = GroupInfo.GetBlankGroupXML("Contact");
    // this retrieves the condition as a subquery
    // (QueryBuilder is another piece I have that builds the query - anything that will
    // return a query string will work, though)
    String condition = ((QueryBuilder)group.CreateKeyFieldQuery(false)).GetSqlQuery(true);
    GroupInfo ginfo = new GroupInfo();
    ginfo.GroupXML = groupXml;
    ginfo.GroupName = groupName;
    ginfo.AddLookupCondition("CONTACT:CONTACTID", " IN ", "(" + condition + ")");

    // remove the existing group
    _groupManager.DeleteGroup("Contact", groupName, "ADMIN");

    // we could just use ginfo.Save here but it doesnt work without an active web session
    XmlDocument doc = new XmlDocument();
    doc.LoadXml(ginfo.GroupXML);
    XmlElement pluginDataNode = (XmlElement)doc.SelectSingleNode("SLXGroup/plugindata");
    pluginDataNode.Attributes["name"].Value = groupName;
    pluginDataNode.Attributes["displayname"].Value = groupName;
    pluginDataNode.Attributes["id"].Value = "";
    doc.SelectSingleNode("SLXGroup/groupid").InnerText = "";
    String groupId = translator.SaveGroup(doc.OuterXml, GroupInfo.ConnectionString);
}
finally
{
    System.Runtime.InteropServices.Marshal.ReleaseComObject(translator);
}

3. Displaying the Group

If you try to display the group by redirecting the browser to Contact.aspx?gid=…., there is a good chance they will get a NullReferenceException at that point.  This is because the GroupContext has a cache of the active groups for the entity and this cache is not automatically rebuilt.

The following code will take care of clearing this.  Note that I have to catch the NullReferenceException because of the way GroupContext works outside of the web client and I have this class unit-tested.  The call to ClearCache and the setting of the CurrentTable are the other tricky parts necessary.

try
{
    if (GroupContext.GetGroupContext() != null)
    {
        LOG.Debug("Setting group context");
        GroupContext.GetGroupContext().CurrentTable = "CONTACT";
        var groupCache = GroupContext.GetGroupContext().GetGroupInfoForTable("CONTACT");
        groupCache.ClearCache();
        groupCache.CurrentID = groupId;
    }
    else
    {
        LOG.Debug("Group Context is not available.");
    }
}
catch (NullReferenceException)
{
    // ignore those because GroupContext is f. up
}

After that you can redirect to "Contact.aspx?gid=" + groupId and have the new group displayed.  Or maybe just redirect to Contact.aspx since we set the current group id (didn’t test that one).

4. Displaying a Temporary Group

There is a SetCurrentGroupAsLookupResult method in the EntityGroupInfo object, however I could not get it to work.

I am out of time for today but would love to know if anyone figures this one out.