Saturday, 28 April 2007

Deploying master pages and page layouts as a feature

This is the fourth article in my series of how to create common SharePoint site artifacts as features. Last time we looked at deploying content types as a feature.

Now that we have content types and all the supporting artifacts deployed, we can focus on deploying the things that make a real difference to how our site works - master pages and page layouts. I'll also cover web part deployment in a forthcoming post.

The basic premise is:-

  • develop master pages/layouts using SharePoint Designer (SPD)
  • save each file to the filesystem (as opposed to the Master Page Gallery) ready to be added to a feature. Unfortunately, SPD will actually screw up hyperlinks and some control references when you do this - in particular references to user controls using the '~/_controltemplates/' path. The best solution is to copy the contents of the final file into notepad, and save the file to the filesystem with the same name.
  • create the feature.xml and element manifest file for the feature. An example of an elements manifest file to deploy 2 master pages and 3 page layouts is shown at the end of this post.

Let's talk through the values used in the example below. At the module level (collection of files to deploy to a particular location in SharePoint), we specify the URL and whether the files should only be deployed to the root web or to all webs in the site collection. At the file level, attributes for the master pages are fairly simple:-

  • 'IgnoreIfAlreadyExists' - should be true if we want to overwrite an existing file of this name, false if not.
  • 'Type' - should be 'GhostableInLibrary' for files which exist in a document library such as the Pages, Style Library or Master Page Gallery libraries which exist in a SharePoint publishing site.

For the page layouts, things are slightly more complex. Here, we need to specify the following:-
  • 'ContentType' - specifies whether the file is a page layout or master page. Use the value '$Resources:cmscore,contenttype_pagelayout_name;' to specify SharePoint's internal string which represents the page layout option.
  • 'PublishingPreviewImage' - path to URL accessible image file to be displayed when this layout is selected in the listbox when creating a page.
  • 'PublishingAssociatedContentType' - this is where we specify which content type the layout should be associated with. This means the layout will automatically have this binding and will be ready for use. Note that if this value is omitted, by default your layout will be associated with the basic 'Page' content type from the publishing feature. This means any custom columns you have added will not be available. The value for this property should be in form ';#<Content type name>;#<Content type ID;#>. So a real example would be ';#Welcome Page;#0x010100C568DB52D9D0A14D9B2FDCC96666E9F2007948130EC3DB064584E219954237AF390064DEA0F50FC8C147B0B6EA0636C4A7D4;#'.


Another interesting facet of deploying files to SharePoint in this way, is that it's not possible (AFAIK) to update a file deployed from feature 'A' by a separate feature 'B'. This makes sense but gets in the way if you want to update a common file like itemStyle.xsl (used by the Content Query web part) using a feature. This won't work since the file was originally provisioned by the PublishingResources feature, not your custom feature.

I've also seen problems updating page layouts which were associated with content types created through the UI rather than by a feature.

So now that we have our lists, site columns, content types and master pages/page layouts deployed, we can create pages using the layouts and add content to the site.

Next time I'll talk about options around deploying web parts.

Here's the XML sample mentioned earlier:-

<?xml version="1.0" encoding="utf-8"?>
<elements xmlns="http://schemas.microsoft.com/sharepoint/">
<module name="MasterPagesModule" url="_catalogs/masterpage" rootwebonly="True" path="">
<file url="cScape.master" ignoreifalreadyexists="TRUE" type="GhostableInLibrary"></file>
<file url="accessible.master" ignoreifalreadyexists="TRUE" type="GhostableInLibrary"></file>
</module>
<module name="PageLayoutsModule" url="_catalogs/masterpage" rootwebonly="True" path="">
<file url="AdvancedSearchLayout.aspx" ignoreifalreadyexists="TRUE" type="GhostableInLibrary">
<property name="ContentType" value="$Resources:cmscore,contenttype_pagelayout_name;"></property>
<property name="PublishingPreviewImage" value="~SiteCollection/_catalogs/masterpage/$Resources:core,Culture;/Preview Images/ArticleLinks.png, ~SiteCollection/_catalogs/masterpage/$Resources:core,Culture;/Preview Images/ArticleLinks.png"></property>
<property name="PublishingAssociatedContentType" value=";#Welcome Page;#0x010100C568DB52D9D0A14D9B2FDCC96666E9F2007948130EC3DB064584E219954237AF390064DEA0F50FC8C147B0B6EA0636C4A7D4;#"></property>
</file>
<file url="HelplineHomeLayout.aspx" ignoreifalreadyexists="TRUE" type="GhostableInLibrary">
<property name="ContentType" value="$Resources:cmscore,contenttype_pagelayout_name;"></property>
<property name="PublishingPreviewImage" value="~SiteCollection/_catalogs/masterpage/$Resources:core,Culture;/Preview Images/ArticleLinks.png, ~SiteCollection/_catalogs/masterpage/$Resources:core,Culture;/Preview Images/ArticleLinks.png"></property>
<property name="PublishingAssociatedContentType" value=";#RNIB Welcome;#0x010100C568DB52D9D0A14D9B2FDCC96666E9F2007948130EC3DB064584E219954237AF390064DEA0F50FC8C147B0B6EA0636C4A7D4007AC2318FCE0E474eADE554A22E4B6135;#"></property>
</file>
<file url="HomePage.aspx" ignoreifalreadyexists="TRUE" type="GhostableInLibrary">
<property name="ContentType" value="$Resources:cmscore,contenttype_pagelayout_name;"></property>
<property name="PublishingPreviewImage" value="~SiteCollection/_catalogs/masterpage/$Resources:core,Culture;/Preview Images/ArticleLinks.png, ~SiteCollection/_catalogs/masterpage/$Resources:core,Culture;/Preview Images/ArticleLinks.png"></property>
<property name="PublishingAssociatedContentType" value=";#RNIB Welcome;#0x010100C568DB52D9D0A14D9B2FDCC96666E9F2007948130EC3DB064584E219954237AF390064DEA0F50FC8C147B0B6EA0636C4A7D4007AC2318FCE0E474eADE554A22E4B6135;#"></property>
</file>
</elements>

Friday, 20 April 2007

Feature to create lookup fields on Codeplex

In a recent post I posted some sample code for a feature receiver which would create lookup fields (site columns which get their data from lists). A couple of people left comments asking for full set of files.

I've put these on Codeplex at http://www.codeplex.com/SP2007LookupFields.

Some notes:-

  • I've enhanced the solution to be more generic and deal with creating multiple lookup fields in one feature. Now the name of the list can be included in the CAML and the feature receiver will parse this, find the list and fix the reference via the list GUID using the API. Note currently the list must be in the root web, though it would be trivial to extend this.
  • All the hardcoded values have been removed, e.g. the path to the file containing the CAML definition is now passed as a feature property.
  • I mentioned in the earlier post that you could have a dependent feature so that the assembly containing the feature receiver also gets deployed automatically on feature activation. This doesn't quite make sense since assemblies can only be deployed using SharePoint solutions not features. Hence I've wrapped the feature in a SharePoint solution which deploys the assembly and feature. When the feature is activated the assembly is already in the GAC and the feature receiver runs happily. Note it would also be fairly simple to enhance the solution such that the assembly gets deployed to a site bin with appropriate CAS policy for highly-controlled environments.
  • I've also included an STSADM script to take care of deploying the solution - you need to edit the URL in this file to point to your SharePoint site.

Hope this is useful, let me know if you have feedback..

Tuesday, 17 April 2007

Deploying content types as a feature

This is the third article in my series of how to create common SharePoint site artifacts as features. Last time we looked at creating site columns as a feature.

Site columns are typically used in lists and/or content types. The concepts are fairly simple since a content type is also effectively a list with a series of columns (for those who have worked with CMS2002 the equivalent entity was a template definition). The columns give the data entered into content areas on a page somewhere to be stored. However, content types in SharePoint are also a valuable unit of granularity, since workflow, document lifecycle policy and several other key pieces of functionality can be configured at this level.

Any page layout you create as a template for pages on your site must be associated with a content type. Generally each column in the content type will be matched to a field control on the page layout, allowing the author to enter content to be stored in each column.

Fortunately creating content types as features is fairly simple - CAML only, no need for a feature receiver (code). Alas VSeWSS can't help us much here but don't forget to use the CAML schema intellisense in VS by ensuring the XML file we're about to write is linked to the schema at C:\Program Files\Common Files\Microsoft Shared\web server extensions\12\TEMPLATE\XML\wss.xsd.

Initially, the part that can seem complex is the ID structure for a content type. Having been happily using GuidGen in Visual Studio every time we need an ID for something in a feature (though never trusting the first one ;-)), it comes as a surprise to have to read documentation just about IDs. As explained in Content Type IDs, the structure reflects the ancestry/parentage of a content type, meaning the parent content types can be determined very efficiently since simple string/byte matching can be used thus reducing database lookups.

The basics are:

  • find the ID of the content type you are deriving from. This can be done by either examining the content type in the SharePoint UI (Site Settings > Manage Content Types) and copying the ID from the URL querystring. Alternatively, search the feature files which the MS developers used to deploy the out-of-the-box content types. The former is probably simpler but the latter gives more scope for learning.
  • Add '00' and then a GUID you have generated (i.e. with GuidGen) to the end (suffix). You now have a valid content type ID. Note that you'll get a meaningful exception on feature activation if it's not.
  • For any child content types, to generate their IDs you can now add a simple ID such as '01' or '02' to the ID generated in the previous step. It's not necessary to suffix the ID with '00' and another GUID now since your unique ID is in the string. This means any ID's you generate will be different from anyone else's, so you can use the simple option and use a 2 digit number rather than another GUID. This means your content type IDs shouldn't grow too long.

The rest is fairly simple. Just add a FieldRef element for each site column the content type uses, specifying it's ID and name. Note that the approach we used to create our site columns meant that we got to specify the ID for them in CAML. We just need to dig out the IDs we specified there. So in both features, the IDs are in easily edited XML rather than being in any compiled code or similar so they are fairly loosely-coupled. Of course in many cases you'd choose to have both artifacts in the same feature, and in the future I'll post about factoring with relation to features.

You should end up with something like for each content type you are deploying:-


<!-- this content type is derived from the 'Page' content type from the 'Publishing' feature -->
<ContentType ID="0x010100C568DB52D9D0A14D9B2FDCC96666E9F2007948130EC3DB064584E219954237AF3900CF38CCD7FC6E8247AA124F3EE5796C20"
Name="COBArticle"
Group="COB demo content types"
Description="Base content type for articles."
Version="0">
<FieldRefs>
<FieldRef ID="{ae8e6ab8-b151-4fa4-8694-3cd24ad3b1bc}" Name="Locations" />
<FieldRef ID="{A4983A93-4B14-4a61-AE08-09108A718628}" Name="Sectors" />
<FieldRef ID="{6F26090A-C2AE-44d7-8F70-EE1663FE29F1}" Name="Disciplines" />
<FieldRef ID="{71316CEA-40A0-49f3-8659-F0CEFDBDBD4F}" Name="Article Date" />
</FieldRefs>
</ContentType>

Note that it's not necessary to repeat the fields declared in parent content types, though I notice some of the Microsoft features do this.

So now you have your content types deployed, they can now be used in document libraries/lists or associated with any page layouts you have. So next time is deploying master pages/page layouts/CSS etc. as a feature, including having the layouts automatically bound to the content types.

Sunday, 15 April 2007

Sample code - creating lookup columns as a feature

I promised in an earlier post to post the code I used to create lookup columns as a feature. The basic idea here to use CAML as far as possible to define the site column, but use code to inject the list ID which the column refers to (unknown at feature design-time since SharePoint decides the GUID for lists). Extracted below is the code for the feature receiver.

A couple of key points:-

  • this assumes a file exists at the location D:\COB.Demo.ListBasedSiteColumns.Fields.xml with the following contents (note the 'List' attribute with an empty value):-
    <?xml version="1.0" encoding="utf-8"?>
    <elements xmlns="http://schemas.microsoft.com/sharepoint/">
    <!-- _filecategory="ContentType" _filetype="File" _filename="fields.xml" _uniqueid="c0188da6-e320-44c0-b50c-cb6eaecec512" -->
    <Field
    Type="Lookup"
    Displayname="Locations"
    Required="FALSE"
    List=""
    ShowField="LinkTitleNoMenu"
    UnlimitedLengthInDocumentlibrary="FALSE"
    Group="COBDemo"
    ID="{ae8e6ab8-b151-4fa4-8694-3cd24ad3b1bc}"
    SourceId="{8c066b26-5a3e-4e1b-85ec-7e584cf178d7}"
    StaticName="Locations"
    Name="Locations">
    </elements>

  • Since most of the column definition is in CAML, this allows you to specify the ID of the column. This is useful later on when we deploy content types or lists which need to reference this site column by ID.
  • Site columns cannot be deleted when they are in use i.e. used in content types or lists. I prefer to try to delete the column on feature deactivation or reactivation, but omit error-handling so SharePoint throws an exception. This makes it clear to me why the feature cannot be deactivated. See comments in code for more information.
  • Note that the assembly which contains the code below must be available (in the GAC, or site bin directory with appropriate CAS policy) when the feature is activated. You must also specify the FeatureAssembly and FeatureReceiver attributes in your feature.xml file to register the feature receiver.
  • In terms of deploying the assembly, note that whilst you certainly can deploy assemblies to the GAC or site bin using the feature framework, you can't do it in this feature. Despite the 'FeatureActivated' name of the event handler, when your code executes the CAML won't yet have been processed. Hence your assembly won't yet have been copied and you'll get a FileNotFoundException when it tries to load the assembly. A good solution is to use a feature dependency. Here you can create a 2nd feature which deploys the assembly, and set the main feature to be dependent on this one. Additionally you can mark the assembly deployment feature to be hidden thus making the implementation a bit tidier. If it's useful to see the entire set of files I used leave me a comment and I'll put them up somewhere.
Apologies for the formatting, I will try to do something about this when I get time:-


public class FeatureReceiver : SPFeatureReceiver
{
private readonly string f_csSITE_COLS_DEFINITION_PATH = @"D:\COB.Demo.ListBasedSiteColumns.Fields.xml";

public override void FeatureActivated(SPFeatureReceiverProperties properties)
{
// feature is scoped at Site, so the parent is type SPSite rather than SPWeb..
using (SPSite site = properties.Feature.Parent as SPSite)
{
SPWeb currentWeb = null;
Guid gRootWebId = Guid.Empty;
if (site != null)
{
currentWeb = site.RootWeb;
gRootWebId = currentWeb.ID;
}
else
{
currentWeb = properties.Feature.Parent as SPWeb;
gRootWebId = currentWeb.Site.RootWeb.ID;
}





using (currentWeb)
{
// get reference to the list..
SPList referencedList = currentWeb.Site.RootWeb.Lists["LocationsList"];
string sFieldElement = null;
XmlTextReader xReader = new XmlTextReader(f_csSITE_COLS_DEFINITION_PATH);
while (xReader.Read())
{
if (xReader.LocalName == "Field")
{
sFieldElement = xReader.ReadOuterXml();
break;
}
}


string sFinalCaml = replaceListGuidString(sFieldElement, referencedList);
createLookupColumn(currentWeb, sFinalCaml, "Locations");
currentWeb.Update();
}
}
}


private string replaceListGuidString(string sFieldElement, SPList referencedList)
{
string sPopulatedGuid = string.Format("List=\"{0}\"", referencedList.ID);
return sFieldElement.Replace("List=\"\"", sPopulatedGuid);
}

/// <summary>
/// Attempt to delete the column. Note that this will fail if the column is inuse,
/// i.e. it is used in a content type or list. I prefer to not catch the exception
/// (though it may be useful to add extra logging), hence feature deactivation/re- /// activation will fail. This effectively means this feature cannot be deactivated whilst the column is in use.
/// </summary>
/// <param name="column">Column to delete.</param>

private void attemptColumnDelete(SPFieldLookup column)
{
try
{
column.Delete();
}
catch (SPException e)
{
// consider logging full explanation..
throw;
}
}






private void createLookupColumn(SPWeb web, string sColumnDefinitionXml, string sColumnName)
{
// delete the column if it exists already and is not yet in use..
SPFieldLookup lookupColumn = null;
lookupColumn = web.Fields[sColumnName] as SPFieldLookup;
if (lookupColumn != null)
{
attemptColumnDelete(lookupColumn);
}

// now create the column from the CAML definition..
string sCreatedColName = web.Fields.AddFieldAsXml(sColumnDefinitionXml);

// also set LookupWebId so column can be used in webs other than web which hosts list..
lookupColumn = web.Fields[sCreatedColName] as SPFieldLookup;
lookupColumn.LookupWebId = web.Site.RootWeb.ID;
lookupColumn.Update();
}


public override void FeatureDeactivating(SPFeatureReceiverProperties properties)
{
// delete the column if it exists already and is not yet in use..



// feature is scoped at Site, so the parent is type SPSite rather than SPWeb..
using (SPSite site = properties.Feature.Parent as SPSite)
{
SPWeb currentWeb = null;
Guid gRootWebId = Guid.Empty;
if (site != null)
{
currentWeb = site.RootWeb;
gRootWebId = currentWeb.ID;
}
else
{
currentWeb = properties.Feature.Parent as SPWeb;
gRootWebId = currentWeb.Site.RootWeb.ID;
}

SPFieldLookup lookupColumn = null;
lookupColumn = currentWeb.Fields["LocationsList"] as SPFieldLookup;

if (lookupColumn != null)
{
attemptColumnDelete(lookupColumn);
}
}
}



public override void FeatureInstalled(SPFeatureReceiverProperties properties)
{
}


public override void FeatureUninstalling(SPFeatureReceiverProperties properties)
{
}
}


If it's useful to have the full set of files illustrating the 2 features(particularly since the above isn't very readable!), leave me a comment and I'll make them available somewhere..

[Update - these have now been uploaded to Codeplex, see http://sharepointnutsandbolts.blogspot.com/2007/04/feature-to-create-lookup-fields-on.html]