はじめに

Firebaseを使ったWebアプリ開発の後編です。前編はこちらです。

アプリの要件(再掲)

  • 「いきたい場所(必須)」、「ひとこと(任意)」、「場所に関連するURL(任意)」を登録する
  • いきたい場所の追加・削除ができる(編集は後々・・)
    • 削除は自分が追加したもののみできる
  • いきたいリストの閲覧は誰のものでもできる
  • いきたいリストを作成したい場合はユーザーの登録が必要
  • いきたい場所と場所に関連するURLの入力を自動補完したい

実装

アカウント作成

アカウント作成では前編でコンソールで手動でおこなった以下の処理をフォームの入力を使っておこないます。

  • Firebase Authentification(以降 Firebase Auth)にユーザーを作成
  • FirestoreにUIDをドキュメントとして追加

以下はアカウント作成のページのHTMLとJavaScriptです。(※本記事では内容が多くなってしまうためHTMLとCSSの説明は割愛します)

HTML

<div class='wrapper'>
    <div class='form'>
        <label>ユーザー名</label>
        <input type='text' name='userName'>
        <label>メールアドレス</label>
        <input type='text' name='email'>
        <label>パスワード</label>
        <input type='password' name='password'>
        <p id='btn_signup'>アカウントを作成</p>
        <p class='error'></p>
    </div>
</div>

JavaScript

// 「アカウントを作成」ボタンのイベント
$('#btn_signup').on('click', async function () {
    // 1. フォームの入力を取得
    var email = $('[name=email]').val();
    var password = $('[name=password]').val();
    var userName = $('[name=userName]').val();
    if(!userName){
        $('.form .error').text('ユーザー名を入力してください');
        return;
    };
    // 2. Firebase Authにユーザーを作成し、作成したユーザーのUIDをFirestoreに追加
    try{
        var result = await firebase.auth().createUserWithEmailAndPassword(email, password);
        await createFirestoreUser(result.user, userName);
        location.href = "./user.html" + "?id=" + result.user.uid;
    } catch(error){
        $('.form .error').text(error);
    };
});
// 3. FirestoreにUIDを追加する関数
function createFirestoreUser(user, userName, retry = 3){
    return new Promise(function(resolve,reject) {
        db.collection("users").doc(user.uid).set({
            name: userName,
            created_at: new Date(),
        })
        .then(function() {
            resolve();
        })
        .catch(function(error) {
            console.error(error);
            // 失敗したら3回までリトライする
            if (retry > 0){
                retry --;
                console.log('リトライ');
                createFirestoreUser(user, userName, retry).then(resolve).catch(reject);
            }else{
                // 3回リトライして失敗したらfirebase authに登録したユーザーを削除して再度登録のし直しを促す
                user.delete();
                reject('ユーザーの登録に失敗しました。お手数ですが時間を置いて再度登録してください。');
            }
        });
    });
}

1.ではフォームに入力された情報を取得しています。

2.ではメールアドレスとパスワードを使ってFirebase Authにアカウントを作成し、作成したユーザーのUIDをFirestoreに追加しています。

3.はUIDをFirestoreに追加する関数です。FirestoreにUIDを追加する処理が失敗してしまうと、アカウントの作成が途中で終わってしまう(Firebase Authにだけ作成されている状態)ので、失敗しても3回はリトライし、3回とも失敗した場合はFirebase Authに作成したアカウントを物理削除して、登録のし直しを促すようにしています。
全ての認証が完了したら、ユーザーごとのページにとばします。

ログイン

ログインはアカウントの作成と同様にフォームの入力内容を使ってアカウントの認証をおこないます。

HTML

<div class='wrapper'>
    <div class='form'>
        <label>メールアドレス</label>
        <input type='text' name='email'>
        <label>パスワード</label>
        <input type='password' name='password'>
        <p id='btn_login'>ログイン</p>
        <p class='error'></p>
    </div>
</div>

JavaScript

// 「ログイン」ボタンのイベント
$('#btn_login').on('click', async function() {
    // 1. フォームの入力を取得
    var email = $('[name=email]').val();
    var password = $('[name=password]').val();
    try{
        // 2. Firebase Authで認証
        var result = await firebase.auth().signInWithEmailAndPassword(email, password);
        // 3. FirestoreにUIDがあるかを確認
        await isUserInFirestore(result.user.uid);
        location.href = "./user.html" + "?id=" + result.user.uid;
    } catch(error){
        $('.form .error').text(error);
    };
});
// firestoreにUIDがあるかを判定する関数
function isUserInFirestore(uid){
    return new Promise(function(resolve, reject){
        db.collection("users").doc(uid).get()
        .then((docSnapshot) => {
            if (docSnapshot.data()) {
                resolve();
            } else {
                firebase.auth().signOut();
                reject('firestoreに登録されていません');
            };
        });
    });
}

1.でフォームの内容を取得します。
2.では1で取得したメールアドレスとパスワードを使ってFirebase Authでユーザーの認証をおこないます。
3.Firebase Authで認証が完了するとres.user.uidで承認されたユーザーのUIDを取得できるので、UIDがFirestoreにあるかを確認します。全ての認証が完了したら、ユーザーごとのページにとばします。

いきたいリストを表示するページ

いきたいリストを表示するページでは、ユーザーごとに異なる内容を表示する必要があります。そのため、URLに「user.html?id=\」というようにUIDを含めることで、表示する内容を動的に取得します。

URLに含まれているパラメータは以下のコードで取得できます。

var params = (new URL(document.location)).searchParams;
var pageUid = params.get('id');

また、以下はいきたいリストが表示される前の元のHTMLです。

HTML

<div class='wrapper'>
    <div class='header'>
        <div class='menu'></div>
    </div>
    <div class='ikitai_list'></div>
    <div class='form hide'>
        <input type='text' name='placeName' placeholder='いきたい場所(必須)' id='search-text'>
        <textarea name='comment' rows="4" placeholder='ひとこと(任意 最大100字)' maxlength="100"></textarea>
        <input type='text' name='url' placeholder='Google MapやホームページのURL(任意)' id='url'>
        <p id='btn_create'>登録</p>
        <p class='error'></p>
    </div>
</div>

いきたいリストの取得・表示

いきたいリストはURLから取得したpageUidを用いて取得することで、ページごとに表示する内容を変えます。

「isThisPageCurrentUsers」という関数は、詳しくは後述しますが、いきたいリストがユーザー自身のものであるかどうかを返す関数で、trueの場合にはいきたいリストを削除するボタンを表示するようにしています。削除する際に、リストに対応するドキュメントのidが必要なので、display:noneにしたaタグにドキュメントのidを隠しています。

// いきたいリストを取得する関数
function getIkitaiList(){
    return new Promise(function(resolve,reject) {
        db.doc('users/' + pageUid).get()
        .then((docSnapshot) => {
            var ikitaiList = [];
            docSnapshot.ref.collection("posts").get()
            .then((querySnapshot) => {
                querySnapshot.forEach((doc) => {
                    var placeName = doc.data().place_name;
                    var comment = doc.data().comment;
                    var url = doc.data().url;
                    var docId = doc.id;
                    ikitaiList.push({
                        'placeName':placeName,
                        'comment': comment,
                        'url': url,
                        'docId': docId
                    });
                });
                resolve(ikitaiList);
            })
            .catch((error) => {
                console.error(error);
                reject('いきたいリストの取得に失敗しました');
            });
        })
        .catch((error) => {
            console.error(error);
            reject('いきたいリストの取得に失敗しました');
        });
    });
}
// いきたいリストを表示する関数
async function showIkitaiList(ikitaiList){
    // いきたいリストが登録されていない場合の処理
    if(!ikitaiList.length){
        $('.ikitai_list').html(`<p>いきたいリストがまだ登録されていないようです</p>`);
        return;
    }
    ikitaiListHtmlString = '';
    var isThisPageCurrentUsersFlag = await isThisPageCurrentUsers(pageUid);
    ikitaiList.forEach((item) => {
        var ikitaiListItemHtmlString = '';
        var placeName = item.placeName;
        var comment = item.comment;
        var url = item.url;
        var docId = item.docId;
        // urlが登録されていたらリンクに飛べるようにする
        if(url){
            ikitaiListItemHtmlString += `<p><a href="${url}" target="_blank" rel="noopener noreferrer">${placeName}</a></p>`
        }else{
            ikitaiListItemHtmlString += `<p>${placeName}</p>`
        };
        ikitaiListItemHtmlString += `<p>${comment}</p>`
        // ユーザー自身のいきたいリストだったら削除ボタンを表示する
        if(isThisPageCurrentUsersFlag){
            ikitaiListItemHtmlString += `<a style='display:none;'>${docId}</a><a id="btn_delete"><i class="fas fa-trash-alt"></i></a>`
        }
        ikitaiListItemHtmlString = `<li>${ikitaiListItemHtmlString}</li>`;
        ikitaiListHtmlString += ikitaiListItemHtmlString;
    });
    $('.ikitai_list').html(`<ul>${ikitaiListHtmlString}</ul>`);
}

いきたい場所の登録

いきたい場所の登録はアカウント作成やログインのようにフォームの入力を用います。後述しますがいきたい場所を登録するボタンはユーザー自身のいきたいリストのページにしか表示されないようにするので、pageUidを用いて登録します。

いきたい場所の新規登録が完了したら、いきたいリストを再取得・再表示するようにしています。

// いきたい場所登録ボタンのイベント
$(document).on('click', '#btn_create', async function(){
    var placeName = $('[name=placeName]').val();
    var comment = $('[name=comment]').val();
    var url = $('[name=url]').val();
    if(!placeName){
        $('.form .error').text('いきたい場所を入力してください');
        return;
    };
    try{
        $('.form .error').text('');
        await createIkitai(placeName, comment, url);
        var ikitaiList = await getIkitaiList();
        showIkitaiList(ikitaiList);
    }catch(error){
        $('.form .error').text(error);
    }
});
// いきたいリストに場所を登録する関数
function createIkitai(placeName, comment, url){
    return new Promise(function(resolve,reject) {
        db.collection('users').doc(pageUid).collection('posts').add({
            place_name: placeName,
            comment: comment,
            url: url,
            created_at: new Date()
        })
        .then(function() {
            // フォームを初期化する
            $('[name=placeName]').val('');
            $('[name=comment]').val('');
            $('[name=url]').val('');
            resolve('いきたいリストの追加に成功');
        })
        .catch(function(error) {
            console.error(error);
            reject('いきたいリストの追加に失敗しました');
        });
    });
}

いきたい場所の削除

pageUidと隠れているドキュメントidを使っていきたい場所を削除します。
追加と同様に、いきたい場所の新規登録が完了したら、いきたいリストを再取得・再表示するようにしています。

// いきたい場所削除ボタンのイベント
$(document).on('click', '#btn_delete', async function(){
    try{
        // 隠れているドキュメントidを取得する
        var docId = $(this).prev('a').text();
        await deleteIkitai(docId);
        var ikitaiList = await getIkitaiList();
        showIkitaiList(ikitaiList);
    } catch(error){
        console.error(error);
    }
});
// いきたいリストに登録されている場所を削除する関数
function deleteIkitai(docId){
    return new Promise(function(resolve,reject) {
        db.collection('users').doc(pageUid).collection('posts').doc(docId).delete()
        .then(function() {
            resolve('いきたいリストの削除に成功');
        })
        .catch(function(error) {
            console.error(error);
            reject('いきたいリストの削除に失敗しました');
        });
    });
}

ページの制御

いきたいリストの登録・削除は自身のリストでしかできないようにする必要があります。そこでFirebase AuthのonAuthStateChangedを使うことで、現在ログインしているユーザーを取得することができます。自身がログインしていれば自身のUIDを取得でき、pageUidと自身のUIDが一致すれば、そのページはユーザーのマイページであると判定できます。
以下の関数でユーザーのマイページであるかを判定することで、いきたいリストを追加・削除するボタンの表示を切り替えることができます。

// このページがユーザーのものかを判定する
function isThisPageCurrentUsers(pageUid){
    return new Promise(function(resolve){
        firebase.auth().onAuthStateChanged((user) => {
            if(user && (pageUid === user.uid)){
                resolve(true);
            }else{
                resolve(false);
            }
        });
    });
}

また、ユーザーがログインしているかどうかを判定してログイン・ログアウトの表示を切り替えたり、ログインしている場合はログインページやアカウント作成ページにはいけないようにするといった機能があると親切です。
前述したように、onAuthStateChangedを使えばユーザーがログインしているかどうかも簡単に判定することができます。

// ユーザーがログインしているかを判定する
// ログインしていたらidを返す
function isThisUserCurrentUser() {
    return new Promise(function(resolve, reject){
        firebase.auth().onAuthStateChanged((user) => {
            if (user) {
                // firestoreに入っているかどうかも確認する
                var uid = user.uid;
                isUserInFirestore(uid).then(resolve(uid)).catch(reject());
            }else{
                reject();
            };
        });
    });
}

ログアウト

ログアウトはアカウント作成やログインと同様にFirebase Authの機能にあるのでとても簡単にできます。

// ログアウトボタンのイベント
$(document).on('click', '#logout', function(){
    firebase.auth().signOut().then(()=>{
        // ログインページにとばす
        location.href = "./index.html"
    })
    .catch( (error)=>{
        console.error(error);
    });
});

入力補完

いきたい場所が表記揺れしたり、リンクを自分で入力するのが面倒なので、Google Places APIのオートコンプリート機能を使いました。
あまり日本語のドキュメントがないことが難点ですが、実装自体はとてもシンプルです。
GCPのコンソールからMaps JavaScript APIとPlaces APIを有効にし、Googleの公式ドキュメントを参考に実装しました。
また、補完を使って入力された場所のURLを取得し、URLも補完するようにしました。

// オートコンプリートの関数
function initAutocomplete() {
  var input = document.getElementById("search-text");
  var options = {
    componentRestrictions: { country: "jp" },
    fields: ["name", "place_id", "url"],
    types: ["establishment"],
  };
  var autocomplete = new google.maps.places.Autocomplete(input, options);
  autocomplete.addListener("place_changed", () => {
    var place = autocomplete.getPlace();
    input.value = place.name;
    $('#url').val(place.url);
  });
};

まとめ

面倒なアカウント作成・ログインの実装や高機能なオートコンプリートがFirebaseとGoogle Places APIを使うと、開発コストをかなり削減して実装できました。