SharePointのWebパーツをReactベースで作る

#はじめに

SharePointのWebパーツをReactで作れるらしいので、実際にやってみました。しばらくMSから離れていたのですが、私の知っている鎖国のような時代は終わっていて、オープンソースワールドがそこには広がっていました。MSでReactやgulpを使う日が来るなんて…。これをきっかけに、またいろいろと作ってみたくなりました。

#Office UI Fabric React

Webパーツ自体は、JavaScriptフレームワークを使っても使わなくてもよいです。が、Reactベースの素晴らしすぎるUIフレームワークがあったので、これを使います。このコンポーネントを使うだけで、簡単にそれっぽいものが作れそうです。
<img width="1665" alt="office-ui-fabric-sample.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/319797/24cc2ee9-6542-4cfc-73bf-0a101f294725.png">


#今回作るWebパーツ

せっかくなので、SharePointのサイトコンテンツにアクセスするものを作ろうと思います。今回は、リストデータを取得して表示するパーツを作ります。使うコンポーネントは、DetailsListです。

<img width="1660" alt="detailslist-sample.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/319797/5443035d-1cab-7676-fa97-5f237cdf71d7.png">


#やってみた

##0. 事前準備

[こちら](https://docs.microsoft.com/ja-jp/sharepoint/dev/spfx/set-up-your-development-environment)を参照して、開発環境のセットアップをしておきます。

##1. 新しいWebパーツプロジェクトの作成

次のコマンドを実行して、自分の好きな場所に新しいプロジェクトを作成します。

```terminal
mkdir detailslist-webpart
cd detailslist-webpart
yo @microsoft/sharepoint
```
対話形式で必要な情報をインプットします。
以下は明示的に指定して、それ以外はデフォルトの設定としました。

* What is your solution name? **detailslist-webpart**
* Which baseline packages do you want to target for your component(s)? **SharePoint Online only (latest)**
* Where do you want to place the files? **Use the current folder**
* Which type of client-side component to create? **WebPart**
* What is your Web part name? **DetailsListSample**
* Which framework would you like to use? **React**
<img width="960" alt="create-new-project-1.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/319797/8c9628f2-b882-460a-7127-8772ba7f0caa.png">
このようになれば、プロジェクトの作成は完了です。
<img width="578" alt="create-new-project-2.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/319797/be444c8a-5e58-bbd7-7bc7-bad37a66839c.png">

##2. Office UI Fabricコンポーネントの追加

Office UI Fabric Reactコンポーネントを追加します。
src/webparts/detailsListSample/components/DetailsListSample.tsxを開きます。
まず、1行目から4行目までを次のように置き換えます。

```tsx
import * as React from 'react';
import { IDetailsListSampleProps } from './IDetailsListSampleProps';
import { Announced } from 'office-ui-fabric-react/lib/Announced';
import { TextField, ITextFieldStyles } from 'office-ui-fabric-react/lib/TextField';
import { DetailsList, DetailsListLayoutMode, Selection, IColumn } from 'office-ui-fabric-react/lib/DetailsList';
import { MarqueeSelection } from 'office-ui-fabric-react/lib/MarqueeSelection';
import { Fabric } from 'office-ui-fabric-react/lib/Fabric';
import { mergeStyles } from 'office-ui-fabric-react/lib/Styling';

const exampleChildClass = mergeStyles({
  display: 'block',
  marginBottom: '10px',
});

const textFieldStyles: Partial<ITextFieldStyles> = { root: { maxWidth: '300px' } };

export interface IDetailsListItem {
  key: number;
  name: string;
  value: number;
}

export interface IDetailsListSampleState {
  items: IDetailsListItem[];
  selectionDetails: string;
}
```
次に、DetailsListSampleクラスも編集します。

```tsx
export default class DetailsListSample extends React.Component<IDetailsListSampleProps, IDetailsListSampleState> {
  private _selection: Selection;
  private _allItems: IDetailsListItem[];
  private _columns: IColumn[];

  constructor(props: IDetailsListSampleProps) {
    super(props);

    this._selection = new Selection({
      onSelectionChanged: () => this.setState({ selectionDetails: this._getSelectionDetails() }),
    });

    // Populate with items for demos.
    this._allItems = [];
    for (let i = 0; i < 200; i++) {
      this._allItems.push({
        key: i,
        name: 'Item ' + i,
        value: i,
      });
    }

    this._columns = [
      { key: 'column1', name: 'Name', fieldName: 'name', minWidth: 100, maxWidth: 200, isResizable: true },
      { key: 'column2', name: 'Value', fieldName: 'value', minWidth: 100, maxWidth: 200, isResizable: true },
    ];

    this.state = {
      items: this._allItems,
      selectionDetails: this._getSelectionDetails(),
    };
  }

  public render(): JSX.Element {
    const { items, selectionDetails } = this.state;

    return (
      <Fabric>
        <div className={exampleChildClass}>{selectionDetails}</div>
        <Announced message={selectionDetails} />
        <TextField
          className={exampleChildClass}
          label="Filter by name:"
          onChange={this._onFilter}
          styles={textFieldStyles}
        />
        <Announced message={`Number of items after filter applied: ${items.length}.`} />
        <MarqueeSelection selection={this._selection}>
          <DetailsList
            items={items}
            columns={this._columns}
            setKey="set"
            layoutMode={DetailsListLayoutMode.justified}
            selection={this._selection}
            selectionPreservedOnEmptyClick={true}
            ariaLabelForSelectionColumn="Toggle selection"
            ariaLabelForSelectAllCheckbox="Toggle selection for all items"
            checkButtonAriaLabel="Row checkbox"
            onItemInvoked={this._onItemInvoked}
          />
        </MarqueeSelection>
      </Fabric>
    );
  }

  private _getSelectionDetails(): string {
    const selectionCount = this._selection.getSelectedCount();

    switch (selectionCount) {
      case 0:
        return 'No items selected';
      case 1:
        return '1 item selected: ' + (this._selection.getSelection()[0] as IDetailsListItem).name;
      default:
        return `${selectionCount} items selected`;
    }
  }

  private _onFilter = (ev: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>, text: string): void => {
    this.setState({
      items: text ? this._allItems.filter(i => i.name.toLowerCase().indexOf(text) > -1) : this._allItems,
    });
  }

  private _onItemInvoked = (item: IDetailsListItem): void => {
    alert(`Item invoked: ${item.name}`);
  }
}
```

ここまでできたら、いったんローカル実行をしてみます。次のコマンドを実行すると、localhost:4321のローカルワークベンチでWebパーツをプレビューすることができます。

```terminal
gulp serve
```
無事、DetailsListコンポーネントが表示されました。
<img width="1674" alt="office-ui-fabric-1.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/319797/b523ed34-0254-ea90-fcbe-66b1aa9715fc.png">

##3. モックストアの作成
今回のゴールは、SharePointサイトのリストデータを表示することですが、ローカルワークベンチでもテストできるように、モックストアを作成します。

リストデータを操作するための、リストモデルを作成します。ここは、実際のSharePointサイトでリスト定義を確認して、適宜モデルを修正してください。今回は、このようなモデルにします。

```tsx
export interface IDetailsListItems {
  value: IDetailsListItem[];
}

export interface IDetailsListItem {
  key: number;
  name: string;
  value: number;
}
```
src/webparts/detailsListSample/components/MockHttpClient.tsという名前で、新規ファイルを作成します。

```tsx
import { IDetailsListItem } from './DetailsListSample';

export default class MockHttpClient  {
  private static _items: IDetailsListItem[] = [{ key: 1, name: 'Mock List 1', value: 1 },
                                        { key: 2, name: 'Mock List 2', value: 2 },
                                        { key: 3, name: 'Mock List 3', value: 3 }];

  public static get(): Promise<IDetailsListItem[]> {
    return new Promise<IDetailsListItem[]>((resolve) => {
      resolve(MockHttpClient._items);
    });
  }
}
```
src/webparts/detailsListSample/components/DetailsListSample.tsxに戻って、MockHttpClientモジュールをインポートします。

```typescript
import MockHttpClient from './MockHttpClient';
```
クラス内でリストデータを取得するプライベートメソッドを追加します。

```tsx
private _getMockListData(): Promise<IDetailsListItems> {
  return MockHttpClient.get()
    .then((data: IDetailsListItem[]) => {
      var listData: IDetailsListItems = { value: data };
      return listData;
    }) as Promise<IDetailsListItems>;
}
```

##4. リストデータを取得
SharePointワークベンチでは、EnvironmentTypeモジュールを使って、Webパーツを実行している環境に応じて、接続先を簡単にスイッチすることができます。これを使って、ローカル環境の場合はモックストアから、SharePointサイトの場合は実際のリストを取得できるようにします。

src/webparts/detailsListSample/components/DetailsListSample.tsxで、EnvironmentTypeモジュールをインポートします。

```tsx
import {
  Environment,
  EnvironmentType
} from '@microsoft/sp-core-library';
```
ワークベンチがSharePointでホストされている場合は、ページのコンテキストにアクセスして、サイトのURLを取得する必要があります。また、SharePoint Frameworkには、spHttpClientというSharePoint APIを呼び出すためのヘルパークラスが含まれており、これを使ってリストにアクセスします。

PropsでサイトのURLとspHttpClientをコンポーネントに与えてあげます。
src/webparts/detailsListSample/components/IDetailsListSampleProps.tsを次のように書き換えます。

```typescript
import { SPHttpClient } from "@microsoft/sp-http";
export interface IDetailsListSampleProps {
  description: string;
  spHttpClient: SPHttpClient;
  siteUrl: string;
}
```
src/webparts/detailsListSample/DetailsListSampleWebPart.tsで、値をセットします。

```typescript
public render(): void {
  const element: React.ReactElement<IDetailsListSampleProps> = React.createElement(
    DetailsListSample,
    {
      description: this.properties.description,
      spHttpClient: this.context.spHttpClient,
      siteUrl: this.context.pageContext.web.absoluteUrl,
    }
  );

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

src/webparts/detailsListSample/components/DetailsListSample.tsxを開いて、ヘルパークラスをインポートします。

```tsx
import {
  SPHttpClient,
  SPHttpClientResponse
} from '@microsoft/sp-http';
```
では、Propsに持たせたspHttpClientを使って、SharePointからリストを取得するプライベートメソッドを追加します。

```tsx
private _getListData(): Promise<IDetailsListItems> {
    return this.props.spHttpClient.get(this.props.siteUrl + `/_api/web/lists/getbytitle('リスト名')/items?$top=10`, SPHttpClient.configurations.v1)
      .then((response: SPHttpClientResponse) => {
        return response.json();
      });
  }
```

最後に、モックストアとSharePointを切り替えてデータを取得するためのプライベートメソッドを追加して、これを初期化のタイミングあたりでキックしてあげます。

```tsx
private _getListItemAsync(): void {
  // Local environment
  if (Environment.type === EnvironmentType.Local) {
    this._getMockListData().then((response) => {
      this._allItems = response.value;
      this.setState({
        items: this._allItems,
      });
    });
  }
  else if (Environment.type == EnvironmentType.SharePoint ||
            Environment.type == EnvironmentType.ClassicSharePoint) {
    this._getListData()
      .then((response) => {
        this._allItems = response.value;
        this.setState({
          items: this._allItems,
        });
      });
  }
}
```
ローカルでプレビューすると、こんな感じになります。モックストアでセットしたデータがちゃんと表示されています。
<img width="1674" alt="mock-store-1.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/319797/6726b972-1ab9-b0cc-c8c2-76eb000eda5b.png">

##5. SharePointにWebパーツをデプロイする
次のコマンドでプロジェクトをビルドして、ソリューションをパッケージ化します。

```terminal
gulp bundle --ship
gulp package-solution --ship
```
sharepoint/solution/detailslist-webpart.sppkgというファイルが生成されると思います。あとは、これをサイトにアップロードして展開すると、実際にWebパーツとして利用できるようになります。

ちょっと今回は適当なサイトがなかったので、キャプチャなどは割愛します。実際に動かしてみた感じでは、とてもサクサク動くのでびっくりしました。

#おわりに
ReactベースでSharePointのWebパーツを作成しました。
これを応用すると、結構いろんなパーツが作れるので、めちゃくちゃ活用できそうです。何でもっと早くやらなかったんだろうと思うくらい、控えめに言って最強なソリューションでした。