Développer une colonne lookup personnelle avec de l'AJAX

Stéphane Eyskens

Dans ce tutoriel, nous allons voir comment on peut développer une colonne personnelle de type lookup en y intégrant de l'AJAX.

- Titre : Développer une colonne lookup personnelle avec de l'AJAX

- Auteur : Stéphane Eyskens

- Parution : 6 juin 2008

- Licence : Copyright ®  Developpez. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- II. Pourquoi une lookup avec de l'AJAX?

- III. Démonstration du projet

- IV. Structure de notre solution

- V. Communication en AJAX avec le service web lists.asmx

- VI. Dériver de SPFieldLookup

- VI-A. L'héritage en question

- VI-B. Le contrôle en question

- VI-C. Le contrôle d'addition/édition de la colonne à une liste

- VII. Petits désavantages liées aux custom fields

- VIII. Téléchargement

I - Introduction

Pour une introduction sur les colonnes personnelles, veuillez consulter ce tutoriel. Il explique en effet toutes les démarches à suivre pour créer une colonne personnelle en terme de structure, de déploiement etc...Dans ce tutoriel, nous allons uniquement nous concentrer sur le type de colonne (lookup) et sur une intégration "manuelle" de code AJAX.

Notre composant fait en effet appel à de l'AJAX mais sans nécessiter l'installation du framework ASP.NET AJAX qui n'est pas compatible en tant que tel avec SharePoint lorsque le SP1 n'est pas installé (UpdatePannel non supporté même s'il est possible de le faire fonctionner moyennant quelques efforts). C'est d'ailleurs la raison pour laquelle, ce composant génère l'AJAX "à la main" car dans certains environnements, installer à la fois le SP1 de SharePoint et le framework AJAX est encore proscrit par crainte d'instabilité.

D'hatibude, je préfère ne pas réinventer la roue mais certains cas de figure l'imposent :). En d'autres circonstances, je me serais bien sûr reposé sur le framework ASP.NET AJAX riche, stable et très facile à utiliser.
Ceci dit, cela nous permettra de démystifier l'AJAX et de se rendre compte qu'on peut très facilement intégrer des composants avec de l'AJAX sans devoir changer quoi que ce soit à la configuration de SharePoint. Après tout, l'AJAX ce n'est qu'un peu de code client et un composant serveur qui lui répond, en l'occurrence dans notre cas, il s'agira du service web lists.asmx de SharePoint

II - Pourquoi une lookup avec de l'AJAX?

Pourquoi implémenter une colonne de type lookup et faire appel à de l'AJAX? La réponse se trouve dans cette question : avez-vous déjà essayé de créer une colonne lookup standard sur une liste contenant plusieurs milliers d'éléments?

Si oui, vous aurez probablement constaté une forte dégradation des performances lors de l'encodage/édition d'un élement de liste. Sinon, faites le test. Notez toutefois que cette dégradation est uniquement dûe au navigateur, SharePoint quant à lui restitue les milliers d'enregistrements en un clin d'oeil.

L'AJAX va nous permettre de rappatrier des éléments de la liste source petit à petit sans faire de full page postback et donc de garder un confort optimal pour l'utilisateur.

III - Démonstration du projet

Le projet est actuellement téléchargeable sur codepelex à cette adresse. Voici en quelques étapes comment il fonctionne

Etape 1 : Ajout de la colonne à une liste et sélection de la liste source

Notez que vous ne pouvez pas sélectionner de champ cible. En effet, la colonne se basera toujours sur le champ Title pour plus de facilité. Je donnerai les détails à ce propos plus tard.

Etape 2 : création d'un élément de liste

au fur et à mesure que vous tapez des lettres (ici do), la liste du dessous retourne les 10 éléments commençant par la/les lettre(s) entrée(s).

IV - Structure de notre solution

- CONTROLTEMPLATES => CustomLookupFieldControl.ascx : contrôle affiché lors de la saisie de données

- CONTROLTEMPLATES => CustomLookupFieldEditor.ascx : contrôle affiché lors de la création de la colonne

- XML => FLDTYPES_...: fichier XML décrivant la colonne personnelle et son comportement en mode "raw view"

- CustomLookup.cs => classe invoquée lors de l'addition/édition/affichage de notre colonne personnelle

- CustomLookupFieldControl.cs => classe invoquée lors de l'addition/édition/affichage de notre colonne personnelle

- CustomLookupFieldEditor.cs => classe invoquée lors de l'addition/édition de notre colonne à une liste

Le fichier Install.bat n'est là que pour déployer les composants dans un environnement de développement. Il vous faudrait idéalement générer un fichier de solution. Vous pouvez le faire très facilement avec WSPBUILDER

V - Communication en AJAX avec le service web lists.asmx

Tout d'abord, en préambule, il faut savoir que le service web lists.asmx out of the box permet entre-autres de récupérer des éléments de liste via des requêtes CAML.

Il est accessible depuis n'importe quel site de votre ferme, il suffit d'ajouter à la fin de l'URL de votre site courant /_vti_bin/lists.asmx. Lorsque vous le faites, vous obtenez la liste des méthodes utilisables par n'importe quel applicatif.

Vous retrouvez notamment la méthode GetListItems que nous utiliserons dans notre composant. Lorsque vous cliquez sur le nom de cette méthode, vous obtenez des informations cruciales telles que la SOAPACTION, les différentes en-têtes HTTP et les paramètres que vous allez devoir lui transmettre. En l'occurrence, on voit qu'on doit transmettre les paramètres listName, viewName, query, viewFields, rowLimit et queryOptions

Avant de passer à l'AJAX, voyons comment appeler cette méthode depuis un programme console ou une application windows

XmlDocument XmlDoc = new System.Xml.XmlDocument();
XmlNode QueryNode = XmlDoc.CreateNode(XmlNodeType.Element, "Query", "");
XmlNode ViewFieldsNode =
XmlDoc.CreateNode(XmlNodeType.Element, "ViewFields", "");
XmlNode QueryOptionsNode =
XmlDoc.CreateNode(XmlNodeType.Element, "QueryOptions", "");
QueryOptionsNode.InnerXml =
"<IncludeMandatoryColumns>FALSE</IncludeMandatoryColumns>";
QueryNode.InnerXml = "<OrderBy><FieldRef Name='Title'/></OrderBy><Where>" +
"<Eq><FieldRef Name='Title'/><Value Type='Text'>test</Value></Eq></Where>";
list.Lists SrvInstance = new ConsoleApplication1.list.Lists();
SrvInstance.Credentials = System.Net.CredentialCache.DefaultNetworkCredentials;
try
{
XmlNode Result = SrvInstance.GetListItems(
"guid de la liste",
null,
QueryNode,
null,
"10",
QueryOptionsNode,
null);

Console.WriteLine(Result.OuterXml);
}
catch (SoapException Ex)
{
Console.WriteLine(Ex.Detail.InnerText);
}

Ce petit exemple nous ramène le flux XML suivant en guise de réponse

<listitems xmlns:s="uuid:BDC6E3F0-6DA3-11d1-A2A3-00AA00C14882"
xmlns:dt="uuid:C2F41010-65B3-11d1-A29F-00AA00C14882"
xmlns:rs="urn:schemas-microsoft-com:rowset"
xmlns:z="#RowsetSchema"
xmlns="http://schemas.microsoft.com/sharepoint/soap/">
<rs:data ItemCount="1">
<z:row ows_Attachments="0" ows_LinkTitle="test"
ows_ID="1" ows_MetaInfo="1;#"
ows__ModerationStatus="0"
ows__Level="1"
ows_owshiddenversion="3"
ows_UniqueId="1;#{6AC7A2B9-BDD0-48DC-B73C-2389344D2619}"
ows_FSObjType="1;#0"
ows_Created_x0020_Date="1;#2008-05-19T06:25:52Z"
ows_Created="2008-05-19T06:25:52Z"
ows_FileLeafRef="1;#1_.000"
ows_FileRef="1;#Lists/test/1_.000" />
</rs:data>
</listitems>

Flux qu'il faudra ensuite analyser pour récupérer les différentes valeurs de notre élément de liste. Avec l'AJAX, c'est un peu pareil sauf qu'on doit en plus passer les en-têtes HTTP.

Voici un exemple complet d'appel à la méthode GetListItems du service web lists.asmx en AJAX et le traitement de la réponse.

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
<head>
<script language="javascript">
function DoQuery()
{
var SpCAMLQuery;
HttpObject = null;
TargetObject = document.getElementById("UneListe");
SpCAMLQuery= '<?xml version="1.0" encoding="utf-8"?>';
SpCAMLQuery+='<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"';
SpCAMLQuery+='xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">';
SpCAMLQuery+='<soap:Body>';
SpCAMLQuery+='<GetListItems xmlns="http://schemas.microsoft.com/sharepoint/soap/">';
SpCAMLQuery+='<listName>a1420e71-84d3-4602-98e0-643a10c4fafd</listName>' ;
SpCAMLQuery+='<rowLimit>10</rowLimit>' ;
SpCAMLQuery+="<query><Query><Where><Eq><FieldRef Name='Title'/><Value Type='Text'>test</Value>';
SpCAMLQuery+='</Eq></Where></Query></query>";
SpCAMLQuery+='<queryOptions><QueryOptions><IncludeMandatoryColumns>TRUE</IncludeMandatoryColumns>';
SpCAMLQuery+='</QueryOptions></queryOptions>' ;
SpCAMLQuery+='</GetListItems></soap:Body></soap:Envelope>' ;
var serverUrl = 'http://sey-pc/_vti_bin/Lists.asmx?wsdl' ;
if (HttpObject==null)
{
if (window.XMLHttpRequest)
{
HttpObject = new XMLHttpRequest();
}
else if (window.ActiveXObject)
{
HttpObject = new ActiveXObject("MSXML2.XMLHTTP.3.0");
}
}
//Executing the call
HttpObject.open("POST", "http://sey-pc/_vti_bin/lists.asmx", true);
HttpObject.setRequestHeader("Content-Type","text/xml; charset=utf-8");
HttpObject.setRequestHeader("Host", "sey-pc");
HttpObject.setRequestHeader("SOAPAction","http://schemas.microsoft.com/sharepoint/soap/GetListItems");
readyStateChangeHandler = AjaxAnswer;
HttpObject.onreadystatechange = readyStateChangeHandler;
HttpObject.send(SpCAMLQuery);
}
function AjaxAnswer()
{

//When the answer is back and the http request was successfull
if (HttpObject.readyState==4 && HttpObject.status==200)
{
window.status = "SOAP request successfully processed...";
var xml = HttpObject.responseXML;
if (xml.documentElement)
{
//retrieving the returned rows
var rows = xml.documentElement.getElementsByTagName("z:row");
if (rows.length==0)
{
rows = xml.documentElement.getElementsByTagName("row");
}

TargetObject.length = 0;
TargetObject.disabled=true;
if (rows.length > 0)
{

for (var i = 0; i < rows.length; i++)
{
var TitleValue = "";
var IdValue = "";
if (rows[i].getAttributeNode("ows_Title")!=null)
{
TitleValue = rows[i].getAttributeNode("ows_Title").nodeValue;
}
if (rows[i].getAttributeNode("ows_ID")!=null)
{
IdValue = rows[i].getAttributeNode("ows_ID").nodeValue;
}
NewOption = document.createElement("option");
NewOption.value = IdValue+";#"+TitleValue;
NewOption.text = TitleValue;
try
{
TargetObject.add(NewOption,null);
}
catch(ex)
{
TargetObject.add(NewOption);
}
TargetObject.disabled=false;
}
}
}
}
else
{
window.status = "SOAP request failed...";
}
}
</script>
</head>
<body>
<select id="UneListe"></select><input type="button" onclick="DoQuery()" value="Exécuter"/>
</body>
</html>

Si vous copiez/collez ce code dans un fichier html, que vous remplacez bien sûr sey-pc par votre serveur et votre url et que vous remplacez le guid de la liste par l'un de vos guids et pour autant que votre liste contienne bien les données requêtes par la requête CAML, vous devriez obtenir ceci :))

Je ne rentrerai pas dans le détail du code AJAX (similaire) de mon composant, vous aurez tout le loisir de le découvrir si cela vous intéresse en examinant le code source (contrôle CustomLookupFieldControl.ascx). Il y a juste un peu de code client supplémentaire pour traiter l'évènement onkeyup de la zone de texte où l'utilisateur saisit les données et un traitement spécifique de celle-ci. En effet, dans le contexte d'un contrôle SharePoint en général (webpart, custom field etc...), il est toujours possible que plusieurs instances d'un même contrôle soient déployées en même temps sur une même page. Il faut donc s'assurer de travailler avec des ID uniques lorsque l'on effectue des opérations du côté client.

Au sein de notre solution, c'est le contrôle utilisateur CustomLookupFieldControl.ascx qui contient le code AJAX très similaire à celui présenté ci-dessus

VI - Dériver de SPFieldLookup

Comme pour un SPFieldText, lorsque l'on crée un custom field, il faut dériver d'un type existant. La grosse différence entre le SPFieldText et le SPFieldLookup est que SPFieldText fonctionne en mode "isolé/autonome" alors que SPFieldLookup maintient une référence vers un élément d' une liste de données source. La manière dont on stockera la valeur du champ sera donc différente. Il faut également spécifier au custom field vers quelle liste source il doit pointer.

Avant de se lancer dans le code, il est toujours bon de se rappeler comment une colonne de type lookup fonctionne en standard dans SharePoint. Pour pouvoir maintenir sa référence vers l'élément source, la valeur stockée au sein d'une lookup correspond à l'ID de l'élément source + le libellé de la colonne qu'on a décidé d'afficher comme le montre le schéma ci-dessous

Notre tâche principale va donc consister d'une part à indiquer à notre champ quelle est la liste source. Celle-ci peut-être choisie par l'utilisateur. C'est donc le contrôle utilisateur CustomLookupFieldEditor.ascx et son code-behind CustomLookupFieldEditor.cs qui s'en chargeront et d'autre part, nous devons stocker la valeur de l'ID+libellé de l'élément source.

En standard, une colonne lookup peut-être utilisée en mode multi-valué, je n'ai pas implémenté celui-ci sur notre composant, donc je ne l'aborderai pas.

VI-A - L'héritage en question

public class CustomLookup : SPFieldLookup
{

#region default contructors
public CustomLookup(SPFieldCollection fields, string fieldName)
: base(fields, fieldName)
{

}

public CustomLookup(Microsoft.SharePoint.SPFieldCollection fields, string typeName, string displayName)
: base(fields, typeName, displayName)
{

}
#endregion

/// <summary>
/// This method ensures that a value is provided if the field is mandatory
/// </summary>
/// <param name="value"></param>
/// <returns></returns>
public override string GetValidatedString(object value)
{

if (this.Required)
{
if (value == null || value.ToString() == "")
throw new Microsoft.SharePoint.SPFieldValidationException("Please fill in this mandatory field");
else
return value.ToString();
}
else
{
if (value != null)
return value.ToString();
else
return null;
}

}

/// <summary>
/// Rendering control is called when the field renders.
/// </summary>
public override Microsoft.SharePoint.WebControls.BaseFieldControl FieldRenderingControl
{
get
{
Microsoft.SharePoint.WebControls.BaseFieldControl CtField = new CustomLookupFieldWithAJAX.CustomLookupFieldControl();
CtField.FieldName = InternalName;
return CtField;
}

}
}

Héritage tout à fait classique, on indique simplement à SharePoint qu'on dérive de SPFieldLookup au lieu de SPFieldText. Dans la méthode FieldRenderingControl invoquée par les interfaces d'addition/édition/affichage web de SharePoint, nous indiquons qu'on retourne une instance de CustomLookupFieldControl, qui n'est ni plus ni moins que la classe qui décrit le comportement de notre colonne.

VI-B - Le contrôle en question

Là encore, pas énormément de différence par rapport à SPFieldText. Je ne vais pas copier/coller tout le code de cette classe, simplement voici le code de la seule propriété changeante.

public override object Value
{
get
{
EnsureChildControls();
if (LookupHiddenValue.Text != "")
return new SPFieldLookupValue(LookupHiddenValue.Text);
else
return "";
}
set
{
if (ItemFieldValue != null)
{
LookupHiddenValue.Text = ItemFieldValue.ToString();
LookupValue.Text = new SPFieldLookupValue(ItemFieldValue.ToString()).LookupValue;
}
}
}

Ce qui change est donc la manière dont on construit la valeur qui je vous le rappelle a le format ID;#Libellé. Dans ce code, Le contrôle LookupHiddenValue est un simple TextBox caché qui contient la valeur ID;#Libellé. La classe SPFieldLookupValue permet de générer une valeur lookup compréhensible par SharePoint.

Au cas où vous implémentez un custom lookup field multi-valué, vous travaillerez avec la classe SPFieldLookupValueCollection

Une autre différence encore entre un SPFieldText et un SPFieldLookup est la sécurité. Lorsque vous créez un custom field dérivé de SPFieldText, vous ne vous souciez pas le moins du monde de savoir si l'utilisateur a le droit ou non de saisir une valeur pour votre colonne puisque SharePoint se chargera de lui interdire l'accès. Par contre, dans le cadre d'une colonne de type Lookup, le comportement de SharePoint est différent.
Si l'utilisateur n'a pas d'accès en lecture à la liste source pointée par la colonne, celle-ci reste vide et aucune donnée n'est sélectionnable. Il faut donc que nous adoptions le même comportement. J'ai ajouté un contrôle de sécurité au niveau du composant pour afficher un message à l'utilisateur si il n'a pas le droit d'accéder à la liste source. En plus de cette notification, j'éviterai de générer le code client faisant appel aux fonctions AJAX.

Ce contrôle se passe dans la méthode CreateChildControls que tout développeur connaît bien :)

using (SPWeb Web = SPContext.Current.Site.OpenWeb())
{
try
{
Web.Site.CatchAccessDeniedException = false;
if (Web.Lists[new Guid(CurrentField.LookupList)].DoesUserHavePermissions(
SPBasePermissions.ViewListItems))
AccessListOk = true;
}
catch(UnauthorizedAccessException)
{
AccessListOk = false;
}
}

Ce code vérifie si oui ou non l'utilisateur courant a le droit d'accéder en lecture à la liste source pointée par notre colonne de type lookup.
La variable AccessListOk est exploitée ultérieurement pour afficher le message d'erreur et ne pas générer le code client le cas échéant. L'utilisateur verra donc ceci en cas de non accessibilité à la liste source

VI-C - Le contrôle d'addition/édition de la colonne à une liste

Ce contrôle permet de gérer le comportement de notre colonne lors de l'addition/édition de celle-ci à une liste. Dans notre solution, il s'agit du contrôle CustomLookupFieldEditor.ascx dont le code est le suivant

<wssuc:InputFormControl runat="server"
LabelText="<%$Resources:wss,fldedit_getinfofrom%>"
>
<Template_Control>
<asp:DropDownList id="TargetLookupList" runat="server"
Title = "<%$Resources:wss,fldedit_getinfofrom%>"
Enabled="false"
>
</asp:DropDownList>
</Template_Control>
</wssuc:InputFormControl>

Ce code est relativement restreint et sert à déclarer notre contrôle DropDown qui contiendra l'ensemble des listes/bibliothèques du site courant.

Une fois de plus, c'est plutôt dans le code-behind qu'il faut travailler davantage.

public class CustomLookupFieldEditor : UserControl, IFieldEditor

On dérive de UserControl et on implémente IFieldEditor.

public void InitializeWithField(SPField field)
{
CurrentField = field as SPFieldLookup;
using (SPWeb Web = SPContext.Current.Site.OpenWeb())
{
//If the field is being created
if (CurrentField == null)
{

TargetLookupList.Enabled = true;

foreach (SPList List in Web.Lists)
{
if (!List.Hidden)
TargetLookupList.Items.Add(new ListItem(List.Title));
}
}
//The field exists, so just show the list name and does not allow
//the selection of another list
else
{
try
{
TargetLookupList.Items.Add(new ListItem(Web.Lists[new Guid(CurrentField.LookupList)].Title));

}
catch
{
TargetLookupList.Items.Add(new ListItem("The source list does not exist"));
}
TargetLookupList.Enabled = false;
return;
}
}

}

Si CurrentField est null, cela signifie qu'on ajoute le champ à une liste et dans ce cas, on ajoute à la dropdown, la liste de toutes les listes/bibliothèques non cachées du site. Sinon, le champ a déjà été ajouté à la liste et a donc forcément été lié à une liste source, donc on affiche le nom de celle-ci et on verrouille la dropdown.

public void OnSaveChange(SPField field, bool bNewField)
{
SPFieldLookup CurrentLookupField = (SPFieldLookup)field;

if (bNewField)
{
using (SPWeb Web = SPContext.Current.Site.OpenWeb())
{
CurrentLookupField.LookupList = Web.Lists[TargetLookupList.SelectedItem.Text].ID.ToString();
CurrentLookupField.LookupWebId = Web.ID;
CurrentLookupField.LookupField = "Title";
}
}
}

La méthode OnSaveChange est appelée lorsque l'utilisateur clique sur le bouton Ok. On ne traite dans ce cas présent que l'addition de la colonne à une liste puisqu'on ne permet pas de la modifier. En cas d'ajout, on spécifie à la propriété LookupList le GUID de la liste choisie, le GUID du site courant et le champ cible. Dans ce cas, j'ai hard-codé le champ Title qui existe toujours quelle que soit la liste et la langue du site.

La raison pour laquelle j'ai hard-codé ce champ est tout simplement pour faciliter la génération de la requête CAML qu'on envoie vers le service web de SharePoint. En effet, en sachant qu'on travaille avec Title, il est aisé de créer une requête CAML travaillant avec l'opérateur BeginsWith puisqu'on sait qu'on traite toujours une colonne de type String. Si on permet à l'utilisateur de choisir la colonne cible, on va devoir traiter tous les types de champs, ce qui complique grandement la tâche.

VII - Petits désavantages liées aux custom fields

Bien que les custom fields sont des composants très intéressants, ils ont néanmoins leur lot de désavantages, parmi ceux-ci

- ils sont en lecture seule en mode feuille de données (datasheet)

- ils sont en lecture seule en mode intégration cliente avec les produits Office (dans word par ex, un custom field est grisé et sa valeur ne peut être modifiée)

- ils sont actifs ou non pour la ferme entière, en effet, on ne peut pas décider d'activer un custom field pour une collection particulière. On peut toutefois créer un custom field, travailler avec et le rendre invisible par la suite mais ce n'est pas idéal

- ils ne sont parfois pas pris en charge par certains produits tiers tels que des webparts etc...car ils ont un type interne Invalid

VIII - Téléchargement

Vous trouverez le fichier de solution ainsi que les sources du projet à cette adresse