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