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(