lariv-rs 0.1.0

Compile-time plugin web application framework built on Axum, SeaORM, Maud, and HTMX
Documentation
//! Shared query params and typed wiring for create-modal GET/POST handlers.

use serde::Deserialize;

use crate::components::SwapKey;
use crate::http::{ModalGet, RouteQueryBuilder, RouteUrl};

/// Query string for create-modal forms (`name` form identity + optional parent table
/// refresh and FK field target).
///
/// `refresh` is the parent [`.data-table-container`](crate::components::data_table) element id
/// (a [`SwapKey`](crate::components::SwapKey) id). When set on successful create *without*
/// [`Self::target_input`], the modal is closed and that table is asked to re-fetch via
/// `HX-Trigger` on `document` (see [`crate::web::table_refresh_event`]).
///
/// `target_input` is the FK / M2M field name to fill instead of refreshing a picker table.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ModalFormQuery {
    #[serde(default)]
    pub name: Option<String>,
    #[serde(default)]
    pub refresh: Option<String>,
    #[serde(default)]
    pub target_input: Option<String>,
}

impl ModalFormQuery {
    /// Form identity string (empty when absent).
    pub fn form_name(&self) -> String {
        self.name.clone().unwrap_or_default()
    }

    /// Parent table id to refresh after create (empty when absent).
    pub fn refresh_table(&self) -> String {
        self.refresh.clone().unwrap_or_default()
    }

    /// FK / M2M field name to fill after create (empty when absent).
    pub fn target_input(&self) -> String {
        self.target_input.clone().unwrap_or_default()
    }

    /// True when `refresh` matches the typed table key id.
    pub fn refreshes_table<T: SwapKey>(&self) -> bool {
        self.refresh.as_deref() == Some(T::ID)
    }
}

/// Create-modal swap key with typed GET/POST routes and form identity.
///
/// Table refresh is chosen at the call site via [`modal_create_get_url`] /
/// [`modal_create_post_url_for_table`], not on the route — the same create modal may refresh
/// different tables (list vs FK picker).
pub trait CreateModal: SwapKey {
    type Get: ModalGet + RouteUrl + Copy + Default;
    type Post: RouteUrl + Copy + Default;
    const FORM_NAME: &'static str;
}

/// Implement [`CreateModal`] for a swap key with typed GET/POST routes and form identity.
#[macro_export]
macro_rules! impl_create_modal {
    ($modal:ty, $get:ty, $post:ty, $form:expr) => {
        impl $crate::web::CreateModal for $modal {
            type Get = $get;
            type Post = $post;
            const FORM_NAME: &'static str = $form;
        }
    };
}

/// Append `name` and `refresh=<T::ID>` to an existing create-modal GET URL.
///
/// Use when the GET URL already has extra query params (e.g. account `ParentID`) that
/// [`modal_create_get_for`] would drop.
pub fn modal_create_href_for_table<T: SwapKey>(href: &str, form_name: &str) -> String {
    let mut out = href.to_string();
    append_query_if_absent(&mut out, "name", form_name);
    append_query_if_absent(&mut out, "refresh", T::ID);
    out
}

/// Append `name` and `target_input` to an existing create-modal GET URL (FK picker plus).
///
/// Use when the GET URL already has extra query params (e.g. account `ParentID`) that
/// [`modal_create_get_for_picker`] would drop.
pub fn modal_create_href_for_picker(href: &str, form_name: &str, target_input: &str) -> String {
    let mut out = href.to_string();
    append_query_if_absent(&mut out, "name", form_name);
    append_query_if_absent(&mut out, "target_input", target_input);
    out
}

fn query_has_param(url: &str, key: &str) -> bool {
    url.contains(&format!("?{key}=")) || url.contains(&format!("&{key}="))
}

fn append_query_if_absent(url: &mut String, key: &str, value: &str) {
    if value.is_empty() || query_has_param(url, key) {
        return;
    }
    let sep = if url.contains('?') { '&' } else { '?' };
    url.push(sep);
    url.push_str(key);
    url.push('=');
    url.push_str(value);
}

/// Build a create-modal GET URL with `name` and typed parent table refresh.
pub fn modal_create_get_url<T: SwapKey>(route: impl RouteUrl, form_name: &str) -> String {
    // Trailing slash matches other modal openers (`RouteUrl::url`) used by Lead/Contact create.
    modal_create_url(route, form_name, T::ID, "", true)
}

/// Build a create-modal GET URL for [`CreateModal`] `M` refreshing table `T`.
pub fn modal_create_get_for<M: CreateModal, T: SwapKey>() -> String {
    modal_create_get_url::<T>(M::Get::default(), M::FORM_NAME)
}

/// Build a create-modal GET URL for [`CreateModal`] `M` that fills FK field `target_input`.
pub fn modal_create_get_for_picker<M: CreateModal>(target_input: &str) -> String {
    modal_create_url(M::Get::default(), M::FORM_NAME, "", target_input, true)
}

/// Build a create-modal POST action URL with optional `name` and `refresh` query params.
pub fn modal_create_post_url(route: impl RouteUrl, form_name: &str, refresh: &str) -> String {
    modal_create_post_query(route, form_name, refresh, "")
}

/// Build a create-modal POST action URL with optional `refresh` and `target_input`.
pub fn modal_create_post_query(
    route: impl RouteUrl,
    form_name: &str,
    refresh: &str,
    target_input: &str,
) -> String {
    modal_create_url(route, form_name, refresh, target_input, false)
}

/// Build a create-modal POST action URL refreshing typed table `T`.
pub fn modal_create_post_url_for_table<T: SwapKey>(
    route: impl RouteUrl,
    form_name: &str,
) -> String {
    modal_create_post_url(route, form_name, T::ID)
}

/// Build a create-modal POST action URL for [`CreateModal`] `M` refreshing table `T`.
pub fn modal_create_post_for<M: CreateModal, T: SwapKey>() -> String {
    modal_create_post_url_for_table::<T>(M::Post::default(), M::FORM_NAME)
}

/// Build an edit-modal POST action URL with optional `name` form identity (no table refresh).
pub fn modal_edit_post_url(route: impl RouteUrl, form_name: &str) -> String {
    modal_create_url(route, form_name, "", "", false)
}

fn modal_create_url(
    route: impl RouteUrl,
    form_name: &str,
    refresh: &str,
    target_input: &str,
    trailing_slash: bool,
) -> String {
    let mut builder = RouteQueryBuilder::new(route);
    if !form_name.is_empty() {
        builder = builder.query("name", form_name);
    }
    if !refresh.is_empty() {
        builder = builder.query("refresh", refresh);
    }
    if !target_input.is_empty() {
        builder = builder.query("target_input", target_input);
    }
    if trailing_slash {
        builder.build()
    } else {
        builder.build_with_query()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::components::swap::SwapKey;
    use crate::http::{ModalGet, RouteTag, RouteUrl};
    use crate::swap_key;

    swap_key!(TestCreateModalKey, "test-create-modal");
    swap_key!(TestTableKey, "test-table");

    #[derive(Clone, Copy, Default)]
    pub struct TestCreateGetRoute;
    impl RouteTag for TestCreateGetRoute {
        const PATH: &'static str = "/test/create";
    }
    impl RouteUrl for TestCreateGetRoute {
        fn path(self) -> String {
            Self::PATH.to_owned()
        }
        fn url(self) -> String {
            Self::PATH.to_owned()
        }
    }
    impl ModalGet for TestCreateGetRoute {}

    #[derive(Clone, Copy, Default)]
    pub struct TestCreatePostRoute;
    impl RouteTag for TestCreatePostRoute {
        const PATH: &'static str = "/test/create";
    }
    impl RouteUrl for TestCreatePostRoute {
        fn path(self) -> String {
            Self::PATH.to_owned()
        }
        fn url(self) -> String {
            Self::PATH.to_owned()
        }
    }

    impl CreateModal for TestCreateModalKey {
        type Get = TestCreateGetRoute;
        type Post = TestCreatePostRoute;
        const FORM_NAME: &'static str = "p_test.CreateForm";
    }

    #[test]
    fn modal_create_urls_embed_table_refresh() {
        let get = modal_create_get_for::<TestCreateModalKey, TestTableKey>();
        assert!(get.contains("/test/create/?"), "{get}");
        assert!(get.contains("name=p_test.CreateForm"), "{get}");
        assert!(get.contains("refresh=test-table"), "{get}");

        let post = modal_create_post_for::<TestCreateModalKey, TestTableKey>();
        assert!(post.contains("/test/create?"), "{post}");
        assert!(post.contains("refresh=test-table"), "{post}");
        assert!(!post.contains("/test/create/?"), "{post}");
    }

    #[test]
    fn modal_create_href_preserves_existing_query() {
        let href = modal_create_href_for_table::<TestTableKey>(
            "/test/create/?ParentID=9",
            "p_test.CreateForm",
        );
        assert!(href.contains("ParentID=9"), "{href}");
        assert!(href.contains("name=p_test.CreateForm"), "{href}");
        assert!(href.contains("refresh=test-table"), "{href}");
        assert_eq!(href.matches('?').count(), 1, "{href}");
    }

    #[test]
    fn modal_create_get_for_picker_embeds_target_input() {
        let get = modal_create_get_for_picker::<TestCreateModalKey>("CustomerID");
        assert!(get.contains("/test/create/?"), "{get}");
        assert!(get.contains("name=p_test.CreateForm"), "{get}");
        assert!(get.contains("target_input=CustomerID"), "{get}");
        assert!(!get.contains("refresh="), "{get}");
    }

    #[test]
    fn modal_create_href_for_picker_preserves_existing_query() {
        let href = modal_create_href_for_picker(
            "/test/create/?ParentID=9",
            "p_test.CreateForm",
            "ParentID",
        );
        assert!(href.contains("ParentID=9"), "{href}");
        assert!(href.contains("name=p_test.CreateForm"), "{href}");
        assert!(href.contains("target_input=ParentID"), "{href}");
        assert!(!href.contains("refresh="), "{href}");
    }

    #[test]
    fn modal_create_post_query_embeds_target_input() {
        let post =
            modal_create_post_query(TestCreatePostRoute, "p_test.CreateForm", "", "CustomerID");
        assert!(post.contains("target_input=CustomerID"), "{post}");
        assert!(!post.contains("refresh="), "{post}");
    }

    #[test]
    fn modal_form_query_matches_table_key() {
        let q = ModalFormQuery {
            refresh: Some(TestTableKey::ID.to_string()),
            ..Default::default()
        };
        assert!(q.refreshes_table::<TestTableKey>());
        assert!(!q.refreshes_table::<TestCreateModalKey>());
    }
}

#[cfg(all(test, feature = "plugin-crm"))]
mod crm_button_tests {
    use crate::components::table_create_button;
    use crate::plugins::crm::keys::{CompanyCreateModalKey, CompanyTableKey};

    #[test]
    fn company_table_create_button_hx_get() {
        let html = table_create_button::<CompanyTableKey, CompanyCreateModalKey>(
            Some("plus"),
            "btn-square btn-outline btn-sm",
        )
        .into_string();
        assert!(html.contains("hx-get="), "{html}");
        assert!(
            html.contains("/crm/companies/create/?name=p_crm.CompanyCreateForm"),
            "{html}"
        );
        assert!(html.contains("refresh=crm-company-table"), "{html}");
        // name must not be duplicated by button_modal_form
        assert_eq!(
            html.matches("name=p_crm.CompanyCreateForm").count(),
            1,
            "{html}"
        );
    }
}