8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

PCFとPowerApps CLIでカスタムコンポーネントを触ってみる。

Last updated at Posted at 2019-05-03

本稿の内容はPublic Preview版で日本語の資料が皆無なため、かなり適当な意訳が含まれます。(私は英語が苦手です。泣)
間違った内容も少なからず含まれていると思いますのでその辺りはご容赦ください!

また、ココで扱っている内容は随時更新されていきます。最新情報はMicrosoftの公式Docsを確認してください。

概要

PCFとPowerApps CLIを利用すると、モデル駆動型アプリで利用できる独自のコンポーネント(カスタムコンポーネント)を開発することができます。(キャンバスアプリで言う所のComponentsとは別物(?)ぽいので注意。少なくとも今はキャンバスには対応していません。)

PCFはPowerApps Component Frameworkの略で、カスタムコンポーネントを作成する際のフレームワークを指します。
PowerApps CLIはPCFを使ってカスタムコンポーネントを開発するためのCUIツールです。

カスタムコンポーネントを開発すると、既存のモデル駆動型アプリよりも更に高いUXを提供することが可能になります。但し、開発とあるように、PCFとPowerApps CLIが対象とするのはプロの開発者であり、ローコードソリューションを求めるユーザはターゲットではありません。

カスタムコンポーネントはモデル駆動型アプリのソリューションコンポーネントの1つとして分類されます。
このため、1度作成したカスタムコンポーネントはソリューションに含めてエクスポートすることができ、AppSourceで配信することができるそうです。ここで収益化を狙うことも可能というわけですね。

今回はこのカスタムコンポーネントについてサラッと触ってみました。

なお以降は、

  • PowerAppsアプリの利用者を「ユーザ」
  • PowerAppsアプリを作成する人を「アプリの作成者」
  • カスタムコンポーネントを開発するプロの開発者を「コンポーネントの開発者」

と呼び分けます。コンポーネントの開発者は、ユーザだけでなくアプリの作成者のことも考えて開発する必要があるわけですね。

どう開発するのか(大ざっぱな話)

開発言語はTypeScriptです。また、コンポーネントのViewに関するレイアウト定義はCSSで行います。

上記で察した人もいるかも知れませんが、HTML/CSS/JSで行われるWEBフロントエンド開発と同じような感覚で開発できるとのこと。
ただ、実際にHTMLのマークアップをするわけではなく、TypeScriptでViewをinitするときに、DOM ElementをCreateし、そのElementのAttributeにClassをSetしたり、EventListenerをAddしたりという感じっぽいです。

このためWEB開発のプラクティスを知っている人からすればある程度はすんなり受け入れられるような気がします。

カスタムコンポーネントの構成要素

カスタムコンポーネントには3つの構成要素があります。

Manifest

マニフェストはコンポーネントに関する主要なメタデータを定義するXMLファイルです。
コンポーネントの名前や名前空間、コンポーネントで扱えるデータの種類やプロパティなどを定義することが可能です。

例えばこのマニフェストで定義されたプロパティはアプリの作成者がコンポーネントを追加したときに使用できるプロパティとして画面に表示されます。コンポーネントの開発者はこの値を利用してアプリの作成者もしくはユーザからのデータを受け取る事ができます。

Component implementation library

要するにカスタムコンポーネントのロジック実装部分です。前述の通りTypeScriptで開発するため、「〇〇.ts」ファイルになります。

コンポーネントの実装では以下のメソッドを実装する必要があります。

メソッド名 役割 必須
init() コンポーネントインスタンスを初期化します。レンダリングするElement情報などもここで実装します。 Yes
updateView() このメソッドはプロパティの更新があった際にコールされます。値によってViewを書き換えたりなど動的な処理をすることが出来そうです。 Yes
getOutputs() コンポーネントが新しいデータを受け取る前にPCFによってコールされます。データを別途変数に格納したりする場合に実装するそうです。 -
destroy() コンポーネントがDOMツリーから削除されるときにコールされます。イベントハンドラの削除など、ゴミの後始末系はココで行えば良さそうですね。 Yes

Resources

コンポーネントが構成されるために必要なリソースファイルを総称しています。
当然上記のTSファイルも含まれますし、画面構成を定義するCSSファイルや画像ファイルなども含まれます。
要は必要なもの一式ってことですね。これらはManifestファイル内で所在を定義し、コンポーネントはManifestに従って各種リソースへアクセスします。

開発環境を用意する。

カスタムコンポーネントの開発に必要な環境は以下です。

  • Node.js
  • VisualStudio 2017以降(もしくは、.NET Core 2.2 SDK + VSCode)
  • Microsoft PowerApps CLI

PowerApps CLIはココからDLできます。(※直リンク)

試したときはNode.js 10.15.3、VS 2019 Communityでやりました。
とは言っても、VS 2019本体ではなく、これに付属するCMDを使うだけなので、エディタ用にVSCodeもあるといいと思います。お遊び程度に軽く開発したいですよね。

サンプルを動かしてみよう!

ココの内容を踏襲するだけです。
大まかな流れしか記載しないので、ソースの解説を見たい人はDocs読んでください。

ワークスペースの作成

以下の作業はVisual Studioの開発者コマンドプロンプトで行います。
Win + QでDeveloper Command...とか打てば出てくると思います。

1.適当な場所に適当なディレクトリを掘って移動します。

mkdir LinearControl
cd LinearControl

2.次のコマンドを実行してコンポーネントのプロジェクトテンプレートを作成します。

pac pcf init --namespace SampleNamespace --name TSLinearInputControl --template field

--namespaceは名前空間
--nameはコンポーネント名
--templateはコンポーネントのテンプレート
を指定します。テンプレートは今の所、[field]か[dataset]しか使えません。

3.npmでプロジェクト構築に必要な依存関係をインストールします。

npm install

これで開発の準備が整いました。

マニフェストの編集

先程の作業で、ディレクトリ内にTSLinearInputControlというディレクトリが掘られているはずです。
この中に、ControlManifest.Input.xmlというファイルがあるので、これを以下のように編集します。

ControlManifest.Input.xml
<?xml version="1.0" encoding="utf-8" ?>
<manifest>
  <control namespace="SampleNamespace" constructor="TSLinearInputControl" version="0.0.1" display-name-key="TSLinearInputControl_Display_Key" description-key="TSLinearInputControl_Desc_Key" control-type="standard">
    <!-- property node identifies a specific, configurable piece of data that the control expects from CDS -->
    <property name="sliderValue" display-name-key="slidervalue_Display_Key" description-key="slidervalue_Desc_Key" of-type-group="numbers" usage="bound" required="true" />
    <type-group name="numbers">
      <type>Whole.None</type>
      <type>Currency</type>
      <type>FP</type>
      <type>Decimal</type>
    </type-group>
    <resources>
      <code path="index.ts" order="1"/>
      <css path="css/TS_LinearInputControl.css" order="1"/>
    </resources>
  </control>
</manifest>

<property /> <type-group />タグでプロパティとデータの種類を定義し、<resources />タグ内でTSファイルやCSSファイルの所在を定義している感じですね。

コンポーネントロジックの実装

いよいよコンポーネントのロジック部分です。先程のControlManifest.Input.xmlと同じ階層に、index.tsというファイルがあるはずです。これを以下のように編集します。

index.ts
import { IInputs, IOutputs } from "./generated/ManifestTypes";

export class TSLinearInputControl implements ComponentFramework.StandardControl<IInputs, IOutputs> {
	// Value of the field is stored and used inside the control 
	private _value: number;
	// PCF framework delegate which will be assigned to this object which would be called whenever any update happens. 
	private _notifyOutputChanged: () => void;
	// label element created as part of this control
	private labelElement: HTMLLabelElement;
	// input element that is used to create the range slider
	private inputElement: HTMLInputElement;
	// Reference to the control container HTMLDivElement
	// This element contains all elements of our custom control example
	private _container: HTMLDivElement;
	// Reference to ComponentFramework Context object
	private _context: ComponentFramework.Context<IInputs>;
	// Event Handler 'refreshData' reference
	private _refreshData: EventListenerOrEventListenerObject;

	/**
	 * Empty constructor.
	 */
	constructor() {

	}

	/**
	 * Used to initialize the control instance. Controls can kick off remote server calls and other initialization actions here.
	 * Data-set values are not initialized here, use updateView.
	 * @param context The entire property bag available to control via Context Object; It contains values as set up by the customizer mapped to property names defined in the manifest, as well as utility functions.
	 * @param notifyOutputChanged A callback method to alert the framework that the control has new outputs ready to be retrieved asynchronously.
	 * @param state A piece of data that persists in one session for a single user. Can be set at any point in a controls life cycle by calling 'setControlState' in the Mode interface.
	 * @param container If a control is marked control-type='starndard', it will receive an empty div element within which it can render its content.
	 */
	public init(context: ComponentFramework.Context<IInputs>, notifyOutputChanged: () => void, state: ComponentFramework.Dictionary, container: HTMLDivElement) {
		// Add control initialization code
		this._context = context;
		this._container = document.createElement("div");
		this._notifyOutputChanged = notifyOutputChanged;
		this._refreshData = this.refreshData.bind(this);
		// creating HTML elements for the input type range and binding it to the function which refreshes the control data
		this.inputElement = document.createElement("input");
		this.inputElement.setAttribute("type", "range");
		this.inputElement.addEventListener("input", this._refreshData);
		//setting the max and min values for the control.
		this.inputElement.setAttribute("min", "1");
		this.inputElement.setAttribute("max", "1000");
		this.inputElement.setAttribute("class", "linearslider");
		this.inputElement.setAttribute("id", "linearrangeinput");
		// creating a HTML label element that shows the value that is set on the linear range control
		this.labelElement = document.createElement("label");
		this.labelElement.setAttribute("class", "TS_LinearRangeLabel");
		this.labelElement.setAttribute("id", "lrclabel");
		// retrieving the latest value from the control and setting it to the HTMl elements.
		this._value = context.parameters.sliderValue.raw;
		this.inputElement.setAttribute("value", context.parameters.sliderValue.formatted ? context.parameters.sliderValue.formatted : "0");
		this.labelElement.innerHTML = context.parameters.sliderValue.formatted ? context.parameters.sliderValue.formatted : "0";
		// appending the HTML elements to the control's HTML container element.
		this._container.appendChild(this.inputElement);
		this._container.appendChild(this.labelElement);
		container.appendChild(this._container);
	}

	/**
	* Updates the values to the internal value variable we are storing and also updates the html label that displays the value
	* @param context : The "Input Properties" containing the parameters, control metadata and interface functions
	*/

	public refreshData(evt: Event): void {
		this._value = (this.inputElement.value as any) as number;
		this.labelElement.innerHTML = this.inputElement.value;
		this._notifyOutputChanged();
	}

	/**
	 * Called when any value in the property bag has changed. This includes field values, data-sets, global values such as container height and width, offline status, control metadata values such as label, visible, etc.
	 * @param context The entire property bag available to control via Context Object; It contains values as set up by the customizer mapped to names defined in the manifest, as well as utility functions
	 */
	public updateView(context: ComponentFramework.Context<IInputs>): void {
		// Add code to update control view
		// storing the latest context from the control.
		this._value = context.parameters.sliderValue.raw;
		this._context = context;
		this.inputElement.setAttribute("value",context.parameters.sliderValue.formatted ? context.parameters.sliderValue.formatted : "");
		this.labelElement.innerHTML = context.parameters.sliderValue.formatted ? context.parameters.sliderValue.formatted : "";
	}

	/** 
	 * It is called by the framework prior to a control receiving new data. 
	 * @returns an object based on nomenclature defined in manifest, expecting object[s] for property marked as “bound” or “output”
	 */
	public getOutputs(): IOutputs {
		return {
			sliderValue: this._value
		};
	}

	/** 
	 * Called when the control is to be removed from the DOM tree. Controls should use this call for cleanup.
	 * i.e. cancelling any pending remote calls, removing listeners, etc.
	 */
	public destroy(): void {
		// Add code to cleanup control if necessary
		this.inputElement.removeEventListener("input", this._refreshData);
	}
}

index.tsファイルは、テンプレートをインストールした時点で最低限の内容は書かれています。
メソッドを眺めてもらえればわかりますが、カスタムコンポーネントの構成要素で紹介した実装しなければ行けないメソッド達は全てココに書かれています。特にinit()なんかでは、DOM Elementを作ったりClassを付与したり、イベントリスナーをかけているのがわかりますね。

リソースファイルを追加する

最後にリソースファイルを追加していきます。リソースファイルはいろいろありますが、今回は画面レイアウトを定義するスタイルシート(CSSファイル)を追加します。

CSSファイルはマニフェストに書いたとおりに配置します。マニフェストでは次のように定義しました。

ControlManifest.Input.xml
<css path="css/TS_LinearInputControl.css" order="1"/> 

cssディレクトリの下にTS_LinearInputControl.cssですね。作ってVSCodeとかで以下のように編集してください。

TS_LinearInputControl.css
.SampleNamespace\.TSLinearInputControl input[type=range].linearslider {
    margin: 1px 0;
    background: transparent;
    -webkit-appearance: none;
    width: 100%;
    padding: 0;
    height: 24px;
    -webkit-tap-highlight-color: transparent
}

.SampleNamespace\.TSLinearInputControl input[type=range].linearslider:focus {
    outline: none;
}

.SampleNamespace\.TSLinearInputControl input[type=range].linearslider::-webkit-slider-runnable-track {
    background: #666;
    height: 2px;
    cursor: pointer
}

.SampleNamespace\.TSLinearInputControl input[type=range].linearslider::-webkit-slider-thumb {
    background: #666;
    border: 0 solid #f00;
    height: 24px;
    width: 10px;
    border-radius: 48px;
    cursor: pointer;
    opacity: 1;
    -webkit-appearance: none;
    margin-top: -12px
}

.SampleNamespace\.TSLinearInputControl input[type=range].linearslider::-moz-range-track {
    background: #666;
    height: 2px;
    cursor: pointer
}

.SampleNamespace\.TSLinearInputControl input[type=range].linearslider::-moz-range-thumb {
    background: #666;
    border: 0 solid #f00;
    height: 24px;
    width: 10px;
    border-radius: 48px;
    cursor: pointer;
    opacity: 1;
    -webkit-appearance: none;
    margin-top: -12px
}

.SampleNamespace\.TSLinearInputControl input[type=range].linearslider::-ms-track {
    background: #666;
    height: 2px;
    cursor: pointer
}

.SampleNamespace\.TSLinearInputControl input[type=range].linearslider::-ms-thumb {
    background: #666;
    border: 0 solid #f00;
    height: 24px;
    width: 10px;
    border-radius: 48px;
    cursor: pointer;
    opacity: 1;
    -webkit-appearance: none;
}

後はビルドする!

一通りの準備が出来たら最後にビルドします。package.jsonbuildでエイリアスされているので以下でビルドできます。

npm run build

そうすると何やらゴチャゴチャ流れて

> pcf-project@1.0.0 build C:\[YOUR_PATH]\LinearControl
> pcf-scripts build

Kicking off PCF build process...
[Build task]: Validating control manifest...
[Build task]: Generating manifest types...
[Build task]: Compiling and bundling control...
[Build stats]:
Hash: XXXXXXXXXXXXXXXX
Version: webpack 4.28.4
Time: 6483ms
Built at: 05/03/2019 1:56:52 PM
    Asset      Size  Chunks             Chunk Names
bundle.js  9.15 KiB    main  [emitted]  main
Entrypoint main = bundle.js
[./TSLinearInputControl/index.ts] 4.99 KiB {main} [built]
[Build task]: Generating build outputs...
[Done] build succeeded.

のようになれば成功です。

成功すると、out/controls/TSLinearInputControlに一式が格納されています。

デバッグしてみる

同じようにpackage.jsonにエイリアスされています。次のように実行します。

npm start

するとNode.js上でカスタムコンポーネントが実行され、ブラウザが立ち上がります。
デバッグはブラウザのデバッグツールを使ってデバッグしていきます。ほんとにWEB開発と似てますね。
Debug.png

今回作成したコンポーネントはスライドバーで1~1000の値を入力することができるインターフェースでした。
デバッグ時の画面構成としては大きく3つに別れており、コンポーネントをレンダリングしている部分と、プロパティへの入力値を示すInputs部分、コンポーネントが出力している値を表示するOutputs部分があります。


こんな感じで、PCFとPowerApps CLIを用いてカスタムコンポーネントを開発することが出来ました。
今はまだ作れるコンポーネントのテンプレートが少なく、モデル駆動型アプリのみでしか使えませんが、キャンバスアプリでも使えるようになったら実際にアプリにもデプロイしてみみたいと思います。

参考リンク

8
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?