diff --git a/CHANGELOG.md b/CHANGELOG.md
index e8d411bd0ab..bb78d7feed6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -46,6 +46,9 @@ and this project adheres to
[#4440](https://github.com/OpenFn/lightning/issues/4440)
- Fix sandbox creation failing silently on backend validation errors
[#4440](https://github.com/OpenFn/lightning/issues/4440)
+- Project settings form now auto-converts project names to url-safe format,
+ matching the creation form behavior
+ [#4437](https://github.com/OpenFn/lightning/issues/4437)
## [2.15.14] - 2026-02-13
diff --git a/lib/lightning/helpers.ex b/lib/lightning/helpers.ex
index 5787ed2d549..1cd6cb4bbd2 100644
--- a/lib/lightning/helpers.ex
+++ b/lib/lightning/helpers.ex
@@ -178,6 +178,18 @@ defmodule Lightning.Helpers do
|> String.trim("-")
end
+ @doc """
+ Derives `"name"` from `"raw_name"` in form params so that context functions
+ (which cast `:name`, not `:raw_name`) receive the correct URL-safe value
+ without relying on hidden-field timing.
+ """
+ @spec derive_name_param(map()) :: map()
+ def derive_name_param(%{"raw_name" => raw_name} = params) do
+ Map.put(params, "name", url_safe_name(raw_name))
+ end
+
+ def derive_name_param(params), do: params
+
@doc """
Normalizes all map keys to strings recursively.
diff --git a/lib/lightning/projects/project.ex b/lib/lightning/projects/project.ex
index 38b48efeb61..11da766f000 100644
--- a/lib/lightning/projects/project.ex
+++ b/lib/lightning/projects/project.ex
@@ -35,6 +35,8 @@ defmodule Lightning.Projects.Project do
field :color, :string
field :env, :string
+ field :raw_name, :string, virtual: true
+
belongs_to :parent, __MODULE__, type: :binary_id
has_many :project_users, ProjectUser
@@ -78,6 +80,7 @@ defmodule Lightning.Projects.Project do
:color,
:env
])
+ |> validate_required([:name])
|> set_default_env_for_root_projects()
|> validate()
end
@@ -96,7 +99,6 @@ defmodule Lightning.Projects.Project do
def validate(changeset) do
changeset
|> validate_length(:description, max: 240)
- |> validate_required([:name])
|> validate_format(:name, ~r/^[a-z\-\d]+$/)
|> maybe_validate_dataclip_retention_period()
|> validate_inclusion(:history_retention_period, data_retention_options())
@@ -123,6 +125,68 @@ defmodule Lightning.Projects.Project do
else: changeset
end
+ @doc """
+ Changeset for forms that accept a human-friendly `:raw_name` and derive
+ the URL-safe `:name` automatically.
+ """
+ def form_changeset(project, attrs) do
+ project
+ |> cast(attrs, [
+ :id,
+ :raw_name,
+ :concurrency,
+ :description,
+ :scheduled_deletion,
+ :requires_mfa,
+ :retention_policy,
+ :history_retention_period,
+ :dataclip_retention_period,
+ :allow_support_access,
+ :parent_id,
+ :color,
+ :env
+ ])
+ |> validate_required([:raw_name])
+ |> derive_name_from_raw_name()
+ |> validate_required([:name])
+ |> set_default_env_for_root_projects()
+ |> validate()
+ end
+
+ @doc """
+ Like `form_changeset/2` but also casts the `:project_users` association.
+ """
+ def form_with_users_changeset(project, attrs) do
+ project
+ |> cast(attrs, [
+ :id,
+ :raw_name,
+ :description,
+ :concurrency,
+ :parent_id,
+ :color,
+ :env,
+ :allow_support_access,
+ :requires_mfa,
+ :retention_policy,
+ :history_retention_period,
+ :dataclip_retention_period
+ ])
+ |> validate_required([:raw_name])
+ |> derive_name_from_raw_name()
+ |> validate_required([:name])
+ |> cast_assoc(:project_users, required: true, sort_param: :users_sort)
+ |> validate()
+ |> validate_project_owner()
+ end
+
+ defp derive_name_from_raw_name(changeset) do
+ case get_change(changeset, :raw_name) do
+ nil -> changeset
+ raw -> put_change(changeset, :name, Lightning.Helpers.url_safe_name(raw))
+ end
+ end
+
@doc """
Returns `true` if the project is a sandbox (i.e. `parent_id` is a UUID),
`false` otherwise.
@@ -198,6 +262,7 @@ defmodule Lightning.Projects.Project do
:history_retention_period,
:dataclip_retention_period
])
+ |> validate_required([:name])
|> cast_assoc(:project_users, required: true, sort_param: :users_sort)
|> validate()
|> validate_project_owner()
diff --git a/lib/lightning/projects/provisioner.ex b/lib/lightning/projects/provisioner.ex
index a57b0e67eb9..dba3ee1da8c 100644
--- a/lib/lightning/projects/provisioner.ex
+++ b/lib/lightning/projects/provisioner.ex
@@ -397,7 +397,7 @@ defmodule Lightning.Projects.Provisioner do
defp project_changeset(project, attrs) do
project
|> cast(attrs, [:id, :name, :description])
- |> validate_required([:id])
+ |> validate_required([:id, :name])
|> validate_extraneous_params()
|> Project.validate()
end
diff --git a/lib/lightning_web/components/pills.ex b/lib/lightning_web/components/pills.ex
index 358f277596b..0476fa06149 100644
--- a/lib/lightning_web/components/pills.ex
+++ b/lib/lightning_web/components/pills.ex
@@ -72,6 +72,36 @@ defmodule LightningWeb.Components.Pills do
"""
end
+ @doc """
+ Renders a preview of a derived URL-safe name inside a yellow badge.
+
+ Shows the badge only when the derived name is non-empty.
+
+ ## Example
+
+ ```heex
+ <.name_badge name={@name} field={f[:name]}>
+ Your project will be named
+
+ ```
+ """
+ attr :name, :string, required: true, doc: "The derived URL-safe name"
+
+ attr :field, Phoenix.HTML.FormField,
+ required: true,
+ doc: "The hidden :name field"
+
+ slot :inner_block
+
+ def name_badge(assigns) do
+ ~H"""
+
+ {render_slot(@inner_block)}
+ {@name}.
+
+ """
+ end
+
@doc """
Renders a filter badge with a close button.
diff --git a/lib/lightning_web/live/collection_live/collection_creation_modal.ex b/lib/lightning_web/live/collection_live/collection_creation_modal.ex
index 477b546a6ec..6648c80244a 100644
--- a/lib/lightning_web/live/collection_live/collection_creation_modal.ex
+++ b/lib/lightning_web/live/collection_live/collection_creation_modal.ex
@@ -9,7 +9,14 @@ defmodule LightningWeb.CollectionLive.CollectionCreationModal do
@impl true
def update(assigns, socket) do
- changeset = Collection.changeset(assigns.collection, %{})
+ collection = assigns.collection
+
+ changeset =
+ if collection.name do
+ Collection.form_changeset(collection, %{raw_name: collection.name})
+ else
+ Collection.form_changeset(collection, %{})
+ end
{:ok,
socket
@@ -36,23 +43,19 @@ defmodule LightningWeb.CollectionLive.CollectionCreationModal do
def handle_event("validate", %{"collection" => collection_params}, socket) do
changeset =
socket.assigns.collection
- |> Collection.changeset(
- collection_params
- |> coerce_raw_name_to_safe_name
- )
+ |> Collection.form_changeset(collection_params)
+ |> Helpers.copy_error(:name, :raw_name)
|> Map.put(:action, :validate)
{:noreply,
socket
- |> assign(
- :changeset,
- Lightning.Helpers.copy_error(changeset, :name, :raw_name)
- )
+ |> assign(:changeset, changeset)
|> assign(:name, Ecto.Changeset.fetch_field!(changeset, :name))}
end
def handle_event("save", %{"collection" => collection_params}, socket) do
%{mode: mode, return_to: return_to} = socket.assigns
+ collection_params = Helpers.derive_name_param(collection_params)
result =
case mode do
@@ -84,21 +87,11 @@ defmodule LightningWeb.CollectionLive.CollectionCreationModal do
assign(
socket,
:changeset,
- Lightning.Helpers.copy_error(changeset, :name, :raw_name)
+ Helpers.copy_error(changeset, :name, :raw_name)
)}
end
end
- defp coerce_raw_name_to_safe_name(%{"raw_name" => raw_name} = params) do
- new_name = Helpers.url_safe_name(raw_name)
-
- params |> Map.put("name", new_name)
- end
-
- defp coerce_raw_name_to_safe_name(%{} = params) do
- params
- end
-
@impl true
def render(assigns) do
~H"""
@@ -144,10 +137,9 @@ defmodule LightningWeb.CollectionLive.CollectionCreationModal do
/>
<.input type="hidden" field={f[:name]} />
- <%= if to_string(f[:name].value) != "" do %>
- This collection will be named
- <%= @name %>.
- <% end %>
+ <.name_badge name={@name} field={f[:name]}>
+ This collection will be named
+
diff --git a/lib/lightning_web/live/dashboard_live/project_creation_modal.ex b/lib/lightning_web/live/dashboard_live/project_creation_modal.ex
index 18dec612a79..01cb6e352f7 100644
--- a/lib/lightning_web/live/dashboard_live/project_creation_modal.ex
+++ b/lib/lightning_web/live/dashboard_live/project_creation_modal.ex
@@ -8,13 +8,14 @@ defmodule LightningWeb.DashboardLive.ProjectCreationModal do
@impl true
def update(assigns, socket) do
project = %Project{}
- changeset = Project.changeset(project, %{})
+ changeset = Project.form_changeset(project, %{})
{:ok,
socket
|> assign(assigns)
|> assign(project: project)
- |> assign(changeset: changeset)}
+ |> assign(changeset: changeset)
+ |> assign(:name, Ecto.Changeset.get_field(changeset, :name))}
end
@impl true
@@ -25,10 +26,8 @@ defmodule LightningWeb.DashboardLive.ProjectCreationModal do
def handle_event("validate", %{"project" => project_params}, socket) do
changeset =
socket.assigns.project
- |> Project.changeset(
- project_params
- |> coerce_raw_name_to_safe_name
- )
+ |> Project.form_changeset(project_params)
+ |> Helpers.copy_error(:name, :raw_name)
|> Map.put(:action, :validate)
{:noreply,
@@ -41,6 +40,7 @@ defmodule LightningWeb.DashboardLive.ProjectCreationModal do
%{current_user: current_user, return_to: return_to} = socket.assigns
project_params
+ |> Helpers.derive_name_param()
|> Map.put_new("project_users", %{
0 => %{
"user_id" => current_user.id,
@@ -57,20 +57,11 @@ defmodule LightningWeb.DashboardLive.ProjectCreationModal do
|> push_navigate(to: return_to)}
{:error, %Ecto.Changeset{} = changeset} ->
- {:noreply, assign(socket, :project_changeset, changeset)}
+ changeset = Helpers.copy_error(changeset, :name, :raw_name)
+ {:noreply, assign(socket, :changeset, changeset)}
end
end
- defp coerce_raw_name_to_safe_name(%{"raw_name" => raw_name} = params) do
- new_name = Helpers.url_safe_name(raw_name)
-
- params |> Map.put("name", new_name)
- end
-
- defp coerce_raw_name_to_safe_name(%{} = params) do
- params
- end
-
@impl true
def render(assigns) do
~H"""
@@ -105,10 +96,9 @@ defmodule LightningWeb.DashboardLive.ProjectCreationModal do
<.input type="text" field={f[:raw_name]} label="Name" required="true" />
<.input type="hidden" field={f[:name]} />
- <%= if to_string(f[:name].value) != "" do %>
- Your project will be named
- <%= @name %>.
- <% end %>
+ <.name_badge name={@name} field={f[:name]}>
+ Your project will be named
+
diff --git a/lib/lightning_web/live/project_live/form_component.ex b/lib/lightning_web/live/project_live/form_component.ex
index 9102844d19d..ea115708e17 100644
--- a/lib/lightning_web/live/project_live/form_component.ex
+++ b/lib/lightning_web/live/project_live/form_component.ex
@@ -74,9 +74,9 @@ defmodule LightningWeb.ProjectLive.FormComponent do
end)
changeset =
- Project.project_with_users_changeset(
+ Project.form_with_users_changeset(
project,
- %{project_users: project_users}
+ %{project_users: project_users, raw_name: project.name}
)
{:ok,
@@ -86,20 +86,15 @@ defmodule LightningWeb.ProjectLive.FormComponent do
|> assign(:sort_key, "name")
|> assign(:sort_direction, "asc")
|> assign(:filter, "")
- |> assign(
- :name,
- Helpers.url_safe_name(fetch_field!(changeset, :name))
- )}
+ |> assign(:name, fetch_field!(changeset, :name))}
end
@impl true
def handle_event("validate", %{"project" => project_params}, socket) do
changeset =
socket.assigns.project
- |> Project.project_with_users_changeset(
- project_params
- |> coerce_raw_name_to_safe_name()
- )
+ |> Project.form_with_users_changeset(project_params)
+ |> Helpers.copy_error(:name, :raw_name)
|> Map.put(:action, :validate)
{:noreply,
@@ -160,7 +155,11 @@ defmodule LightningWeb.ProjectLive.FormComponent do
"users_sort" => Map.keys(users_params)
})
- save_project(socket, socket.assigns.action, params)
+ save_project(
+ socket,
+ socket.assigns.action,
+ Helpers.derive_name_param(params)
+ )
end
defp save_project(socket, :edit, project_params) do
@@ -175,6 +174,7 @@ defmodule LightningWeb.ProjectLive.FormComponent do
|> push_patch(to: socket.assigns.return_to)}
{:error, %Ecto.Changeset{} = changeset} ->
+ changeset = Helpers.copy_error(changeset, :name, :raw_name)
{:noreply, assign(socket, :changeset, changeset)}
end
end
@@ -188,20 +188,11 @@ defmodule LightningWeb.ProjectLive.FormComponent do
|> push_patch(to: socket.assigns.return_to)}
{:error, %Ecto.Changeset{} = changeset} ->
+ changeset = Helpers.copy_error(changeset, :name, :raw_name)
{:noreply, assign(socket, changeset: changeset)}
end
end
- defp coerce_raw_name_to_safe_name(%{"raw_name" => raw_name} = params) do
- new_name = Helpers.url_safe_name(raw_name)
-
- params |> Map.put("name", new_name)
- end
-
- defp coerce_raw_name_to_safe_name(%{} = params) do
- params
- end
-
defp full_user_name(user) do
"#{user.first_name} #{user.last_name}"
end
diff --git a/lib/lightning_web/live/project_live/form_component.html.heex b/lib/lightning_web/live/project_live/form_component.html.heex
index df241ca6427..2b34666a472 100644
--- a/lib/lightning_web/live/project_live/form_component.html.heex
+++ b/lib/lightning_web/live/project_live/form_component.html.heex
@@ -16,14 +16,11 @@
value={f[:name].value}
/>
<.old_error field={f[:name]} />
- <%= if to_string(f[:name].value) != "" do %>
-
+
+ <.name_badge name={@name} field={f[:name]}>
Note that this project will be named:
-
- {@name}
-
-
- <% end %>
+
+
diff --git a/lib/lightning_web/live/project_live/settings.ex b/lib/lightning_web/live/project_live/settings.ex
index 638fc5e2fe9..4f4b134e635 100644
--- a/lib/lightning_web/live/project_live/settings.ex
+++ b/lib/lightning_web/live/project_live/settings.ex
@@ -8,8 +8,10 @@ defmodule LightningWeb.ProjectLive.Settings do
alias Lightning.Collections
alias Lightning.Credentials
+ alias Lightning.Helpers
alias Lightning.Policies.Permissions
alias Lightning.Projects
+ alias Lightning.Projects.Project
alias Lightning.Projects.ProjectLimiter
alias Lightning.Projects.ProjectUser
alias Lightning.VersionControl
@@ -123,7 +125,9 @@ defmodule LightningWeb.ProjectLive.Settings do
collections: collections,
current_user: socket.assigns.current_user,
github_enabled: VersionControl.github_enabled?(),
- project_changeset: Projects.change_project(socket.assigns.project),
+ name: project.name,
+ project_changeset:
+ Project.form_changeset(project, %{raw_name: project.name}),
project_files: project_files,
project_repo_connection: repo_connection,
project_user: project_user,
@@ -186,19 +190,29 @@ defmodule LightningWeb.ProjectLive.Settings do
@impl true
def handle_event("validate", %{"project" => params}, socket) do
+ # The retention and concurrency forms don't include raw_name,
+ # so default to the project's current name for form_changeset.
params =
- if params["retention_policy"] == "erase_all" do
- Map.merge(params, %{"dataclip_retention_period" => nil})
- else
- params
- end
+ params
+ |> Map.put_new("raw_name", socket.assigns.project.name)
+ |> then(fn p ->
+ if p["retention_policy"] == "erase_all" do
+ Map.put(p, "dataclip_retention_period", nil)
+ else
+ p
+ end
+ end)
changeset =
socket.assigns.project
- |> Projects.change_project(params)
+ |> Project.form_changeset(params)
+ |> Helpers.copy_error(:name, :raw_name)
|> Map.put(:action, :validate)
- {:noreply, assign(socket, :project_changeset, changeset)}
+ {:noreply,
+ socket
+ |> assign(:project_changeset, changeset)
+ |> assign(:name, Ecto.Changeset.get_field(changeset, :name))}
end
# validate without input can be ignored
@@ -207,17 +221,19 @@ defmodule LightningWeb.ProjectLive.Settings do
end
def handle_event("cancel-retention-change", _params, socket) do
+ project = socket.assigns.project
+
{:noreply,
socket
|> assign(
:project_changeset,
- Projects.change_project(socket.assigns.project)
+ Project.form_changeset(project, %{raw_name: project.name})
)}
end
def handle_event("save", %{"project" => project_params}, socket) do
if socket.assigns.can_edit_project do
- save_project(socket, project_params)
+ save_project(socket, Helpers.derive_name_param(project_params))
else
{:noreply,
socket
@@ -541,6 +557,7 @@ defmodule LightningWeb.ProjectLive.Settings do
|> put_flash(:info, "Project updated successfully")}
{:error, %Ecto.Changeset{} = changeset} ->
+ changeset = Helpers.copy_error(changeset, :name, :raw_name)
{:noreply, assign(socket, :project_changeset, changeset)}
{:error, :not_related_to_project} ->
diff --git a/lib/lightning_web/live/project_live/settings.html.heex b/lib/lightning_web/live/project_live/settings.html.heex
index e37addb0c7c..5c4024cd491 100644
--- a/lib/lightning_web/live/project_live/settings.html.heex
+++ b/lib/lightning_web/live/project_live/settings.html.heex
@@ -90,10 +90,17 @@
<.input
type="text"
- field={f[:name]}
+ field={f[:raw_name]}
+ value={@name}
disabled={!@can_edit_project}
label="Project name"
/>
+ <.input type="hidden" field={f[:name]} />
+
+ <.name_badge name={@name} field={f[:name]}>
+ Your project will be named
+
+
diff --git a/lib/lightning_web/live/sandbox_live/form_component.ex b/lib/lightning_web/live/sandbox_live/form_component.ex
index 8b65e6e7727..1b177b94f9c 100644
--- a/lib/lightning_web/live/sandbox_live/form_component.ex
+++ b/lib/lightning_web/live/sandbox_live/form_component.ex
@@ -108,17 +108,26 @@ defmodule LightningWeb.SandboxLive.FormComponent do
|> noreply()
else
{:error, %Ecto.Changeset{} = changeset} ->
- Logger.error(
- "Sandbox creation failed for project #{parent.id}: #{inspect(changeset.errors)}"
- )
-
- socket
- |> put_flash(
- :error,
- "Something went wrong while creating the sandbox. Please check the parent project's settings and try again."
- )
- |> push_navigate(to: return_to || ~p"/projects/#{parent.id}/sandboxes")
- |> noreply()
+ changeset = Helpers.copy_error(changeset, :name, :raw_name)
+
+ if changeset.errors[:raw_name] do
+ socket
+ |> assign(:changeset, changeset)
+ |> assign(:name, Changeset.get_field(changeset, :name))
+ |> noreply()
+ else
+ Logger.error(
+ "Sandbox creation failed for project #{parent.id}: #{inspect(changeset.errors)}"
+ )
+
+ socket
+ |> put_flash(
+ :error,
+ "Something went wrong while creating the sandbox. Please check the parent project's settings and try again."
+ )
+ |> push_navigate(to: return_to || ~p"/projects/#{parent.id}/sandboxes")
+ |> noreply()
+ end
{:error, _reason, %{text: text}} ->
socket
@@ -151,6 +160,8 @@ defmodule LightningWeb.SandboxLive.FormComponent do
|> push_navigate(to: return_to || ~p"/projects/#{sandbox.id}/w")}
{:error, %Ecto.Changeset{} = changeset} ->
+ changeset = Helpers.copy_error(changeset, :name, :raw_name)
+
{:noreply,
socket
|> assign(:changeset, changeset)
@@ -241,10 +252,7 @@ defmodule LightningWeb.SandboxLive.FormComponent do
:edit -> "The sandbox will be named"
end}
<%= if to_string(f[:name].value) != "" do %>
- <%= @name %>.
+ <.name_badge name={@name} field={f[:name]} />
<% else %>
sandbox.name,
"raw_name" => sandbox.name,
"color" => sandbox.color
}
@@ -300,10 +307,10 @@ defmodule LightningWeb.SandboxLive.FormComponent do
end
defp form_changeset(%Project{} = base, params, parent_id) do
- params
- |> coerce_raw_name_to_safe_name()
- |> then(&Project.changeset(base, &1))
+ base
+ |> Project.form_changeset(params)
|> validate_unique_sandbox_name(parent_id)
+ |> Helpers.copy_error(:name, :raw_name)
end
defp validate_unique_sandbox_name(changeset, parent_id) do
@@ -314,7 +321,7 @@ defmodule LightningWeb.SandboxLive.FormComponent do
if Projects.sandbox_name_exists?(parent_id, name, id) do
Changeset.add_error(
changeset,
- :raw_name,
+ :name,
"Sandbox name already exists"
)
else
@@ -325,13 +332,9 @@ defmodule LightningWeb.SandboxLive.FormComponent do
end
end
- defp coerce_raw_name_to_safe_name(%{"raw_name" => raw} = params) do
- Map.put(params, "name", Helpers.url_safe_name(raw))
- end
-
- defp coerce_raw_name_to_safe_name(params), do: params
-
defp build_sandbox_attrs(params) do
+ params = Helpers.derive_name_param(params)
+
%{
name: params["name"],
color: params["color"]
diff --git a/test/lightning/projects/project_test.exs b/test/lightning/projects/project_test.exs
index 6f4ad76435f..7a819e26314 100644
--- a/test/lightning/projects/project_test.exs
+++ b/test/lightning/projects/project_test.exs
@@ -166,6 +166,101 @@ defmodule Lightning.Projects.ProjectTest do
end
end
+ describe "form_changeset/2" do
+ test "casts raw_name and derives name via url_safe_name" do
+ cs = Project.form_changeset(%Project{}, %{raw_name: "My Cool Project!"})
+ assert cs.valid?
+ assert Ecto.Changeset.get_change(cs, :raw_name) == "My Cool Project!"
+ assert Ecto.Changeset.get_field(cs, :name) == "my-cool-project"
+ end
+
+ test "requires raw_name" do
+ cs = Project.form_changeset(%Project{}, %{})
+ refute cs.valid?
+ assert "can't be blank" in errors_on(cs).raw_name
+ end
+
+ test "does not cast name directly" do
+ cs =
+ Project.form_changeset(%Project{}, %{
+ raw_name: "good",
+ name: "should-be-ignored"
+ })
+
+ assert Ecto.Changeset.get_field(cs, :name) == "good"
+ end
+
+ test "casts other fields like description and color" do
+ cs =
+ Project.form_changeset(%Project{}, %{
+ raw_name: "test",
+ description: "A test project",
+ color: "#ff0000"
+ })
+
+ assert cs.valid?
+ assert Ecto.Changeset.get_field(cs, :description) == "A test project"
+ assert Ecto.Changeset.get_field(cs, :color) == "#ff0000"
+ end
+
+ test "sets default env for root projects" do
+ cs = Project.form_changeset(%Project{}, %{raw_name: "root-proj"})
+ assert Ecto.Changeset.get_field(cs, :env) == "main"
+ end
+
+ test "requires both raw_name and name when raw_name is blank" do
+ for attrs <- [%{raw_name: ""}, %{"raw_name" => ""}] do
+ cs = Project.form_changeset(%Project{}, attrs)
+ refute cs.valid?
+
+ assert [{"can't be blank", [validation: :required]}] =
+ Keyword.get_values(cs.errors, :raw_name)
+
+ assert [{"can't be blank", [validation: :required]}] =
+ Keyword.get_values(cs.errors, :name)
+ end
+ end
+
+ test "requires name when raw_name resolves to a blank slug" do
+ form_cs = Project.form_changeset(%Project{}, %{raw_name: "!!!"})
+ refute form_cs.valid?
+
+ save_cs = Project.changeset(%Project{}, %{name: ""})
+ refute save_cs.valid?
+
+ assert "can't be blank" in errors_on(form_cs).name
+ assert errors_on(form_cs).name == errors_on(save_cs).name
+ end
+ end
+
+ describe "form_with_users_changeset/2" do
+ test "casts raw_name, derives name, and validates project_users" do
+ user = insert(:user)
+
+ cs =
+ Project.form_with_users_changeset(%Project{}, %{
+ raw_name: "Team Project",
+ project_users: [%{user_id: user.id, role: :owner}]
+ })
+
+ assert cs.valid?
+ assert Ecto.Changeset.get_field(cs, :name) == "team-project"
+ end
+
+ test "requires at least one owner" do
+ user = insert(:user)
+
+ cs =
+ Project.form_with_users_changeset(%Project{}, %{
+ raw_name: "no-owner",
+ project_users: [%{user_id: user.id, role: :editor}]
+ })
+
+ refute cs.valid?
+ assert errors_on(cs).owner
+ end
+ end
+
describe "sandbox?/1" do
test "false for root, true for child" do
root = insert(:project)
diff --git a/test/lightning_web/live/dashboard_live_test.exs b/test/lightning_web/live/dashboard_live_test.exs
index 06fd0c17474..62d3c9c32c4 100644
--- a/test/lightning_web/live/dashboard_live_test.exs
+++ b/test/lightning_web/live/dashboard_live_test.exs
@@ -268,6 +268,21 @@ defmodule LightningWeb.DashboardLiveTest do
assert has_element?(view, "tr#projects-table-row-#{project.id}")
end
+ test "Creating a project with a name that resolves to blank shows error",
+ %{conn: conn, user: user} do
+ {:ok, view, _html} = live(conn, ~p"/projects")
+
+ view
+ |> form("#project-form",
+ project: %{raw_name: "!!!"}
+ )
+ |> render_submit()
+
+ html = render(view)
+ assert html =~ "can't be blank"
+ assert Enum.empty?(Lightning.Projects.get_projects_for_user(user))
+ end
+
test "When the user closes the modal without submitting the form, the project won't be created",
%{conn: conn, user: user} do
{:ok, view, _html} = live(conn, ~p"/projects")
diff --git a/test/lightning_web/live/project_live_test.exs b/test/lightning_web/live/project_live_test.exs
index 8ec7d18fd54..0a168c8d8e4 100644
--- a/test/lightning_web/live/project_live_test.exs
+++ b/test/lightning_web/live/project_live_test.exs
@@ -394,6 +394,27 @@ defmodule LightningWeb.ProjectLiveTest do
end
end
+ test "editing a project with a name that resolves to blank shows error", %{
+ conn: conn
+ } do
+ user1 = insert(:user, first_name: "Alice", last_name: "Owner")
+
+ project =
+ insert(:project, project_users: [%{role: :owner, user_id: user1.id}])
+
+ {:ok, view, _html} =
+ live(conn, ~p"/settings/projects/#{project.id}", on_error: :raise)
+
+ view
+ |> form("#project-form",
+ project: %{raw_name: "!!!"}
+ )
+ |> render_submit()
+
+ html = render(view)
+ assert html =~ "can't be blank"
+ end
+
test "sorting projects by name works correctly", %{conn: conn} do
_project_a = insert(:project, name: "alpha-project")
_project_b = insert(:project, name: "beta-project")
@@ -1649,10 +1670,6 @@ defmodule LightningWeb.ProjectLiveTest do
assert html =~ "Project settings"
- invalid_project_name = %{
- name: "some name"
- }
-
invalid_project_description = %{
description:
Enum.map(1..250, fn _ ->
@@ -1661,10 +1678,6 @@ defmodule LightningWeb.ProjectLiveTest do
|> to_string()
}
- assert view
- |> form("#project-settings-form", project: invalid_project_name)
- |> render_change() =~ "has invalid format"
-
assert view
|> form("#project-settings-form",
project: invalid_project_description
@@ -1691,7 +1704,7 @@ defmodule LightningWeb.ProjectLiveTest do
assert html =~ "Project settings"
valid_project_attrs = %{
- name: "somename",
+ raw_name: "somename",
description: "some description"
}
@@ -1705,6 +1718,56 @@ defmodule LightningWeb.ProjectLiveTest do
} = Repo.get!(Project, project.id)
end
+ test "project settings form converts uppercase name to url-safe format",
+ %{
+ conn: conn,
+ user: user
+ } do
+ project =
+ insert(:project,
+ name: "project-1",
+ project_users: [%{user_id: user.id, role: :admin}]
+ )
+
+ {:ok, view, _html} =
+ live(conn, ~p"/projects/#{project}/settings", on_error: :raise)
+
+ assert view
+ |> form("#project-settings-form",
+ project: %{raw_name: "DEP-burundi-datafi"}
+ )
+ |> render_submit() =~ "Project updated successfully"
+
+ assert %{name: "dep-burundi-datafi"} = Repo.get!(Project, project.id)
+ end
+
+ test "saving project settings with name that resolves to blank shows error",
+ %{
+ conn: conn,
+ user: user
+ } do
+ project =
+ insert(:project,
+ name: "project-1",
+ project_users: [%{user_id: user.id, role: :admin}]
+ )
+
+ {:ok, view, _html} =
+ live(conn, ~p"/projects/#{project}/settings", on_error: :raise)
+
+ html =
+ view
+ |> form("#project-settings-form",
+ project: %{raw_name: "!!!"}
+ )
+ |> render_submit()
+
+ assert html =~ "can't be blank"
+
+ # Project name is unchanged
+ assert %{name: "project-1"} = Repo.get!(Project, project.id)
+ end
+
test "project admin can edit project concurrency with valid data",
%{
conn: conn,
@@ -1745,7 +1808,9 @@ defmodule LightningWeb.ProjectLiveTest do
assert html =~ "Project settings"
assert view
- |> has_element?("input[disabled='disabled'][name='project[name]']")
+ |> has_element?(
+ "input[disabled='disabled'][name='project[raw_name]']"
+ )
assert view
|> has_element?(
@@ -1777,7 +1842,9 @@ defmodule LightningWeb.ProjectLiveTest do
assert html =~ "Project settings"
assert view
- |> has_element?("input[disabled='disabled'][name='project[name]']")
+ |> has_element?(
+ "input[disabled='disabled'][name='project[raw_name]']"
+ )
assert view
|> has_element?(
@@ -2764,6 +2831,42 @@ defmodule LightningWeb.ProjectLiveTest do
|> String.trim()
end
+ @tag role: :admin
+ test "cancel button resets retention form to saved values", %{
+ conn: conn,
+ project: project
+ } do
+ {:ok, view, _html} =
+ live(conn, ~p"/projects/#{project.id}/settings#data-storage")
+
+ # Change retention policy
+ view
+ |> form("#retention-settings-form",
+ project: %{retention_policy: "erase_all"}
+ )
+ |> render_change()
+
+ assert ["checked"] ==
+ view
+ |> element("#erase_all")
+ |> render()
+ |> Floki.parse_fragment!()
+ |> Floki.attribute("input", "checked")
+
+ # Click cancel
+ view
+ |> element("button[phx-click='cancel-retention-change']")
+ |> render_click()
+
+ # Verify it resets back to retain_all (the default)
+ assert ["checked"] ==
+ view
+ |> element("#retain_all")
+ |> render()
+ |> Floki.parse_fragment!()
+ |> Floki.attribute("input", "checked")
+ end
+
@tag role: :editor
test "project editor does not have permission", %{
conn: conn,
diff --git a/test/lightning_web/live/sandbox_live/form_component_test.exs b/test/lightning_web/live/sandbox_live/form_component_test.exs
index d4ad6f29aa8..4bf28d38349 100644
--- a/test/lightning_web/live/sandbox_live/form_component_test.exs
+++ b/test/lightning_web/live/sandbox_live/form_component_test.exs
@@ -107,7 +107,7 @@ defmodule LightningWeb.SandboxLive.FormComponentTest do
assert html =~ "Sandbox name already exists"
end
- test "creating sandbox with blank name disables submit and keeps placeholder",
+ test "creating sandbox with blank name disables submit and shows error",
%{
conn: conn,
parent: parent
@@ -122,7 +122,7 @@ defmodule LightningWeb.SandboxLive.FormComponentTest do
html = render(view)
assert html =~ ~s(