GAS(GoogleAppsScript)とVueを使いWebアプリ(タスク管理アプリ)を作ってみる

記事構成 ①ブラウザ画面を表示する ②CRUDしてみる←イマココ

完成画像↓ 1641579913269.jpeg

概要

「GASでスプレットシートを使ってデータベースみたいなことできない?」という命を受け調査。 とりあえず、「みんな大好きタスク管理アプリ」を作ってみました。 VueとBootstrap-VueでCRUDを構築していきます。


全ソースコード

// server.gs
function include(filename) {
  return HtmlService.createHtmlOutputFromFile(filename).getContent();
}

function doGet() {
  var template = 'index';
  const htmlOutput = HtmlService.createTemplateFromFile(template).evaluate();
  htmlOutput.setTitle('タスク管理アプリ【GAS】');
  return htmlOutput;
}

function doPost(e) {

  // パラメータから実行関数を分岐する。
  const parameter = e.parameter.parameter;
  switch (parameter){
    case 'getAll':
      return doGetAll();
      break;
    case 'create':
      return doCreate(e);
      break;
    case 'update':
      return doUpdate(e);
      break;
    case 'done':
      return changeDone(e);
      break;
    case 'delete':
      return doDelete(e);
      break;
    default:
      doGet();
  }
}

// タスクを全取得してくる関数
function doGetAll() {
  try {
    let st = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('contents');
    let lastrow = st.getLastRow();
    // セルの範囲を取得する
    const range = st.getRange(2, 1, lastrow - 1, 3);
   // セル範囲の値を2次元配列で取得する
    const todos = range.getValues();

    return makeResponse('200', 'getAll', todos);
  } catch (err) {
    logError(err);
  }
  
}

// タスクをスプレットシートに書き込む関数
function doCreate(e) {
  try {
    const todo = e.parameter.todo;
    let st = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('contents');
    let lastrow = st.getLastRow();
    let lastId = st.getRange(lastrow,1).getValue();
    if(lastId && typeof lastId === 'number') {
      lastId++;
    } else {
      lastId = 1;
    }
    st.getRange(lastrow+1,1).setValue(lastId);
    st.getRange(lastrow+1,2).setValue(todo);
    st.getRange(lastrow+1,3).setValue(false);

    return makeResponse('200', 'create');
  } catch (err) {
    logError(err);
  }
}

// タスクの状態を更新する関数
function changeDone(e) {
  try {
    const id = e.parameter.id;
    // logError('id:' + id);
    let st = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('contents');
    let lastrow = st.getLastRow();;
    // セルの範囲を取得する
    const range = st.getRange(1, 1, lastrow - 1, 3);
   // セル範囲の値を2次元配列で取得する
    const todos = range.getValues();

    let row = todos.length + 1;
    for(let i = 0; i<todos.length; i++){
      // logError('todos:' + todos[i][0]);
      if(todos[i][0] == id){
        row = i + 1;
        break;
      }
    }
    // logError('row:' + row)
    if(row == 0) {
      return makeResponse('400', 'doneError');
    }

    const done = st.getRange(row,3).getValue() ? false : true;
    st.getRange(row,3).setValue(done);

    return makeResponse('200', 'done');
  } catch (err) {
    logError(err);
  }
}

// タスクを更新する関数
function doUpdate(e) {
  try {
    const id = e.parameter.id;
    const todo = e.parameter.todo;
    let st = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('contents');
    let lastrow = st.getLastRow();;
    // セルの範囲を取得する
    const range = st.getRange(1, 1, lastrow - 1, 3);
   // セル範囲の値を2次元配列で取得する
    const todos = range.getValues();

    let row = todos.length + 1;
    for(let i = 0; i<todos.length; i++){
      if(todos[i][0] == id){
        row = i + 1;
        break;
      }
    }

    if(row == 0) {
      return makeResponse('400', 'updateError');
    }

    st.getRange(row,2).setValue(todo);

    return makeResponse('200', 'update');
  } catch (err) {
    logError(err);
  }
}

// タスクを削除する関数
function doDelete(e) {
  try {
    const id = e.parameter.id;
    let st = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('contents');
    let lastrow = st.getLastRow();;
    // セルの範囲を取得する
    const range = st.getRange(1, 1, lastrow - 1, 3);
   // セル範囲の値を2次元配列で取得する
    const todos = range.getValues();

    let row = todos.length + 1;
    for(let i = 0; i<todos.length; i++){
      if(todos[i][0] == id){
        row = i + 1;
        break;
      }
    }

    if(row == 0) {
      return makeResponse('400', 'deleteError');
    }

    st.deleteRow(row);

    return makeResponse('200', 'delete');
  } catch (err) {
    logError(err);
  }
}

// フロントへ返却するレスポンスを構築する関数
function makeResponse(status, message, payload = null) {
  const data = {
    status: status,
    message: message,
    data: payload,
  }
  const output = ContentService.createTextOutput(JSON.stringify(data));
  output.setMimeType(ContentService.MimeType.JSON);
  return output;
}

// エラーシートにログを残す関数
function logError(err) {
  const st = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('errors');
  const lastrow = st.getLastRow();
  st.getRange(lastrow+1,1).setValue(err);
}

// index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <base target="_top">
    <!-- <script src="https://cdn.jsdelivr.net/npm/vue"></script> -->
    <!-- Load required Bootstrap and BootstrapVue CSS -->
    <link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap/dist/css/bootstrap.min.css" />
    <link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap-vue@latest/dist/bootstrap-vue.min.css" />

    <!-- Load polyfills to support older browsers -->
    <!-- <script src="//polyfill.io/v3/polyfill.min.js?features=es2015%2CIntersectionObserver" crossorigin="anonymous"></script> -->

    <!-- Load Vue followed by BootstrapVue -->
    <script src="//unpkg.com/vue@latest/dist/vue.min.js"></script>
    <script src="//unpkg.com/bootstrap-vue@latest/dist/bootstrap-vue.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/es6-promise@4/dist/es6-promise.auto.min.js"></script>

    <!-- Load the following for BootstrapVueIcons support -->
    <script src="//unpkg.com/bootstrap-vue@latest/dist/bootstrap-vue-icons.min.js"></script>
    <?!= include('css'); ?>
  </head>
  <body>
    <br>
    <div id="app">
      <b-container class="text-center mt-5" style="width:100%">
        <b-modal id="modal-edit" title="タスク編集" @ok="doUpdate()">
          <p class="my-4">タスクID:{{dialogId}}</p>
          <div>
            <b-form-input
              v-model="editTodo"
              placeholder="やること"
            ></b-form-input>
          </div>
        </b-modal>
        <b-modal id="modal-delete" title="タスク削除" @ok="doDelete()">
          <p class="my-4">ID:{{dialogId}}のタスクを削除しますか?</p>
        </b-modal>
        <b-alert
          class="fixed-top mx-auto mt-2"
          style="width:90%"
          :show="dismissCountDown"
          :variant="alertColor"
          @dismissed="dismissCountDown=0"
          @dismiss-count-down="countDownChanged"
          fade
        >
          {{alertText}}
        </b-alert>
        <b-form @submit="onSubmit">
          <b-row>
            <b-container class="mb-2">
              <b-row>
                <b-col cols="10">
                  <b-form-input
                    v-model="newTodo"
                    placeholder="やること"
                  ></b-form-input>
                </b-col>
                <b-col cols="2">
                  <b-button
                    pill
                    variant="outline-success"
                    @click="onSubmit"
                    :disabled="isDisabled"
                  >タスク登録</b-button>
                </b-col>
              </b-row>
            </b-container>
            <b-alert show class="text-center">◆タスク一覧◆</b-alert>
            <b-col>
              <b-list-group>
                <b-list-group-item class="mb-1">
                  <div class="d-flex justify-content-between">
                    <div class="d-flex align-items-center mr-1 h5">
                      ID
                    </div>
                    <div class="d-flex align-items-center" style="max-width: 270px">
                      タスク名
                    </div>
                    <div class="d-flex align-items-center justify-content-center" style="width: 160px">
                      操作ボタン
                    </div>
                  </div>
                </b-list-group-item>
                <b-list-group-item v-for="todo in todos" :key="todo.id" class="mb-1">
                  <div class="d-flex justify-content-between">
                    <div class="d-flex align-items-center mr-1 h5">
                      {{todo.id}}
                    </div>
                    <div class="d-flex align-items-center wrap_text left px-2" :class="{ done: todo.done }" style="max-width: 270px">
                      {{todo.todo}}
                    </div>
                    <div class="d-flex align-items-center no_padding" style="width: 160px">
                      <div>
                        <b-button
                          pill
                          variant="outline-warning"
                          size="sm"
                          @click="changeDone(todo.id)"
                          :disabled="isDisabled"
                        >完了</b-button>&nbsp;
                        <b-button
                          v-b-modal.modal-edit
                          pill
                          variant="outline-info"
                          size="sm"
                          @click="onEdit(todo.id, todo.todo)"
                          :disabled="isDisabled"
                        >編集</b-button>&nbsp;
                        <b-button
                          v-b-modal.modal-delete
                          pill
                          variant="outline-danger"
                          size="sm"
                          @click="onDelete(todo.id)"
                          :disabled="isDisabled"
                        >削除</b-button>
                      </div>
                    </div>
                  </div>
                </b-list-group-item>
              </b-list-group>
            </b-col>
          </b-row>
        </b-form>
      </b-container>
    </div>
    <?!= include('js'); ?>
  </body>
</html>
// css.html
<style>
.done {
  text-decoration: line-through;
}

.wrap_text {
  word-break: break-all;
  overflow-wrap: break-word;
}

.left {
  text-align: left;
}

.right {
  text-align: right;
}

.no_padding {
  padding: 0;
}
</style>
// js.html
<script>
  new Vue({
    el: "#app",
    data: {
      url: 'デプロイURL', // ここにデプロイしたURLを設定する
      todos: [], // スプレットシートに保存されたタスクが格納される
      newTodo: "", // 新規タスク作成するための変数
      editTodo: "", // タスクを更新する時に格納するための変数
      dialogId: "",
      isDisabled: false,
      alertColor: "info",
      alertText: "",
      dismissSecs: 5,
      dismissCountDown: 0
    },
    methods: {
      // スプレットシートに保存されたタスクを取得する関数
      async getAll() {
        console.log('getAll')
        // パラメータにgetAllを仕込んでバックエンドで分岐させる
        const data = {
          'parameter': 'getAll',
        }
        const params = new URLSearchParams(data)

        // axiosでポストする
        await axios.post(this.url, params, {
          headers: {'content-type': 'application/x-www-form-urlencoded'}
        })
          .then((res) => {
            console.log(res.data.data)
            const todos = res.data.data
            for (let i = 0; i < todos.length; i++) {
              const todo = {
                id: todos[i][0],
                todo: todos[i][1],
                done: todos[i][2],
              }
              this.todos.push(todo);
            }
          })
          .catch((err) => {
            console.log(err)
          });
      },
      // タスク登録ボタンを押したときの処理。新規タスクを作成する
      async onSubmit(){
        console.log('submit')
        this.isDisabled = true;
        // パラメータにcreateを仕込んでバックエンドで分岐させる
        const parameter = {
          'parameter': 'create',
          'todo': this.newTodo,
        }

        const params = new URLSearchParams(parameter)
        await axios.post(this.url, params, {
          headers: {'content-type': 'application/x-www-form-urlencoded'}
        })
          .then((res) => {
            console.log(res);
            this.resetTodos();
            this.getAll();
            this.showAlert('success', 'タスクを追加しました。');
          })
          .catch((err) => {
            console.log(err)
          });
        this.newTodo = "";
        this.isDisabled = false;
      },
      // タスクの状態を更新する関数
      async changeDone(id){
        console.log('changeDone')
        this.isDisabled = true;
        // パラメータにdoneを仕込んでバックエンドで分岐させる
        const parameter = {
          'parameter': 'done',
          'id': id,
        }

        const params = new URLSearchParams(parameter)
        await axios.post(this.url, params, {
          headers: {'content-type': 'application/x-www-form-urlencoded'}
        })
          .then((res) => {
            console.log(res);
            this.resetTodos();
            this.getAll();
            this.showAlert('warning', 'タスクの状態を更新しました。');
          })
          .catch((err) => {
            console.log(err)
          });
        this.newTodo = "";
        this.isDisabled = false;
      },
      // タスクを更新する関数
      async doUpdate(){
        console.log('doUpdate')
        this.isDisabled = true;
        // パラメータにupdateを仕込んでバックエンドで分岐させる
        const parameter = {
          'parameter': 'update',
          'id': this.dialogId,
          'todo': this.editTodo,
        }

        const params = new URLSearchParams(parameter)
        await axios.post(this.url, params, {
          headers: {'content-type': 'application/x-www-form-urlencoded'}
        })
          .then((res) => {
            console.log(res);
            this.resetTodos();
            this.getAll();
            this.showAlert('info', 'タスクを更新しました。');
          })
          .catch((err) => {
            console.log(err)
          });
        this.editTodo = "";
        this.isDisabled = false;
      },
      // タスクを削除する関数
      async doDelete(){
        console.log('doDelete')
        this.isDisabled = true;
        // パラメータにdeleteを仕込んでバックエンドで分岐させる
        const parameter = {
          'parameter': 'delete',
          'id': this.dialogId,
        }

        const params = new URLSearchParams(parameter)
        await axios.post(this.url, params, {
          headers: {'content-type': 'application/x-www-form-urlencoded'}
        })
          .then((res) => {
            console.log(res);
            this.resetTodos();
            this.getAll();
            this.showAlert('danger', 'タスクを削除しました。');
          })
          .catch((err) => {
            console.log(err)
          });
        this.dialogId = "";
        this.isDisabled = false;
      },
      // 編集ボタンを押したときの処理
      onEdit(id, todo) {
        this.dialogId = id;
        this.editTodo = todo;
      },
      // 削除ボタンを押したときの処理
      onDelete(id) {
        this.dialogId = id;
      },
      // アラートの表示時間カウントダウン
      countDownChanged(dismissCountDown) {
        this.dismissCountDown = dismissCountDown
      },
      // アラートの表示
      showAlert(color, text) {
        this.alertText = "";
        this.alertText = text;
        this.alertColor = color;
        this.dismissCountDown = this.dismissSecs
      },
      // タスクリセット
      resetTodos() {
        this.todos = []
      }
    },
    mounted(){
      // 画面が呼び出されたらタスクをバックエンドから取得するgetAll関数を実行
      this.getAll();
      
    }
  })
</script>

スプレットシートはこんな感じです。 「contentsシート」と「errorsシート」を作成します。

1641579218701.jpeg

1641579233373.jpeg

デプロイで認証を問われるので少しやっかいですが、許可させましょう。 1641579556078.jpeg

1641579596694.jpeg

1641579670263.jpeg

1641579701254.jpeg

1641579734718.jpeg

デプロイしたら動かしてみましょう。

まとめ&感想

ちょっと処理の遅さが気になります。 試してはみましたが、変則的ですし処理速度も遅いのであまり積極的にGASでWebアプリを作ることはないかもしれません。 とはいえスプレッドシートなどのGoogleツールとの連携させた自動化システムをちょっと作るのに最適なGASは今後も勉強していこうと思います。 次はHerokuなどにデプロイする記事を書けたらな、と思っています。

記事構成 ①ブラウザ画面を表示する ②CRUDしてみる←イマココ