Org chart with a twist

A customer really liked Microsoft’s Org chart web part in SharePoint. However, they needed to be able to display data from the manager-field as well as a custom field for line-manager combined in a single view. I also found great joy being able to create a menu for switching between the different departments without leaving the page.

Their data comes from a couple of sources, the data source for this example comes from an extension attribute in Entra ID/Azure AD.

In the solution I use Modern PnP Search for the front end and an Automation Runbook in an Azure Subscription for moving data to the SharePoint User profiles.

When the customer needs to change or add a manager, they have to enter the manager’s e-mail address in the Search Query box, or as the above screen shot demonstrates, use a Vertical connected to the input query text as a menu for each department. This solution also has clickable contacts in the mgt-person hover menu, so that you can deep dive to the resources reporting to the manager you’ve selected.

Search Query

Replace with managed properties you want to use.

Manager:"{SearchTerms}" OR FunctionalManager:"{SearchTerms}" -UserName:"{SearchTerms}"

Modern PNP Search template

I use mgt-person quite a lot to make this happen, with some funky CSS-styling in order to get the right look and feel.

<content id="data-content">
    <style>
        .template--header {
            flex-wrap: wrap;
            display: flex;
            justify-content: center;
        }
        mgt-person {
            --initials-background-color: {{@root.theme.palette.themePrimary}};
        }
        .Manager {
            display: block;
            margin-left: auto;
            margin-right: auto;
            padding: 8px;
            width: 380px;
            height: 50px;
            background-color: white;
            border: solid;
            border-width: 1px;
            border-color: {{@root.theme.palette.neutralQuaternary}};
            border-radius: 5px;
        }
        .counter {
            font-size: 12px;
            margin-left: 5px;
            padding: 5px;
        }

        .reportsto {
            --avatar-size: 0px;
            --font-size: 12px;
            --font-weight: 400;
            --color:black;
            margin-left: -5px;
        }

        .employees {
            flex-wrap: wrap;
            display: flex;
            justify-content: space-between;
            padding: 5px;
        }
        .template--defaultList {
            padding: 5px;
            background-color: {{@root.theme.palette.neutralLighterAlt}};
            border: solid;
            border-width: 1px;
            border-color: {{@root.theme.palette.neutralQuaternary}};
            border-radius: 5px;
        }
        .template--peopleListItem {
            width: 300px;
        }
    </style>

    <div class="template">
        <div class="Manager">
                    <mgt-person person-query="{{getUserEmail inputQueryText}}" fallback-details='{"displayName": "Searching for user."}' view="twoLines" line2-property="jobTitle" person-card="hover">
                    </mgt-person-card><br>
        </div></br>
            {{#if data.items}}
        <div class="template--header">
        <div class="template--defaultList">
            <div class="counter">
            People reporting to <mgt-person class="reportsto" person-query="{{getUserEmail inputQueryText}}" view="oneLine"></mgt-person> ({{@root.data.totalItemsCount}}) 
            </div>
            {{/if}}
            <ul class="employees">
                {{#each data.items as |item|}}
                    <pnp-select 
                        data-index="{{@index}}"
                        <template id="content">
                                {{#> resultTypes item=item}}
                            <li class="template--peopleListItem" tabindex="0">
                                        <mgt-person user-id="{{getUserEmail (slot item @root.slots.PersonQuery)}}" person-card="hover">
                                            <template>
                                                <pnp-persona 
                                                    data-image-url="\{{personImage}}" 
                                                    data-fields-configuration="{{JSONstringify @root.properties.layoutProperties.peopleFields}}" 
                                                    data-item="{{JSONstringify item}}" 
                                                    data-persona-size="small"
                                                    data-theme-variant="{{JSONstringify @root.theme}}"
                                                    data-instance-id="{{@root.instanceId}}"
                                                    data-context="{{JSONstringify @root}}">
                                                </pnp-persona>
                                            </template>
                                            <template data-type="person-card">
                                                <mgt-person-card inherit-details>
                                                    <template data-type="additional-details">
                                                        <a href="https://tenant.sharepoint.com/sites/OurPeople/SitePages/View-manager.aspx?m={{getUserEmail UserName}}"><h3>Direct reports</h3>
                                                    </a>
                                                    </template>
                                                </mgt-person-card>
                                            </template>
                                        </mgt-person>
                                    
                                {{/resultTypes}}
                            </li>
                        </template>
                    </pnp-select>
                {{/each}}
            </ul>
        </div>
    </div>
</content>

Azure Automation

The automation runs in 7.2 with the Graph Authentication 2 and Graph Users 2 (important), so that we can autenticate with a managed identity, which is absolutely great!

What we basically do is moving all user data in the extension attribute over to SharePoint User profile field SPS-dotted-line, which is a people picker field.
The properties are stored in a temp file on a site called IntranetHelper, which should be restricted to admins.

Remember to give the automation the proper credentials. (Sites.FullControl.All is probably a bit much, but the Automation account in this customer story also does a bit of lifting where these privileges are needed.)

Add-PnPAzureADServicePrincipalAppRole -Principal “GUID-GUID-GUID-GUID“-AppRole "Directory.Read.All" -BuiltInType MicrosoftGraph

Add-PnPAzureADServicePrincipalAppRole -Principal “GUID-GUID-GUID-GUID“-AppRole "Sites.FullControl.All" -BuiltInType SharePointOnline

Add-PnPAzureADServicePrincipalAppRole -Principal “GUID-GUID-GUID-GUID“ -AppRole “User.Read.All” -BuiltInType SharePointOnline

Over to the automation, which also has a dryrun-parameter where you can test with a single user (replace name in code).

[CmdletBinding()]
param (
    [Parameter()]
    [string]
    $TenantUrl = "https://TENANTNAME.sharepoint.com",
    [Parameter()]
    [string]
    $ServerRelativePathToStoreUserPropertiesFile = "/sites/IntranetHelper",
    [Parameter(Mandatory = $false)]
    [bool]$DryRun
)

$ErrorActionPreference = "Stop"

Import-Module -Name Microsoft.Graph.Authentication -MinimumVersion 2.0.0
Import-Module -Name Microsoft.Graph.Users -MinimumVersion 2.0.0

$Url = [System.Uri]$TenantUrl
$TenantAdminUrl = "https://" + $Url.Authority.Replace(".sharepoint.com", "-admin.sharepoint.com")

Write-Output "Connecting to Microsoft Graph"
Connect-MgGraph -Identity

Write-Output "Retrieving all users from Microsoft Graph"
if ($DryRun) {
    $RelevantUserProps = Get-MgUser -Property Id,DisplayName,Mail,UserPrincipalName,UserType,OnPremisesExtensionAttributes -Filter "startsWith(DisplayName, 'REPLACE WITH USER NAME TO TEST WITH')" | Where-Object {$null -ne $_.OnPremisesExtensionAttributes.ExtensionAttribute7} | ForEach-Object {
        @{"UserPrincipalName"=$_.UserPrincipalName;"ExtensionAttribute7"=$_.OnPremisesExtensionAttributes.ExtensionAttribute7}
    }
} else {
    $RelevantUserProps = Get-MgUser -Property Id,DisplayName,Mail,UserPrincipalName,UserType,OnPremisesExtensionAttributes -All | Where-Object {$null -ne $_.OnPremisesExtensionAttributes.ExtensionAttribute7} | ForEach-Object {
        @{"UserPrincipalName"=$_.UserPrincipalName;"ExtensionAttribute7"=$_.OnPremisesExtensionAttributes.ExtensionAttribute7}
    }
}

Write-Output "Converting all relevant properties to JSON"
$InputObject = @{"value"=$RelevantUserProps}
($InputObject | ConvertTo-Json) | Out-File ".\properties.json" -Encoding utf8 -Force -NoNewline

Connect-PnPOnline -Url $($TenantUrl + $ServerRelativePathToStoreUserPropertiesFile) -ManagedIdentity

$UploadedFile = Add-PnPFile -Path ".\properties.json" -Folder "Shared documents"
$UrlToFile = $TenantUrl + $UploadedFile.ServerRelativeUrl
Write-Output "JSON-file generated and uploaded to $UrlToFile"

Connect-PnPOnline -Url $TenantAdminUrl -ManagedIdentity

Write-Output "Scheduling UPA Bulk Import Job"
if (-not $DryRun) {
    $ImportJob = New-PnPUPABulkImportJob -IdProperty "UserPrincipalName" -IdType PrincipalName -UserProfilePropertyMapping @{"ExtensionAttribute7"="SPS-Dotted-line"} -Url $UrlToFile
    
    Write-Output "Bulk user profile import job scheduled with ID " $ImportJob.JobId
    Write-Output "You can check status of the import by running 'Get-PnPUPABulkImportStatus -JobId $($ImportJob.JobId)'"
} else {
    Write-Output "Skipping Bulk import because -DryRun was specified"
}
Write-Output "Bulk import scheduled and script completed"

Remember to map both the crawled properties for Manager and SPS-Dotted-line from the user profile service to respective managed properties and reindex the user profiles.

That’s basically it. Good luck with your solutions!


Posted

in

, , ,

by

Tags:

Comments

Leave a Reply

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