はじめに
この記事は SAP Advent Calendar 2021 の12月21日分の記事として執筆しています。
コロナ禍で多くのイベントがオンライン開催となっていますが、SAP のテクニカルカンファレンスである「SAP TechEd」もそのひとつです。参加無料で多くの最新情報に触れる絶好の機会ですので、ご興味ある方は、以下にアクセスしてみてください。
さて、タイトルに TypeScript 編とありますが、この記事は「UI5 Web Components for React で参戦ライブを振り返る」の続編となります。そもそも 「Web Components とは?」など基本情報をまとめていますので、Web Coponents そのものについて知りたい方は、こちらも合わせてご覧ください。
普段、SAP と関わりのない方々は、「SAP って ERP の会社でしょ?」、「SAP なのに Web Components? React?」と思うかも知れません。しかし、SAP 単なる ERP 企業ではなく、自身がクラウドシフト(ERP自体のクラウド版へのシフトや、拡張開発のためのクラウドラットフォームの提供など)を進める上で、オープンソースコミュニティをうまく巻き込み、コミュニティへの貢献を強力にコミットしているクラウド企業の側面も持ちます。
この記事でご紹介する UI5 Web Components for React も Apache License 2.0 ライセンスによる SAP 謹製オープンソースのひとつです(SAP の公式 GitHub では、200 を超えるリポジトリ上で、様々なプログラミング言語による開発が進められています)。この記事が皆さんにとって SAP オープンソースの入り口となれば幸いです。
React & TypeScript プロジェクトを作成する
まず、はじめに React & TypeScript プロジェクトを作成し、起動確認までしてみましょう。
テンプレートを活用した初期プロジェクトの作成
古い人間なので、型に厳しい言語の方が好ましいこともあり、TypeScript で React プロジェクト(プロジェクト名:lives-in-my-life-ts
)を作成します。以前の投稿 では、npx を利用していましたが、今回は yarn にしてみました。
yarn create react-app lives-in-my-life-ts --template typescript
早速、起動確認してみましょう。
cd lives-in-my-life-ts
yarn start
UI5 Web Components for React モジュールの追加
公式サイトに記載されている 3 つのモジュールを追加してみましょう。
- @ui5/webcomponents
- @ui5/webcomponents-react
- @ui5/webcomponents-fiori
yarn add @ui5/webcomponents @ui5/webcomponents-react @ui5/webcomponents-fiori
package.json に指定した 3 モジュールが追加されています。@ui5/webcomponents-react
は、まだ 0.20.3
ですね、、、。一方で、@ui5/webcomponents
と @ui5/webcomponents-fiori
は、1.0.2
として正式リリース済みでした。以前の投稿 の時点では、まだ 1.0.0-rc
リリース候補(Release Candidate)でしたが、2021年11月10日に正式リリースされたようです!
{
"name": "lives-in-my-life-ts",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/jest": "^26.0.15",
"@types/node": "^12.0.0",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@ui5/webcomponents": "^1.0.2",
"@ui5/webcomponents-fiori": "^1.0.2",
"@ui5/webcomponents-react": "^0.20.3",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-scripts": "4.0.3",
"typescript": "^4.1.2",
"web-vitals": "^1.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
エラー修正前に関数コンポーネント化しておく
以前の投稿 の JavaScript ソース App.js
の内容を App.tsx
に丸ごと貼り付けただけでは、大量の TypeScript エラーが発生します。このエラーをひとつひとつ潰していきたいのですが、以前の投稿 以降、React 自体が進化し、よりモダンかつスマートな実装が可能となっています。エラー解消よりも先に、コードをモダナイズしておきます。
App クラスコンポーネントの関数コンポーネント化
ここでは手始めに、メインの App
クラスコンポーネントを App
関数コンポーネントに書き換えておきましょう。以下の変更を加えていきます。コンポーネントについて詳しく知りたい方は、React 公式リファレンス をご参照ください。
-
class
をシンプルなアロー関数へ変更 -
state
をuseState
フックでスッキリと -
filterText
がstring
型であることを明示 -
handleFilterTextChange
内はfilterText
のセッターでスッキリと - 自身が関数となったため、
render()
メソッドを削除してスッキリと - 自身が関数となったため、
this
参照やthis.state
参照を削除してスッキリと
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
filterText: "",
};
}
handleFilterTextChange = (filterText) => {
this.setState({
filterText: filterText,
});
};
render() {
return (
<ThemeProvider>
<FlexBox
style={{ width: "100%", height: "100%" }}
direction={FlexBoxDirection.Column}
justifyContent={FlexBoxJustifyContent.Center}
alignItems={FlexBoxAlignItems.Center}
>
<LiveToolbar
filterText={this.state.filterText}
onFilterTextChange={this.handleFilterTextChange}
/>
<LiveTimeline filterText={this.state.filterText} />
</FlexBox>
</ThemeProvider>
);
}
}
const App = () => {
const [filterText, setFilterText] = useState<string>("");
const handleFilterTextChange = (filterText: string) => {
setFilterText(filterText);
};
return (
<ThemeProvider>
<FlexBox
style={{ width: "100%", height: "100%" }}
direction={FlexBoxDirection.Column}
justifyContent={FlexBoxJustifyContent.Center}
alignItems={FlexBoxAlignItems.Center}
>
<LiveToolbar
filterText={filterText}
onFilterTextChange={handleFilterTextChange}
/>
<LiveTimeline filterText={filterText} />
</FlexBox>
</ThemeProvider>
);
};
だいぶスッキリとしましたね!
LiveTimeline クラスコンポーネントの関数コンポーネント化
続けて、LiveTimeline
コンポーネントです。以下の変更を加えていきます。
-
class
をReact.FC
型へ変更(FC は Function Component の略) - パラメータ
props
がLiveTimelineProps
型であることを明示 -
LiveTimelineProps
型を宣言しておく - 自身が関数となったため、
this
参照を削除してスッキリと
const LiveTimeline extends React.Component {
render() {
const filteredLives = LIVES.filter((element) => {
return (
(element.date + element.title + element.venue)
.toLocaleLowerCase()
.indexOf(this.props.filterText.toLowerCase()) > -1
);
});
if (filteredLives.length > 0) {
return (
<Timeline className="Lives-timeline">
{filteredLives.map((element, index) => {
return (
<TimelineItem
key={index}
icon="calendar"
itemName={element.title}
subtitleText={element.date}
>
<div>{element.venue}</div>
</TimelineItem>
);
})}
</Timeline>
);
} else {
return (
<MessageStrip className="Lives-timeline">No data found.</MessageStrip>
);
}
}
}
type LiveTimelineProps = {
filterText: string;
};
// 途中省略
const LiveTimeline: React.FC<LiveTimelineProps> = (props) => {
const filteredLives = LIVES.filter((element) => {
return (
(element.date + element.title + element.venue)
.toLocaleLowerCase()
.indexOf(props.filterText.toLowerCase()) > -1
);
});
if (filteredLives.length > 0) {
return (
<Timeline className="Lives-timeline">
{filteredLives.map((element, index) => {
return (
<TimelineItem
key={index}
icon="calendar"
itemName={element.title}
subtitleText={element.date}
>
<div>{element.venue}</div>
</TimelineItem>
);
})}
</Timeline>
);
} else {
return (
<MessageStrip className="Lives-timeline">No data found.</MessageStrip>
);
}
};
LiveToolbar クラスコンポーネントの関数コンポーネント化
最後は、LiveToolbar
コンポーネントです。以下の変更を加えていきます。
-
class
をReact.FC
型へ変更(FC は Function Component の略) - パラメータ
props
がLiveToolbarProps
型であることを明示 -
LiveToolbarProps
型を宣言しておく -
handleFilterTextChange
関数のパラメータがUi5CustomEvent<HTMLInputElement>
型であることを明示 -
Ui5CustomEvent
をインポートしておく - 自身が関数となったため、
this
参照を削除してスッキリと
class LiveToolbar extends React.Component {
handleFilterTextChange = (e) => {
this.props.onFilterTextChange(e.target.value);
};
render() {
return (
<ShellBar
primaryTitle="Lives in My Life"
logo={
<img
alt="SAPUI5 Logo"
src="https://sap.github.io/ui5-webcomponents/assets/images/ui5.png"
/>
}
profile={
<Avatar image="https://avatars0.githubusercontent.com/u/25473342?s=400&u=b399ebf80c62121616c0435bed3f3c39b4fc9c9b&v=4" />
}
searchField={
<Input
value={this.props.filterText}
placeholder="Please input ..."
onInput={this.handleFilterTextChange}
/>
}
/>
);
}
}
import { Ui5CustomEvent } from '@ui5/webcomponents-react/interfaces/Ui5CustomEvent';
// 途中省略
type LiveToolbarProps = {
filterText: string;
onFilterTextChange: (filterText: string) => void;
};
// 途中省略
const LiveToolbar: React.FC<LiveToolbarProps> = (props) => {
const handleFilterTextChange = (e: Ui5CustomEvent<HTMLInputElement>) => {
props.onFilterTextChange(e.target.value);
};
return (
<ShellBar
primaryTitle="Lives in My Life"
logo={
<img
alt="SAPUI5 Logo"
src="https://sap.github.io/ui5-webcomponents/assets/images/ui5.png"
/>
}
profile={
<Avatar image="https://avatars0.githubusercontent.com/u/25473342?s=400&u=b399ebf80c62121616c0435bed3f3c39b4fc9c9b&v=4" />
}
searchField={
<Input
value={props.filterText}
placeholder="Please input ..."
onInput={handleFilterTextChange}
/>
}
/>
);
};
以上で、App
、LiveToolbar
、LiveTimeline
、合わせて 3 つのクラスコンポーネントの関数コンポーネント化が完了しました。関数コンポーネント化しただけの状態では、幾つかのエラーが残っていますので、それらを潰していきましょう。
残っているエラーをコツコツと修正する
以下のエラーを順番に解消していきます。
- Theme の型定義ファイルが見つからない
- TimelineItem に itemName プロパティを指定することはできない
- Avatar に image プロパティを指定することはできない
エラーその1:Theme の型定義ファイルが見つからない
まずは、型に厳密な TypeScript らしいエラーから対応していきます。TypeScript 対応あるあるですが、https://github.com/SAP/ui5-webcomponents-react/discussions/1136 で回答されているように、一部のコンポーネントについては、TypeScript 型定義ファイルがまだ用意されていないようです。ここで推奨されているように、自分で型定義ファイルを用意しましょう。
import { setTheme } from "@ui5/webcomponents-base/dist/config/Theme.js";
Could not find a declaration file for module '@ui5/webcomponents-base/dist/config/Theme.js'. '/Users/shunichirock/Repositories/lives-in-my-life-ts/node_modules/@ui5/webcomponents-base/dist/config/Theme.js' implicitly has an 'any' type.
Try `npm i --save-dev @types/ui5__webcomponents-base` if it exists or add a new declaration (.d.ts) file containing `declare module '@ui5/webcomponents-base/dist/config/Theme.js';`ts(7016)
src/@types
フォルダに Theme.d.ts
ファイルを追加し、モジュールを明示的に宣言することで、エラーが解消されます。tsconfig.json
で "strict": false
を指定することでもエラーは解消されますが、せっかく TypeScript を利用するのですから、厳密にまいりましょう。
declare module "@ui5/webcomponents-base/dist/config/Theme";
ついでに import
時の拡張子(.js
)も削除しておきます。
- import "@ui5/webcomponents/dist/Assets.js";
- import "@ui5/webcomponents-react/dist/Assets.js";
- import "@ui5/webcomponents-fiori/dist/Assets.js"; // Only if using the @ui5/webcomponents-fiori package
- import "@ui5/webcomponents-icons/dist/Assets.js"; // Only if using the @ui5/webcomponents-icons package
- import { setTheme } from "@ui5/webcomponents-base/dist/config/Theme.js";
+ import "@ui5/webcomponents/dist/Assets";
+ import "@ui5/webcomponents-react/dist/Assets";
+ import "@ui5/webcomponents-fiori/dist/Assets"; // Only if using the @ui5/webcomponents-fiori package
+ import "@ui5/webcomponents-icons/dist/Assets"; // Only if using the @ui5/webcomponents-icons package
+ import { setTheme } from "@ui5/webcomponents-base/dist/config/Theme";
エラーその2:TimelineItem に itemName プロパティを指定することはできない
こちらは、UI5 Web Components for React に限らず、ライブラリ全般あるあるですが、バージョンアップにより仕様が変わったようです。最新のリファレンス を確認してみましょう。
Type '{ children: Element; key: number; icon: string; itemName: string; subtitleText: string; }' is not assignable to type 'IntrinsicAttributes & Pick<TimelineItemPropTypes, "children" | "id" | "color" | "translate" | "hidden" | "dir" | "slot" | "style" | ... 252 more ... | "nameClickable"> & RefAttributes<...>'.
Property 'itemName' does not exist on type 'IntrinsicAttributes & Pick<TimelineItemPropTypes, "children" | "id" | "color" | "translate" | "hidden" | "dir" | "slot" | "style" | ... 252 more ... | "nameClickable"> & RefAttributes<...>'.ts(2322)
最新のリファレンス(TimelineItem) によると、itemName
プロパティが廃止され、代わりに titleText
プロパティが追加されたようですので、プロパティ名を変更します。これでエラーが解消されました。
- itemName={element.title}
+ titleText={element.title}
エラーその3:Avatar に image プロパティを指定することはできない
こちらも、エラーその 2 と同様のエラーですね。最新のリファレンス を確認してみましょう。
Type '{ image: string; }' is not assignable to type 'IntrinsicAttributes & Pick<AvatarPropTypes, "children" | "id" | "color" | "translate" | "hidden" | "dir" | "slot" | "style" | "title" | ... 252 more ... | "initials"> & RefAttributes<...>'.
Property 'image' does not exist on type 'IntrinsicAttributes & Pick<AvatarPropTypes, "children" | "id" | "color" | "translate" | "hidden" | "dir" | "slot" | "style" | "title" | ... 252 more ... | "initials"> & RefAttributes<...>'.ts(2322)
最新のリファレンス(Avatar) によると、image
プロパティは廃止され、子要素として指定するように変更されたようですので、img
タグに変更します。これでエラーが解消されました。
- <Avatar image="https://avatars0.githubusercontent.com/u/25473342?s=400&u=b399ebf80c62121616c0435bed3f3c39b4fc9c9b&v=4" />
+ <Avatar>
+ <img
+ alt="Profile"
+ src="https://avatars0.githubusercontent.com/u/25473342?s=400&u=b399ebf80c62121616c0435bed3f3c39b4fc9c9b&v=4"
+ />
+ </Avatar>
動作確認してみる
これで全てのエラーが解消されましたので、早速、実行してみましょう。
yarn start
React アプリは正常に起動はしましたが、以下 2 点の最終調整が必要なようです。
- SAPUI5 ロゴのリンクが切れている
- カレンダーアイコンが表示されない
最終調整その1:SAPUI5 ロゴのリンクが切れている
こちらは、SAPUI5 公式のロゴへの URL リンクだったのですが、ファイル名が変更されたようです。最新パスを確認して修正します。細かいポイントですが、リソース名へのこだわりが垣間見えて好ましいですね。
- src="https://sap.github.io/ui5-webcomponents/assets/images/ui5.png"
+ src="https://sap.github.io/ui5-webcomponents/assets/images/ui5-logo.png"
最終調整その2:カレンダーアイコンが表示されない
公式のリファレンス によると、Assets
ではなく AllIcons
を指定するか、利用するアイコンを個別指定(今回は calendar
)するようです。バージョンアップの過程で、より分かりやすい名称に変更されたようです。こちらも好ましいですね。
- import "@ui5/webcomponents-icons/dist/Assets"; // Only if using the @ui5/webcomponents-icons package
+ import "@ui5/webcomponents-icons/dist/AllIcons"; // Only if using the @ui5/webcomponents-icons package
最終調整結果はこちら
これにて SAPUI5 ロゴもカレンダーアイコンも正常に表示されました。これにて、参戦ライブ記録アプリ lives-in-my-life-ts
の TypeScript 化は完了となります。
ちなみに、変更後のソースコードは以下の通りです。(以前の投稿 と同様ですが)JSON 直書きだったり、日付データが yyyy/MM/dd 形式だったり、i18n 対応していなかったり、コード分割を検討していなかったり、色々と考えるべきことはありますが、お手軽な初期バージョンとして晒します。
import {
Avatar,
FlexBox,
FlexBoxAlignItems,
FlexBoxDirection,
FlexBoxJustifyContent,
Input,
MessageStrip,
ShellBar,
ThemeProvider,
Timeline,
TimelineItem,
} from "@ui5/webcomponents-react";
import React, { useState } from "react";
import { Ui5CustomEvent } from "@ui5/webcomponents-react/interfaces/Ui5CustomEvent";
import "./App.css";
import "@ui5/webcomponents/dist/Assets";
import "@ui5/webcomponents-react/dist/Assets";
import "@ui5/webcomponents-fiori/dist/Assets"; // Only if using the @ui5/webcomponents-fiori package
import "@ui5/webcomponents-icons/dist/calendar"; // Only if using the @ui5/webcomponents-icons package
import { setTheme } from "@ui5/webcomponents-base/dist/config/Theme";
setTheme("sap_fiori_3_dark");
type LiveTimelineProps = {
filterText: string;
};
type LiveToolbarProps = {
filterText: string;
onFilterTextChange: (filterText: string) => void;
};
const LIVES = [
{
date: "1991/12/31",
title: "METALLICA / EUROPE / TESLA / THUNDER - FINAL COUNTDOWN 1991",
venue: "東京ドーム",
},
/* (以下、省略) */
];
const LiveTimeline: React.FC<LiveTimelineProps> = (props) => {
const filteredLives = LIVES.filter((element) => {
return (
(element.date + element.title + element.venue)
.toLocaleLowerCase()
.indexOf(props.filterText.toLowerCase()) > -1
);
});
if (filteredLives.length > 0) {
return (
<Timeline className="Lives-timeline">
{filteredLives.map((element, index) => {
return (
<TimelineItem
key={index}
icon="calendar"
titleText={element.title}
subtitleText={element.date}
>
<div>{element.venue}</div>
</TimelineItem>
);
})}
</Timeline>
);
} else {
return (
<MessageStrip className="Lives-timeline">No data found.</MessageStrip>
);
}
};
const LiveToolbar: React.FC<LiveToolbarProps> = (props) => {
const handleFilterTextChange = (e: Ui5CustomEvent<HTMLInputElement>) => {
props.onFilterTextChange(e.target.value);
};
return (
<ShellBar
primaryTitle="Lives in My Life"
logo={
<img
alt="SAPUI5 Logo"
src="https://sap.github.io/ui5-webcomponents/assets/images/ui5-logo.png"
/>
}
profile={
<Avatar>
<img
alt="Profile"
src="https://avatars0.githubusercontent.com/u/25473342?s=400&u=b399ebf80c62121616c0435bed3f3c39b4fc9c9b&v=4"
/>
</Avatar>
}
searchField={
<Input
value={props.filterText}
placeholder="Please input ..."
onInput={handleFilterTextChange}
/>
}
/>
);
};
const App = () => {
const [filterText, setFilterText] = useState<string>("");
const handleFilterTextChange = (filterText: string) => {
setFilterText(filterText);
};
return (
<ThemeProvider>
<FlexBox
style={{ width: "100%", height: "100%" }}
direction={FlexBoxDirection.Column}
justifyContent={FlexBoxJustifyContent.Center}
alignItems={FlexBoxAlignItems.Center}
>
<LiveToolbar
filterText={filterText}
onFilterTextChange={handleFilterTextChange}
/>
<LiveTimeline filterText={filterText} />
</FlexBox>
</ThemeProvider>
);
};
export default App;
さいごに
以前の投稿「UI5 Web Components for React で参戦ライブを振り返る」から、早いもので約一年が経ちました。残念ながら一度もライブに参戦できておらず、App.js
の JSON 配列へのエントリ追加は実現しませんでした。その間に UI5 Web Components for React のバージョンは 0.12.3
から 0.20.3
へ、for React のベースである UI5 Web Components のバージョンは、1.0.0-rc.10
から、1.0.2
に粛々とバージョンアップしました。歩みを止めないソフトウェア開発/コミュニティ貢献に、頭が下がる思いです。
以下は、以前の投稿 からの引用ですが、UI5 Web Components が正式リリースされたことで、エンタープライズアプリケーションの世界でも採用しやすくなったのではないでしょうか? 個人的には「for React」の方も一日も早く正式リリースされることを心待ちにしています。なにかと MUI(Material UI)を採用しがちですが、エンタープライズ向けには UI5 Web Components の優位性が際立ちます。自社のアセット開発だけでなく、クライアント向けにも積極採用していきたいと思います。
エンタープライズアプリケーションの世界では、どのような UI テーマ(デザイン)を採用すべきか悩みどころですが、少なくとも SAP は Fiori という答えを用意してくれています。2013 年にリリースされた Fiori は、単なるモダン Web フレームワークではなく、SAP の統一的・横断的な UX として進化を遂げてきました。「Fiori にしておけば間違いない」というのは、(エンタープライズアプリケーションの世界では)大きな魅力です。機会があれば、積極的に採用していきたいと思います。