13
1

RailsエンジニアはPhoenixでもStimulusを使いたい

Last updated at Posted at 2023-12-13

はじめに

この記事は、Elixir Advent Calendar 2023 14日目となります。

前日13日目は、

@mnishiguchi さんで Elixir Erlang match_spec の文法 でした。

作るもの

今年は、Phoenix触る時間が全然なくて、ネタに困ったので、ズバリ・・・

PhoenixでもStimulusを使いたい

です。

いや、LiveView使えよというツッコミはご容赦いただきたく・・・

今年はRails7漬けになってて全然Phoenix使ってなかったのですが、いつの間にやらPhoenixが超絶洗練されておりまして・・・。

いや、ほんと私程度が言うのもなんですが、ディレクトリ構造が直感的になっているというか、以前の何処に何があんねんというのが皆無。

一言で言うとめちゃめちゃ使いやすくなってる!!!!

これは普段Rails使っている人でも違和感なくない?
ということで、せっかくなので、今年もまたRailsエンジニア目線のPhoenixやります。

なので、

Phoenix で Stimulus を使って GoogleMapApi を動かしてみようではないか え・・LiveView? 知らない子ですね。

と、こんな感じの入力補助からピン表示までの操作をやってみたいと思います。

環境

elixir
 1.15.7-otp-26
erlang
 26.1.2
Phoenix
 v1.7.10

プロジェクト作成

zsh
% mkdir google_map_phx
% cd google_map_phx
% mix phx.new . --no-mailer --no-live

ログが流れます。

log
(略)
* creating assets/vendor/heroicons/LICENSE.md
* creating assets/vendor/heroicons/UPGRADE.md
* extracting assets/vendor/heroicons/optimized

Fetch and install dependencies? [Yn] Y
* running mix deps.get
* running mix assets.setup
* running mix deps.compile

We are almost there! The following steps are missing:

    $ cd .

Then configure your database in config/dev.exs and run:

    $ mix ecto.create

Start your Phoenix app with:

    $ mix phx.server

You can also run your app inside IEx (Interactive Elixir) as:

    $ iex -S mix phx.server

言われた通り実行します。

zsh
% mix ecto.create
% mix phx.server
log
Compiling 14 files (.ex)
Generated phx_map app
The database for PhxMap.Repo has already been created
 phx_map % mix phx.server
[info] Running PhxMapWeb.Endpoint with cowboy 2.10.0 at 127.0.0.1:4000 (http)
[info] Access PhxMapWeb.Endpoint at http://localhost:4000
[debug] Downloading esbuild from https://registry.npmjs.org/@esbuild/darwin-arm64/0.17.11

Rebuilding...

Done in 240ms.
[watch] build finished, watching for changes...
[info] GET /
[debug] Processing with PhxMapWeb.PageController.home/2
  Parameters: %{}
  Pipelines: [:browser]
[info] Sent 200 in 59ms

良さそうです。

Image from Gyazo

モデル作成

phx.gen.html

htmlでMVC作ります。Railsで言うscaffoldです。

zsh
% mix phx.gen.html Maps Point points name:string latitude:float longitude:float address:string
log
* creating lib/google_map_phx_web/controllers/point_controller.ex
* creating lib/google_map_phx_web/controllers/point_html/edit.html.heex
* creating lib/google_map_phx_web/controllers/point_html/index.html.heex
* creating lib/google_map_phx_web/controllers/point_html/new.html.heex
* creating lib/google_map_phx_web/controllers/point_html/show.html.heex
* creating lib/google_map_phx_web/controllers/point_html/point_form.html.heex
* creating lib/google_map_phx_web/controllers/point_html.ex
* creating test/google_map_phx_web/controllers/point_controller_test.exs
* creating lib/google_map_phx/maps/point.ex
* creating priv/repo/migrations/20231213204606_create_points.exs
* creating lib/google_map_phx/maps.ex
* injecting lib/google_map_phx/maps.ex
* creating test/google_map_phx/maps_test.exs
* injecting test/google_map_phx/maps_test.exs
* creating test/support/fixtures/maps_fixtures.ex
* injecting test/support/fixtures/maps_fixtures.ex

Add the resource to your browser scope in lib/google_map_phx_web/router.ex:

    resources "/points", PointController


Remember to update your repository by running migrations:

    $ mix ecto.migrate

また途中で何か言われますが、ルーティングの記述とマイグレーションの実行を言われているようです。

ルーティング

ですので、まずはルーティングを

lib/google_map_phx_web/router.ex(before)
(略)
  scope "/", GoogleMapPhxWeb do
    pipe_through :browser

    get "/", PageController, :home
  end
(略)

このように変更しておきます。

lib/google_map_phx_web/router.ex(after)
()
  scope "/", GoogleMapPhxWeb do
    pipe_through :browser

    get "/", PointController, :index
    resources "/points", PointController
  end
(略)

マイグレーション

次に、マイグレーション

zsh
% mix ecto.migrate
log
05:51:32.894 [info] == Running 20231213204606 GoogleMapPhx.Repo.Migrations.CreatePoints.change/0 forward

05:51:32.896 [info] create table points

05:51:32.902 [info] == Migrated 20231213204606 in 0.0s

通りました。

seeds

マストではないのですが、最初のプレビュー用にダミーデーター入れておきます。

priv/repo/seeds.exs
# Script for populating the database. You can run it as:
#
#     mix run priv/repo/seeds.exs
#
# Inside the script, you can read and write to any of your
# repositories directly:
#
#     GoogleMapPhx.Repo.insert!(%GoogleMapPhx.SomeSchema{})
#
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.

alias GoogleMapPhx.Repo
alias GoogleMapPhx.Maps.Point

points = [
  %Point{
    name: "都庁",
    latitude: 35.6895014,
    longitude: 139.6917337,
    address: "日本、〒163-8001 東京都新宿区西新宿2丁目8−1"
  },
  %Point{
    name: "後楽園",
    latitude: 35.7054551,
    longitude: 139.7535553,
    address: "日本、〒112-0004 東京都文京区後楽1丁目3"
  },
  %Point{
    name: "Home Taco Bar",
    latitude: 35.3076198,
    longitude: 139.4937805,
    address: "日本、〒248-0033 神奈川県鎌倉市腰越2丁目10−25"
  }
]

for point <- points, do:
  Repo.insert!(point)

ファイルに書いてあるコマンドでデーターを流します。

zsh
% mix run priv/repo/seeds.exs
log
[debug] QUERY OK source="points" db=4.9ms decode=2.0ms queue=1.0ms idle=6.5ms
INSERT INTO "points" ("name","address","longitude","latitude","inserted_at","updated_at") VALUES ($1,$2,$3,$4,$5,$6) RETURNING "id" ["都庁", "日本、〒163-8001 東京都新宿区西新宿2丁目8−1", 139.6917337, 35.6895014, ~U[2023-12-13 20:55:14Z], ~U[2023-12-13 20:55:14Z]]
↳ Enum."-map/2-lists^map/1-1-"/2, at: lib/enum.ex:1693
[debug] QUERY OK source="points" db=0.6ms queue=0.8ms idle=24.1ms
INSERT INTO "points" ("name","address","longitude","latitude","inserted_at","updated_at") VALUES ($1,$2,$3,$4,$5,$6) RETURNING "id" ["後楽園", "日本、〒112-0004 東京都文京区後楽1丁目3", 139.7535553, 35.7054551, ~U[2023-12-13 20:55:14Z], ~U[2023-12-13 20:55:14Z]]
↳ Enum."-map/2-lists^map/1-1-"/2, at: lib/enum.ex:1693
[debug] QUERY OK source="points" db=0.5ms queue=0.5ms idle=25.7ms
INSERT INTO "points" ("name","address","longitude","latitude","inserted_at","updated_at") VALUES ($1,$2,$3,$4,$5,$6) RETURNING "id" ["Home Taco Bar", "日本、〒248-0033 神奈川県鎌倉市腰越2丁目10−25", 139.4937805, 35.3076198, ~U[2023-12-13 20:55:14Z], ~U[2023-12-13 20:55:14Z]]
↳ Enum."-map/2-lists^map/1-1-"/2, at: lib/enum.ex:1693

通りました。確認します。

Image from Gyazo

OKのようです。

JS周り

では、JS周りいきましょう。

Rails7で正式採用されたJSフレームワーク Stimulus を使います。

実は元ネタは、かなり前からあって・・・

ほんとは、LiveViewでやろうかと思ってたら、 @the_haigo さんの記事が

すでにあり・・・

この記事がかなり前なので、ちょっと検証してみようかというのが発端です。

assetsに移動

zsh
cd assets

Stimulusのインストール

zsh
% npm install --save stimulus
log
added 3 packages in 3s

GoogleMapApi

ついでにGooGleMapApiのライブラリも入れておきましょう。
NPM js-api-loader パッケージを使います。

zsh
% npm install @googlemaps/js-api-loader

階層を戻します。

zsh
cd ..

Stimulusコントローラーの作成

StimulusはJS階層下にコントローラーを作成しますので、そのファイルを作成します。

  • 一覧表示
  • 個別表示
  • フォーム

用に3つ作ってみたいと思います。
これにapplication_controllerを含め、4つ新規作成します。

ディレクトリを作っておきましょう。

zsh
% mkdir assets/js/controllers
% mkdir assets/js/controllers/google_maps

controllers/ 直下でも構わないのですが、 controllers/google_map/ に纏めます。

では、それぞれ作っていきましょう。

application_controller.js

assets/js/controllers/google_map/application_controller.js
import { Controller } from "stimulus"
import { Loader } from "@googlemaps/js-api-loader"

export default class extends Controller {
  setLoader() {
    return new Loader({
      apiKey: "xxxxxxxxYOUR_API_KEYxxxxxxxxx",//GooGleのApiキー
      version: "weekly",
    });
  }
}

ここで、先程のライブラリを読んでいます。

import { Controller } from "stimulus"
import { Loader } from "@googlemaps/js-api-loader"

その他3つはこの application_controller.js を継承するようにします。

あと、GoogleのApiキーに関しては取得後、必ず各必要環境に絞って制限を掛けておきましょう。

では、残り3つです。先頭には、

import ApplicationController from "./application_controller";

と書く事で継承されます。

index_controller.js

assets/js/controllers/google_map/index_controller.js
import ApplicationController from "./application_controller";

let map
let markers = []
let infoWindows = []

// Connects to data-controller="google-map--index"
export default class extends ApplicationController {
  static values = { location: { 
                        lat: 38.041184121,
                        lng: 137.1063077823
                    },
                    zoom: 5,
                    points: [] }

  static targets = [ 'map' ]

  connect() {
    this.setPoints()
    this.newMap()
  }

  newMap() {
    const loader = this.setLoader()
    loader.load().then(async () => {
      const { Map } = await google.maps.importLibrary("maps");

      map = new Map(this.mapTarget, {
        center: this.locationValue,
        zoom: this.zoomValue,
      })
      this.addMarkersToMap()
    })
  }

  addMarkersToMap() {
    this.pointsValue.forEach((o, i) => {
      this.addMarkerToMarkers(o)
      this.addInfoWindowToInfoWindows(o)
      this.addEventToMarker(i)
    })
  }

  addMarkerToMarkers(o) {
    this._marker = new google.maps.Marker({
      position: { lat: o.lat, lng: o.lng },
      map,
      name: o.name
    })
    markers.push(this._marker)
  }

  addInfoWindowToInfoWindows(o) {
    this._infoWindow = new google.maps.InfoWindow({
      content: `
        <a href="/points/${o.id}">
          ${o.name}
        </a>
      `
    })
    infoWindows.push(this._infoWindow)
  }

  addEventToMarker(i) {
    markers[i].addListener('click', () => {
      infoWindows[i].open(map, markers[i]);
    });
  }

  setPoints() {
    this.pointsValue = JSON.parse(this.mapTarget.dataset.json)
    this.getLastPointLocation()
  }

  getLastPointLocation() {
    if (this.pointsValue.length > 0) { 
      this._lastPoint = this.pointsValue.sort((a, b) => { a.id - b.id }).reverse()[0]
      this.locationValue = this._lastPoint
      this.zoomValue = 12
    } 
  }
}

show_controller.js

assets/js/controllers/google_map/show_controller.js
import ApplicationController from "./application_controller";

// Connects to data-controller="google-map--show"
export default class extends ApplicationController {

  static targets = [ 'map' ]

  connect() {
    this.newMap()
  }

  newMap() {
    const loader = this.setLoader()
    loader.load().then(async () => {
      const { Map } = await google.maps.importLibrary("maps");
      this._location = JSON.parse(this.mapTarget.dataset.json)
      this._map = new Map(this.mapTarget, {
        center: { lat: this._location.lat, lng: this._location.lng },
        zoom: 15,
      })
      new google.maps.Marker({
        map: this._map,
        position: this._location
      })
    })
  }
}

form_controller.js

assets/js/controllers/google_map/form_controller.js
import ApplicationController from "./application_controller";

let map
let marker

// Connects to data-controller="google-map--form"
export default class extends ApplicationController {

  static values = { location: {
                      lat: 35.6895014,
                      lng: 139.6917337
                    },
                    zoom: 15,
                    addressList: [] }

  static targets = [ 'map', 'keyword', `address`,
                     'latitude', 'longitude', 'addressList' ]

  connect() {
    this.newMap()
  }

  newMap() {
    const loader = this.setLoader()
    loader.load().then(async () => {
      const { Map } = await google.maps.importLibrary("maps");
      map = new Map(this.mapTarget, {
        center: this.locationValue,
        zoom: this.zoomValue,
      })
      this.initMarker()
      map.setCenter(this.locationValue)
      this.keywordTarget.addEventListener('change', () => {
        this.changeKeywordAction()
      })
      google.maps.event.addListener(map, 'click', (e) => {
        this.clickMapAction(e)
      });
    })
  }

  changeKeywordAction() {
    this.geoCoding(this.keywordTarget.value)
  }

  clickMapAction(e) {
    this._location = { lat: e.latLng.lat(), lng: e.latLng.lng() }
    this.clearMarker()
    this.setLocation(this._location)
    this.newMarker()
    this.keywordTarget.value = ""
    this.reverseGeoGoding() 
  }

  geoCoding(keyword) {
    this._geocoder = new google.maps.Geocoder()
    this._geocoder.geocode({ address: keyword }, (results, status) => {
      if (status == 'OK') {
        this._result = results[0].geometry.location
        this._location = { lat: this._result.lat(), lng: this._result.lng() }
        this.clearMarker()
        this.setLocation(this._location)
        this.newMarker()
        map.setCenter(this._location)
        this.reverseGeoGoding()
      } else {
        this.clearLocationForm()
      }
    }) 
  }

  reverseGeoGoding() {
    this._geocoder = new google.maps.Geocoder()
    this._geocoder.geocode({ location: this.locationValue }, (results, status) => {
      if (status == 'OK') {
        this.setAddresList(results)
      } else {
        this.clearAddressList()
      }
    })
    this.addressTarget.value = ""
  }

  clearAddressList() {
    this.addressListValue = []
    this.addressListTarget.innerHTML = ""
  }

  setAddresList(result) {
    this.clearAddressList()
    this.setAddressListValue(result)
    this.addressListValue.forEach(address => {
      this._option = document.createElement('option')
      this._option.value = address
      this.addressListTarget.append(this._option)
    })
  }

  setAddressListValue(result) {
    this._addressList = []
    result.forEach(o => {
      this._address = o.formatted_address
      this._addressList.push(this._address)
    });
    this.addressListValue = this._addressList
  }

  clearMarker() {
    if (marker != null) {
      marker.setMap(null)
    }
  }

  newMarker() {
    marker = new google.maps.Marker({
      map: map,
      position: this.locationValue
    })
  }
  
  writeToLocationForm(location) {
    this.latitudeTarget.value = location.lat
    this.longitudeTarget.value = location.lng
  }

  clearLocationForm() {
    this.latitudeTarget.value = ""
    this.longitudeTarget.value = "" 
  }

  initMarker() {
    this._latitude = this.latitudeTarget.value
    this._longitude = this.longitudeTarget.value
    if (this._latitude != "" && this._longitude != "") { 
      this.locationValue = { lat: parseFloat(this._latitude), lng: parseFloat(this._longitude) } 
      this.newMarker(this.locationValue)
    }
  }

  setLocation(location) {
    this.locationValue = location
    this.writeToLocationForm(location)
  }
}

では、最後にこれらを機能させる為にStimulusに登録する設定を書いておきます。

app.js

assets/js/app.js


//*以下を末尾に追記

// Configure Stimulus development experience
import { Application } from "stimulus"
// import ApplicationController from "./controllers/google_map/application_controller"
const application = Application.start()
export { application }

// controllers
import GoogleMap__ApplicationController from "./controllers/google_map/application_controller"
application.register("google-map--application", GoogleMap__ApplicationController)

import GoogleMap__IndexController from "./controllers/google_map/index_controller"
application.register("google-map--index", GoogleMap__IndexController)

import GoogleMap__ShowController from "./controllers/google_map/show_controller"
application.register("google-map--show", GoogleMap__ShowController)

import GoogleMap__FormController from "./controllers/google_map/form_controller"
application.register("google-map--form", GoogleMap__FormController)

これでJS周りはOKです。

テンプレート

テンプレートですが、元々 gen.html で用意されているものに追加していきます。
それにしても、いつのまにかこんなものまで・・・。

では、index show form の3点を変更していきます。

変更後のみ記載していますので、そのまま置き換えましょう。

index.html.heex

ここでは、全ポイントのピンを見る事ができます。

lib/google_map_phx_web/controllers/point_html/index.html.heex
<.header>
  Listing Points
  <:actions>
    <.link href={~p"/points/new"}>
      <.button>New Point</.button>
    </.link>
  </:actions>
</.header>

<div data-controller="google-map--index" class="mt-4">
  <div data-google-map--index-target="map" data-json={@points_json} style="height:50vh;max-width:800px;"></div>
</div>

<.table id="points" rows={@points} row_click={&JS.navigate(~p"/points/#{&1}")}>
  <:col :let={point} label="Name"><%= point.name %></:col>
  <:col :let={point} label="Latitude"><%= point.latitude %></:col>
  <:col :let={point} label="Longitude"><%= point.longitude %></:col>
  <:col :let={point} label="Address"><%= point.address %></:col>
  <:action :let={point}>
    <div class="sr-only">
      <.link navigate={~p"/points/#{point}"}>Show</.link>
    </div>
    <.link navigate={~p"/points/#{point}/edit"}>Edit</.link>
  </:action>
  <:action :let={point}>
    <.link href={~p"/points/#{point}"} method="delete" data-confirm="Are you sure?">
      Delete
    </.link>
  </:action>
</.table>

この部分がコントローラーとの接続部分となります。

<div data-controller="google-map--index"></div>

google-map--index で、
google_map ディレクトリの index.js に接続です。

data-google-map--index-target の部分は、該当のDomを static targets として登録できるようにしている部分です。

また、使用するデーターは、 @points_json として渡されています。

show.html.heex

ここでは、個別のピンを見る事ができます。

lib/google_map_phx_web/controllers/point_html/show.html.heex
<.header>
  Point <%= @point.id %>
  <:subtitle>This is a point record from your database.</:subtitle>
  <:actions>
    <.link href={~p"/points/#{@point}/edit"}>
      <.button>Edit point</.button>
    </.link>
  </:actions>
</.header>

<div data-controller="google-map--show">
  <div data-google-map--show-target="map" data-json={@point_json} style="height:30vh;max-width:400px;"></div>
</div>

<.list>
  <:item title="Name"><%= @point.name %></:item>
  <:item title="Latitude"><%= @point.latitude %></:item>
  <:item title="Longitude"><%= @point.longitude %></:item>
  <:item title="Address"><%= @point.address %></:item>
</.list>

<.back navigate={~p"/points"}>Back to points</.back>

使用するデーターは、 @point_json として渡されています。

point_form.html.heex

ここでは、キーワード検索や、マップをクリックする事で、緯度経度の入力を行ったり、住所データーの候補を datalist タグに出力します。

lib/google_map_phx_web/controllers/point_html/point_form.html.heex
<div data-controller="google-map--form">

  <.simple_form :let={f} for={@changeset} action={@action}>
    <.error :if={@changeset.action}>
      Oops, something went wrong! Please check the errors below.
    </.error>
    
    <label> ※ You can search by address or keyword, or by clicking on the map.
      <input type="search" data-google-map--form-target='keyword' class="mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6 phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400 border-zinc-300 focus:border-zinc-400"> 
    </label>

    <.input field={f[:name]} type="text" label="Name" step="any" />

    <.input field={f[:address]} type="text" label="Address(datalist)" list="address_list" data-google-map--form-target='address' />
    <datalist id="address_list" data-google-map--form-target="addressList"></datalist>

    <div data-google-map--form-target="map" style="height:20vh;max-width:300px;"></div>

    <div class="grid grid-cols-2 gap-2"> 
      <div class="col-span-1">
        <.input field={f[:latitude]} type="number" label="Latitude(readonly)" step="any" data-google-map--form-target='latitude' />
      </div>
      <div class="col-span-1">
        <.input field={f[:longitude]} type="number" label="Longitude(readonly)" step="any" data-google-map--form-target='longitude' />
      </div>
    </div>

    <:actions>
      <.button>Save Point</.button>
    </:actions>
  </.simple_form>
</div>

これでテンプレートはOKです。

コントローラー

それにしても、コントローラーとテンプレート、ルーターが同じ階層ってすごく扱いやすいですね。
頻度の高いものがそれぞれ固まっていて素敵です。(laravelとか触るとマジ発狂しそうになる民)

Image from Gyazo

では、追記していきましょう。

ここでは、data-json に渡すデーターをゴニョゴニョします。

point_controller.ex

DBをからの情報が必要なのは indexshow です。

それぞれ、 JS側が要求する形にしていきます。

  def index(conn, _params) do
    points = Maps.list_points()
    points_json = Jason.encode!( points |> Enum.map( fn o -> %{ id: o.id, name: o.name, lat: o.latitude, lng: o.longitude } end ) )
    render(conn, :index, points: points, points_json: points_json)
  end
  def show(conn, %{"id" => id}) do
    point = Maps.get_point!(id)
    point_json = %{ id: point.id, name: point.name, lat: point.latitude, lng: point.longitude }
      |> Jason.encode!
    render(conn, :show, point: point, point_json: point_json)
  end

全体だとこのような感じになります。

lib/google_map_phx_web/controllers/point_controller.ex
defmodule GoogleMapPhxWeb.PointController do
  use GoogleMapPhxWeb, :controller

  alias GoogleMapPhx.Maps
  alias GoogleMapPhx.Maps.Point

  def index(conn, _params) do
    points = Maps.list_points()
    points_json = Jason.encode!( points |> Enum.map( fn o -> %{ id: o.id, name: o.name, lat: o.latitude, lng: o.longitude } end ) )
    render(conn, :index, points: points, points_json: points_json)
  end

  def new(conn, _params) do
    changeset = Maps.change_point(%Point{})
    render(conn, :new, changeset: changeset)
  end

  def create(conn, %{"point" => point_params}) do
    case Maps.create_point(point_params) do
      {:ok, point} ->
        conn
        |> put_flash(:info, "Point created successfully.")
        |> redirect(to: ~p"/points/#{point}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :new, changeset: changeset)
    end
  end

  def show(conn, %{"id" => id}) do
    point = Maps.get_point!(id)
    point_json = %{ id: point.id, name: point.name, lat: point.latitude, lng: point.longitude }
      |> Jason.encode!
    render(conn, :show, point: point, point_json: point_json)
  end

  def edit(conn, %{"id" => id}) do
    point = Maps.get_point!(id)
    changeset = Maps.change_point(point)
    render(conn, :edit, point: point, changeset: changeset)
  end

  def update(conn, %{"id" => id, "point" => point_params}) do
    point = Maps.get_point!(id)

    case Maps.update_point(point, point_params) do
      {:ok, point} ->
        conn
        |> put_flash(:info, "Point updated successfully.")
        |> redirect(to: ~p"/points/#{point}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :edit, point: point, changeset: changeset)
    end
  end

  def delete(conn, %{"id" => id}) do
    point = Maps.get_point!(id)
    {:ok, _point} = Maps.delete_point(point)

    conn
    |> put_flash(:info, "Point deleted successfully.")
    |> redirect(to: ~p"/points")
  end
end

完成です。

確認してみましょう。

zsh
% mix phx.server

Image from Gyazo

はい、完了です。お疲れ様でした。

最後に

今回実はRailsで動かしたStimulusをほぼコピペ状態で移植してみました。

Webアプリの移植でJSのフレームワーク周りがネックだったりするのですが、この手間なら全然良いよなぁ・・という感じでした。

明日15日は、

@g_kenkun さんで、

「3年ぶりにElixirのAtCoder用Mixタスクライブラリを更新しました」

です。よろしくお願いいたします。

最後までご覧いただき、ありがとうございます。

13
1
8

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
1