PowerApps コンポーネントフレームワーク : 画像アップロードサンプル

[前回](https://qiita.com/kenakamu/items/489d8c9dafbcc18829e0)に引き続き、今回は画像をアップロードするサンプルを解説します。

# プロジェクトの作成

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

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

```shell
mkdir ImageUploadControl
cd ImageUploadControl
```

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

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

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

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

```shell
npm install
code .
```

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

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

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

- Multiple 文字列フィールドに画像データを保存
- resources に ts と css、既定の画像 を指定
- feature-usage : 今回は利用する機能として Device.pickFile を指定

```xml:ControlManifest.Input.xml
<?xml version="1.0" encoding="utf-8" ?>
<manifest>
  <control namespace="SampleNamespace" constructor="TSImageUploadControl" version="1.0.0" 
    display-name-key="Image Upload Control" description-key="Image Upload Control" control-type="standard">
    <property name="value" display-name-key="image value" description-key="image value" 
      of-type="Multiple" usage="bound" required="true" />
    <resources>
      <code path="index.ts" order="1" />
      <css path="css/TS_ImageUploadControl.css" order="1" />
      <img path="img/default.png" />
    </resources>
    <feature-usage>
      <uses-feature name="Device.pickFile" required="true" />
    </feature-usage>
  </control> 
</manifest>
```

2\. ファイルの作成から css フォルダパスを含めて TS_ImageUploadControl.css を作成。中身を以下に差し替え。

```css:TS_ImageUploadControl.css
.SampleNamespace\.TSImageUploadControl button{
text-decoration: none;
display: block;
font-size: 14px;
margin: 4px 6px;
cursor: pointer;
color: white;
border-radius: 0px;
background-color: rgb(59, 121, 183);
border: none;
padding: 5px;
text-align: center;
}
.SampleNamespace\.TSImageUploadControl img{
display: block;
box-shadow: 0 5px 10px 0 rgba(30, 30, 30, 0.3);
width: 100px;
height: 100px;
}
.SampleNamespace\.TSImageUploadControl .NoImage>.RemoveButton {
display: none;
}
.SampleNamespace\.TSImageUploadControl label{
display: none;
}
.SampleNamespace\.TSImageUploadControl .ShowError>label{
display: block;
color: red;
}
```

3\. img フォルダを作成して、既定の画像を指定。今回は[本家のサンプル](https://go.microsoft.com/fwlink/?linkid=2088525)にあった画像をそのまま利用。

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

```shell
npm run build
```

# index.ts の編集

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

```typescript
import {IInputs, IOutputs} from "./generated/ManifestTypes";
// 既定の画像名
const DefaultImageFileName:string = "default.png";
// エラー表示用クラス名
const ShowErrorClassName = "ShowError";
// 画像無し用クラス名
const NoImageClassName = "NoImage";
// 画像削除用クラス名
const RemoveButtonClassName = "RemoveButton";
```
2\. クラスとプロパティの定義。

```typescript
export class TSImageUploadControl implements ComponentFramework.StandardControl<IInputs, IOutputs> 
{
    // 画像の値 
    private _value: string | null;
    // コンテキスト
    private _context: ComponentFramework.Context<IInputs>;
    // 変更を画面に通知するイベントハンドラー
    private _notifyOutputChanged: () => void;
    // コントロールをホストするコンテナ
    private _container: HTMLDivElement;
    // 画像アップロードボタン
    private uploadButton: HTMLButtonElement;
    // 画像削除ボタン
    private removeButton: HTMLButtonElement;
    // 画像エレメント
    private imgElement: HTMLImageElement;
    // エラー用ラベル
    private errorLabelElement: HTMLLabelElement;
}
```

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

```typescript
/**
    * init メソッド
    * @param context : 各種オブジェクトや API へのアクセスを提供するコンテキスト
    * @param notifyOutputChanged : 出力変更通知のコールバック
    * @param state : 前回保存したステート
    * @param container : UI コントロールを保持するコンテナ
    */
public init(context: ComponentFramework.Context<IInputs>, notifyOutputChanged: () => void, state: ComponentFramework.Dictionary, container:HTMLDivElement)
{
    this._context = context;
    this._notifyOutputChanged = notifyOutputChanged;
    // コンテナ
    this._container = document.createElement("div");
    // アップロードボタンの作成およびイベントの設定
    this.uploadButton = document.createElement("button");
    this.uploadButton.innerHTML = "画像のアップロード";
    this.uploadButton.addEventListener("click", this.onUploadButtonClick.bind(this));
    // 画像エレメントの作成
    this.imgElement = document.createElement("img");
    // 削除ボタンの作成およびイベントの設定
    this.removeButton = document.createElement("button");
    this.removeButton.classList.add(RemoveButtonClassName);
    this.removeButton.innerHTML = "画像の削除"
    this.removeButton.addEventListener("click", this.onRemoveButtonClick.bind(this));
    // エラーラベルの作成
    this.errorLabelElement = document.createElement("label");
    // 画像データがある場合は表示
    if(this._context.parameters.value.raw)
    {
        this.imgElement.src = context.parameters.value.raw;
    }
    else
    {
        this.setDefaultImage();
    }
    // すべてのコントロールをコンテナに設定し PCF に設定
    this._container.appendChild(this.uploadButton);
    this._container.appendChild(this.imgElement);
    this._container.appendChild(this.removeButton);
    this._container.appendChild(this.errorLabelElement);
    container.appendChild(this._container);
}
```

4\. PCF からの変更通知を処理する updateView メソッドを追加。ここではコンテキストのみ保存。

```typescript
public updateView(context: ComponentFramework.Context<IInputs>): void
{
    this._context = context;
}
```

5\.　ボタンクリック時の処理である onUploadButtonClick と onRemoveButtonClick メソッドを追加。

- context.device.pickFile: ファイル選択のダイアログを開く

```typescript
private onUploadButtonClick(event: Event): void 
{	
    // context.device.pickFile(successCallback, errorCallback) でファイルを取得
    this._context.device.pickFile().then(this.processFile.bind(this), this.showError.bind(this));
}

private onRemoveButtonClick(event: Event): void 
{
    // 規定の画像にセット
    this.setDefaultImage();
}
```

6\. ボタンの処理から呼ばれる processFile メソッドを追加。

- string|undefined と記述することで複数の型を指定可能
- pop() : 配列の最後を取得

```typescript
private processFile(files: ComponentFramework.FileObject[]): void
{
    // ファイルが選択された場合
    if(files.length > 0)
    {
        // 初めのファイルだけ処理
        let file: ComponentFramework.FileObject = files[0];
        try
        {
            // string または undefined 型として拡張子を取得
            let fileExtension: string|undefined;
            if(file && file.fileName)
            {
                fileExtension = file.fileName.split('.').pop();
            }
            if(fileExtension)
            {
                this.setImage(true, fileExtension, file.fileContent);
                this._container.classList.remove(NoImageClassName);
            }
            else
            {
                this.showError();
            }
        }
        catch(err)
        {
            this.showError();
        }
    }
}
```

7\. 画像設定関連のメソッドを追加。

- context.resources.getResource : リソースを取得。ここでは既定の画像を取得

```typescript
private setDefaultImage():void
{
    // context.resources よりリソースとして登録している画像を取得
    this._context.resources.getResource(DefaultImageFileName, this.setImage.bind(this, false, "png"), this.showError.bind(this));
    this._container.classList.add(NoImageClassName);
    // 画像がある場合は値を削除
    if(this._context.parameters.value.raw)
    {
        this._value = null;
        this._notifyOutputChanged();
    }
}

/**
* 画像の設定
* @param shouldUpdateOutput PCF 側の値を戻すか指定
* @param fileType ファイルの種類。"png", "gif", "jpg"
* @param fileContent base64 フォーマットの画像データ
*/
private setImage(shouldUpdateOutput:boolean, fileType: string, fileContent: string): void
{
    // 画像のタイプと合わせて画像エレメント用にソースデータを作成して設定
    let imageUrl:string = this.generateImageSrcUrl(fileType, fileContent);
    this.imgElement.src = imageUrl;
    if(shouldUpdateOutput)
    {
        this._container.classList.remove(ShowErrorClassName);
        this._value = imageUrl;
        this._notifyOutputChanged();
    }
}

/**
* 画像ソース用のデータ作成
* @param fileType ファイルの種類。"png", "gif", "jpg"
* @param fileContent base64 フォーマットの画像データ
*/
private generateImageSrcUrl(fileType: string, fileContent: string): string
{
    return  "data:image/" + fileType + ";base64, " + fileContent;
}
```

8\. エラー出力用のメソッドを追加。

```typescript
private showError(): void
{
    this.errorLabelElement.innerText = "ファイルが見つかりませんでした";
    this._container.classList.add(ShowErrorClassName);
}
```

9\. 必須である getOutputs と destroy メソッドを追加。

```typescript
public getOutputs(): IOutputs
{
    let result: IOutputs = 
        {
            value: this._value!
        };
        return result;
}

public destroy(): void
{
}
```

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

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

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

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

2\. SampleSolution\Other\Solution.xml でバージョン情報を更新。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/cce36957-a8e5-e957-9c4d-14dc1e043313.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/13a00c48-98a2-8063-765f-aadbe2b36ee4.png"/>

5\. 次の画面も既定のまま「インポート」をクリック。
<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/8ddf0a23-1579-62ba-2837-b4ee66e7f438.png"/>

6\. インポートが終わったら Data | Entities | 取引先企業 | Fields で「Add field」をクリック。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/1f690ea2-129b-8ac9-9af4-b9f56f59d57f.png)

7\. Data Type で 「Multiline Text」を選択し、「Advanced options」をクリック。
<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/28747394-c032-ba3e-dccc-529571e6bcbb.png"/>

8\. 値に最大値である 1,048,576 を設定して OK をクリック。フィールド一覧で「Save Entity」をクリックして変更を確定。
<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/e00aa81b-8e94-bea1-fe8d-14f50c33c375.png"/>

9\. Forms | 取引先企業フォームを開き、「Switch to classic」をクリック。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/ae8e7c7d-deea-afc6-85f3-3841fa8366aa.png)

10\. 作成した image フィールドをフォームに配置。プロパティよりコントロールを選択し、開発したコントロールを追加。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/a6ae9cb0-cd9b-b1f0-8f2e-aaeddccb5492.png)

11\. すべての保存してフォームを公開。

# 動作確認

最後に動作を確認しましょう。

1\. アプリより取引先企業のレコードを開き、開発したコントロールを確認。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/8d5e7c9c-8454-68a9-c18e-6911efd1e284.png)

2\. 「画像のアップロード」をクリックして任意の画像を開く。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/8eb69c8f-831a-1f31-7941-1d3ce9888b0f.png)

3\. レコードを保存後、レコードを開きなおして画像が存在することを確認。ビューで image 列を表示すれば実際のデータも確認可能。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/4d8da0ae-5ead-af52-6e53-70ced4ed7e3f.png)

# まとめ

今回のソリューションは画像を base64 文字列としてそのまま保存していますが、Azure Storage に保存するなどいろいろやり方は考えられます。次回は Web API のサンプルを見ていきます。

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