Lustre / Gleam: How to open confirm dialog

Given that we have a list of items, and a "delete" button for each item. We want that when user clicks the button, a confirm dialog will appear, asking user one more time, before proceeding with API call to delete the item from the database.

Delete confirmation

Here is how to do that in Lustre. The dialog in this example is the HTML native dialog created by window.confirm() method, in case we want some thing quick and simple. If you want to use <dialog> element, I will write later.

First, let's write a view function to prepare the HTML:

import lustre/attribute as a
import lustre/element/html as h
import lustre/event as ev
import lucide_lustre as lucide_icon

fn render_post_row(post: Post) {
  let Post(id:, ..) = post

  h.button(
    [
      a.type_("button"),
      a.class("hover:text-red-600 cursor-pointer"),
    ],
    [
      lucide_icon.eraser([a.class("w-5 h-auto")]),
    ],
  ),
}

We define message variant for the click event and the "confirmed" event:

pub type Msg {
  ...
  UserClickedDeletion(String)
  UserConfirmedDeletion(String)
}

Attach the click handler:

  h.button(
    [
      ...,
      ev.on_click(UserClickedDeletion(id)),
    ],
    [
      ...
    ],
  ),

When handling the UserClickedDeletion message, we will call the JS method window.confirm to create and show the dialog. It's JS code, so we need to create a simple FFI function:

In element.ffi.mjs:

/**
 * Show a confirmation dialog to the user
 * @param {string} message - The message to display in the confirmation dialog
 * @returns {boolean} True if user confirmed, false otherwise
 */
export function confirm(message) {
  return window.confirm(message)
}

In ffi.gleam:

@external(javascript, "./element.ffi.mjs", "confirm")
pub fn confirm(message: String) -> Bool

The operation of showing dialog, waiting for user action must be asynchronous, to not block our app update cycle, so we will implement it as Effect. The effect.before_paint() utility can be used for this:

fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
  case msg {
    UserClickedDeletion(id) -> {
      // To show dialog for deletion confirmation
      let whatnext = {
        use dispatch, _root <- effect.before_paint
        let agreed = ffi.confirm("Are you sure want to delete?")
        io.println("User agree? " <> bool.to_string(agreed))
        use <- bool.guard(!agreed, Nil)
        dispatch(UserConfirmedDeletion(id))
      }
      #(model, whatnext)
    }
  }
}

If user clicks "Yes", the Lustre runtime will send back the UserConfirmedDeletion message to us.

If you are not familiar with Gleam use expression yet, here is the callback version:

let whatsnext =
  effect.before_paint(fn(dispatch, _root) {
    let agreed = ffi.confirm("Are you sure want to delete?")
    io.println("User agree? " <> bool.to_string(agreed))
    case agreed {
      True -> dispatch(UserConfirmedDeletion(id))
      False -> Nil
    }
  })

In the handling of UserConfirmedDeletion, we start to call API to delete the item:

fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
  case msg {
    UserConfirmedDeletion(id) -> {
      let model = Model(..model, loading_status: IsSubmitting)
      let whatnext = actions.delete_content_item_via_api(id)
      #(model, whatnext)
    }
  }
}

I don't go to the detail of actions.delete_content_item_via_api(), because it is out of scope. Basically, it is just an Effect that uses rsvp to make API call.