Default Memberfields closes #74 #48 #49 #50 #81

Merged
carla merged 6 commits from feature/74_memberfields into main 2025-06-25 14:28:07 +02:00
16 changed files with 730 additions and 20 deletions

View file

@ -9,6 +9,7 @@ migrate-database:
reset-database:
mix ash.reset
MIX_ENV=test mix ash.reset
carla marked this conversation as resolved Outdated

Why not use ash.reset here as well?

Why not use `ash.reset` here as well?

typo that works as well. but i change it

typo that works as well. but i change it
seed-database:
mix run priv/repo/seeds.exs

View file

@ -14,6 +14,23 @@ defmodule Mv.Membership.Member do
create :create_member do
primary? true
argument :properties, {:array, :map}
accept [
:first_name,
:last_name,
:email,
:birth_date,
:paid,
:phone_number,
:join_date,
:exit_date,
:notes,
:city,
:street,
:house_number,
:postal_code
]
change manage_relationship(:properties, type: :create)
end
@ -21,12 +38,138 @@ defmodule Mv.Membership.Member do
primary? true
require_atomic? false
carla marked this conversation as resolved Outdated

I think this will run using ash's "notifiers", and as such will run outside the transaction used for the destroy itself. This means that we could end up with errors if postgres detects that a property does not have an associated member.

Also, I think the "references" block in the property's postgres configuration should take make this line obsolete because it tells postgres to cascade delete properties when a member is deleted? 🤔

I think this will run using ash's "notifiers", and as such [will run outside the transaction used for the destroy itself](https://hexdocs.pm/ash/notifiers.html#transactions). This means that we could end up with errors if postgres detects that a property does not have an associated member. Also, I think the "references" block in the property's postgres configuration should take make this line obsolete because it tells postgres to cascade delete properties when a member is deleted? 🤔

I tried different approaches to get the cascade deletion working. I think in the end the postgres reference block has done the job and this is still a leftover. I will try to run it without this change.

I tried different approaches to get the cascade deletion working. I think in the end the postgres reference block has done the job and this is still a leftover. I will try to run it without this change.

Seems that properties are deleted using default :destroy with the reference block

Seems that properties are deleted using default :destroy with the reference block
argument :properties, {:array, :map}
accept [
:first_name,
:last_name,
:email,
:birth_date,
:paid,
:phone_number,
:join_date,
:exit_date,
:notes,
:city,
:street,
:house_number,
:postal_code
]
change manage_relationship(:properties, on_match: :update, on_no_match: :create)
end
end
validations do
# Required fields are covered by allow_nil? false
# First name and last name must not be empty
validate present(:first_name)
validate present(:last_name)
validate present(:email)
# Birth date not in the future
validate compare(:birth_date, less_than_or_equal_to: &Date.utc_today/0),
where: [present(:birth_date)],
message: "cannot be in the future"
# Join date not in the future
validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0),
where: [present(:join_date)],
message: "cannot be in the future"
# Exit date not before join date
validate compare(:exit_date, greater_than: :join_date),
where: [present([:join_date, :exit_date])],
message: "cannot be before join date"
# Phone number format (only if set)
validate match(:phone_number, ~r/^\+?[0-9\- ]{6,20}$/),
where: [present(:phone_number)],
message: "is not a valid phone number"
carla marked this conversation as resolved Outdated

I'm fine with doing this using custom validations as I think "real" code is easier to read than DSLs - but I think if we wanted to, we could achieve this in a more concise way using ash's built-in compare validation, e.g.:

validate compare(:join_date, less_than: &Date.utc_today/0),
  where: [present(:join_date)],
  message: "cannot be in the future"

Not sure if I got the syntax exactly right, but I hope you get what I mean. I think this approach would work for other custom validations in this file as well, e.g. using the built-in match validation for regexes.

One advantage of using the built-in validations is that they are "atomic", meaning they can run in the database directly, which can improve performance.

I'm fine with doing this using custom validations as I think "real" code is easier to read than DSLs - but I think if we wanted to, we could achieve this in a more concise way using ash's [built-in compare validation](https://hexdocs.pm/ash/Ash.Resource.Validation.Builtins.html#compare/2), e.g.: ```elixir validate compare(:join_date, less_than: &Date.utc_today/0), where: [present(:join_date)], message: "cannot be in the future" ``` Not sure if I got the syntax exactly right, but I hope you get what I mean. I think this approach would work for other custom validations in this file as well, e.g. using the built-in `match` validation for regexes. One advantage of using the built-in validations is that they are "atomic", meaning they can run in the database directly, which can improve performance.

Thank you for the hint, I will apply it and test if it works.

Thank you for the hint, I will apply it and test if it works.

Maybe not from a technical but from a user perspective: Why should it not be possible to add members already, where the membership starts maybe beginning of next month? Should we take the question to the Pilotvereine?

Maybe not from a technical but from a user perspective: Why should it not be possible to add members already, where the membership starts maybe beginning of next month? Should we take the question to the Pilotvereine?
# Postal code format (only if set)
validate match(:postal_code, ~r/^\d{5}$/),
where: [present(:postal_code)],
message: "must consist of 5 digits"
# Email validation with EctoCommons.EmailValidator
validate fn changeset, _ ->
email = Ash.Changeset.get_attribute(changeset, :email)
changeset2 =
{%{}, %{email: :string}}
|> Ecto.Changeset.cast(%{email: email}, [:email])
|> EctoCommons.EmailValidator.validate_email(:email, checks: [:html_input, :pow])
if changeset2.valid? do
:ok
else
{:error, field: :email, message: "is not a valid email"}
end
end
end
attributes do
uuid_v7_primary_key :id
attribute :first_name, :string do
allow_nil? false
constraints min_length: 1
end
attribute :last_name, :string do
allow_nil? false
constraints min_length: 1
end
attribute :email, :string do
allow_nil? false
constraints min_length: 5, max_length: 254
end
attribute :birth_date, :date do
allow_nil? true
carla marked this conversation as resolved Outdated

I don't think this works the way it appears: If I remove the type="checkbox" attribute in the paid form field, and e.g. enter "not a boolean" instead of "true" or "false", I get back an "is invalid" error. This hints to me that the validate function does not get called if the input does not cast to the required attribute type. There's a hint buried in the ash docs:

Keep in mind that some types cast certain values to nil, and validations are applied after all inputs have been cast. For example, a :string type attribute with the default constraints will cast "" as nil, meaning an input of "" would pass the absent validation.

I don't think this works the way it appears: If I remove the `type="checkbox"` attribute in the paid form field, and e.g. enter "not a boolean" instead of "true" or "false", I get back an "is invalid" error. This hints to me that the validate function does not get called if the input does not cast to the required attribute type. There's a [hint buried in the ash docs](https://hexdocs.pm/ash/Ash.Resource.Validation.Builtins.html#absent/2): > Keep in mind that some types cast certain values to nil, and validations are applied after all inputs have been cast. For example, a :string type attribute with the default constraints will cast "" as nil, meaning an input of "" would pass the absent validation.

Should we omit that validation? Because we define "paid" as boolean and I think using type="checkbox" should not be removed anywhere or am I wrong?

Should we omit that validation? Because we define "paid" as boolean and I think using `type="checkbox"` should not be removed anywhere or am I wrong?

Just realized that my comment was very unspecific about what to change :D I think we can omit the validation, because ash already ensures that the attribute is a boolean, because the attribute definition marks it as the type :boolean. We should not remove the type="checkbox", that was just what I did to be able to send invalid data to the server.

Just realized that my comment was very unspecific about what to change :D I think we can omit the validation, because ash already ensures that the attribute is a boolean, because the attribute definition marks it as the type `:boolean`. We should not remove the `type="checkbox"`, that was just what I did to be able to send invalid data to the server.
end
attribute :paid, :boolean do
allow_nil? true
end
attribute :phone_number, :string do
allow_nil? true
end
attribute :join_date, :date do
allow_nil? true
end
attribute :exit_date, :date do
allow_nil? true
end
attribute :notes, :string do
allow_nil? true
end
attribute :city, :string do
allow_nil? true
end
attribute :street, :string do
allow_nil? true
end
attribute :house_number, :string do
allow_nil? true
end
attribute :postal_code, :string do
allow_nil? true
end
end
relationships do

View file

@ -6,6 +6,10 @@ defmodule Mv.Membership.Property do
postgres do
table "properties"
repo Mv.Repo
references do
reference :member, on_delete: :delete
end
end
actions do

View file

@ -21,7 +21,7 @@ defmodule Mv.Membership.PropertyType do
attribute :value_type, :atom,
constraints: [one_of: [:string, :integer, :boolean, :date, :email]],
allow_nil?: false,
description: "Definies the datatype `Property.value` is interpreted as"
description: "Defines the datatype `Property.value` is interpreted as"
attribute :description, :string, allow_nil?: true, public?: true

View file

@ -673,4 +673,28 @@ defmodule MvWeb.CoreComponents do
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
@doc """
Renders a list of items with name and value pairs.
## Examples
<.generic_list items={[
{item.name, item.value},
{other.name, other.value}
]} />
"""
attr :items, :list, required: true, doc: "List of {name, value} tuples"
def generic_list(assigns) do
~H"""
<div class="mt-14">
<dl class="-my-4 divide-y divide-zinc-100">
<div :for={{name, value} <- @items} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
<dt class="w-1/4 flex-none text-zinc-500">{name}</dt>
<dd class="text-zinc-700">{value}</dd>
</div>
</dl>
</div>
"""
end
end

View file

@ -36,6 +36,21 @@ defmodule MvWeb.MemberLive.FormComponent do
phx-change="validate"
phx-submit="save"
>
<.input field={@form[:first_name]} label="First Name" required />
<.input field={@form[:last_name]} label="Last Name" required />
<.input field={@form[:email]} label="Email" required type="email" />
<.input field={@form[:birth_date]} label="Birth Date" type="date" />
<.input field={@form[:paid]} label="Paid" type="checkbox" />
<.input field={@form[:phone_number]} label="Phone Number" />
<.input field={@form[:join_date]} label="Join Date" type="date" />
<.input field={@form[:exit_date]} label="Exit Date" type="date" />
<.input field={@form[:notes]} label="Notes" />
<.input field={@form[:city]} label="City" />
<.input field={@form[:street]} label="Street" />
<.input field={@form[:house_number]} label="House Number" />
<.input field={@form[:postal_code]} label="Postal Code" />
<h3 class="mt-8 mb-2 text-lg font-semibold">Custom Properties</h3>
<.inputs_for :let={f_property} field={@form[:properties]}>
<% type = Enum.find(@property_types, &(&1.id == f_property[:property_type_id].value)) %>
<.inputs_for :let={value_form} field={f_property[:value]}>

View file

@ -18,7 +18,12 @@ defmodule MvWeb.MemberLive.Index do
rows={@streams.members}
row_click={fn {_id, member} -> JS.navigate(~p"/members/#{member}") end}
>
<:col :let={{_id, member}} label="Id">{member.id}</:col>
<!-- <:col :let={{_id, member}} label="Id">{member.id}</:col> -->
<:col :let={{_id, member}} label="First Name">{member.first_name}</:col>
<:col :let={{_id, member}} label="Last Name">{member.last_name}</:col>
<:col :let={{_id, member}} label="Email">{member.email}</:col>
<:col :let={{_id, member}} label="City">{member.city}</:col>
<:col :let={{_id, member}} label="Join Date">{member.join_date}</:col>
<:action :let={{_id, member}}>
<div class="sr-only">

View file

@ -1,11 +1,12 @@
defmodule MvWeb.MemberLive.Show do
use MvWeb, :live_view
import Ash.Query
@impl true
def render(assigns) do
~H"""
<.header>
Member {@member.id}
{@member.first_name} {@member.last_name}
<:subtitle>This is a member record from your database.</:subtitle>
<:actions>
@ -17,8 +18,35 @@ defmodule MvWeb.MemberLive.Show do
<.list>
<:item title="Id">{@member.id}</:item>
<:item title="First Name">{@member.first_name}</:item>
<:item title="Last Name">{@member.last_name}</:item>
<:item title="Email">{@member.email}</:item>
<:item title="Birth Date">{@member.birth_date}</:item>
<:item title="Paid">{if @member.paid, do: "Yes", else: "No"}</:item>
<:item title="Phone Number">{@member.phone_number}</:item>
<:item title="Join Date">{@member.join_date}</:item>
<:item title="Exit Date">{@member.exit_date}</:item>
<:item title="Notes">{@member.notes}</:item>
<:item title="City">{@member.city}</:item>
<:item title="Street">{@member.street}</:item>
<:item title="House Number">{@member.house_number}</:item>
<:item title="Postal Code">{@member.postal_code}</:item>
</.list>
<h3 class="mt-8 mb-2 text-lg font-semibold">Custom Properties</h3>
<.generic_list items={
Enum.map(@member.properties, fn p ->
{
# name
p.property_type && p.property_type.name,
# value
case p.value do
%{value: v} -> v
v -> v
end
}
end)
} />
<.back navigate={~p"/members"}>Back to members</.back>
<.modal
@ -46,10 +74,17 @@ defmodule MvWeb.MemberLive.Show do
@impl true
def handle_params(%{"id" => id}, _, socket) do
query =
Mv.Membership.Member
|> filter(id == ^id)
|> load(properties: [:property_type])
member = Ash.read_one!(query)
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:member, Ash.get!(Mv.Membership.Member, id))}
|> assign(:member, member)}
end
defp page_title(:show), do: "Show Member"

View file

@ -69,7 +69,8 @@ defmodule Mv.MixProject do
{:bandit, "~> 1.5"},
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
{:sobelow, "~> 0.14", only: [:dev, :test], runtime: false},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:ecto_commons, "~> 0.3"}
]
end

View file

@ -13,9 +13,11 @@
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.12.6", "8bf762dc5b87d85b7aca7ad5fe31ef8142a84cea473a3381eb933bd925751300", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4c0cba01795463eebbcd9e4b5ef53c1ee8e68b9c482baef2a80de5a61e7a57fe"},
"ecto_commons": {:hex, :ecto_commons, "0.3.6", "7b1d9e59396cf8c8cbe5a26d50d03f9b6d0fe6c640210dd503622f276f1e59bb", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.2", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "3f12981a1e398f206c5d2014e7b732b7ec91b110b9cb84875cb5b28fc75d7a0a"},
"ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
"ex_phone_number": {:hex, :ex_phone_number, "0.4.5", "2065cc48c3e9d1ed9821f50877c32f2f6898362cb990f44147ca217c5d1374ed", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "67163f8706f8cbfef1b1f4b9230c461f19786d0d79fd0b22cbeeefc6f0b99d4a"},
"expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"},
"file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"},
"finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"},
@ -30,6 +32,7 @@
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
"live_debugger": {:hex, :live_debugger, "0.2.4", "2e0b02874ca562ba2d8cebb9e024c25c0ae9c1f4ee499135a70814e1dea6183e", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20.4 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "bfd0db143be54ccf2872f15bfd2209fbec1083d0b06b81b4cedeecb2fa9ac208"},
"luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},
@ -58,6 +61,7 @@
"splode": {:hex, :splode, "0.2.9", "3a2776e187c82f42f5226b33b1220ccbff74f4bcc523dd4039c804caaa3ffdc7", [:mix], [], "hexpm", "8002b00c6e24f8bd1bcced3fbaa5c33346048047bb7e13d2f3ad428babbd95c3"},
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
"swoosh": {:hex, :swoosh, "1.19.2", "b2325aa7cd2bcd63ba023fa07a73dfc4f80660a592d40912975a879966ed9b7b", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cab7ef7c2c94c68fe21d3da26f6b86db118fdf4e7024ccb5842a4972c1056837"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
"tailwind": {:hex, :tailwind, "0.3.1", "a89d2835c580748c7a975ad7dd3f2ea5e63216dc16d44f9df492fbd12c094bed", [:mix], [], "hexpm", "98a45febdf4a87bc26682e1171acdedd6317d0919953c353fcd1b4f9f4b676a2"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},

View file

@ -0,0 +1,45 @@
defmodule Mv.Repo.Migrations.MemberFields do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:members) do
add :first_name, :text, null: false
add :last_name, :text, null: false
add :email, :text, null: false
add :birth_date, :date
add :paid, :boolean
add :phone_number, :text
add :join_date, :date
add :exit_date, :date
add :notes, :text
add :city, :text
add :street, :text
add :house_number, :text
add :postal_code, :text
end
end
def down do
alter table(:members) do
remove :postal_code
remove :house_number
remove :street
remove :city
remove :notes
remove :exit_date
remove :join_date
remove :phone_number
remove :paid
remove :birth_date
remove :email
remove :last_name
remove :first_name
end
end
end

View file

@ -0,0 +1,38 @@
defmodule Mv.Repo.Migrations.MemberDelete do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
drop constraint(:properties, "properties_member_id_fkey")
alter table(:properties) do
modify :member_id,
references(:members,
column: :id,
name: "properties_member_id_fkey",
type: :uuid,
prefix: "public",
on_delete: :delete_all
)
end
end
def down do
drop constraint(:properties, "properties_member_id_fkey")
alter table(:properties) do
modify :member_id,
references(:members,
column: :id,
name: "properties_member_id_fkey",
type: :uuid,
prefix: "public"
)
end
end
end

View file

@ -7,37 +7,30 @@ alias Mv.Membership
for attrs <- [
%{
name: "Vorname",
name: "String Field",
value_type: :string,
description: "Vorname des Mitglieds",
description: "Example for a field of type string",
immutable: true,
required: true
},
%{
name: "Nachname",
value_type: :string,
description: "Nachname des Mitglieds",
immutable: true,
required: true
},
%{
name: "Geburtsdatum",
name: "Date Field",
value_type: :date,
description: "Geburtsdatum des Mitglieds",
description: "Example for a field of type date",
immutable: true,
required: true
},
%{
name: "Bezahlt",
name: "Boolean Field",
value_type: :boolean,
description: "Status des Mitgliedsbeitrages des Mitglieds",
description: "Example for a field of type boolean",
immutable: true,
required: true
},
%{
name: "Email",
name: "Email Field",
value_type: :email,
description: "Email-Adresse des Mitglieds",
description: "Example for a field of type email",
immutable: true,
required: true
}

View file

@ -0,0 +1,187 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"uuid_generate_v7()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "first_name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "last_name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "email",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "birth_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "paid",
"type": "boolean"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "phone_number",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "join_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "exit_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "notes",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "city",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "street",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "house_number",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "postal_code",
"type": "text"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "CF80317E7EE409618E08458B10EE122FF605640DDA8CD6000B433F1979614F5D",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "members"
}

View file

@ -0,0 +1,105 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "value",
"type": "map"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "properties_member_id_fkey",
"on_delete": "delete",
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "members"
},
"scale": null,
"size": null,
"source": "member_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "properties_property_type_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "property_types"
},
"scale": null,
"size": null,
"source": "property_type_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "4F17BE0106435A1D75D46A3ABDE6A3DA20FC9B1C43D101B6C310009279DD7CBA",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "properties"
}

View file

@ -0,0 +1,110 @@
defmodule Mv.Membership.MemberTest do
use Mv.DataCase, async: false
alias Mv.Membership
describe "Fields and Validations" do
@valid_attrs %{
first_name: "John",
last_name: "Doe",
birth_date: ~D[1990-01-01],
paid: true,
email: "john@example.com",
phone_number: "+49123456789",
join_date: ~D[2020-01-01],
exit_date: nil,
notes: "Test note",
city: "Berlin",
street: "Main Street",
house_number: "1A",
postal_code: "12345"
}
test "First name is required and must not be empty" do
attrs = Map.put(@valid_attrs, :first_name, "")
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :first_name) =~ "must be present"
end
test "Last name is required and must not be empty" do
attrs = Map.put(@valid_attrs, :last_name, "")
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :last_name) =~ "must be present"
end
test "Email is required" do
attrs = Map.put(@valid_attrs, :email, "")
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :email) =~ "must be present"
end
test "Email must be valid" do
attrs = Map.put(@valid_attrs, :email, "test@")
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :email) =~ "is not a valid email"
end
test "Birth date is optional but must not be in the future" do
attrs = Map.put(@valid_attrs, :birth_date, Date.utc_today() |> Date.add(1))
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :birth_date) =~ "cannot be in the future"
end
test "Paid is optional but must be boolean if specified" do
attrs = Map.put(@valid_attrs, :paid, nil)
attrs2 = Map.put(@valid_attrs, :paid, "yes")
assert {:ok, _member} = Membership.create_member(Map.delete(attrs, :paid))
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs2)
assert error_message(errors, :paid) =~ "is invalid"
end
test "Phone number is optional but must have a valid format if specified" do
attrs = Map.put(@valid_attrs, :phone_number, "abc")
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :phone_number) =~ "is not a valid phone number"
attrs2 = Map.delete(@valid_attrs, :phone_number)
assert {:ok, _member} = Membership.create_member(attrs2)
end
test "Join date is optional but must not be in the future" do
attrs = Map.put(@valid_attrs, :join_date, Date.utc_today() |> Date.add(1))
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :join_date) =~ "cannot be in the future"
attrs2 = Map.delete(@valid_attrs, :join_date)
assert {:ok, _member} = Membership.create_member(attrs2)
end
test "Exit date is optional but must not be before join date if both are specified" do
attrs = Map.put(@valid_attrs, :exit_date, ~D[2010-01-01])
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :exit_date) =~ "cannot be before join date"
attrs2 = Map.delete(@valid_attrs, :exit_date)
assert {:ok, _member} = Membership.create_member(attrs2)
end
test "Notes is optional" do
attrs = Map.delete(@valid_attrs, :notes)
assert {:ok, _member} = Membership.create_member(attrs)
end
test "City, street, house number are optional" do
attrs = @valid_attrs |> Map.drop([:city, :street, :house_number])
assert {:ok, _member} = Membership.create_member(attrs)
end
test "Postal code is optional but must have 5 digits if specified" do
attrs = Map.put(@valid_attrs, :postal_code, "1234")
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :postal_code) =~ "must consist of 5 digits"
attrs2 = Map.delete(@valid_attrs, :postal_code)
assert {:ok, _member} = Membership.create_member(attrs2)
end
end
# Helper function for error evaluation
defp error_message(errors, field) do
errors
|> Enum.filter(fn err -> Map.get(err, :field) == field end)
|> Enum.map(&Map.get(&1, :message, ""))
|> List.first()
end
end