はじめに
以前、Yellowfin のダッシュボードに 「CSV出力」ボタン を置く方法を書きました。
コードモードの JavaScript タブに貼るだけで動くので手軽なのですが、ダッシュボードがいくつもあると面倒です。
Yellowfin には コードウィジェット という仕組みがあり、JAR にまとめてプラグイン管理画面からアップロードすれば、ダッシュボード編集画面のウィジェットメニューに並ぶようになります。
この記事では、その作り方をまとめます。検証環境は Yellowfin 9.18(build 20260813)です。
動作イメージ
見た目の設定は 標準のボタンウィジェットとまったく同じ項目 が出ます。テキスト、フォント、色、枠線、影、角の丸み、ホバー時の色まで一通り揃っています。これは自作したのではなく、標準ボタンが使っているプロパティパネルをそのまま継承しているからです。
そこに「エクスポート設定」セクションを足して、対象レポート と 出力形式 を選べるようにしています。
コードモードとコードウィジェットの違い
同じ「コード」でも別物です。
| コードモード | コードウィジェット | |
|---|---|---|
| 書く場所 | ダッシュボードの編集画面 | Java + JS のプロジェクト |
| 配布 | ダッシュボードごとにコピペ | JAR をアップロード |
| 導入の手軽さ | ◎ | △(ビルドが要る) |
1つのダッシュボードで完結するならコードモードで十分です。複数の環境やダッシュボードに配りたい となったらコードウィジェットの出番、という住み分けになります。
全体構成
JAR の中身はこうなります。
src/main/
├── java/jp/co/example/yellowfin/widget/exportbutton/
│ ├── ExportButtonTemplate.java ウィジェットの定義
│ ├── ExportButtonPanel.java プロパティパネル
│ ├── ExportSettingsSection.java 「エクスポート設定」セクション
│ ├── CanvasReportLookup.java 同じキャンバスのレポートを列挙する
│ ├── ReportChoiceLoader.java レポート候補の再取得
│ └── Labels.java 表示文字列
└── resources/jp/co/.../exportbutton/assets/
├── main.js ウィジェット本体
├── exportbutton.css
└── exportbutton.html
リソースのパスはクラスのパッケージからの相対 です。assets/ が resources 側の同じパッケージ階層の下にある点に注意してください。
ディスクリプタファイルの類は不要です。Yellowfin がアップロードされた JAR のクラスを走査して、ウィジェットとして登録してくれます。
Java 側:ウィジェットの定義
実装するメソッド
AbstractCodeTemplate を継承して、以下を実装します。
| メソッド | 内容 |
|---|---|
getTemplateTitle() |
ウィジェット一覧に出る名前 |
getMainJavascriptPath() |
最初に読まれる JS |
setupResources() |
JS / CSS / HTML を登録する |
getPanel() |
プロパティパネル |
getAvailableOn() |
配置できる画面。CanvasType.SUBTAB がダッシュボード |
getCategory() / getSubCategory()
|
追加メニューでの分類 |
public class ExportButtonTemplate extends AbstractCodeTemplate {
private static final String MAIN_JS = "assets/main.js";
@Override
public void setupResources() {
addResource(new Resource(MAIN_JS, "text/javascript"));
addResource(new Resource("assets/exportbutton.css", "text/css"));
addResource(new Resource("assets/exportbutton.html", "text/html"));
}
@Override
public String getMainJavascriptPath() {
return MAIN_JS;
}
@Override
public CanvasWidgetPanel getPanel(CanvasWidgetPanelInfo info) {
return new ExportButtonPanel(info);
}
@Override
protected Set<CanvasType> getAvailableOn() {
return EnumSet.of(CanvasType.SUBTAB);
}
@Override
public CanvasMenuCategory getCategory() {
return CanvasMenuCategory.CODE;
}
}
Yellowfin に同梱されている TickerTapeBreadcrumb が組み込みのコードウィジェットです。
プロパティパネルは標準ボタンのものを継承する
ここがいちばんの近道でした。見た目の設定項目を自作する必要はありません。
標準ボタンの CodeButtonTemplate#getPanel() を追ってみると、既製の CanvasButtonPanel を返しているだけでした。であれば、こちらもそれを継承して、自分のセクションを足せば済みます。
public class ExportButtonPanel extends CanvasButtonPanel {
public ExportButtonPanel(CanvasWidgetPanelInfo info) {
super(info);
}
@Override
protected void buildSections() {
super.buildSections(); // ボタン・スタイル・枠線・影
this.sections.add(new ExportSettingsSection(getCanvasWidgetPanelInfo()));
}
}
これだけで、標準ボタンと同じ設定項目が並びます。
buildSections() はコンストラクタからではなく getSections() から遅延実行されるので、この上書きは安全です。
なお、パネルに使える標準セクションは com.hof.mi.widgetcanvas.panelcollection.sections パッケージにあります。ボタン以外のウィジェットを作るときは、ここから必要なものを選んで組み立てられます。
CanvasButtonSection / CanvasButtonStylesSection / CanvasButtonBorderSection
CanvasButtonHoverSection / CanvasButtonShadowSection
BackgroundSection / SizeLocationSection / SpacingSection / NameSection
対象レポートを選ばせる
「同じダッシュボードに置かれているレポート」を列挙してドロップダウンにします。サーバー側で引けます。
CanvasDependency d = new CanvasDependency();
DBAction db = d.newDBAction();
// キャンバス UUID は CanvasWidgetPanelInfo.getEntity().getParentUUID() から取れる
Collection<WidgetItemBean> widgets =
d.getWidgetItemManager().selectWidgetItemsForCanvas(db, canvasUUID);
ここで 値に何を持たせるか が地味に重要です。レポートの UUID ではなく、ウィジェットの publishUUID を使います。
- フロントの
canvas.selectAll()はwidget-uuid/publish-uuidでも要素を解決してくれる - そのため レポート UUID を知る必要がない
- 同じレポートがダッシュボードに複数配置されていても 取り違えない
widgetUUID のほうは保存をまたぐと変わりますが、publishUUID は不変です。設定値として保存するならこちらです。
WidgetItemBean.getWidgetType() は列挙名ではなく 実装クラス名 を返します(例: com.hof.mi.widgetcanvas.ReportWidget)。WidgetType.REPORT と比較しても一致しないので、単純名で判定しています。
ドロップダウンの先頭には、空値の「(レポートを選択してください)」を必ず入れています。理由は後述の「既定値は誰も保存してくれない」に書きます。
候補を取り直すボタン
プロパティパネルの定義は ダッシュボードの読み込み時に確定 します。そのため、ウィジェットを先に置いてから後でレポートを配置すると、候補に出てきません。
これは Yellowfin 標準の ParameterValueLoader で解決できます。「プロパティ名 → 選択肢」を返すと、そのドロップダウンが差し替わる仕組みです。
まずセクションのコンストラクタで登録します。
ExportSettingsSection(CanvasWidgetPanelInfo info) {
this.info = info;
setParameterValueLoader(new ReportChoiceLoader(), info);
}
ボタンには ParameterEvent を付けます。InputType.BUTTON はフロント側で clickEvent が立つ ので、クリックがそのままサーバーイベントになります。
p.setInputType(InputType.BUTTON);
p.setEventObject(new ParameterEvent(
EVENT_RELOAD_REPORTS,
ParameterEvent.ParameterEventType.SERVER,
ParameterEvent.EventTriggerType.CLICK,
new LinkedHashMap<String, String>(),
new ArrayList<ValueDependent>()));
受け口はこれだけです。自分のイベント以外でも呼ばれるので、events を見て判定し、無関係なら空の Map を返します。
public class ReportChoiceLoader extends ParameterValueLoader {
@Override
public Map<String, List<CustomValue<?>>> getUpdatedPossibleValues() throws Exception {
Map<String, List<CustomValue<?>>> updated = new HashMap<>();
if (events == null || !events.contains(ExportSettingsSection.EVENT_RELOAD_REPORTS)) {
return updated;
}
updated.put(ExportSettingsSection.PROP_REPORT,
ExportSettingsSection.reportChoices((CanvasWidgetPanelInfo) getPanelInfo()));
return updated;
}
@Override
public void generateDynamicParameters() throws Exception {
}
}
同梱の StoryWidgetSection / StoryWidgetValueLoader が同じ仕組みを使っています(カテゴリを選ぶとサブカテゴリの候補が入れ替わるアレです)。値の変更を起点にしたい場合はそちらが参考になります。
アイコンだけのボタンにする
ButtonView は flat と img を渡すと画像だけを描画し、text は title 属性に回す 仕様です。これでアイコンのみのボタンになります。アイコンは同梱の images/refresh-icon-blue.svg を使いました。
view.put("img", "images/refresh-icon-blue.svg");
view.put("flat", Boolean.TRUE);
view.put("text", "レポートの一覧を取得し直す"); // title 属性になる
view.put("css", buttonCss()); // 背景・枠線を消して絶対配置にする
view.put("imgCss", iconCss()); // 14px
ラベルと同じ行に置く方法 にはコツがあります。div.parameter はパラメータごとに1つ作られるので、ボタンを独立したパラメータにして絶対配置しても、基準になるのは ボタン自身の div.parameter で、ラベルとは別の行に出てしまいます。
そこで「対象レポート」のラベルとヘルプ(i)を ボタンのパラメータに持たせ、ドロップダウン側は setName("") にしました。こうすると1つの div.parameter が「ラベル + (i) + 右端のボタン」になります。Yellowfin 自身も同じ手を使っています。
/* 標準の toggleParam。ラベル行の右端にトグルを置いている */
div.propertiesPanel div.sectionParameters div.panelParameter.toggleParam .paramControl {
position: absolute;
right: 0;
...
}
JavaScript 側:ウィジェットの中身
コンストラクタに渡るもの
AMD 形式で書きます。define() の依存には、setupResources() で登録したリソースを 同じ相対パス で指定します。CSS は自動適用され、HTML は文字列で渡ってきます。
define(['assets/exportbutton.css', 'assets/exportbutton.html'], function (css, html) {
function ExportButton(options) {
// options.messenger 設定値の取得・イベント購読
// options.element 描画先の DOM
// options.resourceLoader
// options.api { main, dashboard, filters, widget, canvas }
}
return ExportButton;
});
options.api.canvas が渡る ので、コードモード版と同じ canvas.selectAll() → reportAPI → setupExport() の流れがそのまま使えます。
設定値は messenger から取る
ここは最初に押さえておきたいところです。コンストラクタに渡るのは messenger / element / resourceLoader / api の4つだけ で、formats は含まれません。設定値は messenger が持っています。
| 呼び出し | 内容 |
|---|---|
messenger.getOptionValue(key) |
設定値の取得 |
messenger.setOption(key, value) |
設定値の書き込み(編集モードのみ保存される) |
messenger.registerListener(type, fn) |
イベント購読 |
messenger.edit |
編集モードか |
キーは Java 側で setProperty() に指定したものがそのまま使えます。設定が変更されると optionChanged が飛んでくるので、そこで再描画します。
this.messenger.registerListener('optionChanged', this.render.bind(this));
既定値は自分で書き込む
コードウィジェットを作る上で、いちばん引っかかりやすいのがここだと思います。
getOptionValue() は 保存済みの値を返すだけ で、Java 側 Parameter.setDefaultValue() は参照しません。
CodeMessenger.prototype.getOptionValue = function(property) {
return this.formats == null ? null : this.formats[property];
};
つまり プロパティパネルに「既定値として表示されている値」は、まだどこにも保存されていません。放っておくと、パネルの表示と実物が食い違い、その項目を一度触るまで設定が効かない、という状態になります。
標準ボタンはこの差を、配置直後に formatModel へ既定値を一式書き込む ことで埋めています(ButtonWidgetView の isNewObject ブロック)。こちらも同じ方針にしました。
ExportButton.prototype.initDefaults = function () {
if (!this.isEdit()) { return; }
if (!blank(this.raw(PROP_INITIALIZED))) { return; } // 二重実行を防ぐ目印
var self = this;
Object.keys(DEFAULTS).forEach(function (k) {
if (blank(self.raw(k))) { self.messenger.setOption(k, DEFAULTS[k]); }
});
this.messenger.setOption(PROP_INITIALIZED, '1');
};
書き込む値は標準ボタンに合わせてあります。
| キー | 既定値 |
|---|---|
textAlign |
CENTER |
font |
Libre Franklin |
fontSize |
14 |
bold / italic / underline
|
BOLD / NORMAL / NONE
|
textColour / hoverTextColour
|
#333740 |
backgroundColour / hoverButtonColour
|
rgb(219, 221, 229) |
backgroundOpacity / hoverOpacity
|
100 / 60
|
borderWidth / borderStyle / borderColour
|
2px / solid / rgb(219, 221, 229)
|
borderRadius |
0 |
対象レポートだけは自動で決められません。実在するレポートを既定値にすると「選択済みに見えるのに未設定」という同じ食い違いが起きるので、ドロップダウンの先頭に空値の「(レポートを選択してください)」を置いて、必ず選ばせる形にしました。
エクスポートを実行する
やっていることはコードモード版と同じです。
ExportButton.prototype.openExportDialog = function (repEl) {
var targetType = String(this.setting(PROP_FORMAT)).toUpperCase();
var items = this.exportItems(repEl.reportAPI);
var item = null;
for (var i = 0; i < items.length; i++) {
if (String(items[i].targetType).toUpperCase() === targetType) { item = items[i]; break; }
}
if (!item) { /* 使えない形式だった場合の案内 */ return; }
repEl.setupExport(item);
};
対象レポートの要素は、設定値(ウィジェットの publishUUID)から引きます。
var matches = this.api.canvas.selectAll(widgetUUID) || [];
var repEl = matches.length ? matches[0] : null;
ビルドとデプロイ
Yellowfin 本体の jar はライセンス物なのでリポジトリに含められません。稼働中のコンテナから抽出して libs/ に置き、compileOnly で参照します(実行時は Yellowfin 本体のものが使われます)。
for j in i4-mi.jar i4-core.jar i4-content.jar i4-adapter.jar; do
docker cp yf918-app:/opt/yellowfin/appserver/webapps/ROOT/WEB-INF/lib/$j libs/$j
done
plugins { java }
// 9.1x を広くカバーするため Java 11 をターゲットにする
tasks.withType<JavaCompile>().configureEach {
options.release.set(11)
options.encoding = "UTF-8"
}
dependencies {
compileOnly(fileTree("libs") { include("*.jar") })
}
tasks.jar {
archiveBaseName.set("ea-export-button-widget")
archiveVersion.set("0.1.0")
}
できた JAR は、管理 > プラグイン管理 > 新規プラグイン追加 からアップロードするだけです。ダッシュボードの編集画面で「ウィジェット追加 > コード」に出てくれば成功です。
JAR を差し替えたときは、ブラウザに古い JS が残っていることがあります。反映されないと思ったらスーパーリロードしてみてください。
ハマりどころ
9.1x の実装を追って分かった、仕様として知っておきたい点をまとめます。
太字・斜体・下線は真偽値ではない
FontStyleParameter は fontStyle という名前ですが、実際に formatModel へ書かれるのは サブプロパティの bold / italic / underline で、値は 文字列 です。
| キー | オン | オフ |
|---|---|---|
bold |
BOLD |
NORMAL |
italic |
ITALIC |
NORMAL |
underline |
UNDERLINE |
NONE |
Java 側の setDefaultValue() には {bold: true, italic: false} と真偽値の Map が渡っているのですが、これはパネル表示用で、保存される値とは別物です。真偽値として判定すると 太字が一切効きません。
角の丸みはボタン系セクションにない
borderRadius は CanvasButton*Section ではなく SizeLocationSection(「角」の項目)が持つキーです。ボタンのスタイル設定を探しても見つからないので注意してください。
不透明度は要素全体にかかる
backgroundOpacity / hoverOpacity は背景色のアルファではなく、opacity: 値/100 として 要素全体 に適用されます。文字も一緒に薄くなるのが標準の挙動です。
見た目のフォーマット値は自動適用されない
標準セクションが書き込む値は、コードウィジェットには 自動では反映されません。JS 側で読んで CSS に当てる必要があります。標準ボタンも CodeButton.js で同じことをしているので、キーと CSS の対応はそちらが手本になります。
exportItems は2か所にあって中身が食い違う
ReportState は使えるエクスポート形式を2か所に持っています。
// 生成時点のスナップショット
this.exportItems = options.exportItems || this._reportDefinitionModel.get('exportItems') || [];
// レポート定義モデルの現在値
ReportState.prototype.getExportItems = function() {
return this._reportDefinitionModel.get("exportItems");
}
生成が早いとスナップショットは空配列のまま埋まりません。しかも 空配列は truthy なので api.exportItems || api.getExportItems() と書くとフォールバックが働かず、「この形式は利用できません」になります。現在値を先に見て、length で判定して落とすのが安全です。
エクスポート前にレポートの実行完了を待つ
repEl.reportAPI は ReportState そのもので(get reportAPI() { return this._reportState; })、要素が生成された時点ですでに存在します。存在するかどうかでは「実行が終わったか」を判定できません。
//Set the runRequired flag to true as soon as the ReportAPI is created, because it has never been run
this.runRequired = true;
しかもエクスポートの REPORT 経路は、実行要否を見ずに現在の state をそのまま送ります(再実行を要求する reportRunsRequired を積むのは DASHBOARD 経路だけです)。未実行のまま実行すると中身の無いファイルが生成されるので、runRequired を見て onReportLoad を待つようにしています。
pdf_jsp は PDF 専用ではない
エクスポートに失敗すると、こんなスタックが出ることがあります。
java.lang.IllegalStateException: File failed to generate
at com.hof.jsp.pdf_jsp._jspService(pdf_jsp.java:119)
pdf とあるので PDF の問題に見えますが、この JSP は PDFBean(filename / contentType / data / attachment を持つだけの 汎用ファイル配信 Bean)を書き出す共通の出力層です。CSV 専用の出力 JSP は存在しないので、CSV の失敗でもこのスタックが出ます。「生成結果が空のまま配信段階に来た」という意味に読むのが正しいです。
まとめ
- ダッシュボードごとにコードを貼るのが面倒になったら、コードウィジェット(JAR) にすると配布できるようになります
- 見た目の設定 UI は自作せず、標準ボタンの
CanvasButtonPanelを継承 すれば同じものがそのまま出ます - 設定値は
options.formatsではなくmessengerから取ります - 既定値は誰も保存してくれない ので、標準ボタンと同じように配置直後に自分で書き込みます
- 後からレポートを追加したときのために、
ParameterValueLoaderで候補を取り直せるようにしておくと親切です
エクスポート処理そのものは、コードモード版とまったく同じ setupExport() の1行です。標準の入口を呼ぶだけ という方針は変えずに、配布しやすい形に持っていけました。
