defmodule Formex.Ecto.CustomField.SelectAssoc do @behaviour Formex.CustomField import Ecto.Query alias Formex.Field alias Formex.Form @repo Application.get_env(:formex, :repo) @moduledoc """ This module generates a `:select` field with options downloaded from Repo. Example of use for Article with one Category: ``` schema "articles" do belongs_to :category, App.Category end ``` ``` form |> add(:category_id, Formex.Ecto.CustomField.SelectAssoc, label: "Category") ``` Formex will find out that `:category_id` refers to App.Category schema and download all rows from Repo ordered by name. If you are using `:without_choices` option (from `Formex.Field.create_field/3`), you don't need to implement `:choice_label_provider`, this module will do it for you. ## Options * `choice_label` - controls the content of ``'s. May be `:field_name`, `:assoc_name` or `[:assoc_name, :field_name]` Example of use: ``` schema "users" do field :first_name, :string field :last_name, :string belongs_to :department, App.Department end ``` ``` schema "departments" do field :name, :string field :description, :string end ``` Group by last name of user: ``` form |> add(:user_id, SelectAssoc, group_by: :last_name) ``` Group by department, by `:name` (default) field: ``` form |> add(:user_id, SelectAssoc, group_by: :department) ``` Group by department, but by another field ``` form |> add(:user_id, SelectAssoc, group_by: [:department, :description]) ``` * `search_field` - schema field to be used in query in `search/3`. If it's a `nil`, then the final value depends on the `choice_label` value: * if `:choice_label` is nil, `:search_field` becomes `:name` * if `:choice_label` is an atom, `:search_field` gets this atom * if `:choice_label` is a function, `:search_field` is still nil * `search_query` - if the `search_field` functionality is not enough for you, use this to apply your own query. It's necessary if you have more than one field to search, e.g. first name and last name. """ @doc false def create_field(form, name, opts) do if form.struct_module.__schema__(:association, name) == nil do create_field_single(form, name, opts) else create_field_multiple(form, name, opts) end end @doc """ Can be used in controller, along with `:without_choices` option from `Formex.Field.create_field/3`. It gets rows from repo that matches given `search` argument and returns them as `{label, id}` list. Example of use for [Ajax-Bootstrap-Select](https://github.com/truckingsim/Ajax-Bootstrap-Select): ``` def search_categories(conn, %{"q" => search}) do result = create_form(App.ArticleType, %Article{}) |> Formex.Ecto.CustomField.SelectAssoc.search(:category_id, search) |> Enum.map(fn {label, id} -> %{ "value" => id, "text" => label } end) json(conn, result) end ``` """ @spec search(form :: Form.t, name :: atom, search :: String.t) :: List.t def search(form, name, search) do name_id = Regex.replace(~r/_id$/, Atom.to_string(name), "") |> String.to_atom search = "%"<>search<>"%" module = form.struct_module.__schema__(:association, name_id).related form_field = Form.find(form, name) opts = form_field.opts query = if opts[:search_query] do opts[:search_query].(module, search) else search_field = case opts[:search_field] do x when is_atom(x) and not is_nil(x) -> x _ -> case opts[:choice_label] do x when is_atom(x) and not is_nil(x) -> x x when is_nil(x) -> :name x when is_function(x) -> raise "Provide a value for :search_field option in #{name} field" end end from(e in module, where: like(field(e, ^search_field), ^search) ) end query |> apply_query(opts[:query]) |> @repo.all |> group_rows(opts[:group_by]) |> generate_choices(opts[:choice_label]) end defp create_field_single(form, name_id, opts) do name = Regex.replace(~r/_id$/, Atom.to_string(name_id), "") |> String.to_atom module = form.struct_module.__schema__(:association, name).related opts = opts |> parse_opts(module) |> put_choices(module) Field.create_field(:select, name_id, opts) end defp create_field_multiple(form, name, opts) do module = form.struct_module.__schema__(:association, name).related opts = opts |> parse_opts(module) |> put_choices(module) selected = if form.struct.id do form.struct |> @repo.preload(name) |> Map.get(name) |> Enum.map(&(&1.id)) else [] end phoenix_opts = Keyword.merge(opts[:phoenix_opts] || [], [ selected: selected ]) opts = Keyword.merge(opts, phoenix_opts: phoenix_opts) Field.create_field(:multiple_select, name, opts) end defp put_choices(opts, module) do if !opts[:without_choices] do choices = module |> apply_query(opts[:query]) |> apply_group_by_assoc(opts[:group_by]) |> @repo.all |> group_rows(opts[:group_by]) |> generate_choices(opts[:choice_label]) Keyword.put(opts, :choices, choices) else Keyword.put(opts, :choice_label_provider, fn id -> row = from(e in module, where: e.id == ^id) |> apply_query(opts[:query]) |> apply_group_by_assoc(opts[:group_by]) |> @repo.one if row do get_choice_label_val(row, opts[:choice_label]) else nil end end) end end defp parse_opts(opts, module) do opts |> Keyword.update(:group_by, nil, fn(property_path) -> cond do is_list(property_path) -> property_path is_atom(property_path) -> cond do module.__schema__(:association, property_path) -> [property_path, :name] true -> [property_path] end true -> nil end end) end defp apply_query(query, custom_query) when is_function(custom_query) do custom_query.(query) end defp apply_query(query, _) do query end defp apply_group_by_assoc(query, [assoc|t]) do if Enum.count(t) > 0 do from(query, preload: [^assoc]) else query end end defp apply_group_by_assoc(query, _) do query end defp group_rows(rows, property_path) when is_list(property_path) do rows |> Enum.group_by(&(Formex.Utils.Map.get_property(&1, property_path))) end defp group_rows(rows, _) do rows end defp generate_choices(rows, choice_label) when is_list(rows) do rows |> Enum.map(fn row -> label = get_choice_label_val(row, choice_label) {label, row.id} end) |> Enum.sort(fn {name1, _}, {name2, _} -> name1 < name2 end) end defp generate_choices(grouped_rows, choice_label) when is_map(grouped_rows) do grouped_rows |> Enum.map(fn {group_label, rows} -> {group_label, generate_choices(rows, choice_label)} end) |> Map.new(&(&1)) end defp get_choice_label_val(row, choice_label) do cond do is_function(choice_label) -> choice_label.(row) !is_nil(choice_label) -> Map.get(row, choice_label) true -> if Map.has_key? row, :name do row.name else throw """ Field :name not found in the schema. You should provide the :choice_label value in SelectAssoc """ end end end end