PowerApps コンポーネントフレームワーク : ステート API サンプル

[前回](https://qiita.com/kenakamu/items/564315b9ab9134cc5bf0)に引き続き、今回はステート API のサンプルとして [control state API component](https://docs.microsoft.com/en-us/powerapps/developer/component-framework/sample-controls/control-state-api) サンプルを解説します。

このサンプルは、同一セッション中であればレコード単位にコントロールのステートを記憶して、ユーザーがフォームから一度離れた後に戻ってきても、前回の状態を復元できるサンプルです。と書いてもよく分からないので、以下に完成コントロールを使った操作のイメージを紹介します。

# サンプルの動作イメージ

1\. コントロールが配置されているフォームを任意のレコードで開く。以下は「アドベンチャーワークス」のレコードを利用。ここで「Green」のボタンをクリック。これで Green を選択したことをステートに保存する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/25be7921-d1c0-9082-0607-873b32827f65.png)

2\. レコードは保存せずに、任意の別のレコードを開く。ここでは「アルパイン スキーハウス」を利用。ここで「Red」ボタンをクリックして、Red を選択したことをステートに保存。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/5707c3ed-2612-7df2-79d6-3bfca6d7c275.png)

3\. レコードの保存はせずに、先ほど開いた「アドベンチャーワークス」のレコードを再度開く。ここで先ほど選択した「Green」が引き続き選択されている状態、つまりステートが保存されていて復元されたことを確認する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/2c0d7383-7b27-bcb1-52d3-720777940565.png)

4\. F5 キーを押下してセッションをリセットすると何も選ばれていない状態に戻ることを確認。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/2ba9d617-5d50-c09f-6773-b08b2328c3f0.png)

# プロジェクトの作成

今回も既に作成した SampleSolution に追加するように開発していきます。

1\. 前回まで作ってきた PCFControls フォルダに移動して、フィールド用のコントロールを作成。

```shell
mkdir StateControl
cd StateControl
```

2\. 以下のコマンドを実行してプロジェクトを作成。

- 名前空間とプロジェクト名の指定
- template 引数は field を指定

```shell
pac pcf init --namespace SampleNamespace --name TSControlStateAPI --template field
```

3\. npm パッケージをリストアして任意の IDE でフォルダを開く。ここでは Visual Studio Code を利用。

```shell
npm install
code .
```

# コントロールマニフェストの編集とリソースの追加

これまで同様にまずはマニフェストから編集します。

1\. ControlManifest.Input.xml を以下の様に編集。

- 一行テキスト型で bind を使用するように指定 ※実際にはバインドされた値は使わない
- resources に ts と css を指定

```xml:ControlManifest.Input.xml
<?xml version="1.0" encoding="utf-8" ?>
<manifest>
<control namespace="SampleNamespace" constructor="TSControlStateAPI" version="1.0.0" display-name-key="Status Control Sample" description-key="Status Control Sample" control-type="standard">
	<property name="stateControlProperty" display-name-key="Color" description-key="Remember user selected color" of-type="SingleLine.Text" usage="bound" required="true" />
	<resources>
		<code path="index.ts" order="1" />
		<css path="css/TS_ControlStateAPI.css" order="1" />
	</resources>
</control>
</manifest>
```

2\. ファイルの作成から css フォルダパスを含めて TS_ControlStateAPI.css を作成。こうするとフォルダも自動で作成される。
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/41366c77-e835-382d-1c4e-1321bb2eeffe.png" />


3\. TS_ControlStateAPI.css の中身を以下に差し替え。

```css:TS_ControlStateAPI.css
.SampleNamespace\.TSControlStateAPI 
{
	font-family: "SegoeUI-Semibold", "Segoe UI Semibold", "Segoe UI Regular", "Segoe UI";
}
.SampleNamespace\.TSControlStateAPI .ControlState_Container
{
	overflow-x: auto;
}
.SampleNamespace\.TSControlStateAPI  .ControlState_ButtonClass
{
	text-decoration: none;
	display: inline-block;
	font-size: 14px;
	cursor: pointer;
	color: #1160B7;
	background-color: #FFFFFF;
	border: 1px solid black;
	padding: 5px;
	text-align: center;
	min-width: 200px;
	margin-top: 10px;
	margin-bottom: 10px;
	display: block;
}
.SampleNamespace\.TSControlStateAPI .ControlState_SelectedColorElement
{
	width: 200px;
	height: 40px;
	margin-bottom: 25px;
	text-align: center;
	padding: auto;
	font-size: 24px;
	color: rgb(51, 51, 51);
}
.SampleNamespace\.TSControlStateAPI .ControlState_DivLabelClass
{
	width: 200px;
	height: 40px;
	text-align: center;
	padding: auto;
	font-size: 24px;
	color: rgb(51, 51, 51);
}
```

4\. ターミナルより以下コマンドでビルドを実行。

```shell
npm run build
```

# index.ts の編集

1\. まずは import の追加と定数の定義

```typescript
import { IInputs, IOutputs } from "./generated/ManifestTypes";

// 前回選択された色用のキー 
const PERSISTED_SELECTED_COLOR_KEY_NAME = "selectedColor";
// 前回選択された色のラベル用のキー
const PERSISTED_SELECTED_Label_KEY_NAME = "selectedLabel";
```

2\. クラスとプロパティの定義。

```typescript
export class TSControlStateAPI implements ComponentFramework.StandardControl<IInputs, IOutputs>
{
    // コントロールが描画完了用フラグ
    private _controlViewRendered: Boolean;
    // カスタムコントロールを保持するコンテナ
    private _container: HTMLDivElement;
    // 現在選択されている色を表示
    private _selectedColorElement: HTMLDivElement;
    // コンテキスト
    private _context: ComponentFramework.Context<IInputs>;
    // 前回選択された色
    private _persistedSelectedColor: string;
    // 前回選択された色を表示するラベル
    private _persistedSelectedLabel: string;
    // ステートとして保存するオブジェクトのディクショナリ
    private _stateDictionary: ComponentFramework.Dictionary = {};

    // 赤、青、緑のボタン
    private _buttonRed: HTMLButtonElement;
    private _buttonBlue: HTMLButtonElement;
    private _buttonGreen: HTMLButtonElement;
}
```

3\. init 初期化メソッドを追加。

- 引数で渡される state から前回ステートを復元

```typescript
/**
 * カスタムコントロールの初期化。データセットの初期化は updateView メソッド内で行う
 * @param context コンテキスト
 * @param notifyOutputChanged コントロール側でも出力変更を通知するコールバック
 * @param state セッションレベルで保持されるステート
 * @param container カスタムコントロールを描画する親コンテナ
 */
public init(context: ComponentFramework.Context<IInputs>, notifyOutputChanged: () => void, state: ComponentFramework.Dictionary, container: HTMLDivElement): void {
    // カスタム UI 描画完了フラグを false に設定
    this._controlViewRendered = false;
    // コンテナの作成
    this._container = document.createElement("div");
    // コンテキストをローカル変数に保存
    this._context = context;
    // コンテナを画面の要素に追加
    this._container.classList.add("ControlState_Container");
    container.appendChild(this._container);

    // ステートが存在するか確認
    if (state) {
        // ステートをディクショナリとして保持
        this._stateDictionary = state;

        // 前回選択した色のとラベルの文字列をステートから取得
        this._persistedSelectedColor = state[PERSISTED_SELECTED_COLOR_KEY_NAME];
        this._persistedSelectedLabel = state[PERSISTED_SELECTED_Label_KEY_NAME];
    }

    // 前回選択した色がない場合、既定で「transparent」を設定
    if (!this._persistedSelectedColor) {
        this._persistedSelectedColor = "transparent";
    }

    // 前回選択した色のラベルがない場合、既定で「none」を設定
    if (!this._persistedSelectedLabel) {
        this._persistedSelectedLabel = "none";
    }
}
```

4\. PCF からの変更通知を処理する updateView メソッドを追加。

```typescript
/**
 * データセットや画面サイズなど、各種変更時に呼び出される
 * @param context コンテキスト
 */
public updateView(context: ComponentFramework.Context<IInputs>): void {
    // カスタム UI の描画完了フラグが false なら
    if (!this._controlViewRendered) {
        // ヘッダーの追加
        let chooseColorLabel: HTMLElement = this.renderLabelDivElement("Select a Color");
        this._container.appendChild(chooseColorLabel);

        // 3 色のボタンを追加
        this._buttonGreen = this.renderButtonElement("Green", "#80ff80");
        this._container.appendChild(this._buttonGreen);
        this._buttonBlue = this.renderButtonElement("Blue", "#add8e6");
        this._container.appendChild(this._buttonBlue);
        this._buttonRed = this.renderButtonElement("Red", "#ff8080");
        this._container.appendChild(this._buttonRed);

        // 'selected color' エリアに選択された色を追加
        let selectedColorLabel: HTMLElement = this.renderLabelDivElement("Selected Color");
        this._container.appendChild(selectedColorLabel);

        // 'selected color' エリアの描画
        this.renderSelectedColorElement();
        this.updateSelectedColorElement(this._selectedColorElement, this._persistedSelectedLabel, this._persistedSelectedColor);

        // 描画完了フラグを true に更新
        this._controlViewRendered = true;
    }
}
```

5\.　updateView から呼ばれてコントロールの要素を描写する renderLabelDivElement、renderButtonElement、renderSelectedColorElement および updateSelectedColorElement メソッドを追加。

```typescript
/**
 * ラベルの Div を描画  
 * @param labelText : 表示するラベル 
 */
private renderLabelDivElement(labelText: string): HTMLDivElement {
    let div: HTMLDivElement = document.createElement("div");
    div.innerText = labelText;
    div.classList.add("ControlState_DivLabelClass");
    return div;
}

/**
 * 色を選択するボタンを描画
 * @param label : ボタンの文字列
 * @param color : 色
 */
private renderButtonElement(label: string, color: string): HTMLButtonElement {
    let button: HTMLButtonElement = document.createElement("button");
    button.innerHTML = label;
    button.setAttribute("value", label);
    button.setAttribute("buttonColor", color);
    button.classList.add("ControlState_ButtonClass");
    button.addEventListener("click", event => this.onButtonClick(event, this._selectedColorElement));

    return button;
}


/** 
 * 最後に選択された色の情報を描画
 */
private renderSelectedColorElement() {
    this._selectedColorElement = document.createElement("div");
    this._selectedColorElement.classList.add("ControlState_SelectedColorElement");
    this._container.appendChild(this._selectedColorElement);
}

/**
 * 最後に選択された色の情報を更新 
 * @param selectedColorElement 選択された色のエレメント
 * @param label 表示するラベル
 * @param backgroundColor 背景色
 */
private updateSelectedColorElement(selectedColorElement: HTMLDivElement, label: string, backgroundColor: string) {
    selectedColorElement.innerText = label;
    this._selectedColorElement.style.backgroundColor = backgroundColor;
}
```

6\. 色のボタンをクリックした際に呼ばれるメソッドを追加。

- context.mode.setControlState: 引数に渡された ComponentFramework.Dictionary オブジェクトをステートとして保存

```typescript
/**
 * 色ボタンの Onclick イベントハンドラー
 * @param event クリックイベント
 * @param selectedColorElement 選択された色のエレメント
 */
private onButtonClick(event: Event, selectedColorElement: HTMLDivElement) {
    const eventTarget: Element = event.srcElement as Element;

    if (eventTarget) {
        // クリックされたボタンのラベルと色を取得
        let label: string = eventTarget.attributes.getNamedItem("value")!.value;
        let selectedColor: string = eventTarget!.attributes.getNamedItem("buttonColor")!.value;

        // 選択された色のエリアを更新
        this.updateSelectedColorElement(selectedColorElement, label, selectedColor);

        // ステートに選択されたラベルと色の情報を格納
        this._stateDictionary[PERSISTED_SELECTED_Label_KEY_NAME] = label;
        this._stateDictionary[PERSISTED_SELECTED_COLOR_KEY_NAME] = selectedColor;

        // ステートの保存
        this._context.mode.setControlState(this._stateDictionary);
    }
}
```

7\. 必須である getOutputs と destroy メソッドを追加。今回は処理はなし。

```typescript
public getOutputs(): IOutputs {
    return {};
}

public destroy(): void {
}
```

# コントロールのパッケージ化と配布

これまでと同じ方法でパッケージ化と配布を実行。

1\. SampleSolution フォルダに移動し、以下のコマンドを実行。

```shell
pac solution add-reference --path ../StateControl
```

2\. SampleSolution\Other\Solution.xml でバージョン情報を更新。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/a1c8cf74-e1f1-6538-4ff8-f027d96932c9.png)

3\. 以下コマンドでパッケージをビルド。

```shell
msbuild /restore
msbuild /p:configuration=Release
```

4\. https://make.powerapps.com に接続。管理者権限でログイン。すでに SampleSolution を入れた環境で Solutions を選択し、Import をクリック。コンパイルした zip をインポート。インポート時にパッケージが更新される旨が表示されるので、そのまま次へ。
<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/e589ec0a-8fa7-fed3-aaca-43ba839df7b0.png"/>

5\. 次の画面も既定のまま「インポート」をクリック。
<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/96b6d3ea-eb24-e326-4d55-48882b22368c.png"/>

6\. インポートが終わったら Data | Entities | 取引先企業 | Forms | 取引先企業フォームを開き、「Switch to classic」をクリック。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/ae8e7c7d-deea-afc6-85f3-3841fa8366aa.png)

7\. 任意の文字列フィールドのプロパティよりコントロールを選択し、開発したコントロールを追加。
<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/02bca775-c40e-d711-84c8-6dc48054cd78.png"/>

8\. 全ての保存してフォームを公開。

公開が完了すれば、記事の初めに紹介したような動作となります。

# まとめ

ステートはレコード単位で自動的に管理されるため、開発者は渡されたステートの確認と、ステートの復元に集中できます。また個別のステートを保存する必要がなく、ディクショナリ形式で渡せるのもシンプルでいい感じです。次回は画像のアップロードを見ていきます。

[目次へ戻る](https://qiita.com/kenakamu/items/5dd4c64b14c453fa7d85)
[次の記事へ](https://qiita.com/kenakamu/items/ba99090d501bf815a7bf)