Etendre ou modifier le Portail des communautés

Bonjour,

j’ai récemment rencontré plusieurs problématiques avec le nouveau template de site « Portail des communautés » / « Community Portal ».

Pour rappel, un site communautaire est un template qui fournit de base un liste de discussions (sorte de forum) avec un système assez poussé de gestion des membres (possibilité de s’inscrire, gestion de badges pour les membres les plus actifs etc.)

Voilà ce que Microsoft dit à propos des communautés et du portail associé

Créer un portail des communautés :

comPort1

Le portail des communautés OOTB :

comPort2

L’affichage est plutôt sympa, avec les informations principales (Titre, Description, Nombre de membres, Nombre de topics et de réponses), on a aussi accès à plus d’informations avec une popup

comPort4

De base nous obtenons donc un site qui diffère un peu des autres templates standard par le fait qu’il ne possède pas de Logo, pas de colonne de menu gauche.

Le contenu principal est géré par une webPart de type Content Search, préconfigurée pour afficher toutes les communautés accessibles (et par accessibles j’entend à minima que l’utilisateur courant ait un droit Visitor sur les communautés ou la collection qui les contient)

Les communautés sont triées par popularité (formule barbare incluse ci dessous ;-) )

[formula:((CommunityMembersCount*0.5)+CommunityTopicsCount+(CommunityRepliesCount*0.5))/(log(abs(Created-{0})+1)+log(abs(LastModifiedTime-{0})+1)+1)]

La paramètre {0} étant généré par la méthode suivante

private static ulong NowInTicks()
{
    DateTime utcNow = DateTime.UtcNow;
    checked
    {
        ulong num = (ulong)utcNow.Second * 10000000uL;
        num += (ulong)utcNow.Minute * 600000000uL;
        num += (ulong)utcNow.Hour * 36000000000uL;
        num += (ulong)utcNow.DayOfYear * 864000000000uL;
        num += (ulong)utcNow.Year * 316224000000000uL;
        if (utcNow.Month > 2 && !DateTime.IsLeapYear(utcNow.Year))
        {
            num += 864000000000uL;
        }
        return num;
    }
}

N’ayant pas encore acquis la médaille Fields j’ai encore un peu de mal à décortiquer la totalité, ce que j’en ait retenu c’est que le nombre de membres et le nombre de topics (et réponses) sont importants dans le classement. Les dates de création de dernière modification jouent aussi.


Le portail des communauté est donc un site qui peut être très utile OOTB, et qui se met en place en quelques clics.
Par contre si vous avez besoin de modifier ce site vous allez au devant de quelques problèmes.

    1 – Pas de bandeau ni de possibilité d’insérer des WebParts supplémentaires

comPort3

Concrètement la div qui affiche normalement le ruban est volontairement cachée, ce qui vous en conviendrez ne facilite pas l’ajout de nouvelles WebParts par exemple.

<div id="s4-ribbonrow" style="height: 0px"> &amp; Display à none
    2 – Les possibilités de modification de la WebPart « Communautés populaires » sont limitées
  • Il est impossible de modifier le titre de la WebPart, ce sera toujours « Communautés Populaires » / « Popular communities »
  • Par défaut vous ne disposez que des templates d’affichages définis avec la WebPart
  • Les besoins exprimés par le client n’étant pas satisfait par le template OOTB il a fallu composer avec ces problématiques.


      Problématique 1 : Le titre de la WebPart ne convient pas

    Ok, donc là pas de solution miracle il va falloir aller fouiller dans les entrailles SP, à l’aide d’ILSpy

    Un export de la WebPart nous donne le type Microsoft.SharePoint.Portal.WebControls.ExistingCommunitiesWebPart

    Un rapide aperçu de la classe en question
    comPort5

    On remarque qu’elle est sealed (donc pas possible d’en hériter) et qu’elle est basée sur la ContentBySearchWebPart (ça on s’en doutait au vu de l’interface)

    Allons voir ce qui se cache sous notre problème de titre non modifiable
    comPort6

    Hum… mystère résolu, pas de setter et le getter renvoie une chaîne vide, mais alors comment est généré notre titre par défaut ?

    comPort7

    Ok donc tout est géré une méthode spécifique appelée dans le Render via une ressource multilangues.

    Pour modifier le titre il va donc falloir développer notre propre WebPart en s’inspirant de l’existante.


      Problématique 2 : Je dois afficher les communautés dont je suis membre (et pas uniquement celles auxquelles j’ai accès)

    La requête KQL passée pour récupérer les communautés est la suivante : WebTemplate:Community

    Comme dit plus haut elle va remonter toutes les communautés auxquelles j’ai possibilité d’accéder, mais pas forcément celles dont je suis membre (pour rappel le fait d’être membre d’une communauté est conditionné par la présence d’un objet SPUser dans la liste Community Members

    Après quelques recherches j’ai trouvé la méta donnée qui permet d’obtenir ces informations : MemberOWSUSER:{User.Name}
    Si vous exécutez cette requête dans une WebPart vous constaterez que les résultats remontés ne sont hélas pas des communautés mais items de listes (plus précisément les liens vers les objets SPUser dans chaque communauté où l’utilisateur est membre)

    Le problème est que l’on souhaite remonter des communautés (afin que le template fourni affiche correctement les informations)

    Même constatation que pour la problématique 1, sans développement ça ne sera possible.


      Problématique 3 : Par défaut toutes les communautés sont privées, pourtant je dois pouvoir afficher les communautés populaires pour pouvoir demander à les rejoindre

    Etant donné que le moteur de recherche est par défaut « bridé » sur les objets auxquels l’utilisateur a accès, obtenir le résultat en standard ne semble pas possible à priori.

  • Hypothèse 1 : Créer un Result Source retournant les communautés avec un « Credential Informations » ayant des droits élevés

  • Sur papier la solution semble idéale, le Result Source renverrait toutes les communautés et donc on pourrait tout simplement les afficher à partir de là.
    Bon il s’avère que dans les faits je n’ai jamais réussi à obtenir le résultat attendu, le compte utilisé pour le Result Source a pourtant les privilèges qu’il faut (si je lance la requête via ce user j’obtiens bien des résultats corrects)

  • Hypothèse 2 : Créer une WebPart à partir de la WebPart ExistingCommunitiesWebPart dans laquelle j’exécute la requête de récupération des communautés dans un bloc de privilèges élevés (afin de retourner toutes les communautés)
  • Cette solution fonctionne et c’est ce que je vais présenter ci dessous.


    Création de la WebPart vide
    comPort8

    Créer un élément de type WebPart, dans le fichier .cs collez le code ci dessous (code d’ExistingCommunitiesWebPart)

    using Microsoft.Office.Server.Search.Query;
    using Microsoft.Office.Server.Search.WebControls;
    using Microsoft.SharePoint.Security;
    using Microsoft.SharePoint.Utilities;
    using System;
    using System.Collections.Generic;
    using System.Globalization;
    using System.Security.Permissions;
    using System.Web;
    using System.Web.UI;
    using System.Web.UI.WebControls.WebParts;
    namespace Microsoft.SharePoint.Portal.WebControls
    {
        [SharePointPermission(SecurityAction.LinkDemand, ObjectModel = true), AspNetHostingPermission(SecurityAction.LinkDemand, Level = AspNetHostingPermissionLevel.Minimal)]
        public sealed class ExistingCommunitiesWebPart : ContentBySearchWebPart
        {
            private bool _includeSubSites;
            public override string Title
            {
                get
                {
                    return string.Empty;
                }
                set
                {
                }
            }
            public override PartChromeType ChromeType
            {
                get
                {
                    return PartChromeType.None;
                }
                set
                {
                }
            }
            public bool IncludeSubSites
            {
                get
                {
                    return this._includeSubSites;
                }
                set
                {
                    this._includeSubSites = value;
                }
            }
            public ExistingCommunitiesWebPart()
            {
                base.BypassCBSFeatureCheck = true;
                this._includeSubSites = true;
            }
            protected override bool RequiresWebPartClientScript()
            {
                return false;
            }
            private static ulong NowInTicks()
            {
                DateTime utcNow = DateTime.UtcNow;
                checked
                {
                    ulong num = (ulong)utcNow.Second * 10000000uL;
                    num += (ulong)utcNow.Minute * 600000000uL;
                    num += (ulong)utcNow.Hour * 36000000000uL;
                    num += (ulong)utcNow.DayOfYear * 864000000000uL;
                    num += (ulong)utcNow.Year * 316224000000000uL;
                    if (utcNow.Month &gt; 2 &amp;&amp; !DateTime.IsLeapYear(utcNow.Year))
                    {
                        num += 864000000000uL;
                    }
                    return num;
                }
            }
            protected override void OnLoad(EventArgs e)
            {
                if (base.DataProvider != null)
                {
                    string propertyName = string.Format(CultureInfo.InvariantCulture, "[formula:((CommunityMembersCount*0.5)+CommunityTopicsCount+(CommunityRepliesCount*0.5))/(log(abs(Created-{0})+1)+log(abs(LastModifiedTime-{0})+1)+1)]", new object[]
                    {
                        ExistingCommunitiesWebPart.NowInTicks()
                    });
                    if (this._includeSubSites)
                    {
                        base.DataProvider.QueryTemplate = "WebTemplate:Community";
                    }
                    else
                    {
                        base.DataProvider.QueryTemplate = "WebTemplate:Community AND contentclass:STS_Site";
                    }
                    base.DataProvider.SourceID = "8413CD39-2156-4E00-B54D-11EFD9ABDB89";
                    base.DataProvider.FallbackSort = new List();
                    base.DataProvider.FallbackSort.Add(new ResultSort(propertyName, SortDirection.Descending));
                }
                base.ResultsPerPage = 30;
                base.RenderTemplateId = "~sitecollection/_catalogs/masterpage/Display Templates/Search/Control_SearchResults.js";
                base.ItemTemplateId = "~sitecollection/_catalogs/masterpage/Display Templates/System/Item_CommunityPortal.js";
                base.ShowAdvancedLink = false;
                base.ShowPreferencesLink = false;
                base.ShowAlertMe = false;
                base.ShouldHideControlWhenEmpty = false;
                base.EmptyMessage = StringResourceManager.GetString(LocStringId.CommunityPortalNoCommunities);
                base.OnLoad(e);
            }
            private static void RenderTitle(HtmlTextWriter writer)
            {
                string localizedString = SPUtility.GetLocalizedString("$Resources:spscore,CommunityPortal_WebPart_Title", null, checked((uint)CultureInfo.CurrentUICulture.LCID));
                writer.AddAttribute(HtmlTextWriterAttribute.Class, "ms-webpart-chrome-title");
                writer.RenderBeginTag(HtmlTextWriterTag.Div);
                writer.AddAttribute(HtmlTextWriterAttribute.Class, "ms-webpart-titleText-withMenu ms-webpart-titleText");
                writer.AddAttribute(HtmlTextWriterAttribute.Title, localizedString);
                writer.RenderBeginTag(HtmlTextWriterTag.H2);
                writer.Write(localizedString);
                writer.RenderEndTag();
                writer.RenderEndTag();
            }
            protected override void Render(HtmlTextWriter writer)
            {
                ExistingCommunitiesWebPart.RenderTitle(writer);
                base.Render(writer);
            }
        }
    }

    Modifiez tout d’abord le nom de la classe ainsi que le namespace afin qu’ils correspondent à votre besoin / votre projet

    La méthode qui va principalement nous intéresser est OnLoad car c’est dans celle ci que sont définis la requête et les templates d’affichage des résultat.

    Par rapport à la problématique 3 (l’exécution de la requête avec des privilèges élevés) le code suivant remontera correctement toutes les communautés (que l’utilisateur y ait accès ou non)

    protected override void OnLoad(EventArgs e)
            {
                SPSecurity.RunWithElevatedPrivileges(delegate()
                {
                    if (base.ActiveDataProvider != null)
                    {
                        string propertyName = string.Format(CultureInfo.InvariantCulture, "[formula:((CommunityMembersCount*0.5)+CommunityTopicsCount+(CommunityRepliesCount*0.5))/(log(abs(Created-{0})+1)+log(abs(LastModifiedTime-{0})+1)+1)]", new object[]
                    {
                        PopularCommunitiesWP.NowInTicks()
                    });

                        base.ActiveDataProvider.QueryTemplate = "WebTemplate:Community";
                        // Local Sharepoint Results
                        base.ActiveDataProvider.SourceID = "8413CD39-2156-4E00-B54D-11EFD9ABDB89";
                        base.ActiveDataProvider.FallbackSort = new List();
                        base.ActiveDataProvider.FallbackSort.Add(new ResultSort(propertyName, Microsoft.Office.Server.Search.Query.SortDirection.Descending));
                    }

                    base.ResultsPerPage = 30;
                    base.RenderTemplateId = "~sitecollection/_catalogs/masterpage/Display Templates/Search/Control_SearchResults.js";
                    base.ItemTemplateId = "~sitecollection/_catalogs/masterpage/Display Templates/System/Item_CommunityPortal.js";
                    base.ShowAdvancedLink = false;
                    base.ShowPreferencesLink = false;
                    base.ShowAlertMe = false;
                    base.ShouldHideControlWhenEmpty = false;
                    base.EmptyMessage = "No communities";
                    base.OnLoad(e);
                });
            }

    Il est bien évidemment possible de raffiner la requêtes suivant vos problématiques (collections spécifiques etc.)

    Le sourceID est le GUID par défaut du scope « Local SharePoint Results », scope par défaut du moteur de recherche FAST interne.

    Le FallbackSort est le mode de tri par défaut (formule détaillée plus haut), vous pouvez le modifier pour afficher par ordre alphabétique ou autre…

    Le RenderTemplateId et le ItemTemplateId sont les templates d’affichages globaux et de chaque item, vous pouvez récupérer les existants et les adapter à vos besoins.

    Par rapport à la problématique 1 (Modification du titre de la WebPart) vous avez plusieurs possibilités.
    Si votre site est multilangue alors vous devez probablement utiliser les fichiers de ressources pour gérer les libellés.
    Dans ce cas modifiez dans la méthode RenderTitle la ligne suivante avec vos informations

    string localizedString = SPUtility.GetLocalizedString("$Resources:NomFichierResource,nomChaineResource", null, checked((uint)CultureInfo.CurrentUICulture.LCID));

    Dans le cas où vous n’auriez qu’une langue à gérer vous pouvez tout simplement définir la variable localizedString.
    Il est aussi possible de supprimer l’override du Title afin de redonner à l’utilisateur la possiblité de définir lui même le titre de sa WebPart. (pensez à supprimer l’appel à RenderTitle dans ce cas)

    Il nous reste à voir la réponse à la problématique 2 (Afficher les communautés dont je suis membre)

    Nous avons vu tout à l’heure que la requête MemberOWSUSER:{User.Name} ne remontait que des items de liste et non des communautés, par contre ce qui peut nous être utile est le chemin url de la communauté.
    Une solution est donc d’exécuter une 1ère requête interne qui récupérera tous les chemins des communautés dont l’utilisateur est membre, puis ensuite une requête qui va récupérer les objets communautés pour affichage.

    private string GetFinalQueryFullCommunitiesPath()
            {
                StringBuilder fullQuery = new StringBuilder();

                string curentUser = SPContext.Current.Web.CurrentUser.Name;

                ResultTableCollection myResultCol = SPSearchUtils.ExecKQLQuery(SPContext.Current.Site, "MemberOWSUSER:\"" + curentUser + "\"");

                if (myResultCol.Count &gt; 0)
                {
                    DataTable resultTables = myResultCol.Filter("TableType", KnownTableTypes.RelevantResults).FirstOrDefault().Table;
                    List lCommunityImMember = resultTables.AsEnumerable().ToList();

                    List lCommPath = new List();
                    foreach (DataRow dr in lCommunityImMember)
                    {
                        // Colonne 19 champ ParentLink
                        string listURL = dr.ItemArray[19].ToString();
                        string webURL = listURL.Substring(0, listURL.IndexOf("Lists") - 1);

                        StringBuilder searchPath = new StringBuilder();

                        searchPath.AppendFormat(" Path:{0}", webURL);
                        lCommPath.Add(searchPath.ToString());
                    }
                    fullQuery.Append("(");
                    foreach (string str in lCommPath)
                    {
                        fullQuery.Append(str);
                    }
                    // Filtrage par communautés
                    fullQuery.Append(") AND WebTemplate:Community");
                }
                return fullQuery.ToString();
            }

    et ensuite de passer la requête générée au moteur de la WebPart (dans OnLoad)

    base.ActiveDataProvider.QueryTemplate = GetFinalQueryFullPopularCommunitiesPath();

    Voilà j’espère que cette note un peu détaillée vous a permis d’appréhender un peu mieux les possibilités du Portail des communautés et de sa WebPart associée.

    N’hésitez pas à revenir vers moi pour plus de détails ou si des points sont peu compréhensibles.

    Laisser un commentaire