Seven primary buttons did nothing when clicked

747f1596ed6c · AtlantisPleb · · parent 9e75db7eedc9

Seven primary buttons did nothing when clicked

`UI.button/1` defaults to `type="button"`. That is the right default -- a
button outside a form that submits one by accident is worse than one that does
nothing -- but it means a call site inside a form has to opt in, and seven had
not. Creating a project, an issue, a milestone, a label, a project item, and
posting either kind of comment all rendered a primary button that was inert.

The suite could not catch it, and that is the more useful half of this. Every
one of those flows had a passing test, because `render_submit/1` submits the
form directly and never touches the button: the server-side path was exercised
and correct while the browser-side control was dead. A test that drives the
form can never see this defect.

So the guard reads the templates instead, which is the only place the defect
is visible: no `<.button>` inside a form that submits may be left without a
`type`. Buttons carrying a `phx-click`, and buttons rendered as links, are
controls rather than submits and are left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149rBWy7br1Z7bbz9NrQhEr
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified lib/openagents_web/live/issue_new_live.ex
  • modified lib/openagents_web/live/issue_show_live.ex
  • modified lib/openagents_web/live/label_index_live.ex
  • modified lib/openagents_web/live/milestone_index_live.ex
  • modified lib/openagents_web/live/project_index_live.ex
  • modified lib/openagents_web/live/project_show_live.ex
  • added test/openagents_web/form_submit_buttons_test.exs

Diff

7 files changed, +84 -7

lib/openagents_web/live/issue_new_live.ex modified +1 -1

@@ -105,7 +105,7 @@ defmodule OpenAgentsWeb.IssueNewLive do

105 105
          <.link navigate={~p"/#{@owner}/#{@repo}/issues"} class="btn" data-variant="ghost">
106 106
            Cancel
107 107
          </.link>
108
          <.button variant={:primary}>Create issue</.button>
108
          <.button type="submit" variant={:primary}>Create issue</.button>
109 109
        </footer>
110 110
      </.form>
111 111
    </Layouts.app>
lib/openagents_web/live/issue_show_live.ex modified +2 -2

@@ -114,7 +114,7 @@ defmodule OpenAgentsWeb.IssueShowLive do

114 114
            <button type="button" class="btn" data-variant="ghost" phx-click="toggle_edit">
115 115
              Cancel
116 116
            </button>
117
            <.button variant={:primary}>Save</.button>
117
            <.button type="submit" variant={:primary}>Save</.button>
118 118
          </footer>
119 119
        </.form>
120 120
      <% else %>

@@ -207,7 +207,7 @@ defmodule OpenAgentsWeb.IssueShowLive do

207 207
          >
208 208
            <.input field={@comment_form[:body]} type="textarea" label="Write a comment" />
209 209
            <footer class="flex justify-end mt-2">
210
              <.button variant={:primary}>Comment</.button>
210
              <.button type="submit" variant={:primary}>Comment</.button>
211 211
            </footer>
212 212
          </.form>
213 213
        </div>
lib/openagents_web/live/label_index_live.ex modified +1 -1

@@ -64,7 +64,7 @@ defmodule OpenAgentsWeb.LabelIndexLive do

64 64
          <.input field={@form[:description]} label="Description" />
65 65
        </div>
66 66
        <footer class="flex justify-end mt-2">
67
          <.button variant={:primary}>Add label</.button>
67
          <.button type="submit" variant={:primary}>Add label</.button>
68 68
        </footer>
69 69
      </.form>
70 70
lib/openagents_web/live/milestone_index_live.ex modified +1 -1

@@ -102,7 +102,7 @@ defmodule OpenAgentsWeb.MilestoneIndexLive do

102 102
          <.input field={@form[:description]} label="Description" />
103 103
        </div>
104 104
        <footer class="flex justify-end mt-2">
105
          <.button variant={:primary}>Add milestone</.button>
105
          <.button type="submit" variant={:primary}>Add milestone</.button>
106 106
        </footer>
107 107
      </.form>
108 108
lib/openagents_web/live/project_index_live.ex modified +1 -1

@@ -64,7 +64,7 @@ defmodule OpenAgentsWeb.ProjectIndexLive do

64 64
      >
65 65
        <.input field={@form[:title]} label="Title" required />
66 66
        <footer class="flex justify-end mt-2">
67
          <.button variant={:primary}>Add project</.button>
67
          <.button type="submit" variant={:primary}>Add project</.button>
68 68
        </footer>
69 69
      </.form>
70 70
lib/openagents_web/live/project_show_live.ex modified +1 -1

@@ -108,7 +108,7 @@ defmodule OpenAgentsWeb.ProjectShowLive do

108 108
          />
109 109
        </div>
110 110
        <footer class="flex justify-end mt-2">
111
          <.button variant={:primary}>Add to board</.button>
111
          <.button type="submit" variant={:primary}>Add to board</.button>
112 112
        </footer>
113 113
      </.form>
114 114
test/openagents_web/form_submit_buttons_test.exs added +77

@@ -0,0 +1,77 @@

1
defmodule OpenAgentsWeb.FormSubmitButtonsTest do
2
  @moduledoc """
3
  Every button that is meant to submit a form must say so.
4
5
  `OpenAgentsWeb.UI.button/1` defaults to `type="button"`, which is the right
6
  default -- a button outside a form that submits one by accident is worse than
7
  one that does nothing. But it means a call site inside a form has to opt in,
8
  and eight of them had not: creating an issue, a milestone, a label, a
9
  project, a project item, and two comment forms all rendered a primary button
10
  that did nothing at all when clicked.
11
12
  The suite could not catch it. `render_submit/1` submits the form directly and
13
  never touches the button, so every one of those flows was covered by a
14
  passing test while being completely broken in a browser. This test reads the
15
  templates instead, which is the only place the defect is visible.
16
  """
17
18
  use ExUnit.Case, async: true
19
20
  @form_pattern ~r/<\.form\b.*?<\/\.form>/s
21
  @button_pattern ~r/<\.button\b[^>]*?>/s
22
23
  test "no button inside a submitting form is left as type=button" do
24
    offenders =
25
      templates()
26
      |> Enum.flat_map(&offenders_in/1)
27
      |> Enum.sort()
28
29
    assert offenders == [],
30
           """
31
           These buttons sit inside a form that submits, but carry no `type`,
32
           so `UI.button/1` renders them as `type="button"` and clicking them
33
           does nothing:
34
35
           #{Enum.map_join(offenders, "\n", fn {file, tag} -> "  #{file}\n    #{tag}" end)}
36
37
           Add `type="submit"`. If the button is meant to run a `phx-click`
38
           rather than submit, it is already correct -- give it a `phx-click`
39
           and this test will leave it alone.
40
           """
41
  end
42
43
  defp templates do
44
    Path.wildcard("lib/openagents_web/**/*.ex") ++ Path.wildcard("lib/openagents_web/**/*.heex")
45
  end
46
47
  defp offenders_in(path) do
48
    source = File.read!(path)
49
50
    @form_pattern
51
    |> Regex.scan(source)
52
    |> Enum.map(&hd/1)
53
    |> Enum.filter(&submitting?/1)
54
    |> Enum.flat_map(fn form ->
55
      @button_pattern
56
      |> Regex.scan(form)
57
      |> Enum.map(&hd/1)
58
      |> Enum.filter(&needs_type?/1)
59
      |> Enum.map(&{path, squeeze(&1)})
60
    end)
61
  end
62
63
  # A form with neither a `phx-submit` nor an `action` is not going anywhere,
64
  # so a button inside it is not a submit button.
65
  defp submitting?(form),
66
    do: String.contains?(form, "phx-submit") or String.contains?(form, "action=")
67
68
  defp needs_type?(tag) do
69
    # A `phx-click` button is a control, not a submit.
70
    # A button rendered as a link is an anchor and submits nothing.
71
    not String.contains?(tag, "type=") and
72
      not String.contains?(tag, "phx-click") and
73
      not Enum.any?(~w(navigate href patch), &String.contains?(tag, &1))
74
  end
75
76
  defp squeeze(tag), do: tag |> String.split() |> Enum.join(" ")
77
end

This page updates live while a promote is in flight · changelog