I’ve been working on an SPFx web part that allows users to edit content on the page without being a Contributor on the site. It’s basically a remake of the Out of the Box Text webpart but putting it all together had some interesting challenges that I want to share.

The focus of this blog is not on the exact solution for the text editor but on how the web part calls an Azure function to update it’s properties AND make the information that is displayed searchable.

What are the major problems?

The users using this web part are visitors. Visitors cannot update edit pages or list items. This web part allows them to edit the content of the web part BUT they can’t actually edit the page and they can’t actually edit update page content or list items.

This is why we need to incorporate the Azure Function. The Azure Function will perform the update using elevated privileges. This allows visitors to edit content on the page without having rights to do so normally.

Architecture

Below is the architecture used in the scenario. The Azure Function in this case is using a Azure AD app permissions, Sites.ReadWrite.All and authenticating via a certificate to access the SharePoint site and make the necessary changes.

SPFx Web Part

On the client side there is a standard SPFx web part that will call an Azure Function with a POST request and a body. This body will contain the content of the web part text. In order for the web part to maintain this information it needs to store this in the Web Part Properties. The web part contains a Web Part Property called Content that is a string value. This is what the web part displays.

Azure Function – PnP PowerShell

using namespace System.Net

# Input bindings are passed in via param block.
param($Request, $TriggerMetadata)

# Write to the Azure Functions log stream.
Write-Host "PowerShell HTTP trigger function processed a request."

$cert = "$($TriggerMetadata.FunctionDirectory)\\cert.pfx"
$cert_password = (ConvertTo-SecureString -String $env:CERT_PASSWORD -AsPlainText -Force)

# Parameters for the Webpart update 
$SiteURL = $Request.Query.SiteURL
$Page = $Request.Query.Page
$WebPartIdentity = $Request.Query.WebPartIdentity
$PropertyKey = $Request.Query.PropertyKey
$PropertyValue = $Request.Query.PropertyValue

$v = (Get-Module "PnP.PowerShell").Version
Write-Host $v

$SiteURL = $Request.Body.SiteURL
if (-not $SiteURL) {
    $errorMessage = "SiteURL parameter can not be empty. "
}
$Page = $Request.Body.Page
if (-not $Page) {
    $errorMessage += "Page parameter can not be empty. "
}
$WebPartIdentity = $Request.Body.WebPartIdentity
if (-not $WebPartIdentity) {
    $errorMessage += "WebPartIdentity parameter can not be empty. "
}
$PropertyKey = $Request.Body.PropertyKey
if (-not $PropertyKey) {
    $errorMessage += "PropertyKey parameter can not be empty. "
}
$PropertyValue = $Request.Body.PropertyValue
if (-not $PropertyValue) {
    $errorMessage += "PropertyValue parameter can not be empty. "
}
 
Write-Host "Request Data is ... "

if (-not $errorMessage) {
    try {
        Connect-PnPOnline -Url $SiteURL -ClientId $env:APP_CLIENT_ID -CertificatePath $cert -CertificatePassword $cert_password -Tenant $env:APP_TENANT_ID
        Write-Host "Successfully connected"
        $web = Get-PnPWeb
        $webTitle = $web.Title
        Write-Host "Web: $webTitle"

        $page = Get-PnPPage -Identity $page.Substring($page.LastIndexOf("/") + 1)

        $controls = $page.Controls | Where-Object { $WebPartIdentity -eq $_.Title -or $WebPartIdentity -eq $_.WebPartId -or $WebPartIdentity -eq $_.InstanceId }    

        $controls | ForEach-Object {                        
            Write-Host "Updating web part, Title: " $($_.Title) ", InstanceId: " $($_.InstanceId)
            try {
                $webpartJsonObj = ConvertFrom-Json $_.PropertiesJson

                if ($PropertyKey -and $PropertyValue) {
                    # Check if both PropertyKey and PropertyValue are arrays of the same length
                    if ($PropertyKey.Count -eq $PropertyValue.Count) {
                        for ($i = 0; $i -lt $PropertyKey.Count; $i++) {
                            $webpartJsonObj | Add-Member -MemberType NoteProperty -Name $PropertyKey[$i] -Value $PropertyValue[$i] -Force
                        }
                    }
                    else {
                        # Handle the case where the arrays have different lengths
                        $errorMessage = "PropertyKey and PropertyValue arrays must have the same number of elements. $($PropertyKey.Count) $($PropertyValue.Count)" 
                    }
                }
                else {
                    $errorMessage = "PropertyKey and PropertyValue parameters must be provided as arrays."
                }

                if (-not $errorMessage) {
                    $_.PropertiesJson = $webpartJsonObj | ConvertTo-Json
                    Write-Host "Web part properties updated!" -ForegroundColor Green
                    $body = "Web part properties updated!"
                }
                else {
                    Write-Host $errorMessage -ForegroundColor Red
                    $body = $errorMessage
                }
            }
            catch {       
                $errorMessage += "Failed updating web part, Title: $($_.Title), InstanceId: $($_.InstanceId), Error: $($_.Exception)"                    
                Write-Host "Failed updating web part, Title: $($_.Title), InstanceId: $($_.InstanceId), Error: $($_.Exception)"
            }
        }

        # Save the changes and publish the page
        Write-Host "Saving Page"
        $page.Save()
        $page.Publish()
        Write-Host "Saved Page"

        try {
            $item = Get-PnPListItem -Id $page.PageListItem.Id -List $page.PagesLibrary.Id -Fields "CanvasContent1"
            $canvasContent = $item["CanvasContent1"]
            
            # Load the HTML document using AngleSharp
            $parser = [AngleSharp.Html.Parser.HtmlParser]::new() 
            $document = $parser.ParseDocument($canvasContent)

            # Define the target element selector
            $targetElementSelector = "div[data-sp-webpartdata*='$($WebPartIdentity)'] div[data-sp-htmlproperties='']"

            # Find the target element
            $targetElement = $document.QuerySelector($targetElementSelector)

            $index = $PropertyKey.IndexOf("searchableTextString")
            if ($index -ne -1) {
                # Create a new div element
                $newDiv = $document.CreateElement("div")
                $newDiv.SetAttribute("data-sp-prop-name", "searchableTextString")
                $newDiv.SetAttribute("data-sp-searchableplaintext", "true")
                $newDiv.TextContent = $PropertyValue[$index]

                # Remove all child nodes from the target element
                if ($targetElement.Children.Length -ge 1) {
                    $targetElement.Children.Remove()
                }

                # Append the new div element to the target element
                $targetElement.AppendChild($newDiv)
            }  
            
            # Get the updated HTML content as a string
            $updatedHtmlContent = $document.DocumentElement.OuterHtml

            # Encode special characters
            $encodedString = $updatedHtmlContent -replace '{', '{' -replace '}', '}' -replace ':', ':' -replace [char]0x00A0, " "

            # Update the CanvasContent1 property with the encoded string
            $item["CanvasContent1"] = $encodedString

            $item.SystemUpdate()
            Invoke-PnPQuery
        }
        catch {
            $status = [HttpStatusCode]::BadRequest   
            $errorMessage += $_.Exception.Message
            Write-Host $_.Exception.Message
        }

        Write-Host "$($_) saved and published." -ForegroundColor Green 
        $body += " Page saved and published." 
        $status = [HttpStatusCode]::OK
    }
    catch {
        $status = [HttpStatusCode]::BadRequest   
        $errorMessage += $_.Exception.Message
        Write-Host $_.Exception.Message
    }
}

if (-not $errorMessage) {
    $message = @{
        message = $body | ConvertTo-Json -Compress
    }
} else {
    $message = @{
        message = $errorMessage | ConvertTo-Json -Compress
    }
    $status = [HttpStatusCode]::BadRequest
}

# Associate values to output bindings by calling 'Push-OutputBinding'.
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
    StatusCode = $status 
    Body       = $message | ConvertTo-Json -Compress
})

Issue – Updating Web Part Properties from the backend (PnP PowerShell) won’t make the content searchable

There is a bit of magic that happens when you edit a page through the UI. When you edit the page and publish, SharePoint will update a page property called CanvasContent1. In this property it contains web part properties for all web parts on the page as well as the content of the webparts. This holds key information in searchable text strings. All this magic happens when you use the product as designed but when you make an update via PnP PowerShell the CanvasContent1 will not be automagically updated.

This sample code demonstrates a method to update the web part and render a new CanvasContent1. By creating a new CanvasContent1 your content would now be searchable.

About the Author

Developer, Designer, Thinker, Problem Solver, Office Servers and Services MVP, & Collaboration Director @ SoHo Dragon.

View Articles