Month: December 2022

A SharePoint document generator as Microsoft 365 app II (SPFx)

A SharePoint document generator as Microsoft 365 app II (SPFx)

Recently I already wrote about Microsoft 365 across applications that can be ran in either Teams, Office (Microsoft 365) or Outlook. Since version 1.16 now it is also possible to develop and host those applications in SharePoint with SharePoint Framework (SPFx). So let’s dive into another sample application and show how this can be realized with SharePoint Framework (SPFx).

Series

Content

Setup

For the set up a standard react, SharePoint Framework web part needs to be scaffolded in the /teams folder where already two icons reside a manifest needs to be added manually. But therefore see the next section.

SharePoint content type

In a previous post, I already explained how to create a SharePoint content type either manually or in an automatic way. Here we want to use exactly this content type. So for details refer to the other post, but what’s essential is the placement of a custom document template inside hidden folder.

Teams manifest

With SharePoint Framework 1.16 it is not yet possible to create a Teams app manifest out of the box. This is usually done by pressing “Sync to Teams” in SharePoint app catalog. Currently the required version is not yet met, which is the manifest version 1.13 or above.

But it was always possible to create a manual app package for a SharePoint framework solution. The two necessary icons are already there and a Teams app manifest for a personal app only is created very quickly. The key part is the contentUrl of the staticTabs. Here the componentId has to match the id in the …WebPart.manifest.json

{
    "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.13/MicrosoftTeams.schema.json",
    "manifestVersion": "1.13",
    "id": "570b5f8e-de6f-4c82-9892-3d9f24897aa4",
    "version": "1.0.0",
    "packageName": "OfferCreationSpFxWebPart",
    "developer": {
      "name": "Markus Moeller",
      "websiteUrl": "https://mmsharepoint.wordpress.com",
      "privacyUrl": "https://mmsharepoint.wordpress.com",
      "termsOfUseUrl": "https://mmsharepoint.wordpress.com"
    },
    "name": {
      "short": "Offer Creation (SPFx)",
      "full": "Offer Creation (SPFx)"
    },
    "description": {
      "short": "A personal M365 app to create custom offer documents",
      "full": "A personal Microsoft 365 app to create custom offer documents"
    },
    "icons": {
      "outline": "570b5f8e-de6f-4c82-9892-3d9f24897aa4_outline.png",
      "color": "570b5f8e-de6f-4c82-9892-3d9f24897aa4_color.png"
    },
    "accentColor": "#D85028",
    "configurableTabs": [],
    "staticTabs": [
      {
        "entityId": "108eec64-bd4b-4095-a02a-38ad1b2f848c",
        "name": "Offer Creation",
        "contentUrl": "https://{teamSiteDomain}/_layouts/15/TeamsLogon.aspx?SPFX=true&dest=/_layouts/15/teamshostedapp.aspx%3Fteams%26personal%26componentId=570b5f8e-de6f-4c82-9892-3d9f24897aa4%26forceLocale={locale}",
        "scopes": [
          "personal"
        ]
      }
    ],

Here now the matching part of the …WebPart.manifest.json:

{
  "$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
  "id": "570b5f8e-de6f-4c82-9892-3d9f24897aa4",
  "alias": "OfferCreationSpFxWebPart",
  "componentType": "WebPart",
SPFx Teams package - 2 logos, a manual manifest and ZIPped together
SPFx Teams package – 2 logos, a manual manifest and ZIPped together

The Form

The form looks quite similar than in the previous yo teams solution. Only it is using FluentUI controls as included in SPFx by default. The functionality is the same. All controls handle their values in the state and on submit Button the values are sent to the backend.

Create Offer form in SPFx with FluentUI controls
Create Offer form in SPFx with FluentUI controls

SP service

To instantiate a custom offer document programmatically, four steps are necessary:

  1. Retrieve some configuration about the site where to store the offers
  2. Load the template
  3. Upload the new document
  4. Set the document’s metadata

For all these operations, the SharePoint rest API is used. Load the template, store as a document and adjust the metadata. This was the same in the previous post but here SPHttpClient is used. I am using SharePoint Rest API here because Microsoft Graph cannot access the file template in the hidden folder, yet (using a normal document in a standard library would work of course), and PnPJS doesn’t work well in a S-S-O solution like in the previous part. Here it would work brilliant but for comparability I stayed as close as possible.

Configuration with tenant properties

A site url is needed to know where to store the offer documents. This requests a configuration setting. In the Teams web application this simply can be stored in the app’s configuration settings. With SharePoint Framework this is a bit different. One option is to store it in the so-called tenant properties.

To identify the tenant as a good starting point the teamSiteDomain available from the teams context helps. This was already illustrated in the previous part but here it’s even more essential. In onInit getTeamSiteDomain is called which will first check if microsoftTeams SDK is available. If so, the app is running in either Teams, Office or Outlook. If not, it’s running in SharePoint only. In SharePoint, the site URL is directly grabbed from the property pane standard configuration instead. If this property is filled (only the case in SharePoint) it’s simply treated dominant.

protected onInit(): Promise<void> {
    return this.getTeamSiteDomain().then(domain => {
      this.teamSiteDomain = domain;
    });
  }
  private getTeamSiteDomain(): Promise<string> {
    if (!!this.context.sdks.microsoftTeams) { // running in Teams, office.com or Outlook
      return this.context.sdks.microsoftTeams.teamsJs.app.getContext()
        .then(context => {          
          return context.sharePointSite.teamSiteDomain;
        });
    }
    const uri = new URL(this.context.pageContext.site.absoluteUrl);
    return Promise.resolve(uri.host);
}

Having the teamSiteDomain or SharePoint tenant url (and not a siteUrl respectively a blank one) next the url of the tenant app catalog can be retrieved. Having that, finally the corresponding tenant property can be retrieved. That is, the siteUrl where the offers shall be stored in.

private async getSiteUrl(tenantUrl: string): Promise<string> {
    const requestUrl: string = `${tenantUrl}/_api/SP_TenantSettings_Current`;
    const response = await this._spHttpClient.get(requestUrl, SPHttpClient.configurations.v1);
    const jsonResp = await response.json();
    const appCatalogUrl: string = jsonResp.CorporateCatalogUrl;
    if (appCatalogUrl && appCatalogUrl.length > 8) {
      const apprequestUrl: string = `${appCatalogUrl}/_api/web/GetStorageEntity('CreateOfferSiteUrl')`;
      const appResponse = await this._spHttpClient.get(apprequestUrl, SPHttpClient.configurations.v1);
      const jsonAppResp = await appResponse.json();
      const siteUrl: string = jsonAppResp.Value;
      return siteUrl
    }
    return "";
}

A personal configuration is also possible with SPFx but this is shown in another part of this blog series.

Load the template

This and the next section are quite similar than in the previous post. The same SharePoint rest API calls are executed. Still using OpenBinaryStream() and arrayBuffer() to retrieve the template from the hidden _cts folder where typiucally content type templates reside.

private async loadTemplate (offer: IOffer): Promise<IFile> {
    const requestUrl: string = `${this.teamSiteUrl}/_api/web/GetFileByServerRelativeUrl('${this.teamSiteRelativeUrl}/_cts/Offering/Offering.dotx')/OpenBinaryStream()`;
    const response = await this._spHttpClient.get(requestUrl, SPHttpClient.configurations.v1);
    const fileBlob = await response.arrayBuffer();
    const respFile = { data: fileBlob, name: `${offer.title}.docx`, size: fileBlob.byteLength };
    return respFile;
}

Store as new document

Nothing special in the file storage function anymore. The only result of interest is the file’s ServerRelativeUrl:

pprivate async createOfferFile(tmplFile: IFile): Promise<string> {
    const uploadUrl = `${this.teamSiteUrl}/_api/web/GetFolderByServerRelativeUrl('${this.teamSiteRelativeUrl}/Shared Documents')/files/add(overwrite=true,url='${tmplFile.name}')` ;

    const spOpts : ISPHttpClientOptions  = {
      headers: {
        "Accept": "application/json",
        "Content-Length": tmplFile.size.toString(),
        "Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
      },
      body: tmplFile.data        
    };
    const response = await this._spHttpClient.post(uploadUrl, SPHttpClient.configurations.v1, spOpts);
    const jsonResp = await response.json();
    return jsonResp.ServerRelativeUrl;
}

File and metadata operations

As till here only the file was dealt with now it’s time to switch to the ListItem with metadata. This works well with the /ListItemAllFields endpoint of the file. Needed for an update next are itemID and @odata.type.

private async getFileListItem(fileName: string): Promise<any> {
    const requestUrl = `${this.teamSiteUrl}/_api/web/GetFileByServerRelativeUrl('${this.teamSiteRelativeUrl}/Shared Documents/${fileName}')/ListItemAllFields`;
    const response = await this._spHttpClient.get(requestUrl, SPHttpClient.configurations.v1);
    const jsonResp = await response.json();
    const itemID = jsonResp.ID;
    return { id: itemID, type: jsonResp["@odata.type"].replace('#', '') }; 
}
private async updateFileListItem(itemID: string, itemType: string, offer: IOffer): Promise<any> {
    const requestUrl = `${this.teamSiteUrl}/_api/web/lists/GetByTitle('Documents')/items(${itemID})`;
    const spOpts : ISPHttpClientOptions  = {
      headers: {
        "Content-Type": "application/json;odata=verbose",
        "Accept": "application/json;odata=verbose",
        "odata-version": "3.0",
        "If-Match": "*",
        "X-HTTP-Method": "MERGE"
      },
      body: JSON.stringify({
        "__metadata": {
            "type": itemType
        },
        "Title": offer.title,
        "OfferingDescription": offer.description,
        "OfferingVAT": offer.vat,
        "OfferingNetPrice": offer.price,
        "OfferingDate": offer.date
      })
    };
    const response = await this._spHttpClient.post(requestUrl, SPHttpClient.configurations.v1, spOpts);
    if (response.status === 204) {
      return Promise.resolve();
    }
    else {
      return Promise.reject();
    }    
  }

Finally the metadata values can be updated with the values collected in the form. Having the itemID and @odata.type there is no big surprise anymore. Maybe only to highlight the "X-HTTP-Method": "MERGE" inside the Header of the POST request. (For those, like me, who do not deal with SP Rest API daily, anymore)

All in all the app in action looks like this:

In this post the key parts of the solution were highlighted and illustrated. Nevertheless for your reference you can find the whole solution in my GitHub repository. As mentioned above I will come back with a next post on how to personally configure this web part when used as a M365 application (in Teams, Outlook, Office) with another web part dedicated for configuration only. Also I will extend the parallel yo teams sample with a search-based messaging extension for further steps inside Teams or Outlook, which unfortunately currently has no pendant in SPFx.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in M365 Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.
A SharePoint document generator as Microsoft 365 app I (yoteams)

A SharePoint document generator as Microsoft 365 app I (yoteams)

Recently I already wrote about Microsoft 365 across applications that can be run in either Teams, Office (Microsoft 365) or Outlook. Since version 1.16 now it is also possible to develop and host those applications in SharePoint with SharePoint Framework (SPFx). So let’s dive into another sample application and see how this can be realized with either a Teams dev application, or based on SharePoint Framework (SPFx). Let’s start here with the native teams alternative.

Series

Content

Setup

At first a solution needs to be scaffolded with the yeoman generator for Teams as one option:

Generate a personal tab and search-based messaging extension with yo teams
Generate a personal tab and search-based messaging extension with yo teams

It is important to use the generator version 4 or above which includes TeamsJS SDK version 2, and to select a manifest version 1.13 or above. We pick a personal tab as base application, and the search-based messaging extension for a later scenario. Only those types can currently be used across different Microsoft 365 products.

SharePoint content type

In a previous post, I already explained how to create a SharePoint content type either manually or in an automatic way. Here we want to use exactly this content type. So for details refer to the other post, but what’s essential is the placement of a custom document template inside the hidden _cts folder.

SSO for SharePoint Rest API

I already wrote about generating an SSO token for SharePoint Rest API in Teams applications. But there I needed a token on top and not exclusively. So here we are not using a refresh token, but simply the standard on-behalf-of flow.

const tokenResult = await exchangeForToken(teamSiteDomain.toLowerCase().replace('sharepoint', 'onmicrosoft'),
            req.header("Authorization")!.replace("Bearer ", "") as string,
            `https://${teamSiteDomain}/AllSites.Write`);
...
const exchangeForToken = (tenantName: string, token: string, scope: string): Promise<{accessToken: string}> => {
    return new Promise((resolve, reject) => {
      const url = `https://login.microsoftonline.com/${tenantName}/oauth2/v2.0/token`;
      const params = {
        client_id: process.env.TAB_APP_ID,
        client_secret: process.env.TAB_APP_SECRET,
        grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
        assertion: token,
        requested_token_use: "on_behalf_of",
        scope: scope
      };
      Axios.post(url,
        qs.stringify(params), {
        headers: {
            "Accept": "application/json",
            "Content-Type": "application/x-www-form-urlencoded"
        }
      }).then(result => {
        if (result.status !== 200) {
          reject(result);
        } else {
          resolve({accessToken: result.data.access_token});
        }
      }).catch(err => {
        // error code 400 likely means you have not done an admin consent on the app
        log(err);
        reject(err);
      });
    });
};

The preparation and implementation is very close to the generation of a graph access token described in one of my previous posts. The only difference is the scope which requests a dedicated SharePoint permission and makes the token usable for SharePoint Rest API.

I am using SharePoint Rest API here because Microsoft Graph cannot access the file template in the hidden folder, yet (using a normal document in a standard library would work of course), and the transformation to a SharePoint framework solution is much easier, too. Also, unfortunately, I currently see no option to authenticate in PnPJS with a given O-B-O access token.

Client-side components

Client-side there is the normal Tab component, which holds the functionality, and includes the Form component, which holds the UI controls. This will later also help to reproduce the same solution in SPFx.

React components for create offer in VSCode
React components for create offer process

Let’s start with the form. It uses various FluentUI Northstar components such as text and number Input fields, a Dropdown or a Textarea. All handle their values in the state and on submit Button the values are sent to the backend.

Form to create a custom offer document
Form to create a custom offer document

From the form component a callback function coming from the parent Tab component is called and the offer object to be created consisting of all the controls’ values is handed in.

<Button onClick={storeData}>Create Offer</Button>
... 
 const storeData = useCallback(() => {
    const newOffer: IOffer = {
      title: title ? title : '',
      description: description ? description : '',
      date: date ? date : '',
      price: price ? price : 0,
      vat: vat ? vat : 0
    };
    props.createOffer(newOffer);
  }, [title, description, date, price,vat]);
const createOffer = (offer: IOffer) => {
    if (idToken) {
      setShowSpinner(true);
      const requestBody = {
        domain: context?.sharePointSite?.teamSiteDomain,
        offer: offer
      };
      Axios.post(`https://${process.env.PUBLIC_HOSTNAME}/api/createoffer`, requestBody, {
                  responseType: "json",
                  headers: {
                    Authorization: `Bearer ${idToken}`
                  }
      }).then(result => {
        if (result.data.fileUrl) {
          setOfferCreated(true);
        }     
      })
      .catch((error) => {
        console.log(error);
      })
      .finally(() => {
        setShowSpinner(false);
      });
    }
};

Teams context

Although the app is a personal tab and not running in a specific context of a team / SharePoint site, we can get an interesting detail from the context:

context.sharePointSite.teamSiteDomain

While other attributes of sharePointSite such as teamSiteId, teamSitePath or teamSiteUrl are not filled in a personal Tab without direct context of a Team / Site, teamSiteDomain still is (as this is more tenant-related) and this can be helpful. Also, information about the user’s my-site is available here.

Copy template to document

To instantiate a custom offer document programmatically, three steps, are necessary:

  1. Load the document template from the hidden folder
  2. Copy it as document to the shared documents library
  3. Set the metadata to the corresponding form controls’ values

Everything should be ready for the first SP rest API request, such as access token, site URL and namings. The first request is for a blob file in SharePoint’s and hidden _CTS site folder.

const loadTemplate = async (spoAccessToken: string, offer: IOffer, siteUrl: string, teamSiteRelativeUrl: string): Promise<any> => {
    const requestUrl: string = `${siteUrl}/_api/web/GetFileByServerRelativeUrl('${teamSiteRelativeUrl}/_cts/Offering/Offering.dotx')/OpenBinaryStream()`;
    return Axios.get(requestUrl,
      {
        responseType: 'arraybuffer', // no 'blob' as 'blob' only works in browser
        headers: {          
            Authorization: `Bearer ${spoAccessToken}`
        }
      })
      .then(response => {
        const respFile = { data: response.data, name: `${offer.title}.docx`, size: response.data.length };
        return respFile;      
      }).catch(err => {
        log(err);
      });
  };

Two things are mentionable here. The OpenBinaryStream() endpoint requesting the file addressed by name and the responseType: ‘arraybuffer’ which is necessary in backend applications outside the browser.

Having the template file as a stream, it can be uploaded as a new document to the target site’s standard document library.

const createOfferFile = async (spoAccessToken: string, siteUrl: string, teamSiteRelativeUrl: string, file: any): Promise<any> => {
    const uploadUrl = `${siteUrl}/_api/web/GetFolderByServerRelativeUrl('${teamSiteRelativeUrl}/Shared Documents')/files/add(overwrite=true,url='${file.name}')` ;
    return Axios.post(uploadUrl, file.data,
      {
        headers: {          
            Authorization: `Bearer ${spoAccessToken}`,
            "Content-Length": file.size,
            "Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
        }
      })
      .then(response => {
        return response.data;      
      }).catch(err => {
        log(err);
      });
};

The file name is created from the offer’s metadata. Additionally, the content-type in the header of the request is important.

To finally set the metadata of the file’s list item there is a need for additional information. This can be requested with the /ListItemAllFields endpoint of the file first.

const getFileListItem = async (spoAccessToken: string, siteUrl: string, teamSiteRelativeUrl: string, fileName: string): Promise<any> => {
    const requestUrl = `${siteUrl}/_api/web/GetFileByServerRelativeUrl('${teamSiteRelativeUrl}/Shared Documents/${fileName}')/ListItemAllFields`;
    return Axios.get(requestUrl,
      {
        headers: {          
            Authorization: `Bearer ${spoAccessToken}`
        }
      })
      .then(response => {
        const itemID = response.data.ID;
        return { id: itemID, type: response.data["odata.type"] }; 
      }).catch(err => {
        log(err);
      });
};

Now, having the item ID and type it is possible to update the file’s list item with the corresponding metadata requested in the form.

const updateFileListItem = async (spoAccessToken: string, siteUrl: string, teamSiteRelativeUrl: string, itemID: string, itemType: string, offer: IOffer) => {
    const requestUrl = `${siteUrl}/_api/web/lists/GetByTitle('Documents')/items(${itemID})`;
    const requestBody = {
      "__metadata": {
          "type": itemType
      },
      "Title": offer.title,
      "OfferingDescription": offer.description,
      "OfferingVAT": offer.vat,
      "OfferingNetPrice": offer.price,
      "OfferingDate": offer.date
    };
    return Axios.post(requestUrl, requestBody,
      {
        headers: {          
            Authorization: `Bearer ${spoAccessToken}`,
            "Content-Type": "application/json;odata=verbose",
            "Accept": "application/json;odata=verbose",
            "If-Match": "*",
            "X-HTTP-Method": "MERGE"
        }
      })
      .then(response => {
        return response.data;      
      }).catch(err => {
        log(err);
      });
  };

All in all the app in action looks like this:

In this post the key parts of the solution were highlighted and illustrated. Nevertheless for your reference you can find the whole solution in my GitHub repository. I hope this sample was valuable for you. Next we gonna have a look how to achieve quite the same with the SharePoint framework. Furthermore this solution can also be extended by a search-based messaging extension to select and send such offer documents in Outlook for a review or something similar.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in M365 Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.