Skip to content

Workshop where we build an application that renders restaurants on a map, powered by Phoenix Channels.

Notifications You must be signed in to change notification settings

lisbon-elixir/phoenix-channels-workshop

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

8 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Elixir Workshop

Step 1: Create Project

Step 2: Add tomato library

  • Communicate with the Zomato API
    • Add tomato library to mix.exs

      defp deps do
        [
          ...
          {:tomato, "~> 0.1.1"},
          ...
        ]
      end
    • Configure tomato app

      config :tomato,
        zomato_api_key: "06f7e6b2df1c9baf49e3a69a3defac49",
        zomato_api_uri: "https://developers.zomato.com/api/v2.1/"
    • Restart web app

      <Ctrl-C><Ctrl-C>
      
      iex -S mix phx.server
      
    • Explore the API of the tomato library and see how it maps to the Zomato REST API

    • Try to find restaurants by geolocation

      Tomato.geocode(38.733563, -9.144688)

Step 3: Add simple /restaurants endpoint

  • Create /restaurants endpoint in router

    # lib/restaurants_web/router.ex
    
    scope "/", RestaurantsWeb do
      pipe_through :browser
    
      get "/", PageController, :index
      get "/restaurants", RestaurantController, :index
    end
  • Create controller

    # lib/restaurants_web/controllers/restaurant_controller.ex
    
    defmodule RestaurantsWeb.RestaurantController do
      use RestaurantsWeb, :controller
    
      @lisbon_id 82
    
      def index(conn, _params) do
        {:ok, restaurants} = Tomato.search(%{entity_type: "city", entity_id: @lisbon_id})
        render(conn, "index.html", restaurants: restaurants)
      end
    end
  • Create view

    # lib/restaurants_web/views/restaurant_view.ex
    
    defmodule RestaurantsWeb.RestaurantView do
      use RestaurantsWeb, :view
    end
  • Create template

    # lib/restaurants_web/templates/restaurant/index.html.eex
    
    <%= for restaurant <- @restaurants do %>
      <li><%= restaurant.name %></li>
    <% end %>
  • Visit http://localhost:4000/restaurants and you should see something like the following:

Step 4: Static render of restaurants in map

  • Add Leaflet.js library to render map

    # lib/restaurants_web/templates/layout/app.html.eex
    
    <head>
      ...
      <link rel="stylesheet" href="https://unpkg.com/leaflet@1.3.4/dist/leaflet.css" integrity="sha512-puBpdR0798OZvTTbP4A8Ix/l+A4dHDD0DGqYW6RQ+9jxkRFclaxxQb/SJAWZfWAkuyeQUytO7+7N4QKrDh+drA==" crossorigin=""/>
      <script src="https://unpkg.com/leaflet@1.3.4/dist/leaflet.js" integrity="sha512-nMMmRyTVoLYqjP9hrbed9S+FzjZHW5gY1TWCHA5ckwXZBadntCNs8kEqAWdrb9O7rxbCaA4lKTIWjDXZxflOcA==" crossorigin=""></script>
    </head>
  • Update restaurant view to display Leaflet.js map

    # lib/restaurants_web/templates/restaurant/index.html.eex
    
    <div style="height:720px; width:1280px;" id="mapid"></div>
    <script type="text/javascript">
      window.mymap = L.map('mapid').setView([38.71667, -9.16667], 13);
    
      L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
        attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CartoDB</a>',
        reuseTiles: true,
        detectRetina: true,
        maxZoom: 18,
        minZoom: 1,
        noWrap: true
      }).addTo(window.mymap);
    </script>
  • Update max width on main container

    # assets/css/phoenix.css
    
    # ...
    .container {
      # ...
      max-width: 133.0rem;
      # ...
    }
    # ...
  • Visit http://localhost:4000/restaurants and you should see something like the following:

  • Add markers for each restaurant on top of Leaflet.js map

    # lib/restaurants_web/templates/restaurant/index.html.eex
    
    window.mymap = ...
    
    <%= for restaurant <- @restaurants do %>
      var marker = L.marker([
        <%= restaurant.location.latitude %>,
        <%= restaurant.location.longitude %>,
      ]).addTo(mymap);
    
      marker.bindPopup("<a href=\"<%= restaurant.url %>\"><%= restaurant.name %></a></br>");
    <% end %>
    
    L.tileLayer(...
    
    ...
  • Visit http://localhost:4000/restaurants and you should see something like the following:

Step 5: Connect to Phoenix server via WebSockets

  • Add user channel to communicate with front-end via WebSockets

    # lib/restaurants_web/channels/user_socket.ex
    
    channel "user:*", RestaurantsWeb.UserChannel
    # lib/restaurants_web/channels/user_channel.ex
    
    defmodule RestaurantsWeb.UserChannel do
      use RestaurantsWeb, :channel
      require Logger
    
      def join("user:" <> user_id, _auth, socket) do
        Logger.info("Receiving connection for user #{user_id}")
        {:ok, socket}
      end
    end
  • Change socket connection to subscribe to user topic

    // assets/js/socket.js
    
    ...
    
    const channel = socket.channel("user:123", {})
    
    channel.join()
      .receive("ok", resp => { console.log("Joined successfully", resp) })
      .receive("error", resp => { console.log("Unable to join", resp) })
    
    ...
    
    export default socket
    export {channel}
  • Create dynamic restaurants module (used in next step)

    // assets/js/dynamic_restaurants.js
    
    import {channel} from "./socket"
    
    let DynamicRestaurants = {
      init() {
        console.log("Init DynamicRestaurants")
      },
    }
    
    export default DynamicRestaurants
  • Glue everything together by actually calling DynamicRestaurants.init()

    // assets/js/app.js
    
    ...
    
    import DynamicRestaurants from "./dynamic_restaurants"
    DynamicRestaurants.init()
  • Visit http://localhost:4000/restaurants and you should see something like the following:

Step 6: Static render of restaurants in map via WebSockets

  • Remove rendering of restaurants via MVC model

    # lib/restaurants_web/templates/restaurant/index.html.eex
    # Remove the following code
    
    ...
    
    <%= for restaurant <- @restaurants do %>
      var marker = L.marker([
        <%= restaurant.location.latitude %>,
        <%= restaurant.location.longitude %>,
      ]).addTo(mymap);
    
      marker.bindPopup("<a href=\"<%= restaurant.url %>\"><%= restaurant.name %></a></br>");
    <% end %>
    
    ...
  • Visit http://localhost:4000/restaurants and you should see something like the following:

  • Render restaurants via WebSockets using Phoenix channels

    • Server side (back-end):

      # lib/restaurants_web/channels/user_channel.ex
      
      ...
      
      def handle_in("get_restaurants", %{"coords" => %{"lat" => lat, "lng" => lng}}, socket) do
        {:ok, %{"nearby_restaurants" => restaurants}} = Tomato.geocode(lat, lng)
      
        response =
          restaurants
          |> Enum.map(&render_restaurant/1)
      
        {:reply, {:ok, %{restaurants: response}}, socket}
      end
      
      def render_restaurant(%{"restaurant" => restaurant}) do
        %{
          name: restaurant["name"],
          url: restaurant["url"],
          latitude: restaurant["location"]["latitude"],
          longitude: restaurant["location"]["longitude"]
        }
      end
    • Client side (front-end):

      // assets/js/dynamic_restaurants.js
      
      ...
      
      let DynamicRestaurants = {
        init() {
          this.getRestaurants(window.mymap.getCenter())
        },
      
        getRestaurants(coords) {
          channel.push("get_restaurants", {coords: coords}).receive(
            "ok", ({restaurants}) => {
              restaurants.forEach(r => {
                DynamicRestaurants.renderRestaurant(r.latitude, r.longitude, r.name, r.url)
              });
            }
          )
        },
      
        renderRestaurant(lat, long, name, url) {
          const marker = window.L.marker(window.L.latLng(lat, long)).addTo(window.mymap);
          marker.bindPopup(`<a href="${url}">${name}</a></br>`)
        }
      }
  • Visit http://localhost:4000/restaurants and you should see something like the following:

Step 7: Dynamic render of restaurants in map

  • Add handler for map move

    // assets/js/dynamic_restaurants.js
    
    init() {
      window.mymap.on('moveend', this.handleMapMove)
      this.getRestaurants(window.mymap.getCenter())
    },
    
    ...
    
    handleMapMove() {
      DynamicRestaurants.getRestaurants(window.mymap.getCenter())
    }
  • Now clear previous markers on each move

    # lib/restaurants_web/templates/restaurant/index.html.eex
    
    ...
    
    window.mymap = L.map('mapid').setView([38.71667, -9.16667], 13)
    window.layerGroup = L.layerGroup().addTo(window.mymap)
    
    ...
    // assets/js/dynamic_restaurants.js
    
    let DynamicRestaurants = {
      ...
    
      renderRestaurant(lat, long, name, url) {
        const marker = window.L.marker(window.L.latLng(lat, long)).addTo(window.layerGroup)
        marker.bindPopup(`<a href="${url}">${name}</a></br>`)
      },
    
      handleMapMove() {
        window.layerGroup.clearLayers()
        DynamicRestaurants.getRestaurants(window.mymap.getCenter())
      }
    }
  • Visit http://localhost:4000/restaurants and you should see something like the following:

Step 8: Broadcast favorite restaurants from server

  • Add handler for get_favorite_restaurants message

    // assets/js/dynamic_restaurants.js
    
    let DynamicRestaurants = {
    init() {
      window.mymap.on('moveend', this.handleMapMove);
      channel.on("get_favorite_restaurants", ({restaurants}) =>
        restaurants.forEach(r => {
          this.renderFavoriteRestaurant(r.latitude, r.longitude, r.name, r.url)
        })
      )
    
      // ...
    
      renderFavoriteRestaurant(lat, long, name, url) {
        const icon = window.L.divIcon({
          iconAnchor: [6, 60],
          popupAnchor: [0, -50],
          className: 'no-background',
          html: '<div class="fa fa-star fa-big orange"></div>'
        });
    
        const favorite_marker = window.L.marker(L.latLng(lat, long), {
          icon
        }).addTo(window.layerGroup);
    
        favorite_marker.bindPopup(`<a href="${url}">${name}</a></br>`)
      },
    
      // ...
  • Update app.css with necessary classes to render stars above markers

    /* assets/css/app.css */
    
    /* ... */
    
    .orange {
      color: orange;
    }
    
    .fa-big {
      transform: scale(1.5, 1.5);
    }
    
    .no-background {
      background: transparent;
    }
    
    @import "./phoenix.css";
  • Add link tag to FontAwesome (to user the star icon)

    # lib/restaurants_web/templates/layout/app.html.eex
    
    # ...
    <head>
      # ...
      <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.1/css/all.css" integrity="sha384-50oBUHEmvpQ+1lW4y57PTFmhCaXp0ML5d60M1M7uH2+nqUivzIebhndOJK28anvf" crossorigin="anonymous">
      # ...
    </head>
    # ...
  • Asynchronously process favorite restaurants in the server (and broadcast them)

    # lib/restaurants_web/channels/user_channel.ex
    
    # ...
    
    def handle_in("get_restaurants", %{"coords" => %{"lat" => lat, "lng" => lng}}, socket) do
    
      # ...
    
      spawn_link(__MODULE__, :process_favorite_restaurants, [socket, restaurants])
    
      # ...
    
      def process_favorite_restaurants(socket, restaurants) do
        favorite_restaurants =
          restaurants
          |> Enum.filter(fn restaurant ->
            restaurant
            |> Map.get("restaurant")
            |> Map.get("user_rating")
            |> Map.get("aggregate_rating")
            |> String.to_float()
            |> Kernel.>(4.7)
          end)
          |> Enum.map(&render_restaurant/1)
    
        broadcast!(socket, "get_favorite_restaurants", %{restaurants: favorite_restaurants})
      end
    end
  • Visit http://localhost:4000/restaurants and you should see something like the following:


The End.

We hope you enjoyed the workshop :) Thank you!

About

Workshop where we build an application that renders restaurants on a map, powered by Phoenix Channels.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published