This post will show you how to add a custom tooltip component to your Phoenix project that uses the PopperJS library.
You can view all the code in a simple project on Github. See a diff from the initial install at 922ecca..30e278a
This post wil also demonstrate how to incorporate any Javascript library that requires bindings to an element. In Phoenix that is done through Hooks.
The Component
In the CoreComponents
module, we have a tooltip component. I have defined a class attribute to allow style overrides, but with a defaults of nil so itās not required
I have also defined a slot which will take the content of the tooltip. The HTML of the tooltip is just a div element. This element has the phx-hook
attribute which tells Phoenix which set of hooks to bind this element with (more under Javascript).
Every element that is bound with hooks requires an id. Because we need to use tooltips easily, I have added a random ID generated through a random_id/1
function. The function takes a prefix just to help avoid collisions, even though weāre using random bytes. The end result looks like this <div id="tt_F3P5wcGhyQA"ā¦
defmodule PhoenixTooltipsWeb.CoreComponents do
# ...
attr :class, :string, default: nil
slot :inner_block, required: true
def tooltip(assigns) do
~H"""
<div id={random_id("tt")} class={["tooltip", @class]} role="tooltip" phx-hook="TooltipHook">
<%= render_slot(@inner_block) %>
<div class="arrow" data-popper-arrow></div>
</div>
"""
end
def random_id(prefix) do
prefix <> "_" <> (:crypto.strong_rand_bytes(8) |> Base.url_encode64(padding: false))
end
# ...
end
The Live View
Every demonstration needs a contrived example. Here I am producing a grid of HTML colours using the HTML standard colour names. The block is a colour and the tooltip is the name of the colour.
defmodule PhoenixTooltipsWeb.PageLive do
use PhoenixTooltipsWeb, :live_view
def mount(_params, _session, socket) do
{:ok, assign(socket, :color_names, color_names())}
end
def color_names() do
[
"AliceBlue",
"AntiqueWhite",
"Aqua",
#...
"BlueViolet",
#...
"WhiteSmoke",
"Yellow",
"YellowGreen"
]
end
end
The Heex template iterates through all the colour names and adds a button (so you can see the tooltip on focus/blur) with the colour as the background and a tooltip with the colour name as itās text.
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<%= for color <- @color_names do %>
<button class="h-16 rounded-none" style={"background-color:#{color}"}>
<.tooltip class="px-4 py-2 font-medium text-white bg-black border border-white rounded-md">
<%= color %>
</.tooltip>
</button>
<% end %>
</div>
The CSS
The CSS is almost directly from the PopperJS tutorial.
The .tooltip
class (included in the component by default) is initially set to display:none
and then, when given the data-show
attribute, is set to display:block
. The rest is the styling of the arrow and itās position.
.tooltip {
display: none;
}
.tooltip[data-show] {
display: block;
}
.tooltip > .arrow,
.tooltip > .arrow:before {
position: absolute;
width: 8px;
height: 8px;
background: inherit;
border: inherit;
border-bottom: none;
border-right: none;
}
.tooltip > .arrow {
visibility: hidden;
}
.tooltip > .arrow::before {
visibility: visible;
content: '';
transform: rotate(45deg);
}
.tooltip[data-popper-placement^='top'] > .arrow {
bottom: -5.5px;
}
.tooltip[data-popper-placement^='bottom'] > .arrow {
top: -5.5px;
}
.tooltip[data-popper-placement^='left'] > .arrow {
right: -5.5px;
}
.tooltip[data-popper-placement^='right'] > .arrow {
left: -5.5px;
}
The Javascript
The first step is to install the PopperJS library via npm. the --prefix assets
is needed to place the node related files in the assets/
directory.
$ npm install @popperjs/core --prefix assets
I have created a Tooltip
javascript class to manage the lifescycle of the tooltip. This can be pasted into the top of your app.js
file or in itās own file and imported into app.js
The constructor sets up the tooltip by creating a popperInstance
, linking the tooltip element to itās parent, and setting event listeners on the parent to show/hide the tooltip. For this example we are using mouseenter
and focus
to show the tooltip and mouseleave
and blur
to hide it. The class also stores destructors so that event lsiteners are removed when the tooltip is removed from the DOM (preventing possible memory leaks).
import { createPopper } from '@popperjs/core';
// A class to manage the tooltip lifecycle.
class Tooltip {
showEvents = ['mouseenter', 'focus'];
hideEvents = ['mouseleave', 'blur'];
$parent;
$tooltip;
popperInstance;
constructor($tooltip) {
this.$tooltip = $tooltip;
this.$parent = $tooltip.parentElement;
this.popperInstance = createPopper(this.$parent, $tooltip, {
modifiers: [
{
name: 'offset',
options: {
offset: [0, -8],
},
},
],
});
this.destructors = [];
// For each show event, add an event listener on the parent element
// and store a destructor to call removeEventListener
// when the tooltip is destroyed.
this.showEvents.forEach((event) => {
const callback = this.show.bind(this);
this.$parent.addEventListener(event, callback);
this.destructors.push(() =>
this.$parent.removeEventListener(event, callback)
);
});
// For each hide event, add an event listener on the parent element
// and store a destructor to call removeEventListener
// when the tooltip is destroyed.
this.hideEvents.forEach((event) => {
const callback = this.hide.bind(this);
this.$parent.addEventListener(event, callback);
this.destructors.push(() =>
this.$parent.removeEventListener(event, callback)
);
});
}
// The show method adds the data-show attribute to the tooltip element,
// which makes it visible (see CSS).
show() {
this.$tooltip.setAttribute('data-show', '');
this.update();
}
// Update the popper instance so the tooltip position is recalculated.
update() {
this.popperInstance?.update();
}
// The hide method removes the data-show attribute from the tooltip element,
// which makes it invisible (see CSS).
hide() {
this.$tooltip.removeAttribute('data-show');
}
// The destroy method removes all event listeners
// and destroys the popper instance.
destroy() {
this.destructors.forEach((destructor) => destructor());
this.popperInstance?.destroy();
}
}
Phoenix Hooks are where the magic happens.
Our TooltipHook
listens for three of the six possible lifecycle hooks.
-
mounted
is called when the tooltip has been added to the DOM and this is where we instantiate an instance of our Tooltip class and link it to the element. (this.el
is made available by Phoenix).
-
updated
is called if the element is updated by Phoenix and we simply pass on that message.
-
destroyed
is called when the element is removed from the DOM and we in turn call destroy
on the Tooltip
object so that the event listeners are removed.
Important: You need to update the let liveSocket = ā¦
line in app.js
and add , hooks: Hooks
which is not there by default.
// ...
const Hooks = {
TooltipHook: {
mounted() {
this.el.tooltip = new Tooltip(this.el);
},
updated() {
this.el.tooltip?.update();
},
destroyed() {
this.el.tooltip?.destroy();
},
},
};
// ...
let liveSocket = new LiveSocket('/live', Socket, {
params: { _csrf_token: csrfToken },
// add the Hooks object
hooks: Hooks,
});
// ...
By using Phoenix Client hooks we are able to connect the element of a component to a Javascript client library, add event listeners, and provide custom interaction.
See all the code on Github.
Elixir has some useful utility functions available in iex like h/1 which prints documentation on the given module or function/arity
pair.
You can add your own utility functions or macros by defining a utility module and then importing it into your .iex
file.
Example
defmodule MyApp.IexUtilities do
def u(id_or_username) do
MyApp.Users.find_user(id_or_username)
end
end
Import your utility module in your .iex
file in the project root
# .iex
import MyApp.IexUtilities
and the function is available in your iex session
iex> user = u "demo"
%MyApp.User{id: 42, username: "demo", name: "John Doe"}
Macros
You can take this a bit further and automatically assign it to a variable within the iex session by using a macro and an unhygienic variable. The variable defined with var!/1
will bleed out to the outer scope meaning you can type u "username"
and have the result automaitcally added to a variable, in this case user
;
defmodule MyApp.IexUtilities do
defmacro u(id_or_username) do
var!(user) = MyApp.Users.find_user(unquote(id_or_username))
end
end
and now in your iex session you can easily lookup a user to work with.
iex> u "demo"
%MyApp.User{id: 42, username: "demo", name: "John Doe"}
iex> user
%MyApp.User{id: 42, username: "demo", name: "John Doe"}
I had a GenServer that I wanted to change the state of during a hot upgrade release, so I dutifully reached for code_change/3
as per the documentation, but no matter how hard I tried, I couldnāt get it to work.
I read and re-read all the documentation I could find on releases and hot upgrades and tried and tried again but my callback was never called.
I quite like Dave Thomasā method of splitting the API from the server implementation so my code looked something like this:
defmodule MyStore do
def child_spec(opts) do
%{
id: MyStore.Server,
start: {MyStore, :start_link, [opts]},
type: :worker,
restart: :permanent,
shutdown: 500
}
end
def start_link(args \\ nil, opts \\ []) do
GenServer.start_link(MyStore.Server, args, opts)
end
def put(pid, key, value) do
GenServer.call(pid, {:put, key, value})
end
def get(pid, key) do
GenServer.call(pid, {:get, key})
end
defmodule Server do
use GenServer
require Logger
@impl true
def init(_opts) do
{:ok, []}
end
@impl true
def handle_call({:put, key, value}, _from, server_state) do
server_state = [{key, value} | server_state]
{:reply, :ok, server_state}
end
def handle_call({:get, key}, _from, server_state) do
{:reply, Keyword.get(server_state, key), server_state}
end
@vsn "1"
@impl true
def code_change(from_vsn, server_state, _extra) do
Logger.info("code_change from: #{inspect(from_vsn)}")
{:ok, server_state}
end
end
end
A very simple and contrived example of a store running on a GenServer with the obvious flaw that itās implemented as a keyword list instead of the more obvious map. So the idea is to change the state via a hot upgrade.
Adding the following code_change/3
code before the original implementation should do the trickāalong with updating the server API to use the map.
defmodule Server do
use GenServer
require Logger
@impl true
def init(_opts) do
{:ok, %{}}
end
@impl true
def handle_call({:put, key, value}, _from, server_state) do
server_state = Map.put(server_state, key, value)
{:reply, :ok, server_state}
end
def handle_call({:get, key}, _from, server_state) do
{:reply, Map.get(server_state, key), server_state}
end
@vsn "2"
@impl true
# Ignoring downgrading for this example
def code_change("1", server_state, _extra) do
Logger.info("code_change from: #{inspect(server_state)}")
{:ok, Map.new(server_state)}
end
def code_change(from_vsn, server_state, _extra) do
Logger.info("code_change from: #{inspect(from_vsn)}")
{:ok, server_state}
end
end
All good. So have you found out whatās wrong yet? Neither had I.
So far as I can tell, there is nothing wrong with my code. The problem isnāt even visible here, it becomes apparent when you look at the supervisor and how Erlang finds the processes itās going to run code_change/3
against.
During an application upgrade, the Release handler works through the supervision tree and pauses processes that need updating. It then runs the code_change/3
function on the module for each process and then unpauses the processes and finalises the release.
The appup file for the example above would look something like this:
{"2",
[{"1", [{update, 'Elixir.MyStore.Server', {advanced, []}}]}],
[{"1", [{update, 'Elixir.MyStore.Server', {advanced, []}}]}]
}.
That looks fine. We want the upgrade to run MyStore.Server.code_change/3
.
When the map is started under a dynamic supervisor, the response from which_children/1
is
[{:undefined, #PID<0.161.0>, :worker, [MyStore]}]
This is the same result that Erlang gets when it retrieves all supervised processes in get_supervised_procs/0
which is āā¦the magic function. It finds all process in the system and which modules they execute as a call_back or process module.ā
{:undefined, #PID<0.161.0>, :worker, [MyStore]}
is included in the results of :release_handler_1.get_supervised_procs()
(which I was super happy to find was an exported functionāthank you Erlang) and there we have the problemā==Erlang thinks that MyStore
is the module that is being executed as the call_back or process module, not MyStore.Server
==
Because MyStore
is not listed as changing in the appup file, no code_change/3
is called on it, and because MyStore.Server
isnāt listed as a module of a running process, code_change/3
isnāt called on that module either and so the process is left, state unchanged, and the next call to the process will have the incorrect state and the process will crash š£.
After a lot of code spelunking I have identified the problem and the solution is quite a simple change: move start_link/3
into MyStore.Server
and update the child_spec accordingly.
defmodule MyStore do
def child_spec(opts) do
%{
id: MyStore.Server,
start: {MyStore.Server, :start_link, [opts]},
type: :worker,
restart: :permanent,
shutdown: 500
}
end
#...
defmodule Server do
use GenServer
require Logger
def start_link(args \\ nil, opts \\ []) do
GenServer.start_link(Server, args, opts)
end
#...
end
end
Now the output of :release_handler_1.get_supervised_procs()
looks like this:
[#...
{:undefined, #PID<0.161.0>, :worker, [MyStore.Server]}]
and code_change/3
is correctly called š.
I always appreciate gaining a deeper understanding of how the underlying toolset of a system works and I hope that when you are searching for āwhy code_change isnāt called on my GenServerā youāll get this helpful result ;-)
Phoenix 1.4 is on itās way
and one of the big changes is that webpack
is replacing brunch. If you are a
SASS
fan then this is how to update the default Webpack configuration to use SASS (SCSS flavour).
Install NPM packages
The first step is to install the node-sass
and sass-loader
packages from NPM.
Using Yarn
$ yarn add node-sass sass-loader --dev
Using NPM
$ npm install node-sass sass-loader --save-dev
Update webpack.config.js
Update the assets/webpack.config.js
file with a change to chain the sass-loader
plugin after the css-loader
.
diff --git a/assets/webpack.config.js b/assets/webpack.config.js
index 5225785..4c14948 100644
--- a/assets/webpack.config.js
+++ b/assets/webpack.config.js
@@ -26,8 +26,18 @@ module.exports = (env, options) => ({
}
},
{
- test: /\.css$/,
- use: [MiniCssExtractPlugin.loader, 'css-loader']
+ test: /\.scss$/,
+ use: [
+ MiniCssExtractPlugin.loader,
+ {
+ loader: 'css-loader',
+ options: {}
+ },
+ {
+ loader: 'sass-loader',
+ options: {}
+ }
+ ]
}
]
},
Update app.css
Rename your assets/css/app.css
to assets/css/app.scss
.
$ mv assets/css/app.css assets/css/app.scss
Update app.js
Because the CSS files are loaded by Webpack through the javascript file, you need to update the css import path as well.
diff --git a/assets/js/app.js b/assets/js/app.js
index 8ee7177..0aa55a0 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -1,7 +1,7 @@
// We need to import the CSS so that webpack will load it.
// The MiniCssExtractPlugin is used to separate it out into
// its own CSS file.
-import css from "../css/app.css"
+import css from "../css/app.scss"
// webpack automatically bundles all modules in your
// entry points. Those entry points can be configured
Build assets
Finally test your assets build.
$ node node_modules/webpack/bin/webpack.js --mode development
With Phoenix 1.4 announced at ElixirConf EU (https://youtu.be/MTT1Jl4Fs-E) I was keen to try it out. I was specifically interested in seeing the new Webpack integration.
Getting started with Phoenix 1.4 is really quite easy.
Uninstall the existing Phoenix 1.3 archive
From the README,
Remove any previously installed phx_new
archives so that Mix will pick up the local source code. This can be done with
mix archive.uninstall phx_new
or by simply deleting the file, which is usually in ~/.mix/archives/
.
Clone the Phoenix master repo
$ git clone https://github.com/phoenixframework/phoenix
Build and install the Phoenix archive
$ cd phoenix/installer
$ MIX_ENV=prod mix do archive.build, archive.install
Generate your new Phoenix 1.4 app
Run mix phx.new my_app
Your mix.exs deps will now look like this:
defp deps do
[
{:phoenix, github: "phoenixframework/phoenix", override: true},
#ā¦
]
end
When Phoenix 1.4 is released, you can just update this line to:
defp deps do
[
{:phoenix, "~> 1.4.0"},
#ā¦
]
end
Revert back to the Phoenix 1.3 installer
Reverting to the 1.3 installer is as easy as uninstalling and reinstalling the Phoenix archive.
Related
mix archive.uninstall phx_new-1.3.0
mix archive.install https://github.com/phoenixframework/archives/raw/master/phx_new.ez
I have often wanted to just do the following but Ectoās Repo module doesnāt have a count method.
iex> MyApp.Repo.count(MyApp.Account)
42
It is not too difficult to create a count
function that will allow you to count the results of any query.
defmodule MyApp.DBUtils do
import Ecto.Query, only: [from: 2]
@doc "Generate a select count(id) on any query"
def count(query),
do: from t in clean_query_for_count(query), select: count(t.id)
# Remove the select field from the query if it exists
defp clean_query_for_count(query),
do: Ecto.Query.exclude(query, :select)
end
This will provide a shortcut for counting any query
MyApp.DBUtils.count(MyApp.Account) |> Repo.one!
Now, to enable Repo.count
we can modify the repo module usually found in lib/my_app/repo.ex
defmodule MyApp.Repo do
use Ecto.Repo, otp_app: :my_app
def count(query),
do: MyApp.DBUtils.count(query) |> __MODULE__.one!
end
Thatās it. This will enable a count on any query including complicated queries and those that have a select expression set.
Appending to a list in Elixir ([1] ++ [2]
) is slower than prepending and reversing
[ 2 | [1] ] |> Enum.reverse
but how bad is it?
Start by creating a new project, mix new benchmarking
and add benchfella as a dependency in your mix.exs file
defp deps do
[{:benchfella, "~> 0.3.2"}]
end
and run mix deps.get
Benchfella
benchmarks work similarly to tests. Create a directory named bench
and then create a file ending in _bench.exs
. Benchfella will find these files and run them.
Create a file bench/list_append_bench.exs
We will write our functions in the bench file but you can reference functions in another module to benchmark your project code.
This benchmark will test three different ways to build a list, (1) append each element to the list using ++
, (2) build up the list using a recursive tail where the element is added to the head but the tail is built up recursively, and (3) prepending the element to a list accumulator and then reversing the list at the end.
defmodule ListAppendBench do
use Benchfella
@length 1_000
# First bench mark
bench "list1 ++ list2" do
build_list_append(1, @length)
end
# Second bench mark
bench "[head | recurse ]" do
build_list_recursive_tail(1, @length)
end
# Third bench mark
bench "[head | tail] + Enum.reverse" do
build_list_prepend(1, @length)
end
@doc """
Build a list of numbers from `num` to `total` by appending each item
to the end of the list
"""
def build_list_append(num, total, acc \\ [])
def build_list_append(total, total, acc), do: acc
def build_list_append(num, total, acc) do
acc = acc ++ [num]
next_num = num + 1
build_list_append(next_num, total, acc)
end
@doc """
Build a list of numbers from `num` to `total` by building
the list with a recursive tail instead of using an accumulator
"""
def build_list_recursive_tail(total, total), do: []
def build_list_recursive_tail(num, total) do
[ num | build_list_recursive_tail(num + 1, total) ]
end
@doc """
Build a list of numbers from `num` to `total` by prepending each item
and reversing the list at the end
"""
def build_list_prepend(num, total, acc \\ [])
def build_list_prepend(total, total, acc), do: Enum.reverse(acc)
def build_list_prepend(num, total, acc) do
acc = [num | acc]
next_num = num + 1
build_list_prepend(next_num, total, acc)
end
end
Run the benchmark with mix bench
and you see the results,
Settings:
duration: 1.0 s
## ListAppendBench
[10:15:32] 1/3: list1 ++ list2
[10:15:34] 2/3: [head | tail] + Enum.reverse
[10:15:37] 3/3: [head | recurse ]
Finished in 6.66 seconds
## ListAppendBench
[head | tail] + Enum.reverse 100000 20.87 µs/op
[head | recurse ] 100000 21.25 µs/op
list1 ++ list2 500 3228.16 µs/op
The results: prepending to a list and reversing it is 200 times faster than appending and only fractionally faster than building the tail recursively.
For more complex benchmarks, Benchfella
has various hooks for test setup and teardown.
It also has ability to compare benchmark runs with mix bench.cmp
and graph the results with mix bench.graph
.