Subscribe to access all episodes. View plans →
Published May 30, 2020
Elixir 1.10
Phoenix 1.5
Follow along with the episode starter on GitHub
Here we have an application that lists some movies. When we can click to view a movie, we’re taking to that movies page that has the full description and some extra information. And if we look at the URL we see that it’s using the movie’s ID from the database. While this works, it’s not very descriptive.
In this episode let’s update the URL to use a “slug”, And to for our “slugs” they’ll be URL safe versions or our titles. This will give each movie page a nice, readable URL. This will make our page more descriptive and help search engines to better recognize what the page is about.
Before we get started writing any code, let’s take a quick look at at our movie.ex
module. We have three fields, the “summary”, “title”, and “year”. And in our changeset function we’re using the unique_constraint
function to ensure each title is unique. We’ll want our slug
to be a field on our movie so let’s create a new column in our movies table to represent that. From the command line we’ll generate a new Ecto migration.
$ mix ecto.gen.migration add_slug_to_movies
* creating priv/repo/migrations/{timestamp}_add_slug_to_movies.exs
Let’s open that new migration file. Since our new column will be for our movies
table, let’s call alter table(:movies)
and then we’ll add a :slug
, which we’ll want to be a :string
. Because we’re going to using this field to lookup our movies let’s add an index to our slug field to speed up the database query.
priv/repo/migrations/{timestamp}_add_slug_to_movies.exs
defmodule Teacher.Repo.Migrations.AddSlugToMovies do
use Ecto.Migration
def change do
alter table(:movies) do
add :slug, :string
end
create index(:movies, [:slug])
end
end
Then we can go back to the command line and migrate our database.
$ mix ecto.migrate
...
Great, let’s go back to our Movie
module. And we’ll add slug
as a field in our movies
schema and then in our changeset
function we wont need to include it in our attributes to cast, since we’ll be creating it from the movie’s title. But let’s go ahead and make it required to ensure each movie has one.
Now we need to convert the movie title into a slug. To do that let’s create a new private function called build_slug
that will take the changeset
. Then in the function we’ll call the Ecto.Changeset.get_field
function. This will return the title
from the changeset
’s changes first or - if no changes exist - from the existing data. This will make it easier to update the existing movie records we have. Once we have our title we’ll build the slug
and then update our changeset
.
Then to create our slug
let’s use the slugify
package. Slugify offers some nice customizations so you can generate slugs in different formats. To use it in our project let’s grab the package from Hex and then add it to our Mixfile.
mix.exs
...
defp deps do
...
{:slugify, "~> 1.3"},
...
end
...
Then we'll go to the command line and run `mix deps.get`.
```
$ mix deps.get
...
```
Then going Back to our `movie.ex` module, we'll call `Slug.slugify` to create our `slug` from our `title`. And we'll update our changeset with our slug using `Ecto.Changeset.put_change`.
Then let's only call this if there's a `title` returned. If we don't get a `title` back we can simply return the `changeset` as-is. Now we can add our new `build_slug` function to our `changeset`'s pipeline.
One last change we'll need to make in this module, is to tell Phoenix to use use our `slug` instead of the database ID in the URL. To do this we'll derive the `Phoenix.Param` protocol specifying the key to use, in this case our `slug`.
lib/teacher/film/movie.ex
defmodule Teacher.Film.Movie do
use Ecto.Schema
import Ecto.Changeset
@derive {Phoenix.Param, key: :slug}
schema "movies" do
field :summary, :string
field :title, :string
field :year, :integer
field :slug, :string
timestamps()
end
@doc false
def changeset(movie, attrs) do
movie
|> cast(attrs, [:title, :summary, :year, :slug])
|> unique_constraint(:title)
|> build_slug()
|> validate_required([:title, :summary, :year, :slug])
end
defp build_slug(changeset) do
if title = get_field(changeset, :title) do
slug = Slug.slugify(title)
put_change(changeset, :slug, slug)
else
changeset
end
end
end
Now that our URL is updated to use our movie `slug`, we'll need to update how we lookup our movies from the database.
Let's open our `Film` module and the `get_movie` function is what we're using to lookup a given movie by it's ID. Let's change this to use the `slug`. We'll also want to update our `@doc`.
lib/teacher/film.ex
...
@doc """
Gets a single movie.
Raises `Ecto.NoResultsError` if the Movie does not exist.
## Examples
iex> get_movie!("movie-title")
%Movie{}
iex> get_movie!("doesnt-exist")
** (Ecto.NoResultsError)
"""
def get_movie!(slug), do: Repo.get_by!(Movie, slug: slug)
...
Now before we test our changes out, we need to create slugs for our existing movies. To do that let's run our app inside `IEx`. Once everything starts up, I'll clear the screen here to better see.
Then we'll `alias Teacher.Film` to get all of our movies. Now we can loop through each movie and update it with `Film.update_movie` passing in our movie, and then an empty map. If we setup everything correctly, our `build_slug` function will pull the title from each movie and generate a slug for it. It looks like our records were updated.
```
$ iex -S mix phx.server
> alias Teacher.Film
Teacher.Film
> movies = Film.list_movies()
[
...
]
> Enum.each(movies, fn(movie) ->
> Film.update_movie(movie, %{"title" => movie.title})
> end)
...
:ok
```
Now let's go to the browser and if view a movie - great our slugs are now being used.
Let's test our creating a new movie. We'll click add our "New Movie" link to trigger the form modal and I'll just enter some info for another movie and when we save our movie - we're taken to our index page where we can see our new movie is added to our list. And clicking to the movie's page works - and we see the URL is using the slug here too.
Now let's test one more thing - editing a movie title. If we edit this movie title. We get an error - it's using our old movie slug.
We're using Phoenix LiveView to manage our movies and the LiveView and templates we have were created with the `phx.gen.live` generator. This created a `FormComponent` module to handle saving, updating, and validating our movie form. Let's open that.
Here in the `save_movie` function, we can see it's redirecting to a `return_to` URL. This is based off the old movie record - before we updated the title, which created a new slug. Let's update this to always redirect to the movie show path, using the update movie record. And since we're using the updated `movie` we need to make sure we don't ignore it in the OK tuple above.
lib/teacher_web/live/movie_live/form_component.ex
...
defp save_movie(socket, :edit, movie_params) do
case Film.update_movie(socket.assigns.movie, movie_params) do
{:ok, movie} ->
{:noreply,
socket
|> put_flash(:info, "Movie updated successfully")
|> push_redirect(to: Routes.movie_show_path(socket, :show, movie))}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :changeset, changeset)}
end
end
...
Now when we go back to the browser and edit our movie title again the slug is updated and our new URL is used. Our app is now using slugs for movie URLs.
Kacper Muryn
4 years agoHello! Thanks for this video. Is it possible to create slug composed from two fields? I have such code:
def build_slug(changeset) do
end and it throws such error when I try to update each record as you did:
** (FunctionClauseError) no function clause matching in String.Unicode.graphemes/1
Alekx
4 years agoHey Kapeusz!
Yes, it is possible :) Your code from the example only returns the
last_name
field. So you’ll need to get thefirst_name
to use when creating the slug.In this modified example, maybe you only want to build the slug if both the
first_name
andlast_name
fields are present. You also want to be sure to use the returned values inSlug.slugify
not the atoms (:first_name
,:last_name
).Be sure to test to ensure it works for you. Hope this helps!
Kacper Muryn
4 years agoThank you, for quick reply! I made changes you suggested, however, it looks like the first_name is not included in the slug and it raises such error: ** (FunctionClauseError) no function clause matching in Keyword.get/3
EDIT: What worked for me is to add/change these lines: