Erweiterung der SharePoint 2016 SuiteBar über einen RESTful Service

Auch wenn es mit dem neuesten Feature Pack möglich ist die Einträge im AppLauncher zu erweitern (siehe dazu diesen Beitrag), möchte ich in diesem Beitrag einen alternativen Ansatz vorstellen, mit dem auch weitere Bereiche der oberen SuiteBar erweitert werden können. Dazu zählen zum Beispiel die Links unter dem Benutzernamen. Abgeleitet wurde die hier beschriebene Lösung aus diesem Blog: https://code.msdn.microsoft.com/office/SharePoint-2016-SuiteBar-9f9d8304

Hierzu wird ein REST-Service erstellt, der einen JSON string zurückgibt in dem beschrieben steht, welche Einträge angezeigt werden sollen. Mit dieser Vorgehensweise wird der SharePoint Standard durch unsere eigene Konfiguration überschrieben.

Die fertige Lösung ist bei GitHub verfügbar: https://github.com/SharePointJungs/SuiteNavExtender

Zunächst erstellen wir ein neues SharePoint-Projekt (FarmSolution). Diesem fügen wir die zwei Klassen SuiteNavExtenderServiceStub (abgeleitet von ServerStub (MSDN)) und SuiteNavExtenderService hinzu.

SuiteNavExtenderServiceStub

In dieser Klasse werden grundsätzliche Informationen für den Service hinterlegt und müssen zu der restlichen Definition des Services passen, was insbesondere für die GUID und den FullName gilt, die nach einer Änderung in der Service-Klasse gerne mal vergessen werden.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
[ServerStub(
    typeof(SuiteNavExtenderService),
    TargetTypeId = "{8AE98184-E986-4E62-8CE7-630EEEC08218}")]
public class SuiteNavExtenderServiceStub : ServerStub
{
    protected override Type TargetType => typeof(SuiteNavExtenderService);
    protected override Guid TargetTypeId => new Guid("{8AE98184-E986-4E62-8CE7-630EEEC08218}");
    protected override string TargetTypeScriptClientFullName => "SharePointJungs.SuiteNavExtender";
    protected override ClientLibraryTargets ClientLibraryTargets => ClientLibraryTargets.All;

    protected override object InvokeConstructor(XmlNodeList xmlargs, ProxyContext proxyContext)
    {
        CheckBlockedMethod(".ctor", proxyContext);
        return new SuiteNavExtenderService();
    }

    protected override object InvokeConstructor(ClientValueCollection xmlargs, ProxyContext proxyContext)
    {
        CheckBlockedMethod(".ctor", proxyContext);
        return new SuiteNavExtenderService();
    }

    protected override object InvokeMethod(object target, string methodName, ClientValueCollection xmlargs, ProxyContext proxyContext, out bool isVoid)
    {
        SuiteNavExtenderService service = (SuiteNavExtenderService)target;
        methodName = GetMemberName(methodName, proxyContext);
        switch (methodName)
        {
            case "Empty":
                isVoid = true;
                return null;
            case "GetSuiteNavData":
                isVoid = true;
                return service.GetSuiteNavData();
        }
        return base.InvokeMethod(target, methodName, xmlargs, proxyContext, out isVoid);
    }

    protected override IEnumerable<MethodInformation> GetMethods(ProxyContext proxyContext)
    {
        MethodInformation methodGetString = new MethodInformation
        {
            Name = "GetSuiteNavData",
            IsStatic = false,
            OperationType = OperationType.Default,
            ClientLibraryTargets = ClientLibraryTargets.All,
            OriginalName = "GetSuiteNavData",
            WildcardPath = false,
            ReturnType = typeof(string),
            ReturnODataType = ODataType.Primitive,
            RESTfulExtensionMethod = true,
            ResourceUsageHints = ResourceUsageHints.None,
            RequiredRight = ResourceRight.Default
        };
        yield return methodGetString;

        MethodInformation methodCtor = new MethodInformation
        {
            Name = ".ctor",
            IsStatic = false,
            OperationType = OperationType.Default,
            ClientLibraryTargets = ClientLibraryTargets.RESTful,
            OriginalName = ".ctor",
            WildcardPath = false,
            ReturnType = null,
            ReturnODataType = ODataType.Invalid,
            RESTfulExtensionMethod = false,
            ResourceUsageHints = ResourceUsageHints.None,
            RequiredRight = ResourceRight.None
        };
        yield return methodCtor;
    }
}

 

SuiteNavExtenderService 

In dieser Klasse wird die eigentliche Funktionalität geboten. Um die Konfiguration zunächst möglichst einfach zu halten, wird der Inhalt einer JSON-Datei geladen, die im Layout-Verzeichnis des SharePoint abgelegt wird. Die Funktion kann von diesem Beispiel ausgehend erweitert werden, sodass Konfigurationen dynamischer geladen werden, wenn diese zum Beispiel speziell berechtigte Verknüpfungen darstellen sollen. Wichtig ist, dass die Funktion zwingend GetSuiteNavData heißt, da die Lösung sonst nicht funktioniert.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[ClientCallableType(
    Name = "SuiteBarService", 
    ServerTypeId = "{8AE98184-E986-4E62-8CE7-630EEEC08218}")]
public class SuiteNavExtenderService
{
    /// <summary>
    /// Liefert die Daten für den Aufbau der oberen Navigationsleiste
    /// </summary>
    /// <returns>Daten im benötigten JSON-Format</returns>
    [ClientCallableMethod(
        RequiredRight = ResourceRight.None, 
        ClientLibraryTargets = ClientLibraryTargets.RESTful, 
        Name = "GetSuiteNavData", 
        OperationType = OperationType.Read)]
    public string GetSuiteNavData()
    {
        // JSON aus Datei im "_layouts"-Verzeichnis laden
        string path = SPUtility.GetCurrentGenericSetupPath(@"TEMPLATE\LAYOUTS\SuiteNavExtender\SuiteNavExtenderData.json");
        using (TextReader reader = new StreamReader(path))
        {
            return reader.ReadToEnd();
        }
    }
}

 

Nachdem die zwei Klassen erstellt wurden, müssen diese im SharePoint noch bekannt gemacht werden. Hierzu wird über Rechtsklick auf das Projekt und Add -> SharePoint Mapped Folder der Ordner „ClientCallable“ hinzugefügt.

addclientcallablemappedfolder

In diesem fügen wir die XML-Datei ProxyLibrary.SharePointJungs.SuiteNavExtenderService.xml hinzu. Hierbei ist es wichtig, dass der Name der Datei mit ProxyLibrary. beginnt.
In der Datei selbst wird der Service wie folgt angegeben:

1
2
3
<ClientCallableProxyLibrary>
  <AssemblyName SupportAppAuth="true">SharePointJungs.SuiteNavExtender, Version=1.0.0.0, Culture=neutral, PublicKeyToken=fba9ff59b0c7d6ba</AssemblyName>
</ClientCallableProxyLibrary>

 

Zusätzlich wird über ein neues „Empty Element“ folgender Eintrag hinzugefügt:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<?xml version="1.0" encoding="utf-8"?>
<Elements xmlns="http://schemas.microsoft.com/sharepoint/">
  <Control Id="SuiteBarDelegate"
          Sequence="50"
          ControlClass="Microsoft.SharePoint.WebControls.SuiteNavControl"
          ControlAssembly="Microsoft.SharePoint, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c"
          xmlns="http://schemas.microsoft.com/sharepoint/">
    <!--Dem SuiteNavControl die Rest-Methode nennen, über dir er nun die Daten laden soll-->
    <Property Name="SuiteNavRestMethod">SuiteNavExtenderService/GetSuiteNavData</Property>
  </Control>
  <!--AdditionalPageHead-->
  <Control ControlSrc="/_controltemplates/15/SuiteNavExtender/AdditionalPageHeader.ascx"
           Id="AdditionalPageHead"
           Sequence="90" />
</Elements>

 

Das erste Control registriert den Service im SuiteNavStapling-Element. Das zweite Control ist ein AdditionalPageHeader, der für einen Bugfix benötigt wird, der weiter unten beschrieben wird.

Zum Schluss muss noch ein Eintrag in der AssemblyInfo ergänzt werden:

1
[assembly: UrlSegmentAliasMap("SuiteNavExtenderService", "SharePointJungs.SuiteNavExtender", ResourceType = typeof(SuiteNavExtenderService))]

 

Damit der Service auch etwas zu laden hat, muss noch eine entsprechende Konfiguration in das Layout-Verzeichnis gelegt werden. Dies kann wahlweise manuell oder auch direkt über die SharePoint-Lösung erfolgen. Eine Konfiguration mit einem Element sähe wie folgt aus:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
{
  "DoNotCache": true,
  "NavBarData": {
    "AboutMeLink": null,
    "ClientData": null,
    "CurrentMainLinkElementID": null,
    "CurrentWorkloadHelpSubLinks": null,
    "CurrentWorkloadSettingsSubLinks": null,
    "CurrentWorkloadUserSubLinks": null,
    "HelpLink": null,
    "IsAuthenticated": false,
    "PinnedApps": null,
    "WorkloadLinks": [
      {
        "AriaLabel": null,
        "BackgroundColor": null,
        "BrandBarText": null,
        "CollectorId": null,
        "FontIconCss": null,
        "IconFullUrl": "/_layouts/15/images/suitenavextender/logospj200.png",
        "Id": "Sharepointjungs",
        "IsNewGroup": false,
        "LaunchFullUrl": null,
        "Size": 0,
        "TargetWindow": null,
        "Text": "Sharepointjungs",
        "Title": "Sharepointjungs",
        "Url": "http://www.sharepointjungs.de"
      },
      {
        "AriaLabel": null,
        "BackgroundColor": null,
        "BrandBarText": null,
        "CollectorId": null,
        "FontIconCss": null,
        "IconFullUrl": null,
        "Id": "Datenjungs",
        "IsNewGroup": false,
        "LaunchFullUrl": null,
        "Size": 0,
        "TargetWindow": null,
        "Text": "Datenjungs",
        "Title": "Datenjungs",
        "Url": "http://www.datenjungs.de"
      },
      {
        "AriaLabel": null,
        "BackgroundColor": null,
        "BrandBarText": null,
        "CollectorId": null,
        "FontIconCss": null,
        "IconFullUrl": null,
        "Id": "Sites",
        "IsNewGroup": false,
        "LaunchFullUrl": null,
        "Size": 0,
        "TargetWindow": null,
        "Text": "Websiteinhalte",
        "Title": "Websiteinhalte",
        "Url": "/_layouts/15/viewlsts.aspx"
      }
    ]
  },
  "SPSuiteVersion": 2
}

 

Wie man sieht, gibt es neben den „WorkloadLink“ noch weitere Bereiche, auf die ich hier aber vorerst nicht näher eingehe, da es hier nur um den ersten Aufbau des Service geht.

Jetzt ist die Lösung bereit für einen ersten Testlauf im SharePoint. Hierzu die Lösung entsprechend veröffentlicht und das dazugehörige Feature aktiviert werden. Da die SuiteLinks im Browser gecached werden, kann es sein, dass nach der Browser nach der Aktivierung des Features geschlossen und neu geöffnet werden muss, damit der gewünschte Effekt zu sehen ist.

Bugfix für die Verwendung eigener Bilder

In der bei der Entwicklung verwendeten SharePoint-Version gab es leider noch Probleme mit eigenen Bildern, die über „IconFullUrl“ angegben werden. Der Bug ist im SharePoint-Skript SuiteNav.js. Dort wird der Wert aus IconFullUrl nicht weiter verarbeitet. Da SharePoint an dieser Stelle mit IconFonts arbeitet, fällt dies im Standard nicht auf. Als Lösung für diesen Bug kann man die entsprechende Funktion per JavaScript, das über einen AdditionalPageHeader eingebunden wird, überschreiben.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
SP.SOD.executeFunc('suitenav.js', '_o365sg2c._navMenuMouseRenderer.renderLauncher', replaceFunction);
function replaceFunction() {
    // Cache für die Navigation leeren
    ClearSuiteLinksCache();

    _o365sg2c._navMenuMouseRenderer.renderLauncher = function (i) {
        var h;
        if (_o365sg2c.O365Shell._$$pf_ShellData$p.PinnedApps)
            h = _o365sg2c.O365Shell._$$pf_ShellData$p.PinnedApps;
        else {
            var c = [];
            if (_o365sg2c.O365Shell._$$pf_ShellData$p.WorkloadLinks) {
                var o;
                c = (o = c).concat.apply(o, _o365sg2c.O365Shell._$$pf_ShellData$p.WorkloadLinks);
            }
            if (_o365sg2c.O365Shell._$$pf_ShellData$p.AppsLinks) {
                var p;
                c = (p = c).concat.apply(p, _o365sg2c.O365Shell._$$pf_ShellData$p.AppsLinks);
            }
            if (_o365sg2c.O365Shell._$$pf_ShellData$p.AdminLink)
                c = c.concat(_o365sg2c.O365Shell._$$pf_ShellData$p.AdminLink);
            if (_o365sg2c.O365Shell._$$pf_ShellData$p.PartnerLink)
                c = c.concat(_o365sg2c.O365Shell._$$pf_ShellData$p.PartnerLink);
            h = new Array(0);
            for (var m = c, r = m.length, j = 0; j < r; ++j) {
                var d = m[j], a = {};
                a.Id = d.Id;
                a.Title = d.Text;
                a.AriaLabel = d.Title;
                a.BrandBarText = d.BrandBarText;
                a.IconFullUrl = d.IconFullUrl;
                if (!a.IconFullUrl) {
                    a.FontIconCss = "wf-o365-" + _o365sg2cm.ShellCoreMinIcons.getTileIcon(d.Id, _o365sg2c.O365Shell._$$pf_ClientData$p.IsConsumerShell)._$$pf_Name$p$0;
                }
                a.TargetWindow = d.TargetWindow;
                a.BackgroundColor = _o365sg2cm.ShellCoreMinIcons.getTileBackgroundColor(d.Id, _o365sg2c.O365Shell._$$pf_ClientData$p.IsConsumerShell);
                a.LaunchFullUrl = d.Url;
                h.push(a);
            }
        }
        for (var e = null, n = h, s = n.length, k = 0; k < s; ++k) {
            var a = n[k];
            a.Size = !_o365su.ScriptUtils.isNullOrUndefined(a.Size) ? a.Size : 2;
            if (a.Size === 1) {
                if (a.IsNewGroup || !e) {
                    e = document.createElement("div");
                    e.className = "o365cs-nav-appItem o365cs-nav-appItemGroup";
                    i.appendChild(e);
                }
            }
            else
                e = null;
            _o365sg2c._navMenuMouseRenderer._renderTileElement$p(e || i, a);
            if (a.Id === _o365sg2c.O365Shell._$$pf_ShellData$p.CurrentMainLinkElementID || "Shell" + a.Id === _o365sg2c.O365Shell._$$pf_ShellData$p.CurrentMainLinkElementID)
                _o365sg2c._o365BrandingMouseRenderer._$$pf_AppHeaderLink$p = a;
        }
        if (!_o365sg2c._o365BrandingMouseRenderer._$$pf_AppHeaderLink$p && _o365sg2c.O365Shell._$$pf_ClientData$p.AppHeaderLinkText) {
            var f = {};
            f.Id = "ShellAppHeader";
            f.Title = _o365sg2c.O365Shell._$$pf_ClientData$p.AppHeaderLinkText;
            f.LaunchFullUrl = _o365sg2c.O365Shell._$$pf_ClientData$p.AppHeaderLinkUrl;
            _o365sg2c._o365BrandingMouseRenderer._$$pf_AppHeaderLink$p = f;
        }
        if (_o365sg2c.O365Shell._$$pf_ClientData$p.MyAppsUrl) {
            var g = document.createElement("div");
            g.className = "o365cs-nav-navMenuMyApps";
            var b = _o365sg2c._controls.link();
            b.id = "ShellMyApps";
            _o365sg2c._domElementExtensions.addClass(b, "o365cs-nav-navMenuMyAppsLink ms-fcl-tp ms-fcl-ts-h");
            _o365sg2c._domElementExtensions.toggleClass(b, "o365cs-topnavText", _o365sg2c.O365Shell._$$pf_ClientData$p.ShowNewAppLauncher);
            _o365sg2c._domElementExtensions.href(b, _o365sg2c.O365Shell._$$pf_ClientData$p.MyAppsUrl);
            _o365sg2c._domElementExtensions.target(b, "_top");
            var q = _o365sg2c._controls.icon(_o365sg2cm.ShellCoreMinIcons.myApps);
            _o365sg2c._domElementExtensions.addClass(q, "o365cs-nav-navMenuMyAppsLinkIcon");
            b.appendChild(q);
            var l = document.createElement("span");
            l.className = "o365cs-nav-navMenuMyAppsLinkText";
            _o365sg2c._domElementExtensions.text(l, _s1.ShellG2Strings.l_ShellCore_NavMenu_MyApps_Text);
            b.appendChild(l);
            _o365sg2c._domElementExtensions.click(b, function () {
                _o365sg2c.O365Shell._logLinkClicked$i(b.id);
            }, false);
            _o365sg2c._navMenuMouseRenderer._setTabHandling$p(i, g, b);
            g.appendChild(b);
            i.appendChild(g);
        }
        O365.Log.writeShellLog(401057, 1, 1, 0);
    };
}

 

Entscheidend sind hier die Zeilen 31 bis 34, die gegenüber der Originalfunktion angepasst sind und den Wert für die IconUrl nun korrekt weitergeben, sodass dieser diese auch verwendet wird.

Leave a Comment

Your email address will not be published. Required fields are marked *