diff --git a/web/src/crate_web/pages/add.gleam b/web/src/crate_web/pages/add.gleam index f94aec8..90bb60d 100644 --- a/web/src/crate_web/pages/add.gleam +++ b/web/src/crate_web/pages/add.gleam @@ -54,8 +54,7 @@ fn add_form_view(model: Model) -> Element(Msg) { frm.field("FOLDER", "Rock A-M", form.folder, FormFolder, "text"), acquisition_view(form), form_error_region(model.form_error), - ctl.button(save_label(model.busy), ctl.Primary, [ - attr.type_("submit"), + ctl.submit_button(save_label(model.busy), ctl.Primary, [ attr.disabled(model.busy), attr.class("btn--block"), ]), diff --git a/web/src/crate_web/pages/login.gleam b/web/src/crate_web/pages/login.gleam index 2c3b0c6..cd9543e 100644 --- a/web/src/crate_web/pages/login.gleam +++ b/web/src/crate_web/pages/login.gleam @@ -161,9 +161,8 @@ fn login_action(handle: String) -> Element(Msg) { // FFI. An anchor would be hijacked by modem (same-origin) and never reach // the server. Submission lives on the form so Enter works too. let disabled = string.trim(handle) == "" - ctl.button("LOG IN WITH ATPROTO →", ctl.Primary, [ + ctl.submit_button("LOG IN WITH ATPROTO →", ctl.Primary, [ attr.disabled(disabled), attr.class("btn--block"), - attr.type_("submit"), ]) } diff --git a/web/src/crate_web/ui/controls.gleam b/web/src/crate_web/ui/controls.gleam index 5a00257..90b8e43 100644 --- a/web/src/crate_web/ui/controls.gleam +++ b/web/src/crate_web/ui/controls.gleam @@ -76,12 +76,31 @@ pub fn status_badge(status: String) -> Element(msg) { } } +/// Always `type="button"`: inside a `
` a bare button is a submit +/// button, so an `on_click` sticker would fire its message and submit the +/// form. Form submits go through `submit_button`, which is the only way to +/// get `type="submit"` here; passing one in `attrs` would emit a duplicate +/// `type` attribute rather than override it. pub fn button( label: String, variant: Btn, attrs: List(attr.Attribute(msg)), ) -> Element(msg) { - html.button([attr.class(btn_class(variant)), ..attrs], [text(label)]) + html.button([attr.class(btn_class(variant)), attr.type_("button"), ..attrs], [ + text(label), + ]) +} + +/// The one button that submits its enclosing form; everything else is a +/// `button`. +pub fn submit_button( + label: String, + variant: Btn, + attrs: List(attr.Attribute(msg)), +) -> Element(msg) { + html.button([attr.class(btn_class(variant)), attr.type_("submit"), ..attrs], [ + text(label), + ]) } pub fn link_button(label: String, variant: Btn, href: String) -> Element(msg) { diff --git a/web/src/crate_web/update/add.gleam b/web/src/crate_web/update/add.gleam index f77c236..7561527 100644 --- a/web/src/crate_web/update/add.gleam +++ b/web/src/crate_web/update/add.gleam @@ -12,6 +12,7 @@ import crate_web/msg.{ type Msg, ArtistSearch, DisarmDiscogsDisconnect, DiscogsSearch, } import crate_web/update/common.{failed, succeeded, write_error} +import gleam/bool import gleam/int import gleam/list import gleam/option.{type Option, None, Some} @@ -105,7 +106,10 @@ pub fn form_counterparty(model: Model, value: String) -> #(Model, Effect(Msg)) { ) } +/// Guarded on `busy`, so a double dispatch (a stray form submit racing the +/// button's own click) can never produce two `add_item` writes. pub fn submit_add(model: Model) -> #(Model, Effect(Msg)) { + use <- bool.guard(when: model.busy, return: #(model, effect.none())) case string.trim(model.form.title), string.trim(model.form.artist), diff --git a/web/test/add_test.gleam b/web/test/add_test.gleam index 6a9664e..e91c890 100644 --- a/web/test/add_test.gleam +++ b/web/test/add_test.gleam @@ -8,10 +8,12 @@ import crate_web/msg.{ } import crate_web/pages/add import crate_web/update.{update} +import gleam/list import gleam/option.{None, Some} +import gleam/result import gleam/string import lustre/element -import support.{base, logged_in} +import support.{base, empty_effect, logged_in} // Validation failures are page-local: they never touch the entered fields, // and they show as an inline region above SAVE TO CRATE rather than the @@ -167,11 +169,50 @@ pub fn no_form_error_renders_nothing_extra_test() { assert !string.contains(html, "class=\"error-state\"") } -pub fn segment_buttons_never_submit_the_form_test() { +/// Inside a `` a bare `