N

Modal form with validation

A dialog that hosts a form. Validation errors render inside the dialog; a successful save closes it and refreshes the list.

#When to use

"New X" / "Edit X" creates/edits that shouldn't take the user off the current page. Pair with for the dialog chrome and HTMX for the form submit.

#Markup

cshtml
@* Trigger button — wrap in <div x-data> so $dispatch resolves *@
<div x-data class="flex gap-2">
    <noundry-button variant="primary" x-on:click="$dispatch('open-modal', 'product_dialog')">
        New product
    </noundry-button>
</div>

<noundry-modal id="product_dialog" title="New product" size="lg">
    <form id="product_form"
          hx-post="/products?handler=Save"
          hx-target="#products_table"
          hx-swap="outerHTML"
          hx-disabled-elt="find button[type=submit]">
        <partial name="_ProductForm" model="Model.NewProduct" />

        <div class="flex justify-end gap-2 pt-2">
            <noundry-button variant="secondary" type="button"
                            x-on:click="$dispatch('close-modal', 'product_dialog')">
                Cancel
            </noundry-button>
            <noundry-button variant="primary" type="submit">Save</noundry-button>
        </div>
    </form>
</noundry-modal>

#How the close-on-success works

The server emits an HX-Trigger response header on the success path. Htmx parses it and dispatches a corresponding CustomEvent on the page. The listens for close-modal events and closes itself when the event detail matches its id:

csharp
public async Task<IActionResult> OnPostSaveAsync(ProductEditForm form, CancellationToken ct)
{
    if (!ModelState.IsValid)
    {
        return this.PageOrPartial("_ProductForm", form, AjaxExtensions.UnprocessableEntity);
    }

    await _products.SaveAsync(form, ct);
    this.AddToast("Saved.", ToastLevel.Success);

    // Tell htmx to fire close-modal on the client after the swap.
    Response.HtmxTrigger("close-modal");

    return this.PageOrPartial("_ProductsTable", await _products.ListAsync(ct));
}

For events that need a payload, use the JSON form of HX-Trigger:

csharp
Response.Headers[AjaxExtensions.HtmxTriggerHeader] =
    """{"close-modal":{"id":"product_dialog"}}""";

#What 422 does

On validation failure the server returns 422 with the form partial. The starter's _Layout.cshtml includes a htmx:beforeSwap hook that promotes 422 responses to the form element itself (rather than the success target), so the user sees the failed form re-render in place inside the open dialog. See Inline validation for the swap-promotion script.