vertigo-forms 0.2.0

Building block for forms in vertigo
Documentation
use std::{future::Future, pin::Pin, rc::Rc};
use vertigo::{Computed, DomComment, DomNode, Value, bind_rc, bind_spawn, css, dom, transaction};

use crate::button::{Button, ButtonColor, ButtonVariant};

pub type AsyncResult<R> = Pin<Box<dyn Future<Output = R>>>;
pub type ProcessCallback<Model> = Rc<dyn Fn(Model) -> AsyncResult<Option<String>>>;
pub type CancelCallback = Rc<dyn Fn() -> AsyncResult<Option<String>>>;
pub type CreateFn = Rc<dyn Fn() -> DomNode>;
pub type RowResult<T> = Result<T, Vec<String>>;

#[derive(Clone, PartialEq)]
pub enum RowState {
    View { confirm_delete: bool },
    Edit,
    Processing,
}

#[derive(Clone)]
pub struct ResourceTableLabels {
    pub edit: String,
    pub delete: String,
    pub confirm_delete: String,
    pub cancel: String,
    pub confirm_question: String,
    pub processing: String,
    pub save: String,
}

impl Default for ResourceTableLabels {
    fn default() -> Self {
        Self {
            edit: "Edit".to_string(),
            delete: "Delete".to_string(),
            confirm_delete: "Confirm delete".to_string(),
            cancel: "Cancel".to_string(),
            confirm_question: "Are you sure you want to delete this record?".to_string(),
            processing: "Processing...".to_string(),
            save: "Save".to_string(),
        }
    }
}

#[allow(clippy::too_many_arguments)]
pub fn row_form<Model: Clone + PartialEq + 'static, FormModel: Clone + 'static>(
    initial_state: RowState,
    item: &Model,
    create_form_model: fn(&Model) -> FormModel,
    update_model: fn(&Model, &FormModel, &vertigo::Context) -> RowResult<Model>,
    render_view: fn(&Model, create_buttons: CreateFn, alert: Computed<bool>) -> DomNode,
    render_form: fn(&FormModel, buttons: DomNode) -> DomNode,

    process_label: String,
    process: ProcessCallback<Model>,

    cancel_label: String,
    cancel: CancelCallback,

    delete: Option<ProcessCallback<Model>>,

    labels: ResourceTableLabels,
) -> DomNode {
    let state = Value::new(initial_state);
    let error = Value::new(None::<String>);
    let form_model = create_form_model(item);
    let optimistic_item: Value<Option<Model>> = Value::new(None);

    let alert_view = state.map(|value| {
        if let RowState::View { confirm_delete } = value
            && !confirm_delete
        {
            false
        } else {
            true
        }
    });

    let item = item.clone();
    state.clone().render_value(move |current_state| {
        let error_view = error.clone().render_value(|err| {
            if let Some(err) = err {
                dom! { <div css={css! {"color: #dc3545; font-size: 0.8rem; margin: 8px 20px; font-weight: 600;"}}>{err}</div> }
            } else {
                dom! { <div /> }
            }
        });

        match current_state {
            RowState::View { confirm_delete } => {
                let confirm_view = if confirm_delete {
                    let confirm = bind_rc!(state, error, delete, item, || {
                        if let Some(delete_fn) = &delete {
                            state.set(RowState::Processing);

                            bind_spawn!(state, error, delete_fn, item, async move {
                                let result = delete_fn(item).await;
                                if let Some(err) = result {
                                    error.set(Some(err));
                                    state.set(RowState::View { confirm_delete: false });
                                } else {
                                    error.set(None);
                                    state.set(RowState::View { confirm_delete: false });
                                }
                            })();
                        }
                    });

                    confirm_delete_view(state.clone(), confirm, &labels)
                } else {
                    dom! {
                        <div />
                    }
                };

                let state_for_buttons = state.clone();
                let delete_for_buttons = delete.clone();
                let edit_label = labels.edit.clone();
                let delete_label = labels.delete.clone();

                let create_buttons = move || {
                    let delete_view_inner = if delete_for_buttons.is_some() {
                        let delete_click = bind_rc!(state_for_buttons, || {
                            state_for_buttons.set(RowState::View { confirm_delete: true });
                        });

                        dom! {
                            <Button
                                label={delete_label.clone()}
                                on_click={delete_click}
                                color={ButtonColor::Danger}
                                variant={ButtonVariant::Outline}
                            />
                        }
                    } else {
                        dom! { <div /> }
                    };

                    dom! {
                        <div data-testid="row-buttons" css={css! {"display: flex; gap: 12px;"}}>
                            <Button
                                label={edit_label.clone()}
                                on_click={bind_rc!(state_for_buttons, || state_for_buttons.set(RowState::Edit))}
                                color={ButtonColor::Primary}
                                variant={ButtonVariant::Outline}
                            />
                            {delete_view_inner}
                        </div>
                    }
                };

                DomComment::dom_fragment(vec![
                    render_view(&item, Rc::new(create_buttons), alert_view.clone()),
                    dom! {
                        <div>
                            { confirm_view }
                        </div>
                    },
                ])
                .into()
            }
            RowState::Edit => {
                let buttons = dom! {
                    <div css={css! {"display: flex; gap: 8px; justify-content: flex-end;"}}>
                        <Button
                            label={process_label.clone()}
                            on_click={bind_rc!(state, error, item, process, form_model, optimistic_item, || {
                                let result = transaction(|ctx| {
                                    update_model(&item, &form_model, ctx)
                                });

                                match result {
                                    Ok(new_model) => {
                                        optimistic_item.set(Some(new_model.clone()));
                                        state.set(RowState::Processing);
                                        bind_spawn!(state, error, optimistic_item, process, async move {
                                            let result = process(new_model).await;
                                            if let Some(err) = result {
                                                error.set(Some(err));
                                                optimistic_item.set(None);
                                                state.set(RowState::Edit);
                                            } else {
                                                error.set(None);
                                                state.set(RowState::View { confirm_delete: false });
                                                optimistic_item.set(None);
                                            }
                                        })();
                                    }
                                    Err(errors) => {
                                        error.set(Some(errors.join(", ")));
                                    }
                                }
                            })}
                            color={ButtonColor::Success}
                            variant={ButtonVariant::Outline}
                        />
                        <Button
                            label={cancel_label.clone()}
                            on_click={bind_rc!(state, cancel, || {
                                bind_spawn!(cancel, async move {
                                    cancel().await;
                                })();
                                state.set(RowState::View { confirm_delete: false });
                            })}
                            color={ButtonColor::Danger}
                            variant={ButtonVariant::Outline}
                        />
                    </div>
                };

                let rendered_form = render_form(&form_model, buttons);

                dom! {
                    <div>
                        { rendered_form }
                        { error_view }
                    </div>
                }
            }
            RowState::Processing => {
                let processing_label = labels.processing.clone();
                optimistic_item.clone().render_value(move |opt_item| {
                    if let Some(opt_item) = opt_item {
                        let no_alert = Computed::from(|_ctx| false);
                        render_view(&opt_item, Rc::new(|| dom! { <div /> }), no_alert)
                    } else {
                        let label = processing_label.clone();
                        dom! {
                            <div css={css! {"
                                padding: 24px 20px;
                                text-align: center;
                                color: #888;
                                background: #f9f9f9;
                                border-bottom: 1px solid #eee;
                            "}}>
                                <span css={css! {"
                                    display: inline-block;
                                    animation: spin 1s linear infinite;
                                    margin-right: 8px;
                                "}}>""</span>
                                {label}
                            </div>
                        }
                    }
                })
            }
        }
    })
}

fn confirm_delete_view(
    state: Value<RowState>,
    on_confirm: Rc<dyn Fn() + 'static>,
    labels: &ResourceTableLabels,
) -> DomNode {
    let confirm_question = labels.confirm_question.clone();
    let confirm_delete_label = labels.confirm_delete.clone();
    let cancel_label = labels.cancel.clone();

    dom! {
        <div css={css! {"
            background: #fff0f0;
            padding: 16px 20px;
            border-bottom: 1px solid #ffcccc;

            display: flex;
            flex-direction: row;
            justify-content: space-between;
        "}}>
            <div css={css! {"
                font-weight: 600;
                color: #b02a37;

                display: flex;
                align-items: center;
            "}}>
                {confirm_question}
            </div>

            <div css={css! {"
                display: flex;
                gap: 12px;
                justify-content: flex-end;
            "}}>
                <Button
                    label={confirm_delete_label}
                    on_click={on_confirm}
                    color={ButtonColor::Danger}
                    variant={ButtonVariant::Outline}
                />
                <Button
                    label={cancel_label}
                    on_click={bind_rc!(state, || {
                        state.set(RowState::View { confirm_delete: false });
                    })}
                    color={ButtonColor::Secondary}
                    variant={ButtonVariant::Outline}
                />
            </div>
        </div>
    }
}