Build Microsoft Teams meeting app with SPFx

5 minute read

Build Microsoft Teams meeting app with SPFx

Overview

SPFx v1.12 support for Microsoft Teams meeting apps development.

In this article, we will develop pre-meeting app experience using SPFx. Questionnaire meeting app provides Pre-meeting app experience for MS Teams meeting attendees to ask the questions related to meeting before the meeting starts.

I am privileged to implement this solution along with my colleagues Ravi Kulkarni and Smita Nachan.

Note

SPFx v1.12 has added support for Node.js v12 and Gulp 4.

SharePoint Asset

A SharePoint list (named Teams Meeting Questionnaire) should be manually created to store the meeting questionnaires. The attendees questions will be stored per meeting against unique MeetingID from MS Teams meeting. The schema of the list is as below.

Display Name Internal Name Type Required Comments
Title Title Single line of text Y OOB Title column
Description Description Multiple lines of text N ย 
MeetingID MeetingID Single line of text N ย 

Develop SPFx Solution

We will develop the SPFx web part as shown below.

We donโ€™t need to use a specific host in the supportedHosts property (like TeamsTab or TeamsPersonalApp) inside web partsโ€™s manifest.json. However, we just need to extend the teams manifest in your SharePoint Framework solution with configurableTabs.

NPM Packages Used

Below NPM package(s) are used to develop this sample:

  1. @pnp/sp
  2. moment

Add Teams Manifest to the solution

We will configure the manifest schema to surface the SPFx web part as Microsoft Teams meeting app.

In the solution, add manifest.json file to teams folder.

{
    "$schema": "https://developer.microsoft.com/json-schemas/teams/v1.5/MicrosoftTeams.schema.json",
    "manifestVersion": "1.8",
    "version": "1.0.0",
    "id": "%MICROSOFT-APP-ID%",
    "packageName": "Questionnaire Meeting App",
    "developer": {
        "name": "Nanddeep Nachan",
        "websiteUrl": "https://aka.ms/sppnp",
        "privacyUrl": "https://privacy.microsoft.com/privacystatement",
        "termsOfUseUrl": "https://www.microsoft.com/servicesagreement"
    },
    "name": {
        "short": "Questionnaire Meeting App",
        "full": "SPFx based MS Teams Questionnaire Meeting App"
    },
    "description": {
        "short": "MS Teams pre-meeting questionnaire experience",
        "full": "Provides MS Teams pre-meeting experience with questionnaire"
    },
    "icons": {
        "outline": "%MICROSOFT-APP-ID%_outline.png",
        "color": "%MICROSOFT-APP-ID%_color.png"
    },
    "accentColor": "#004578",
    "configurableTabs": [
        {
            "configurationUrl": "https://{teamSiteDomain}{teamSitePath}/_layouts/15/TeamsLogon.aspx?SPFX=true&dest={teamSitePath}/_layouts/15/teamshostedapp.aspx%3FopenPropertyPane=true%26teams%26componentId=%MICROSOFT-APP-ID%%26forceLocale={locale}",
            "canUpdateConfiguration": true,
            "scopes": [
                "team",
                "groupchat"
            ],
            "context": [
                "channelTab",
                "privateChatTab",
                "meetingChatTab",
                "meetingDetailsTab",
                "meetingSidePanel"
            ]
        }
    ],
    "validDomains": [
        "*.login.microsoftonline.com",
        "*.sharepoint.com",
        "*.sharepoint-df.com",
        "spoppe-a.akamaihd.net",
        "spoprod-a.akamaihd.net",
        "resourceseng.blob.core.windows.net",
        "msft.spoppe.com"
    ],
    "webApplicationInfo": {
        "resource": "https://{teamSiteDomain}",
        "id": "00000003-0000-0ff1-ce00-000000000000"
    }
}

In the above json, replace %MICROSOFT-APP-ID% with your SPFx solution GUID.

Web Part Properties

The properties should be pre-configured inside QuestionnaireMeetingAppWebPart.manifest.json as when the web part is added as MS Teams meeting experience, we do not get any settings to configure.

Property Type Required Default value Comments
siteUrl Text Yes / Provide the relative URL of the site where below list exists.
listName Text Yes Teams Meeting Questionnaire Title of the list storing meeting questionnaires.

Set SharePoint context using PnP JS

The SharePoint site URL will be availale as configured web part property. In the web part file src\webparts\questionnaireMeetingApp\QuestionnaireMeetingAppWebPart.ts, set the SharePoint context as follows:

public async onInit(): Promise<void> {
    return super.onInit().then(_ => {
      if (this.context.sdks.microsoftTeams) {
        // checking that we're in Teams
        const context = this.context.sdks.microsoftTeams!.context;
        this._applyTheme(context.theme || 'default');
        this.context.sdks.microsoftTeams.teamsJs.registerOnThemeChangeHandler(this._applyTheme);

        // Setup context to PnPjs
        sp.setup({
          spfxContext: this.context,
          sp: {
            baseUrl: `https://${this.context.sdks.microsoftTeams.context.teamSiteDomain}${this.properties.siteUrl}`
          }
        });
      }
    });
}

Project setup and important files

Below are the major components in the SPFx solution.

spfx-react-teams-meeting-app-questionnaire
    โ”œโ”€โ”€ teams                                                     <-- MS Teams manifest
    โ”‚   โ””โ”€โ”€ manifest.json
    โ””โ”€โ”€ src
        โ””โ”€โ”€ models
            โ”œโ”€โ”€ IQuestionnaireItem.ts
        โ””โ”€โ”€ services
            โ”œโ”€โ”€ SPOService.ts                                     <-- Extensible Service
        โ””โ”€โ”€ webparts
            โ””โ”€โ”€ questionnaireMeetingApp
                โ”œโ”€โ”€ QuestionnaireMeetingAppWebPart.manifest.json  <-- Configurable web part properties
                โ”œโ”€โ”€ QuestionnaireMeetingAppWebPart.ts
                โ”œโ”€โ”€ components
                โ”‚   โ””โ”€โ”€ QuestionnaireMeetingApp
                โ”‚   โ”‚   โ”œโ”€โ”€ QuestionnaireMeetingApp.tsx           <-- Questionnaire Component
                โ”‚   โ”‚   โ”œโ”€โ”€ QuestionnaireMeetingApp.module.scss
                โ”‚   โ”‚   โ”œโ”€โ”€ IQuestionnaireMeetingAppProps.ts
                โ”‚   โ”‚   โ”œโ”€โ”€ IQuestionnaireMeetingAppState.ts
                โ”‚   โ””โ”€โ”€ Popup                                     <-- New Question Creation Component
                |   โ”‚   โ”œโ”€โ”€ AskQuestion.tsx
                |   โ”‚   โ”œโ”€โ”€ IAskQuestionProps.ts
                |   โ”‚   โ”œโ”€โ”€ IAskQuestionState.ts
                โ””โ”€โ”€ loc
                    โ”œโ”€โ”€ en-us.js
                    โ””โ”€โ”€ mystrings.d.ts

Questionnaire Meeting App Component

Implement the React component (src\webparts\questionnaireMeetingApp\components\QuestionnaireMeetingApp.tsx), to let attendees ask the qeustions and display them based on MeetingID.

import * as React from 'react';
import * as strings from 'QuestionnaireMeetingAppWebPartStrings';
import styles from './QuestionnaireMeetingApp.module.scss';
import { IQuestionnaireMeetingAppProps } from './IQuestionnaireMeetingAppProps';
import { IQuestionnaireMeetingAppState } from './IQuestionnaireMeetingAppState';
import { IQuestionnaireItem } from "../../../models/IQuestionnaireItem";
import SPOService from '../../../services/SPOService';
import { PrimaryButton } from 'office-ui-fabric-react';
import { AskQuestion } from './Popup/AskQuestion';
import { ActivityItem, IActivityItemProps, Link, mergeStyleSets } from 'office-ui-fabric-react';
import * as moment from 'moment';

const classNames = mergeStyleSets({
  exampleRoot: {
    marginTop: '20px',
  },
  nameText: {
    fontWeight: 'bold',
  },
});

export default class QuestionnaireMeetingApp extends React.Component<IQuestionnaireMeetingAppProps, IQuestionnaireMeetingAppState> {
  private SPOService: SPOService = null;

  public constructor(props) {
    super(props);

    this.state = {
      infoLoaded: false,
      meetingQuestionnaire: [],
      showPopup: false
    };

    this.SPOService = new SPOService();
    this.onDismissPanel = this.onDismissPanel.bind(this);
  }

  public async componentDidMount() {
    const meetingQuestionnaireInfo: IQuestionnaireItem[] = await this.SPOService.getQuestionnaire(this.props.listName, this.props.context.sdks.microsoftTeams.context.meetingId);

    this.setState({
      infoLoaded: true,
      meetingQuestionnaire: meetingQuestionnaireInfo
    });
  }

  private async onDismissPanel(refresh: boolean) {
    this.setState({ showPopup: false, infoLoaded: false });
    if (refresh === true) {
      const meetingQuestionnaireInfo: IQuestionnaireItem[] = await this.SPOService.getQuestionnaire(this.props.listName, this.props.context.sdks.microsoftTeams.context.meetingId);
      this.setState({
        infoLoaded: true,
        meetingQuestionnaire: meetingQuestionnaireInfo
      });
    }
  }

  public render(): React.ReactElement<IQuestionnaireMeetingAppProps> {
    return (
      <div className={styles.questionnaireMeetingApp}>
        <div className={styles.container}>
          <div className={styles.row}>
            <div className={styles.column}>
              <PrimaryButton className={styles.button} onClick={() => { this.setState({ showPopup: true }); }} text={strings.AddQuestion} />
            </div>
          </div>
          <div className={styles.row}>
            <div className={styles.column}>
              <div>
                {
                  this.state.meetingQuestionnaire.map(item => {
                    const activityItem: (IActivityItemProps & { key: string | number }) = {
                      key: item.ID,
                      activityDescription: [
                        <Link
                          key={item.ID}
                          className={classNames.nameText}
                        >
                          {item.Author.Title}
                        </Link>,
                        <span key={2}> {strings.Posted} </span>
                        <span key={3} className={classNames.nameText}>
                          {item.Title}
                        </span>
                      ],
                      activityPersonas: [{ imageUrl: `/_layouts/15/userphoto.aspx?size=S&username=${item.Author.EMail}` }],
                      comments: item.Description,
                      timeStamp: moment(item.Modified).format("LLL")
                    };

                    return (
                      <ActivityItem {...activityItem} key={activityItem.key} className={classNames.exampleRoot} />
                    );
                  })
                }
              </div>

              <div>
                {
                  this.state.showPopup &&
                  <AskQuestion
                    onDissmissPanel={this.onDismissPanel}
                    showPopup={this.state.showPopup}
                    context={this.props.context}
                    listName={this.props.listName}
                  />
                }
              </div>
            </div>
          </div>
        </div>
      </div>
    );
  }
}

SPO Service

Implement the service for SPO operations.

import { IQuestionnaireItem } from "../models/IQuestionnaireItem";
import { sp } from '@pnp/sp/presets/all';

export default class SPOService {
    public async getQuestionnaire(listTitle: string, meetingId: string): Promise<IQuestionnaireItem[]> {
        let meetingQuestionnaire: IQuestionnaireItem[] = [];

        try {
            // Get Client POC Master
            meetingQuestionnaire = await sp.web.lists.getByTitle(listTitle)
                .items
                .select("ID,MeetingID,Title,Description,Author/Title,Author/EMail,Modified")
                .expand("Author")
                .filter(`MeetingID eq '${meetingId}'`)
                .orderBy("Modified", false)
                .get<IQuestionnaireItem[]>();
        }
        catch (error) {
            console.log(error);
            return Promise.reject(error);
        }
        return meetingQuestionnaire;
    }

    public async addQuestion(listTitle: string, item: IQuestionnaireItem): Promise<boolean> {
        try {
            // Get Client POC Master
            return sp.web.lists.getByTitle(listTitle)
                .items
                .add({
                    Title: item.Title,
                    Description: item.Description,
                    MeetingID: item.MeetingID
                })
                .then((value) => {
                    return Promise.resolve(true);
                });
        }
        catch (error) {
            return Promise.reject(error);
        }
    }
}

Deploy the solution

SharePoint Deployment

  • Bundle the solution: $ gulp bundle --ship
  • Package the solution: $ gulp package-solution --ship
  • Locate the solution at ./sharepoint/solution/spfx-ms-teams-questionnaire-meeting-app.sppkg and upload it to SharePoint tenant app catalog

Deploy SPFx solution

  • Select Make this solution available to all sites in the organization.
  • Click Deploy

MS Teams Deployment

  • Navigate to teams folder and zip the content (2 png files and manifest.json).
  • Open MS Teams.
  • Click Apps.
  • Click Upload a custom app > Upload for <tenant>.

Deploy to MS Teams

The meeting app in action

The Questionnaire meeting app displays the questions from attendees as pre-meeting app experience. Questionnaire Preview

Summary

This article illustrates the possibilities to surface SPFx web part as Microsoft Teams meeting app.

References

Code Download

The code developed during this article is proudly available under PnP repo and can be found here: https://github.com/pnp/sp-dev-fx-webparts/tree/master/samples/react-teams-meeting-app-questionnaire

Leave a comment