How would i go about creating an input field for tags?

I may be going about this the wrong way but I can’t seem to figure out a good way to handle input for “tags” in a blog post. I’ve looked at using .inputs_for but I don’t want to create an input field for every tag. Ideally, it would just be a single text input that spits the value on spaces and transforms those into “tag” structs. Maybe this would be better to do outside a form?

I’ve been messing with it for hours and this doesn’t seem like it should be that difficult. Hopefully someone can point out an easier way I’m missing. Here is what I have right now (back to square one because the handful of things I tried hadn’t worked).

form_component.ex

#...
  @impl true
  def render(assigns) do
    ~H"""
    <div>
      <.header>
        <%= @title %>
        <:subtitle>by <%= @post.author %></:subtitle>
      </.header>

      <.simple_form
        :let={form}
        for={@form}
        id="post-form"
        as={"post_form"}
        phx-target={@myself}
        phx-change="validate"
        phx-submit="save"
      >
        <.input field={@form[:title]} type="text" label="Title" />
        <.input field={@form[:tags]} type="text" label="Tags"/> # <<<< This doesn't work, of course.
        <.input field={@form[:author]} type="hidden" />
        <.input field={@form[:body]} type="textarea" label="Body" />
        <:actions>
          <.button phx-disable-with="Saving...">Save Post</.button>
        </:actions>
      </.simple_form>
    </div>
    """
  end
# ...

post.ex

defmodule Personal.Blog.Post do
  use Ecto.Schema
  import Ecto.Changeset

  schema "posts" do
    field :author, :string
    field :body, :string
    field :title, :string
    field :views, :integer, default: 0
    many_to_many(:tags, Personal.Blog.Tag, join_through: "post_tags",  on_replace: :delete)

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(post, attrs) do
    post
    |> cast(attrs, [:title, :author, :body, :views])
    |> validate_required([:title, :author, :body, :views])
  end
end

tag.ex

defmodule Personal.Blog.Tag do
  use Ecto.Schema
  import Ecto.Changeset

  schema "tags" do
    field :name, :string, default: ""
    many_to_many(:posts, Personal.Blog.Post, join_through: "post_tags", on_replace: :delete)

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(tag, attrs) do
    tag
    |> cast(attrs, [:name])
    |> validate_required([:name])
  end
end

post_tag.ex

defmodule Personal.Blog.PostTag do
  use Ecto.Schema
  import Ecto.Changeset

  schema "post_tags" do
    belongs_to(:posts, Personal.Blog.Post)
    belongs_to(:tags, Personal.Blog.Tag)

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(post_tag, attrs) do
    post_tag
    |> cast(attrs, [:post_id, :tag_id])
    |> validate_required([:post_id, :tag_id])
  end
end

There’s a bit to unpack here as tagging implementations often vary to support different use cases. For example, do you want that text input to also act like a typeahead with suggestions/autocomplete for existing tags? Do you allow creations of new tags on the fly? Do you want to support removing added tags?

Here’s a relevant article on A Reusable Multi-Tag Selection Component for Phoenix LiveView that demonstrates how to create a client side hook which watches for comma seperated tags. And here’s previous discussion on Building a dynamic select component (in Phoenix LiveView?) - #4 by kartheek and filtering/narrowing down options based on a typeahead input.

You can also give LiveSelect a try. It doesn’t split values on spaces though.

1 Like

If you only support tag entry as a comma-separated list it could be accomplished using something like

@taglist_delimiter Regex.compile!("[[:space:]]*,[[:space:]]*")

def create_post(post_params, available_tags) do
  post_tags =
    post_params
    |> Map.get("taglist", "")
    |> String.split(@taglist_delimiter)
    |> Enum.map(fn tag_name ->
      Enum.find(available_tags, %Tag{name: tag_name}, fn %Tag{name: name} -> tag_name == name end)
    end)

  %Post{}
  |> Post.changeset(post_params)
  |> put_assoc(:tags, post_tags)
  |> store_post()
  |> case do
    {:ok, saved_post} -> do_something_with_saved_post(saved_post)
    {:error, post_changeset} -> render_form_again(post_changeset)
  end
end

Those are all great questions. I think I was more focused on trying to understand a clean way to handle an Ecto schema that contains associations inside a form.

These are some great resources, thank you so much! I probably should have spent some more time searching. I was just too focused on the problem and getting frustrated.

@nmk This would only work for a predefined list of tags correct? I was thinking it might be easier (to use, not to code) if I had it spit it then create the tags if they don’t exist. Which I think would be pretty much what you suggested but with checking to see if I can grab the tag from the database first before creating a new one.

This looks promising, I don’t want to have to reinvent the wheel if there is a good solution out there for it. Although, if it’s a simple thing to impliement it might be better to avoid the dependency. I’ll give it another shot after going through the resources people have provided me here and if it turns out to be too much work I’ll turn to this. Thanks a ton, I appreciate the resource!

1 Like