Monday, May 12, 2008

Microsoft Project Fx (mpFx): Progress Report

I spent a wee sliver of time this weekend on mpFx and have some progress to report.  I started with a simple object model and delved into one of newest features of Microsoft Office Project Server: Enterprise Custom Fields.

Prior versions of Microsoft Project supported custom fields but with one serious drawback: no organization, company, tool, or technology could guarantee that the custom field they were using wasn't used by another system.  This, plus the physical limits with respect to number of per-type custom fields, made relying on custom fields difficult.

Enter Microsoft Project 2007.

The 2007 release is a huge advancement in many ways.  In particular the introduction of a completely new custom field architecture makes development of custom fields-dependent system feasible and once you get a sense for how it all works, enjoyable.

Accessing Enterprise Resource Custom Fields with mpFx

Over the next few weeks I will be posting code to CodePlex (and I have lots of help now thanks to Brian K at Microsoft) so you will be able to get into this directly.  For now, I want to demonstrate how to access enterprise resource custom fields using mpFx.  See Figure 1.  The screen shot shows my resource name as well as the Expertise resource custom field, which just so happens to allow for multiple values to be selected on a resource.  Figure 2 shows the menu that allows me to select which custom fields will be loaded for each resource.  Scroll down to below the two images to see the remainder of this discussion.

Figure 1 - Accessing Enterprise Resource Custom Fields using mpFx

image

Figure 2 - Selecting Custom Fields to Add to Data Grid View

image

I am assuming you have read my previous post on how to create an instance of the ProjectServer object, which in the world of mpFx serves as the base object for assessing PSI data and functionality.

Here is how to get the list of resource custom fields:

using (CustomFieldDataSet customFields = _ProjectServer.EnterpriseResources.CustomFields)
{
    foreach (CustomFieldDataSet.CustomFieldsRow customField in customFields.CustomFields.Rows)
    {
        ToolStripMenuItem menuItem = new ToolStripMenuItem(customField.MD_PROP_NAME);

        menuItem.Tag = customField;
        menuItem.CheckOnClick = true;
        menuItem.CheckedChanged += customResouceFieldMenuItem_CheckedChanged;
        resourceCustomFieldsToolStripMenuItem.DropDownItems.Add(menuItem);
    }
}

Notice that accessing the resource custom fields is done through a property get off the EnterpriseRsources class.  This is a theme across mpFx: access all things related to resources (or projects) are accessed via base object rather than calling disparate web services to access resources-related data and functionality.

The custom fields property looks like this:

get
{
    if (_CustomFields == null)
    {
        LoadCustomFields();
    }

    return _CustomFields;
}

Most things are demand, or lazy loaded, in mpFx.  LoadCustomFields() looks like this:

public void LoadCustomFields()
{
    if (_CustomFields != null)
    {
        _CustomFields.Dispose();
    }

    _CustomFields = _Parent.CustomFieldsWebService.ReadCustomFieldsByEntity(
                        new Guid(Utilities.GetEntityFromName("Resource")));
}

The class library will include many helper functions and data.  Notice the call to Utilities.GetEntityFromName() above.  This returns the Guid of the entity requested, in this case "Resource".  GetEntityFromName() looks like this:

public static string GetEntityFromName(string name)
{
    name = name.ToLower();

    // Entity = Microsoft.Office.Project.Server.Library.Entity
    foreach (Entity entity in Entities)
    {
        if (entity.Name.ToLower() == name)
        {
            return entity.UniqueId;
        }
    }

    return string.Empty;
}

That is all it takes to load the resource custom fields.  Let's take a look at getting specific custom field values for a given resource.

I apologize for the funky indenting, but the blog's width makes this necessary.  Below is the code that looks up a resource custom field for a given resource:

using (
    ResourceDataSet.ResourceCustomFieldsDataTable customFields =
        _ProjectServer.EnterpriseResources[resourceGuid].CustomFields) {

    foreach (ResourceDataSet.ResourceCustomFieldsRow customField in customFields.Rows)
    {
        if (customFieldDefinition.MD_PROP_UID == customField.MD_PROP_UID)
        {
            resourceAdvancedInformationDataGridView.Rows.Add(
                new object[]
                    {
                        "Custom Field",
                        checkedMenuItem.Text,
                        _ProjectServer.EnterpriseResources.GetCustomFieldValueAsString(customField,
                                                                                       customFieldDefinition)
                    });
        }
    }
}

Again, notice that I am accessing the custom fields through the EnterpriseResources object.  The code above iterates through the custom fields until it finds the field that was added to the resource custom fields data grid view shown in Figure 1.  Once located, the field definition, which was saved to the menu item's tag, plus the custom field object itself is passed to GetCustomFieldValuAsString, which we will examine next.  The ResourceCustomFieldsDataTable returned by _ProjectServer.EnterpriseResources[resourceGuid].CustomFields returns the resource custom fields for a specific resource.  In this case, it is the resource selected in the tree control to the left in Figure 1.

Here is the source for GetCustomFieldValueAsString.  NOTE: the method is incomplete and handles just a subset of the possible data types available:

public string GetCustomFieldValueAsString(ResourceDataSet.ResourceCustomFieldsRow customField, CustomFieldDataSet.CustomFieldsRow customFieldDefinition)
{
    PSDataType type = (PSDataType)customField.FIELD_TYPE_ENUM;
    string data = string.Empty;

    switch (type)
    {
        case PSDataType.STRING:
            if (customField.IsTEXT_VALUENull() == false)
            {
                data = customField.TEXT_VALUE;
            }
            else if (customField.IsCODE_VALUENull() == false)
            {
                data = 
                    _Parent.EnterpriseLookupTables.GetLookupTableValue(
                        customFieldDefinition.MD_LOOKUP_TABLE_UID, customField.CODE_VALUE, 1033);
            }
            break;
        case PSDataType.NUMBER:
            data = customField.NUM_VALUE.ToString();
            break;
        case PSDataType.DATE:
            data = customField.DATE_VALUE.ToUniversalTime().ToLongDateString();
            break;
        default:
            Debug.Print(type.ToString());
            break;
    }

    return data;

I switch on the FIELD_TYPE_NUM and handle accordingly.  The above should be fairly self-explanatory, except the call to _Parent.EnterpriseLookupTables.GetLookupTableValue.  A couple of things to note here.  First, the PSI web services are housed off the root object, which is ProjectServer.  Each nested object gets a "this" pointer as a parent, which allows the nested object to access internal properties that deference to the web services.  This allows for all nested objects to access PSI data and functionality, which is an underlying design goal of mpFx.  Also, IDisposable is implemented on ProjectServer and having the web services hosted in the root object makes cleanup easier.

_Parent.EnterpriseLookupTables.GetLookupTableValue looks like this:

public string GetLookupTableValue(Guid lookupTableGuid, Guid valueGuid, int languageCode)
{
    using (
        LookupTableMultiLangDataSet.LookupTableValuesDataTable lookupTableValues =
            LoadLookupTableValues(lookupTableGuid,
                                  Filters.LookupTableFilters.ItemBasicInformation(lookupTableGuid)))
    {
        if (lookupTableValues == null)
        {
            throw new ArgumentException( /*  TODO: Exception Message */);
        }

        LookupTableMultiLangDataSet.LookupTableValuesRow valuesTable =
            lookupTableValues.FindByLT_STRUCT_UIDLCID(valueGuid, languageCode);

        if (valuesTable == null)
        {
            throw new ArgumentException( /*  TODO: Exception Message */);
        }
        else
        {
            return valuesTable.LT_VALUE_TEXT;
        }
    }
}

 

The above makes a call to LoadLookupTableValues(), which looks like this:

public LookupTableMultiLangDataSet.LookupTableValuesDataTable LoadLookupTableValues(Guid lookupTableGuid,
                                                                                    string columnsFilter)
{
    if (_CacheLookupTables)
    {
        if (_MultiLanguageLookupTableValues.ContainsKey(lookupTableGuid))
        {
            return _MultiLanguageLookupTableValues[lookupTableGuid];
        }
    }

    LookupTableMultiLangDataSet.LookupTableValuesDataTable valuesTable =
        GetLookupTableValues(columnsFilter, false);

    if (_CacheLookupTables)
    {
        _MultiLanguageLookupTableValues.Add(lookupTableGuid, valuesTable);
    }

    return valuesTable;
}

Lookup tables are optionally cached (but can be programmatically refreshed at any time).    Notice earlier that a call to another static helper class is made:

Filters.LookupTableFilters.ItemBasicInformation(lookupTableGuid)

 

The class library has helper features to make PSI development easier.  The above call looks like this:

public string ItemBasicInformation(Guid lookupTableGuid)
{
    Filter filter = new Filter();

    using (LookupTableMultiLangDataSet lookupTable = new LookupTableMultiLangDataSet())
    {
        filter.FilterTableName = lookupTable.LookupTableValues.TableName;
        filter.Fields.Add(new Filter.Field(lookupTable.LookupTableValues.LT_UIDColumn.ColumnName));
        filter.Fields.Add(new Filter.Field(lookupTable.LookupTableValues.LT_STRUCT_UIDColumn.ColumnName));
        filter.Fields.Add(new Filter.Field(lookupTable.LookupTableValues.LT_VALUE_TEXTColumn.ColumnName));

        Filter.FieldOperator equalUID =
            new Filter.FieldOperator(Filter.FieldOperationType.Equal,
                                     lookupTable.LookupTableValues.LT_UIDColumn.ColumnName, lookupTableGuid);
        filter.Criteria = equalUID;
        return filter.GetXml();
    }
}

Finally, a called to GetLookupTableValues() is made, which looks like this:

public LookupTableMultiLangDataSet GetLookupTables(string xmlFilter, bool autoCheckout)
{
    return _Parent.LookupTablesWebService.ReadLookupTablesMultiLang(xmlFilter, autoCheckout);
}

So, after all of these calls you get the Expertise (Figure 1) values back from the PSI for my resource--all three of them, as the custom field supports multiple values.

More later!

No comments:

Disclaimer

Content on this site is provided "AS IS" with no warranties and confers no rights. Additionally, all content on this site is my own personal opinion and does not represent my employer's view in any way.