Use SPFx for Task Modules in Teams Messaging Extensions and access Microsoft Graph

Use SPFx for Task Modules in Teams Messaging Extensions and access Microsoft Graph

Since SPFx 1.11 you can also use SharePoint Framework for task modules in Teams messaging extensions. This further simplifies the authentication mechanism to access Microsoft Graph or other 3rd party APIs. In my last post I showed how to implement an action based messaging extension. Here is the alternative using SPFx components.

My use case scenario is:
Assume you have some documents out there that need a review from time to time and those that are “due” you wanna share inside Microsoft Teams. So a user can select inside his channel from all retrieved due documents to post them as a card into the current channel. Furthermore any other user in that channel (assuming access rights) can mark them as ‘reviewed’ for another period.

Messaging Extension Task module to select a document
Messaging Extension Task module to select a document

Contents

Setup

At first there is the need for two solutions. One SPFx webpart solution and a small Teams bot solution. The webpart can be setup the following:

Document select task module in yo @microsoft/sharepoint

There is a need for SharePoint Framework version 1.11 but no need to use (–plusbeta) here. Additionally the yeoman generator for Teams creates a small bot solution, nothing special cause most of the logic takes place in the SPFx part:

yo teams for a small bot solution

To establish the initial Messaging extension the teams manifest.json (THE ONE in the SPFx solution!) needs to be configured manually. In my github repo there is also a sample (v1.6) with placeholders that show the configurable points.

"bots": [
    {
      "botId": "{{Bot-AppID}}",
      "needsChannelSelector": false,
      "isNotificationOnly": false,
      "scopes": [
        "team"
      ]
    }
  ],
  "composeExtensions": [
    {
      "botId": "{{Bot-AppID}}",
      "canUpdateConfiguration": true,
      "commands": [
        {
          "id": "docReview",
          "type": "action",
          "title": "Doc Review Action Extension",
          "description": "{Extension description}",
          "initialRun": false,
          "fetchTask": false,
          "context": [
            "commandBox",
            "compose"
          ],
          "taskInfo": {
            "title": "Documents to review",
            "width": "1100",
            "height": "665",
            "url": "https://{teamSiteDomain}/_layouts/15/TeamsLogon.aspx?SPFX=true&dest=/_layouts/15/teamstaskhostedapp.aspx%3Fteams%26personal%26componentId={componentID}%26forceLocale={locale}"
          }
        }
      ]
    }
  ],
  "validDomains": [
    "*.login.microsoftonline.com",
    "*.sharepoint.com",
    "spoprod-a.akamaihd.net",
    "resourceseng.blob.core.windows.net"
  ],

Three important parts here:

  1. A bot channel needs to be created. Part I of my series shows how to create it, but no need here for a CustomGraphConnection.
  2. The composeExtension once again references the bot but also defines the task module in the “taskInfo” part.
    1. The {teamSiteDomain} will be automatically replaced at runtime here, no need to overwrite.
    2. The {componentID} is the most essential part: It points to the ID of the SPFx webpart so can be found in the webpart manifest (not to mixup with the solution id inside the package-solution.json!)
    3. The {locale} again will be automatically replaced at runtime here, no need to overwrite.
  3. The “validDomains” also need to include some Url from CDNs here to load SPFx components e.g.

Once the manifest is done it can be zipped in a file together with both created icons in the \teams folder. Run gulp bundle and gulp package-solution and install the .sppkg file in the tenant’s app catalog. (Best is to install the solution tenant-wide cause otherwise the task module might not work, as it needs to be present in any Team using it but also in SP’s root site (see Url in taskInfo above)).
Then install the zipped file as a Teams app in your tenant and add it to your Team. Now the messaging extension would already be available in Teams and render the classic ‘Hello World’ webpart after click on:

Invoke Teams Messaging Extension

But no need to show the simple ‘Hello World’ stuff. Instead lets have a look on the detailed implementation.

The “Select” task module

In the root class of the webpart some properties need to be handed over to the root react component:

export default class DocReviewSelectWebPart extends BaseClientSideWebPart<IDocReviewSelectWebPartProps> {
  private isTeamsMessagingExtension;

  protected onInit(): Promise<void> {
    initializeIcons(); // Not needed inside SharePoint but inside Teams!
    this.isTeamsMessagingExtension = (this.context as any)._host && 
                                      (this.context as any)._host._teamsManager &&
                                      (this.context as any)._host._teamsManager._appContext &&
                                      (this.context as any)._host._teamsManager._appContext.applicationName &&
                                      (this.context as any)._host._teamsManager._appContext.applicationName === 'TeamsTaskModuleApplication';    
    return Promise.resolve();                                      
  }

  public render(): void {
    const element: React.ReactElement<IDocReviewSelectProps> = React.createElement(
      DocReviewSelect,
      {
        serviceScope: this.context.serviceScope,
        siteUrl: this.context.pageContext.site.absoluteUrl,
        isTeamsMessagingExtension: this.isTeamsMessagingExtension,
        teamsContext: this.context.sdks.microsoftTeams
      }
    );

    ReactDom.render(element, this.domElement);
  }
...
}

The serviceScope, a siteUrl and the teamsContext for use with the sdk are needed later. But it also can be already detected if the code is running inside the context of a Messaging Extension. This takes place in onInit here and is handed “down” as boolean result only.

The react part of the webart now renders as a FunctionComponent with hooks.

const DocReviewSelect: React.FunctionComponent<IDocReviewSelectProps> = (props) => {
  const [documents, setDocuments] = useState([] as IDocument[]);
  const columns = [
    { key: 'columnPre', name: '', fieldName: 'urgent', minWidth: 12, maxWidth: 12, isResizable: false },
    { key: 'column1', name: 'Name', fieldName: 'name', minWidth: 60, maxWidth: 150, isResizable: true },
    { key: 'column2', name: 'Description', fieldName: 'description', minWidth: 100, maxWidth: 150, isResizable: true },
    { key: 'column3', name: 'Created by', fieldName: 'author', minWidth: 100, maxWidth: 200, isResizable: true },
    { key: 'column4', name: 'Next Review', fieldName: 'nextReview', minWidth: 50, maxWidth: 100, isResizable: true },
    { key: 'column5', name: 'Url', fieldName: 'url', minWidth: 100, maxWidth: 200, isResizable: true }
  ];

  const getDocsForReview = async () => {
    const graphService: GraphService = new GraphService();
    graphService.initialize(props.serviceScope, props.siteUrl)
      .then(() => {
        return graphService.getDocumentsForReview()
        .then((docs) => {
          setDocuments(docs);
        });     
      });
  };

  useEffect(() => {
    if (documents && documents.length < 1) {
      getDocsForReview();        
    }
  });
... omitted for brevity
  const docSelected = (item: any): void => {    
    if (props.isTeamsMessagingExtension) {
      props.teamsContext.teamsJs.tasks.submitTask(item);
    }
  };

  return (
    <div className={ styles.docReviewSelect }>
      <div className={ styles.container }>
        <div className={ styles.row }>
        <DetailsList compact={true}
            items={documents}
            columns={columns}
            onRenderItemColumn={renderItemColumn}
            onRenderRow={renderRow}
            setKey="set"
            layoutMode={DetailsListLayoutMode.justified}
            onItemInvoked={docSelected} />
        </div>
      </div>
    </div>
  );
  
};

export default DocReviewSelect;

The full file is available in my github repo but the essential points are

  • With a simple Effect hook the docs are loaded from a service and set to the State (hook)
  • The docs are rendered in a FluentUI Details list
  • Once an item is selected (double click here) the corresponding function ‘docSelected’ submits a TaskActivity and this is where the connection to the bot and the corresponding (backend) solution takes place …

The bot SubmitTask

In the bot there is only the need to edit two files:
In the .env file the bot app ID and it’s corresponding secret as well as the hostname (ngrok for temp access here)

# The domain name of where you host your application
HOSTNAME=0110ece162f3.ngrok.io
# App Id and App Password for the Bot Framework bot
MICROSOFT_APP_ID=00000000-0000-0000-0000-000000000000
MICROSOFT_APP_PASSWORD=

And the whole code takes place in the app\<YourBotName>\<YourBotName>.ts
Once the above submitTask(item) function was called, it arrives in the following backend function:

protected async handleTeamsMessagingExtensionSubmitAction(context: TurnContext, action: MessagingExtensionAction): Promise<any> {
    const docCard = CardFactory.adaptiveCard(
      {
        type: "AdaptiveCard",
        body: [
          {
            type: "ColumnSet",
            columns: [
                {
                  type: "Column",
                  width: 25,
                  items: [
                    {
                      type: "Image",
                      url: `https://${process.env.HOSTNAME}/assets/icon.png`,
                      style: "Person"
                    }
                  ]
                },
                {
                  type: "Column",
                  width: 75,
                  items: [
                    {
                      type: "TextBlock",
                      text: action.data.name,
                      size: "Large",
                      weight: "Bolder"
                    },
                    {
                      type: "TextBlock",
                      text: action.data.description,
                      size: "Medium"
                    },
                    {
                      type: "TextBlock",
                      text: `Author: ${action.data.author}`
                    },
                    {
                      type: "TextBlock",
                      text: `Modified: ${action.data.modified}`
                    }
                  ]
                }
            ]
          }
        ],
        actions: [
          {
              type: "Action.OpenUrl",
              title: "View",
              url: action.data.url
          },          
          {
            type: "Action.Submit",
            title: "Reviewed",
            data: {
              item: action.data,
              msteams: {
                type: "task/fetch"
              }  
            } 
                 
          }
        ],
        $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
        version: "1.0"
      });
    const response: MessagingExtensionActionResponse = {
      composeExtension: {
        type: 'result',
        attachmentLayout: 'list',
        attachments:  [ docCard ]
      }
    }
    return Promise.resolve(response);
  }

Using parts of the item, that is the selected document element an AdaptiveCard is created. The most important point is the “Action.Submit” for executing the “Reviewed” activity which also retrieves the item. Beyond that the “msteams” part inside data classifies a “task/fact” action. This will later trigger another function inside the bot. But first the result of our card is here

Document for review in an Adaptive Card

So once the user (or another one) clicks on the “Reviewed” button a fetch task is executed and the bot will route it to the following function:

protected handleTeamsTaskModuleFetch(context: TurnContext, value: any): Promise<any> {
    const componentID = '75f1c63b-e3d1-46b2-957f-3d19a622c463';
    const itemID = value.data.item.key;
    return Promise.resolve({
      task: {
        type: "continue",
        value: {
          title: "Mark document as reviewed",
          height: 500,
          width: "medium",
          url: `https://{teamSiteDomain}/_layouts/15/TeamsLogon.aspx?SPFX=true&dest=/_layouts/15/teamstaskhostedapp.aspx%3Fteams%26personal%26componentId=${componentID}%26forceLocale={locale}%26itemID=${itemID}`,
          fallbackUrl: `https://{teamSiteDomain}/_layouts/15/TeamsLogon.aspx?SPFX=true&dest=/_layouts/15/teamstaskhostedapp.aspx%3Fteams%26personal%26componentId=${componentID}%26forceLocale={locale}`
        }
      }
    });
  }

This function reminds a bit on the beginning where the manifest.json was created. And indeed another task module shall be fired up here. Two parameters are essential: The ID of the corresponding item which will be attached to the url as a custom parameter and a componentID. This time it’s another one. That is because another task module shall show up and that needs to be created as a 2nd webpart but inside the existing solution:

The ‘Reviewed’ task module

The root class of the webpart mainly works the same like above. It detects if running in the context of a messaging extension e.g. But on top it needs to retrieve the itemID from the query parameters of the url:

public render(): void {
    const queryParms = new UrlQueryParameterCollection(window.location.href);
    const itemID = queryParms.getValue("itemID");
    const element: React.ReactElement<IDocReviewMarkReviewedProps> = React.createElement(
      DocReviewMarkReviewed,
      {
        itemID: itemID,
        serviceScope: this.context.serviceScope,
        siteUrl: this.context.pageContext.site.absoluteUrl,
        isTeamsMessagingExtension: this.isTeamsMessagingExtension,
        teamsContext: this.context.sdks.microsoftTeams
      }
    );

    ReactDom.render(element, this.domElement);
  }

In the root react component now an additional DatePicker is rendered which will be used to set the date for the next review. Finally a button can be clicked to update the document.

The ‘Reviewed’ task module
const DocReviewMarkReviewed:React.FunctionComponent<IDocReviewMarkReviewedProps> = (props) => {
  const [nextReview, setNextReview] = useState(new Date() as Date);

  const setReviewDate = (date: Date) => {
    setNextReview(date);
  };

  const execReview = async () => {
    if (!props.isTeamsMessagingExtension) {
      return;
    }    
    const fieldValueSet = {
      LastReviewed: new Date().toISOString(),
      NextReview: nextReview.toISOString()
    };
    const graphService: GraphService = new GraphService();
    graphService.initialize(props.serviceScope, props.siteUrl)
      .then(() => {
        graphService.setDocumentReviewed(props.itemID, fieldValueSet)
          .then((responseDoc) => {
            props.teamsContext.teamsJs.tasks.submitTask();
          });
      });    
  };

  return (
    <div className={ styles.docReviewMarkReviewed }>
      <div className={ styles.container }>
        <div className={ styles.row }>
          <div className={ styles.column }>
              <DatePicker
                className={styles.dateControl}
                firstDayOfWeek={DayOfWeek.Monday}
                label="Next Review"
                placeholder="Select a date for next review..."
                ariaLabel="Select a date"
                showWeekNumbers={true}
                onSelectDate={setReviewDate}
                value={nextReview}
              />
            </div>
            <div className={ styles.column }>
              <DefaultButton text="Reviewed"
                              onClick={execReview} />
            </div>
        </div>
      </div>
    </div>
  );
};

export default DocReviewMarkReviewed;

Once the button is clicked again a service is called to update the document. Finally a submitTask is executed to inform the button that the activity is closed. This will also end the task module and get back to the Teams client.

Configuration

At the end lets also have a short look at the services implementation. Especially about the configuration cause in part I.I of my series there was the need to define a siteID and a listID from where the documents are collected. And the “settings” of the messaging extension cannot be implemented as in part IV of my series.

As we have SPFx here and with it very simple access to SharePoint why not use tenant properties? I created a small PnP PowerShell script to configure a tenant property named ‘DocReviewConfig’ with a simple JSON string containing both needed values (siteID, listID). In our graph service this is now consumed the following way:

export default class GraphService {
	private client: MSGraphClient;
	private spService: SPService;

	public async initialize (serviceScope, siteUrl: string) {
		const graphFactory: MSGraphClientFactory = serviceScope.consume(MSGraphClientFactory.serviceKey);
		this.spService = new SPService();
    this.spService.initialize(serviceScope, siteUrl);
		return graphFactory.getClient()
			.then((client) => {
				this.client = client;
				return Promise.resolve();
			});
	}

	public async setDocumentReviewed(itemID: string, fieldValueSet) {		
    const config: IConfig = await this.spService.getConfig();
		return this.client.api(`https://graph.microsoft.com/v1.0/sites/${config.siteID}/lists/${config.listID}/items/${itemID}/fields`)
      .patch(fieldValueSet)
      .then((response) => {
        return response;
      })
      .catch((error) => {
        console.error(error);
      });
	}
}

Already inside the react components where the service was consumed, two steps could be observed: The initialization and the function call afterwards. That is because the graphFactory.getClient() is asynchronous. In the executing function (here only the setDocument… as an example) first a config object from the parallel opened spService is retrieved. With its values then the call against Microsoft Graph and the dedicated site and list can be executed. Finally a short look into the spService:

export default class SPService {
  private spClient: SPHttpClient;
  private siteUrl: string;

  public initialize (serviceScope, siteUrl: string) {
    this.spClient = serviceScope.consume(SPHttpClient.serviceKey);
    this.siteUrl = siteUrl;
  }

  public async getConfig (): Promise<IConfig> {
    const requestUrl = `${this.siteUrl}/_api/web/GetStorageEntity('DocReviewConfig')`;    
    
    return this.spClient
      .get(requestUrl, SPHttpClient.configurations.v1)
        .then((response): Promise<IConfig> => {
          return response.json()
            .then((jsonResponse) => {
              const config: IConfig = JSON.parse(jsonResponse.Value);
              return config;
            });
        });
  }
}

But that’s no rocket science anymore.

I hope this is another interesting example beyond Microsoft’s ‘classic’ Leads bot solution used to introduce SPFx usage in Teams task modules. I tried to explain a bit more of the details and slightly different capabilities here. For sure I did not cover all aspects but this is ‘brand new’ at the time of writing and I am also still learning πŸ˜‰
SPFx makes the authentication (did we even use that word so far?) a lot easier. And indeed we could also have used SPHttpClient to access our documents in an even simpler way. But I wanted to keep my example ‘comparable’ to the previous approaches so I remained using Microsoft Graph. Maybe this helps for other scenarios.
Finally find the whole solution in my github repo.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 and SharePoint Online development. He loves the new SharePoint Framework as well as some backend stuff around Azure Automation or Azure Functions and also has a passion for Microsoft Graph.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
Although if partially inspired by his daily work opinions are always personal.

5 thoughts on “Use SPFx for Task Modules in Teams Messaging Extensions and access Microsoft Graph

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s