Hexabaseは企業向けのBaaS(Backend as a Service)を提供しています。クラウドデータベースがあり、テーブルを作成してAPI経由でCRUD操作が可能です。他、ファイルストレージや認証、リアルタイム通知機能があります。

今回はHexabaseの提供する機能を使って、掲示板アプリを作成します。掲示板アプリは、スレッドの一覧表示、スレッドの詳細表示、スレッドの新規作成、コメントの投稿ができるものです。

Hexabaseの構造について

まず、Hexabaseの構造について簡単に説明します。

Hexabaseでは、一番上位のオブジェクトが「ワークスペース」です。ワークスペースの中には、複数の「プロジェクト」を作成することができます。そして、各プロジェクトには、複数の「データストア」を設定できます。データストアは、「テーブル」に相当します。

フォーラム用のHexabase設計

今回は、以下のように設計しました。

  • ワークスペース:Demo
  • プロジェクト:Forum
  • データストア:Thread

ワークスペース「Demo」の中に、プロジェクト「Forum」を作成し、その中にデータストア「Thread」を設定しました。これで、フォーラムを構築するための基本的な枠組みができあがりました。

また、プロジェクト「Forum」では、通知設定を有効にしておきました。これにより、新しい投稿があった際に、関係者に通知が届くようになります。

Hexabaseのフィールド設定

Hexabaseでは、自分でデータベース設計を行います。今回は、Threadを以下のようにフィールド作成しました。

フィールド名
id 自動採番
Title 文字列型
level 選択肢
body テキストエリア
pubDate 日付・時刻型
attachment 添付ファイル

フレームワークとルーティングについて

このフォーラムアプリケーションでは、Framework7をフレームワークとして利用しています。Framework7は、モバイルアプリケーションの開発に特化したフレームワークで、UIコンポーネントやルーティング機能などを提供しています。

アプリケーションの画面遷移は、以下のようなルーティング設定によって管理されています。

このルーティング設定により、URLパスに応じた画面の表示が可能になります。例えば、 "/home" にアクセスすると、 "./pages/home.html" が表示されます。また、 "/threads/:id" のように、パラメータを含むパスも定義できます。

const routes = [
  {
    path: "/",
    url: "./index.html",
  },
  // ログイン
  {
    path: "/home",
    name: "Home",
    componentUrl: "./pages/home.html"
  },
  // スレッド一覧
  {
    path: "/threads",
    name: "Threads",
    componentUrl: "./pages/threads.html"
  },
  // スレッド詳細
  {
    path: "/threads/:id",
    name: "Thread",
    componentUrl: "./pages/thread.html"
  },
  // スレッド新規作成
  {
    path: "/form",
    name: "Form",
    componentUrl: "./pages/form.html"
  },
  // Default route (404 page). MUST BE THE LAST
  {
    path: "(.*)",
    url: "./pages/404.html",
  },
];

ライブラリの読み込みと設定

index.htmlでは、Framework7のViewとdayjs(日付操作ライブラリ)、HexabaseのSDKを読み込んでいます。

<div id="app">
    <!-- Views/Tabs container -->
    <div class="views tabs safe-areas">
    <div id="app">
        <div class="views tabs safe-areas">
            <div id="view-home" class="view view-init view-main" data-url="/home">
            </div>
        </div>
    </div>
    </div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.10.7/dayjs.min.js" integrity="sha512-bwD3VD/j6ypSSnyjuaURidZksoVx3L1RPvTkleC48SbHCZsemT3VKMD39KknPnH728LLXVMTisESIBOAb5/W0Q==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="js/hexabase.js"></script>

また、画像の表示で blob:// を使用するため、CSP(Content Security Policy)を設定しています。

<!-- blob: を追加します -->
<meta http-equiv="Content-Security-Policy"
    content="default-src * data: gap: blob: content: https://ssl.gstatic.com; style-src * "unsafe-inline"; script-src * "unsafe-inline" "unsafe-eval"">

この設定により、 blob:// プロトコルを使用した画像の表示が可能になります。

Hexabase SDKの初期化

まず、js/app.jsでHexabase SDKを初期化します。
通常は引数不要ですが、開発環境などを指定する場合は引数を渡します。

// Hexabaseの初期化
const { HexabaseClient } = hexabase;
const client = new HexabaseClient();

ログイン処理について

ログイン処理はpages/home.htmlに実装されています。

メールアドレスとパスワードを入力し、ログインボタンを押すと以下の処理が実行されます。

入力されたメールアドレスとパスワードを使ってログインを試み、成功すればワークスペースを設定し、一覧画面に遷移します。

// ログインボタンを押した時の処理
const login = async () => {
    // 入力値を受け取る
    const email = $f7.$el.find("#email").val();
    const password = $f7.$el.find("#password").val();
    try {
        // ログイン
        const res = await client.login({ email, password });
        await client.setWorkspace("demo");
        const text = res ? "ログインしました" : "ログイン失敗しました";
        // トースト表示
        $f7.toast.create({
            text,
            position: "top",
            closeTimeout: 500,
        }).open();
        // 一覧画面に遷移
        $f7router.navigate({
            name: "Threads",
        });
    } catch (e) {
        console.log(e);
    }
};

スレッド一覧の取得

スレッド一覧はpages/threads.htmlで処理されます。

画面が表示されると、以下の処理が実行され、データストア"Thread"からレコードデータを取得します。

取得したデータは$update()でHTMLに反映されます。HTMLではtableタグを使って一覧を表示しています。

let records = []; // スレッド一覧用
// 画面が表示されたら呼び出されるイベント
$on("pageAfterIn", async (e, page) => {
        const project = await client.currentWorkspace.project("Forum");
        const thread = await project.datastore("Thread");
        // スレッド一覧の取得
        records = await thread.items();
        // 画面を更新
        $update();
});
<table>
    <thead>
        <tr>
            <th>#</th>
            <th>配信日</th>
            <th>タイトル</th>
            <th>重要度</th>
        </tr>
    </thead>
    <tbody>
        ${records.map(r => $h`
        <tr @click=${()=> click(r)}>
            <td>${parseInt(r.get('id'))}</td>
            <td>${dayjs(r.get('pubDate')).format('MM月DD日 hh:mm')}</td>
            <td>${r.get('Title')}</td>
            <td>${r.get('level') ? r.get('level').ja : ''}</td>
        </tr>
        `)}
    </tbody>
</table>

行をタップするとclick関数が呼ばれ、スレッド詳細画面に遷移します。

// スレッド一覧をタップした際のイベント
const click = (record) => {
        // スレッド詳細画面に遷移
        $f7router.navigate(/threads/${record.id}, {
                props: {
                        record,
                },
        });
}

データの新規作成

新規スレッドを作成するには、pages/threads.htmlのヘッダーにある+アイコンをタップし、/formに遷移します。

<div class="right">
    <a href="/form" class="link">
        <i class="f7-icons">plus</i>
    </a>
</div>

フォームについて

pages/form.htmlではフォームを表示し、タイトル、日付、重要度、本文、添付ファイルを入力できます。

<form id="report">
    <div class="list">
        <ul>
            <li class="item-content item-input">
                <div class="item-inner">
                    <div class="item-input-wrap">
                        <input type="text" name="Title" placeholder="掲示板のタイトル" placeholder="掲示板のタイトル" />
                        <span class="input-clear-button"></span>
                    </div>
                </div>
            </li>
            <li class="item-content item-input">
                <div class="item-inner">
                    <div class="item-input-wrap">
                        <input type="date" name="pubDate" placeholder="2023-03-26" />
                    </div>
                </div>
            </li>
            <li class="item-content item-input">
                <div class="item-inner">
                    <div class="item-input-wrap">
                        <select name="level">
                            <option value="通常" selected>通常</option>
                            <option value="至急">至急</option>
                            <option value="重要">重要</option>
                        </select>
                    </div>
                </div>
            </li>
            <li class="item-content item-input">
                <div class="item-inner">
                    <div class="item-input-wrap">
                        <textarea name="body" class="resizable"></textarea>
                    </div>
                </div>
            </li>
            <li class="item-content item-input">
                <div class="item-inner">
                    <div class="item-input-wrap">
                        <input type="file" name="attachment" multiple />
                    </div>
                </div>
            </li>
            <li>
                <a href="#" @click=${save} class="list-button">保存する</a>
            </li>
        </ul>
    </div>
</form>

保存ボタンを押すとsave関数が呼ばれ、入力データをHexabaseのデータストアアイテムに変換し、saveメソッドでデータを保存します。

// 新しいスレッドを保存するイベント
const save = async (e) => {
        // フォームデータをオブジェクトに変換
        const item = await formToObject($(e.target).parents("form")[0]);
        try {
                // レコードの登録
                const res = await item.save();
                // データが登録できればメッセージ表示
                const text = res ? "保存しました" : "保存失敗しました";
                $f7.toast.create({
                        text,
                        position: "top",
                        closeTimeout: 2000,
                }).open();
                $f7router.back();
        } catch (e) {
                console.log(e);
        }
};

formToObject関数では、日付型の変換やファイルオブジェクトの作成を行っています。

// フォームのデータをオブジェクトに変換する関数
const formToObject = async (form) => {
        // プロジェクト・データストアを取得
        const project = await client.currentWorkspace.project("Forum");
        const thread = await project.datastore("Thread");
        // 新規アイテムを生成
        const item = await thread.item();
        // フォームの入力値をアイテムに適用する
        const ary = Array.prototype.slice.call(form.elements);
        for (const ele of ary) {
                const { name, type, value } = ele;
                // ファイルは別処理
                if (type !== "file") {
                        // 日付は日付型に変換
                        if (name === "pubDate") {
                                item.set(name, new Date(value));
                        } else {
                                item.set(name, value);
                        }
                        continue;
                }
                // ファイルはHexabaseのファイルオブジェクトに変換
                const files = ele.files.map(file => {
                        const f = item.file();
                        f
                                .set("name", file.name)
                                .set("data", file);
                        return f;
                });
                if (files.length > 0) item.set("attachment", files);
        }
        return item;
}

スレッド一覧からの画面遷移

スレッドをタップしたら、スレッド詳細画面に遷移します。これは pages/threads.html に定義しています。

// スレッド一覧をタップした際のイベント
const click = (record) => {
        // スレッド詳細画面に遷移
        $f7router.navigate(/threads/${record.id}, {
                props: {
                        record,
                },
        });
}

スレッド詳細画面

pages/thread.html についてです。この画面では前の画面から送られてきたスレッド詳細の表示と、コメントの取得を行っています。

<div class="page-content">
    <div class="list media-list">
        <ul>
            <li>
                <div class="item-content">
                    <div class="item-inner">
                        <div class="item-title-row">
                            <div class="item-title">${record.get('title')}</div>
                            <div class="item-after">${dayjs(record.get('pubDate')).format('MM月DD日 HH:mm')}</div>
                        </div>
                        <div class="item-subtitle">${record.get('level') ? record.get('level').ja : ''}</div>
                        <div class="item-text">${showText(record.get('body'))}</div>
                    </div>
                </div>
            </li>
            <li>
                ${attachments.map(a => $h`
                <div class="item-content">
                    <div class="item-inner">
                        <img src="${a}" width="100%" />
                    </div>
                </div>
                `)}
            </li>
        </ul>
    </div>
    <div class="block-title">コメント</div>
    <div class="list media-list">
        <ul>
            ${comments.map(c => $h`
            <li>
                <div class="item-content">
                    <div class="item-inner">
                        <div class="item-title-row">
                            <div class="item-title">${c.user.userName}</div>
                            <div class="item-after">${dayjs(c.createdAt).format('MM月DD日 HH:mm')}</div>
                        </div>
                        <div class="item-text">${c.comment}</div>
                    </div>
                </div>
            </li>
            `)}
        </ul>
    </div>
    <div class="list inset">
        <ul>
            <li class="item-content item-input">
                <div class="item-inner">
                    <div class="item-input-wrap">
                        <textarea id="text" class="resizable"></textarea><br />
                        <button class="button" @click=${addComment}>コメントする</button>
                    </div>
                </div>
            </li>
        </ul>
    </div>
</div>

コメントの取得

コメントは、画面を表示した際に取得します。Hexabaseにはコメント通知機能があるので、アイテムに対するsubscribeメソッドを使ってリアルタイム通知を受け取ります。

既存のコメントはrecord.histories()で取得できます。

export default (props, { $f7, $f7router, $update, $onMounted }) => {
    const { record } = props; // 前の画面から送られてくるスレッドデータ
    let comments = []; // コメント一覧用
    let attachments = []; // 添付ファイル用

    // 新しいコメントが追加された際のリアルタイム通知を受け取る
    record.subscribe("update", comment => {
        if (comment.comment === "") return;
        comments.push(comment);
        $update();
    });

    // 画面をマウントした際に実行されるイベント
    $onMounted(() => {
        initView(); // 初期表示用
        getAttachments(); // 添付ファイルの取得
    });

    // 初期表示用関数
    const initView = async () => {
        // コメントの取得
        comments = (await record.histories())
                .filter(c => c.comment !== "") // 空文字は除外
                .reverse(); // 逆順に
        // 画面更新
        $update();
    };

    // 省略
};

添付ファイルの取得

添付ファイル(今回は画像のみ)は、getAttachments関数で取得します。downloadメソッドを使うと、レコードのdataプロパティにファイルのデータが入ります。

// 添付ファイルの取得
const getAttachments = async () => {
    const attachment = record.get("attachment");
    if (!attachment || attachment.length === 0) return;
    try {
        // 添付ファイルのダウンロード
        await Promise.all(attachment.map(a => a.download()));
        attachments = attachment.map(a => URL.createObjectURL(a.data));
        $update();
    } catch (e) {
        console.log(e);
    }
}

ダウンロードした内容は Blob 型になっているので、 URL.createObjectURL を使って blob:〜 形式に変換しています。表示はテンプレートの attachments にて処理しています。以下は該当部分の抜粋です。

<li>
    ${attachments.map(a => $h`
        <div class="item-content">
            <div class="item-inner">
                <img src="${a}" width="100%" /> 
            </div>
        </div>
    `)}
</li>

コメントの登録

新しいコメントを入力してボタンを押すと、addComment関数が呼ばれます。この関数ではコメントを登録するのみで、非同期処理にする必要はありません。データが保存されればsubscribeメソッドが呼ばれるので、そこで画面の更新を行います。

// コメントを登録するイベント
const addComment = async () => {
    // 入力値の取得
    const text = $("#text").val();
    // コメント投稿
    const comment = record.comment();
    comment
        .set("comment", text)
        .save();
    $("#text").val(""); // リセット
};

登録したコメントはHexabaseでも確認できます。

まとめ

Hexabase SDKを使えば、データストアからデータを取得したり、登録したりするのは簡単です。Hexabaseではデータの型があるので、間違ったデータ型で登録しようとするとエラーになるので安心です。

外部システムと連携したり、よりスマホやタブレットから便利に使うためにMonacaアプリとHexabaseを組み合わせて利用してください。