diff --git a/CHANGELOG.md b/CHANGELOG.md index 74a076dad5..8e2309f77b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,8 @@ and this project adheres to ### Fixed +- Fix canvas "lockup" after AI chat errors, prevent sending empty message to AI + [3605](https://github.com/OpenFn/lightning/issues/3605) - Fix memory bloat on dataclip viewer in dataclip detail page [#3641](https://github.com/OpenFn/lightning/issues/3641) - Ameliorate memory usage when scrubbing dataclips for security diff --git a/lib/lightning_web/live/ai_assistant/component.ex b/lib/lightning_web/live/ai_assistant/component.ex index be74472fbe..1b8e5dfcac 100644 --- a/lib/lightning_web/live/ai_assistant/component.ex +++ b/lib/lightning_web/live/ai_assistant/component.ex @@ -250,6 +250,7 @@ defmodule LightningWeb.AiAssistant.Component do socket ) do cleared_params = Map.put(params, "content", nil) + trimmed_content = if is_binary(content), do: String.trim(content), else: "" cond do not socket.assigns.can_edit -> @@ -263,6 +264,23 @@ defmodule LightningWeb.AiAssistant.Component do socket.assigns.ai_limit_result != :ok -> {:noreply, socket} + trimmed_content == "" -> + changeset = socket.assigns.handler.validate_form(%{"content" => ""}) + + changeset = + Ecto.Changeset.add_error( + changeset, + :content, + "Please enter a message before sending" + ) + + {:noreply, + socket + |> assign( + changeset: changeset, + alert: "Please enter a message before sending" + )} + true -> {:noreply, socket @@ -272,7 +290,7 @@ defmodule LightningWeb.AiAssistant.Component do :changeset, socket.assigns.handler.validate_form(cleared_params) ) - |> save_message(socket.assigns.action, content)} + |> save_message(socket.assigns.action, trimmed_content)} end end @@ -447,7 +465,9 @@ defmodule LightningWeb.AiAssistant.Component do end defp handle_save_error(socket, error) do - assign(socket, alert: socket.assigns.handler.error_message(error)) + socket + |> assign(alert: socket.assigns.handler.error_message(error)) + |> assign(pending_message: AsyncResult.ok(nil)) end defp redirect_url(base_url, query_params) do @@ -679,11 +699,11 @@ defmodule LightningWeb.AiAssistant.Component do <.simple_button_with_tooltip id={"ai-assistant-form-submit-btn-#{@id}"} type="submit" - disabled={@disabled} + disabled={@disabled || form_content_empty?(@form[:content].value)} form={@form_id} class={[ "p-1.5 rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 transition-all duration-200 flex items-center justify-center h-7 w-7", - if(@disabled, + if(@disabled || form_content_empty?(@form[:content].value), do: "text-gray-400 bg-gray-300 cursor-not-allowed focus:ring-gray-300", else: @@ -933,6 +953,15 @@ defmodule LightningWeb.AiAssistant.Component do Timex.diff(DateTime.utc_now(), datetime, :hours) < 1 end + defp form_content_empty?(value) do + case value do + nil -> true + "" -> true + content when is_binary(content) -> String.trim(content) == "" + _ -> false + end + end + defp render_onboarding(assigns) do assigns = assign(assigns, ai_quote: Quotes.random_enabled()) diff --git a/lib/lightning_web/live/ai_assistant/mode_behavior.ex b/lib/lightning_web/live/ai_assistant/mode_behavior.ex index 99cd4d65d9..188cd152f7 100644 --- a/lib/lightning_web/live/ai_assistant/mode_behavior.ex +++ b/lib/lightning_web/live/ai_assistant/mode_behavior.ex @@ -233,11 +233,20 @@ defmodule LightningWeb.Live.AiAssistant.ModeBehavior do field :content, :string end - def changeset(params) do + @doc false + def changeset(params \\ %{}) do %__MODULE__{} |> cast(params, [:content]) + |> validate_required([:content], + message: "Please enter a message before sending" + ) + |> validate_length(:content, + min: 1, + message: "Please enter a message before sending" + ) end + @doc false def extract_options(_changeset), do: [] end diff --git a/lib/lightning_web/live/ai_assistant/modes/job_code.ex b/lib/lightning_web/live/ai_assistant/modes/job_code.ex index 1bc71b5cc3..8cc2fc2073 100644 --- a/lib/lightning_web/live/ai_assistant/modes/job_code.ex +++ b/lib/lightning_web/live/ai_assistant/modes/job_code.ex @@ -40,6 +40,13 @@ defmodule LightningWeb.Live.AiAssistant.Modes.JobCode do def changeset(params) do %__MODULE__{} |> cast(params, [:content]) + |> validate_required([:content], + message: "Please enter a message before sending" + ) + |> validate_length(:content, + min: 1, + message: "Please enter a message before sending" + ) |> cast_embed(:options, with: &options_changeset/2) end diff --git a/test/lightning_web/live/ai_assistant_live_test.exs b/test/lightning_web/live/ai_assistant_live_test.exs index 02bedff390..1fec4578b2 100644 --- a/test/lightning_web/live/ai_assistant_live_test.exs +++ b/test/lightning_web/live/ai_assistant_live_test.exs @@ -308,7 +308,8 @@ defmodule LightningWeb.AiAssistantLiveTest do assert has_element?(input_element) refute render(input_element) =~ "disabled=\"disabled\"" assert has_element?(submit_btn) - refute render(submit_btn) =~ "disabled=\"disabled\"" + # Submit button should be disabled when no content is entered + assert render(submit_btn) =~ "disabled=\"disabled\"" html = view @@ -2354,7 +2355,8 @@ defmodule LightningWeb.AiAssistantLiveTest do ) refute render(input_element) =~ "disabled=\"disabled\"" - refute render(submit_btn) =~ "disabled=\"disabled\"" + # Submit button should be disabled when no content is entered + assert render(submit_btn) =~ "disabled=\"disabled\"" refute render(input_element) =~ "Save your workflow first" end diff --git a/test/lightning_web/live/workflow_live/ai_assistant_component_test.exs b/test/lightning_web/live/workflow_live/ai_assistant_component_test.exs index 36d0aed528..9d0f654443 100644 --- a/test/lightning_web/live/workflow_live/ai_assistant_component_test.exs +++ b/test/lightning_web/live/workflow_live/ai_assistant_component_test.exs @@ -289,4 +289,45 @@ defmodule LightningWeb.WorkflowLive.AiAssistant.ComponentTest do ] end end + + describe "form validation" do + alias LightningWeb.Live.AiAssistant.Modes.WorkflowTemplate + + test "JobCode Form validates empty content" do + changeset = JobCode.Form.changeset(%{"content" => ""}) + + assert changeset.valid? == false + assert Keyword.has_key?(changeset.errors, :content) + {msg, _opts} = changeset.errors[:content] + assert msg == "Please enter a message before sending" + end + + test "JobCode validate_form includes content validation" do + changeset = JobCode.validate_form(%{"content" => nil}) + + assert changeset.valid? == false + assert Keyword.has_key?(changeset.errors, :content) + end + + test "WorkflowTemplate DefaultForm validates empty content" do + changeset = WorkflowTemplate.DefaultForm.changeset(%{"content" => ""}) + + assert changeset.valid? == false + assert Keyword.has_key?(changeset.errors, :content) + {msg, _opts} = changeset.errors[:content] + assert msg == "Please enter a message before sending" + end + + test "form validation accepts valid content" do + # JobCode + changeset = JobCode.validate_form(%{"content" => "Help me with my code"}) + assert changeset.valid? == true + + # WorkflowTemplate + changeset = + WorkflowTemplate.validate_form(%{"content" => "Create a workflow"}) + + assert changeset.valid? == true + end + end end