Merge branch 'gmcintire/user-first-last-name' into 'main'
Replace single name field with first_name and last_name See merge request towerops/towerops!3
This commit is contained in:
commit
a66aec675e
7 changed files with 225 additions and 18 deletions
|
|
@ -23,7 +23,8 @@ defmodule Towerops.Accounts.User do
|
|||
field :confirmed_at, :utc_datetime
|
||||
field :authenticated_at, :utc_datetime, virtual: true
|
||||
field :is_superuser, :boolean, default: false
|
||||
field :name, :string
|
||||
field :first_name, :string
|
||||
field :last_name, :string
|
||||
field :timezone, :string, default: "UTC"
|
||||
field :totp_secret, :binary, redact: true
|
||||
field :totp_verified_at, :utc_datetime, virtual: true
|
||||
|
|
@ -46,7 +47,8 @@ defmodule Towerops.Accounts.User do
|
|||
confirmed_at: DateTime.t() | nil,
|
||||
authenticated_at: DateTime.t() | nil,
|
||||
is_superuser: boolean(),
|
||||
name: String.t() | nil,
|
||||
first_name: String.t() | nil,
|
||||
last_name: String.t() | nil,
|
||||
timezone: String.t(),
|
||||
totp_secret: binary() | nil,
|
||||
totp_verified_at: DateTime.t() | nil,
|
||||
|
|
@ -100,12 +102,13 @@ defmodule Towerops.Accounts.User do
|
|||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for updating the profile information (name, timezone).
|
||||
A user changeset for updating the profile information (first_name, last_name, timezone).
|
||||
"""
|
||||
def profile_changeset(user, attrs) do
|
||||
user
|
||||
|> cast(attrs, [:name, :timezone])
|
||||
|> validate_length(:name, max: 255)
|
||||
|> cast(attrs, [:first_name, :last_name, :timezone])
|
||||
|> validate_length(:first_name, max: 100)
|
||||
|> validate_length(:last_name, max: 100)
|
||||
|> validate_length(:timezone, max: 100)
|
||||
end
|
||||
|
||||
|
|
@ -124,11 +127,13 @@ defmodule Towerops.Accounts.User do
|
|||
"""
|
||||
def registration_changeset(user, attrs, opts \\ []) do
|
||||
user
|
||||
|> cast(attrs, [:email, :password, :timezone, :privacy_policy_consent, :terms_of_service_consent])
|
||||
|> cast(attrs, [:email, :password, :first_name, :last_name, :timezone, :privacy_policy_consent, :terms_of_service_consent])
|
||||
|> validate_email_for_registration(opts)
|
||||
|> validate_password(opts)
|
||||
|> validate_consent()
|
||||
|> validate_timezone()
|
||||
|> validate_length(:first_name, max: 100)
|
||||
|> validate_length(:last_name, max: 100)
|
||||
end
|
||||
|
||||
defp validate_consent(changeset) do
|
||||
|
|
@ -255,4 +260,19 @@ defmodule Towerops.Accounts.User do
|
|||
Argon2.no_user_verify()
|
||||
false
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the user's full name by combining first_name and last_name.
|
||||
|
||||
Returns nil if both fields are nil or empty.
|
||||
"""
|
||||
@spec full_name(t()) :: String.t() | nil
|
||||
def full_name(%__MODULE__{first_name: first, last_name: last}) do
|
||||
[first, last]
|
||||
|> Enum.reject(&(is_nil(&1) or &1 == ""))
|
||||
|> case do
|
||||
[] -> nil
|
||||
parts -> Enum.join(parts, " ")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -47,7 +47,8 @@ defmodule ToweropsWeb.Api.AccountDataController do
|
|||
%{
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
timezone: user.timezone,
|
||||
is_superuser: user.is_superuser,
|
||||
confirmed_at: user.confirmed_at,
|
||||
|
|
|
|||
|
|
@ -166,9 +166,15 @@ defmodule ToweropsWeb.AccountLive.MyData do
|
|||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{@user_data.profile.email}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Name</dt>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">First Name</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
{@user_data.profile.name || "Not set"}
|
||||
{@user_data.profile.first_name || "Not set"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Name</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
{@user_data.profile.last_name || "Not set"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -564,7 +570,8 @@ defmodule ToweropsWeb.AccountLive.MyData do
|
|||
defp get_user_profile(user) do
|
||||
%{
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
timezone: user.timezone,
|
||||
inserted_at: user.inserted_at,
|
||||
confirmed_at: user.confirmed_at
|
||||
|
|
|
|||
|
|
@ -167,13 +167,27 @@ defmodule ToweropsWeb.UserRegistrationLive do
|
|||
<input type="hidden" name="user[invitation_token]" value={@invitation_token} />
|
||||
<% end %>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<.input
|
||||
field={@form[:first_name]}
|
||||
type="text"
|
||||
label="First name"
|
||||
autocomplete="given-name"
|
||||
phx-mounted={JS.focus()}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:last_name]}
|
||||
type="text"
|
||||
label="Last name"
|
||||
autocomplete="family-name"
|
||||
/>
|
||||
</div>
|
||||
<.input
|
||||
field={@form[:email]}
|
||||
type="email"
|
||||
label="Email"
|
||||
autocomplete="email"
|
||||
required
|
||||
phx-mounted={JS.focus()}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:password]}
|
||||
|
|
|
|||
|
|
@ -759,17 +759,39 @@ defmodule ToweropsWeb.UserSettingsLive do
|
|||
class="md:col-span-2"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
|
||||
<div class="col-span-full">
|
||||
<label for="name" class="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Name
|
||||
<div class="sm:col-span-3">
|
||||
<label
|
||||
for="first_name"
|
||||
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
First name
|
||||
</label>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
type="text"
|
||||
name={@profile_form[:name].name}
|
||||
id="name"
|
||||
value={@profile_form[:name].value}
|
||||
autocomplete="name"
|
||||
name={@profile_form[:first_name].name}
|
||||
id="first_name"
|
||||
value={@profile_form[:first_name].value}
|
||||
autocomplete="given-name"
|
||||
class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-3">
|
||||
<label
|
||||
for="last_name"
|
||||
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Last name
|
||||
</label>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
type="text"
|
||||
name={@profile_form[:last_name].name}
|
||||
id="last_name"
|
||||
value={@profile_form[:last_name].value}
|
||||
autocomplete="family-name"
|
||||
class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
defmodule Towerops.Repo.Migrations.AddFirstLastNameToUsers do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:users) do
|
||||
add :first_name, :string
|
||||
add :last_name, :string
|
||||
end
|
||||
|
||||
# Migrate existing name data to first_name
|
||||
execute "UPDATE users SET first_name = name WHERE name IS NOT NULL", ""
|
||||
|
||||
alter table(:users) do
|
||||
remove :name, :string
|
||||
end
|
||||
end
|
||||
end
|
||||
126
test/towerops/accounts/user_test.exs
Normal file
126
test/towerops/accounts/user_test.exs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
defmodule Towerops.Accounts.UserTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
alias Towerops.Accounts.User
|
||||
|
||||
describe "registration_changeset/3" do
|
||||
test "accepts first_name and last_name" do
|
||||
attrs = %{
|
||||
email: "test#{System.unique_integer()}@example.com",
|
||||
password: "valid_password123",
|
||||
first_name: "John",
|
||||
last_name: "Smith",
|
||||
privacy_policy_consent: true,
|
||||
terms_of_service_consent: true
|
||||
}
|
||||
|
||||
changeset = User.registration_changeset(%User{}, attrs)
|
||||
|
||||
assert changeset.valid?, "Changeset errors: #{inspect(changeset.errors)}"
|
||||
assert Ecto.Changeset.get_field(changeset, :first_name) == "John"
|
||||
assert Ecto.Changeset.get_field(changeset, :last_name) == "Smith"
|
||||
end
|
||||
|
||||
test "validates first_name length" do
|
||||
attrs = %{
|
||||
email: "test#{System.unique_integer()}@example.com",
|
||||
password: "valid_password123",
|
||||
first_name: String.duplicate("a", 256),
|
||||
last_name: "Smith",
|
||||
privacy_policy_consent: true,
|
||||
terms_of_service_consent: true
|
||||
}
|
||||
|
||||
changeset = User.registration_changeset(%User{}, attrs)
|
||||
|
||||
refute changeset.valid?
|
||||
assert "should be at most 100 character(s)" in errors_on(changeset).first_name
|
||||
end
|
||||
|
||||
test "validates last_name length" do
|
||||
attrs = %{
|
||||
email: "test#{System.unique_integer()}@example.com",
|
||||
password: "valid_password123",
|
||||
first_name: "John",
|
||||
last_name: String.duplicate("a", 256),
|
||||
privacy_policy_consent: true,
|
||||
terms_of_service_consent: true
|
||||
}
|
||||
|
||||
changeset = User.registration_changeset(%User{}, attrs)
|
||||
|
||||
refute changeset.valid?
|
||||
assert "should be at most 100 character(s)" in errors_on(changeset).last_name
|
||||
end
|
||||
end
|
||||
|
||||
describe "profile_changeset/2" do
|
||||
test "accepts first_name and last_name updates" do
|
||||
user = %User{first_name: "John", last_name: "Smith"}
|
||||
|
||||
attrs = %{first_name: "Jane", last_name: "Doe"}
|
||||
changeset = User.profile_changeset(user, attrs)
|
||||
|
||||
assert changeset.valid?
|
||||
assert Ecto.Changeset.get_change(changeset, :first_name) == "Jane"
|
||||
assert Ecto.Changeset.get_change(changeset, :last_name) == "Doe"
|
||||
end
|
||||
|
||||
test "validates first_name length in profile update" do
|
||||
user = %User{first_name: "John", last_name: "Smith"}
|
||||
|
||||
attrs = %{first_name: String.duplicate("a", 256)}
|
||||
changeset = User.profile_changeset(user, attrs)
|
||||
|
||||
refute changeset.valid?
|
||||
assert "should be at most 100 character(s)" in errors_on(changeset).first_name
|
||||
end
|
||||
|
||||
test "validates last_name length in profile update" do
|
||||
user = %User{first_name: "John", last_name: "Smith"}
|
||||
|
||||
attrs = %{last_name: String.duplicate("a", 256)}
|
||||
changeset = User.profile_changeset(user, attrs)
|
||||
|
||||
refute changeset.valid?
|
||||
assert "should be at most 100 character(s)" in errors_on(changeset).last_name
|
||||
end
|
||||
|
||||
test "does not accept name field (deprecated)" do
|
||||
user = %User{first_name: "John", last_name: "Smith"}
|
||||
|
||||
attrs = %{name: "Jane Doe"}
|
||||
changeset = User.profile_changeset(user, attrs)
|
||||
|
||||
# name field should not be cast
|
||||
assert Ecto.Changeset.get_change(changeset, :name) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "full_name/1" do
|
||||
test "returns full name when both first and last name are present" do
|
||||
user = %User{first_name: "John", last_name: "Smith"}
|
||||
assert User.full_name(user) == "John Smith"
|
||||
end
|
||||
|
||||
test "returns first name when only first name is present" do
|
||||
user = %User{first_name: "John", last_name: nil}
|
||||
assert User.full_name(user) == "John"
|
||||
end
|
||||
|
||||
test "returns last name when only last name is present" do
|
||||
user = %User{first_name: nil, last_name: "Smith"}
|
||||
assert User.full_name(user) == "Smith"
|
||||
end
|
||||
|
||||
test "returns nil when neither name is present" do
|
||||
user = %User{first_name: nil, last_name: nil}
|
||||
assert User.full_name(user) == nil
|
||||
end
|
||||
|
||||
test "handles empty strings as nil" do
|
||||
user = %User{first_name: "", last_name: ""}
|
||||
assert User.full_name(user) == nil
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue