20191011のvue.jsに関する記事は13件です。

Vue + SVG で作るグラフィックアプリの骨格

はじめに

この記事はもう一つの記事、
Vue + Canvas で作るグラフィックアプリの骨格
の SVG 版です。

Canvas や SVG のおかげでブラウザ上でもインタラクティブにグラフィックを簡単に扱えるようになりました。

おまけに Vue を使うとまるでパソコン上のアプリを作るかのように Web 上にグラフィックアプリを作ることができます。

この記事では Vue + SVG でマウスで四角を書いていくような 80 行程度の簡単なサンプルを作ることで、Web 上のグラフィックアプリの骨格を示したいと思います。

できあがったものは、以下のような感じです。
ss.gif

準備

  • node, npm をダウンロードします

https://nodejs.org
この記事では node v12.4.0 で検証しています

  • vue-cli をダウンロードして、とりあえず qvs という名前でアプリを作ります。
$ npm i @vue/cli -g
$ vue create qvs
$ cd qvs
$ yarn serve

この記事では vue 3.12.0 で検証しています。yarn を使ってない人は最後の行は

$ npm run serve

になると思います。

なお、以下のコードは ESLint の掟に従ってないので、vue create qvs の時に ESLint をはずさないと多分エラーになると思われます。
また、セパレータを前に書いたり、引数とか変数の名前が _ だったりするのは単なる趣味ですので、気に入らなくても怒らないでください。

コード

src/App.vue を以下のようにしてみます。

App.vue
<template>
    <div id=app style="overflow: scroll">
        <div id=menu style="position: fixed; top: 0; left: 0">
            <button @click="mode='select'"  >select </button>
            <button @click="mode='rect'"    >rect   </button>
        </div>
        <svg
            style       = "margin: 24px 4px 4px 4px; background: aliceblue"
            :class      = "{ drawRect: mode == 'rect' }"
            :width      = "extent[ 0 ]"
            :height     = "extent[ 1 ]"
            @mousedown  = "mouseDown"
            @mousemove  = "mouseMove"
            @mouseup    = "mouseUp"
            @keyup.esc  = "keyUpESC"
        >
            <template v-for="_ in elements">
                <rect v-bind="_" stroke=black fill=none />
            </template>

            <rect v-bind=dragRect stroke=blue fill=none />
        </svg>
    </div>
</template>

<script>
export default {
    data    : () => (
        {   b           : null
        ,   c           : null
        ,   mode        : 'select'
        ,   elements    : []
        }
    )
,   computed: {
        extent  () {
            return [ 700, 500 ]
        }
    ,   svg     () {
            return this.$el.getElementsByTagName( 'svg' )[ 0 ]
        }
    ,   dragRect() {
            return ( ! this.b || ! this.c )
            ?   null
            :   {   x       : this.b.offsetX
                ,   y       : this.b.offsetY
                ,   width   : this.c.offsetX - this.b.offsetX
                ,   height  : this.c.offsetY - this.b.offsetY
                }
        }
    }
,   methods : {
        mouseDown( _ ) {
            this.b = _
        }
    ,   mouseMove( _ ) {
            this.c = _
        }
    ,   mouseUp( _ ) {
            this.c = _
            switch ( this.mode ) {
            case 'rect':
                this.elements.push( this.dragRect )
                break
            }
            this.b = null
        }
    ,   keyUpESC( _ ) {
            this.mode = 'select'
        }
    }
,   mounted() {
        this.svg.setAttribute( 'tabindex', 0 )
    }
}
</script>

<style>
.drawRect {
    cursor: crosshair
}
</style>

勘所

キーイベントの取得

svg に tabindex 属性をつけると、キーイベントを取得できるようになります。

    this.svg.setAttribute( 'tabindex', 0 )

カーソルの切り替え

mode が 'select' と 'rect' の2つの状態が存在します。
'rect' の状態の時にクロスヘアカーソルを表示するために
クラスとスタイルのバインディングを動的に使っています。

    :class = "{ drawRect: mode == 'rect' }"


<style>
.drawRect {
    cursor: crosshair
}
</style>

v-bind でプロパティを一括設定

プロパティ名と同じキーを持つ辞書を用意して

dragRect = {
    x       : 100
,   y       : 200
,   width   : 300
,   height  : 400
}
<rect v-bind=dragRect />

とやると、

<rect x=100 y=200 width=300 height=400 />

と展開されます。

最後に

この記事が何かの役にたてたら幸いです。わからないことがあればコメントください!

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

Vue + Canvas で作るグラフィックアプリの骨格

はじめに

この記事はもう一つの記事、
Vue + SVG で作るグラフィックアプリの骨格
の Canvas 版です。

Canvas や SVG のおかげでブラウザ上でもインタラクティブにグラフィックを簡単に扱えるようになりました。

おまけに Vue を使うとまるでパソコン上のアプリを作るかのように Web 上にグラフィックアプリを作ることができます。

この記事では Vue + Canvas でマウスで四角を書いていくような 100 行程度の簡単なサンプルを作ることで、Web 上のグラフィックアプリの骨格を示したいと思います。

できあがったものは、以下のような感じです。
ss.gif

準備

  • node, npm をダウンロードします

https://nodejs.org
この記事では node v12.4.0 で検証しています

  • vue-cli をダウンロードして、とりあえず qvc という名前でアプリを作ります。
$ npm i @vue/cli -g
$ vue create qvc
$ cd qvc
$ yarn serve

この記事では vue 3.12.0 で検証しています。yarn を使ってない人は最後の行は

$ npm run serve

になると思います。

なお、以下のコードは ESLint の掟に従ってないので、vue create qvc の時に ESLint をはずさないと多分エラーになると思われます。
また、セパレータを前に書いたり、引数とか変数の名前が _ だったりするのは単なる趣味ですので、気に入らなくても怒らないでください。

コード

src/App.vue を以下のようにしてみます。

App.vue
<template>
    <div id=app style="overflow: scroll">
        <div id=menu style="position: fixed; top: 0; left: 0">
            <button @click="mode='select'"  >select </button>
            <button @click="mode='rect'"    >rect   </button>
        </div>
        <canvas
            style       = "margin: 24px 4px 4px 4px; background: aliceblue"
            :class      = "{ drawRect: mode == 'rect' }"
            :width      = "extent[ 0 ]"
            :height     = "extent[ 1 ]"
            @mousedown  = "mouseDown"
            @mousemove  = "mouseMove"
            @mouseup    = "mouseUp"
            @keyup.esc  = "keyUpESC"
        />
    </div>
</template>

<script>
export default {
    data    : () => (
        {   b           : null
        ,   c           : null
        ,   mode        : 'select'
        ,   elements    : []
        }
    )
,   computed: {
        extent  () {
            return [ 700, 500 ]
        }
    ,   canvas  () {
            return this.$el.getElementsByTagName( 'canvas' )[ 0 ]
        }
    ,   ctx     () {
            return this.canvas.getContext( '2d' )
        }
    ,   dragRect() {
            return ( ! this.b || ! this.c )
            ?   null
            :   [   this.b.offsetX
                ,   this.b.offsetY
                ,   this.c.offsetX - this.b.offsetX
                ,   this.c.offsetY - this.b.offsetY
                ]
        }
    }
,   methods : {
        draw() {
            this.ctx.clearRect( 0, 0, ...this.extent )

            for ( let _ of this.elements ) this.ctx.strokeRect( ..._ )

            if ( ! this.dragRect ) return
            switch ( this.mode ) {
            case 'rect':
                this.ctx.setLineDash( [ 1 ] )
                this.ctx.strokeRect( ...this.dragRect )
                this.ctx.setLineDash( [] )
                break
            }
        }
    ,   mouseDown( _ ) {
            this.b = _
            this.draw()
        }
    ,   mouseMove( _ ) {
            this.c = _
            this.draw()
        }
    ,   mouseUp( _ ) {
            this.c = _
            switch ( this.mode ) {
            case 'rect':
                this.elements.push( this.dragRect )
                break
            }
            this.b = null
            this.draw()
        }
    ,   keyUpESC( _ ) {
            this.mode = 'select'
            this.draw()
        }
    }
,   mounted() {
        this.canvas.setAttribute( 'tabindex', 0 )
        this.draw()
    }
}
</script>

<style>
.drawRect {
    cursor: crosshair
}
</style>

勘所

キーイベントの取得

canvas に tabindex 属性をつけると、キーイベントを取得できるようになります。

    this.canvas.setAttribute( 'tabindex', 0 )

カーソルの切り替え

mode が 'select' と 'rect' の2つの状態が存在します。
'rect' の状態の時にクロスヘアカーソルを表示するために
クラスとスタイルのバインディングを動的に使っています。

        :class = "{ drawRect: mode == 'rect' }"
<style>
.drawRect {
    cursor: crosshair
}
</style>

最後に

この記事が何かの役にたてたら幸いです。わからないことがあればコメントください!

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

propsで受け取った値を、チェックしてdataに格納する。

親から子に値を渡す時に、子はpropsで受け取りますが、
受け取ったpropsの値をチェックしてからdataに格納したい。
けど、迷ったので忘備録です。

props: {
  'value': {
    'type': [Array, Boolean, String],
    'default': false
  }
},

data: function(){
  const me = this;
  let checkVal = false;
  if(me.value) {
    checkVal = true;
  }
  return {
    'val': checkVal,
  };
},

propsで受け取ったデータはmethodsとかcomputedで
ごにゃごにゃしてからdataに格納するのかと思ったら、
こんなシンプルな方法できました。

このdata内のifでバリデーションを色々かけれそうですね。

ちなみにpropsの値を直接ゴニョゴニョ変更しようとすると怒られます。

簡単な記事だけど、とりあえず忘備録。
時間があるときにちゃんとした記事にします。

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

VuexでIndexedDBのコネクション管理

HTML5の勉強してたら出てきた"Indexed DataBase API"を使ってみたかったんです

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

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    db: null, 
  },

  mutations: {
    DB_CONNECTION: (state, payload) => (state.db = payload),
  },

  actions: {
   createDatabase({ commit }) {

      // ブラウザにより異なるらしい
      const indexedDB = window.indexedDB || window.mozIndexedDB || window.msIndexedDB

      if (indexedDB) {
        var dbOpen = indexedDB.open(`${YOUR_DATABASE_NAME}`, 1.0) // 第2引数はversion

        // データベースがない場合、既存のversionよりも大きい引数でopenした場合
        dbOpen.onupgradeneeded = function(event) {
          const dbConnection = event.target.result
          // スキーマを作成する
          const store = dbConnection.createObjectStore(`${YOUR_STORE_NAME}`, { // store = テーブルのようなもの
            keyPath: `id`,         // 主キー用
            autoIncrement: true    // オートインクリメントを有効にしておかないとレコード挿入時に指定必須
          })
          store.createIndex('contentIndex', `${YOUR_INDEX}`) // index = カラムのようなもの
          commit('DB_CONNECTION', dbConnection)
        }

        // 接続成功の場合
        dbOpen.onsuccess = function(event) {
          commit('DB_CONNECTION', event.target.result)
        }
      } else {
        // IndexedDB使用不可
      }
    },
  },
})

あとはVueのインスタンス作成した時にdispatchで呼んで、以後はstateのdbを参照すればOK

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

graphql-ruby×nuxt.js 画像アップロード

走り書きすみません。綺麗にします。

Rails

carrierwaveuploader/carrierwaveを設定した後のお話。
ModelとUploaderを設定してね。

GraphqlController

jaydenseric/apollo-upload-clientコレを使うと、ActionController::Parametersに入ってくる値が変化する。(リクエストを変更している。)

~/app/controllers/graphql_controller.rb
class GraphqlController < ActionController::API
  def execute
    if params[:operations].present?
      param = JSON.parse(params[:operations])
      query = param["query"]
      operation_name = param["operationName"]
      variables = {
        "file" => params["1"],
      }
    else # コレ要らないかも?
      variables = ensure_hash(params[:variables])
      query = params[:query]
      operation_name = params[:operationName]
    end
    context = {
      session: session,
      current_user: current_user,
    }
    result = ApiSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
    render json: result
  rescue JWT::ExpiredSignature, JWT::DecodeError, JWT::VerificationError => e
    render json: { error: { message: e.message } }, status: :unauthorized
  rescue => e
    raise e unless Rails.env.development?
    handle_error_in_development e
  end
end

ImageType

~/app/graphql/scalar_types/image_type.rb
module ScalarTypes
  class ImageType < Types::BaseScalar
    graphql_name 'ImageType'
    description 'ActionDispatch::Http::UploadedFile'

    def coerce_input(file, _context)
      ActionDispatch::Http::UploadedFile.new(
        filename: file.original_filename,
        type: file.content_type,
        head: file.headers,
        tempfile: file.tempfile
      )
    end

    def coerce_result(value, _context)
      I18n.l(value, format: :default)
    end
  end
end

中身の確認

variables["file"].original_filename # => "hogehoge.jpg"
variables["file"].content_type # => "image/jpeg"
variables["file"].headers # => "Content-Disposition: form-data; name=\"1\"; filename=\"hogehoge.jpg\"\r\nContent-Type: image/jpeg\r\n"
variables["file"].tempfile # =>  #<File:/tmp/RackMultipart20191011-1-vtsg19.jpg>

Mutation

~/app/graphql/mutations/user_resource/update_user_profile_image.rb
module Mutations
  module UserResource
    class UpdateUserProfileImage < Mutations::BaseMutation
      null false
      argument :profile_image, ScalarTypes::ImageType, required: true
      field :results, Boolean, null: true

      def resolve(profile_image:)
        ActiveRecord::Base.transaction do
          user = context[:current_user]
          user.remove_profile_image! if user.profile_image
          user.profile_image = profile_image
          user.save ? { results: true } : { results: false }
        end
      end
    end
  end
end
~/app/graphql/object_types/user_type.rb
module ObjectTypes
  class UserType < Types::BaseObject
    field :id, ID, null: false
    field :profile_image, ScalarTypes::ImageType, null: true
    field :created_at, GraphQL::Types::ISO8601DateTime, null: true
    field :updated_at, GraphQL::Types::ISO8601DateTime, null: true
  end
end

Nuxt

Apollo設定

以下のコードは使いません。

~/apollo/client-configs/default.js
import { HttpLink } from 'apollo-link-http'
export default () => {
  const httpLink = new HttpLink({ uri: 'http://localhost:3000/graphql' })
}

このnode_moduleを使います。jaydenseric/apollo-upload-client

入力フォーム

vue-upload-componentインストールする。

~/apollo/client-configs/default.js
import { ApolloLink } from 'apollo-link'
import { InMemoryCache } from 'apollo-cache-inmemory'
import { createUploadLink } from 'apollo-upload-client'

export default () => {
  const uploadLink = new createUploadLink({ uri: 'http://localhost:3000/graphql' })
  const current_user = JSON.parse(localStorage.getItem('current_user'))
  const middlewareLink = new ApolloLink((operation, forward) => {
    operation.setContext({
      headers: { authorization: !current_user ? '' : current_user.token ? `Bearer ${current_user.token}` : '' }
    })
    return forward(operation)
  })
  const link = ApolloLink.from([
    middlewareLink,
    uploadLink
  ])
  return {
    link,
    cache: new InMemoryCache()
  }
}
~/pages/settings/profile.vue
<template>
  <v-container> 
    <label for="profile_image">Button</label>
      <file-upload extensions='gif,jpg,jpeg,png,webp' accept='image/png,image/gif,image/jpeg,image/webp' name='profile_image' v-model='profileImage' @input-filter='inputFilter' @input-file='inputFile' ref='upload'/>
  </v-container>
</template>
<script>
import UpdateUserProfileImage from '~/apollo/gql/mutations/user_resource/updateUserProfileImage.gql'
import FileUpload from 'vue-upload-component'
export default {
  components: {
    FileUpload
  },
  data: () => ({
    profileImage: []
  }),
  methods: {
    async storeProfileImage(file) {
      await this.$apollo.mutate({
        mutation: UpdateUserProfileImage,
        variables: {
          file: file.file
        }
      }).then(res => {
        console.log(res)
      }).catch(err => {
        console.error(err)
      })
    },
    inputFile(newFile) {
      if (newFile) {
        this.$nextTick(function() {
          this.storeProfileImage(newFile)
        })
      }
    },
    inputFilter(newFile, oldFile, prevent) {
      if (newFile && !oldFile) {
        if (!/\.(gif|jpg|jpeg|png|webp)$/i.test(newFile.name)) {          
          return prevent()
        }
      }
      if (newFile && (!oldFile || newFile.file !== oldFile.file)) {
        newFile.url = ''
        let URL = window.URL || window.webkitURL
        if (URL && URL.createObjectURL) {
          newFile.url = URL.createObjectURL(newFile.file)
        }
      }
    }
  }
}
</script>

GQL

引数の名前が$fileでなければ、エラーが発生するっぽい。

jaydenseric/apollo-upload-client

Usage

Use FileList, File, Blob or ReactNativeFile instances anywhere within query or mutation variables to send a > GraphQL multipart request.

~/apollo/gql/mutations/user_resource/updateUserProfileImage.gql
mutation UpdateUserProfileImage($file: ImageType!) {
  updateUserProfileImage(profileImage: $file) {
    results
  } 
}

残った疑問

  • ↓調べる。
ActionController::Parameters
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vuexメモ

state

dataのようなもの
状態を表している

getter

computedのようなもの
stateから別の値を算出するために用いられる。
ステートの値を書き換える力はない。
getterの中で他のgetterを使うことも可能

mutation

唯一stateを更新する役割。
methodsのようなもの。
第一引数に渡されたstateを更新する。
呼ぶときは、store.commit(mutation)のように呼ぶ。
この時第二引数に何らかの値を渡すと、mutationの第二引数に渡される。
この値のことをペイロードという。
まあメソッドの引数みたい感じ。
mutationの中ではsettimeoutのような非同期の処理は用いらない。

action

非同期処理や外部APIとの通信を行い、最終的にmutationを呼び出すために用いられる。
actionの定義はmutationに似ているが、第一引数にステートではなく、コンテキストと呼ばれる特別なオブジェクトが渡される。
コンテキストとは、state、getters、dispatch、commitが含まれる。
actionはmutationを実行するために用いられるため、commitが使われることが多い。
dispatchでactionを呼ぶ。
いろいろ複雑だが、流れ的には
1、store.dispatch(incrementAction)でincrementActionというactionを呼び出す。
2、incrementActionの中のcommit(incrementMutation)でincrementMutationというmutationを実行する。
という風なる。
dispatch→action→commit→mutationという感じにコードを追っていけば、実際に行われている処理にたどり着くであろう。

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

Nuxt.js - Firebaseを使ったToDoアプリの作成

はじめに

Nuxt.jsの学習でTodoアプリを作成したので、理解した内容で少し機能の追加をしてみました。
0から作成する手順をまとめたいと思います。初学者のため、コード等お見苦しい箇所が多くあるかもしれません。お許しください:mask:

作成物

todo.gif

リポジトリ

機能

以下が主な機能でnewが追加した部分になります。

  • タスクが登録できる
  • タスクに備考が登録できる new
  • タスクに日付が登録できる new
  • タスクを完了にできる
  • タスクを削除できる
  • UIフレームワークでVuetifyを使用する new
  • 登録の際にダイアログを表示する new
  • TodoとDoneは別々の表示領域を用意する new

前提

  • Googleアカウントを所持していること
  • Nuxtの開発環境が用意してあること

※開発環境の用意についてはこちらにまとめてみました!

Firebaseの設定

データの格納場所としてFirebaseの CloudFirestore を使用します。
Cloud Firestore

サイトへアクセス

以下のURLへアクセスします。
https://firebase.google.com/?hl=ja

使ってみるを選択するとGoogleアカウントの認証画面が開くので認証情報を入力します。

スクリーンショット 2019-10-08 10.59.05.png

プロプロジェクトの作成

Firebaseブロジェクトの「プロジェクトの追加」を選択します。

プロジェクト名を入力して「続行」を選択します。

スクリーンショット 2019-10-08 11.02.50.png

Googleアナリティクスを有効にして「続行」を選択します。

スクリーンショット 2019-10-08 11.03.51.png

アナリティクスを日本にして利用規約等を読み「プロジェクトを作成」を選択します。

スクリーンショット 2019-10-08 11.04.31.png

Database(CloudFirestore)の作成

「開発」 -> 「Database」からCloudFirestoreを開きます。
「データベースを作成」を選択します。

セキュリティ保護ルールの作成にて「テストモードで開始」を選択します。ロックモードでは認証ありのモードです。テストモードは認証がありませんので検証や学習の際に利用します。

テストモードの場合、プロジェクトIDのみで利用できてしまうので、外部にプロジェクトIDを出さないよう注意しましょう。検証が終わったらdatabaseは削除します

スクリーンショット 2019-10-08 11.43.19.png

ロケーション(物理サーバーの配置場所)の設定では「asia-northeast1 (東京)」を選択して完了を選択します。

スクリーンショット 2019-10-08 11.44.30.png

これでFirebase側の準備はOKです!

 プロジェクトの作成

terminal
npx create-nuxt-app nuxt-todo
作成時以下の内容だけ指定して、他は初期値にします。
// 利用するパッケージマネージャー
? Choose the package manager Npm
- Npm

// 利用するUIフレームワーク
? Choose UI framework None
- Vuetify

// 利用するコード検証ツール(コードのチェックツール)
? Choose linting tools (Press <space> to select, <a> to toggle all, <i> to invert selection)
- ESLint
- Prettier

// 利用するレンダリングモード
? Choose rendering mode Universal (SSR)
- SPA

Firebase関連のパッケージをインストール

firebaseを利用するための基本パッケージ(firebase)をインストールします。

terminal
npm install --save firebase@6.2.4 

CloudfireStoreを簡単に利用するためのパッケージ(vuexfire)をインストールします。

terminal
npm install --save vuexfire@3.0.1

環境変数の設定

.envから環境変数を読み込むためのパッケージ(dotenv)をインストールします。

terminal
npm install --save @nuxtjs/dotenv@1.3.0

.envを作成

.env
FIREBASE_PROJECT_ID = 'project-id'

プロジェクトIDはコンソール画面の歯車マークから「プロジェクトの設定」を選択し、開いた「Setting」の画面に表示されています。

Gitで管理する予定の場合は .gitignoreに.envが含まれていることを確認します。(Gitの反映対象外とする)

.gitignore
# dotenv environment variables file
.env

Firebaseとの連携

pluginsディレクトリの下に連携用のファイルを作成します。ファイル名は自由です。

firebase.js
import firebase from 'firebase/app'
import 'firebase/firestore'

// .envからプロジェクトIDを取得して定数に設定
const config = {
  projectId: process.env.FIREBASE_PROJECT_ID
}

// firebaseの初期化処理
if (!firebase.apps.length) {
  firebase.initializeApp(config)
}

export default firebase

ストアの作成

storeディレクトリの下にindex.jsファイルを作成します。

index.js
import { vuexfireMutations } from 'vuexfire'

export const mutations = {
  ...vuexfireMutations
}

storeディレクトリの下にtask.jsファイルを作成します。

task.js
import { firestoreAction } from 'vuexfire'
import firebase from '~/plugins/firebase'

const db = firebase.firestore()
const taskRef = db.collection('task')

export const state = () => ({
  tasks: []
})

export const actions = {
  // 初期化
  init: firestoreAction(({ bindFirestoreRef }) => {
    bindFirestoreRef('tasks', taskRef)
  }),
  // 追加
  add: firestoreAction((context, { title, detail, date }) => {
    if (title.trim()) {
      taskRef.add({
        title,
        detail,
        date,
        status: false
      })
    }
  }),
  // 削除
  remove: firestoreAction((context, id) => {
    taskRef.doc(id).delete()
  }),
  // ステータス更新
  toggle: firestoreAction((context, todo) => {
    taskRef.doc(todo.id).update({
      status: !todo.status
    })
  })
}

// 日付の昇順でソート
export const getters = {
  orderdTodos: (state) => {
    return _.orderBy(state.tasks, 'date', 'asc')
  }
}

一覧データを日付の昇順で表示するためにlodashというライブラリを使用しました。
使用するためにはnuxt.config.jsに以下の記載をします。

nuxt.config.js
import webpack from 'webpack'

  build: {
    /*
     ** You can extend webpack config here
     */
    extend(config, ctx) {},
    plugins: [
      new webpack.ProvidePlugin({
        _: 'lodash'
      })
    ]
  }

コンポーネントの作成

コンポーネントは全部で3つ作成しました。
もっと細かく分けたかったのですが、ざっくりと分けることにしました。

タスクコンポーネント

まずはダイアログの部分です。
Vuetifyにはダイアログのコンポーネントがあるのでそれを利用しました。
datepickerのコンポーネントも色々表示のさせ方が豊富で便利ですね:relaxed:
登録を押した時にaddメソッドからtask.jsのaddが実行されてDBに登録されます。

スクリーンショット 2019-10-09 20.17.11.png

/components/TaskDetail.vue
<template>
  <v-dialog v-model="dialog" persistent max-width="600px">
    <template v-slot:activator="{ on }">
      <v-btn color="#5963F8" dark class="font-weight-bold" v-on="on"
        ><v-icon small class="mr-2">mdi-plus-circle-outline </v-icon
        >新規タスクを追加</v-btn
      >
    </template>
    <v-card>
      <v-card-title>
        <span class="headline">新規タスクを追加</span>
      </v-card-title>
      <v-card-text>
        <v-container>
          <v-row>
            <v-col cols="12">
              <v-text-field v-model="title" label="Title"></v-text-field>
            </v-col>
            <v-col cols="12">
              <v-textarea v-model="detail" label="Detail"></v-textarea>
            </v-col>
            <v-col cols="12">
              <v-dialog
                ref="dialog"
                v-model="modal"
                :return-value.sync="date"
                persistent
                width="290px"
              >
                <template v-slot:activator="{ on }">
                  <v-text-field
                    v-model="date"
                    label="日時"
                    readonly
                    v-on="on"
                  ></v-text-field>
                </template>
                <v-date-picker v-model="date" scrollable>
                  <div class="flex-grow-1"></div>
                  <v-btn text color="primary" @click="modal = false"
                    >Cancel</v-btn
                  >
                  <v-btn text color="primary" @click="$refs.dialog.save(date)"
                    >OK</v-btn
                  >
                </v-date-picker>
              </v-dialog>
            </v-col>
          </v-row>
        </v-container>
      </v-card-text>
      <v-card-actions>
        <div class="flex-grow-1"></div>
        <v-btn color="primary" text @click="dialog = false">キャンセル</v-btn>
        <v-btn color="primary" text @click="add">登録</v-btn>
      </v-card-actions>
    </v-card>
  </v-dialog>
</template>

<script>
export default {
  data() {
    return {
      title: '',
      detail: '',
      dialog: false,
      date: new Date().toISOString().substr(0, 10),
      menu: false,
      modal: false
    }
  },
  methods: {
    add() {
      this.$store.dispatch('task/add', {
        title: this.title,
        detail: this.detail,
        date: this.date
      })
      this.dialog = false
    }
  }
}
</script>

リストコンポーネント

次に一覧を表示するタスクの一覧の部分です。
TodoとDoneで利用するのでtitileとtasklistは親から値をもらうようにしています。
チェックボックスを押した時にtoggle、削除アイコンを押した時にremoveを実行します。

スクリーンショット 2019-10-09 20.21.26.png

/components/TaskList.vue
<template>
  <v-row>
    <v-col cols="12" md="12">
      <v-list color="#f4f5fc">
        <v-subheader class="font-weight-bold">{{ title }}</v-subheader>
        <v-col v-for="task in tasklist" :key="task.id" cols="12" class="pt-0">
          <v-card>
            <v-card-title class="headline pb-0">
              <v-checkbox
                :checked="task.status"
                color="primary"
                class="ma-0"
                :label="task.title"
                @change="toggle(task)"
              ></v-checkbox>
            </v-card-title>
            <v-card-text class="pb-0">{{ task.detail }}</v-card-text>
            <v-card-actions class="pt-0">
              <v-col cols="10" md="10" class="pl-0">
                <v-btn text>
                  <v-chip color="grey lighten-3" label>
                    <v-avatar left>
                      <v-icon small color="primary">mdi-calendar</v-icon>
                    </v-avatar>
                    {{ task.date }}
                  </v-chip></v-btn
                >
              </v-col>
              <v-col cols="2" md="2">
                <v-btn icon color="grey" text dark @click="remove(task.id)">
                  <v-icon>mdi-close-circle-outline</v-icon>
                </v-btn>
              </v-col>
            </v-card-actions>
          </v-card>
        </v-col>
      </v-list>
    </v-col>
  </v-row>
</template>

<script>
export default {
  props: {
    title: {
      type: String,
      default: ''
    },
    tasklist: {
      type: Array,
      default: null
    }
  },
  methods: {
    remove(id) {
      this.$store.dispatch('task/remove', id)
    },
    toggle(task) {
      this.$store.dispatch('task/toggle', task)
    }
  }
}
</script>

<style>
.theme--light.v-label {
  color: #000;
}
.v-input--selection-controls:not(.v-input--hide-details) .v-input__slot {
  margin-bottom: 0px;
}
.v-application--is-ltr .v-list-item__avatar:first-child {
  margin-right: 0;
}
</style>

ボードコンポーネント

最後にpagesディレクトリにboard.vueを作成します。
上記で作成した、タスクコンポーネント、リストコンポーネントをimportします。
また新規登録用の処理とcreatedのタイミングでfirebaseの初期化処理を実行します。

スクリーンショット 2019-10-11 12.31.07.png

/pages/board.vue
<template>
  <v-container class="todo">
    <v-form ref="form">
      <v-row>
        <v-col cols="12" md="12">
          <task-detail></task-detail>
        </v-col>
      </v-row>
    </v-form>
    <task-list title="Todo" :tasklist="todolist"></task-list>
    <task-list title="Done" :tasklist="donelist"></task-list>
  </v-container>
</template>

<script>
import TaskList from '../components/TaskList.vue'
import TaskDetail from '../components/TaskDetail.vue'

export default {
  components: {
    TaskList,
    TaskDetail
  },
  computed: {
    todolist() {
      return this.$store.getters['task/orderdTodos'].filter(function(el) {
        return el.status === false
      }, this)
    },
    donelist() {
      return this.$store.getters['task/orderdTodos'].filter(function(el) {
        return el.status === true
      }, this)
    }
  },
  created() {
    this.$store.dispatch('task/init')
  }
}
</script>

<style scoped>
.status.done {
  text-decoration: line-through;
}
</style>

UIの調整

外観の部分を調整するためレイアウトを少しだけ修正しました。

layout/default.vue
<template>
  <v-app dark>
    <v-app-bar color="#5963F8" fixed app dark>
      <v-toolbar-title>{{ title }}</v-toolbar-title>
    </v-app-bar>
    <v-content>
      <v-container>
        <nuxt />
      </v-container>
    </v-content>
  </v-app>
</template>

<script>
export default {
  data() {
    return {
      title: 'TodoList'
    }
  }
}
</script>

<style>
.theme--light.v-application {
  background: #fff;
}
.v-toolbar__title {
  font-family: 'Damion', cursive;
  font-size: 2.5rem;
  width: 100%;
  text-align: center;
}
</style>

おわりに

Vueの学習課題としてよく見かけるTodoアプリの作成を、自分が本当に理解できたのか確認するために今回の作業を行いました。編集できるようにしたり、ドラッグ&ドロップできるようにしたりなど意外にやれることは多くて面白い課題だなと感じました。どこかでTrelloやTodoListなどのアプリを参考にして機能を追加していけたらいいなと思っています。

ここまでお読みいただきありがとうございました!!:bow_tone1:

参考

Nuxt.js
Vuetify

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

30分で基礎がしっかりわかる【Vuex】-入門

はじめに

皆さん、こんにちは!Webシステム開発エンジニアの蘭です!
今日は【Vuex】について語りたいと思います。

Vuexって何?

Vuexはすべてのコンポーネントでデータを一元管理するための仕組みです。

何故Vuex?

Vueで足りるんじゃない?
アプリを構築している中、最初はコンポーネント内だけでdataを操作してたが、コンポネントが多くなってくると、コンポーネントで共有して使うデータが現れます。その時思いついたのが、Vueの$emitやpropsを使うことでコンポーネント間のデータ共有問題を解決してました。

しかしアプリが大きくなるに連れ、コンポーネントが更に多くなり、共有データが300以上に増加、もうこのコンポーネントのデータはどのコンポーネントから持ってきたのかが分からない!:scream:
まさに開発に連れ、地獄の道へと進んでしまってたのです。

その時に、現れたのがVuexでした。

Vuexっていつ導入すべきなのか?

ここで想像してみましょう。
例えばコンポネントは一つの店だとします。
Aコンポーネントはバナナしか在庫がなく、Bコンポーネントはりんご、Cコンポーネントはスイカ等、それぞれ一種類の果物しか預かってません。

それで各コンポーネントがフルーツパフェを作りたい時に、コンポーネントAはBにりんごを買ってきたり、BはAからバナナを入荷してましたが、なにかややこしいですよね。
それでVuexの考え方はコンポネントで皆使う共有の果物は全部Storeという多きな倉庫に入れといて、他の果物が欲しいコンポネントは倉庫から入荷しましょうという改善方法が生まれました。
その倉庫がVuexでの「Store(ストア)」です
めでたし、めでたし:smiley:

・Vuexでのデータ管理のイメージ

共有データを一つのStoreに管理する
alt

Vuexってどんな時に使うといいの?

・アプリケーション全体で使用されるデータ→Vuexで管理する
・コンポーネントの内部のみで使用されるデータ→dataオプションで管理する

実例:Vuexのシンプルなストア(倉庫)

・以下の例はVuexストア(倉庫)から変数countを取得します。

・コンポーネントからVuexストア共有変数countをstore.state.countで取得。
・VuexではStoreの共有変数countを直接変更できない為、
・対策:ストア共有変数を変更する関数increment()をストア内に準備し、コンポーネントではボタンを押した後、store.commit('increment')経由で共有変数countを間接的に更新する。

See the Pen Vuex_Simple_Store_Demo by Uramaya (@uramaya) on CodePen.

要するに:ストアと単純なグローバルオブジェクトの違い

・ストアの状態を直接変更することはできない。明示的にmutationsをコミットすることによってのみストアの状態を変更する。これによりすべての状態変更に追跡可能な記録を残すことが保証される。

Vuexをやってみよう!

Vuexのインストール

・通常開発ではnpmやyarnでインスタンスします。
  ※前提:Vueのインストール済み

vuex_npm_install.
npm install vuex --save
vuex_yarn_install.
yarn add vuex

・npmやyarnでvuexをインストールの方、
 以下のVuexを明示的に導入が必要。

import_Vuex.
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

・試しであれば、cdnでもOK

vuex_cdn.
https://cdnjs.cloudflare.com/ajax/libs/vuex/2.0.0/vuex.js

Vuexの概念

以下の内容はこちらを参照しています。

Vuexのストアは構成要素として5つの概念を持つ
【state】, 【mutations】, 【getters】, 【actions】, 【modules】
以下は上記の5つの概念について説明します。

alt

 1.【state】

  ・ストアで管理する状態(共有変数、データ)。コンポーネントのdataみたいにデータを保存する場所。
  ・gettersから参照され、更新はmutationsで行う

ストアから状態を取り出す`一番シンプルな方法は、算出プロパティ (computed)で取得しまた返すことです。

・こちらは直接'store.state.count'でストア状態を持ってきてますが、

・開発で以下の欠点があります。

モジュールを使うとき、ストアの状態を使っている全てのコンポーネントをインポートしなければなりません。(ストア状態を共有してるモジュールが100個あった場合、地獄に落ちます。)

See the Pen Vuex_Store_Example by Uramaya (@uramaya) on CodePen.

・上記解決方法:ルートコンポーネントに store オプションを指定

これですべての子コンポーネントにストアを共有する事ができます。

index.vue
new Vue({
  el: '#app',
   //ルートインスタンスに store オプションを渡す
   //★これをすることで、this.$store で各コンポーネントから参照することができます。
   store,
});

See the Pen gOOpGxN by Uramaya (@uramaya) on CodePen.

mapState ヘルパー:算出プロパティ(Computed)で宣伝方法の改善

・以下を見るとわかりますが、算出プロパティを全て宣伝するのは冗長です。

Before:元の算出プロパティ(Computed)宣伝方法
const Counter = {
  template: `<div>{{ count }}</div>`,
  computed: {
    count () {
      return this.$store.state.count
    }
  count2 () {
      return this.$store.state.count2
    }
  count3 () {
      return this.$store.state.count3
    }
   ...
  }
}
After:MapState使用後.
computed: mapState({
    count:  'count',  // count: state => state.countと同義
    count2: 'count2', // count2: state => state.count2と同義
    count3: 'count3'  // count3: state => state.count3と同義
  })

・npmやyarnでvuexをインストールの方、魔法の言葉を忘れずに

mapState.
import {mapState} from 'vuex'

 2.【getters】

  ・state内の状態をもとに算出した値を返す関数が書かれる場所
  ・stateのデータを加工して表示
  ・state加工するので、最初の引数はstate
  ・状態をフィルタリング、カウントした値を返す

See the Pen Vuex_Store_Example_getters by Uramaya (@uramaya) on CodePen.

 3.【mutations】

  ・stateを更新する関数が書かれる場所
  ・stateの更新はしない
  ・第一引数は必ずstate, それ以降の引数はpayload
  ・state状態を更新する際は必ずcommitを使用


See the Pen
Vuex_Store_Example_mutations
by Uramaya (@uramaya)
on CodePen.


 4.【actions】

  ・非同期処理や外部API通信する場所
  ・actionで非同期処理を開始→
   実際stateの更新はmutationsをcommitで実行する
    ⇛actionでstateの更新は行わない
    ⇛最終的にmutationsにデータをコミットする関数
    ⇛commitは同期でなければならない

Actions処理

以下の例でActions処理の過程を見てみよう。

全体の処理の流れとしては、コンポーネントからdispatchでアクションを呼び出し、アクション内で外部APIなどからの非同期処理を行った後、commitでミューテーションを使いステートを更新するという流れとなります。
上記はこちらから引用。

・コンポーネントからストアの処理を呼び出すメモ:
  MutationsはCommit / Actionsはdispatch

コンポーネント内のmethodsにActionsをdispatchで実行.
 methods: {
    increment(plus) {
   //plusは引数
      this.$store.dispatch('incrementAsync', plus);
      this.$store.dispatch('warningAsync');
    }
  },
その後、ストアのActionsでcommitを呼び出します.
  actions:{
    incrementAsync({ commit }, plus) {
      setTimeout(()=>{
        alert("これが非同期処理です。");
        commit('increment', plus)
      }, 5000) //非同期で五秒遅らせる事ができる。
    },
    warningAsync({ commit }) {    
        commit('warning')
    }
  },
そして、commitでMutationsからステートを変更します.
  mutations: {
    //★ここでstate状態を変更する関数を用意
    increment (state, plus) {
      state.total += plus
      state.warning_show = false
    },
    warning (state) {
      state.warning = "5秒お待ち下さい。"
      state.warning_show = true
    }
  }, 

それでは実装してみよう。

非同期処理

See the Pen Vuex_Store_Example_actions by Uramaya (@uramaya) on CodePen.

API通信

・今回非同期のAPI通信では、ライブラリ「axios」を使います。
・async関数を使ってみる(必須ではない):

async関数:
async関数・メソッドはメソッドの冒頭に async をつけることで、 await が使え、awaitは必ずaxios.get等通信処理が終了し、responseもしくはerrorが返ってきた後に、awaitの処理を実行します。

See the Pen ExxVjym by Uramaya (@uramaya) on CodePen.

 【state】、【getters】、【mutations】、【actions】を使ってTodoListを作ってみよう!

ここまで読んでいただき、ありがとうございます!
ではモジュールを理解する前に、以下のコードで遊んでみてください。

See the Pen Vue + Vuex Demo by Arpit Gupta (@arpitg) on CodePen.

 5.【modules】

  ・上記4つのストア構成要素を分割したもの。
  ・アプリケーションの肥大化に伴い大きくなるストアに対し、見通しをよく保つためにモジュールに分割する。

以下の記事を参考しました。
  Vuex公式-モジュール

開発に連れ、コードが膨大する中、全て何百個のstate, getters, mutations, actionsを一つのファイルにまとめるのは事実上管理不可能です。
ここで解決法が【モジュール】です。
モジュールとは元々凄く膨大なstateやgetters等を小さなモジュール単位に分割して、管理することです。
もちろん、これで保守、修正、管理が便利になります。

モジュールの使い方を見てみよう!

・1.同一ファイルでモジュール分け
同一ファイルでモジュール分け.
//モジュールA
const moduleA = {
  state: { ... },
  mutations: { ... },
  actions: { ... },
  getters: { ... }
}

//モジュールB
const moduleB = {
  state: { ... },
  mutations: { ... },
  actions: { ... }
}

const store = new Vuex.Store({
  modules: {
    module_a: moduleA,
    module_b: moduleB
  }
})

store.state.module_a // -> `moduleA` のステート
store.state.module_b // -> `moduleB` のステート
・2.違うファイルでモジュール分け(現場)

・モジュールの分け方について以下を参考しました。
  Vuex の Modules 機能
  vuexでmoduleを分ける方法と注意点

 ・(1)モジュール:「superFunction」と「header」を storeに登録
/store/index.js
import Vue from "vue";
import Vuex from "vuex";

import superFunction from "./superFunction";
import header from "./header";

Vue.use(Vuex);

const store = new Vuex.Store({
  modules: {
    superFunction,
    header
  }
});

export default store;
 ・(2)モジュールの指定を明確にする為、 namespaced: true にすることを忘れずに

 ・namespacedについて以下を引用しました。
   Vuexのストアをモジュールに分割する

・Vuex名前空間の概念:【namespaced: true】 とは

namespaced:オプションをtrueにすることで、それぞれのモジュールに名前空間を与えて呼び出し方を管理することができる。

ただstateについては、namespaced: trueの有無は関係なく一律で$store.state.(モジュール名).data.messageのように呼び出す。

その他のmutation, action, getterはnamespaced: trueを与えなかった場合はモジュールを使用せずグローバルに登録したときと同じように呼び出せる。

もしnamespaced: trueを与えていない複数のモジュール内で名前が被った場合、mutationとactionはそれぞれが同時に実行される。
getterは以下のようなエラーが発生する。

[vuex] duplicate getter key: greetingC.

namespaced: trueを与えたmutation, action, getterは、名前の前にモジュール名/を付与して呼び出すことで、上記のエラーを避けられます。

console.log(this.$store.getters['moduleA/greeting']
console.log(this.$store.commit('moduleA/greeting')
console.log(this.$store.dispatch('moduleA/greeting')
// getterは[], mutationとactionは()で囲むので注意

・モジュールを作ってみよう

/store/superFunction/index.js
const state = {
  appNumber: 0
};

const getters = {
  appNumber(state) {
    return state.appNumber;
  }
};

const actions = {
  changeNumber({ commit }, val) {
    commit("changeNumber", val);
  }
};

const mutations = {
  changeNumber(state, value) {
    state.appNumber = state.appNumber + value;
  }
};

const superFunction = {
  namespaced: true, // 忘れずに
  state,
  getters,
  actions,
  mutations
};

export default superFunction; //モジュールの名前
 ・(3)コンポネントでモジュール:superFunctionを使う

以下の注意点:
・getter と action の参照の仕方が変わる
・this.$store.getters["moduleName/getterName"]
・this.$store.dispatch("moduleName/actionName")

/components/AppMain.vue
<template>
  <main>
    {{appNumber}}
    <Controller :changeNumber="changeNumber"/>
  </main>
</template>

<script>
import Controller from "./Controller";
export default {
  components: {
    Controller
  },
  computed: {
    appNumber() {
      return this.$store.getters["superFunction/appNumber"];
    }
  },
  methods: {
    changeNumber(val) {
      this.$store.dispatch("superFunction/changeNumber", val);
    }
  }
};
</script>

・上記の実際のソースコードを見て試してみよう【Codesandbox】
https://codesandbox.io/embed/w67wklz0pk?fontsize=14

おまけに

・npmやyarnでVuexをインストールした方は、必ずuseでVuexの参照をしてください。

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

Vue.use(Vuex); //忘れずに

const store = new Vuex.Store({
......

・Vuexで開発する際に、以下の記事も参考にできればと思います。
  Vuexを用いた開発プロジェクト用にガイドラインを作成した話
  メンテナンスしやすいVueComponentを設計するために気をつけていること

・VueとVuexの練習
 30minくらいで学ぶVue.jsとVuex
 簡単なTODOアプリで Vue + Vuex を学んでみよう
 簡単なTODOアプリソースコード(Github)

・Vueの基本概念
 10分で基礎がわからるVue.js-入門

最後に

今回はVuexについて基本の使い方を紹介しました。
自分のニーズに沿って、是非Vuexを開発で使って見てください!:D

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

Heroku(Python3)+Auth0+GitHub Pages(Vue.js)で大学の関係者だけが見れるページをサクッと(?)作る。

前作の話

こちらは前に書き、なんとトレンドに乗っかってしまった記事です。

Firebase+Vue.jsで大学の関係者だけが見れるサイトをサクッと作る - Qiita

この投稿からなんと5ヶ月が経ちました。

いろいろ学びを深めたのでそのアウトプットがてら、この記事を持ちましてリベンジをさせて頂きたいと思います。

概要

学生に配布されるメールアドレスのドメインを見て、同じ大学のメールアドレスのドメインの場合だけ見れるようなサイト(ページ)を作りたい!

使う技術・サービス・言語

  • github.io
    • 静的サイトをホスティングしてくれるサービス。GitHubアカウントがあれば利用できます。
  • Auth0
    • 認証プラットフォーム、〇〇でログインとか簡単に実装できてすごく便利。無料でもそれなりに遊べます。
    • 今回の記事はAuth0のRulesが便利すぎて凄いぞと思って生まれています。
  • Heroku
    • アプリを実行してくれるプラットフォーム、いわゆるPaaS。今回はAPIサーバ扱い
  • Python3
    • ここなら割とお馴染みプログラミング言語、機械学習が熱い(らしい)
  • Vue.js (JavaScriptフレームワーク)

動作環境

  • macOS Catalina 10.15 Beta
  • Python3.7.3
  • nodejs v10.15.3
    • npmは v6.4.1

動作イメージ

  1. ログインの処理をAuth0にぶん投げます。
  2. アクセストークン の検証をHeroku(Backend,Python)で 適当に やって、正しい形式なら秘密の情報を返します。
  3. フロントエンドは受け取った情報を良い感じにして表示します。(今回はGitHub Pagesにデプロイします。)

この手順をサクッと進めていこう! と言う企画。

かなり雑ですがこんな感じ

アーキテクチャ図

作成手順

この記事では以下の手順で実装していきます。

  1. Auth0側設定
  2. Herokuのアカウント作成、アプリ作成、Python3でのコード作成、デプロイ(すなわちバックエンドの構築)
  3. フロントエンドアプリの作成
  4. デプロイ
  5. 大学のメールドメインのバリデーション

Auth0側設定

ここにアクセスしてサインアップしましょ
Never Compromise on Identity. - Auth0

サインアップをすると、テナントドメインを決めてくださいと言う旨のメッセージが出ます。

image.png

でこの辺はお好きに。

完了したらダッシュボードに移動して、

image.png

CREATE APPLICATOIONを選択して、作成画面に移行。

アプリ名とかお好きに、application typeはSingle Page Web Applications に設定してください。

image.png

こんな感じで作成します。

Auth0のテナント名はメモっておいてください。

<ここ>.auth0.comです。

次に、Googleログインを無効にします。(今回はメアド+パスワードのみの許可にしたいので)

出来たら、Applicationsから自分の作成したアプリを選択しましょう

image.png

そしてタブからConnectionsを選択

image.png

そしてこんな感じにgoogle-oauth2を無効にします。

image.png

ここまできたら完了です。お疲れ様です。

バックエンド環境の構築

Herokuアカウントの作成

Heroku | Sign up

ここにアクセスしてサインアップしましょう。

アプリの作成

アカウント作ったらこんな感じの画面になるはず

image.png

Create New Appを選んでApp nameやRegionを設定。この辺はお任せします。

今回はこんな感じにしました。

image.png

デプロイ準備

アプリ作ったらいろいろ出てきますが、まずはHeroku CLIだけインストールしましょう。(HerokuのデプロイとかをCLIからやってくれるツールです。)

$ brew tap heroku/brew && brew install heroku

インストールできたら、

$ heroku login

でログインをしましょう。

コーディング

とりあえず、こんな感じの要件で作るとします。

・ アクセストークンが正しければ、JSON形式で{ "himitu": "ゴニョゴニョ"}
・ アクセストークンが変だったら403を返す。

大体こんな感じの機能で作ります。

今回はAPIフレームワークとしてFastAPIを使います。

かなり新しめのフレームワークです。

Features - FastAPI

理由としてはAPIサーバ作るなら一番シンプルだったり、今回は関係ないですがSwagger UIで自動的にdocumentを生成してくれたりで学習コストが軽量な割りに凄く便利で気に入ったからです。 ついでに僕は新しい物好きなので

で、Heroku+FastAPIってことをしているサンプルコード探していたのですが、ないので作りました。 今回はこれを弄くり回して使いましょう。

reud/fast-api-heroku-sample: Python-Server + Heroku Sample

forkしてcloneとかしてください。

で、gitリポジトリできたらとりあえず、プロジェクトフォルダ直下で

$ heroku git:remote -a アプリ名 でリポジトリとherokuアプリの結び付けを行います。

結び付けが出来たら、githubとかにpushする感覚と同様にaddとか色々し終わってるのを確認してから、

git push heroku master でdeployが完了します。

(手順わからない場合は参考資料が凄く役に立つはずなのでそちらをご覧ください)

僕の場合はApp nameの関係で、
https://himitu-api-server.herokuapp.com/ にデプロイされます。

他の人はhttps://<App name>.herokuapp.com/にデプロイされると思います。

ブラウザでアクセスしてみてコード通りの動作している事を確認してください。

多分こんな感じでJSONがブラウザに表示されます。

image.png

で、ここからコードを弄りましょう。

その前に

今回はAuth0からアクセストークンを貰う形式ですが、何に対して発行するかで、やることが変わってきます。

今回はベストなやり方としては、APIサーバを作っている以上Auth0にカスタムAPIを作成して、「このカスタムAPIをください!」ってaudienceを使用して教えて上げる必要があります。(audienceはどのリソースサーバを使用するか決定するために指定します。)

すなわち、Auth0にURL (https://himitu-api-server.herokuapp.com/) でカスタムAPIを作成するのが一番正しいはずです。

しかし、カスタムAPIを作成すると手間がまぁ増えます。

参考: Validate an Access Token - Auth0

端的に言うと、カスタムAPIでアクセストークンを得た場合、トークン検証が必要になります。アクセストークンはJWTと言われる形式で渡されるのでそれをゴニョゴニョしてJWKSを照らし合わせて・・・ってやるのですが、それだけで1記事出来てしまうのと、まだ勉強不足なので今手を出すとこの記事が未完になりそうなため少し手抜きをします。

取得するAuth0のアクセストークンのaudienceをManagement APIにします。こうした場合はJWTの検証は不要になります。

Management APIはユーザの作成や削除などAuth0の全体(テナントって呼ばれています。)を管理するAuth0のAPIです。

Auth0のAPIを発行しているためAuth0がトークンの検証を担保するイメージですね。(これを利用して検証をサボります。)

そんな激ヤバなトークンを発行して良いのか、トークン抜き出されたら終わりではって思うかもしれませんが、その辺も大丈夫です。

さらにSPA側でManagement API アクセストークンを発行すると、勝手にscopeに制限がかかり、出来ることが大幅に減ります。

参考: Get Management API Tokens for Single-Page Applications -Auth0

上記サイトを見る限り、他のユーザに干渉出来るようなscopeは一つも貰えず、良い感じに渡しても良いscopeしか渡さなくなります。

今回は、scopeにread:current_userを入れて、バックエンドはSPAから受け取ったアクセストークン でManagement APIを叩いて、上手く行ったらOKということにしちゃいましょう。

コードを書く

とりあえず、今回はリクエストからAuthorizationヘッダーの値をぶっこ抜いて、得られた文字列をManagenement APIに投げて、上手く行ったらOKみたいな感じにしたいと思います。

まずは追加でパッケージのインストール、

pip install starlette
pip install requests

コードを書きましょう。 main.py にこんな感じのコードをつらつらと、

import traceback

import requests
from fastapi import FastAPI, HTTPException
from starlette.requests import Request
from starlette.middleware.cors import CORSMiddleware
import os

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,   # 追記により追加
    allow_methods=["*"],      # 追記により追加
    allow_headers=["*"]       # 追記により追加
)

@app.get("/")
def read_root():
    return {"200": "Welcome To Heroku"}


@app.get("/himitu/{user_id}")
def read_item(user_id: str, request: Request):
    k = request.headers.get('Authorization')
    if not k:
        raise HTTPException(status_code=403, detail="forbidden")
    headers = {'Authorization': k}
    # 適宜読み替えてください
    try:
        r = requests.get(f'https://{ os.getenv("TENANT") }.auth0.com/api/v2/users/{user_id}', headers=headers)
    except:
        raise HTTPException(status_code=403, detail=traceback.print_exc())
    j = r.json()
    try:
        # checker
        n = j['nickname']

        return {'himitsu': os.getenv('HIMITSU')}
    except:
        raise HTTPException(status_code=403, detail='forbidden')

で、

$ heroku config:set HIMITSU="<あなたの秘密のメッセージ>" TENANT="<あなたのAuth0 テナント名>"

をして、Herokuに環境変数を設定します。

Postmanやcurlなどのツールでリクエストを送ってみて正常に動作したらバックエンドの構築は完了!!お疲れ様です。

フロントエンドの環境構築

サクッと作るためにAuth0のサンプルを持ってくるとします。

これをforkしてcloneしちゃいましょう。

upgrade-sdkブランチに切り替え、ざっとこんな感じに直してください。(URLはよしなに・・・)

デプロイ先はhttps://<GitHub ユーザ名>.github.io/auth0-vue-samples/ になるはず。

update · reud/auth0-vue-samples@b73103a

diffにも出ちゃってますが、デプロイに gh-pages を使用するので、

npm install gh-pages --save-dev

でインストールしちゃいましょう。

色々編集し終えたら、

npm run build からのgh-pages -d dist でアップロードされます。

アップロード後はhttps://<GitHub ユーザ名>.github.io/auth0-vue-samples/にアクセスして、動作するか確認してみましょう。 アクセス後、画面右上LOGINからユーザを作成して、ログインしてみます。

ログインしてこんな感じの画面になればおkです。(Vue.jsのロゴは読み込まれていませんが、今回は直さないまま進めちゃいます。)

image.png

大丈夫そうなら、上記ナビゲーションバーから秘密のページに移動して、Call APIをクリックして、バックエンドとの疎通を試みます。

image.png

こんな感じでうまくいけば完了です!

大学の関係者のみ見れる様にする

ここまででログイン時のみ見れる特別なページを作る事が出来ました。
しかし今のままでは大学の関係者だとか関係なく、メールアドレスを持っていれば、ページがみれてしまいます。

そこで、メールアドレスが大学のドメインでない場合は、ログインに失敗させる様にしましょう。

Auth0のダッシュボードにあるサイドバーからRulesを選択

image.png

大学のメールドメインでない場合は弾く様に設定

CREATE RULEを選ぶ。

image.png

テンプレート一覧が表示されるので、

Check if user email domain matches configured domain を選択。

そして中身をこんな感じに編集

function (user, context, callback) {

  const allowedDomains = new Set(['stu.hoge.ac.jp']);

  // Access allowed if domain is found
  const emailSplit = user.email.split('@');
  const userEmailDomain = emailSplit[emailSplit.length - 1].toLowerCase();
  if (allowedDomains.has(userEmailDomain)) return callback(null, user, context);

  return callback('Access denied you are'+userEmailDomain+" but, expected stu.hoge.ac.jp");
}

これはログイン時のメールアドレスが、stu.hoge.ac.jpでない場合はログイン失敗させるというRuleです。stu.hoge.ac.jpをいじって自分の大学のメールドメインに変更してください。

メールの確認されていない場合は弾く様に設定

デフォルトでは、メールアドレスを確認しなくてもそのままログインできてしまいます。

すなわち、サインアップ時にドメインだけ正しければ、適当なメールアドレスを入れてしまえば秘密のページが見れてしまう事になります。

これは流石によくないので、メールアドレスの確認を強制させましょう。 これもAuth0のRuleで上手くやってくれます。

先ほどと同じ様にCREATE RULEを選んで、Force email verificationを選択、今回は何も編集せずにSAVE CHANGESで大丈夫です。

動作を確認する。

(ログイン失敗時などはつどCookieを削除してみてください)

今回は以下の部分において手抜きをしているため、正常に動作しません。

  • ナビゲーションバーの推移先(URLの設定が上手くいっていないため)
  • ログイン・サインアップ失敗時(URLみるとログイン・サインアップ失敗理由が書いてありますが、Vue側は何もしていないので何も出てきません。また、その状態でLOGINボタンを再度押してもログイン失敗してしまうため、Cookieを手動で削除する必要があります。)

また、以下の部分は甘いです。

  • Heroku側のCORS設定(もっと絞り込む必要あり)
  • カスタムAPIを使用していない(ユーザ毎に権限を振り分けたい時は)(このままだとあまり認可っぽくない)
    • 今回はJWT検証サボりたいためこんな感じの実装にしていますが、ちゃんとしたサービス作るならカスタムAPIにすべきかと

もっと洗い出せば沢山出てきそうですが、とりあえずこの辺り。

所属大学の学生を対象としたサービスなどを作ろうと考えている際はこの辺りを気を付けつつこの記事を見れば役に立つのかもしれません。

以上です。ありがとうございました。

参考資料

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

GoogleのOpenIDを使ったログインの実装

概要

OpenIDについて調べたので、実際にGoogleのOpenIDを利用してのログインの実装方法を調べました。

採用技術

実装

クライアント

クライアントはVue.jsで作っていきます。Vue.jsについての説明は省略します。
今回はImplicit Flowでのログインを行いたいので、GoogleSignInを利用します。実装方法はこちらで説明されています。
ただ、今回Vue.jsを利用するので、そのまま利用はできませんでした。
概要は以下のようなものです。index.htmlの<div id="app"></div>にApp.jsが描画されると思ってください。

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <script src="https://apis.google.com/js/platform.js"></script>
    <title>app</title>
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

App.js

<template>
  <div>
    <div v-if="!signedIn" id="google-signin-button"></div>
    <a href="#" @click="signOut" v-if="signedIn">Sign out</a>
  </div>
</template>
<script>
export default {
  name: 'app',
  data() {
    return {
      signedIn: false
    }
  },
  mounted() {
    this.renderSignInButton();
  },
  methods: {
    renderSignInButton() {
      gapi.load("auth2", (signin2) => {
        gapi.auth2.init({
          client_id: 'YOUR_CLIENT_ID.apps.googleusercontent.com'
          scope: 'profile email',
          hosted_domain: 'YOUR_DOMAIN' // ドメインを限定したい場合
        });
        gapi.signin2.render('google-signin-button', {
          onsuccess: this.onSignIn,
        })
      });
    },
    onSignIn(googleUser) {
      this.signedIn = true;
    },
  }
}
</script>

GoogleSignInの実装サンプルではclass="g-signin2"としているところにボタンを描画してくれるんだと思います。ただ、App.jsの中身が描画されるのが間に合わないらしく、そのままではGoogleSignInボタンを表示してくれませんでした。なのでここを参考にmountedでボタンを描画してます。

API側実装

クライアントからAPIへリクエストするときはAuthorizationヘッダーでIDTokenを渡し、APIはそのIDTokenを検証することでリクエストの認証を行います。
以下では自分のプロフィール情報を取得するAPIを作成します。

クライアントからのリクエストはこんな感じ(apiはlocalhost:3000で起動しているものとします。)

var auth2 = gapi.auth2.getAuthInstance();
var idToken = auth2.currentUser.get().getAuthResponse().id_token;
fetch("http://localhost:3000/my/profile", {
  headers: {
    Authorization: `Bearer ${idToken}`
  }
}).then((res) => {
  return res.json();
}).then((json)=>{
  console.log(json);
});

API側ではとりあえずapplication_controllerに認証処理を記述します。

認証処理

class ApplicationController < ActionController::API
  before_action :verify_id_token

  def verify_id_token
    return false unless request.headers['Authorization'].present?

    # ①IDTokenを取り出してデコード
    id_token = request.headers['Authorization'].gsub(/Bearer /, '')
    decoded_token = JWT.decode id_token, nil, false

    # ②Googleから公開鍵情報を取得
    res = Faraday.get('https://www.googleapis.com/oauth2/v3/certs')
    keys = JSON.parse(res.body)['keys']
    key = keys.find { |item| item['kid'] == decoded_token[1]['kid'] }

    # ③公開鍵情報から公開鍵作成
    exponential = OpenSSL::BN.new(Base64.urlsafe_decode64(key['e']), 2)
    modulus = OpenSSL::BN.new(Base64.urlsafe_decode64(key['n']), 2)
    public_key = OpenSSL::PKey::RSA.new.set_key(modulus, exponential, nil).public_key

    # ④ruby-jwtでIDTokenを検証
    raise JWT::VerificationError if decoded_token[0]['hd'] != 'YOUR_DOMAIN'
    @id_token = JWT.decode id_token, public_key, true, aud: "YOUR_CLIENT_ID.apps.googleusercontent.com", iss: "accounts.google.com", verify_aud: true, verify_iss: true, algorithm: 'RS256'
  rescue JWT::DecodeError => exception
    # ログ出力などなど
  end
  def current_user
    return unless @id_token
    @current_user ||= User.find_or_create_by(google_user_id: @id_token[0]['sub'])
  end
  def authenticate!
    render status: :forbidden unless current_user.present?
  end
end

②Googleから公開鍵情報を取得

IDTokenを検証するための公開鍵を取得します。
公開鍵がどこにあるかというと、こちらで説明されています。以下のURLからOpenIDConnectの情報が取れるみたいです。

https://accounts.google.com/.well-known/openid-configuration

この/.well-known/openid-configurationですが、OpenIDConnectの仕様にも記載されているので、他のOpenIDプロバイダーを利用する際もこんなURLで公開されているんだと思います。

この情報から、公開鍵は以下のURLにあるとわかります。

https://www.googleapis.com/oauth2/v3/certs

④ruby-jwtでIDTokenを検証

基本的にはruby-jwtがいい感じで検証してくれます。
以下の3つは必ず検証するよう記載されていました。

  • iss(accounts.google.com)
  • aud(プロジェクトID)
  • exp

expは特にコード上書いていませんが、ruby-jwtがチェックしてくれるみたいです。
また、ドメインを限定したい場合は、hdにドメインが記載されているのでこちらもチェックすると良いと思います。

アクションに認証をかける

あとは必要なコントローラーで使うだけです。

app/controllers/my/profiles_controller.rb

class My::ProfilesController < ApplicationController
  before_action :authenticate!
  def show
    render json: current_user
  end
end

終わりに

上記コードは認証の概要を理解するためにかなり簡略なもので終わらせています。
公開鍵をキャッシュしたり、検証済みのIDTokenをキャッシュしておいたりなど改善点はいっぱいあるとは思います。
ですがとりあえずかなり便利そうだし、わりと簡単に使えるということはわかりました。

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

Vueでよく書くユニットテストのパターン

Vueのコンポーネントのユニットテストでよく書くパターンを紹介します。
環境はvue-cliで生成したものをそのまま使っています。

APIの詳細な説明はVue Test Utilsを参照してください。

スナップショットテスト

出力されるHTMLが予期せず変更されないようにする場合に使うテストです。

GachaMv.spec.ts
it("itemsの値がhtmlに出力されているか?", () => {
  const items = [
    new Item("アイテム", 3, "アイテム説明")
  ];
  const wrapper = shallowMount(GachaMv, {
    propsData: { items }
  });
  expect(wrapper.html()).toMatchSnapshot();
});

上記のテストを追加しnpm run test:unit or yarn test:unitを実行するとテストファイルと同階層に__snapshots__/GachaMv.spec.ts.snapが出力されます。

GachaMv.spec.ts.snap
exports[`GachaMv.vue itemsの値がhtmlに出力されているか? 1`] = `
<div class="gacha-mv">
  <slick-stub options="[object Object]">
    <div class="gacha-mv-list">
      <p class="gacha-mv-list-name">アイテム</p>
      <p class="gacha-mv-list-rare">3</p>
      <p class="gacha-mv-list-description">アイテム説明</p>
    </div>
  </slick-stub>
</div>
`;

shallowMountではなくmountを使用した場合は、以下のように子コンポーネントも展開した状態で出力されます。

GachaMv.spec.ts.snap
exports[`GachaMv.vue itemsの値がhtmlに出力されているか? 1`] = `
<div class="gacha-mv">
  <div class="slick-initialized slick-slider">
    <div class="slick-list draggable">
      <div class="slick-track" style="opacity: 1; width: 0px; transform: translate(0px, 0px);">
        <div class="slick-slide slick-current slick-active" data-slick-index="0" aria-hidden="false" style="margin-left: 0px; width: 0px;">
          <div>
            <div class="gacha-mv-list" style="width: 100%; display: inline-block;">
              <p class="gacha-mv-list-name">アイテム名</p>
              <p class="gacha-mv-list-rare">3</p>
              <p class="gacha-mv-list-description">アイテム説明</p>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>
`;

以下のようにshallowMountとstubsを組み合わせることで、一部のコンポーネントのみスタブすることも可能です。

GachaMv.spec.ts.snap
const wrapper = shallowMount(GachaMv, {
  stubs: {
    'slick': Slick,
  },
}

computedが期待した値を返しているか?

computedが正しい値を確認しているかのテストです。

GachaMv.spec.ts
it("pickupItemが期待通りか?", async() => {
  const items = [
    new Item("アイテム名3", 3, "アイテム説明3"),
    new Item("アイテム名4", 4, "アイテム説明4"),
    new Item("アイテム名5", 5, "アイテム説明5"),
  ];
  const wrapper = shallowMount(GachaMv, {
    propsData: { items }
  });
  expect((wrapper.vm as any).pickupItems).toEqual([new Item("アイテム名5", 5, "アイテム説明5")]);
});

クリックイベントが動作しているか?

要素がイベントを発火した時に、定義した関数が実行されているかのテストです。
スタブにsinonを使ってます。

GachaPlay.spec.ts
it(".gacha-playクリック時にonPlayGachaが実行されるか?", () => {
  const onPlayGachaStub = sinon.stub();
  const wrapper = shallowMount(GachaPlay, {
    propsData: { 
      number: 1,
      onPlayGacha: onPlayGachaStub
    }
  });
  wrapper.find(".gacha-play").trigger('click');
  expect(onPlayGachaStub.called).toBe(true);
  onPlayGachaStub.restore();
});

sinon or jest.fn

先ほどのクリックイベントのテストでsinonを使いましたが、jest.fnでも同じことができます。
個人的は使い慣れているsinonを使用しています。

unit testing - stubbing a function using jest - Stack Overflow

非同期のテスト

非同期テストでは、flush-promisesやVue.nextTickを使わないと期待した結果が得られません。

flush-promisesについて

flush-promises

保留中の解決済みの約束のハンドラをすべてフラッシュします。

やっていることはmicrotasks または macrotasksのプロミスを返しているだけです。(setImmediate関数がある場合はmicrotasksが選択されるので、これがDOMの更新を待たなかった原因かもしれません。)

microtasks、macrotasksについは以下の記事が分かりやすかったです。

Tasks, microtasks, queues and schedules - JakeArchibald.com

Vue.nextTickについて

Vue.nextTick

callbackを延期し、DOMの更新サイクル後に実行します。DOM更新を待ち受けるために、いくつかのデータを変更した直後に使用してください。

テスト記述例

VueのDOMの更新後の値をテストする場合はnextTick、非同期処理(Promiseをfulfilled)する場合はflushPromisesを使用します。

it('test', async() => {
  const wrapper = shallowMount(Foo);
  await flushPromises(); // マウント時などに非同期処理がある場合
  expect(wrapper.vm.text).toBe('マウント時のテキスト');
  // expect(wrapper.find('text').text()).toBe('マウント時のテキスト'); // DOMが更新される保証はないので、Errorになるかも

  wrapper.find('button').trigger('click'); // クリックイベントで非同期処理を実行

  await wrapper.vm.$nextTick(); // DOMの更新を保証
  expect(wrapper.find('text').text()).toBe('クリック後のテキスト');
})

さいごに

簡単なコンポーネントのテストであれば、これらの組み合わせで十分かと思います。
今回のユニットテストではUIの崩れなど検知できないので、以下のリンク先で紹介されているようなstorybookやvisual regressionテストなどの導入を検討してみてください。

Storybookとvue-i18nで多言語確認を容易にしよう - スタディスト開発ブログ - Medium

Storybookとreg-suitで気軽にはじめるVisual Regression Testing - wadackel.me

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

Vue.jsのv-if,v-show 要素の表示非表示

要素の表示非表示を切り替える

js

var app = new Vue({
  el: '#app',
  data: {
    toggle: true
  }
})

html

<div id='app'>
  <p v-if=toggle>
  Hello
  </p>
</div>

<script src="https://cdn.jsdelivr.net/npm/vue@2.5.16"></script>

表示結果

image.png

jsのtoggleをfalseにすると非表示になる。

var app = new Vue({
  el: '#app',
  data: {
    toggle: false
  }
})

v-showでfalseの場合はcssでdisplay:noneとなる。
v-ifの場合はdomレベルで削除となる。

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

属性のデータバインド v-bind テキストボックスに値を表示する。

javascript

var app = new Vue({
  el: '#app',
  data: {
    message: 'hello world'
  }
})

html

<div id='app'>
  <input type="text" v-bind:value="message">
</div>

<script src="https://cdn.jsdelivr.net/npm/vue@2.5.16"></script>

表示結果

image.png

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