From 526f1c44ab2537433bc5dee710320b9c33e1806d Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Wed, 18 Feb 2026 15:38:48 +0000 Subject: [PATCH 1/8] fix: auto-convert project name to url-safe format in settings form The project creation form auto-converts uppercase and spaces to the lowercase-hyphens-digits format, but the settings form did not, causing users to get a validation error when entering names like "DEP-burundi-datafi". Aligns the settings form with the creation form by using a raw_name input that gets converted via Helpers.url_safe_name/1 before saving. --- .../live/project_live/settings.ex | 29 ++++++++++++---- .../live/project_live/settings.html.heex | 11 ++++++- test/lightning_web/live/project_live_test.exs | 33 ++++++++++++++----- 3 files changed, 56 insertions(+), 17 deletions(-) diff --git a/lib/lightning_web/live/project_live/settings.ex b/lib/lightning_web/live/project_live/settings.ex index 638fc5e2fe..24c10390dd 100644 --- a/lib/lightning_web/live/project_live/settings.ex +++ b/lib/lightning_web/live/project_live/settings.ex @@ -14,6 +14,7 @@ defmodule LightningWeb.ProjectLive.Settings do alias Lightning.Projects.ProjectUser alias Lightning.VersionControl alias Lightning.WebhookAuthMethods + alias Lightning.Helpers alias LightningWeb.Components.GithubComponents require Logger @@ -123,6 +124,7 @@ defmodule LightningWeb.ProjectLive.Settings do collections: collections, current_user: socket.assigns.current_user, github_enabled: VersionControl.github_enabled?(), + name: socket.assigns.project.name, project_changeset: Projects.change_project(socket.assigns.project), project_files: project_files, project_repo_connection: repo_connection, @@ -187,18 +189,25 @@ defmodule LightningWeb.ProjectLive.Settings do @impl true def handle_event("validate", %{"project" => params}, socket) do params = - if params["retention_policy"] == "erase_all" do - Map.merge(params, %{"dataclip_retention_period" => nil}) - else - params - end + params + |> coerce_raw_name_to_safe_name() + |> then(fn params -> + if params["retention_policy"] == "erase_all" do + Map.merge(params, %{"dataclip_retention_period" => nil}) + else + params + end + end) changeset = socket.assigns.project |> Projects.change_project(params) |> Map.put(:action, :validate) - {:noreply, assign(socket, :project_changeset, changeset)} + {:noreply, + socket + |> assign(:project_changeset, changeset) + |> assign(:name, Ecto.Changeset.fetch_field!(changeset, :name))} end # validate without input can be ignored @@ -217,7 +226,7 @@ defmodule LightningWeb.ProjectLive.Settings do def handle_event("save", %{"project" => project_params}, socket) do if socket.assigns.can_edit_project do - save_project(socket, project_params) + save_project(socket, coerce_raw_name_to_safe_name(project_params)) else {:noreply, socket @@ -526,6 +535,12 @@ defmodule LightningWeb.ProjectLive.Settings do end end + defp coerce_raw_name_to_safe_name(%{"raw_name" => raw_name} = params) do + params |> Map.put("name", Helpers.url_safe_name(raw_name)) + end + + defp coerce_raw_name_to_safe_name(params), do: params + defp checked?(changeset, input_id) do Ecto.Changeset.fetch_field!(changeset, :retention_policy) == input_id end diff --git a/lib/lightning_web/live/project_live/settings.html.heex b/lib/lightning_web/live/project_live/settings.html.heex index e37addb0c7..41d35438bf 100644 --- a/lib/lightning_web/live/project_live/settings.html.heex +++ b/lib/lightning_web/live/project_live/settings.html.heex @@ -90,10 +90,19 @@
<.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]} /> + + <%= if to_string(f[:name].value) != "" do %> + Your project will be named + <%= @name %> + . + <% end %> +
diff --git a/test/lightning_web/live/project_live_test.exs b/test/lightning_web/live/project_live_test.exs index 8ec7d18fd5..c4bbe81ac8 100644 --- a/test/lightning_web/live/project_live_test.exs +++ b/test/lightning_web/live/project_live_test.exs @@ -1649,10 +1649,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 +1657,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 +1683,7 @@ defmodule LightningWeb.ProjectLiveTest do assert html =~ "Project settings" valid_project_attrs = %{ - name: "somename", + raw_name: "somename", description: "some description" } @@ -1705,6 +1697,29 @@ 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 "project admin can edit project concurrency with valid data", %{ conn: conn, From 1cbfc7e5304ce7c39cbe1b09e439891c822e1a18 Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Wed, 18 Feb 2026 15:47:00 +0000 Subject: [PATCH 2/8] fix: sort alias in alphabetical order --- lib/lightning_web/live/project_live/settings.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/lightning_web/live/project_live/settings.ex b/lib/lightning_web/live/project_live/settings.ex index 24c10390dd..fbbf1611ca 100644 --- a/lib/lightning_web/live/project_live/settings.ex +++ b/lib/lightning_web/live/project_live/settings.ex @@ -8,13 +8,13 @@ defmodule LightningWeb.ProjectLive.Settings do alias Lightning.Collections alias Lightning.Credentials + alias Lightning.Helpers alias Lightning.Policies.Permissions alias Lightning.Projects alias Lightning.Projects.ProjectLimiter alias Lightning.Projects.ProjectUser alias Lightning.VersionControl alias Lightning.WebhookAuthMethods - alias Lightning.Helpers alias LightningWeb.Components.GithubComponents require Logger From 556863e64fdd526973eb67b48ea17beade3a46be Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Wed, 18 Feb 2026 15:49:40 +0000 Subject: [PATCH 3/8] docs: add changelog entry for project settings name conversion fix --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8d411bd0a..bb78d7feed 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 From 142878db6da62945a09cd3c73487eed765c629c1 Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Wed, 18 Feb 2026 21:26:58 +0000 Subject: [PATCH 4/8] fix: update test selectors for renamed project name input field The project name input was changed from `name` to `raw_name` for the url-safe conversion feature, but two test assertions still referenced the old field name. --- test/lightning_web/live/project_live_test.exs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/lightning_web/live/project_live_test.exs b/test/lightning_web/live/project_live_test.exs index c4bbe81ac8..ed58266f6d 100644 --- a/test/lightning_web/live/project_live_test.exs +++ b/test/lightning_web/live/project_live_test.exs @@ -1760,7 +1760,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?( @@ -1792,7 +1794,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?( From d72053f1e8298095f6b6fa1b0a2915d358d04180 Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Sat, 21 Feb 2026 22:50:10 +0000 Subject: [PATCH 5/8] refactor: consolidate raw_name coercion into Project schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace 5 duplicate `coerce_raw_name_to_safe_name` functions with `Project.form_changeset/2` and `form_with_users_changeset/2` that use a virtual `:raw_name` field to derive the URL-safe `:name` automatically. - Add `Helpers.derive_name_param/1` so all save handlers derive the name server-side instead of relying on hidden-field timing - Fix duplicate "can't be blank" error by moving `validate_required([:name])` out of shared `validate/1` into callers that cast `:name` directly - Fix dashboard creation modal error path assigning to wrong key (`:project_changeset` → `:changeset`) - Extract shared `<.name_badge>` component in Pills for the name preview badge used across all 5 forms --- lib/lightning/helpers.ex | 12 ++++ lib/lightning/projects/project.ex | 65 ++++++++++++++++- lib/lightning/projects/provisioner.ex | 2 +- lib/lightning_web/components/pills.ex | 30 ++++++++ .../collection_creation_modal.ex | 40 +++++------ .../dashboard_live/project_creation_modal.ex | 32 +++------ .../live/project_live/form_component.ex | 33 ++++----- .../project_live/form_component.html.heex | 11 ++- .../live/project_live/settings.ex | 36 +++++----- .../live/project_live/settings.html.heex | 8 +-- .../live/sandbox_live/form_component.ex | 55 +++++++------- test/lightning/projects/project_test.exs | 71 +++++++++++++++++++ .../live/sandbox_live/form_component_test.exs | 4 +- 13 files changed, 274 insertions(+), 125 deletions(-) diff --git a/lib/lightning/helpers.ex b/lib/lightning/helpers.ex index 5787ed2d54..1cd6cb4bbd 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 38b48efeb6..a94a702f91 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,66 @@ 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() + |> 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() + |> 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 +260,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 a57b0e67eb..dba3ee1da8 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 358f277596..cd3f7aa27a 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""" + <%= if to_string(@field.value) != "" do %> + {render_slot(@inner_block)} + <%= @name %>. + <% end %> + """ + 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 477b546a6e..6648c80244 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 18dec612a7..01cb6e352f 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 9102844d19..ea115708e1 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 df241ca642..2b34666a47 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 fbbf1611ca..4f4b134e63 100644 --- a/lib/lightning_web/live/project_live/settings.ex +++ b/lib/lightning_web/live/project_live/settings.ex @@ -11,6 +11,7 @@ defmodule LightningWeb.ProjectLive.Settings do 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 @@ -124,8 +125,9 @@ defmodule LightningWeb.ProjectLive.Settings do collections: collections, current_user: socket.assigns.current_user, github_enabled: VersionControl.github_enabled?(), - name: socket.assigns.project.name, - 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, @@ -188,26 +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 = params - |> coerce_raw_name_to_safe_name() - |> then(fn params -> - if params["retention_policy"] == "erase_all" do - Map.merge(params, %{"dataclip_retention_period" => nil}) + |> 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 - params + 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, socket |> assign(:project_changeset, changeset) - |> assign(:name, Ecto.Changeset.fetch_field!(changeset, :name))} + |> assign(:name, Ecto.Changeset.get_field(changeset, :name))} end # validate without input can be ignored @@ -216,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, coerce_raw_name_to_safe_name(project_params)) + save_project(socket, Helpers.derive_name_param(project_params)) else {:noreply, socket @@ -535,12 +542,6 @@ defmodule LightningWeb.ProjectLive.Settings do end end - defp coerce_raw_name_to_safe_name(%{"raw_name" => raw_name} = params) do - params |> Map.put("name", Helpers.url_safe_name(raw_name)) - end - - defp coerce_raw_name_to_safe_name(params), do: params - defp checked?(changeset, input_id) do Ecto.Changeset.fetch_field!(changeset, :retention_policy) == input_id end @@ -556,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 41d35438bf..5c4024cd49 100644 --- a/lib/lightning_web/live/project_live/settings.html.heex +++ b/lib/lightning_web/live/project_live/settings.html.heex @@ -97,11 +97,9 @@ /> <.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/sandbox_live/form_component.ex b/lib/lightning_web/live/sandbox_live/form_component.ex index 8b65e6e772..1b177b94f9 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 6f4ad76435..ee456fc660 100644 --- a/test/lightning/projects/project_test.exs +++ b/test/lightning/projects/project_test.exs @@ -166,6 +166,77 @@ 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 + 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/sandbox_live/form_component_test.exs b/test/lightning_web/live/sandbox_live/form_component_test.exs index d4ad6f29aa..383038af42 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(