Skip to content
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions lib/lightning/helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
67 changes: 66 additions & 1 deletion lib/lightning/projects/project.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -78,6 +80,7 @@ defmodule Lightning.Projects.Project do
:color,
:env
])
|> validate_required([:name])
|> set_default_env_for_root_projects()
|> validate()
end
Expand All @@ -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())
Expand All @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion lib/lightning/projects/provisioner.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions lib/lightning_web/components/pills.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
</.name_badge>
```
"""
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"""
<span :if={to_string(@field.value) != ""}>
{render_slot(@inner_block)}
<span class="ml-1 rounded-md border border-slate-300 bg-yellow-100 p-1 font-mono text-xs">{@name}</span>.
</span>
"""
end

@doc """
Renders a filter badge with a close button.

Expand Down
40 changes: 16 additions & 24 deletions lib/lightning_web/live/collection_live/collection_creation_modal.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"""
Expand Down Expand Up @@ -144,10 +137,9 @@ defmodule LightningWeb.CollectionLive.CollectionCreationModal do
/>
<.input type="hidden" field={f[:name]} />
<small class="mt-2 block text-xs text-gray-600">
<%= if to_string(f[:name].value) != "" do %>
This collection will be named <span class="font-mono border rounded-md p-1 bg-yellow-100 border-slate-300">
<%= @name %></span>.
<% end %>
<.name_badge name={@name} field={f[:name]}>
This collection will be named
</.name_badge>
</small>
</div>
<div class="space-y-4">
Expand Down
32 changes: 11 additions & 21 deletions lib/lightning_web/live/dashboard_live/project_creation_modal.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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"""
Expand Down Expand Up @@ -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]} />
<small class="mt-2 block text-xs text-gray-600">
<%= if to_string(f[:name].value) != "" do %>
Your project will be named <span class="font-mono border rounded-md p-1 bg-yellow-100 border-slate-300">
<%= @name %></span>.
<% end %>
<.name_badge name={@name} field={f[:name]}>
Your project will be named
</.name_badge>
</small>
</div>
<div class="space-y-4">
Expand Down
Loading