Flexible Programming Model: 登録と照会で別のページを使う試み（2）

## はじめに
[前回](https://qiita.com/tami/items/bd16adb3ca014cf00b1b)の記事で、Flexible Programming Modelを使って以下を実現しようとしました。

- List Reportで"Create"ボタンを押したらカスタムページを開く
- テーブル行をクリックしたらObject Pageを開く

**前回の構成**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/8a569900-46a4-6c0d-0606-6db921d97cee.png)

しかし、この構成では「**Object Pageを編集モードに切り替えると、カスタムページが表示されてしまう**」という問題がありました。前回の記事では代替案として以下の方法を考えました。

**案1**
- 登録用のカスタムページは既存のページとは別のURLパターンで開くようにする
- カスタムの「登録」ボタンを作成し、カスタムボタンが押されたらカスタムページを開く

**案2**
- 登録画面はBuilding Blocksを使用して独立したアプリケーションとして作り、カスタムの「登録」ボタンが押されたら登録画面にナビゲーションする

**今回の構成**
今回は案2をベースに実装することとしました。カスタムの登録ボタンを出すにあたり標準の登録ボタンをどうやって消そうかと考えた結果、List Reportもカスタムページとすることにしました。当初は案1（リストと登録画面が1つのアプリ）で考えていましたが、それだと登録画面に独自のURLパターンを使えない（※）ことがわかったため、登録画面を別アプリとしました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/516ed417-ec5a-80b8-e531-f66fcfa17345.png)

※登録画面に独自のURLパターンを使えない制約について
Flexible Programming Modelで、新規エンティティの登録は[EditFlow](https://sapui5.hana.ondemand.com/#/api/sap.fe.core.controllerextensions.EditFlow)のcreateDocumentメソッドで行います。
createDocumentのパラメータであるcreationModeには以下の3つがあります。
- **NewPage**: Object Pageを開く
- **Inline**: List Reportに行追加する
- **External**: 外部のアプリケーションを開く

NewPageの場合、createDocumentを実行した時点でURLパターンが自動的に`Entity名(key)`になります。このURLパターンではもともとのObjedct Pageが開いてしまうため、Externalを使用する必要があるという結論になりました。


## 前提
- OData V4を使用していること
- ドラフトが有効であること
以下のリポジトリにあるCAPのプロジェクトをベースに作成します。
https://github.com/miyasuta/flex-orders

UIアノテーションは以下のファイルで設定しています。
https://github.com/miyasuta/flex-orders/blob/main/srv/order-srv-ui.cds

## ステップ
**アプリ1**
1. Custom Pageのテンプレートでアプリを生成
1. List Report風のカスタムページを作成
1. 登録ボタン押下時の処理を実装
1. Object Pageへのナビゲーションを実装

**アプリ2**
1. Custom Pageのテンプレートでアプリを生成
1. 登録処理の実装
1. ルーティングの設定
1. 登録用のWizardを作成

### 1. アプリ1
#### 1.1. Custom Pageのテンプレートでアプリを生成
ウィザードで"Custom Page"を選択してアプリを生成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/6ed6b146-a49e-8e86-0874-f422dcb946cf.png)

Custom Pageのテンプレートで作ると、extフォルダにビュー、コントローラーが作成されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/0984a6a7-c4d4-6cf3-fee4-43a69643057a.png)

そのまま実行してみると、何もない画面が開きます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/d4b88125-f4f5-0f48-105e-494f6cde69af.png)

#### 1.2. List Report風のカスタムページを作成
TechEd 2022のハンズオンを参考に、カスタムページをList Report風にしていきます。
https://github.com/SAP-samples/teched2022-DT181/blob/main/exercises/ex3/README.md

- namespaceに`xmlns:f="sap.f"`を追加

```xml:/ext/main/Main.view.xml
<mvc:View xmlns:core="sap.ui.core" 
    xmlns:mvc="sap.ui.core.mvc" 
    xmlns="sap.m" 
    xmlns:macros="sap.fe.macros"
    xmlns:f="sap.f"
    xmlns:html="http://www.w3.org/1999/xhtml" controllerName="flex.customlistreport.ext.main.Main">
```

- DyamicPageでレイアウトを作成
ここではBuilding Blocksの[FilterBar](https://sapui5.hana.ondemand.com/test-resources/sap/fe/core/fpmExplorer/index.html#/buildingBlocks/filterBar/filterBarDefault)と[Table](https://sapui5.hana.ondemand.com/test-resources/sap/fe/core/fpmExplorer/index.html#/buildingBlocks/table/tableDefault)を使っています。

```xml:/ext/main/Main.view.xml
        <f:DynamicPage id="FilterBarDefault" class="sapUiResponsiveContentPadding">
        <f:title>
            <f:DynamicPageTitle id="_IDGenDynamicPageTitle1">
                <f:heading>
                    <Title id="_IDGenTitle1" text="Manage Orders" level="H2" />
                </f:heading>
            </f:DynamicPageTitle>
        </f:title>
        <f:header>
            <f:DynamicPageHeader id="_IDGenDynamicPageHeader1" pinnable="true">
                <VBox id="_IDGenVBox1">
                    <macros:FilterBar 
                        metaPath="@com.sap.vocabularies.UI.v1.SelectionFields" 
                        id="FilterBar" 
                        filterChanged=".handlers.onFiltersChanged" />
                </VBox>
            </f:DynamicPageHeader>
        </f:header>
        <f:content>
            <macros:Table 
            id="myTable" 
            filterBar="FilterBar"
            readOnly="true"
            metaPath="@com.sap.vocabularies.UI.v1.LineItem" />
        </f:content>
    </f:DynamicPage>
```
実行すると、すでにList Reportらしい見た目になっています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/ae8ca787-399f-1189-75e3-9207fca43191.png)

#### 1.3. 登録ボタン押下時の処理を実装

- ビュー
フィルターバーに"Create"ボタンを表示させます。

```xml
            <macros:Table 
            id="myTable" 
            filterBar="FilterBar"
            readOnly="true"
            metaPath="@com.sap.vocabularies.UI.v1.LineItem">
                <macros:actions>
                    <macros:Action
                        key="customAction"
                        text="Create"
                        press=".onCreate"
                        requiresSelection="false"
                    />
                </macros:actions>
            </macros:Table>
```

- コントローラー
コントローラーにボタン押下時の処理を実装します。[EditFlow](https://sapui5.hana.ondemand.com/#/api/sap.fe.core.controllerextensions.EditFlow)のcreateDocumentメソッドで、creationModeに"External"を指定します。outboundにはmanifest.json（後述）で定義するoutboundのナビゲーション先を指定します。creationModeを"External"にした場合、ドキュメントには記載がありませんでしたが`handlers.onChevronPressNavigateOutBound`というメソッドの実装がないとエラーになってしまいました。ここで何をしたらよいかわかりませんでしたが、とりあえず外部アプリへのナビゲーションを実行しました（結果、うまくいきました）。

※creationModeが"External"の場合、ナビゲーション元ではエンティティを作成しないので、普通に外部へのナビゲーションを実行してもよいです。たとえば、LineItemで`DataFieldForIntentBasedNavigation`アノテーションを使用してナビゲーションの設定をしても同じ結果になります。

```javascript:/ext/main/Main.controller.js
sap.ui.define(
    [
        'sap/fe/core/PageController'
    ],
    function(PageController) {
        'use strict';

        return PageController.extend('flex.customlistreport.ext.main.Main', {
            onCreate: function() {
                const listBinding = this.getView().getModel().bindList("/Orders");
                this.editFlow.createDocument(listBinding, {
                    creationMode: "External",
                    outbound: "create-wizard"
                })               
            },

            handlers: {
                onChevronPressNavigateOutBound: function(oController, outbound, undefined, sCreatePath) {
                    oController.intentBasedNavigation.navigateOutbound(outbound);
                }
            }
        });
    }
);
```

- manifest.json
crossNavigationを以下のように設定します。

```json:manifest.json
        "crossNavigation": {
            "inbounds": {
                "flex-customlistreport-inbound": {
                    "signature": {
                        "parameters": {},
                        "additionalParameters": "allowed"
                    },
                    "semanticObject": "Order",
                    "action": "customlistreport",
                    "title": "{{flpTitle}}",
                    "subTitle": "{{flpSubtitle}}",
                    "icon": ""
                }
            },
            "outbounds": {
                "create-wizard": {
                    "semanticObject": "Order",
                    "action": "createWithWizard",
                    "additionalParameters": "allowed"
                }
            }
        }
```
#### 1.4. Object Pageへのナビゲーションを実装
Object Pageの追加およびナビゲーションはPage Mapの設定だけで完了し、コーディングは不要です。

Application Infoページから、Page Mapを開きます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/05b0c555-9a2c-a6c0-2279-c127dc6f3cca.png)
+をクリックしてページを追加します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/2f5b2ec4-7194-be0f-e76f-b2760b996ad0.png)
Page Typeに**ObjectPage**を選択し、NavigationにCustom Pageと同じエンティティを選択して**Add**をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/2a8fa46a-43f9-8eb3-bb44-fce289083ad5.png)

manifest.jsonに自動的にObject Page用のルートが追加されます。

```json
    "routing": {
      "routes": [
        {
          "name": "OrdersMain",
          "pattern": ":?query:",
          "target": "OrdersMain"
        },
        {
          "name": "OrdersObjectPage",
          "pattern": "Orders({OrdersKey}):?query:",
          "target": "OrdersObjectPage"
        }
      ],
      "targets": {
        "OrdersMain": {
          "type": "Component",
          "id": "OrdersMain",
          "name": "sap.fe.core.fpm",
          "options": {
            "settings": {
              "viewName": "flex.customlistreport.ext.main.Main",
              "entitySet": "Orders",
              "navigation": {
                "Orders": {
                  "detail": {
                    "route": "OrdersObjectPage"
                  }
                }
              }
            }
          }
        },
        "OrdersObjectPage": {
          "type": "Component",
          "id": "OrdersObjectPage",
          "name": "sap.fe.templates.ObjectPage",
          "options": {
            "settings": {
              "entitySet": "Orders",
              "navigation": {}
            }
          }
        }
      }
    }
```

### 2. アプリ2
#### 2.1. Custom Pageのテンプレートでアプリを生成
アプリ1と同様に、"Custom Page"のテンプレートでアプリを作成します。

#### 2.2. 登録処理の実装
Mainのビューはアプリ起動時に最初に呼ばれます。ここで登録処理を実行して、登録用のWizard画面に遷移します。

Mainのビューは空です。

```xml:/ext/main/Main.view.xml
<mvc:View xmlns:core="sap.ui.core" xmlns:mvc="sap.ui.core.mvc" xmlns:macros="sap.fe.macros" controllerName="flex.customformentry.ext.main.Main" xmlns="sap.m">
    <App id="app" />
</mvc:View>
```

Mainのコントローラーで登録処理を実行します。ポイントはonInitではなくrouterの**attachPatternMatched**のタイミングでcreateDocumentを呼ぶことです。onInitのタイミングでは早すぎるためか、フレームワーク側の処理でエラーが起きていました。

```javascript:/ext/main/Main.controller.js
sap.ui.define(
    [
        'sap/fe/core/PageController'
    ],
    function(PageController) {
        'use strict';

        return PageController.extend('flex.customformentry.ext.main.Main', {
            onInit: function() {
                PageController.prototype.onInit.apply(this);
                const router = this.getAppComponent().getRouter();
                router.getRoute("OrdersMain").attachPatternMatched(this._onObjectMatched, this);
            },

            _onObjectMatched: function() {
                const listBinding = this.getAppComponent().getModel().bindList("/Orders");
                this.editFlow.createDocument(listBinding, {
                    creationMode: "NewPage"
                })
            }
        });
    }
);
```

#### 2.3. ルーティングの設定
creationModeが"NewPage"の場合、URLパターンが自動的に"/Orders(key)"となります。よってこのパターンの時に登録用のWizard画面を開くようにルーティングの設定をします。

```json:manifest.json
        "routing": {
            "routes": [
                {
                    "name": "OrdersMain",
                    "pattern": ":?query:",
                    "target": "OrdersMain"
                },
                {
                    "name": "Wizard",
                    "pattern": "Orders({key}):?query:",
                    "target": "Wizard"
                }
            ],
            "targets": {
                "OrdersMain": {
                    "type": "Component",
                    "id": "Main",
                    "name": "sap.fe.core.fpm",
                    "options": {
                        "settings": {
                            "viewName": "flex.customformentry.ext.main.Main",
                            "entitySet": "Orders"
                        }
                    }
                },
                "Wizard": {
                    "type": "Component",
                    "id": "Wizard",
                    "name": "sap.fe.core.fpm",
                    "options": {
                        "settings": {
                            "viewName": "flex.customformentry.ext.wizard.Wizard",
                            "entitySet": "Orders"
                        }
                    }
                }
            }
        }
```

#### 2.4. 登録用のWizardを作成
内容は[前回](https://qiita.com/tami/items/bd16adb3ca014cf00b1b#2-%E3%82%AB%E3%82%B9%E3%82%BF%E3%83%A0%E3%83%9A%E3%83%BC%E3%82%B8%E3%82%92%E4%BD%9C%E6%88%90)の記事で作成したものと基本的には同じです（一部うまく動かない部分もありますが、ここではカスタムページが開ければよしとします。）前回の記事と変えたのは、Create, Cancelなどのボタンが登録/キャンセル後は押せないようにViewモデルのプロパティでコントロールしている点です。

- ビュー

```xml:/ext/wizard/Wizard.view.xml
<mvc:View xmlns:core="sap.ui.core" xmlns:mvc="sap.ui.core.mvc" xmlns:macros="sap.fe.macros" xmlns:form="sap.ui.layout.form" id="review" controllerName="flex.customformentry.ext.wizard.Wizard" xmlns="sap.m">
    <NavContainer id="wizardNavContainer">
        <pages>
            <Page id="createOrderPage" title="Guided Order Creation">
                <content>
                    <Wizard id="CreateOrderWizard" class="sapUiResponsivePadding--header sapUiResponsivePadding--content" complete="reviewOrder">
                        <WizardStep id="stepCustomer" title="Customer" validated="true">
                            <VBox width="800px">      
                                <Text text="Select the customer." />
                                <macros:Field metaPath="customer_ID" id="customer" />
                            </VBox>
                        </WizardStep>
                        <WizardStep id="stepItems" validated="true" title="Items">
                            <macros:Table metaPath="to_Items/@com.sap.vocabularies.UI.v1.LineItem" id="items" busy="{ui>/isBusy}" />
                        </WizardStep>
                    </Wizard>
                </content>
                <footer>
                    <OverflowToolbar>
                        <ToolbarSpacer />
                        <Button id="cancelButton" text="Cancel" press="cancelDocument" 
                            visible="{viewModel>/editable}"/>
                    </OverflowToolbar>
                </footer>
            </Page>
            <Page id="reviewPage" title="Review New Order">
                <content>
                    <macros:Form metaPath="@com.sap.vocabularies.UI.v1.FieldGroup#main" id="reviewGeneral" />
                    <macros:Table enableExport="false" isSearchable="false" personalization="false"
                        metaPath="to_Items/@com.sap.vocabularies.UI.v1.LineItem" id="itemsDisplay"  />
                    <MessageStrip class="sapUiSmallMarginBottom" 
                        type="Warning" 
                        text="By clicking on Create New Order you accept you read and accept our internal order guidelines." 
                        showIcon="true" 
                        visible="{viewModel>/editable}"/>
                    <Button text="Create New Order" press="createOrder" type="Emphasized"
                        visible="{viewModel>/editable}" />
                </content>     
            </Page>
        </pages>
    </NavContainer>
</mvc:View>
```

- コントローラー

```javascript/ext/wizard/Wizard.controller.js
sap.ui.define(
    [
        'sap/fe/core/PageController',
        'sap/ui/model/json/JSONModel',
        'sap/m/MessageToast'
    ],
    function(PageController, JSONModel, MessageToast) {
        'use strict';

        return PageController.extend('flex.customformentry.ext.wizard.Wizard', {
            onInit: function() {
                PageController.prototype.onInit.apply(this);
                let model = {
                    editable : true
                };
                this.getView().setModel(new JSONModel(model), "viewModel");
            },

            reviewOrder: function () {
                this.byId("wizardNavContainer").to(this.byId("reviewPage"));
            },
    
            createOrder: function () {
                var that = this;
                this.editFlow.saveDocument(this.getView().getBindingContext()).then(function(){
                    MessageToast.show("Order created!");
                    that.getView().getModel("viewModel").setProperty("/editable", false);
                })
            },
    
            cancelDocument: function () {
                var that = this;
                this.editFlow.cancelDocument(this.getView().getBindingContext(), {
                    control: this.byId("cancelButton")
                }).then(function(){
                    that.getView().getModel("viewModel").setProperty("/editable", false);
                })
            }
        });
    }
);
```

## 動作確認
### 事前設定
外部アプリへのナビゲーションの確認はLaunchpadからでないとできないため、以下の記事を参考にCAPローカルのLaunchpadの設定を行いました。

https://blogs.sap.com/2022/03/14/a-fiori-launchpad-sandbox-for-all-your-cap-based-projects-overview/

ポイントは、server.jsの設定で**UI5のバージョンを指定しない**（または最新のバージョンを指定する）ことです。Building Blocksは比較的新しいバージョンでないと動作しないためです。今回作成したアプリはバージョン1.109.0で動作確認しています。

```javascript:server.js
const cds = require ('@sap/cds');

if (process.env.NODE_ENV !== 'production') {
    const {cds_launchpad_plugin} = require('cds-launchpad-plugin');

    // Enable launchpad plugin
    cds.once('bootstrap',(app)=>{
        const handler = new cds_launchpad_plugin();
        app.use(handler.setup({theme:'sap_horizon'})); //versionは設定しない
    });
}
```

### 動作確認
CAPのサービスを実行し、Launchpadのリンクをクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/034fa4d6-cf66-e633-906c-d4a60b6e17a0.png)

Launchpadが開きます。まずはアプリ1を開きます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/f453bdda-250a-897b-3b03-61c3148a5fd5.png)

"Create"ボタンをクリックします。（Create wizardボタンはLineItemの`DataFieldForIntentBasedNavigation`により表示させたボタンです。こちらもアプリ2へ遷移します）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/b999908b-a853-7a83-cf53-dfe5d06b95b9.png)

アプリ2のWizard画面が表示されました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/9f993a22-1c07-9a3a-24ca-649b0be6b166.png)

登録後、一覧に戻って行をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/06a88148-3161-18c7-da50-f46b18862c55.png)
Object Pageに遷移します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/14ba451f-9107-f2c3-35e6-ca67485ebada.png)
"Edit"をクリックしても（当然）Object Pageのままです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/787e949b-1c50-4845-0e80-6caa86c52dd5.png)

よって、以下の動作が実現できました。

- List Reportで"Create"ボタンを押したらカスタムページを開く
- テーブル行をクリックしたらObject Pageを開く

## 感想
Flexible Programming Modelはドキュメントに具体的な使い方が書いていない場合もあり（例：createDocument）、試行錯誤での実装になりました。ナビゲーションやエンティティの登録、保存などは少ないコーディングで実装でき、「こういうケースではこのように実装する」というパターンを確立すれば効率的にアプリが作成できるのかもしれません。SAP公式やコミュニティで、使い方の紹介がもっと出てくることを期待します。