defmodule OpenAgents.ProjectFields.ProjectField do
@moduledoc """
One stored column of a project board.
A field's `name` is the key an item's stored `values` map is written under, so
the name has to be unique within a project and stable across a rename. A
`single_select` field additionally carries option identifiers, and an item
stores the identifier rather than the label, so relabelling an option never
rewrites the items that chose it.
"""
use Ecto.Schema
import Ecto.Changeset
@data_types ~w(text number date single_select promise_state)
@option_data_types ~w(single_select promise_state)
@max_options 100
@doc "The data types a project field can declare."
def data_types, do: @data_types
@doc "Whether `data_type` carries a bounded set of options."
def options?(data_type), do: data_type in @option_data_types
schema "project_fields" do
field :name, :string
field :data_type, :string
field :options, :map
field :project_id, :id
timestamps(type: :utc_datetime)
end
@doc """
The stable identifiers a field's options offer, in declaration order.
A plain string option is its own identifier: the label is the identifier, and
relabelling it is a new option. An option written as `%{"id" =>, "name" =>}`
keeps its identifier when the label changes, which is what makes a rename
non-destructive for the items that already chose it.
"""
def option_ids(%__MODULE__{options: %{"values" => values}}) when is_list(values),
do: Enum.map(values, &option_id/1)
def option_ids(%__MODULE__{}), do: []
@doc "The identifier of one declared option."
def option_id(value) when is_binary(value), do: value
def option_id(%{"id" => id}) when is_binary(id), do: id
def option_id(_value), do: nil
@doc """
The label of one declared option.
A board heads its column with this and groups its cards by `option_id/1`, so
a relabel moves the heading and leaves the cards where they are.
"""
def option_name(value) when is_binary(value), do: value
def option_name(%{"name" => name}) when is_binary(name), do: name
def option_name(_value), do: nil
@doc "The declared options, as `%{id: ..., name: ...}`, in declaration order."
def options(%__MODULE__{options: %{"values" => values}}) when is_list(values) do
values
|> Enum.map(&%{id: option_id(&1), name: option_name(&1)})
|> Enum.reject(&is_nil(&1.id))
end
def options(%__MODULE__{}), do: []
@doc false
def changeset(project_field, attrs) do
project_field
|> cast(attrs, [:name, :data_type, :options, :project_id])
|> update_change(:name, &normalize_name/1)
|> validate_required([:name, :data_type, :project_id])
|> validate_length(:name, max: 100)
|> validate_inclusion(:data_type, @data_types)
|> validate_options()
|> unique_constraint(:name,
name: :project_fields_project_id_name_index,
message: "has already been taken on this project"
)
|> check_constraint(:data_type, name: :project_fields_data_type_check, message: "is invalid")
|> foreign_key_constraint(:project_id)
end
defp normalize_name(name) when is_binary(name), do: String.trim(name)
defp normalize_name(name), do: name
defp validate_options(changeset) do
data_type = get_field(changeset, :data_type)
options = get_field(changeset, :options)
cond do
data_type not in @data_types ->
changeset
not options?(data_type) ->
validate_no_options(changeset, options)
true ->
validate_option_values(changeset, options)
end
end
defp validate_no_options(changeset, options) when options in [nil, %{}], do: changeset
defp validate_no_options(changeset, _options),
do: add_error(changeset, :options, "are not supported for this data type")
defp validate_option_values(changeset, %{"values" => values})
when is_list(values) and values != [] do
cond do
length(values) > @max_options ->
add_error(changeset, :options, "carry at most #{@max_options} values")
Enum.any?(values, &is_nil(option_id(&1))) ->
add_error(
changeset,
:options,
~s(each value is a name, or an object with a string "id" and "name")
)
Enum.any?(values, &blank_option?/1) ->
add_error(changeset, :options, "cannot carry a blank value")
duplicate_ids?(values) ->
add_error(changeset, :options, "cannot repeat an identifier")
true ->
put_change(changeset, :options, %{"values" => Enum.map(values, &normalize_option/1)})
end
end
defp validate_option_values(changeset, _options),
do:
add_error(
changeset,
:options,
~s(must carry a non-empty "values" list for this data type)
)
defp blank_option?(value) do
id = option_id(value)
name = option_name(value)
String.trim(id) == "" or not is_binary(name) or String.trim(name) == ""
end
defp duplicate_ids?(values) do
ids = Enum.map(values, &option_id/1)
length(Enum.uniq(ids)) != length(ids)
end
defp normalize_option(value) when is_binary(value), do: String.trim(value)
defp normalize_option(%{} = value),
do: %{"id" => String.trim(option_id(value)), "name" => String.trim(option_name(value))}
end