はじめに
この記事は SAP Advent Calendar 2022 の12月22日分の記事として執筆しています。
この記事は「UI5 Web Components for React で参戦ライブを振り返る」シリーズの第3弾となります。そもそも 「Web Components とは?」など基本情報をまとめていますので、Web Components そのものについて知りたい方は、以下も合わせてご覧ください。
第3弾は「参戦ライブを振り返る」というテーマを踏襲しつつ、アプリとしてはガラッと作り変えてみます。これまでは画面遷移もない超シンプルなアプリでしたが、典型的な管理ダッシュボード(Admin Dashboard)っぽく仕立てました。アドベントカレンダーのタイミングで年1回しか投稿していないため、第1弾と第2弾の間には、相当な進化(バージョンアップ)があり、そのギャップを埋めるストーリーが第2弾でした。第3弾は、ゼロからリライトしたこともあり、特にハマることなく進めることが出来ました。これには UI5 Web Components for React がライブラリとして成熟してきたという側面もあると思います。
以下、前回記事からの引用となりますが、改めて SAP 愛をお伝えしてから、本編に入りたいと思います(ただし、ABAP 原理主義者ではありません)。
普段、SAP と関わりのない方々は、「SAP って ERP の会社でしょ?」、「SAP なのに Web Components? React?」と思うかも知れません。しかし、SAP 単なる ERP 企業ではなく、自身がクラウドシフト(ERP自体のクラウド版へのシフトや、拡張開発のためのクラウドラットフォームの提供など)を進める上で、オープンソースコミュニティをうまく巻き込み、コミュニティへの貢献を強力にコミットしているクラウド企業の側面も持ちます。
この記事でご紹介する UI5 Web Components for React も Apache License 2.0 ライセンスによる SAP 謹製オープンソースのひとつです(SAP の公式 GitHub では、200 を超えるリポジトリ上で、様々なプログラミング言語による開発が進められています)。この記事が皆さんにとって SAP オープンソースの入り口となれば幸いです。
完成形のイメージはこちら
ライブリストのイメージ
画面上部のバーと画面右側のメニューがあるだけで、俄然それっぽく見えます。CSS をグリグリすることなく、この心落ち着くデザインに仕上げてくれる、、、なんとありがたいことでしょう。コンシューマ向けではなく、社内向けのアプリであれば、ド標準のテーマのままでもいいんじゃないかとさえ思わせてくれます。なお、「っぽく」仕立てることが目的ですので、People 以下のメニューはダミーです(公式サンプルコードのまま)。
ライブ詳細のイメージ
ライブリストから特定のライブを選択した後の画面イメージです。こちらも同様に、「っぽく」仕立てることが目的ですので、Information 内のコンテンツはダミーです(公式サンプルコードのまま)。話は逸れますが、再結成ツアーを発表した PANTERA 公演の画面イメージをキャプチャしました。オリジナルラインナップの PANTERA を最初に観た記念すべき日は、もう28年前です。時の流れの早さに愕然としつつ、再来日公演を待ちたいと思います!
変更前のイメージ
ご参考までですが、変更前は Timeline
コンポーネントがメインの超シンプルなアプリでした。当時は、SAP Fiori 3 のダークテーマを適用していましたが、変更後は2022年8月にリリースされたばかりの SAP Horizon のダークテーマを適用しています。Horizon の方がよりポップな仕上がりになっていますね。
React プロジェクトを作成する
テンプレートを活用した初期プロジェクトの作成
第2弾と同様に TypeScript で作成します。
yarn create react-app my-lives --template typescript
UI5 Web Components for React モジュールの追加
公式サイトに記載されている 3 つのコアモジュールと SAP Icon を表示するために必要なモジュールを追加します。
- @ui5/webcomponents
- @ui5/webcomponents-react
- @ui5/webcomponents-fiori
yarn add @ui5/webcomponents @ui5/webcomponents-react @ui5/webcomponents-fiori
React Router モジュールの追加
今回は画面遷移のある管理ダッシュボードを SPA(Single Page Application)として作成するため、React Router モジュールを追加しておきます。
yarn add react-router-dom
Luxon モジュールの追加
後ほど日付書式をフォーマットするため、Luxon モジュールを追加しておきます。日付の書式化やバリデーションには欠かせない鉄板モジュールです。
yarn add luxon
yarn add --dev @types/luxon
これにて事前準備は整いました。
管理ダッシュボードに仕立てる
テーマやルーターの適用
自動生成された index.tsx
に3つのコンポーネントを追加します。
- HelmetProvider: ブラウザタブのタイトルを切り替える
- BrowserRouter: ナビゲーション(コンポーネント切り替え)を利用する
- ThemeProvider: SAP テーマ(Horizon など)を利用する
import React from "react";
import ReactDOM from "react-dom/client";
+ import { HelmetProvider } from "react-helmet-async";
+ import { BrowserRouter } from "react-router-dom";
+ import { ThemeProvider } from "@ui5/webcomponents-react";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
<React.StrictMode>
+ <ThemeProvider>
+ <HelmetProvider>
+ <BrowserRouter>
<App />
+ </BrowserRouter>
+ </HelmetProvider>
+ </ThemeProvider>
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
続けて、App.tsx
を以下のように書き換えます。React Router でコンテンツを切り替えるため、App.tsx
自体は超シンプルになりました。
import React, { useEffect } from "react";
import { useRoutes } from "react-router-dom";
import { setTheme } from "@ui5/webcomponents-base/dist/config/Theme";
import "@ui5/webcomponents-react/dist/Assets";
import routes from "./routes";
import "./App.css";
function App() {
useEffect(() => {
setTheme("sap_horizon_dark");
}, []);
const content = useRoutes(routes);
return <>{content}</>;
}
export default App;
レイアウトの定義
以下のような典型的な管理ダッシュボードのレイアウトを定義します。
- 画面の上部バー:共通の ShellBar コンポーネント
- 画面の左側メニュー:共通の SideNavigation コンポーネント
- 画面の右側コンテンツ:各ページ向けコンポーネント(ライブリストやライブ詳細など)
画面の左側メニューを折り畳み可能とするため、isCollapsed
ステートを定義しています。メニューの中身は、それっぽく見せるためのダミーで、ソースは SideNavigation コンポーネントのサンプルコード です。メニュー階層をベタ書きしているので長々としてしていますが、本来はメニューリソースを取得して動的に描画するので、もっとシンプルになります。
import React, { useState } from "react";
import type { FC, ReactNode } from "react";
import { Outlet, useNavigate } from "react-router-dom";
import {
Avatar,
Button,
FlexBox,
ShellBar,
SideNavigation,
SideNavigationItem,
SideNavigationSubItem,
} from "@ui5/webcomponents-react";
import "@ui5/webcomponents-icons/dist/AllIcons.js";
interface MainLayoutProps {
children?: ReactNode;
}
const MainLayout: FC<MainLayoutProps> = () => {
const [isCollapsed, setIsCollapsed] = useState<boolean>(false);
const navigate = useNavigate();
return (
<>
<ShellBar
logo={
<img
alt="UI5 Logo"
src="https://sap.github.io/ui5-webcomponents/assets/images/ui5-logo.png"
/>
}
startButton={
<Button icon="menu" onClick={() => setIsCollapsed(!isCollapsed)} />
}
onLogoClick={() => navigate("/dashboard/live")}
primaryTitle="My Lives"
secondaryTitle="Record of attending concerts and festivals"
showNotifications
profile={
<Avatar>
<img
alt="Profile"
src="<your profile url>"
/>
</Avatar>
}
style={{
borderBottom:
"var(--sapList_BorderWidth) solid var(--sapList_GroupHeaderBorderColor)",
}}
/>
<FlexBox
style={{ height: "calc(100vh - var(--_ui5_shellbar_root_height))" }}
>
<SideNavigation
collapsed={isCollapsed}
fixedItems={
<>
<SideNavigationItem icon="chain-link" text="Useful Links" />
<SideNavigationItem icon="history" text="History" />
</>
}
>
<SideNavigationItem icon="home" text="Lives" />
<SideNavigationItem expanded icon="group" text="People">
<SideNavigationSubItem text="From My Team" />
<SideNavigationSubItem text="From Other Teams" />
</SideNavigationItem>
<SideNavigationItem icon="locate-me" text="Locations" />
<SideNavigationItem expanded icon="calendar" text="Events">
<SideNavigationSubItem text="Local" />
<SideNavigationSubItem text="Others" />
</SideNavigationItem>
</SideNavigation>
<FlexBox fitContainer>
<Outlet />
</FlexBox>
</FlexBox>
</>
);
};
export default MainLayout;
ライブリストの実装
ここでは、非常に高次元なコンポーネントである DynamicPage
を利用します。MUI (Material UI) などを利用する場合、DynamicPage
的な高次元コンポーネントを自作するわけですが、UI5 Web Components for React は自由度の低さ(=標準化度合いの高さ)と引き換えに、そうした自作行為から開放してくれます。
最初から headerTitle
、headerContent
などがプロパティ化されているので、適切な値やノードを指定していくだけです。こちらも、DynamicPage コンポーネントのサンプルコード を大いに参考にしました。
コンテンツの主役であるライブリストは、LiveTable
コンポーネントとして切り出しています。
import React from "react";
import type { FC } from "react";
import {
Breadcrumbs,
BreadcrumbsItem,
DynamicPage,
DynamicPageHeader,
DynamicPageTitle,
Label,
Title,
} from "@ui5/webcomponents-react";
import LiveTable from "./LiveTable";
import lives from "./lives";
const LivePage: FC = () => {
return (
<DynamicPage
headerContent={
<DynamicPageHeader>
<Label>Number of lives: {lives.length}</Label>
</DynamicPageHeader>
}
headerTitle={
<DynamicPageTitle
breadcrumbs={
<Breadcrumbs>
<BreadcrumbsItem>Lives</BreadcrumbsItem>
</Breadcrumbs>
}
header={<Title>Live List</Title>}
subHeader={<Label>List of attending concerts and festivals</Label>}
/>
}
>
<LiveTable lives={lives} />
</DynamicPage>
);
};
export default LivePage;
続けて、LiveTable
コンポーネントです。こちらは、Table
コンポーネントを利用します。
import React from "react";
import type { FC } from "react";
import { useNavigate } from "react-router-dom";
import {
Label,
Table,
TableCell,
TableColumn,
TableDomRef,
TableRow,
Ui5CustomEvent,
} from "@ui5/webcomponents-react";
import { DateTime } from "luxon";
interface Live {
date: string;
title: string;
venue: string;
}
interface LiveTableProps {
lives: Array<Live>;
}
const LiveTable: FC<LiveTableProps> = ({ lives }) => {
const navigate = useNavigate();
const handleRowClick = (
e: Ui5CustomEvent<TableDomRef, { row: HTMLElement }>
) => {
navigate(`/dashboard/live/${e.detail.row.id}`);
};
return (
<Table
columns={
<>
<TableColumn style={{ width: "4rem" }}>
<Label>#</Label>
</TableColumn>
<TableColumn style={{ width: "8rem" }}>
<Label>Date</Label>
</TableColumn>
<TableColumn minWidth={800}>
<Label>Title</Label>
</TableColumn>
<TableColumn minWidth={600}>
<Label>Venue</Label>
</TableColumn>
</>
}
onRowClick={(e) => handleRowClick(e)}
>
{lives.map((live, index) => (
<TableRow key={index} id={`${index}`} type="Active">
<TableCell>
<Label>{index + 1}</Label>
</TableCell>
<TableCell>
<Label>
{DateTime.fromFormat(live.date, "yyyy-MM-dd").toFormat(
"yyyy/MM/dd"
)}
</Label>
</TableCell>
<TableCell>
<Label>{live.title}</Label>
</TableCell>
<TableCell>
<Label>{live.venue}</Label>
</TableCell>
</TableRow>
))}
</Table>
);
};
export default LiveTable;
ライブ詳細の実装
最後に、LiveDetail
コンポーネントです。先ほどは DynamicPage
コンポーネントを利用しましたが、ここでは ObjectPage
コンポーネントを利用します。ObjectPage
も非常に高次元なコンポーネントであり、headerTitle
、headerContent
などがプロパティ化されているので、適切な値やノードを指定していくだけです。こちらも、ObjectPage コンポーネントのサンプルコード を大いに参考にしました。
import React from "react";
import type { FC } from "react";
import { useNavigate } from "react-router-dom";
import { DateTime } from "luxon";
import lives from "../Live/lives";
import {
Breadcrumbs,
BreadcrumbsItem,
BreadcrumbsDomRef,
Button,
DynamicPageHeader,
DynamicPageTitle,
FlexBox,
Form,
FormGroup,
FormItem,
Label,
ObjectPage,
ObjectPageSection,
ObjectPageSubSection,
ObjectStatus,
Text,
Ui5CustomEvent,
} from "@ui5/webcomponents-react";
interface LiveDetailPageProps {
id: string;
}
const LiveDetailPage: FC<LiveDetailPageProps> = ({ id }) => {
const navigate = useNavigate();
const handleItemClick = (
e: Ui5CustomEvent<
BreadcrumbsDomRef,
{
item: HTMLElement;
altKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
shiftKey: boolean;
}
>
) => {
if (e.detail.item.dataset.navigateTo) {
navigate(e.detail.item.dataset.navigateTo ?? "/");
}
};
return (
<ObjectPage
headerContent={
<DynamicPageHeader>
<FlexBox alignItems="Center" wrap="Wrap">
<FlexBox direction="Column">
<Label>
{DateTime.fromFormat(
lives[Number(id)].date,
"yyyy-MM-dd"
).toFormat("yyyy/MM/dd")}
</Label>
<Label>{lives[Number(id)].venue}</Label>
</FlexBox>
</FlexBox>
</DynamicPageHeader>
}
headerContentPinnable
headerTitle={
<DynamicPageTitle
actions={
<Button
design="Emphasized"
onClick={() => {
navigate("/dashboard/live");
}}
>
Back
</Button>
}
breadcrumbs={
<Breadcrumbs onItemClick={(e) => handleItemClick(e)}>
<BreadcrumbsItem data-navigate-to="/dashboard/live">
Lives
</BreadcrumbsItem>
<BreadcrumbsItem>#{Number(id) + 1}</BreadcrumbsItem>
</Breadcrumbs>
}
header={lives[Number(id)].title}
>
<ObjectStatus state="Success">joined</ObjectStatus>
</DynamicPageTitle>
}
selectedSectionId="information"
showHideHeaderButton
>
<ObjectPageSection
aria-label="Information"
id="information"
titleText="Information"
>
... 長いので省略(公式サンプルコードのまま) ...
</ObjectPageSection>
</ObjectPage>
);
};
export default LiveDetailPage;
工夫した点
基本的には、UI5 Web Components for React に頼り切りなのですが、一部、見た目が気になる点があったため、スタイル補正しました。また、ナビゲーション挙動が好ましくなかったので、追加実装しています。
SideNavigation の高さを調整(見た目)
適切な高さを与えないと、以下のように寸足らずな状態になってしまいます(右側メニューの高さが足りず、白背景が見えている)。
SideNavigation
コンポーネントの親の FlexBox
コンポーネントに適切な高さを与えることで解決しています。なお、テーマ変更にも対応できるように CSS 変数を参照しています。
<FlexBox
style={{ height: "calc(100vh - var(--_ui5_shellbar_root_height))" }}
>
ShellBar の境界線を追加(見た目)
デフォルトのままだと、以下のように画面上部のバーとコンテンツの境界が曖昧です(同じ色で影や線がない)。SAP Fiori 3 テーマのときは、配色により境界が表現されていましたが、SAP Horizon テーマに切り替えたため、フラットになりました。
フラットを良しとするかどうか、完全に好みの問題ですが、あえて境界を強調したかったので、ShellBar
コンポーネントの borderBottom
プロパティを指定しました。こちらも、テーマ変更にも対応できるように CSS 変数を参照しています。
style={{
borderBottom:
"var(--sapList_BorderWidth) solid var(--sapList_GroupHeaderBorderColor)",
}}
ナビゲーションを実装(振る舞い)
標準の BreadCrumbsItem
コンポーネントは href
プロパティを持っていますが、名前の通りハイパーリンクと同じ挙動となります。すなわち通常の GET リクエストが飛んでしまいます。今回のアプリは SPA 構成にしたいので、React Router でナビゲーションするように追加実装しました。なお、強引にデータ属性で解決していますが、もっと良い実装はないものでしょうか、、、。
<Breadcrumbs onItemClick={(e) => handleItemClick(e)}>
<BreadcrumbsItem data-navigate-to="/dashboard/live">
Lives
</BreadcrumbsItem>
<BreadcrumbsItem>#{Number(id) + 1}</BreadcrumbsItem>
</Breadcrumbs>
イベントの型がややこしいですが、これらはすべて公式ドキュメントに記載されているので、ご安心ください。
const navigate = useNavigate();
const handleItemClick = (
e: Ui5CustomEvent<
BreadcrumbsDomRef,
{
item: HTMLElement;
altKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
shiftKey: boolean;
}
>
) => {
if (e.detail.item.dataset.navigateTo) {
navigate(e.detail.item.dataset.navigateTo ?? "/");
}
};
高次元コンポーネントのありがたみ
過去の投稿では、あまり UI5 Web Components for React への感謝の気持ちを表明してきませんでしたので、今回はしっかりと表明してみます。上の画面イメージは、ちょっと意地悪な感じで画面をキュッと縮めたときのものです。簡単ではありますが、「ありがたみ」をリストアップしていきます。
- アプリのサブタイトル("Record of 〜")が自動的に非表示になる
- 通知アイコンが自動的にミートボールメニューとして折り畳まれる
- サイドバー(
SideNavigation
コンポーネント)が折り畳むためのプロパティ(collapsed
)を標準として持っている - サイドバーの固定リンク以外の部分だけが自動的にスクロール可能となる
- ヘッダタイトル("Live List")のフォントの大きさが自動的に小さくなる
- ヘッダコンテンツ("Nuber of 〜")が自動的に非表示になる
- テーブルの表示列がテーブルの表示幅に応じて自動的に減少する(イメージ図の例では4列から2列へ減少)
「自動的に」とか「標準として」といった魅力的なキーワードが並びます。素の MUI (Material UI) から組み上げていくようなケースでは、こうした細やかな実装(主にレスポンシブ対応)を自作する必要があり、ひとつひとつは大した工数ではありませんが、積み上げると無視できないボリュームになります。こうした「出来て当たり前」的な「価値」そのものではないところを、高次元なコンポーネントがすべてやっておいてくれるのは、大変ありがたいことです。
さいごに
第2弾の投稿「UI5 Web Components for React で参戦ライブを振り返る(TypeScript編)」から、早いもので約一年が経ちました。この一年でライブ参戦に対する状況は大きく改善され、8月の DOWNLOAD JAPAN 2022 および11月の GUNS N' ROSES さいたまスーパーアリーナ公演 を新たに lives.ts
に追加することが出来ました。来年2月には MEGADETH 武道館公演 が控えています。こうして少しずつ日常が戻りつつあることに感謝したいと思います。
さて、第2弾の投稿では、以下のように締めくくっていました。
以下は、以前の投稿 からの引用ですが、UI5 Web Components が正式リリースされたことで、エンタープライズアプリケーションの世界でも採用しやすくなったのではないでしょうか? 個人的には「for React」の方も一日も早く正式リリースされることを心待ちにしています。なにかと MUI(Material UI)を採用しがちですが、エンタープライズ向けには UI5 Web Components の優位性が際立ちます。自社のアセット開発だけでなく、クライアント向けにも積極採用していきたいと思います。
UI5 Web Components for React は、2022年9月に 1.0.1
がリリースされて以来、継続的にバージョンアップされており、この記事を執筆している時点では 1.6.0
が最新バージョンとなっています。バージョンが 1.0.0 未満であることだけが採用しない理由ではないですが、なんにせよ正式リリースされたことは、とても良いニュースです。自身を振り返ってみると、結果的には MUI (Material UI) の採用率が 100% となりましたが、2023 年こそは UI5 Web Components for React を採用する機会をしたたかに狙っていきたいと思います。
"@ui5/webcomponents": "^1.0.2",
"@ui5/webcomponents-fiori": "^1.0.2",
"@ui5/webcomponents-react": "^0.20.3",
"@ui5/webcomponents": "^1.9.1",
"@ui5/webcomponents-fiori": "^1.9.1",
"@ui5/webcomponents-react": "^1.6.0",