20191223のvue.jsに関する記事は25件です。

【Vue.js】エラーの表示を簡単に実装してみる ~ vue-toasted ~

概要

本記事では、Vue.jsで使える便利なNotificationライブラリである vue-toastedについてまとめていきます。

vue-toasted って?

vue-toasted とは、概要にも記述した通り、Vue.jsで使えるNotificationライブラリです。
実装自体も簡単で、3分あれば実装できてしまうとても便利なものです。
トーストでお知らせを表示させたい時にぜひ使ってみてください!

公式ページ : https://www.npmjs.com/package/vue-toasted

環境

Vue.js 2.9.6

インストール

yarn、npm、CDNののいずれかでインストールしてください。

yarn

yarn add vue-toasted

npm

npm install vue-toasted --save

CDN

index.html
<script src="https://unpkg.com/vue-toasted"></script>

実装方法

  1. vue-toastedを読み込む
main.js
import Vue from 'vue'
import App from './App.vue'
import Toasted from 'vue-toasted';

Vue.use(Toasted);

new Vue({
  render: h => h(App)
}).$mount('#app')

※CDNの場合はimportの記述は必要ありません。

2.Vueコンポーネントで呼び出す

App.vue
<template>
  <div id="app">
    <button @click="btnClick">クリック</button>
  </div>
</template>

<script>
export default {
  methods: {
    btnClick() {
      this.errToast("エラーです!");
    },
    errToast:function(msg){
      // main.jsで読み込んだので this.$toasted で呼び出せる
      this.$toasted.error(msg);
    },
  }
}
</script>

<style>
.toasted-container .toasted {
  /* スタイルを修正することもできます */
}
</style>

これで実装は完了です。
今回はエラーの表示の時のトーストを実装したため、赤色のトーストが表示されたと思います。
エラー以外にも、オプションを設定すれば様々なトーストを実装することができます。

応用編

オプション

index.js
import Vue from 'vue'
import App from './App.vue'
import Toasted from 'vue-toasted';

// オプション設定
// 今回はポジション・表示されてから消えるまでの時間・横幅についてのオプション追加
var options = {
  position: 'top-center',
  duration: 2000,
  fullWidth: true,
}

Vue.use(Toasted, options);

new Vue({
  render: h => h(App)
}).$mount('#app')

このようにオプションを渡してあげることによって、思い通りのトーストを簡単に実装することができます。
下記URLから、ぽちぽち操作して目で見ながら理想のトーストを作っていくこともできるのでおすすめです!
https://shakee93.github.io/vue-toasted/

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

Atomのgit-controlでPushができないと思ったら、pre-commitでeslintにより弾かれていた件

これはTOWN Advent Calendar 2019 23日目のエントリーです。

Atomのgit-controlプラグインを使ってGitの操作をしているのですが、コミットはできるもののプッシュができない(ボタンが選択可能にならない)現象が発生しました。
(よくよく見ると画面の下の方にエラーが出ていたのですが最初気がつかづ。。。)

こういうときには面倒ですがTerminalからコマンドを直接入力して確認をしてみます。

% git commit
husky > pre-commit (node v13.5.0)
  ✔ Stashing changes...
  ❯ Running linters...
    ❯ Running tasks for *.{js,vue}
      ✖ eslint
  ↓ Updating stash... [skipped]
    → Skipping stash update since some tasks exited with errors
  ✔ Restoring local changes...



✖ eslint found some errors. Please fix them and try committing again.

/project/plugins/firestore.js
  1:28  error  Delete `;`  prettier/prettier
  2:42  error  Delete `;`  prettier/prettier
  4:32  error  Delete `;`  prettier/prettier
  7:18  error  Delete `;`  prettier/prettier

✖ 4 problems (4 errors, 0 warnings)
  4 errors and 0 warnings potentially fixable with the `--fix` option.

husky > pre-commit hook failed (add --no-verify to bypass)

どうやらpre-commit時にeslintが効いてコミットが弾かれている、ということがわかります。

該当するファイルを修正して再度コミットすることでPushができるようになりました。

スクリーンショット 2019-12-23 22.57.49.png

git-controlは画面上のウィンドウサイズをを変更できないので長いログが出たときに見落としがちなので注意です。

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

Vue.jsとaxiosでAPIを叩く ( GET, POST, PUT, DELETE )

概要

本記事では、axoisを用いたAPIリクエストの基本的なことについてまとめていきます。
※Vuexについては記述しません。

環境変数

認証情報は.envファイルに記述し、認証情報が必要な場合はこちらから読み込ませて利用します。

.env
TOKEN='取得したトークン'
BASE_URL = 'リクエスト先のURL'

GET リクエスト

index.js
import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'

Vue.use(Vuex)

// .envファイルから環境変数(トークン・URLを読み込ませる)
var TOKEN = process.env.TOKEN
var BASE_URL = process.env.BASE_URL

export default new Vuex.Store({
  actions: {
    get_event(context,param) {
      return axios.get(BASE_URL + param, {
        headers: {
          "Content-Type": "application/json",
          "Authorization": 'Bearer ' + TOKEN
        },
        responseType: 'json',
      })
      .then(() => {
        // レスポンスが200の時の処理
        console.log("取れたよ")
      })
      .catch(err => {
        if(err.response) {
          // レスポンスが200以外の時の処理
        }
      });
    }
  }
})

 
渡すパラメータが複数の場合には、一つのオブジェクトにまとめて記述する必要があります。

index.js
import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'

Vue.use(Vuex)

var TOKEN = process.env.TOKEN
var BASE_URL = process.env.BASE_URL

export default new Vuex.Store({
  actions: {
    get_event(context,{ param, id }) {
      return axios.get(BASE_URL + param + id, {
        headers: {
          "Content-Type": "application/json",
          "Authorization": 'Bearer ' + TOKEN
        },
        responseType: 'json',
      })
      .then(() => {
        // レスポンスが200以外の時の処理
        console.log("取れたよ")
      })
      .catch(err => {
        if(err.response) {
          // レスポンスが200以外の時の処理
        }
      });
    }
  }
})

POST リクエスト

hoge: hogeとしているところで、引数で渡ってきたデータをRequest payloadとしてサーバー側に送信することができます。

index.js
import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'

Vue.use(Vuex)

var TOKEN = process.env.TOKEN
var BASE_URL = process.env.BASE_URL

export default new Vuex.Store({
  actions: {
    get_checkin_statuses(context, {param, hoge}) {
      return axios.post(BASE_URL + param, {
        hoge: hoge,
      }, {
        headers: {
          "Content-Type": "application/json",
          "Authorization": 'Bearer ' + TOKEN
        }
      })
      .then(() => {
        // レスポンスが200の時の処理
        console.log("送れたよ")
      })
      .catch(err => {
        if(err.response) {
          // レスポンスが200以外の時の処理
        }
      });
    }
  }
})

PUT リクエスト

hoge: hogeとしているところで、引数で渡ってきたデータをRequest payloadとしてサーバー側に送信することができます。

index.js
import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'

Vue.use(Vuex)

var TOKEN = process.env.TOKEN
var BASE_URL = process.env.BASE_URL

export default new Vuex.Store({
  actions: {
    async postCode(context,{ param, hoge }) {
      await axios.put(BASE_URL + param, {
        hoge: hoge,
      }), {
        headers: {
          "Content-Type": "application/json",
          "Authorization": 'Bearer ' + TOKEN
        }
      })
      .then(res => {
        // レスポンスが200の時の処理
        console.log("更新したよ")
      })
      .catch(err => {
        if(err.response) {
          // レスポンスが200以外の時の処理
        }
      });
    }
  }
})

DELETEリクエスト

index.js
import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'

Vue.use(Vuex)

var TOKEN = process.env.TOKEN
var BASE_URL = process.env.BASE_URL

export default new Vuex.Store({
  actions: {
    async delCheckin(context, param) {
      await axios.delete(BASE_URL + param, {
        headers: {
          "Content-Type": "application/json",
          "Authorization": 'Bearer ' + TOKEN
        }
      })
      .then(res => {
        // レスポンスが200の時の処理
        console.log("消しといたよ")
      })
      .catch(err => {
        if(err.response) {
          // レスポンスが200以外の時の処理
        }
      });
    },
  }
})
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

.preventで画面遷移をさせない ❏Vue.js❏

リンクがあるけど、画面遷移をさせたくない時を考えます。
そんな時あんのか

Screenshot from Gyazo

開発環境はJSFiddle
https://qiita.com/ITmanbow/items/9ae48d37aa5b847f1b3b

html
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<div id="app">
  <a @click.prevent href="https://google.com">google</a>
</div>

preventDefaultを仕込むと画面遷移を無効にできます。
本来はJS側でevent.preventDefault()と記述します。

しかし、Vue.jsでは、html側で@click.preventと繋げることで簡単に実装できます。



ではまた!

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

初心者によるプログラミング学習ログ 192日目

100日チャレンジの192日目

twitterの100日チャレンジ#タグ、#100DaysOfCode実施中です。
すでに100日超えましたが、継続。

100日チャレンジは、ぱぺまぺの中ではプログラミングに限らず継続学習のために使っています。

192日目は

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

【環境構築】Docker + Rails6 + Vue.js + Vuetifyの環境構築手順

はじめに

Docker + Rails6 + Vue.js + Vuetifyの開発環境構築手順をまとめました。

以下の記事を参考にさせて頂きました!ありがとうございます:bow_tone1:

環境

OS: macOS Catalina 10.15.1
zsh: 5.7.1
Ruby: 2.6.5
Rails: 6.0.2
Docker: 19.03.5
docker-compose: 1.24.1
Vue: 2.6.10
vue-router: 2.6.10
vuex: 3.1.2
vuetify: 2.1.0

1.準備

作成するアプリケーション名はhogeappとします。
まずは以下ファイルを作成して下さい。

Dockerfile

yarnが必要になるので、Dockerfileに反映しています。

hogeapp/Dockerfile
FROM ruby:2.6.5

RUN apt-get update -qq && \
  apt-get install -y build-essential \
  libpq-dev \
  nodejs \
  vim

RUN apt-get update && apt-get install -y curl apt-transport-https wget && \
  curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
  echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \
  apt-get update && apt-get install -y yarn

RUN mkdir /app_name
ENV APP_ROOT /app_name
WORKDIR $APP_ROOT

COPY ./Gemfile $APP_ROOT/Gemfile
COPY ./Gemfile.lock $APP_ROOT/Gemfile.lock
RUN bundle install

COPY entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT [ "entrypoint.sh" ]
ADD . $APP_ROOT

docker-compose.yml

hogeapp/docker-compose.yml
version: "3"
services:
  db:
    image: mysql:5.7
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: root
    volumes:
      - db-data:/var/lib/mysql
    ports:
      - "3306:3306"
  web:
    build: .
    command: /bin/sh -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/app_name
    ports:
      - "3000:3000"
    links:
      - db
    tty: true
    stdin_open: true
    depends_on:
      - db
  data:
    image: busybox
    volumes:
      - db-data:/var/lib/mysql
    tty: true
volumes:
  db-data:
    driver: local

Gemfile

hogeapp/Gemfile
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

gem 'rails', '~> 6.0.2', '>= 6.0.2.1'

Gemfile.lock

中身は空で作成します。

hogeapp/Gemfile.lock

entrypoint.sh

hogeapp/entrypoint.sh
#!/bin/bash
set -e

# Remove a potentially pre-existing server.pid for Rails.
rm -f /app_name/tmp/pids/server.pid

# Then exec the container's main process (what's set as CMD in the Dockerfile).
exec "$@"

これで必要なファイルが揃ったので、次はRailsアプリの作成です。

2.Railsアプリの作成

rails new

$ docker-compose run web rails new . --force --database=mysql --skip-bundle

※このエラーが出たら

Error response from daemon: OCI runtime create failed: container_linux.go:346: starting container process caused "exec: \"rails\": executable file not found in $PATH": unknown

$ docker-compose build

※何らかのgemが不足しているようなエラーが出たら

自分は以下のようなエラーが発生しました。

Could not find public_suffix-4.0.1 in any of the sources

$ docker-compose run web bundle install

一度bundle installを試してみて下さい。

database.ymlの変更

config/database.yml
default: &default
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password: password #ここを変更
  host: db #ここを変更

コンテナが立ち上がるか確認

docker-compose up -dだとログが見えないので、無事に環境構築が完了するまでは-dなしが良いと思います。

$ docker-compose up

db:create

無事にコンテナが立ち上がったら、DBを作成しましょう。

$ docker-compose exec web rails db:create

Yay! You’re on Rails!

localhost:3000にアクセスして確認してみましょう。

image.png

これでRailsはOKなので、次はVue.jsです!

3.Vue.jsの導入

webpackerのインストール

$ docker-compose exec web rails webpacker:install

Vue.jsのインストール

$ docker-compose exec web rails webpacker:install:vue

Vue.jsとの連携を確認

Railsでコントローラーを作ってみて、Vue.jsと連携出来るかを確認してみます。

$ docker-compose exec web rails g controller home index
app/views/home/index.html.erb
<%= javascript_pack_tag 'hello_vue' %>
<%= stylesheet_pack_tag 'hello_vue' %>

このように変更し、hello_vue.jsを読み込みます。

※Vue.jsのインストール時にhello_vue.jsはデフォルトで作成されています。

Railsのルーティングを設定

config/routes.rb
root to: 'home#index'

ブラウザで確認

localhost:3000にアクセスし、下記画面が出力されているか確認してみて下さい。

image.png

これでVue.jsの導入はOKですが、他の単一ファイルコンポーネントも作成し、読み込めるかどうかを確認しておきます。

4.他の単一ファイルコンポーネントが読み込めるかどうか確認する

Top.vueの作成

app/javascript/components/ディレクトリを作成し、その中にTop.vueを作成します。

Top.vueの中身は以下のようにしました。

app/javascript/components/Top.vue
<template>
  <section id="top">
    <h1>This is Top.vue!</h1>
  </section>
</template>

<script>
  export default {
    name: 'Top'
  }
</script>

<style>
  h1 {
    text-align: center;
  }
</style>

app.vueを変更

app/javascript/app.vue
<template>
  <div id="app">
    <p>{{ message }}</p>
    <Top/> //追記
  </div>
</template>

<script>
import Top from "./components/Top"; //追記

export default {
  data: function () {
    return {
      message: "Hello Vue!"
    }
  },
  components: {
    Top, //追記
  }
}
</script>
...以下略

ブラウザで確認

再度読み込みすると、以下のような画面になっているはずです。

image.png

これできちんと単一ファイルコンポーネントが読み込まれていることが確認できたので、Vue.jsはOKです。

次はラスト!Vuetifyの導入です。

5.Vuetifyの導入

Vuetifyのインストール

$ docker-compose exec web yarn add vuetify -D

hello_vue.jsに追記

hello_vue.js
import Vue from 'vue'
import Vuetify from 'vuetify' //追加
import "vuetify/dist/vuetify.min.css" //追加
import App from '../app.vue'

Vue.use(Vuetify) //追加

const vuetify = new Vuetify(); //追加

document.addEventListener('DOMContentLoaded', () => {
  const app = new Vue({
    vuetify, //追加
    render: h => h(App)
  }).$mount()
  document.body.appendChild(app.$el)

  console.log(app)
})

application.html.erbの変更

application.html.erbでVuetify用にフォントとアイコンの読み込みと不要な箇所の削除を行います。

app/views/layouts/application.html.erb
  <head>
...略
    <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet">
    <link href="https://cdn.jsdelivr.net/npm/@mdi/font@4.x/css/materialdesignicons.min.css" rel="stylesheet">
...略
  </head>

下記タグは不要なので削除しておきます。

app/views/layouts/application.html.erb
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>

これで準備が整ったので、あとはVuetifyのコンポーネントが反映されるかどうか、作成して確認してみましょう。

Header.vueの作成

新規にHeader.vueを作成し、<v-app-bar>を組み込んでみます。

app/javascript/components/Header.vue
<template>
  <header id="header">
    <v-app-bar>
      <v-app-bar-nav-icon></v-app-bar-nav-icon>
      <v-toolbar-title>This is Header.vue</v-toolbar-title>
    </v-app-bar>
  </header>
</template>

<script>
  export default {
    name: 'Header',
  }
</script>

app.vueを変更

Header.vueを読み込みつつ、全体をまとめている<div><v-app>タグに変更しておきます。

app/javascript/app.vue
<template>
  <v-app id="app"> //divから変更
    <Header/> //追記
    <h1>This is app.vue</h1> //一応追記。どっちでもいいです
    <p>{{ message }}</p>
    <Top/>
  </v-app>
</template>

<script>
import Header from "./components/Header"; //追記
import Top from "./components/Top";

export default {
  data: function () {
    return {
      message: "Hello Vue!"
    }
  },
  components: {
    Header, //追記
    Top,
  }
}
</script>

これで再度localhost:3000にアクセスし、問題ないかを確認してみます。

image.png

無事に画像のようなヘッダーが表示されていれば完了です!

以上です!お疲れ様でした!:clap:

おわりに

最後まで読んで頂きありがとうございました:bow_tone1:

どなたかのお役に立てれば幸いです:relaxed:

参考にさせて頂いたサイト(いつもありがとうございます)

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

お試し投稿

qiitaに投稿してみる

hogehoge

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

new.Vue を export default に書き換える

タイトルの通り、new.Vueexport default に書き換えるときの備忘録です。

例えばこういうのがあれば↓

new Vue({
  el: "#app",
  data: () => ({
    /* data */
  },
  methods: {
    /* methods */
  }
})

export default を使うとこんなふうになります。

export default {
  name: 'App',
  data: () => ({
    /* data */
  },
  methods: {
    /* methods */
  }
}

これがうまくわからず手こずっていました :bow:

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

Vue CLIでconsole.logを有効化する方法

はじめに

Vue CLI(ESLint)のデフォルト設定では、console.logがエラーを吐く仕様になっています。
でも簡単なデバッグなどでは手軽に使えたら嬉しいですよね。
そこで、Vue CLIでconsole.logを有効にする方法をメモしておきます。
非常に簡単なので、お困りの方がいらしたらぜひお試しください。

環境

Vue CLIバージョン:3.9.3

1. 永続的に有効化したい場合

■変更対象ファイル(src/package.json)
eslintConfig内のrules"no-console": 0を設定

package.json
"eslintConfig": {
    "root": true,
    "env": {
      "node": true
    },
    "extends": [
      "plugin:vue/essential",
      "eslint:recommended"
    ],
    "rules": {
      "no-console": 0
    },
    "parserOptions": {
      "parser": "babel-eslint"
    }
  }

2. 一時的に有効化したい場合

console.logを使いたいファイルに、以下のコメントを追加

/* eslint-disable no-console */

.jsファイルであればファイルの一番上に、
.vueファイルであればscriptタグ内の一番上に記述すれば有効化されます!

他にも色々と設定方法が存在しますが、この2つの方法だけでも特に開発作業に問題はないかと思います。
以上です!!

ぺーぺーなので、間違いなどあったら優しく教えていただけると泣いて喜びます( ;∀;)

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

vue.js redis 足跡機能を作ろうぜ

足跡機能を作りたい。

参考
https://qiita.com/maip0902/items/eafc35ed762648ddadbf

基礎知識。
来訪ユーザーが重複した場合には、削除され、新しく追加される。
よって、重複して登録されることは無い。

Okwscontroller.php
use Illuminate\Support\Facades\Redis;//忘れずに。

public static function getAshiato(request $request)
{
    $res = Redis::zrevrange('ashiato:'.$request->home_user_id,0,-1);//全員分の足跡をタイムスタンプが新しい順に取得
    return response()->json(['res'=> $res]);
}

public static function addAshiato(request $request)
{
    $res = true;
    Redis::zadd('ashiato:'.$request->home_user_id, time(),$request->user_id);//どのユーザーのページに、いつ訪問したか、来訪したユーザーを追加
    return response()->json(['res'=> $res]);
}


Ashiato.vue

created() {

    this.addAshiato();
    this.getAshiato();

},


methods: {


//足跡を追加(例えばユーザーページに入れる)
addAshiato(){

    let dataform = new FormData();

    dataform.append('home_user_id',100);
    dataform.append('user_id',333);

    axios.post('/okws/addAshiato/', dataform).then(e => {
        console.log(e.data.res);
        console.log("登録成功");
    }).catch((error) => {
        console.log("エラー");
    });

},

//足跡を読み込み
getAshiato(){

    let dataform = new FormData();
    dataform.append('home_user_id',100);
    axios.post('/okws/getAshiato/', dataform).then(e => {
        console.log(e.data.res);
        console.log("取得成功");
    }).catch((error) => {
        console.log("エラー");
    });

}



}


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

【Vuex】storeの複雑化を抑えた長期運用可能なwebアプリのための方策草案

検討中の内容をまとめた。
storeの記述は可能な限りシンプルに、複雑さはコンポーネントに押し込める事を基本とする。

モチベ

処理をstoreに寄せるとstoreが膨らむ。どのviewに影響するか分からない処理で膨らんだstoreはリーディングコストが高い。
複雑化したコンポーネントを捨てるのは影響範囲が限定されるが、複雑化したstoreはアプリ全体に影響を及ぼすため安易に改修出来ない。
手が入れ辛い方から複雑性を排除したい。

思考実験段階のため、実情に即して随時修正加筆を行う。

stateのコンポーネントでの利用単位ごとにgettersを作成する

mapStateは用いない
全てmapGettersで行う

gettersは抽出のみとする 

抽出以上の表示用データ整形はコンポーネント側で行う
コンポーネントとの密結合を避ける目的

store moduleは独立させる 

module間の伝達は行わない
複数moduleにまたがるstore管理は人類には容易でない

ひとつのmutationsはひとつのstateを更新するのみとする

複数のstateを更新しない
mutations内でデータ加工を行わない
コンポーネントからmutationsを直接叩かない 必ずactionsを経由させる

mutationsひとつに対しactionsひとつを基本とする

コンポーネントから叩いてよい
必要であれば非同期処理を書いてよい
複数mutationsをcommitしない

actionsに複雑な処理は書かない

事情により必要な場合はstore外に切り出した関数をactions内で呼び出す形とする
actionsでのデータ整形は最小限にとどめる
 

payloadのデータ整形はコンポーネントで行う

 2つ以上のコンポーネントで共通して用いるデータ整形があればstore側に置いてもよい
 ただしstoreの外に定義し呼び出す形とする
 ※store/module/helpers.js等が考えられる

※本記事でいうstore外に定義とは以下のsomeHelperのような形を指す
actionsやgettersとは独立して定義する

js
// helpers.js
const someHelper = () = {
  // ... some functions
  return someData
}

// store.js
import { someHelper } from './helpers.js'

const state = {}
const getters = {}
const mutations = {}
const actions = {}

export default {
  state,
  getters,
  mutations,
  actions
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

弊社アドベントカレンダーで一番いいね!を獲得したユーザは誰か?

この記事は

FORK Advent Calendar 2019 の 23日目の記事です。
Vue.js (Vue CLI with TypeScript) と Qiita API で Oraganization ビューアを作ったお話です。

モチベーション

ふと先日「アドベントカレンダーでいちばん多くいいね!を獲得したユーザに、サンタが何かをプレゼントしてくれれば、良質な記事が社内のエンジニアたちからわしゃわしゃ集まる」ことに気が付いてしまいました。そこで Qiita APIVue CLI を使って、弊社の Qiita メンバーの月毎の投稿記事の「いいね!」数を表示する Qiita ビューアを作りました。

おことわり

  • 突貫で開発を行ったため、アプリの挙動が若干あやしい(言い訳)
  • 一般公開すると Qiita API のリクエスト制限数(後述)を超える可能性がある(多分ないけど)

以上の理由から完成版の URL は開示しておりません。
開発のポイントと併せて、サンプルのコードを記載しておりますので、なんとなく実装の内容をお伝えできれば幸いです。

完成したアプリのキャプチャ

こんな感じのアプリです。本日までのランキングでは @yoh_zzzz さんの Nuxt + Firebase の記事がトップですね。
スクリーンショット 2019-12-23 10.37.29.png

開発のポイント

開発の要所を 3 分間クッキング :cooking: でお届けします。

1. Qiita API のインターフェースを定義する

開発は TypeScript で行います。(しれっと言い切るスタイル)
ブラウザで API を叩いたときのレスポンスや、JSON Schema を確認しながら、API のインターフェースを定義します。型定義ファイルはどこに配置しても良いのですが、僕は Vuex のモジュールディレクトリに用意しました。余談ですが、個人的にこの作業がとても楽しく、不安になります。

▼ ユーザ周りインターフェース

/src/store/user/types.ts
export interface QiitaUser {
  description: string | null
  facebook_id: string | null
  followees_count: number
  followers_count: number
  github_login_name: string | null
  id: string
  items_count: number
  linkedin_id: string | null
  location: string | null
  name: string | null
  organization: string | null
  permanent_id: number
  profile_image_url: string
  team_only: boolean
  twitter_screen_name: string | null
  website_url: string | null
}

export interface UserState {
  list: QiitaUser[]
}

開くとソースコードが表示されます。:point_down:

投稿記事周りインターフェース
/src/store/post/types.ts
export interface QiitaGroup {
  created_at: string
  id: number
  name: string
  private: boolean
  updated_at: string
  url_name: string
}

export interface QiitaTag {
  name: string
  versions: string[]
}

export interface QiitaPost {
  rendered_body: string
  body: string
  coediting: boolean
  comments_count: number
  created_at: string
  group: QiitaGroup | null
  id: string
  likes_count: number
  private: boolean
  reactions_count: number
  tags: QiitaTag[]
  title: string
  updated_at: string
  url: string
  user: QiitaUser
  page_views_count: number | null
}

export interface PostState {
  list: QiitaPost[]
}

2. API 呼び出し時にリクエストトークンを含める

Qiita API は get リクエストの場合はトークンなしでも呼び出しが可能なのですが、トークンの有り/無しで 利用制限 (1 時間あたりに呼び出しが可能な回数)に大きな差があります。60 回 / 1 時間以上のリクエストが必要な場合はトークンが必要にります。トークンの取得は OAuthを利用した認可フローか、ユーザの管理画面 から発行できます。今回は時間もなかったため後者を選択しました。
以下は、Vuex store モジュールの action として定義した、投稿記事取得 API のリクエストです。

投稿記事取得 action
/src/store/post/actions.ts
const actions: ActionTree<PostState, RootState> = {
  async fetchPosts(context: ActionContext<PostState, RootState>, payload: { userId: string, page?: number, perPage?: number }): Promise<QiitaPost[]> {
    return new Promise(async (resolve, reject) => {
      const url: string = `https://qiita.com/api/v2/users/${payload.userId}/items?page=${payload.page}&per_page=${payload.perPage}`
      const accessToken: string = 'XXXXXXXXX'
      const config: AxiosRequestConfig = {
        params: { time: Math.floor(Date.now() / 1000 / 60 / 60) }, // キャッシュ対策(1時間毎に異なる文字列を設定)
        headers: { Authorization: `Bearer ${accessToken}` }, // リクエストヘッダにトークンを含める
      }
      const response: AxiosResponse<QiitaPost[]> = await axios.get<QiitaPost[]>(url, config)
      if (response.status === 200) {
        const posts: QiitaPost[] = response.data
        if (posts.length) context.commit('add', posts)
        resolve(posts)
      } else {
        reject(response.status)
      }
    })
  },
}
export default actions

3. リクエスト回数を最小限にする

一意の Ogranization に所属している Qiita ユーザを取得する API が用意されていないため、弊社の Organization 所属ユーザは、ハードコーディングで用意した静的 JSON ファイルにユーザ ID を列挙することにしました。ユーザ情報は、/api/v2/users/:user_id から取得できるのですが、この API から各ユーザの投稿記事の合計いいね!数は取得することはできず、すべての投稿記事から各々のいいね!数を合算する必要があります。また投稿記事一覧を取得する API (/api/v2/users/:user_id/items) の戻り値に、記事のユーザ情報がそのまま含まれているため、全所属ユーザの記事一覧を取得し、そこからユーザリストを抽出する Vuex の getters 関数を用意しました。

ユーザリスト取得 getters
/src/store/post/getters.ts
export const getters: GetterTree<PostState, RootState> = {
  getUsers(state: PostState): QiitaUser[] {
    const users: QiitaUser[] = []
    state.list.forEach((post: QiitaPost) => {
      const user: QiitaUser = post.user
      if (!users.some((usr: QiitaUser): boolean => usr.id === user.id)) {
        users.push(user)
      }
    })
    return users
  },
}

export default getters

なお記事取得 API では、1 回のリクエストで最大 100 件の投稿記事を取得することができます。投稿記事数が 100 件を超えるユーザについては、未取得の残りの記事を追加でリクエストするようにしました。
一連の流れを整理すると以下になります。

  1. Organization 所属のユーザ ID を自分で用意した JSON から読み込み
  2. 全ユーザの投稿記事を 100 件ずつ API から取得 (Vuex action)
  3. 取得した投稿記事からユーザー情報を取得 (Vuex getters)
  4. 投稿記事が 100 件を超えるユーザについて、残りの記事を取得 (Vuex action)

おわりに

駆け足での解説となりましたが、ざっくりと要所の雰囲気だけでも伝わったでしょうか。
おそらく今年最後の投稿になるであろう本記事にて Vue のことを書きましたが、実際の仕事でも 2019 年は Vue が活躍してくれました。来年は Vue 3.0 と React を学ぼうと思ってます。

それではみなさま良い年末年始をお過ごしください。:sunrise:


:christmas_tree: FORK Advent Calendar 2019
:arrow_left: 22日目 正規表現にマッチした文字列を replace を使わずにハイライトさせる @yshrkn
:arrow_right: 24日目 Nuxt.jsとmysqlを連携してデータを表示してみた @ktn

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

json からデータを検索、取得し computed にて反映

vue.js で設定ファイルの json に書く

config.json

  "work":[
    {"key":0,"value":"マッチング後にまずは会いたい"},
    {"key":1,"value":"気が合えば会いたい"},
    {"key":2,"value":"条件が合えば会いたい"},
    {"key":3,"value":"メッセージを重ねて"},
    {"key":4,"value":"その他"}
  ],

jsonを読み込む

hoge.vue
import configJson from '../assets/config.json'

export default {

    data () {
        return {
            configJson:configJson,

対応したデータを取得

computed: {

    work() {
        const key = this.user.work;//2。
        const computed_tmp = this.configJson.work.find((v) => v.key === key);
        return computed_tmp.value;//条件が合えば会いたい
    }

んで取得したデータを表示

{{work}}//条件が合えば会いたい

と表示される。
まぁ、これがベストなんじゃないでしょうか。

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

vue.js NAN になるのを防ぐ

どう NAN ?
数値を入れるところに一瞬 NAN と表示されるのは。
ちょっと見苦しいじゃありませんか。

ということで。

{{age||'-'}} 歳

とやると
・数値が異常な場合
・ageに値が代入される前は

- 歳と表示される。

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

【後編】GridsomeとContentfulを使って、簡単に更新できる静的LPページを作ってみる

diffeasyアドベントカレンダー23日目の記事で、前回の「【前編】GridsomeとContentfulを使って、簡単に更新できる静的LPページを作ってみる」の続きです。

みなさん、あと2日でクリスマスですがいかがお過ごしでしょうか?
僕はというもの、12月に入ってから彼女からアドベントカレンダー、自称:沙枝ベントカレンダーを毎日もらっては開けているのですが、早くメインのプレゼントが開けたくて開けたくて喉から手が出そうです?(自室の手の届くところにすでに置いてあるので…)

そんなことはさておき、前回

1. Contentfulでお知らせコンテンツの作成
2. Gridsomeで 1. で作ったお知らせコンテンツの表示

までを作りました。
今回は、

1. Firebase Hostingへのデプロイ
2. Github Actionsを使ってContentfulでの変更を検知し、自動デプロイ

を作っていきたいと思います?

サンプルリポジトリも作ったので、よかったら見てください?
https://github.com/tk07Sky/deadvent-2019-gridsome-sample

事前準備?‍♂️

今回はFirebase Hostingを使うので、Firebaseプロジェクトの作成(名前はなんでもいいです)と、Firebase CLIのインストールを行ってください。
また、GitHubも使うので、アカウントの作成や、Gridsomeで作ったアプリケーションをアップするリポジトリ(名前はなんでもいいです)を作成しておいてください。

Firebase Hostingにデプロイしてみる

Firebase Hostingのセットアップ

まず、ターミナルでfirebase initを打って、プロジェクトにFirebaseの設定を行っていきます。
次に、どの設定を組み込むのかを問われるので、Hostingのみにチェックを入れます。
スクリーンショット 2019-12-22 0.59.14.png

次にプロジェクトを作るか選択するか問われるのでUse an existing projectを選択し、事前準備で用意してもらっているプロジェクト名を選択します。
その後は、一旦エンターを押し続けてFirebaseの設定を終わらせます。
スクリーンショット 2019-12-22 0.59.34.png

Firebaseの設定を終えると、プロジェクトルートにpublicフォルダができていると思いますが、使わないので削除しておいてください。

Gridsomeコマンドで静的ページを生成し、Hostingのアップロード対象に設定する

Gridsomeのbuildコマンドを使って、Firebase Hostingにアップロードするための静的ページを作ります。

$ npm run build

上コマンドを実行すると、プロジェクトルートにdistフォルダができるので、このフォルダをFirebase Hostingのアップロード対象に設定します。
firebase initをした際に、プロジェクトルートにfirebase.jsonが生成されていると思うので、中身を書き換えていきます。

firebase.json
{
  "hosting": {
    "public": "dist", // ←ここ
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ]
  }
}

デプロイしてみる

ターミナルで

$ firebase deploy --only hosting

を実行してみましょう!
成功すれば、下画像のように Deploy complete! の文字とHosting先のURLが表示されるはずです。

スクリーンショット 2019-12-22 2.03.26.png

?????

Github Actionsを使ってContentfulでの変更を検知し、自動デプロイされるようにする

Firebase Hostingのアクセストークンを取得する

自動デプロイする際に必要なので取得していきます。
ターミナルで

$ firebase login:ci

を入力すると、firebase loginしたときと同じように認証画面に飛ぶので、流れに沿って進みます。
認証が終わるとターミナルにトークンが表示されるので、コピーしておきます。

Github Actionsを設定する

まずはGithub Actionsのワークフローを作っていきます。
Github Actionsのワークフローはプロジェクト直下に.github/workflows/**.ymlの形で追加してきます。

$ touch .github/workflows/ci.yml
$ vim .github/workflows/ci.yml

まずワークフロー名を決めます

.github/workflows/ci.yml
# 今回はciと名付けます
name: ci

次に、何のアクションを起点として発火するかを決めます。
今回はmasterブランチにpushされたときか、外部からリポジトリにGitHub APIを叩かれたときに発火するよう設定します。
外部からのイベントをキャッチするには、repository_dispatchというGitHubのWebhookイベントを使用します。

.github/workflows/ci.yml
on:
  push:
    branches:
      - master
  repository_dispatch:

次に、GitHub Actions上でGridsomeのビルドとデプロイをするjobを書きます。

大まかな部分の説明は公式ドキュメントやプラグインのドキュメントに載っているので割愛しますが、今回知ってほしい点を説明すると

  • 外部に公開できない.envの内容やFirebase tokenなどは、GitHubのSecretsという機能を使って、そこから呼び出すようにする
  • 開発の際に使っていた.envはgitの監視外にありそのまま使えないので、ビルドするときに指定するようにする

という感じです。

.github/workflows/ci.yml
jobs:
  build:
    name: build
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Repo
        uses: actions/checkout@master
      - name: Install Dependencies
        run: yarn install
      - name: Build
        run: yarn build
        env:
          GRIDSOME_CONTENTFUL_SPACE_ID: ${{ secrets.CONTENTFUL_SPACE_ID }}
          CONTENTFUL_ACCESS_TOKEN: ${{ secrets.CONTENTFUL_ACCESS_TOKEN }}
      - name: Archive Production Artifact
        uses: actions/upload-artifact@master
        with:
          name: dist
          path: dist

  deploy:
    name: deploy
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Repo
        uses: actions/checkout@master
      - name: Download Artifact
        uses: actions/download-artifact@master
        with:
          name: dist
      - name: Deploy to Firebase
        uses: w9jds/firebase-action@master
        with:
          args: deploy --only hosting
        env:
          FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}

以上の説明をガッチャンコすると、下のようになります。

.github/workflows/ci.yml
name: ci

on:
  push:
    branches:
      - master
  repository_dispatch:

jobs:
  build:
    name: build
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Repo
        uses: actions/checkout@master
      - name: Install Dependencies
        run: yarn install
      - name: Build
        run: yarn build
        env:
          GRIDSOME_CONTENTFUL_SPACE_ID: ${{ secrets.CONTENTFUL_SPACE_ID }}
          CONTENTFUL_ACCESS_TOKEN: ${{ secrets.CONTENTFUL_ACCESS_TOKEN }}
      - name: Archive Production Artifact
        uses: actions/upload-artifact@master
        with:
          name: dist
          path: dist

  deploy:
    name: deploy
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Repo
        uses: actions/checkout@master
      - name: Download Artifact
        uses: actions/download-artifact@master
        with:
          name: dist
      - name: Deploy to Firebase
        uses: w9jds/firebase-action@master
        with:
          args: deploy --only hosting
        env:
          FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}

Secretsを登録する

GitHubには、外部に公開したくない情報を管理するSecretsという機能があります。
ContentfulのAPIキーやFirebase tokenは外部に漏らすとまずいので、ここに設定していきます。
事前に作ってもらっていたリポジトリの画面からSettings → Secretsを選択してください。

Secretsには今回

  • CONTENTFUL_SPACE_ID
  • CONTENTFUL_ACCESS_TOKEN
  • FIREBASE_TOKEN

の3つを登録します。
登録は一つずつ行ってください。

スクリーンショット 2019-12-22 3.22.00.png

登録例

Name
CONTENTFUL_SPACE_ID

Value
YOUR_CONTENTFUL_SPACE_ID

下画像のようになれば、登録完了です✨

スクリーンショット 2019-12-22 3.29.38.png

GitHub Personal access tokenを取得する

外部(今回はContentful)からのWebhookイベントを受信するために必要なPersonal access tokenを取得します。
右上の自分のアイコン → Settings → Developer settings → Personal access tokensからGenerate new tokenを選択します。
下のような画面が出てくるので、Noteには覚えやすい名前を設定し、repoにチェックを入れてください。

スクリーンショット 2019-12-22 3.39.16.png

最後に一番下のgenerateを押すとトークンが発行されます。次で必要になるので、どこかに控えておいてください。

ContentfulのWebhookの設定をする

最後に、ContentfulのWebhook設定をします。

NameとURLの設定

Contentfulの画面を開いてSettings → Webhooksから、右上のAdd Webhookを選択してください。
すると、下画像の画面が開くので、Nameには好きな名前を、URLにはhttps://api.github.com/repos/${YOUR_GITHUB_ID}/${YOUR_RIPOSITORY_NAME}/dispatchesを指定してください。

スクリーンショット 2019-12-22 3.53.08.png

トリガーの設定

そしてこのままだとContentfulで行ったすべての行動がWebhookを発動するトリガーになるので、Triggersは下のようにしておいたほうがいいです!

スクリーンショット 2019-12-22 3.56.21.png

Headerの設定

Headersも追加していきましょう。
今回は

  • Accept
  • Authorization
  • User-Agent

を追加していきます。
HeadersセクションのAdd custom headerを選択して、下のように追加してください(User-Agentは自分のGitHub IDに変更してください)。
ついでにContent typeapplication/jsonにしましょう。

スクリーンショット 2019-12-22 3.59.06.png

Payloadの設定

PayloadはGitHubのAPIで{"event_type": "event name"}を指定しないといけないらしいので、指定していきます。
Customize the webhook payloadを選択し、下のように指定します。

スクリーンショット 2019-12-22 4.02.05.png

すべて設定が終わったら、右上のSaveを押して保存しましょう!
保存後、新しく記事をPublishすると、GitHub Actionsがうまく動いてるはずです!?

これですべて終了です!
お疲れさまでした?

終わりに

いかがでしたか?
思ってた以上に結構ボリューミーになったのですが、作って公開までできればサクッとメンテしたりページ追加できると思うので、よかったらぜひ試してみてください✨

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

Vue.js で Android 向けに明朝体を使いたい

Andoroid に明朝体がないのは割と知られてる話で、そういう場合は Google Web フォントとか使うのが一般的ですが、
Vue.js でやったときにちょっとハマったのでメモ。

TL;DR

  • npm パッケージのフォントだとダメっぽい(パッケージ内での実装方法にもよるかも?)
  • CSS の @import で読ませればOK
  • または vue-head パッケージ使ってるならそれを使ってもOK

Vue.js で Google Fonts を使う

実は、Google Fonts で公開されているフォントはほとんどが npm パッケージからの導入もできて、
typaface/xxx (あるいは typeface-xxx)みたいな感じで検索するとでてきます。

main.js でインポートすればすぐつかえるので、私はいつもこの方法でやっていました。

たとえば、さわらび明朝ならこんなふうに。

インストール

npm install typeface-sawarabi-mincho --save
# or
yarn add typeface-sawarabi-mincho

インポート

main.js
import 'typeface-sawarabi-mincho/index.css'

ところが、この方法だと Android でちゃんとウェブフォントが反映してくれませんでした。

Vue.js で Google Fonts をつかう

じゃあ、どうすればよいのかという話ですが、普通に CSS の @import で CDN からインポートすればOKでした。
というかこうしないとうまく行かなかった…。

FrontPage.vue
<style>
@import url('https://fonts.googleapis.com/css?family=Sawarabi+Mincho');
</style>

もしも、普段は sass とか scss で書いていて、 style ブロックに lang="scss" とかつけていた場合は、それとは別に style ブロックを追加してあげます。

FrontPage.vue
<style>
@import url('https://fonts.googleapis.com/css?family=Sawarabi+Mincho');
</style>

<style lang="scss">
html {
  font-size: 62.5%;
  font-family: "Sawarabi Mincho", "Hiragino Mincho Pro", "MS Mincho", sans-serif;
}

#app {
  .section {
  // ...
  }
}
</style>

これでOKでした。

vue-head で読ませる

CSS の @import はつかいたくないなぁ、って場合は、HEADタグに突っ込むわけですが、できれば public/index.html は触りたくないなぁ、ということで、<header></header> にいろいろ追加できる vue-head を使えばできます。
これは、meta とか title を書き換えるのに使うことが多いですが、スタイルシートや JS、favicon なんかも読ませることができます。
(これらも HEAD 内に書いてるんでまぁ当たり前なのかもしれませんが、意外と忘れがちなのは私だけ?)

FrontPage.vue
<script>
export default {
  head: {
    link: [
      { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css?family=Sawarabi+Mincho' },
    ]
  },
}
</script>

まとめ

  • とりあえず、vue-router も使ってて、タイトル書き換えとかしてるなら vue-head つかってるだろうから、そこで読ませる。
  • ルーター使ってないような LP などの超シンプルページの場合は、 style ブロックに @import するか、それでもヘッダーにはいろいろ書くだろうから、vue-head 入れちゃうのがやっぱり楽かも。
  • Vue + Electron みたいな感じで、PC/Mac 向けのアプリにするとかで、Android 全く考慮しないで良い&どうしてもオフラインで使わなくちゃいけないってときは、 npm の typeface シリーズを使ってもよいのかも
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Nuxt.js、Google Apps Script、スプレッドシート(DB)を組み合わせて検索アプリケーションを作ってみた

Google Apps Script(GAS)で Nuxt 動かしてみたら面白いのでは?
と思い試していたら動いたので、スプレッドシートをDBにして検索アプリケーションを作ってみました!

Nuxt.js の SPAモードを GAS を使って実際に GAS で Nuxt を動かしているURL
https://script.google.com/macros/s/AKfycbw9rOqkFPqP4Ym3n7goiL0tI4V3cx0UTOjVM8DTHT8FRG3ogjJs/exec

そして Google サイトで上記 URL を埋め込んで完成です。(GAS の 分かりづらい URL を隠すため)
https://sites.google.com/view/nuxt-gas-webapp/

image.png

DBとしてつかっているスプレッドシート
image.png

本記事では Nuxt を GAS 上で実行するためのやりかたを主に解説してきます。
まず nuxt build を実行した結果がどうなっているのかを見ていきましょう。

そもそも Nuxt はどういうファイルを出力するのか

pages/index.vuepages/dev.vue を作っただけのシンプルな Nuxt を build してみます。
ソース: https://github.com/howdy39/nuxt-gas-webapp/tree/master/nuxt/pages

build結果
                         Asset       Size  Chunks                         Chunk Names
../server/client.manifest.json   7.64 KiB          [emitted]
       4dc3b569e856e43cdf74.js   2.33 KiB       4  [emitted] [immutable]  runtime
       59dd7e691e85c4d93bde.js    162 KiB       1  [emitted] [immutable]  commons.app
       6f78c935aa007f5c95d3.js   44.6 KiB       0  [emitted] [immutable]  app
                      LICENSES  510 bytes          [emitted]
       c963b7fb6c8e63b5a272.js   1.19 KiB       2  [emitted] [immutable]  pages/dev
       ea45f7c48fea148ed3c9.js   2.81 KiB       3  [emitted] [immutable]  pages/index
 + 1 hidden asset
Entrypoint app = 4dc3b569e856e43cdf74.js 59dd7e691e85c4d93bde.js 6f78c935aa007f5c95d3.js
treeを表示
howdy39$ tree dist                                                                                        [~/projects/nuxt-gas-webapp]
dist
├── 200.html
├── README.md
├── _nuxt
│   ├── 4dc3b569e856e43cdf74.js
│   ├── 59dd7e691e85c4d93bde.js
│   ├── 6f78c935aa007f5c95d3.js
│   ├── LICENSES
│   ├── c963b7fb6c8e63b5a272.js
│   └── ea45f7c48fea148ed3c9.js
├── dev
│   └── index.html
├── favicon.ico
└── index.html

2 directories, 11 files

この中で実行に最低限必要なのは次の3つのファイル群です。

  1. ルーティングに必要な html
  2. Entrypoint である runtime commons.app app の js
  3. ページごとの js である pages/index pages/dev の js

つまり1〜3のファイル群を GAS 上で適切に読み込ませることができれば、 GAS で Nuxt を動かせます。

次は一旦 Nuxt から離れて GAS で Web 画面を作る手順を見ていきましょう。

GAS は HTML や JavaScript をホスティングすることはできない

GAS は HTML, JavaScript, CSS, 画像 などのファイルをホスティングすることはできません。
じゃあ Web サーバーとしては使えないのか、というとそうではありません。

次の図のようにベースとなる html を GAS の HtmlService.createTemplateFromFile を使ってレスポンスに設定すると HTML を返す Web サーバーになります。
この html に CSS や JavaScript のファイルを include して一つのの html にまとめてしまえば、CSS や JavaScript を含めることができます。
※ または CDN を使う手法もあります。(CDNを使うのが一般的です)

※詳細は公式ドキュメントを参照ください。
HTML Service: Best Practices  |  Apps Script  |  Google Developers

Nuxt の構成と GAS で Web 画面を配信する仕組みはわかりましたね。
次は Nuxt との差分をどう埋めていくかです。

GAS で Nuxt が出力する HTML や JavaScript を読み込ませるために何を変えなければならないのか

次の2点が必要です。

  1. js ファイルを <script>...</script> の形式に変換、include できるように拡張子を html に変える
  2. index.html を作成し、1で変換したファイルを include する

ポイントとして、Nuxt が生成した html は流用しません。
ほとんど JavaScript を読み込んでいるだけなので、自作して、include を使った形にするだけで十分だからです。

これらの作業を手動でやるのはしんどいので、自動で行うスクリプトを Node で作ります。
nuxt build の結果の js ファイルの一覧を読み込んでゴニョゴニョする感じです。

clas-build.js
const fs = require('fs')
const path = require('path')
const chalk = require('chalk')
const ejs = require('ejs')
const rimraf = require('rimraf')
const mkdirp = require('mkdirp')

const sourceDirectory = 'dist/_nuxt'
const destDirectory = 'build'

try {
  console.log(chalk.blue('Start') + ' clasp-build')

  rimraf.sync(destDirectory)
  mkdirp(destDirectory)

  const filenames = fs.readdirSync(sourceDirectory).filter(name => name.endsWith('.js'))

  // GASで読み込むためにjsをhtmlに変換
  filenames.forEach((fileName) => {
    const data = fs.readFileSync(path.join(sourceDirectory, fileName), 'utf8')
    const writeData = `<script> ${data} </script>`
    fs.writeFileSync(path.join(destDirectory, fileName.replace('.js', '.html')), writeData)
    console.log(`Wrote ${fileName}`)
  })

  // index.htmlを生成して上書き
  const templateHtml = `
    <!doctype html>
    <html>
    <body>
        <div id="__nuxt"><style>#nuxt-loading{visibility:hidden;opacity:0;position:absolute;left:0;right:0;top:0;bottom:0;display:flex;justify-content:center;align-items:center;flex-direction:column;animation:nuxtLoadingIn 10s ease;-webkit-animation:nuxtLoadingIn 10s ease;animation-fill-mode:forwards;overflow:hidden}@keyframes nuxtLoadingIn{0%{visibility:hidden;opacity:0}20%{visibility:visible;opacity:0}100%{visibility:visible;opacity:1}}@-webkit-keyframes nuxtLoadingIn{0%{visibility:hidden;opacity:0}20%{visibility:visible;opacity:0}100%{visibility:visible;opacity:1}}#nuxt-loading>div,#nuxt-loading>div:after{border-radius:50%;width:5rem;height:5rem}#nuxt-loading>div{font-size:10px;position:relative;text-indent:-9999em;border:.5rem solid #f5f5f5;border-left:.5rem solid #fff;-webkit-transform:translateZ(0);-ms-transform:translateZ(0);transform:translateZ(0);-webkit-animation:nuxtLoading 1.1s infinite linear;animation:nuxtLoading 1.1s infinite linear}#nuxt-loading.error>div{border-left:.5rem solid #ff4500;animation-duration:5s}@-webkit-keyframes nuxtLoading{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes nuxtLoading{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}</style><script>window.addEventListener("error",function(){var e=document.getElementById("nuxt-loading");e&&(e.className+=" error")})</script><div id="nuxt-loading" aria-live="polite" role="status"><div>Loading...</div></div></div>
        <% filenames.forEach(function (value, key) { %>
          <?!= include('<%= value %>'); ?>
        <% }); %>
    </body>
    </html>
    `
  const html = ejs.render(templateHtml, { filenames: filenames.map(f => f.replace('.js', '')) })
  fs.writeFileSync(path.join(destDirectory, 'index.html'), html)
  console.log(`Wrote index.html`)
} catch (e) {
  console.log(chalk.red(e))
}

console.log(chalk.green(`Success`) + ' clasp-build')

これを実行した結果を clasp push で GAS にデプロイします。
GAS のGoogle謹製CLIツール clasp - Qiita

すると次のようなファイルになります。

image.png

もうひとつのポイント

GAS の Web アプリケーション は https://script.google.com/macros/s/AKfycbwZEWZGSK7fFJD74gmvna8efkJQkjTNYUzt4gfHtCEXhfEiC4GZ/exec のような URL でアクセスしますがこの URL が HTML をそのまま返すわけではありません。
次のような多重のサンドボックス構造になっているのです。

つまり exec ページが読み込まれるわけでもありませんし、 「URLを 〜exec/dev にして pages/dev.vue を表示しよう!」 なんてこともできません。

そのため次のように nuxt.config.js のルーティングを変えてかならず index.vue が読み込まれるようにします。

nuxt.config.js
  router: {
    extendRoutes (routes, resolve) {
      routes.push({
        name: 'custom',
        path: '*',
        component: resolve(__dirname, 'nuxt/pages/index.vue')
      })
    }
  },

ウェブアプリケーションとして導入

デプロイまで終わったら 「公開」 ー 「ウェブアプリケーションとして導入」 を実行するだけです。

では、実際にウェブアプリケーションを見てみましょう。
Nuxt のページが動いていますし、index ページと devページの行き来ができることも確認できます。

https://script.google.com/macros/s/AKfycbwZEWZGSK7fFJD74gmvna8efkJQkjTNYUzt4gfHtCEXhfEiC4GZ/exec

Image from Gyazo

この技術を応用して検索アプリケーション作りました!

それが記事冒頭のアプリケーションです。
https://sites.google.com/view/nuxt-gas-webapp/

Nuxt.js を Google Apps Script で動かしてみる! - Architecture.png

ポイントは GAS の HTML Service で作ったブラウザのグローバルオブジェクトに google が自動で作られるところです。
google.script.run().GASの関数名 の形で実行することで、JavaScript から任意の GAS の function を実行できます。

※ GAS は全ユーザーの情報を JSON で返すだけで、絞り込み自体はブラウザ側の JavaScript でやっています。
(2回目以降の検索は GAS にアクセスせずにブラウザだけで完結するようにするため)

さいごに

この構成はアリ・ナシでいうと、会社などで G Suite(有料版 Gmail) を使っていて社内向けの簡易的なシステムを作るなら、かなりアリかなと思います。

GAS の Web アプリケーションの公開時に Who has access to the app という公開した URL に誰がアクセスできるのかという設定項目があります。
次の画像は筆者が持っている tecthetoarster.org という G Suite の Who has access to the app の選択項目です。
image.png

Anyone within Tech The Toaster を選べば、@tecthetoarster.org のアカウントにログインしていないと実行できない URL になります。
特定のドメイン内限定で使える Web アプリケーションを作るのは面倒ですよね。お手軽にセキュアな Web アプリケーションを作れるのはメリットかなと思います。

全コードは GitHub にあげてあるので良かったら参考にしてみてください。
howdy39/nuxt-gas-webapp - シンプルなやつ
howdy39/nuxt-gas-webapp - ユーザー検索アプリケーション

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

モバイル環境でNetwork Errorでハマった。「EC2にDocker Caddy Laravel, S3にVuejsの構成」

指摘などウェルカムです。
クロスオリジン(CORS)に関連するエラーなんだと思うけどPCでは動くが、携帯 IOS Iphone7(古い)からのブラウザ(最新)ではsafariもchromeも動かないって現象で結構ハマった。

環境

大体の構成
route53から分岐
-> CloudFront+S3にvuejs
-> ALBからEC2(Ubuntu)にDocker Caddy Laravel

解決策

サーバー側のクロスオリジンの設定をちゃんと設定する。

Access-Control-Allow-Origin
Access-Control-Allow-Headers
をざっくり設定して * としていた。
これが原因だった。
ちゃんとフロントs3側のホスト名を指定したら動いた。
この記事で必須と書いてあるので、ん?と思って設定したら動いた。

:OK.php
<?php
namespace App\Http\Middleware;
use Closure;
class Cors
{
  public function handle($request, Closure $next)
  {
    return $next($request)
      //ちゃんとホスティングしてるs3のドメインを指定
      ->header(Access-Control-Allow-Origin, https://hoge.s3.com)
      ->header(Access-Control-Allow-Methods, GET, POST, PUT, DELETE, OPTIONS)
      ->header(Access-Control-Allow-Headers, X-Requested-With, Content-Type, X-Token-Auth, Authorization);
  }
}
駄目.php
<?php
namespace App\Http\Middleware;
use Closure;
class Cors
{
  public function handle($request, Closure $next)
  {
    return $next($request)
      ->header(Access-Control-Allow-Origin, *)
      ->header(Access-Control-Allow-Methods, GET, POST, PUT, DELETE, OPTIONS)
      ->header(Access-Control-Allow-Headers, X-Requested-With, Content-Type, X-Token-Auth, Authorization);
  }
}

https://whatsupguys.net/programming-learning-135/

調べた履歴

Laravelのルーティングについて結構大事だと思う。
axiosでget, post以外にoptionも使うのでルーティング注意。

Route::match(['options', 'patch']'/{test_id}', 'TestController@update');
Route::match(['options', 'delete'], '/{test_id}', 'TestController@destroy');

CORSを許可したLaravel製APIサーバーでput, patch, deleteが出来なくて泣いてたけど、ようやく解決出来た話

CSRFが問題か?
X-Requested-Withヘッダか?

Laravel/Vue SPAs: How to send AJAX requests and not run into CSRF token mismatch exceptions
https://medium.com/@serhii.matrunchyk/laravel-vue-spas-how-to-send-ajax-requests-and-not-run-into-csrf-tokens-mismatch-exceptions-da3b71b287ab
https://www.techalyst.com/posts/vuejs-axios-laravel-x-csrf-token-x-xsrf-token-csrf-protection

axiosが悪いのか?
https://qiita.com/terrierscript/items/ccb56b6fc05aa7821c42

crossのエラーぽいが。
https://stackoverflow.com/questions/50873764/cross-origin-read-blocking-corb
https://stackoverflow.com/questions/38749605/cors-access-control-allow-origin-on-laravel
https://stackoverflow.com/questions/20035101/why-does-my-javascript-code-get-a-no-access-control-allow-origin-header-is-pr
https://github.com/laravel/framework/issues/13643
https://medium.com/@petehouston/allow-cors-in-laravel-2b574c51d0c1

サーバの.htaccessが悪いのか?
https://forum.laragon.org/topic/1435/access-control-allow-origin-is-already-set/6

モバイルではそもそも動かないのか?

Axios doesn't work with Android (emulator) raising a Network Error
https://github.com/axios/axios/issues/973

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

plunkerでvue その43

概要

plunkerでvueやってみた。
俺のvueがie11で、動かない。

SCRIPT1002: 構文エラーです。

ie11は、アローが無い。

    axios.get("cart.php").then(response => (this.shop = response.data))
    var self = this
    axios.get("cart.php").then(function(response) {
       self.shop = response.data
    })

SCRIPT1003: ':' がありません。

ie11は、省略を、許さない。

    computed: {
        total() {
            var total = 0;
            for (var i = 0; i < this.items.length; i++)
            {
                total += this.items[i].price;
            }
            return total;
        }
    },
    computed: {
        total: function() {
            var total = 0;
            for (var i = 0; i < this.items.length; i++)
            {
                total += this.items[i].price;
            }
            return total;
        }
    },

SCRIPT5009: 'Promise' は定義されていません。

ie11は、以下を追加。

<script src="https://www.promisejs.org/polyfills/promise-6.1.0.min.js"></script>

以上。

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

【Vue.js】v-forループ内でFont awesome5アイコンを動的に表示するサンプルコード

はじめに

v-forループ内でFont awesome5アイコンを動的に表示するサンプルコードです。

↓サンプルコードの出力例
image.png

環境

OS: macOS Catalina 10.15.1
Vue: 2.6.10
vue-cli: 4.1.1

前提

Vue-CLIでFont awesome5を使う手順については、以下記事をご覧下さい。

【Vue.js】Vue-CLI4.1.1でFont awesome5を使う手順 - Qiita

指定方法

各アイコンのクラス名から、prefixiconに使う文字列を確認しておきます。

スクリーンショット 2019-12-23 5.17.10.png

表示方法

Font awesomeのアイコン指定方法は下記のように複数選択可能です。

<font-awesome-icon :icon="['fas', 'dog']"/>
<font-awesome-icon fas icon="dog" />
<font-awesome-icon prefix="fas" icon="dog" />

今回は一番下のものを使ってみます。

:prefix:iconとしてv-bindを適用します。

コード

<template>
...略
  <ul>
    <li v-for="item in items" :key="item.id">
        <p>
          <font-awesome-icon
            :prefix="item.prefix"
            :icon="item.icon"
          />
            {{ item.message }}
        </p>
    </li>
  </ul>
...略
</template>

<script>
export default {
  name: 'attention',
  data(){
    return {
      items: [
        { id: 1, prefix: 'fas', icon: 'phone-square', message: 'お電話はこちら'},
        { id: 2, prefix: 'fas', icon: 'dog', message: 'ペット可'},
        { id: 3, prefix: 'fas', icon: 'question-circle', message: '心配毎はご相談下さい'}
      ]
    }
  },
}

</script>

おわりに

最後まで読んで頂きありがとうございました:bow_tone1:

どなたかの参考になれば幸いです:relaxed:

参考にさせて頂いたサイト(いつもありがとうございます)

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

Laravel6系 に Vuetify を入れてみる

こんにばんわ! @ktoshi です。
今回は私が困ったときに縋りつく Laravel についてお話します。
ちなみに私はインフラエンジニアです。メインは。

目的

Laravel6 に Vuetify を導入したい。
Laravel6 より Vue.js などが標準ライブラリから外れたため、Laravel5 以前の記事ではそのまま導入ができなくなりました。
毎度導入しているときに複数の記事を見ながら、導入しているので備忘録もかねて。

Vuetify とは

公式HP
Vue.js のコンポーネント集です。
ボタンやテーブルなどを描画する際のコンポーネントがあつまっているので、
モダンなデザインを容易に作ることができます。

環境

OS: Windows 10 Pro
PHP: 7.3.10

Composer インストール

Laravel で使用するパッケージ管理に利用します。
みなさんご存じですよね。多くは語りません。
Composerインストール手順(Windows) を参考されたし。

node.js インストール

node.js 自体をサーバとして利用も可能ですが、Laravel では主に Vue.js の
ビルドを目的に利用します。
みなさんご存じですよね。多くは語りません。
Node.js / npmをインストールする(for Windows) を参考されたし。
私の環境ではバージョンは下記でした。

node --version
v12.13.0

npm --version
6.12.0

Laravel インストール

みなさんご存じですよね。多くは語りません。

composer create-project laravel/laravel --prefer-dist <PROJECT NAME>

これでカレントディレクトリ以下に PROJECT_NAME のフォルダが作成され、
その配下に Laravel が導入された状態となっています。

Vue.js のインストール

先でも述べたように Laravel5 以前であれば、この状態で npm install をしてやると
Vue.js が導入できましたが、 Laravel6 以降ではひと手間必要です。
ひと手間が料理をおいしくすると同じくひと手間が Laravel をおいしくします。(知りません

# プロジェクトフォルダへ移動
cd <PROJECT NAME>

# 必要なライブラリをインストール
composer require laravel/ui

# 私は Vue を使うねん!と宣言
php artisan ui vue

# おら!
npm install

これで Vue.js の導入が完了です。
Laravel5 以前で npm install を叩いた状態と同じですね。

Vuetify インストール

Vuetify は npm(Node.js のパッケージ管理ツール)を用いてインストールします。
(いや、そのコマンドさっき使ってたのに今更説明かい。)

# Vuetify の最新パッケージのインストール
npm install vuetify

インストールが終われば、Laravel 側で Vuetify を利用するように設定します。

resoureces/js/app.js
/**
 * First we will load all of this project's JavaScript dependencies which
 * includes Vue and other libraries. It is a great starting point when
 * building robust, powerful web applications using Vue and Laravel.
 */

require('./bootstrap');

window.Vue = require('vue');
import Vuetify from 'vuetify';
import 'vuetify/dist/vuetify.min.css';
Vue.use(Vuetify);

/**
 * The following block of code may be used to automatically register your
 * Vue components. It will recursively scan this directory for the Vue
 * components and automatically register them with their "basename".
 *
 * Eg. ./components/ExampleComponent.vue -> <example-component></example-component>
 */

// const files = require.context('./', true, /\.vue$/i)
// files.keys().map(key => Vue.component(key.split('/').pop().split('.')[0], files(key).default))

Vue.component('example-component', require('./components/ExampleComponent.vue').default);

/**
 * Next, we will create a fresh Vue application instance and attach it to
 * the page. Then, you may begin adding components to this application
 * or customize the JavaScript scaffolding to fit your unique needs.
 */

const app = new Vue({
    el: '#app',
    vuetify: new Vuetify(),
});

これで Laravel にてVuetify を利用できる状態となりました。

実践

では、実際にVuetify を使ったページを作ってみましょう。
元々用意されているサンプルのファイルを書き換えてみます。

resources\js\components\ExampleComponent.vue
<template>
  <v-app>
    <v-container>
      <v-row>
        <v-col>
          <v-card class="mx-auto">
            <v-card-text>
              <p class="text--primary">
                Hello Vuetify World!!
              </p>
            </v-card-text>
            <v-divider></v-divider>
            <v-card-actions>
              <v-btn>やったね!!</v-btn>
            </v-card-actions>
          </v-card>
        </v-col>
      </v-row>
    </v-container>
  </v-app>
</template>
resources\views\welcome.blade.php
<!doctype html>
<html lang="{{ app()->getLocale() }}">
  <head>
      <meta charset="utf-8">
      <meta http-equiv="X-UA-Compatible" content="IE=edge">
      <meta name="viewport" content="width=device-width, initial-scale=1">

      <!-- CSRF Token -->
      <meta name="csrf-token" content="{{ csrf_token() }}">

      <title>{{ config('app.name', 'Laravel') }}</title>

      <!-- Styles -->
      <link rel="stylesheet" href="{{ mix('css/app.css') }}">
  </head>
  <body>
    <div id="app">
      <v-app>
        <!-- これがさっき修正したファイル -->
        <example-component></example-component>
      </v-app>
    </div>
    <!-- Scripts -->
    <script src="{{ mix('js/app.js') }}"></script>
  </body>
</html>

次に Vue.js のビルドと Laravel を起動させます。
なお、各コマンドともに実行状態が続くので二つのプロンプトで作業をしてください。

# Laravel の起動
php artisan serve
Laravel development server started: http://127.0.0.1:8000 # これが出ればOK
# ビルドコマンドを発行。watch にしておくと変更を検知して勝手にビルドしてくれるので楽。
npm run watch
DONE  Compiled successfully in XXXms # これが出ればOK 

それぞれが起動すれば http://localhost:8000/ へ接続してみましょう。
下記のようなページが表示されていればOKです。
sample.png

まとめ

Laravel6 から標準でなくなった Vue.js のインストールまで行ってしまえば、
後は Laravel5 と同様の方法で Vuetify を利用することが可能です。

私は今まで element-ui を主に使っていましたが、今回は Vuetify を利用する、という記事を書いてみました。
なんで変えたかって?飽きたからです。
ただ、実際に Vuetify を使ってみて思ったのはデザインなどは非常に好みでした。
テーブルに関しても element-ui より柔軟でよかったです。
ただ、timepicker のデザインが不満だったのと element-ui の datetimepicker がマジ神だなと感じました。

それでは皆様、よい Vuetify ライブをお送りください。

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

Vue.jsでぷ○コンみたいなジョイスティックを作った

概要

タイトルそのままなのだけど
ぷ○コン作りました。

PWAの時代来ると思うし。
きっとVueで手軽にゲーム作りたいなぁってときにこういうのいると思うし。
移動のインターフェイスとしてはとても使い勝手がいいし。

(○にコンと言いつつ正確にはタップした場所を始点に差分を出してるのが同じなだけで見た目は別物)

デモ

https://punidemo.firebaseapp.com/
たんにヒヨコ(?)を移動させるだけ。
マウスとタップどちらにも対応してるのでPCブラウザでもスマホでも動くはず。
リセットボタンは位置が初期位置に戻るだけ。

イメージ
demo.png

タッチ部分はこんな感じのが表示される
pad.png

(以下説明用に円の色変え)
1番外側はタップした際の表示位置に固定で表示される。
pad1.png

真ん中のはタップ位置。動かすとどこまでも追いかけていく。
pad2.png

1番内側のは一定距離まで動かすと距離が固定されるやつ。
正直これは見た目用なので機能としてはいらないやつ。
(タップの距離が中心と離れすぎるとどの方向に伸ばしてるのか分からなくなるので目印に)
pad3.png

環境

Vue.jsと書いてるけどデモ自体はNuxt.jsで作成。
試してないけど、nuxtの要素は特に無いのでたぶんvue-cliとかで作ったプロジェクトでも動くはず…?
vueは2.6.10
nuxtは2.10.2
PCのchromeとスマホのsafariでしか確認してない。

コンポーネントの概要

タッチ位置を始点にして差分を検出、呼び出し元に対して
・x: 始点からの差分の単位ベクトルのx
・y: 始点からの差分の単位ベクトルのy
・v: 始点からの差分の絶対値
・rad: 始点からのラジアン
の4つを通知している。
※デモではradは使ってないし合ってるかも検証してない、なんとなく今後使いみちあるんじゃと思ってつけた

ソースコード

puni.vue
<template>
  <div
    style="position:absolute;width:100%;height:100%;top:0px;left:0px;z-index:1000;"
    @mousemove="mousemove($event)"
    @mouseup="touchend($event)"
    @mouseleave="touchend($event)"
    @touchmove="touchmove($event)"
    @touchend="touchend($event)"
    @mousedown="mousestart($event)"
    @touchstart="touchstart($event)"
  >
    <div
      style="user-select: none"
      :style="padArea.style"
      v-show="isMousedown"
    >
      <div style="position:relative;width:100%;height:100%;">
        <div :style="padHead.style" />
        <div :style="padRoot.style" />
      </div>
    </div>
  </div>
</template>

<script>
export default {
  mixins: [],
  components: {},
  head() {
    return {};
  },
  data() {
    return {
      isMousedown: false,
      padViewPosition: {
        x: 0,
        y: 0
      },
      padMovePosition: {
        x: 0,
        y: 0
      },
      padHead: {
        size: 48,
        edgeSize: 5,
        color: "#aaa5",
        zIndex: 1100,
        style: {}
      },
      padRoot: {
        size: 32,
        edgeSize: 3,
        color: "#aaa5",
        zIndex: 1050,
        style: {}
      },
      padArea: {
        size: 100,
        edgeSize: 3,
        color: "#aaa5",
        zIndex: 1010,
        style: {}
      }
    };
  },
  watch: {
    padMovePosition: {
      handler(newValue, oldValue) {
        const x =
          this.padMovePosition.x -
          (this.padViewPosition.x + this.padArea.size / 2);
        const y =
          this.padMovePosition.y -
          (this.padViewPosition.y + this.padArea.size / 2);
        const vel = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2));

        // 呼び出し元へ通知
        this.$emit("input", {
          x: x == 0 ? 0 : x / vel,
          y: y == 0 ? 0 : y / vel,
          v: vel,
          rad: Math.atan2(y, x)
        });

        // タップ先端のstyle
        this.padHead.style = {
          position: "absolute",
          width: this.padHead.size + this.padHead.edgeSize * 2 + "px",
          height: this.padHead.size + this.padHead.edgeSize * 2 + "px",
          top:
            this.padMovePosition.y -
            this.padViewPosition.y -
            this.padHead.size / 2 -
            this.padHead.edgeSize +
            "px",
          left:
            this.padMovePosition.x -
            this.padViewPosition.x -
            this.padHead.size / 2 -
            this.padHead.edgeSize +
            "px",
          "z-index": this.padHead.zIndex,
          "border-radius": this.padHead.size / 2 + this.padHead.edgeSize + "px",
          border: this.padHead.edgeSize + "px solid " + this.padHead.color,
          "box-sizing": "border-box"
        };

        // タップの根本のstyle
        const padRootPosition = (() => {
          if (vel > this.padArea.size / 2) {
            let value = this.padArea.size / 2 / vel;
            return {
              x: x * value + (this.padViewPosition.x + this.padArea.size / 2),
              y: y * value + (this.padViewPosition.y + this.padArea.size / 2)
            };
          } else {
            return {
              x: this.padMovePosition.x,
              y: this.padMovePosition.y
            };
          }
        })();
        this.padRoot.style = {
          position: "absolute",
          width: this.padRoot.size + this.padRoot.edgeSize * 2 + "px",
          height: this.padRoot.size + this.padRoot.edgeSize * 2 + "px",
          top:
            padRootPosition.y -
            this.padViewPosition.y -
            this.padRoot.size / 2 -
            this.padRoot.edgeSize +
            "px",
          left:
            padRootPosition.x -
            this.padViewPosition.x -
            this.padRoot.size / 2 -
            this.padRoot.edgeSize +
            "px",
          "z-index": this.padRoot.zIndex,
          "border-radius": this.padRoot.size / 2 + this.padRoot.edgeSize + "px",
          border: this.padRoot.edgeSize + "px solid " + this.padRoot.color,
          "box-sizing": "border-box"
        };
      },
      deep: true
    },
    padViewPosition: {
      handler(newValue, oldValue) {
        this.padArea.style = {
          background: "#eee5",
          position: "absolute",
          width: this.padArea.size + this.padArea.edgeSize * 2 + "px",
          height: this.padArea.size + this.padArea.edgeSize * 2 + "px",
          top: this.padViewPosition.y + "px",
          left: this.padViewPosition.x + "px",
          "user-select": "none",
          "z-index": this.padArea.zIndex,
          "border-radius": this.padArea.size / 2 + this.padArea.edgeSize + "px",
          border: this.padArea.edgeSize + "px solid " + this.padArea.color,
          "box-sizing": "border-box"
        };
      },
      deep: true
    }
  },
  created() {},
  mounted() {
    this.padMovePosition = {
      x: this.padViewPosition.x + this.padArea.size / 2,
      y: this.padViewPosition.y + this.padArea.size / 2
    };
  },
  computed: {},
  methods: {
    // 触ったときの処理(マウス)
    mousestart(e) {
      this.isMousedown = true;
      this.padViewPosition.x = e.pageX - this.padArea.size / 2;
      this.padViewPosition.y = e.pageY - this.padArea.size / 2;
    },
    // 触ったときの処理(タップ)
    touchstart(e) {
      this.isMousedown = true;
      let touch = e.targetTouches[0];
      this.padViewPosition.x = touch.pageX - this.padArea.size / 2;
      this.padViewPosition.y = touch.pageY - this.padArea.size / 2;

    },
    // 動いている間の処理(マウス)
    mousemove(e) {
      if (this.isMousedown) {
        this.padMovePosition.x = e.pageX;
        this.padMovePosition.y = e.pageY;
      }
    },
    // 動いている間の処理(タップ)
    touchmove(e) {
      if (this.isMousedown) {
        if (e.targetTouches.length == 1) {
          let touch = e.targetTouches[0];
          this.padMovePosition.x = touch.pageX;
          this.padMovePosition.y = touch.pageY;
        }
      }
    },
    // 離したときの処理(マウス・タップ共通)
    touchend(e) {
      if (this.isMousedown) {
        this.isMousedown = false;
        this.padMovePosition.x = this.padViewPosition.x + this.padArea.size / 2;
        this.padMovePosition.y = this.padViewPosition.y + this.padArea.size / 2;
      }
    }
  }
};
</script>

ざっくりした使い方

①呼び出し元の方でimportする

import puni from "~/components/puni";
export default {
  components: {
    puni,
  },

②設置

<puni v-model="puniInfo"/>

シンプルなのでv-modelでコンポーネントからの通知を省略しているけど、特に画面の方からコンポーネント側に何かをバインドしているわけではない(コンポーネント側ではpropsの設定はしていない)ので、ちゃんと書くなら下記の方が適切かもしれない。

<puni @input="puniInfo = $event"/>

③コンポーネントから通知された値を使って移動

setInterval(() => {
          if (this.puniInfo && !(this.puniInfo.x == 0 && this.puniInfo.y == 0)) {
              this.position.x += this.puniInfo.x * 3 * (this.puniInfo.v <= 45 ? 0.5:1);
              this.position.y += this.puniInfo.y * 3 * (this.puniInfo.v <= 45 ? 0.5:1);
            }
        },
        16
      );

ヒヨコの座標がposition。
3は良い感じのスピード。
45は移動スピードの閾値(よくある、普通の移動とゆっくり移動の設定)。
setIntervalの16はFPS60だとそれくらいかなって。

ざっくりした変更の仕方

円の大きさを変えたいとか色を変えたい!程度のことはdataのここをいじればいけるはず。

      padHead: {
        size: 48,
        edgeSize: 5,
        color: "#aaa5",
        zIndex: 1100,
        style: {}
      },
      padRoot: {
        size: 32,
        edgeSize: 3,
        color: "#aaa5",
        zIndex: 1050,
        style: {}
      },
      padArea: {
        size: 100,
        edgeSize: 3,
        color: "#aaa5",
        zIndex: 1010,
        style: {}
      }

size: 円の直径
edgeSize: borderのサイズ
color: 名前が適切じゃない、colorだけどborderに設定してる色の事
zIndex: そのまま
style: これはwatchで自動設定するやつなので変更してはいけない

ざっくりした注意点

コンポーネント自体が

position:absolute;
top:0;
left:0;
width:100%;
height:100%;

となっている。
つまり画面全体を覆いつつ(0,0)からの位置を前提にしている。
なのでposition:relativeと位置座標が設定されているタグの中に放り込むと位置がズレる。
あとz-indexを1000代に設定している。
(コンポーネントを変更せずにそのまま使いつつ)もしボタンを設定するなら、コンポーネントが画面全体を覆っている関係でz-indexが適用されるようにしつつz-index2000以上を使わないといけない。
(「z-index 効かない」とかでぐぐるとすぐに出てくる。大抵positionの設定が漏れてる。)

悩みどころ

コンポーネントが画面全体を覆う作りって微妙じゃないかなって…
タップの始点はpropsで受け取るようにした方が…と思いつつも機能的には始点から差分まで求めるのはコンポーネントで完結させたいよなぁ…

おわりに

コンポーネントの主要なパラメータがまだベタ書きで変更にも弱い箇所あったりするので改良していく所存。
来年はこれ使ってクソアプリ作るぞ!

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

Vue.jsでぷ○コンを作ってコンポーネント化した

概要

タイトルそのままなのだけど
ぷ○コン作りました。

PWAの時代来ると思うし。
きっとVueで手軽にゲーム作りたいなぁってときにこういうのいると思うし。
移動のインターフェイスとしてはとても使い勝手がいいし。

(○にコンと言いつつ正確にはタップした場所を始点に差分を出してるのが同じなだけで見た目は別物)

デモ

https://punidemo.firebaseapp.com/
たんにヒヨコ(?)を移動させるだけ。
マウスとタップどちらにも対応してるのでPCブラウザでもスマホでも動くはず。
リセットボタンは位置が初期位置に戻るだけ。

イメージ
demo.png

タッチ部分はこんな感じのが表示される
pad.png

(以下説明用に円の色変え)
1番外側はタップした際の表示位置に固定で表示される。
pad1.png

真ん中のはタップ位置。動かすとどこまでも追いかけていく。
pad2.png

1番内側のは一定距離まで動かすと距離が固定されるやつ。
正直これは見た目用なので機能としてはいらないやつ。
(タップの距離が中心と離れすぎるとどの方向に伸ばしてるのか分からなくなるので目印に)
pad3.png

環境

Vue.jsと書いてるけどデモ自体はNuxt.jsで作成。
試してないけど、nuxtの要素は特に無いのでたぶんvue-cliとかで作ったプロジェクトでも動くはず…?
vueは2.6.10
nuxtは2.10.2
PCのchromeとスマホのsafariでしか確認してない。

コンポーネントの概要

タッチ位置を始点にして差分を検出、呼び出し元に対して
・x: 始点からの差分の単位ベクトルのx
・y: 始点からの差分の単位ベクトルのy
・v: 始点からの差分の絶対値
・rad: 始点からのラジアン
の4つを通知している。
※デモではradは使ってないし合ってるかも検証してない、なんとなく今後使いみちあるんじゃと思ってつけた

ソースコード

puni.vue
<template>
  <div
    style="position:absolute;width:100%;height:100%;top:0px;left:0px;z-index:1000;"
    @mousemove="mousemove($event)"
    @mouseup="touchend($event)"
    @mouseleave="touchend($event)"
    @touchmove="touchmove($event)"
    @touchend="touchend($event)"
    @mousedown="mousestart($event)"
    @touchstart="touchstart($event)"
  >
    <div
      style="user-select: none"
      :style="padArea.style"
      v-show="isMousedown"
    >
      <div style="position:relative;width:100%;height:100%;">
        <div :style="padHead.style" />
        <div :style="padRoot.style" />
      </div>
    </div>
  </div>
</template>

<script>
export default {
  mixins: [],
  components: {},
  head() {
    return {};
  },
  data() {
    return {
      isMousedown: false,
      padViewPosition: {
        x: 0,
        y: 0
      },
      padMovePosition: {
        x: 0,
        y: 0
      },
      padHead: {
        size: 48,
        edgeSize: 5,
        color: "#aaa5",
        zIndex: 1100,
        style: {}
      },
      padRoot: {
        size: 32,
        edgeSize: 3,
        color: "#aaa5",
        zIndex: 1050,
        style: {}
      },
      padArea: {
        size: 100,
        edgeSize: 3,
        color: "#aaa5",
        zIndex: 1010,
        style: {}
      }
    };
  },
  watch: {
    padMovePosition: {
      handler(newValue, oldValue) {
        const x =
          this.padMovePosition.x -
          (this.padViewPosition.x + this.padArea.size / 2);
        const y =
          this.padMovePosition.y -
          (this.padViewPosition.y + this.padArea.size / 2);
        const vel = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2));

        // 呼び出し元へ通知
        this.$emit("input", {
          x: x == 0 ? 0 : x / vel,
          y: y == 0 ? 0 : y / vel,
          v: vel,
          rad: Math.atan2(y, x)
        });

        // タップ先端のstyle
        this.padHead.style = {
          position: "absolute",
          width: this.padHead.size + this.padHead.edgeSize * 2 + "px",
          height: this.padHead.size + this.padHead.edgeSize * 2 + "px",
          top:
            this.padMovePosition.y -
            this.padViewPosition.y -
            this.padHead.size / 2 -
            this.padHead.edgeSize +
            "px",
          left:
            this.padMovePosition.x -
            this.padViewPosition.x -
            this.padHead.size / 2 -
            this.padHead.edgeSize +
            "px",
          "z-index": this.padHead.zIndex,
          "border-radius": this.padHead.size / 2 + this.padHead.edgeSize + "px",
          border: this.padHead.edgeSize + "px solid " + this.padHead.color,
          "box-sizing": "border-box"
        };

        // タップの根本のstyle
        const padRootPosition = (() => {
          if (vel > this.padArea.size / 2) {
            let value = this.padArea.size / 2 / vel;
            return {
              x: x * value + (this.padViewPosition.x + this.padArea.size / 2),
              y: y * value + (this.padViewPosition.y + this.padArea.size / 2)
            };
          } else {
            return {
              x: this.padMovePosition.x,
              y: this.padMovePosition.y
            };
          }
        })();
        this.padRoot.style = {
          position: "absolute",
          width: this.padRoot.size + this.padRoot.edgeSize * 2 + "px",
          height: this.padRoot.size + this.padRoot.edgeSize * 2 + "px",
          top:
            padRootPosition.y -
            this.padViewPosition.y -
            this.padRoot.size / 2 -
            this.padRoot.edgeSize +
            "px",
          left:
            padRootPosition.x -
            this.padViewPosition.x -
            this.padRoot.size / 2 -
            this.padRoot.edgeSize +
            "px",
          "z-index": this.padRoot.zIndex,
          "border-radius": this.padRoot.size / 2 + this.padRoot.edgeSize + "px",
          border: this.padRoot.edgeSize + "px solid " + this.padRoot.color,
          "box-sizing": "border-box"
        };
      },
      deep: true
    },
    padViewPosition: {
      handler(newValue, oldValue) {
        this.padArea.style = {
          background: "#eee5",
          position: "absolute",
          width: this.padArea.size + this.padArea.edgeSize * 2 + "px",
          height: this.padArea.size + this.padArea.edgeSize * 2 + "px",
          top: this.padViewPosition.y + "px",
          left: this.padViewPosition.x + "px",
          "user-select": "none",
          "z-index": this.padArea.zIndex,
          "border-radius": this.padArea.size / 2 + this.padArea.edgeSize + "px",
          border: this.padArea.edgeSize + "px solid " + this.padArea.color,
          "box-sizing": "border-box"
        };
      },
      deep: true
    }
  },
  created() {},
  mounted() {
    this.padMovePosition = {
      x: this.padViewPosition.x + this.padArea.size / 2,
      y: this.padViewPosition.y + this.padArea.size / 2
    };
  },
  computed: {},
  methods: {
    // 触ったときの処理(マウス)
    mousestart(e) {
      this.isMousedown = true;
      this.padViewPosition.x = e.pageX - this.padArea.size / 2;
      this.padViewPosition.y = e.pageY - this.padArea.size / 2;
    },
    // 触ったときの処理(タップ)
    touchstart(e) {
      this.isMousedown = true;
      let touch = e.targetTouches[0];
      this.padViewPosition.x = touch.pageX - this.padArea.size / 2;
      this.padViewPosition.y = touch.pageY - this.padArea.size / 2;

    },
    // 動いている間の処理(マウス)
    mousemove(e) {
      if (this.isMousedown) {
        this.padMovePosition.x = e.pageX;
        this.padMovePosition.y = e.pageY;
      }
    },
    // 動いている間の処理(タップ)
    touchmove(e) {
      if (this.isMousedown) {
        if (e.targetTouches.length == 1) {
          let touch = e.targetTouches[0];
          this.padMovePosition.x = touch.pageX;
          this.padMovePosition.y = touch.pageY;
        }
      }
    },
    // 離したときの処理(マウス・タップ共通)
    touchend(e) {
      if (this.isMousedown) {
        this.isMousedown = false;
        this.padMovePosition.x = this.padViewPosition.x + this.padArea.size / 2;
        this.padMovePosition.y = this.padViewPosition.y + this.padArea.size / 2;
      }
    }
  }
};
</script>

ざっくりした使い方

①呼び出し元の方でimportする

import puni from "~/components/puni";
export default {
  components: {
    puni,
  },

②設置

<puni v-model="puniInfo"/>

シンプルなのでv-modelでコンポーネントからの通知を省略しているけど、特に画面の方からコンポーネント側に何かをバインドしているわけではない(コンポーネント側ではpropsの設定はしていない)ので、ちゃんと書くなら下記の方が適切かもしれない。

<puni @input="puniInfo = $event"/>

③コンポーネントから通知された値を使って移動

setInterval(() => {
          if (this.puniInfo && !(this.puniInfo.x == 0 && this.puniInfo.y == 0)) {
              this.position.x += this.puniInfo.x * 3 * (this.puniInfo.v <= 45 ? 0.5:1);
              this.position.y += this.puniInfo.y * 3 * (this.puniInfo.v <= 45 ? 0.5:1);
            }
        },
        16
      );

ヒヨコの座標がposition。
3は良い感じのスピード。
45は移動スピードの閾値(よくある、普通の移動とゆっくり移動の設定)。
setIntervalの16はFPS60だとそれくらいかなって。

ざっくりした変更の仕方

円の大きさを変えたいとか色を変えたい!程度のことはdataのここをいじればいけるはず。

      padHead: {
        size: 48,
        edgeSize: 5,
        color: "#aaa5",
        zIndex: 1100,
        style: {}
      },
      padRoot: {
        size: 32,
        edgeSize: 3,
        color: "#aaa5",
        zIndex: 1050,
        style: {}
      },
      padArea: {
        size: 100,
        edgeSize: 3,
        color: "#aaa5",
        zIndex: 1010,
        style: {}
      }

size: 円の直径
edgeSize: borderのサイズ
color: 名前が適切じゃない、colorだけどborderに設定してる色の事
zIndex: そのまま
style: これはwatchで自動設定するやつなので変更してはいけない

ざっくりした注意点

コンポーネント自体が

position:absolute;
top:0;
left:0;
width:100%;
height:100%;

となっている。
つまり画面全体を覆いつつ(0,0)からの位置を前提にしている。
なのでposition:relativeと位置座標が設定されているタグの中に放り込むと位置がズレる。
あとz-indexを1000代に設定している。
(コンポーネントを変更せずにそのまま使いつつ)もしボタンを設定するなら、コンポーネントが画面全体を覆っている関係でz-indexが適用されるようにしつつz-index2000以上を使わないといけない。
(「z-index 効かない」とかでぐぐるとすぐに出てくる。大抵positionの設定が漏れてる。)

悩みどころ

コンポーネントが画面全体を覆う作りって微妙じゃないかなって…
タップの始点はpropsで受け取るようにした方が…と思いつつも機能的には始点から差分まで求めるのはコンポーネントで完結させたいよなぁ…

おわりに

コンポーネントの主要なパラメータがまだベタ書きで変更にも弱い箇所あったりするので改良していく所存。
来年はこれ使ってクソアプリ作るぞ!

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

Vue.js+element uiでTableUIを遊び倒す

この記事は、「NJC Advent Calendar 2019」 12月23日の記事です。

はじめに

Vue.js+element uiで開発をした際、element uiのTableコンポーネントを色々触ったので備忘録として書きます。備忘録ですみません。正直な話良いネタが思いつかなかったです
また、実現したいことが日本のドキュメントであまりなかったので記事で残してたら需要あるかなと。。。

環境

Vue.js 2.6.10
element-ui 2.13.0

環境構築

今回は、Codepenで作成していくのでそちらの環境作成手順です。
①Settingsを開きます。
②JavaScriptタブで設定をします。
③Add External Scripts/Pensで以下のワードで検索して追加します。
 ・vue
 ・element-ui
⑤以下画面になっていればsave & Closeを押して設定を保存します。
image.png

実際の開発環境については先人の方々の資料を参考にしてください。
・Vue.js環境構築参考
https://qiita.com/yoshi0518/items/76195d52b794c4b81816
・Elemnt UI環境構築参考
https://qiita.com/Sa2Knight/items/7386a83c2058e53f0eef

実際に作成してみる

今回は以下二種類の機能を実装する。
・①RowクリックイベントでRowの詳細を出す機能
・②各Rowの文字が改行しないで「...」にする機能

簡単なTable

Basic tableをそのまま作成する。公式サイト参考Table

html
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<div id="app">
  <template>
    <el-table
      :data="tableData"
      style="width: 100%"
  >
      <el-table-column
        prop="date"
        label="Date"
        width="180">
      </el-table-column>
      <el-table-column
        prop="name"
        label="Name"
        width="180">
      </el-table-column>
      <el-table-column
        prop="address"
        label="Address">
      </el-table-column>
    </el-table>
  </template>
</div>
CSS
#app{
  width: 600px;
}
main.js
Vue.config.devtools = true;

new Vue({
  el: '#app',
   data() {
        return {
          tableData: [{
            date: '2016-05-03',
            name: 'Tom',
            address: 'No. 189, Grove St, Los Angeles'
          }, {
            date: '2016-05-02',
            name: 'Tom',
            address: 'No. 189, Grove St, Los Angeles'
          }, {
            date: '2016-05-04',
            name: 'Tom',
            address: 'No. 189, Grove St, Los Angeles'
          }, {
            date: '2016-05-01',
            name: 'Tom',
            address: 'No. 189, Grove St, Los Angeles'
          }]
        }
      }
});

①RowクリックイベントでRowの詳細を出す

やりたいこと

expandable-rowを用いることで以下画像のように各Rowに詳細の表示切替ができる。しかし、公式サイトの情報は赤枠部分のクリックで実現している情報のみである。
これを各Row全体がクリックされても表示切替ができるようにする。
image.png

実装

html
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<div id="app">
  <template>
    <el-table
      :data="tableData"
      style="width: 100%"
      @row-click="rowClicked"
      ref="tableData"
      id="clickable-rows"
    >
      <el-table-column type="expand">
        <template slot-scope="props">
          Old: {{ props.row.details.old }}<br>
          Like: {{ props.row.details.like }}<br>
          DisLike: {{ props.row.details.dislike }}<br>
        </template>
      </el-table-column>
      <el-table-column
        prop="date"
        label="Date"
        width="180">
      </el-table-column>
      <el-table-column
        prop="name"
        label="Name"
        width="180">
      </el-table-column>
      <el-table-column
        prop="address"
        label="Address">
      </el-table-column>
    </el-table>
  </template>
</div>
css
#app{
  width: 600px;
}
.clickable-rows {
  tbody tr td {
    cursor: pointer;
  }
}
.el-table__expanded-cell {
    cursor: default;
  }
}
main.js
Vue.config.devtools = true;

new Vue({
  el: '#app',
  methods: {
      rowClicked(row) {
        this.$refs.tableData.toggleRowExpansion(row);
      }
   },
   data() {
        return {
          tableData: [{
            date: '2016-05-03',
            name: 'Tom',
            address: 'No. 189, Grove St, Los Angeles',
            details: {
              old: '16 years old',
              like: 'play Baseball',
              dislike: 'Math'
            }
          }, {
            date: '2016-05-02',
            name: 'Tom',
            address: 'No. 189, Grove St, Los Angeles',
            details: {
              old: '16 years old',
              like: 'play Baseball',
              dislike:'Math'
            }
          }, {
            date: '2016-05-04',
            name: 'Tom',
            address: 'No. 189, Grove St, Los Angeles'
          }, {
            date: '2016-05-01',
            name: 'Tom',
            address: 'No. 189, Grove St, Los Angeles',
            details: {
              old: '16 years old',
              like: 'play Baseball',
              dislike: 'Math'
            }
          }]
        }
      }
});

解説

<el-table-column type="expand">でexpandを設定して、そのタグ内に<template slot-scope="props">を記載することで、Rowクリック時に下に表示させる部分を準備する。実際はこの時点でtype="expand"を設定したカラムに詳細行の表示切替実装できているが、Row全体のクリックイベントではない。
Row全体に表示切替機能を実装するには、<el-table>で設定した@row-click="rowClicked"ref="tableData"を用いる。
@row-click="rowClicked"イベントで呼び出される際にメソッド側で選択したRow情報が引数として使用することができる。
メソッド内でthis.$refs.tableData.toggleRowExpansion(row);を実行することによりクリックしたRowの詳細切替を実装することができる。

②各Rowの文字が改行させないで「・・・」にする

やりたいこと

以下画像のように文字数がカラムの幅を超えた場合に、改行せずに「・・・」と省略するように実装する。
image.png

実装

htmlとCSSを変更していく。

html
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<div id="app">
  <template>
    <el-table
      :data="tableData"
      style="width: 100%"
      @row-click="rowClicked"
      ref="tableData"
      id="clickable-rows"
    >
      <el-table-column type="expand">
        <template slot-scope="props">
          Old: {{ props.row.details.old }}<br>
          Like: {{ props.row.details.like }}<br>
          DisLike: {{ props.row.details.dislike }}<br>
        </template>
      </el-table-column>
      <el-table-column
        prop="date"
        label="Date"
        width="180">
      </el-table-column>
      <el-table-column
        prop="name"
        label="Name"
        width="180">
      </el-table-column>
      <el-table-column
        label="Address">
        <template slot-scope="scope">
          <div id="textOver">
              {{ scope.row.address }}
          </div>
        </template>
      </el-table-column>
    </el-table>
  </template>
</div>
css
#app{
  width: 600px;
  list-style: none;
}
#textOver {
  text-overflow: ellipsis;
  overflow: hidden;
  white-space: nowrap;
}
.clickable-rows {
  tbody tr td {
    cursor: pointer;
  }
}
.el-table__expanded-cell {
    cursor: default;
  }
}

解説

CSSで設定する。#textOverの設定で「・・・」を実装できる。
※「・・・」のやり方は調べれば結構情報落ちてます。
cssで設定した#textOverをhtmlに反映させるために、以下のようにel-table-columnを変更します。
template slot-scope="scope"とすることによりel-tableで渡された:data="tableData"をscopeとして受け取ることでtemplateタグ内で様々なデザインを可能にすることができます。

変更部分
<el-table-column
    prop="address"
    label="Address">
</el-table-column>

↓↓↓下記のように変更↓↓↓
 <el-table-column label="Address">
  <template slot-scope="scope">
    <div id="textOver">
      {{ scope.row.address }}
   </div>
  </template>
</el-table-column>

成果物

See the Pen qBEmaVJ by Kento_tj (@Kento-GREEN) on CodePen.

最後に

Element UIは部品が豊富な印象を受けました。
また、実装のやりやすさであまり工数をかけたくないとか、簡単なフロントの作成に向いているなと感じました。
ただ、ちょっとしたデザインを実装する際は日本のドキュメントは少ないなと。。。
実装の方法とか忘れそうなんで、他のElement UIの部品も備忘録として残そうかと思います!!
批判、アドバイス等大歓迎です。
お手柔らかにお願いします。

おまけ

「②各Rowの文字が改行させないで「・・・」にする」に関して補足。
今回の実装では、Safariで確認すると文字省略、改行せずに文字がカラムを抜きぬけて表示されるみたいです。
なぜかはわからないですが、、、
回避方法としては、それぞれのel-table-columnにwidthをpxで設定してあげることが必要みたいです。

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

SkyWay API + Rails6 + Vue でビデオチャットアプリを作る② 複数人同時接続

SkyWayAPIを使って複数人でのビデオチャットアプリに挑戦します!
先週投稿した「SkyWay API + Rails6 + Vue でビデオチャットアプリを作る①」の続きです。

目標物

複数人が同時に参加できるビデオチャットアプリの作成。
部屋は既に作られていて、そこに入室したところから開始です。

注意

前回の回で使ったコードを基本使い回します。
railsのプロジェクトがあること、webpackerがインストールされていることを前提に進めていきます。

サンプルコードの分析

SkyWayが提供している複数同時接続のパターンのDEMOです。
https://example.webrtc.ecl.ntt.com/room/index.html

そのソースコードです。
パッとみてよくわからない部分があったので、上から順にコメントをつけていきました。

githubリポジトリ
https://github.com/skyway/skyway-js-sdk/tree/master/examples/room

script.js
//Peerモデルを定義
const Peer = window.Peer;

(async function main() {
  //操作がDOMをここで取得
  const localVideo = document.getElementById('js-local-stream');
  const joinTrigger = document.getElementById('js-join-trigger');
  const leaveTrigger = document.getElementById('js-leave-trigger');
  const remoteVideos = document.getElementById('js-remote-streams');
  const roomId = document.getElementById('js-room-id');
  const roomMode = document.getElementById('js-room-mode');
  const localText = document.getElementById('js-local-text');
  const sendTrigger = document.getElementById('js-send-trigger');
  const messages = document.getElementById('js-messages');
  const meta = document.getElementById('js-meta');
  const sdkSrc = document.querySelector('script[src*=skyway]');

  meta.innerText = `
    UA: ${navigator.userAgent}
    SDK: ${sdkSrc ? sdkSrc.src : 'unknown'}
  `.trim();

 //同時接続モードがSFUなのかMESHなのかをここで設定
  const getRoomModeByHash = () => (location.hash === '#sfu' ? 'sfu' : 'mesh');
 //divタグに接続モードを挿入
  roomMode.textContent = getRoomModeByHash();
 //接続モードの変更を感知するリスナーを設置
  window.addEventListener(
    'hashchange',
    () => (roomMode.textContent = getRoomModeByHash())
  );

 //自分の映像と音声をlocalStreamに代入
  const localStream = await navigator.mediaDevices
    .getUserMedia({
      audio: true,
      video: true,
    })
    .catch(console.error);

  // localStreamをdiv(localVideo)に挿入
  localVideo.muted = true;
  localVideo.srcObject = localStream;
  localVideo.playsInline = true;
  await localVideo.play().catch(console.error);

  // Peerのインスタンス作成
  const peer = (window.peer = new Peer({
    key: window.__SKYWAY_KEY__,
    debug: 3,
  }));

  // 「div(joinTrigger)が押される&既に接続が始まっていなかったら接続」するリスナーを設置
  joinTrigger.addEventListener('click', () => {
    if (!peer.open) {
      return;
    }

  //部屋に接続するメソッド(joinRoom)
    const room = peer.joinRoom(roomId.value, {
      mode: getRoomModeByHash(),
      stream: localStream,
    });

  //部屋に接続できた時(open)に一度だけdiv(messages)に=== You joined ===を表示
    room.once('open', () => {
      messages.textContent += '=== You joined ===\n';
    });
  //部屋に誰かが接続してきた時(peerJoin)、いつでもdiv(messages)に下記のテキストを表示
    room.on('peerJoin', peerId => {
      messages.textContent += `=== ${peerId} joined ===\n`;
    });

    //重要: streamの内容に変更があった時(stream)videoタグを作って流す
    room.on('stream', async stream => {
      const newVideo = document.createElement('video');
      newVideo.srcObject = stream;
      newVideo.playsInline = true;
      // 誰かが退出した時どの人が退出したかわかるように、data-peer-idを付与
      newVideo.setAttribute('data-peer-id', stream.peerId);
      remoteVideos.append(newVideo);
      await newVideo.play().catch(console.error);
    });

    //重要: 誰かがテキストメッセージを送った時、messagesを更新
    room.on('data', ({ data, src }) => {
      messages.textContent += `${src}: ${data}\n`;
    });

    // 誰かが退出した場合、div(remoteVideos)内にある、任意のdata-peer-idがついたvideoタグの内容を空にして削除する
    room.on('peerLeave', peerId => {
      const remoteVideo = remoteVideos.querySelector(
        `[data-peer-id=${peerId}]`
      );
    //videoストリームを止める上では定番の書き方らしい。https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/stop
      remoteVideo.srcObject.getTracks().forEach(track => track.stop());
      remoteVideo.srcObject = null;
      remoteVideo.remove();

      messages.textContent += `=== ${peerId} left ===\n`;
    });

    // 自分が退出した場合の処理
    room.once('close', () => {
    //メッセージ送信ボタンを押せなくする
      sendTrigger.removeEventListener('click', onClickSend);
    //messagesに== You left ===\nを表示
      messages.textContent += '== You left ===\n';
    //remoteVideos以下の全てのvideoタグのストリームを停めてから削除
      Array.from(remoteVideos.children).forEach(remoteVideo => {
        remoteVideo.srcObject.getTracks().forEach(track => track.stop());
        remoteVideo.srcObject = null;
        remoteVideo.remove();
      });
    });

    // ボタン(sendTrigger)を押すとonClickSendを発動
    sendTrigger.addEventListener('click', onClickSend);
  // ボタン(leaveTrigger)を押すとroom.close()を発動
    leaveTrigger.addEventListener('click', () => room.close(), { once: true });

   //テキストメッセージを送る処理
    function onClickSend() {
      room.send(localText.value);
      messages.textContent += `${peer.id}: ${localText.value}\n`;
      localText.value = '';
    }
  });

  peer.on('error', console.error);
})();
index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>SkyWay - Room example</title>
    <link rel="stylesheet" href="../_shared/style.css">
  </head>
  <body>
    <div class="container">
      <h1 class="heading">Room example</h1>
      <p class="note">
        Change Room mode (before join in a room):
        <a href="#">mesh</a> / <a href="#sfu">sfu</a>
      </p>
      <div class="room">
        <div>
          <video id="js-local-stream"></video>
          <span id="js-room-mode"></span>:
          <input type="text" placeholder="Room Name" id="js-room-id">
          <button id="js-join-trigger">Join</button>
          <button id="js-leave-trigger">Leave</button>
        </div>

        <div class="remote-streams" id="js-remote-streams"></div>

        <div>
          <pre class="messages" id="js-messages"></pre>
          <input type="text" id="js-local-text">
          <button id="js-send-trigger">Send</button>
        </div>
      </div>
      <p class="meta" id="js-meta"></p>
    </div>
    <script src="//cdn.webrtc.ecl.ntt.com/skyway-latest.js"></script>
    <script src="../_shared/key.js"></script>
    <script src="./script.js"></script>
  </body>
</html>

サンプルコードのvue.js化

上記の内容をvue.jsに書き換えていきます!

サンプルと違う部分

  • スタイルは一旦全部無視です。
  • 接続モードは本来2種類ありますが、今回はsfuをデフォルトに実装しました。
  • railsのviewを経由してroomIdを取得するようにしています。
room.vue
<template>
    <div id="app">
        <template v-for="stream in remoteStreams">
            <!-- ①srcObjectをバインドする -->
            <video 
                autoplay 
                playsinline
                :srcObject.prop="stream"
            ></video>
        </template>
        <video id="my-video" muted="true" width="500" autoplay playsinline></video>
        <p>ROOM ID: <span id="room-id">{{ roomId }}</span></p>
        <button v-if="roomOpened === true" @click="leaveRoom" class="button--green">Leave</button>
        <button v-else @click="joinRoom" class="button--green">Join</button>
        <br />
        <div>
            マイク:
            <select v-model="selectedAudio" @change="onChange">
            <option disabled value="">Please select one</option>
            <option v-for="(audio, key, index) in audios" v-bind:key="index" :value="audio.value">
                {{ audio.text }}
            </option>
            </select>

            カメラ: 
            <select v-model="selectedVideo" @change="onChange">
            <option disabled value="">Please select one</option>
            <option v-for="(video, key, index) in videos" v-bind:key="index" :value="video.value">
                {{ video.text }}
            </option>
            </select>
        </div>

        <template v-for="message in messages">
            <p>{{message}}</p>
        </template>
    </div>
</template>

<script>
const API_KEY = "6d7fe6d0-40c7-4acd-9586-063dd7b633dd"; 
// const Peer = require('../skyway-js');
export default {
    data: function () {
        return {
            audios: [],
            videos: [],
            selectedAudio: '',
            selectedVideo: '',
            localStream: {},
            messages: [],
            roomId: "",
            remoteStreams: [],
            roomOpened: false
        }
    },
    methods: {
        // 端末のカメラ音声設定
        onChange: function(){
            if(this.selectedAudio != '' && this.selectedVideo != ''){
                this.connectLocalCamera();
            }
        },
        connectLocalCamera: async function(){
            const constraints = {
                audio: this.selectedAudio ? { deviceId: { exact: this.selectedAudio } } : false,
                video: this.selectedVideo ? { deviceId: { exact: this.selectedVideo } } : false
            }
            const stream = await navigator.mediaDevices.getUserMedia(constraints);
            document.getElementById('my-video').srcObject = stream;
            this.localStream = stream;
        },
        leaveRoom: function(){
            if (!this.peer.open) {
                return;
            }
            this.roomOpened = false;
            this.room.close();
        },
        // 「div(joinTrigger)が押される&既に接続が始まっていなかったら接続」するリスナーを設置
        joinRoom: function(){
            if (!this.peer.open) {
                return;
            }
            this.roomOpened = true;
          //部屋に接続するメソッド(joinRoom)
            this.room = this.peer.joinRoom(this.roomId, {
                mode: "sfu",
                stream: this.localStream,
            });
          //部屋に接続できた時(open)に一度だけdiv(messages)に=== You joined ===を表示
            this.room.once('open', () => {
                this.messages.push('=== You joined ===');
            });
          //部屋に誰かが接続してきた時(peerJoin)、いつでもdiv(messages)に下記のテキストを表示
            this.room.on('peerJoin', peerId => {
                this.messages.push(`=== ${peerId} joined ===`);
            });
            //重要: streamの内容に変更があった時(stream)videoタグを作って流す
            this.room.on('stream', async stream => {
                await this.remoteStreams.push(stream);
            });

            //重要: 誰かがテキストメッセージを送った時、messagesを更新
            this.room.on('data', ({ data, src }) => {
                this.messages.push(`${src}: ${data}`);
            });

            // 誰かが退出した場合、div(remoteVideos)内にある、任意のdata-peer-idがついたvideoタグの内容を空にして削除する
            this.room.on('peerLeave', peerId => {
                const index = this.remoteStreams.findIndex((v) => v.peerId === peerId);
                const removedStream = this.remoteStreams.splice(index, 1);
                this.messages.push(`=== ${peerId} left ===`);
            });

            // 自分が退出した場合の処理
            this.room.once('close', () => {
               //メッセージ送信ボタンを押せなくする
                this.messages.length = 0;
            });
        }
    },

    created: async function(){
        const element = document.getElementById("room")
        const data = JSON.parse(element.getAttribute('data'))
        this.roomId = data.roomId
        //ここでpeerのリスナーを設置
        this.peer = new Peer({key: API_KEY, debug: 3}); //新規にPeerオブジェクトの作成

        //デバイスへのアクセス
        const deviceInfos = await navigator.mediaDevices.enumerateDevices();

        //オーディオデバイスの情報を取得
        deviceInfos
        .filter(deviceInfo => deviceInfo.kind === 'audioinput')
        .map(audio => this.audios.push({text: audio.label || `Microphone ${this.audios.length + 1}`, value: audio.deviceId}));

        //カメラの情報を取得
        deviceInfos
        .filter(deviceInfo => deviceInfo.kind === 'videoinput')
        .map(video => this.videos.push({text: video.label || `Camera  ${this.videos.length - 1}`, value: video.deviceId}));      
    }
}
</script>

<style scoped>
    p {
    font-size: 2em;
    text-align: center;
    }
</style>

vue化のコツ

本記事の趣旨とは異なりますが、素のJSをvueに書き換える時のコツです。
- 定数の定義をcreatedフックに集める
- クリック系のリスナーは全部関数に切り出してDOMの@ clickで発火するようにする
- その他のリスナーはcreatedフックに集める(又は、任意のアクション内)
- 変数をdataに整理する
- createElementやappendなどでDOMを挿入するケースは、dataとfor文をうまく使ってまとめる

videoタグのsrcをバインドさせる時、srcがオブジェクトの場合 :srcObject.prop="オブジェクト"という形で渡してあげないとエラーになります。

参考記事:vue.jsで複数のvideoタグを扱う
https://qiita.com/dbgso/items/271d903237b41dffcc6d

Rails側のコード

rails側の設定です。

rooms/show.html.erb
// roomIdを渡す処理
<% props = {
    roomId: "aiueo" 
  }.to_json
%>

<div id='room' data="<%= props %>">
    <room/>
</div>

<%= javascript_pack_tag 'room' %>
<%= stylesheet_pack_tag 'room' %>
routes.rb
Rails.application.routes.draw do
  get 'rooms/show'
  root 'rooms#show'
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end
rooms_controller.rb
class RoomsController < ApplicationController
  def show
  end
end

結果

これでうまく動くとこんな感じです。
4つのウィンドウを開いてテストしています。

スクリーンショット 2019-12-23 0.38.47.png

残念なお知らせ

相手が部屋から出た時の挙動にバグがあります。近日中に直す予定です?‍♂️

最後に

今回はこれで以上です。
同時接続もSkyWayAPIを使うと楽チンです。
コピペでも動くと思うので、是非ご自身でも試してみてください。

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