Write the slug in an Ash resource

v0.0.1

With Ash in your project, MishkaSlug.Change writes the slug in an action, so you never call slugify/2 by hand. This guide adds the change to a resource, lets users type their own slug, previews the slug in a form and keeps slugs unique. You finish with a resource whose slug follows its title.

Before you begin

  • An Ash resource with a title attribute and a slug attribute.
  • Ash in the dependencies of the project. MishkaSlug.Change exists only when Ash is present.
  • Mishka Slug installed. See Get Started.

Add the change to an action

Put the change in each action that sets the title, the create and the update, or the slug will not follow the title.

elixir
attributes do
  uuid_primary_key :id
  attribute :title, :string, allow_nil?: false, public?: true
  attribute :language, :string, default: "fa", public?: true
  attribute :slug, :string, public?: true, constraints: [max_length: 20]
end

actions do
  defaults [:read]

  create :create do
    accept [:title, :language, :slug]

    change {MishkaSlug.Change,
            from: :title,
            into: :slug,
            language: :language,
            locales: {MyApp.Sites, :languages, []},
            keep: [".", "_"]}
  end
end
  • from: is the attribute or argument the slug is made from. into: is the attribute that gets it.
  • language: :language reads the record's language attribute. A string such as "fa" fixes the language.
  • locales: takes a list, or {module, function, args} called as module.function(changeset, ...args), so the site's languages can come from the tenant.
  • Every option of slugify/2 is accepted as it is. keep: [".", "_"] above is one of them.

The function in locales: returns the list:

elixir
defmodule MyApp.Sites do
  def languages(_changeset), do: ["fa", "en"]
end

Create a record

Create a record without a slug. The change makes it from the title.

elixir
post = Ash.create!(MyApp.Post, %{title: "آموزش React در ۱۰ دقیقه"}, action: :create)
post.slug
#=> "آموزش-react-در-۱۰"

post = Ash.create!(MyApp.Post, %{title: "Hello World: A very long title for a post", language: "en"}, action: :create)
post.slug
#=> "hello-world-a-very"

When you do not pass :max_length, the change uses the max_length constraint of the slug attribute. Here it is 20, so whole words are kept while they fit.

Read the error

A title that gives no slug is a changeset error on the title field, with the message of MishkaSlug.Messages.error/1. The slug attribute is never set to nil or an empty string.

elixir
{:error, %Ash.Error.Invalid{errors: [error]}} =
  Ash.create(MyApp.Post, %{title: "😀🚀"}, action: :create)

error.field
#=> :title

error.message
#=> "has no letters or digits to make a slug from"

A title in a script the site does not allow gives "uses letters or digits that the site languages do not allow in a slug". See Handle errors and translate messages to translate it.

Let users type their own slug

Set skip_if_present?: true and the change leaves the slug alone when the action already sets it.

elixir
create :create_custom do
  accept [:title, :language, :slug]
  change {MishkaSlug.Change, from: :title, into: :slug, skip_if_present?: true}
end
elixir
Ash.create!(MyApp.Post, %{title: "Some title", slug: "my-own-slug"}, action: :create_custom).slug
#=> "my-own-slug"

Ash.create!(MyApp.Post, %{title: "Some title"}, action: :create_custom).slug
#=> "some-title"

The change does not slug what a user typed. Run MishkaSlug.slugify/2 on it in a validation if you want a typed slug normalized.

Update the slug with the title

On an update, the change reads the title from the action. When the action does not touch the title, the change does nothing, so the slug is kept.

elixir
update :update do
  accept [:title]
  require_atomic? false

  change {MishkaSlug.Change,
          from: :title, into: :slug, language: :language, locales: ["fa", "en"]}
end
elixir
post |> Ash.Changeset.for_update(:update, %{title: "New title"}) |> Ash.update!() |> Map.get(:slug)
#=> "new-title"

Do not change the slug of a published page on every title edit unless you redirect the old address. Links to it break.

Preview the slug in a form

MishkaSlug.Change.slug_options/2 returns the options the change would pass to slugify/2, with the language and locales resolved against the changeset and :max_length filled from the attribute. Use it for a live preview, so the preview equals what is saved.

elixir
changeset = Ash.Changeset.for_create(MyApp.Post, :create, %{title: "x", language: "en"})
opts = [from: :title, into: :slug, language: :language, locales: ["fa", "en"]]

MishkaSlug.Change.slug_options(opts, changeset)
#=> [max_length: 20, locales: ["fa", "en"], language: "en"]

MishkaSlug.slugify(title, MishkaSlug.Change.slug_options(opts, changeset))

Keep slugs unique

The change does not check that a slug is free. Put a unique identity on the slug attribute, with the tenant attribute if you use multitenancy:

elixir
identities do
  identity :unique_slug, [:slug]
end

Reserved words are the job of the resource too.

Next steps