20201123のvue.jsに関する記事は9件です。

【Vue + ApexCharts】データを集計して円グラフに表示させる

はじめに

皆さんおはこんばにちは。
エンジニア歴1年目、現場経験ゼロの弱小自称 Web エンジニアです。

Vue のアプリケーション開発で、見た目がシャレ乙な ApexCharts を導入してみたは良いものの
どうやってデータを詰めたらいいんジャイ!!となったので
本記事を作成するにいたった次第でございます。

やりたいこと

・ApexChartsを使って円グラフを表示させる。
・ストアに格納したデータの集計をその円グラフに反映させる。

image.png

環境

・Vue CLI v3.5.0
・Node v12.18.4
・Npm 6.14.6
・Vuetify 2.3.18
・ApexCharts 1.6.0

コードサンプル

本記事の目的は用意されたデータを如何にして ApexCharts に適用させるかなので
Vueプロジェクトの作成方法やデータの格納方法などは割愛します。

もし、プログラムを実際に動かしてみて確認したい方がいましたら
以下のコードサンプルをご活用ください。

Vuetifyの導入に必要なターミナルコマンド
$ vue add vuetify


App.vue
<template>
  <v-app>
    <v-main>
      <router-view />
    </v-main>
  </v-app>
</template>


Form.vue
<template>
    <v-layout row wrap justify-center>
        <v-flex xs5 mt-5>
            <v-card>
                <v-card-title>
                    <v-flex xs12 class="text-center">
                        支出フォーム
                    </v-flex>
                </v-card-title>

                <v-divider></v-divider>

                <v-card-text>
                    <v-form ref="test_form">
                        <v-text-field v-model="expenditure.price" label="支出額:" :rules="[numberRules.required,numberRules.regex]">
                            <template v-slot:append></template>
                        </v-text-field>

                        <v-radio-group row label="カテゴリー:" v-model="expenditure.category">
                            <v-radio label="食費" value="食費"></v-radio>
                            <v-radio label="交通費" value="交通費"></v-radio>
                            <v-radio label="住居費" value="住居費"></v-radio>
                            <v-radio label="その他" value="その他"></v-radio>
                        </v-radio-group>
                    </v-form>
                </v-card-text>

                <v-divider></v-divider>

                <v-card-actions>
                    <v-flex class="text-center">
                        <v-btn dark depressed @click="submit" color="info">入力</v-btn>
                    </v-flex>
                </v-card-actions>
            </v-card>
        </v-flex>
    </v-layout>
</template>

<script>
import { mapActions } from 'vuex'

export default {
    data() {
        return {
            //フォームに入力された値
            expenditure:{},
            //入力規則
            numberRules: {
                required: value => !!value || "未入力は許さない",
                regex: value => /^[0-90-9]+$/.test(value) || "数字だけ入力してちょ",
            },
        }
    },

    methods: {
        submit() {
            if (this.$refs.test_form.validate()) {
                this.expenditure.price = Number(this.expenditure.price); //入力された支出額を数値に変換
                this.addExpenditure(this.expenditure); //actions の addExpenditure を呼び出し
                this.$router.push({name:'Chart'}); //ルーティング
                this.expenditure = {}; //空に戻す
            }
        },
        ...mapActions(["addExpenditure"]),
    }
}
</script>


Chart.vue
<template>
    <v-layout row wrap justify-center>
        <v-flex xs5 mt-5>
            <v-card>
                <v-card-title>
                    <v-flex xs12 class="text-center">
                        円グラフ
                    </v-flex>
                </v-card-title>

                <v-divider></v-divider>

                <v-card-text>
                    <!-- ここに円グラフを表示させるためのタグを書く -->

                </v-card-text>

                <v-divider></v-divider>

                <v-card-actions>
                    <v-flex class="text-center">
                        <v-btn dark depressed @click="returnPage" color="info">戻る</v-btn>
                    </v-flex>
                </v-card-actions>
            </v-card>
        </v-flex>
    </v-layout>
</template>

<script>
export default {
    methods: {
        returnPage() {
            this.$router.push({name:'Form'}); //ルーティング
        }
    }
}
</script>


router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'

import Form from '../views/Form.vue'
import Chart from '../views/Chart.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'Form',
    component: Form
  },
  {
    path: '/chart',
    name: 'Chart',
    component: Chart
  }
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})

export default router


store/index.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    expenditures:[], //支出データを保存する配列
  },
  mutations: {
    //支出データを state の expenditures に追加
    addExpenditure(state, expenditure) {
      state.expenditures.push(expenditure)
    }
  },
  actions: {
    //支出データを追加する mutations の呼び出し
    addExpenditure({commit}, expenditure) {
      commit('addExpenditure', expenditure)
    }
  },
  getters: {
  }
})

ApexCharts のインストール

$ npm install vue-apexcharts

円グラフの表示

まずは以下の記事を参考に円グラフを表示させてみます。
円グラフのコードサンプル

Chart.vue
<apexchart type="pie" height="400" :options="chart.options" :series="chart.series"></apexchart>
<script>
import VueApexCharts from 'vue-apexcharts'

export default {
    components: {
        apexchart: VueApexCharts,
    },

    data: () => ({
        chart: {
            options: {
                labels: ['食費','交通費','住居費','その他'],
                title: {
                    text: 'ジャンル別 支出割合',
                    align: 'center',
                },
            },
            series: [4, 2, 5, 3],
        }
    }),

seriesは、データのオブジェクトの配列を受け付けるオプションです。
labelsは、seriesの配列に対応した配列を受け付けるオプション。

今回の場合であれば
食費、交通費、住居費、その他の割合が 4:2:5:3 の円グラフが表示されます。

算出されたデータの表示

本題はここから。

先程は適当な数値を入れて円グラフを表示させましたが
自前で用意したデータの集計を表示させたい。

その場合は、加工したデータを配列に詰めちゃえばいいわけです。
(結局seriesは配列を受け付けるので。)

今回は state 内のデータを加工するので getters 内で算出処理をする。

store/index.js
getters: {
  chartSeries: (state) => {
    let foodSum = 0; //食費支出額の合計
    let transportationSum = 0; //交通費支出額の合計
    let housingSum = 0; //住居費支出額の合計
    let othersSum = 0; //その他の支出額の合計

    for (let ex of state.expenditures) {
      if (ex.category === '食費') {
        foodSum += ex.price;
      } else if (ex.category === '交通費') {
        transportationSum += ex.price;
      } else if (ex.category === '住居費') {
        housingSum += ex.price;
      } else if (ex.category === 'その他') {
        othersSum += ex.price;
      }
    }

    return [foodSum, transportationSum, housingSum, othersSum]; //それぞれの合計が詰まった配列
  }
}

後は、series: [4, 2, 5, 3]を消して以下のように書き換えれば、ほい完成。

Chart.vue
<apexchart type="pie" height="400" :options="chart.options" :series="$store.getters.chartSeries"></apexchart>

参考記事

Vue.jsでApexchartsを使ってグラフ描画を行う

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Vue.js】簡単にCRUD機能を実装してみた

CRUD機能とは

  • Create(生成)
  • Read(読み取り)
  • Update(更新)
  • Delete(削除)

以上4つの機能をまとめたものを、頭文字を取って「CRUD」と読んでいます。

今回はVue.jsでCRUD機能を兼ね備えたToDoアプリを作成していきます。

Create(生成), Read(読み取り)

まずはCreate, Read機能を実装します。
ToDoアプリで言うところの、タスク追加,一覧表示機能ですね。

Vue.js
<template>
  <div>
    <h1>Todoアプリ</h1>
    <!-- タスク入力欄 -->
    <input 
    type="text" 
    v-model="task"
    placeholder="タスクを入力"
    >
    <button @click="addTask">追加</button>
    <!-- タスクを表示させるためのテーブル -->
    <table>
      <thead>
        <tr>
          <th>タスク</th>
        </tr>
      </thead>
      <tbody>
        <tr 
        v-for="todo in todos"
        :key="todo.id"
        >
          <td>{{ todo.comment }}</td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script>
export default {
  data() {
    return {
      // タスクを配列に格納する
      todos: [],
      task: ''
    }
  },
  methods: {
    // タスク追加メソッド
    addTask() {
      if (this.task === '') {
        alert('タスクを入力してください');
        return
      }
      // inputに入力されたタスクを配列に追加
      this.todos.push({
        id: this.todos.length,
        comment: this.task
      });
      // タスク入力後、inputを空にする
      this.task = '';
    }
  }
}
</script>

上のコードでは以下を実行しています。
1、inputにタスクを入力。
2、追加ボタン押下時に配列todosにタスクを追加。
3、v-forをテーブルタグに付与し、順にタスクを表示させる。

実際にタスクを入力して追加してみましょう。
addtask.gif
ちゃんと機能していることが分かります。

Update(更新)

次はUpdate機能を実装します。
ToDoアプリで言うところの、タスク編集機能ですね。

Vue.js
<template>
  <div>
    <h1>Todoアプリ</h1>
    <!-- タスク入力欄 -->
    <input 
    type="text" 
    v-model="task"
    placeholder="タスクを入力"
    >
    <button @click="addTask">追加</button>
    <!-- タスクを表示させるためのテーブル -->
    <table>
      <thead>
        <tr>
          <th>タスク</th>
        </tr>
      </thead>
      <tbody>
        <tr 
        v-for="todo in todos"
        :key="todo.id"
        >
          <td>{{ todo.comment }}</td>
          <!------------------------ここから追加------------------------>
          <td><button @click="showEditTask">編集</button></td>
          <td><input type="text" v-model="editComment" v-show="showEdit"></td>
          <td><button @click="editTask(todo)" v-show="showEdit">完了</button></td>
          <!------------------------ここまで追加------------------------>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script>
export default {
  data() {
    return {
      // タスクを配列に格納する
      todos: [],
      showEdit: false,
      task: '',
      // 追加
      editComment: ''
    }
  },
  methods: {
    // タスク追加メソッド
    addTask() {
      if (this.task === '') {
        alert('タスクを入力してください');
        return
      }
      // inputに入力されたタスクを配列に追加
      this.todos.push({
        id: this.todos.length,
        comment: this.task
      });
      // タスク入力後、inputを空にする
      this.task = '';
    },

    // ---------------ここから追加---------------
    // タスク編集欄の表示/非表示
    showEditTask() {
      // タスク編集欄が非表示なら表示させる
      if (this.showEdit === false) {
        this.showEdit = true;
      // タスク編集欄が表示中なら非表示にする
      } else if (this.showEdit === true) {
        this.showEdit = false;
      }
    },
    // タスク編集メソッド
    editTask(todo) {
      if (this.editComment === '') {
        alert('タスクを入力してください');
        return
      }
      // どのテーブルを編集するか絞り込む
      const targetIndex = this.todos.indexOf(todo);
      // spliceでタスクを置換する
      this.todos.splice(targetIndex, 1, {
        id: targetIndex,
        comment: this.editComment
      })
      // タスク入力後、inputを空にする
      this.editComment = '';
    }
    // ---------------ここまで追加---------------
  }
}
</script>

上のコードでは以下を実行しています。
1、編集ボタン押下時にv-showでタスク編集用のinputと完了ボタンを出現させる
2、完了ボタン押下時にinputに入力された値に応じて、todosの中身を置換(編集)する

実際にタスクを編集してみましょう。
edittask.gif
ちゃんと機能していることが分かります。

Delete(削除)

最後にDelete機能を実装します。
ToDoアプリで言うところの、タスク削除機能ですね。

Vue.js
<template>
  <div>
    <h1>Todoアプリ</h1>
    <!-- タスク入力欄 -->
    <input 
    type="text" 
    v-model="task"
    placeholder="タスクを入力"
    >
    <button @click="addTask">追加</button>
    <!-- タスクを表示させるためのテーブル -->
    <table>
      <thead>
        <tr>
          <th>タスク</th>
        </tr>
      </thead>
      <tbody>
        <tr 
        v-for="todo in todos"
        :key="todo.id"
        >
          <td>{{ todo.comment }}</td>
          <!------------------------ここから追加------------------------>
          <td><button @click="deleteTask(todo)">削除</button></td>
          <!------------------------ここまで追加------------------------>
          <td><button @click="showEditTask">編集</button></td>
          <td><input type="text" v-model="editComment" v-show="showEdit"></td>
          <td><button @click="editTask(todo)" v-show="showEdit">完了</button></td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script>
export default {
  data() {
    return {
      // タスクを配列に格納する
      todos: [],
      showEdit: false,
      task: '',
      // 追加
      editComment: ''
    }
  },
  methods: {
    // タスク追加メソッド
    addTask() {
      if (this.task === '') {
        alert('タスクを入力してください');
        return
      }
      // inputに入力されたタスクを配列に追加
      this.todos.push({
        id: this.todos.length,
        comment: this.task
      });
      // タスク入力後、inputを空にする
      this.task = '';
    },
    // タスク編集欄の表示/非表示
    showEditTask() {
      // タスク編集欄が非表示なら表示させる
      if (this.showEdit === false) {
        this.showEdit = true;
      // タスク編集欄が表示中なら非表示にする
      } else if (this.showEdit === true) {
        this.showEdit = false;
      }
    },
    // タスク編集メソッド
    editTask(todo) {
      if (this.editComment === '') {
        alert('タスクを入力してください');
        return
      }
      // どのテーブルを編集するか絞り込む
      const targetIndex = this.todos.indexOf(todo);
      console.log(targetIndex);
      // spliceでタスクを置換する
      this.todos.splice(targetIndex, 1, {
        id: targetIndex,
        comment: this.editComment
      })
      // タスク入力後、inputを空にする
      this.editComment = '';
    },
    // ---------------ここから追加---------------
    // タスク削除メソッド
    deleteTask(todo) {
      // どのテーブルを削除するか絞り込む
      const targetIndex = this.todos.indexOf(todo);
      // spliceでタスクを削除する
      this.todos.splice(targetIndex, 1);
      // 削除後idを振り直し、誤作動を無くす
      for (let i = targetIndex; i < this.todos.length; i++) {
        this.todos[i].id = i;
      }
    }
    // ---------------ここまで追加---------------
  }
}
</script>

上のコードでは以下を実行しています。
1、削除ボタン押下時、spliceでtodos配列からタスクを削除
2、削除はタスクと紐づけているidを辿って行っているので、idを振り直し誤作動を無くす

実際にタスクを削除してみましょう。
deletetask.gif
ちゃんと機能していることが分かります。

まとめ

今回は簡単にCRUD機能をToDoアプリで実装しました。

リファクタすればもっと綺麗なコードになりそうですが、ご愛嬌。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Lazy Loadを使ってみる【現状NG】

現状

遅延表示することができていません

概要

下記を参考にLazyを使ってみました。Lazy Load何?とか 、なぜ使うの? とかも記載されてるます
Lazy Loadで画像を遅延ロードする方法

手順

  • lazyloadのjsを読み込む
<script src="https://cdn.jsdelivr.net/npm/lazyload@2.0.0-rc.2/lazyload.min.js"></script>
  • imgタグをの画像指定をsrcからdata-srcに変更(※下記はVueで記述)
  • imgタグのclassにlazyloadを追加
<!-- <img class="content_img" :src="videoInfo.img_url"> -->
<img class="content_img lazyload" :data-src="videoInfo.img_url">
  • bodyの最後でlazyload()を実行
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

WebComponent でラップした Vue コンポーネントを Cypress でテストする

はじめに

レガシーアプリケーションのWebフロントエンド改善に、自己完結型の WebComponent は有力な手段です。

なぜなら、レガシーなWebフロントエンドは以下の特徴を有していることが多いからです。

  • CSSのルールに秩序がない
  • jQueryなどでかなり広い範囲の DOM を変更する
  • グローバルな状態に依存する

Shadow DOM によって、既存の CSS ルールの影響を受けず、JavaScript によるエレメントの改変からUIを守ることができます。
加えて WebComponent の仕様によって、UIパーツにソフトウェア的なインターフェースを明確に定義することができます。
こうすることで、単一のソフトウェアユニットとしてのスコープをUIパーツに定義することができ、ユニットテストや、リファクタリング、UIのデザイン改善を行いやすくなります。

一方で、既存のアプリケーションから隔離されているため、できるだけWebComponent自身で完結していることが望ましいです。
そのため、WebComponet はAPIの通信や自身の状態を更新するなど色々なことを行います。
これらの様々なふるまいを自動でテストできる状態を作ることは、次世代のレガシーにしないためにも重要です。

本文書では、Vue.js をベースに構築した WebComponent を Cypress を使ってテストをするまでの手順を説明します。

コードだけを見れば十分という方は、以下のリポジトリを参照してください。
https://github.com/sterashima78/cypress-vue-webcomponent

プロジェクトの作成

Vue CLI で作成します。

vue create <project-name>

基本的には各自の自由で問題ないと思いますが、 E2E は選択しないでください。
Vue CLI Plugin の E2E テストはSPAを前提としているため、うまくいかないです。

また、Vue.js 自体のバージョンは 2系にしてください。3系は、vue-web-component-wrapper がサポートされていません。

私は、普段から Typescript + Composition API で開発を行っているので、Typescript のサポートを追加し、Class コンポーネントは選択しません。

追加の依存パッケージを追加します。

npm i -D cpx cypress http-server start-server-and-test npm-run-all
npm i axios @vue/composition-api

テスト対象の作成

テスト対象の要件

コンポーネントは、利用者との間に以下のインターフェースを持ちます。

  • Props
    • 利用者が任意の値を渡す
  • Event
    • コンポーネントから利用者へメッセージを送る
  • Slot
    • コンポーネントに任意のコンテンツを挿入する

加えて以下をトリガーに自身の状態を更新します。

  • ライフサイクルイベント
  • ユーザからUIへのアクション
    • ボタンクリック
    • フォーム入力
    • など..

状態の更新は多くの場合はそのまま対応するUIの更新がトリガーされます
また、状態の更新は、何かしらのロジックに基づき直接的に行われることもありますし、Web API などをコールして外部から取得した状態に更新することがります。

これらの機能を持たせたコンポーネントをデモとして実装します。

テスト対象

本筋ではないのでソースを示すだけにします。

以下が、メインのロジックです。useCounter を見ればどんな状態と振る舞いがあるか大体わかります。

src/components/counter.composition.ts
import { ref, computed, Ref, SetupContext } from "@vue/composition-api";
import axios from "axios";

export type Status = "ok" | "error:fetch" | "error:save" | "loading";
export type Message = Omit<Record<Status, string>, "ok">;
export const msg: Message = {
  loading: "読み込み中",
  "error:fetch": "読み込みに失敗",
  "error:save": "保存に失敗"
};

type FetchCount = () => Promise<number>;
type SaveCount = (count: number) => Promise<number>;

export const fetchCount: FetchCount = () =>
  axios
    .get<{ count: string }>("/api/count")
    .then(({ data }) => data.count)
    .then(c => parseInt(c, 10));

export const saveCount: SaveCount = (count: number) =>
  axios
    .post<{ count: string }>("/api/count", { count })
    .then(({ data }) => data.count)
    .then(c => parseInt(c, 10));

export const init = (
  count: Ref<number>,
  state: Ref<Status>,
  fetch: FetchCount,
  emit: SetupContext["emit"]
) => () => {
  state.value = "loading";
  return fetch()
    .then(c => {
      count.value = c;
      state.value = "ok";
    })
    .catch(() => {
      state.value = "error:fetch";
      emit("loaderr");
    });
};

export const save = (
  count: Ref<number>,
  state: Ref<Status>,
  save: SaveCount
) => () =>
  save(count.value)
    .then(c => {
      count.value = c;
      state.value = "ok";
    })
    .catch(() => {
      state.value = "error:save";
    });

export const toMessage = (
  count: Ref<number>,
  state: Ref<Status>,
  messages: Message
) => () => {
  switch (state.value) {
    case "ok":
      return count.value;
    default:
      return messages[state.value];
  }
};

export const useCounter = (
  onMounted: (fn: () => void) => void,
  emit: SetupContext["emit"]
) => {
  const count = ref(0);
  const state = ref<Status>("ok");
  // マウント時に Web API からカウンタの値を取得する
  onMounted(init(count, state, fetchCount, emit));
  return {
    // カウンタの数
    count,
    // 通信などの状態を示す
    status,
    // 表示テキスト
    text: computed(toMessage(count, state, msg)),
    // カウンタを増やす
    increment: () => count.value++,
    // カウンタを減らす
    decrement: () => count.value--,
    // カウンタの値をAPIで送信する
    save: save(count, state, saveCount)
  };
};

コンポーネントが以下。

src/components/Counter.vue
<template>
  <div>
    <h1>{{ msg }}</h1>
    <span v-text="text" />
    <button data-test="increment" @click="increment">increment</button>
    <button data-test="decrement" @click="decrement">decrement</button>
    <button data-test="save" @click="save">save</button>
  </div>
</template>

<script lang="ts">
import Vue from "vue";
import CompositionApi, {
  defineComponent,
  PropType,
  onMounted
} from "@vue/composition-api";
import { useCounter } from "./counter.composition";
Vue.use(CompositionApi);

export default defineComponent({
  props: {
    msg: {
      type: String as PropType<string>,
      default: "default msg"
    }
  },
  setup(_, { emit }) {
    return useCounter(onMounted, emit);
  }
});
</script>

以下のような機能を持ちます。

  • props で表示テキストを変更
  • mounted ライフサイクルイベントで通信
    • 成功時に状態変更
    • 失敗時に状態変更・エラーイベントを送信
  • ボタンクリック時に状態更新
  • ボタンクリック時に API 通信
    • 成功時・失敗時に状態更新

ということで、slot 以外は大体の要素を含んでいるはずです。
WebComponentには Vue の slot-scope などはないので、表示に関する役割が大きいです。
そのため、e2e テストで試験をする利点が小さいと思っているのでここではスコープ外にしています。
何かしらの方法で試験をしたい場合は以下にあるような、スナップショットテストをお勧めします。
https://qiita.com/sterashima78/items/8db32368289e4859480b

テストのための設定

Cypress

Cypress の設定をしていきます。

まずは、 npx cypress open を実行してみてください。
cypress ディレクトリと、いくつかのファイルが生成されたと思います。

cypress 以下に tsconfig.json を追加しておきます。

tsconfig.json
{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "noEmit": false,
    "sourceMap": false,
    "types": ["cypress"]
  },
  "include": [
    "./**/*.ts"
  ]
}

npm scripts

次に npm scripts の設定をします。

以下のタスクを設定して、順番に実行するとこでテストします。

  • vue-cli-service で webcomponents ビルド
  • テスト用のエントリファイル (HTML) 配置
  • テスト用のローカルサーバ起動
  • Cypress 起動 / テスト実行
package.json
{
  "scripts": {
    "build:cypress:test": "npm run build -- --target wc --name v --dest ./cypress-tests --inline-vue \"./src/components/*.vue\"",
    "copy:cypress:template": "cpx cypress/index.html cypress-tests/",
    "serve:cypress": "http-server ./cypress-tests/",
    "preserve:cypress": "run-s build:cypress:test copy:cypress:template",
    "cypress:run": "start-server-and-test 'npm run serve:cypress' http://localhost:8080 'cypress run --headless --browser chrome'",
    "cypress:open": "start-server-and-test 'npm run serve:cypress' http://localhost:8080 'cypress open'"
  },
}

これで、npm run cypress:open をすると、順次タスクが実行されます。
cypress-tests ディレクトリにビルド済み js と HTML が配置されていて、 8080 で serve されていればOKです。
Cypress が終了すると http-server も自動で停止します。

HTML ファイルは、以下のようにビルド済みJSをロードするだけです。

cypress/index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="/v.js"></script>
</head>
<body>

</body>
</html>

テストを記述する

最後にテストを記述していきます。

まず、Cypress でアクセスする予定の HTML は空っぽなので、ここにテスト対象のコンポーネントをマウントさせたりする必要があります。
これを実現するためのカスタムコマンドを実装します。

そのあと、それぞれの振る舞いに対応する試験を記述していきます。

テスト対象をマウントする

以下のようにカスタムコマンドを実装します。

cypress/support/commands.ts
Cypress.Commands.add("setup", (name: string, template?: string) =>
  cy
    .window({ log: false })
    .then(window => {
      window.document.body.innerHTML = template || `<${name}></${name}>`;
    })
    .then(() => cy.wait(500))
    .then(() => cy.get(name).shadow())
);

型定義も欲しいので、以下も作成します。
Cypress で提供されている型定義を拡張します。

cypress/@types/commands.d.ts
/// <reference types="cypress" />
declare namespace Cypress {
  // eslint-disable-next-line @typescript-eslint/class-name-casing
  interface cy {
    setup(name: string, template?: string): Chainable<Element>;
  }
}

以下のように使います。

cy.setup("v-counter").find("span").should("contain.text", "0")

cy.setup("v-counter") をコールすると、ドキュメントが以下のようになります。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="/v.js"></script>
</head>
<body>
  <v-counter></v-counter>
</body>
</html>

加えて、<v-counter> の shadow root が戻り値として得られます。

第二引数に任意のHTML文字列を渡すとそのHTMLがbody 直下に挿入されます。

これで、試験に必要な環境を準備して、shadow root 内部の要素にアクセスできるようになったので、試験を書いていけます。

試験の記述

テストコード全体は、前述のリポジトリを見てもらったほうが早いので、テストの種類ごとに抜粋して説明します。

状態の検証

UIの状態は、その表示に現れているので、UIのテキストや色などを検証します。

例えば、count は何もしていない状態で 0 です。
また、通信など行っていないときは、span 要素に count が表示されるので、以下で検証ができます。

cy.setup("v-counter").find("span").should("contain.text", "0")

このように○○の時は、どのような表示になっているか (どのような状態になっているか)を検証していきます。

props の検証

props が変更されたときの振る舞いを検証します。
ここでは、 msg はそのまま表示テキストであるので、前述を同じ方法で検証します。

describe("props", ()=> {
  it("props が見出しに表示される", ()=> {
    const props = "foo bar"
    cy.visit("http://localhost:8080")
    // 後で参照するために要素に alias 設定する
    cy.setup("v-counter").find("h1").as("title");
    // 設定前はデフォルト
    cy.get("@title").should("contain.text", "default");
    // props を設定する ($el は jQuery オブジェクト)
    cy.get("v-counter").then($el => $el.attr("msg", message));
    // 表記が変わったことを確認
    cy.get("@title").should("contain.text", props );
  })
})

API のモック

このコンポーネントは 外部の API に依存しています。
テスト用のAPIサーバがあればそこに向けておけばいいと考える人もいるかもしれませんが、自動テストという性質を考えると、APIサーバの状態に依存させると、テストが不安定になるなどのデメリットがあるため、モックをしておくのが適当と考えています。

実際のAPIをコールさせたい場合は、アプリケーション全体レベルでのテストをデザインして別途行うのが、良いと思います。

Cypress でAPIをモックするには以下を行います。

  • cy.server() をコール
  • cy.route(options) でモックするAPIを設定する

例えば、初期状態の取得について試験を記述しようとすると、以下を検証するかと思います。

  1. 対象ページにアクセスする (mounted ライフサイクルで通信開始する)
  2. テキストが通信中テキストになる
  3. 通信が完了する
  4. テキストが API から戻ってきた値になっている

以下のように記述します。

import { msg } from "../../../src/components/counter.composition";
describe("Counter", () => {
  const baseUrl = "http://localhost:8080/";
  beforeEach(() => {
    // モック設定
    cy.server();
  });
  describe("初期化通信成功", () => {
    it("initialize", () => {
      const count = 10000;
      // GET /api/count の仕様を設定
      cy.route({
        method: "GET",
        url: "/api/count",
        response: {
          count
        },
        delay: 2000
      })
      // 後で参照するためにエイリアス設定
      .as("init")
      cy.visit(baseUrl);
      cy.setup("v-counter").as("root");
      // ロード中
      cy.get("@span").should("contain.text", msg["loading"]);
      // 通信待ち
      cy.wait("@init");
      // 初期値
      cy.get("@span").should("contain.text", count);
    });
  });
});

エラーの時は、route のオプションで status を指定して再現します。

cy.route({
   method: "GET",
   url: "/api/count",
   status: 500,
   response: "",
   delay: 2000
}).as("init");

コンポーネントのイベント

コンポーネントの発行するイベントを検証します。

このコンポーネントでは、初期化通信に失敗したときにイベントを発行する仕様だったので、それを検証します。

describe("Counter", () => {
  const baseUrl = "http://localhost:8080/";
  beforeEach(() => {
    // モック設定
    cy.server();
  });
  describe("初期化通信失敗", () => {
    it("初期化通信失敗時はエラーメッセージが表示", () => {
      // GET /api/count で通信失敗
      cy.route({
        method: "GET",
        url: "/api/count",
        status: 500,
        response: "",
        delay: 2000
      }).as("init");
      cy.visit(baseUrl);
      cy.setup("v-counter").as("root");
      cy.get("@root")
        .find("span")
        .as("span");
      // 後で検証するために発行されたイベント保持する
      const events: number[] = [];
      // イベントが発行されたら保管ここでは、イベントが発行するたびに 0 を入れておく (ペイロードがあるイベントならそれを入れればいい)
      cy.get("v-counter").then($el => $el.on("loaderr", () => events.push(0)));
      cy.get("@span").should("contain.text", msg["loading"]);
      cy.wait("@init");
      cy.get("@span").should("contain.text", msg["error:fetch"]);
      // イベントの検証
      cy.wrap(events).should("eql", [0]);
    });
  });
});

UIに対するイベント

最後に UI パーツに対するイベント駆動で状態が変更されるような機能の検証です。
これは直観的でわかりやすいですが、一応例を示しておきます。

インクリメントボタンをクリックするとカウンタがインクリメントすることを検証します。

describe("Counter", () => {
  const baseUrl = "http://localhost:8080/";
  beforeEach(() => {
    // モック設定
    cy.server();
  });
  it("increment", () => {
      const count = 10000;
      cy.route({
        method: "GET",
        url: "/api/count",
        response: {
          count
        },
        delay: 2000
      })
      cy.visit(baseUrl);
      cy.setup("v-counter").as("root");
      cy.get("@root")
        .find("span")
        .as("span");
      // increment ボタンにエイリアス設定
      cy.get("@root")
        .find("[data-test=increment]")
        .as("button");
      // クリック前の表示
      cy.get("@span").should("contain.text", count);
      // クリック
      cy.get("@button").click();
      // 表示の更新
      cy.get("@span").should("contain.text", count + 1);
      // クリック
      cy.get("@button").click();
      // 表示の更新
      cy.get("@span").should("contain.text", count + 2);
    });
});

要素の参照に使うセレクタ

今回の例では h1 , span など一つしか要素が存在しないタグはタグ名で取得していましたが、data-test などのテスト用のカスタム属性で値を参照するほうがいいです。

マークアップの構造などにはテスト側ができるだけ興味を持たないように設計できるほうが、テストが壊れにくいです。

ビルド済みコードにテストに関係する情報が含まれてしまうのを懸念する方もいると思いますが、以下などを使うことでテスト時以外は取り除くことができます。

https://github.com/LinusBorg/vue-cli-plugin-test-attrs

まとめ

  • レガシープロジェクトを改良するのに WebComponent は有効
  • WebComponet の品質維持のために Cypress で自動テストを書こう
  • テストの内容は、WebComponet が持つ主なふるまいを網羅できるようにしよう (Cypress をちゃんと使えばできる)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Nuxt.jsとkonvaで台形補正

以下の記事で実現されているものをVue.jsでkonvaを使って実装してみたので、その内容をまとめます。

元記事様は実装については最低限しか触れられていなかったので、この記事では、ここのコードをまるっと使えば動くものを提供したいなと思います。
その他、canvasについてはズブのド素人だったので、以下の一連の記事を参考にさせていただきました。ありがとうございます!

台形補正

最初に出来上がったものをお見せします。

台形補正.png

左図の赤枠で囲われた部分を補正した結果を右図に出力しています。
参考元と同じく、リアルタイムで補正が行われるようにしており、なかなかよいパフォーマンスです。

台形補正.gif

環境

  • Nuxt.js 2.14.6
  • Konva 7.1.8
  • glfx 0.0.4
    • canvasでの画像処理用ライブラリ

glfxは参考にさせていただいた記事にもありましたが、5年ほど更新がありません。そのため、TypeScriptには非対応です。TypeScriptで利用したいという方は、適宜、元記事様を参考にwrapperを定義してあげてください。

準備

konvaとglfxをインストールします。

npm install konva
npm install glfx

template, style

早速実装内容を紹介します。まずは<template><style>ですが、ここは最低限。

glfx.vue
<template>
  <div>
    <div>
      <div ref="container" class="canvas_inline">
        <canvas id="baseImg" width="600" height="600" ref="canvas" />
      </div>
      <div class="canvas_inline canvas_margin">
        <canvas ref="result" width="400" height="566" />
      </div>
    </div>
    <div>
      <span>{{ nowCorner }}</span>
    </div>
  </div>
</template>

<style scoped>
.canvas_inline {
  display: inline-block;
  vertical-align: top;
}

.canvas_margin {
  margin-left: 10px;
}
</style>

script

処理の流れはざっくりと以下のような感じです。

  • 起動時

    1. canvasに画像を表示
    2. draggableな4頂点を定義し、dragmove処理を追加
    3. 4頂点と4辺を描画
    4. 頂点の座標情報を取得し、補正を実施
    5. 補正画像を出力先canvasに描画
  • 頂点操作時(dragmove)

    1. 座標の更新
    2. 座標情報を取得し、補正を実施
    3. 補正画像を更新

宣言

class APIを使います。

glfx.vue
<script>
import { Component, Vue, Watch } from 'nuxt-property-decorator';
import Konva from 'konva';

import * as fx from 'glfx';

@Component
export default class KonvaTop extends Vue {
  ...
}
</script>

mounted

まずは、mountedにてcanvasの初期設定を行い、補正に利用する画像を取得します。

// data
width = 600;
height = 600;
stage = null;
canvas = null;
drawingLayer = null;
drawingScope = null;
imageObj = null;
backgroundLayer = null;
backgroundImageScope = null;

// 座標情報格納配列
coords = [
 [202, 123],
 [544, 103],
 [45, 432],
 [520, 498]
];

// 4辺に関係する関数
line = null;
corners = [];

// 現在の座標を表示する
nowCorner = '';

// perspective
perspectiveNubs = [];
baseCanvas = null;
image = null;
texture = null;
resCanvas = null;

mounted() {
  var container = this.$refs.container;
  this.stage = new Konva.Stage({
    container,
    width: this.width,
    height: this.height 
  });
  this.drawingLayer = new Konva.Layer();
  this.stage.add(this.drawingLayer);

  this.canvas = this.$refs.canvas;
  this.drawingScope = new Konva.Image({
    image: this.canvas,
    stroke: 'black'
  });
  this.drawingLayer.add(this.drawingScope);
  this.stage.draw();

  // 画像読み込み
  this.imageObj = new Image();
  this.imageObj.addEventListener('load', this.imageOnload);
  this.imageObj.src = require('../../static/IMAGE.JPG');
}

画像読み込み完了

画像読み込みが完了するとimageOnload関数が呼び出されます。
4辺の描画と補正画像の出力は、最初に定義したオブジェクトを更新していくため、初期起動時とその後のdragmoveで処理が変わります

imageOnload() {
  // 背景レイヤ
  this.backgroundLayer = new Konva.Layer()

  // 取得された画像データをImageオブジェクトに登録
  this.backgroundImageScope = new Konva.Image({
    image: this.imageObj,
    width: this.canvas.width,
    height: this.canvas.height
  })

  // 背景レイヤに背景イメージを追加
  this.backgroundLayer.add(this.backgroundImageScope)
  this.stage.add(this.backgroundLayer)

  // 背景イメージを最背面に移動。
  this.backgroundLayer.moveToBottom();

  // 頂点の描画
  this.drawCircle();

  // 4辺の初期描画
  this.initDrawLine();

  // 補正画像の初期表示
  this.initPerspectiveImage();
}

頂点の処理

初期起動時にdraggableな4頂点のオブジェクトを生成し、dragmoveのイベントを登録します。
こうすることで、頂点をドラッグで動かしてイベントを発火させることができます。

drawCircle() {
  // 4つの頂点を1つずつ登録する
  for (let i = 0; i < 4; i++){
    this.corners[i] = new Konva.Circle({
      radius: 10,
      x: this.coords[i][0],
      y: this.coords[i][1],
      stroke: 'red',
      strokeWidth: 2,
      // こいつをtrueにすることでcanvas上で動かすことができる
      draggable: true
    });

    // 4つの頂点ごとにイベントを分ける。
    // もっとよい書き方があったら教えて下さい!
    switch(i) {
      case 0:
        this.corners[i].on('dragmove', this.moveCircle1);
        break;
      case 1:
        this.corners[i].on('dragmove', this.moveCircle2);
        break;
      case 2:
        this.corners[i].on('dragmove', this.moveCircle3);
        break;
      case 3:
        this.corners[i].on('dragmove', this.moveCircle4);
        break;
      default: break;
    }

    // 描画レイヤーに追加
    this.drawingLayer.add(this.corners[i]);
  }

  // canvasを更新
  this.stage.add(this.drawingLayer);

  // 座標情報を表示させる処理
  this.getNowCorner();
}

実際に頂点を動かすと、以下の処理が発火します。
座標情報を更新し、更新された座標情報をもとに、4辺を再描画し、補正画像を更新します。

// それぞれどの頂点が動かされたかを識別するだけ
moveCircle1(node) {
  this.moveCircleBase(node, 0);
};

moveCircle2(node) {
  this.moveCircleBase(node, 1);
}

moveCircle3(node) {
  this.moveCircleBase(node, 2);
}

moveCircle4(node) {
  this.moveCircleBase(node, 3);
}

moveCircleBase(node, i) {
  // 座標情報を更新
  this.coords[i][0] = node.target.attrs.x;
  this.coords[i][1] = node.target.attrs.y;

  // 座標情報の表示を更新
  this.getNowCorner();

  // 4辺を再描画
  this.drawLine();

  // 補正画像を更新
  this.updatePerspectiveImage();
}

辺の処理

4辺は、補正エリアを定義する頂点を結んでいます。頂点をドラッグするときに同時に動かしたかったので、頂点のドラッグイベント時に表示を更新し、頂点の動きに追従できるようにしました。

もしもっとスマートなやり方があるようでしたら、ぜひ教えて下さい!

// 初期化処理
initDrawLine() {
  this.line = new Konva.Line({
    // 左上→右上→右下→左下→左上の順番に線をつなぐ
    points: [
      this.coords[0][0], this.coords[0][1],
      this.coords[1][0], this.coords[1][1],
      this.coords[3][0], this.coords[3][1],
      this.coords[2][0], this.coords[2][1],
      this.coords[0][0], this.coords[0][1],
    ],
    stroke: 'red'
  });

  this.drawingLayer.add(this.line);
  this.stage.add(this.drawingLayer);
}

drawLine() {
  // すでに定義したLineオブジェクトの座標情報を更新
  this.line.attrs.points = [
    this.coords[0][0], this.coords[0][1],
    this.coords[1][0], this.coords[1][1],
    this.coords[3][0], this.coords[3][1],
    this.coords[2][0], this.coords[2][1],
    this.coords[0][0], this.coords[0][1],
  ];

  this.drawingLayer.add(this.line);
  this.stage.add(this.drawingLayer);
}

台形補正

ここが今回のキモです。
glfxライブラリは、Image要素からtextureを取得し、そのtexture情報に画像処理をかけることができます。
台形補正を行うにはperspective(before, after)というAPIを利用します。
beforeは補正前の4頂点の情報を[ax, ay, bx, by, cx, cy, dx, dy]と8つの要素の行列として格納し、
afterには補正後の4頂点の情報を同じ形式でセットします。
今回は、afterとして400:566というA版、B版の割合に調整した画像を出力したかったので、

const after = [ 0, 0, 400, 0, 0, 566, 400, 566];

こんな感じでafterをセットしています。

また、別canvasに補正結果を出力するため、補正後のcanvasオブジェクトからgetPixelArray()Uint8Arrayを取り出しています。
この取り出した値でImageDataを作り、出力先canvas要素に埋め込んでいます。

// canvas,texture,出力先canvasを定義しておく
initPerspectiveImage() {
  try {
    this.baseCanvas = fx.canvas();
  } catch (e) {
    alert(e);
    return;
  }

  this.image = this.backgroundLayer.canvas._canvas;
  this.texture = this.baseCanvas.texture(this.image);
  this.resCanvas = this.$refs.result.getContext('2d');

  // 最初の補正情報
  this.updatePerspectiveImage();

}

updatePerspectiveImage() {
  // beforeの座標情報を設定
  this.setPerspectiveNubs();
  const before = this.perspectiveNubs;

  const after = [ 0, 0, 400, 0, 0, 566, 400, 566];
  this.baseCanvas
    .draw(this.texture)
    .perspective(before, after)
    .update()

  // ImageDataを取得して、別canvasに描画
  const data = this.baseCanvas.getPixelArray();
  const img = new ImageData(new Uint8ClampedArray(data), this.width*2, this.height*2);
  this.resCanvas.putImageData(img, 0, 0);
}

setPerspectiveNubs() {
  this.perspectiveNubs = [
    this.coords[0][0] * 2, this.coords[0][1] * 2,
    this.coords[1][0] * 2, this.coords[1][1] * 2,
    this.coords[2][0] * 2, this.coords[2][1] * 2,
    this.coords[3][0] * 2, this.coords[3][1] * 2,
  ];
}

最後に

  • リポジトリはこちら
  • 四隅を検出するデータセットがあれば、画像読み込み時に頂点推定とかできそうですよね...
  • TypeScriptでリファクタリングもしたいなあ
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue(Nuxt)+Rails APIで、ネストした配列で画像とJSONのパラメーターを送る方法

なにこれ

Vue(Nuxt)で、子テーブルへ画像データとJSONを配列にしてパラメーターで送りたい!って時にかなりつまづいたので、備忘録として書き残しておきます。

この記事で得られること

1.Vue(Nuxt)で画像+JSONの形式でPOSTする方法
2.ネストさせた配列で、画像とJSON形式のパラメーターを送る方法

大事なポイント

1.FormData型の変数を宣言して、その変数にappendしていく。

test.js
      const req = new FormData()
      req.append(`name`, this.product.name)

2.ネストした配列で送りたい時はこうする。配列を明示してその中にオブジェクトを生成&appendする。

test.js
          req.append(`product_sub_attributes[][image]`, subs[i].image)

3.axiosでリクエストを投げる時、'Content-Type': 'multipart/form-data'にすること。

test.js
        const response = await axios.post('/products', req, {
          headers: {
            'Content-Type': 'multipart/form-data'
          }
        })

備考

  • バックエンドはRails APIモードを仕様しています
  • 画像アップ方法は、フロントはvue-cropeer、バックエンドはcarrierwaveを使用していますが、まだ理解しきれてない箇所があるのでそちらも後日記事にします。 なので、vue-cropperとcarrierwaveの説明は割愛します。今はJSON配列で送れるんだなーと知っていただければ幸いです。
  • バックエンドはみんな大好きgem 'active_model_serializers’を使用しています。
  • 一部vuetify、bootstrap、axiosを使っています。

注意事項

今回はタイトルの方法を紹介するのがメインになるので、かなり端折っています。
全部解説すると膨大な量になってしまうので。
雰囲気を掴んでいただければ、と思っているのでご了承ください。
既存のコードをQiita用に抽出して載せているので、一部間違いがあるかも知れません。

前提条件

テーブル構造はこんな感じです。
productsテーブルproducts_subsテーブルがあって、1:Nの関係です。
両方とも画像投稿用のimageカラムを持っています。
登場する全てのカラムはstring型です。

product.rb
class Product < ApplicationRecord
  has_many :products_subs
  accepts_nested_attributes_for :products_subs
end
product_sub.rb
class ProductSub < ApplicationRecord
  mount_uploader :image, ProductSubUploader

  belongs_to :product

  validates :name, presence: true
  validates :image, presence: true
end

やりたいこと

productsテーブルへcreateしたい時に、子テーブルのproducts_subsへ同時にデータを保存させたい。
そのためにはネストして送る必要がある。

早速解説します。

画像アップのおおまかな流れ

プレビュー表示用のimgタグと画像アップ用のinputタグを用意して、
imgタグをクリックすると$refsでinputを参照してクリックしたことにします。

配列なので$refs.subImageに[i]をつけて、番号を参照しています。
inputタグはクリックされるとsetSubImageメソッドを呼び出します。

setSubImageで画像を読み込んだ後にvue-cropperに投げて、
らcropSubImageメソッドでsubImageCropperを参照してプレビューを表示する変数に突っ込んだり、画像格納用のオブジェクトに代入したりしてます。
余談だけど、このvue-cropperさんの動きが理解できてなくて、たまに$refsで参照できないバグが生まれます。笑

test.vue
<template v-for="(sub, i) in product.productsSubAttributes">

  <div>
    <v-textarea
      v-model="sub.name"
    >
    </v-textarea>
    <img
      :src="sub.imageSrc ? sub.ImageSrc : '' "
      @click.prevent="$refs.subImage[i].click()"
    >
    <input
      ref="subImage"
      class="d-none"
      type="file"
      name="image"
      accept="image/*"
      @change="setSubImage($event)"
    />
    <v-card>
      <vue-cropper
        ref=“subImageCropper"
        :src="imgSrc"
      />
      <button @click="cropSubImage(sub), i)">
        保存
      <button>
    </v-card>
  </div>
</template>

<script>
export default {
  methods: {
    setSubImage(e) {
      const reader = new FileReader()
      reader.onload = (e) => {
        this.imgSrc = e.target.result
      }
      reader.readAsDataURL(e.target.files[0])
    },
    async cropSubImage(sub, i) {
      // imageSrcがプレビュー表示用のプロパティです。
      sub.imageSrc = this.$refs.subImageCropper[0].getCroppedCanvas().toDataURL()
        this.$refs.subImageCropper[0].getCroppedCanvas().toBlob((blob) => {
          sub.image = blob
        })
      }
    }
  }
}
</script>

本題の画像とJSONオブジェクトをパラメーターで送る方法

今回はaxiosを使います。
その際に、configを設定してmultipart/form-data
という形式に変換します。

【axios】HTTPリクエストメソッド別の引数一覧表(エイリアスを使用した場合)
この記事がよくまとまってたのでリンク貼っておきます。

axios公式ドキュメント

multipart/form-dataとは、画像ファイル(Blob型)を送信できるようにするHTTPリクエストメソッドです。
application/jsonだと画像データが送れないです。

パラメーターで送るための方法

細かく解説するためにコメント式にしました。

test.js
    async onClickCreate() {
      // FormData型の変数reqを定義して、そちらにappendしていきます。
      const req = new FormData()
      // 今回は使用しませんが、こんな感じでバックエンドのカラム名に合わせて
      // オブジェクトを生成してappendすることで、パラメーターを送れます。
      req.append('name', this.product.name)
      // 定数subにアップした画像や文字列のデータを代入して、for文で回します。
      // for文を使っている理由は複数ありますが、今回は省略。状況によってはforEachでも代用可能です。
      const subs = this.product.productSubAttributes
      for(let i = 0; i < subs.length; i++){
        // 送りたいパラメーターに[]をつけることで、0から順番に配列で送ることが出来ます。
        // ただ、配列にインデックスを指定する方法が分からないです。
        req.append(`product_sub_attributes[][name]`, subs[i].name)
        // subs[i].imageがBlob型である=先程vue-cropeerで整形したデータなので、直接カラムに代入します。
        if (subs[i].image instanceof Blob) {
          req.append(`product_sub_attributes[][image]`, subs[i].image)
        // 画像がない場合、番号を指定できないので、空文字を代入しないと順番が狂います。
        // どういうことかと言うと、配列内のオブジェクトが2個以上あった場合、0番目から探して空いているカラムに代入されます。
        // ちなみにここでかなりハマりました。
        } else {
          req.append(`product_sub_attributes[][image]`, '')
        }
      }
      try {
        // 先程代入していった変数reqをパラメーターで送ります。
        // 余談ですが、Railsの場合全てキー名をスネークケースにする必要があります。
        const response = await axios.post('/products', req, {
          headers: {
            'Content-Type': 'multipart/form-data'
          }
        })
      } catch (error) {
        console.error(error.response)
      }
    }

ちなみにバックエンドはこんな感じ

products_controller.rb
 def product_params
    params.permit(
      :id,
      :name,
      {
        product_sub_attributes: [
          :id,
          :name,
          :image,
        ]
      }
    )
  end

おわり

こんな感じでVue(Nuxt)からパラメーターで画像を配列+JSONで送ることが出来ます。

自分が実装しようとした時にこの方法にたどり着くまで苦労したので、

個人的な感想

やり方(How)を覚えるのは良いことなんですけど、
裏側のなぜこの方法なのか(How)の方を理解していきたいです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AWS, Docker, CircleCI, Laravelでポートフォリオを作成してみた【参考リンク付き】

初めに

今回はDocker, CircleCI, AWS等、人気の高まっているインフラ技術を一から学んで、Webアプリを作成してみました。
バックエンドはLaravel、フロントエンドにVue.js等といった構成です。

この記事では、アプリ開発にあたって苦労した点や、
各機能実装の際に参考にした記事や教材についてもご紹介していければと思います。

アプリの概要

朝活をテーマをしたSNSアプリです。

  • 朝活仲間を作り、「コツコツ」継続できる
  • 朝活習慣の「コツ」を共有して、朝活の挫折を防ぐ

ことをコンセプトに、「朝活」を文字って「AsaKotsu」というサービスを開発しました。
URLはこちら↓です。よければ、ご自由に動かしてみてください^^

アプリのURL:https://pf.asakotsu.com/
(※まだスマホ対応が完了していないので、PCでの閲覧推奨です^^;)

GitHubのURL:https://github.com/ngsw877/asakotsu

使用画面のイメージ

トップページ(投稿一覧とランキング等)
スクリーンショット 2020-11-16 8.00.00.png

タグ毎の投稿一覧
スクリーンショット 2020-11-22 20.10.06.png

投稿詳細と、コメント一覧
スクリーンショット 2020-11-22 20.05.41.png

Zoomミーティング一覧
スクリーンショット 2020-11-16 22.44.58 2.png

ユーザー詳細画面
スクリーンショット 2020-11-16 23.06.16.png

早起き達成時
スクリーンショット 2020-11-22 20.03.34.png

無限スクロール
infinitscroll.gif

このアプリの特徴

基本的にはtwitterのような投稿、コメント、いいね、フォロー機能のあるSNSですが、
その他に以下のような特徴のあるアプリです。

  • アプリから、朝活Zoomミーティングを作成、編集、削除できる(ZoomAPI連携)
  • 目標起床時間を設定して、早起き達成日数を記録することができる
  • 早起き達成日数のランキング機能(1ヶ月ごとに集計)
  • 投稿にタグ付けし(カテゴリ)、「朝コツ」タグ等で朝活のコツを共有することができる

使用技術

  • フロントエンド

    • Vue.js 2.6.11
    • jQuery 3.4.1
    • HTML / CSS / Sass / MDBootstrap
  • バックエンド

    • PHP 7.4.9
    • Laravel 6.18.36
    • PHPUnit 8.5.8
    • ZoomAPI (guzzlehttp/guzzle 7.0.1)
  • インフラ

    • CircleCi
    • Docker 19.03.12 / docker-compose 1.26.2
    • nginx 1.18
    • mysql 5.7.31 / PHPMyAdmin
    • AWS ( EC2, ALB, ACM, S3, RDS, CodeDeploy, SNS, Chatbot, CloudFormation, Route53, VPC, EIP, IAM )

サーバーサイドのロジックはPHP/Laravelでプログラミングし、
フロントエンドの細かいデザインはSassで整え、動きを付けたい時はVue.jsやjQueryで実装しました。
開発環境にDocker/docker-composeを使用し、
CI/CDパイプラインに関しては、CircleCIで自動テスト・ビルドを行い、
AWSのCodeDeployで自動デプロイを実現するようにしています。

インフラ構成図

AWS_Diagram.png

開発環境、本番環境について

開発環境にDocker / docker-composeを使用しており、以下の4つの用途のコンテナを使用しています。

  1. Webサーバーのコンテナ: Nginx
  2. アプリケーションのコンテナ: PHP / Laravel / Vue.js
  3. DBのコンテナ: MySQL
  4. DB管理用のコンテナ: PHPMyAdmin

参考リンク:

本番環境のAWS上ではECSでデプロイしたかったのですが、
難易度が高く断念・・
ひとまずEC2でのデプロイ経験にも慣れるため、今回はEC2上で環境構築していく形で進めていきました。

SSL証明書の発行

SSL証明書を発行してHTTPS化も実現したかったため、ACM(AWS Certificate Manager)を使用しています。

ACMを使用するためには、EC2に加えて、ALB(ELB)CloudFrontも必要になってくるため、今回はALBを導入することにしました。
なお、ALBを使用しているものの、節約のため現状では負荷分散やスケールアウトする程のアクセスが見込まれないため、EC2インスタンスは1つのみ用意しています。
なお、アドレスバーに鍵マークがついても、Laravel側のプロキシ設定をしないとcssやjsファイルが読み込まれなかったり、ルーティングがhttps化されなくなるので要注意な印象。。

参考リンク:
- AWS:無料でSSL証明書を取得する方法
- 信用するプロキシの設定

S3バケットへのアップロード

S3は、以下の2つの用途別に用意しています。

  1. CircleCIでビルドしたソースを格納

  2. EC2上のアプリでアップロードした画像データを格納

2に関しては、S3のバケットポリシーの設定や、Laravel側でS3用パッケージのインストールが必要だったりと意外にやるべきことがありました。

参考リンク:

Slackへの通知設定

CodeDeploySNSChatbotを連携して、自動デプロイの開始と終了のタイミングでSlackアカウントに通知が飛んでくるようにしています。なかなか便利。

機能一覧

  • ユーザー登録関連

    • 新規登録、プロフィール編集機能
    • ログイン、ログアウト機能
    • かんたんログイン機能(ゲストユーザーログイン)
  • ZoomAPI連携

    • 朝活Zoomミーティング機能(CRUD)
      • ミーティングの新規作成、一覧表示、編集、削除機能
  • 早起き達成の判定機能

    • ユーザー毎に目標起床時刻を設定可能(4:00〜10:00まで)
    • 目標起床時間より前に投稿をすることができれば、早起き達成記録が1日分増えます。
    • ※深夜過ぎ等に投稿した場合も早起き成功とならぬよう、
        目標起床時間より3時間前に投稿しても無効になるよう対処しています。
      (例)目標起床時間を07:00に設定した場合、04:00~07:00に投稿できたら早起き達成
  • ユーザーの早起き達成日数のランキング機能(1ヶ月毎)

  • 無限スクロール機能 (jQuery / inview.js / ajax)

  • ユーザー投稿関連(CRUD)

  • コメント機能

  • タグ機能 (Vue.js / Vue Tags Input)

  • いいね機能 (Vue.js / ajax)

  • フォロー機能

    • フォロー中/フォロワー一覧(ページネーション)
  • フラッシュメッセージ表示機能 (jQuery/ Toastr)

    • 投稿、編集、削除、ログイン、ログアウト時にフラッシュメッセージを表示
  • 画像アップロード機能 (AWS S3バケット)

  • PHPUnitテスト

DB設計

ER図

AsaKotsu_ERD.png

各テーブルについて

テーブル名 説明
users 登録ユーザー情報
follows フォロー中/フォロワーのユーザー情報
achievement_days ユーザーが早起き達成した日付を、履歴として管理
meetings ユーザーが作成したZoomミーティング情報
articles ユーザー投稿の情報
tags ユーザー投稿のタグ情報
article_tags articleとtagsの中間テーブル
likes 投稿への、いいねの情報
comments ユーザー投稿への、コメントの情報

早起き達成機能 関連のポイント

usersテーブルwake_up_timeはユーザーの目標起床時間を意味しています。
この時間よりも早い時間にユーザーが投稿をできれば、その日の早起きが達成となります。
なお、
「目標起床時間が07:00で、深夜1:00に投稿した」
というように、早過ぎる時間にユーザーが投稿した
場合にも早起き達成とならないように設定しています。
その仕組みとして、usersテーブルrange_of_successの値が利用されています。
これは、
「目標起床時間より何時間前までに投稿すれば早起き達成となるのか、その範囲を表す整数値」
です。
デフォルトは3で、例えば目標起床時間を07:00に設定している場合は、その3時間前の
04:00 〜 07:00 の間に投稿できれば早起き達成となります。

こうして早起き達成をすることができたら、achievement_daysテーブルdateに達成日の日付が履歴として記録されていきます。
例) 2020-11-22
この日付データを利用して、以下の機能を実現しています。

①  1ヶ月毎の早起き達成日数を算出
② ①の日数を利用したランキング機能

当初は、早起き継続日数のランキングにしようかとも考えていましたが、
ユーザーのモチベーション維持等の観点から1ヶ月毎の早起き達成日数を採用することにしました。

※朝活アプリなので、もともとは目標起床時間04:00~10:00の間しか設定できない仕様ですが、
現在は「早起き達成判定」機能を好きな時間にお試しいただけるよう、時間設定を自由にできるようにしています。

苦労したこと

開発からデプロイまで、どの工程でももれなくエラーで苦戦しましたがw、
ここでは特に印象に残っている点をまとめます。

CircleCIで苦労したこと

  • CircleCIの設定ファイルである、config.ymlの設定
  • 自動ビルド、自動テストの流れの理解

config.ymlの設定においては、だいぶエラーに悩まされました。。
特に、コマンドやパスを指定する時は、パスのルートはどこが起点になっているのかを理解することが重要な印象。
テスト失敗時の対策としては、ビルドされたコンテナにSSHログインしてエラーログを確認し、原因を解消していくようにしていました。

参考リンク: SSH を使用したデバッグ

AWSデプロイで苦労したこと

  • ACMでのSSL証明書発行
  • Laravelで画像をS3にアップロードする設定
  • CodeDeployでの、自動デプロイ設定(特にappspec.yml)
  • EC2インスタンスのセットアップ

上述した、
SSL証明書の発行
S3バケットへのアップロード
周りでエラーにハマりがちでした。
また、今回はECSでなくEC2でデプロイすることとしましたが、EC2にSSHログインしてから
インストールしたり設定するファイルが多く、その辺りの作業も大変でした。
この工程を考えると、ますますECSを扱えるようになりたく思いましたね^^;

フロントエンドで苦労したこと

  • UI/UXの調整(Sass)
  • Ajax全般

バックエンドでの苦労

  • DB設計
  • DBリレーション関連の処理
  • PHPUnitでのテスト全般

リレーション周りについては当初かなり苦戦しました。
どのテーブルとどのテーブルを関連付けるのか、また関連付けた情報をどうやって取得すれば良いのか?
また、

  • $article->user()
  • $article->user

例えばこの2つの違いについても重要なポイントと感じました。

PHPUnitのテストコードについては、体系的に学べる情報がなかなか見つからなかったので、情報収集に苦労しました。

ZoomAPI連携で苦労したこと

  • Guzzleの理解
  • ZoomAPIの理解

アプリ上からZoomミーティングを作成したり編集できる機能をつけることにしましたが、
これまで外部APIを利用したことがなかったこともあり、文法的なものや、API通信の仕組みについて理解するまでが難しく感じました。

実装にあたり、まずLaravelでZoomAPIと通信を行うために、PHPのHTTPクライアントである
Guzzleをインストールしました。

参考リンク:

次に、Zoomアプリマーケットプレイスでアプリを登録し、公式ドキュメントを読んでみるも、英語な上初めはどこのページの何を見れば良いのかわからず苦戦しました。。^^;

Laravelで、ZoomAPIと通信を行う処理のサンプルコードを紹介している
海外の記事を参考にしたりしているうちに、次第に公式ドキュメントから必要な情報を探せるようにもなってきました。

参考リンク:

ただ、今回Laravel6系でアプリを開発していたため、通常Laravel7系で使用できるGuzzleラッパーが使えず、ややコードを書き換えないといけない点にも苦労しました。

参考にした学習教材等

基本的には、UdemyTechpitで学習してきました。
この2つはとてもわかりやすいです。
個人的には、Udemyで基礎を学んでから、応用編としてTechpitで手を動かしながら学ぶのが良いと感じました。

Docker / docker-compose

PHPUnit / CircleCI / AWS

AWS

Laravel

Laravel / Vue.js

Sass

今後の課題

  • レスポンシブWebデザイン(スマホ対応)
  • デザイン面の改善
  • 無限スクロールの不具合修正(読み込まれた投稿のいいねボタンが消える)
  • ALBにAuto Scalingを追加し、EC2を冗長化
  • ECS(EKS)でのデプロイ
  • RDSの冗長化
  • インフラのコード化
  • 検索機能の追加
  • テストコードの充実
  • 投稿時に別画面へ遷移するのではなく、入力フォームをモーダルで表示させるようにする
  • 開始前のZoomミーティング、終了ミーティングのソート機能

まだ課題も多いですが、一つずつ改善してよりブラッシュアップしていきたく思います。

だいぶ長い記事になってしまいましたが、ここまで読んでくださりありがとうございました!^^

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【JavaScript】clientWidthプロパティでは小数値は取得できない

JSで横幅や高さを取得する処理を実装することはよくあると思います。
その際に、よく使われるのは

# 横幅取得
element.clientWidth

# 高さ取得
element.clientHeight

上記だと思います。
ただし、clientWidthclientHeightプロパティは整数値に丸め込まれてしまいます。

僕はこれを知らずにclientWidthclientHeightを使ってどはまりました。
横幅や高さを取得した上で計算する際に、小数値が丸め込まれてしまうと計算される値がずれてしまいます。

小数値まで含まれた値が欲しい

タイトルにもあるように、getBoundingClientRect()メソッドを使います。

# 横幅取得
element.getBoundingClientRect().width

# 高さ取得
element.getBoundingClientRect().height

ちなみに、getBoundingClientRect()は要素の横幅・高さだけでなく、ビューポートに対する位置も取得できます。

まとめ

最近は、バックエンドだけでなく、フロントエンドの開発も行うようになりました。
そんなフロントエンド初心者の僕は、基本的に要素の寸法と、そのビューポートに対する位置を取得したいときは、
getBoundingClientRect() を使えば良さそうだと思いました。

clientWidthclientHeightを使うメリットなどがあれば教えてください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue3でYouTubeの動画を操作するChrome拡張機能を開発する

作ったもの

YouTube Chapter Editor』というYouTubeのチャプター(目次)が効率良く作成できるChrome拡張機能を作りました。

動画編集ページのプレイヤーに専用ツールが埋め込まれ、チャプター情報を入力していくことで貼り付け用のテキストが自動生成されるツールです。

picture_pc_5b5bfe4d8082827f9d5101646e7e74d3.gif

YouTubeでチャプター(目次)付きの動画を投稿したことがある方はご存知かと思いますが、このチャプター情報の入力ってすごく面倒ですよね。

詳しい機能や使い方についてはこちらのnoteで書いていますのでよろしければご覧ください。
YouTubeのチャプター(目次)を簡単に入力できるChrome拡張機能を作りました

技術について

以下のような技術を利用して開発しました。

  • Vue 3 / Vuex
  • Webpack
  • Stylus
  • ESlint / Prettier
  • Font Awesome

本記事ではVue.jsのChrome拡張開発での利用、拡張機能からYouTubeの動画の操作、その他Chrome拡張機能開発のTipsについて紹介します。

実装

上記について実装の一部を抜粋しながら紹介します。
わかりやすくするために説明に直接関係ない処理を省略したり、関数名や変数名などはシンプルなものにしています。

ページ内で埋め込む要素に対してVue.jsを利用する

ツールを埋め込むためにまず空divを挿入して、video要素をpropsで渡してcreateApp()します。
後ほど説明しますが、すでに埋め込まれている動画の操作をするのでYouTube Player APIではなく、video要素を直接操作することになります。

まず、ルートに設置するmain.jsです。

main.js
import { createApp } from 'vue'
import App from './App.vue'
import store from './store'

// 動画プレイヤー領域の要素を取得
const playerContainer = document.getElementsByTagName('ytcp-html5-video-player')[0]

// ツールを埋め込むための空要素を挿入
playerContainer.insertAdjacentHTML('afterend', `<div id="app"><App :video="video" /></div>`)

// 動画要素を取得
const video = document.getElementsByTagName('video')[0]

const app = createApp({
  el: '#app',
  data() {
    return { video }
  },
})
app.use(store)
app.component('App', App)
app.mount('#app')

ちなみにApp.vueはこんな感じ。

App.vue
<template>
  <div>
    <Controller :video="video" />
    <div class="chapter-contents">
      <ChapterList :video="video" />
      <Preview />
      <Errors />
    </div>
    <Logo />
  </div>
</template>

<script>
import { defineComponent } from 'vue'
import Controller from './components/Controller'
import ChapterList from './components/ChapterList'
import Preview from './components/Preview'
import Errors from './components/Errors'
import Logo from './components/TheLogo'

export default defineComponent({
  components: {
    Controller,
    Preview,
    ChapterList,
    Errors,
    Logo,
  },
  props: {
    video: {
      type: HTMLVideoElement,
      required: true,
    },
  },
})
</script>

Vue.jsから動画要素を操作する

動画の操作部分の抜粋です。
video要素が持つイベント(playing, pause, ended, timeupdate)を取得して、Vueのstateを更新します。

./components/Controller.vue
<template>
 <!-- 省略 -->
</template>

<script>
import { defineComponent, computed, reactive } from 'vue'
import { useStore } from 'vuex'

const SEEK_TYPE_BACK = 1
const SEEK_TYPE_FORWARD = 2

export default defineComponent({
  props: {
    video: {
      type: HTMLVideoElement,
      required: true,
    },
  },

  setup(props) {
    const store = useStore()

    const state = reactive({
      isPlaying: false,
      currentTimeSeconds: 0,
      video: props.video,
    })

    /**
     * プレイヤーイベントの登録
     */
    state.video.addEventListener('playing', () => (state.isPlaying = true))
    state.video.addEventListener('pause', () => (state.isPlaying = false))
    state.video.addEventListener('ended', () => (state.isPlaying = false))

    // 経過時間の監視
    state.video.addEventListener('timeupdate', () => {
      const currentTimeSeconds = Math.floor(state.video.currentTime)
      // 秒数が更新されたらstateの現在時間を更新
      if (state.currentTimeSeconds !== currentTimeSeconds) {
        state.currentTimeSeconds = currentTimeSeconds
      }
    }, false)

    // templateから実行する関数
    const play = () => state.video.play()
    const pause = () => state.video.pause()

    /**
     * templateから実行するseekの関数
     * @param {Number} seconds
     * @param {Number} type
     */
    const seek = (seconds, type) => {
      switch (type) {
        case SEEK_TYPE_BACK:
          state.video.currentTime = state.video.currentTime - seconds
          break
        case SEEK_TYPE_FORWARD:
          state.video.currentTime = state.video.currentTime + seconds
          break
      }
    }

    return {
      state,
      play,
      pause,
      seek,
    }
  },
})
</script>

状態管理について

各コンポーネント間をまたいでチャプター情報やエラー情報を扱っているので、Vuexを利用しています。
チャプターの追加/変更/削除、エラーの追加/初期化のactionを持っている感じです。

Chrome拡張機能を多言語対応する

chrome.i18nを利用します。
公開ディレクトリの直下に_locales/を作成し、言語ごとにmessages.jsonを用意します。
スクリーンショット 2020-11-22 23.14.43.png

_locales/ja/messages.json
{
  "ext_name": { "message": "YouTube Chapter Editor" },
  "ext_description": { "message": "YouTubeのチャプター編集用のツールです。動画の詳細ページのプレイヤーに編集ツールが埋め込まれます。" }
}

manifest.jsonにdefault_localeを指定します。

"default_locale": "ja"

manifest.jsonからは__MSG_ext_name__のように指定することで取得できます。

manifest.json
{
  "name": "__MSG_ext_name__",
  "description": "__MSG_ext_description__",
  "default_locale": "ja"
}

JavaScriptからはこのように取得します。

chrome.i18n.getMessage('ext_name'))

JavaScriptからローカルの画像を読み込む

extension_idを指定する必要があります。
多言語対応でchrome.i18nを利用している場合は、以下のようにchrome.i18n.getMessage('@@extension_id')でIDが取得できます。

./components/Logo.vue
<template>
  <div class="logo">
    <span class="logo__image" :style="logoStyle"></span>
    <span class="logo__text">YouTube Chapter Editor</span>
  </div>
</template>

<script>
import { defineComponent } from 'vue'

export default defineComponent({
  setup() {
    const logoStyle = {
      backgroundImage: `url('chrome-extension://${chrome.i18n.getMessage('@@extension_id')}/icons/48.png')`,
    }
    return {
      logoStyle,
    }
  },
})
</script>

上記以外だと、CSS側でchrome-extension://__MSG_@@extension_id__と指定する方法もあります。

拡張機能のアイコンをクリックしてContents Script内の関数を実行する

今回の拡張機能は、対象ページに直接アクセスした場合やリロード時に自動でツールが起動するのですが、YouTubeはSPAなので他のページから遷移してきた場合はアイコンをクリックしてツールを起動する必要があります。

manifest.jsonでbackgroundとして指定しているファイルに以下のような処理を書きます。
content_scripts/bundle.jsはVueの実装などが入ったこの拡張機能のメインのスクリプトです。
アイコンがクリックされたらisIconClickedのフラグを立てます。

background.js
chrome.pageAction.onClicked.addListener(tab => {
  chrome.tabs.executeScript(
    tab.id,
    {
      code: 'isIconClicked = true;',
    },
    () => {
      chrome.tabs.executeScript(null, { file: 'content_scripts/bundle.js' })
    },
  )
})

Contents Scriptのmain.jsでisIconClickedを判定して実行します。

main.js
if (typeof isIconClicked !== 'undefined') {
  if (isIconClicked) {
    // アイコンがクリックされたら実行する処理
  }
}

他に良い方法があればコメントいただけると嬉しいです。

開発時のホットリロード(自動再読み込み)

hot-reload.jsを利用します。
hot-reload.jsを任意の場所に設置し、manifest.jsonで以下のように設定します。

"background": {
  "scripts": [ "hot-reload.js" ]
}

ファイルの変更を検知して、開発中にアクティブなタブが自動再読み込みされるようになります。

最後に

ツール自体は比較的シンプルなものなので特に実装で困ることはなかったのですが、VueでChrome拡張開発する方法や、Contents Scriptの動作についてはつまづくことも多かったです。今回は私が特に困った部分を盛り込んだ記事となりましたが、お役に立てれば幸いです。

あと、何よりこの拡張機能はYouTubeで動画を投稿されている方にはぜひ使っていただきたいです。私自身がもうこのツールなしではチャプター作成できないくらい依存しています。。
YouTube Chapter Editor

普段やっているPodcastのYouTube版で今回のツールの紹介をしてみました。技術の話をする番組ではないのですが、ご興味あればこちらもよろしくお願いします。(Spotify / Apple Podcast / Google Podcasts

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む