メシウスのWebサイトではJavaScriptライブラリを活用した業務アプリのUIの実装サンプルを、ダウンロード可能なソースコード付きで多数公開しています。
本連載ではWebサイトで公開しているサンプルアプリケーションを例に、さまざまな業務アプリケーションのUIの実装のポイントや、コントロールの選定ポイントを解説していきます。第3回となる本記事ではJavaScript UIライブラリ「Wijmo(ウィジモ)」の各種コントロールを使用した「WBS(プロジェクト工数管理表)」デモの解説です。
本デモはデータグリッドコントロールの「FlexGrid」と「MultiRow」をベースにしたものと、スプレッドシートコントロールの「FlexSheet」をベースにしたものの2種存在します。
※ FlexGrid/MultiRow版はPureJS、FlexSheet版はAngularとReactのサンプルとして提供しています。
それぞれ同様の機能を実現していますが、使用するコントロールにより実装の方法や手間が異なっているので、その点も詳しく解説していきます。

利用しているコントロール
WBSデモではWijmoの以下のコントロールを使用しています。
| コントロール名 | 利用目的 | 対象デモ | その他 |
|---|---|---|---|
| InputNumber | 1日の規定労働時間を入力する | FlexGrid/MultiRow | |
| InputDate | プロジェクトの開発期間を入力する | 共通 | |
| FlexGrid、MultiRow | プロジェクトのWBSを表示する | FlexGrid/MultiRow | ![]() ![]() |
| FlexSheet | FlexSheet | ![]() | |
| Popup | 1日の労働時間の不正入力時のアラート表示 | FlexGrid/MultiRow | ![]() |
| FlexGridFilter | WBSをフィルタする | FlexGrid/MultiRow (FlexGrid使用の画面のみ) | ![]() |
| UndoStack | Undo/Redoを機能を追加 | FlexGrid/MultiRow | ‐ |
各機能の解説
ここからは各コントロールのソースコード部分を抜粋し、ポイントとなる部分を解説します。
セル結合
FlexGrid(在庫管理システム開発タブ)、MultiRow(勤怠管理システム開発タブ)、FlexSheetそれぞれでセル結合して1レコード複数行を実現しています。MultiRowはlayoutDefinitionを使用してレコード内のセルを縦横いずれの方向にも結合することが可能です。一方、FlexGridとFlexSheetの場合、wijmo.grid.MergeManagerクラスを拡張して、そのオブジェクトを定義してグリッドのmergeManagerプロパティに割り当てることで実現しました。以下のFlexGridのカスタムセル結合のデモもご参考ください
FlexGridの例
export class RestrictedMergeManager extends MergeManager {
getMergedRange(p, r, c, clip = true) {
if (grayTextCellNames.includes(p.columns[c].binding)) {
// create basic cell range
var rng = null;
// start with single cell
rng = new CellRange(r, c);
// expand up
while (rng.row > 0 && rng.row % 2 !== 0) {
rng.row--;
}
// expand down
while (rng.row2 < p.rows.length - 1 && (rng.row2 + 1) % 2 !== 0) {
rng.row2++;
}
// don't bother with single-cell ranges
if (rng.isSingleCell) {
rng = null;
}
// done
return rng;
}
}
}工程、タスク、担当者の列を2行単位でセル結合をしています。

FlexSheetの例
// ベースヘッダーの行のセル結合
export const baseHeaderMerge = (
headerStartRow: number,
headerStartCol: number,
flexSheet: FlexSheet,
) => {
// カスタムMergeManagerを設定
flexSheet.mergeManager = new MergeManager();
flexSheet.mergeManager.getMergedRange = function(p, r, c, clip) {
if (flexSheet.sheets.selectedIndex !== 0) {
// 先頭シート以外は結合表示しない
return null;
}
let rng = null;
if (Object.values(BASE_HEADERS).length + headerStartCol > c
&& headerStartRow < r) {
// start with single cell
rng = new CellRange(r, c);
// expand up
while (rng.row > 0 && rng.row % 2 === 0) {
rng.row--;
}
// expand down
while (rng.row2 < p.rows.length - 1 && (rng.row2 + 1) % 2 === 0) {
rng.row2++;
}
// don't bother with single-cell ranges
if (rng.isSingleCell) {
rng = null;
}
// done
}
return rng;
}
}FlexSheetでは工程から工数消化率の列までを2行単位でセル結合をしています。

自動計算処理
それぞれのデモにおいて、入力されたデータに応じて実績や出来高、工数消化などが自動的に計算されます。
FlexGrid(MultiRow)の場合
FlexGrid(MultiRow)は計算に使用するセルの値が変更する度に、再計算してセルの値を更新する処理を実装する必要があります。例えば、セルに値が張り付けられた際や、セルの入力が完了した場合、行や列が追加・削除された場合などです。
export function getUpdatedView(s, e) {
// FlexGridが描画された後に実行する
recalculateActualTime(s); // 実績(d)の計算
// 全体実績の計算
calcAllCell(s.columns.getColumn(ActualTime).index, s.columns.getColumn(ActualTime).index, s);
// 全体予定の計算
calcAllCell(s.columns.getColumn(ScheduledTime).index, s.columns.getColumn(ScheduledTime).index, s);
// 各々のその他の進捗率を計算
PersonnelList.forEach((personnel) => {
calcCommonProgressRate(0, s.columns.getColumn(Personnel).index, personnel, s);
});
calcProgressRate(0, s.columns.getColumn(Personnel).index, s); // 全体進捗率を計算
// イベントハンドラーを解除
s.updatedView.removeHandler(getUpdatedView);
}
export function getCalculatedFields() {
return {
yield: ($) => {
if ($.isCopy)
return "";
else if ($.scheduledTime === undefined || $.progressRate === undefined)
return 0;
else
return $.scheduledTime * $.progressRate;
},
manHourDigestionRate: ($) => {
if ($.isCopy)
return "";
else if ($.scheduledTime === 0 ||
$.actualTime === undefined ||
$.scheduledTime === undefined ||
$.actualTime === "" ||
$.scheduledTime === "")
return 0;
else
return $.actualTime / $.scheduledTime;
},
};
}
export function getPastedCell(s, e) {
getCellEditEnded(s, e);
}
export function getCellEditEnded(s, e) {
const col = s.columns[e.col];
// コピーセルの工程・タスク担当者を一致させる処理
// multrowの場合はスキップ
if (isFlexGrid() && e.row % 2 === 0) {
let grayTextIndex = grayTextCellNames.indexOf(col.binding);
if (grayTextIndex !== -1) {
s.setCellData(e.row + 1, s.columns.findIndex((c) => c.binding === grayTextCellNames[grayTextIndex]), s.getCellData(e.row, col.index, false));
}
}
// 実績(d)の計算
if (e.row % 2 !== 0 && col.binding.startsWith(dateCellBinding)) {
calcActualTime(s, e.row);
calcAllCell(s.columns.getColumn(ActualTime).index, s.columns.getColumn(ActualTime).index, s); // 全体実績の計算
}
// 全体予定の計算
if (e.row % 2 === 0 && col.binding === ScheduledTime) {
calcAllCell(e.col, e.col, s);
}
// 編集されたセルの担当を取得
const editedPersonnel = s.getCellData(e.row, s.columns.getColumn(Personnel).index, true);
setTimeout(() => {
// その他行の進捗率の計算(出来高/予定時間) 出来高=予定時間x進捗率, 予定時間
// 編集後の担当者の進捗率を更新
calcCommonProgressRate(e.row, e.col, editedPersonnel, s);
// 編集前の担当者の進捗率を更新(削除された担当者は除く)
if (e.col === s.columns.getColumn(Personnel).index &&
editedPersonnel !== e.data &&
PersonnelList.includes(e.data) &&
!(e.data === undefined || e.data === null || e.data === "")) {
calcCommonProgressRate(e.row, e.col, e.data, s);
}
calcProgressRate(e.row, e.col, s); // 全体の進捗率を計算
}, 0);
if (e.col === s.columns.getColumn(Personnel).index) {
// 新しい担当者がリストに存在しない場合、「その他」セルの行を追加
// 既存の担当者リストを取得
if (!PersonnelList.includes(editedPersonnel) && !isEmpty(editedPersonnel)) {
let newRow = {
process: CommonText,
personnel: editedPersonnel,
scheduledTime: 3,
// progressRate: 0,
};
// 追加するIndexを取得
let insertIndex = s.collectionView.sourceCollection.findIndex((item) => ![CommonText, AllText].includes(item.process) &&
(item.isSample === undefined || !item.isSample));
s.collectionView.sourceCollection.splice(insertIndex, 0, newRow);
if (isFlexGrid()) {
// コピー行
let newCopyRow = {
process: CommonText,
personnel: editedPersonnel,
isCopy: true,
scheduledTime: "",
};
s.collectionView.sourceCollection.splice(insertIndex + 1, 0, newCopyRow);
}
PersonnelList.push(editedPersonnel);
}
// 担当者が消えた場合
if (e.col === s.columns.getColumn(Personnel).index &&
editedPersonnel !== e.data) {
let personnelList = [
...new Set(s.collectionView.sourceCollection
.filter((item) => item.process !== CommonText)
.map((item) => item.personnel)),
];
if (!personnelList.includes(e.data)) {
let removeIndex = s.collectionView.sourceCollection.findIndex((item) => item.process === CommonText && item.personnel === e.data);
// FlexGridの場合はコピー行が格納されているが、MultRowにはコピー行がない点を考慮して要素を削除
let removeCount = isFlexGrid() ? 2 : 1;
s.collectionView.sourceCollection.splice(removeIndex, removeCount);
PersonnelList = PersonnelList.filter((item) => item !== e.data);
}
}
s.collectionView.refresh();
}
// Number型のセルでないときに数値のフォーマットを適用させる
let cellData = s.getCellData(e.row, col.index, false);
if (!(col.dataType === DataType.Number) &&
!isNaN(cellData) &&
cellData !== "") {
s.setCellData(e.row, e.col, Globalize.formatNumber(Number(cellData), "n2"));
}
// 新規行を追加
if (e.row >= s.rows.length - 2) {
addRow(s);
}
}FlexSheetの場合
FlexSheetで計算処理を実装する場合、セルの値に他セルを参照するような数式を設定できます。また数式を使用すれば、参照先のセルの値が(ユーザの入力やUndo/Redoなどの操作で)変更されてセルの値が変わっても再計算する必要はありません。
export const setFormula = (
headerStartRow: number,
headerStartCol: number,
flexSheet: FlexSheet,
) => {
const actualTimeIdx =
Object.keys(BASE_HEADERS).indexOf(BASE_HEADERS_TITLE.ActualTime) + headerStartCol
const yieldIdx = Object.keys(BASE_HEADERS).indexOf(BASE_HEADERS_TITLE.Yield) + headerStartCol
const scheduledTimeIdx =
Object.keys(BASE_HEADERS).indexOf(BASE_HEADERS_TITLE.ScheduledTime) + headerStartCol
const progressRateTimeIdx =
Object.keys(BASE_HEADERS).indexOf(BASE_HEADERS_TITLE.ProgressRate) + headerStartCol
const manHourDigestionRateIdx =
Object.keys(BASE_HEADERS).indexOf(BASE_HEADERS_TITLE.ManHourDigestionRate) + headerStartCol
// 設定のシートをがあれば設定値を参照する式を使用するようにする
const settingSheetIdx = flexSheet.sheets.findIndex((sheet) => sheet.name === SETIING_SHEET)
if (settingSheetIdx === -1) {
console.warn(`${SETIING_SHEET}シートが見つかりません`)
}
const workHours = settingSheetIdx !== -1 ? '設定!A2' : WORKING_HOURS
for (let row = 1 + headerStartRow; row < flexSheet.rows.length; row++) {
const formulaActualTime = `=ROUND(SUM(${getColumnLetter(
Object.values(BASE_HEADERS).length + headerStartCol + 1,
)}${row + 2}:XFD${row + 2})/${workHours}, ${2})` // 数式の行数は1から始まるので+1, FlexsheetのIndexは0から始まる
const formulaYield = `=${getColumnLetter(scheduledTimeIdx + 1)}${
row + 1
}*${getColumnLetter(progressRateTimeIdx + 1)}${row + 1}` // 数式の行数は1から始まるので+1, FlexsheetのIndexは0から始まる
const condition = `${getColumnLetter(actualTimeIdx + 1)}${
row + 1
}/${getColumnLetter(scheduledTimeIdx + 1)}${row + 1}`
const formulaManHourDigestionRate = `=IF(ISERROR(${condition}),"",${condition})` // 数式の行数は1から始まるので+1, FlexsheetのIndexは0から始まる
flexSheet.setCellData(row, actualTimeIdx, formulaActualTime)
flexSheet.setCellData(row, yieldIdx, formulaYield)
flexSheet.setCellData(row, manHourDigestionRateIdx, formulaManHourDigestionRate)
const readOnlyCols = [actualTimeIdx, yieldIdx, manHourDigestionRateIdx] // 計算式の列は編集不可セル
readOnlyCols.forEach((colIndex) => {
flexSheet.columns[colIndex].isReadOnly = true
})
}
}Undo/Redo
FlexSheetは標準でUndo/Redo機能が搭載されていますが、FlexGrid(MultiRow)には搭載されていないので、UndoStackを使用して機能を追加します。
export function createUndoStack(parentClassName, view) {
// parentClassNameと子要素の間にformElementを追加する
const parentElement = document.querySelector(`.${parentClassName}`);
// 新しい要素を作成
const formElement = document.createElement("form");
formElement.id = `${parentClassName}-undoable-form`;
// parentElementの子要素をnewElementに移動
while (parentElement.firstChild) {
formElement.appendChild(parentElement.firstChild);
}
// 新しい要素を追加
parentElement.appendChild(formElement);
let undoStack = new UndoStack(`#${formElement.id}`, {
undoneAction: (s, e) => {
getCellEditEnded(view, e.action);
},
redoneAction: (s, e) => {
getCellEditEnded(view, e.action);
},
});
}以下のように各プロジェクトで呼び出します。
・・・(中略)・・・
DataCommonService.addRow(grid);
DataCommonService.createWorkingHoursInputNumber("pj1-theInputNoSrc", grid);
DataCommonService.createUndoStack("project_flexgrid1", grid);
DataCommonService.setFilter(grid);
grid.autoSizeColumns();
return grid;
}Excelエクスポート
それぞれのデモにおいて、各コンポーネントのsaveAsyncメソッド(FlexGrid/FlexSheet)を使用してExcelエクスポート機能を実装しています。
FlexGrid(MultiRow)の場合
document.querySelector("#saveXlsx").addEventListener("click", () => {
if (!showGrid)
return;
let projectName = tabPanel.selectedTab.header.textContent;
FlexGridXlsxConverter.saveAsync(showGrid, {
includeColumnHeaders: true,
includeStyles: false,
formatItem: null,
}, `${projectName}_${dateFns.format(new Date(), "yyyy-MM-dd-HH-mm-ss")}.xlsx`);
});FlexSheetの場合
save() {
const fileName = 'FlexSheet.xlsx';
// 非同期でExcelファイルにエクスポート
if (!this.flex) return;
applyMergesToModel(this.flex)
this.flex.saveAsync(fileName);
}その際、FlexSheetで作成したWBSにおいては、制限はあるものの、セルに設定した数式など、アプリ上で使用していた機能をそのまま維持してExcelエクスポートできます。
さいごに
今回はWijmoの各種コントロールを使用した「WBS(プロジェクト工数管理表)」デモの機能の実装のポイントを解説しました。
今回は同様の画面をデータグリッド系のコンポーネントと、スプレッドシート系のコンポーネントそれぞれで作成していますが、この例では各種の計算に数式や関数を使用できる点や、Excelとの互換性の点で、スプレッドシートコントロールのFlexSheetを使用する方が優位な部分が多かったです。
また、今回のサンプルではExcelのように別のシートを追加して、一日の稼働時間やプルダウンメニューに表示する項目などを定義していました。別途設定値を入力したり保持したりする仕組みを作らなくてよいので、この点もFlexSheetならではの強みといえます。
製品サイトでは今回ご紹介したデモアプリケーションをブラウザ上で手軽にお試し可能で、加えてソースコード付きでダウンロードも可能なので、是非こちらもチェックしてみてください。
そのほか、製品サイトでは、Wijmoのトライアル版も公開しておりますので、こちらもご確認ください。
また、ご導入前の製品に関するご相談、ご導入後の各種サービスに関するご質問など、お気軽にお問合せください。





