Skip to main content

vertigo_forms/resource_table/
row_form.rs

1use std::{future::Future, pin::Pin, rc::Rc};
2use vertigo::{Computed, DomComment, DomNode, Value, bind_rc, bind_spawn, css, dom, transaction};
3
4use crate::button::{Button, ButtonColor, ButtonVariant};
5
6pub type AsyncResult<R> = Pin<Box<dyn Future<Output = R>>>;
7pub type ProcessCallback<Model> = Rc<dyn Fn(Model) -> AsyncResult<Option<String>>>;
8pub type CancelCallback = Rc<dyn Fn() -> AsyncResult<Option<String>>>;
9pub type CreateFn = Rc<dyn Fn() -> DomNode>;
10pub type RowResult<T> = Result<T, Vec<String>>;
11
12#[derive(Clone, PartialEq)]
13pub enum RowState {
14    View { confirm_delete: bool },
15    Edit,
16    Processing,
17}
18
19#[derive(Clone)]
20pub struct ResourceTableLabels {
21    pub edit: String,
22    pub delete: String,
23    pub confirm_delete: String,
24    pub cancel: String,
25    pub confirm_question: String,
26    pub processing: String,
27    pub save: String,
28}
29
30impl Default for ResourceTableLabels {
31    fn default() -> Self {
32        Self {
33            edit: "Edit".to_string(),
34            delete: "Delete".to_string(),
35            confirm_delete: "Confirm delete".to_string(),
36            cancel: "Cancel".to_string(),
37            confirm_question: "Are you sure you want to delete this record?".to_string(),
38            processing: "Processing...".to_string(),
39            save: "Save".to_string(),
40        }
41    }
42}
43
44#[allow(clippy::too_many_arguments)]
45pub fn row_form<Model: Clone + PartialEq + 'static, FormModel: Clone + 'static>(
46    initial_state: RowState,
47    item: &Model,
48    create_form_model: fn(&Model) -> FormModel,
49    update_model: fn(&Model, &FormModel, &vertigo::Context) -> RowResult<Model>,
50    render_view: fn(&Model, create_buttons: CreateFn, alert: Computed<bool>) -> DomNode,
51    render_form: fn(&FormModel, buttons: DomNode) -> DomNode,
52
53    process_label: String,
54    process: ProcessCallback<Model>,
55
56    cancel_label: String,
57    cancel: CancelCallback,
58
59    delete: Option<ProcessCallback<Model>>,
60
61    labels: ResourceTableLabels,
62) -> DomNode {
63    let state = Value::new(initial_state);
64    let error = Value::new(None::<String>);
65    let form_model = create_form_model(item);
66    let optimistic_item: Value<Option<Model>> = Value::new(None);
67
68    let alert_view = state.map(|value| {
69        if let RowState::View { confirm_delete } = value
70            && !confirm_delete
71        {
72            false
73        } else {
74            true
75        }
76    });
77
78    let item = item.clone();
79    state.clone().render_value(move |current_state| {
80        let error_view = error.clone().render_value(|err| {
81            if let Some(err) = err {
82                dom! { <div css={css! {"color: #dc3545; font-size: 0.8rem; margin: 8px 20px; font-weight: 600;"}}>{err}</div> }
83            } else {
84                dom! { <div /> }
85            }
86        });
87
88        match current_state {
89            RowState::View { confirm_delete } => {
90                let confirm_view = if confirm_delete {
91                    let confirm = bind_rc!(state, error, delete, item, || {
92                        if let Some(delete_fn) = &delete {
93                            state.set(RowState::Processing);
94
95                            bind_spawn!(state, error, delete_fn, item, async move {
96                                let result = delete_fn(item).await;
97                                if let Some(err) = result {
98                                    error.set(Some(err));
99                                    state.set(RowState::View { confirm_delete: false });
100                                } else {
101                                    error.set(None);
102                                    state.set(RowState::View { confirm_delete: false });
103                                }
104                            })();
105                        }
106                    });
107
108                    confirm_delete_view(state.clone(), confirm, &labels)
109                } else {
110                    dom! {
111                        <div />
112                    }
113                };
114
115                let state_for_buttons = state.clone();
116                let delete_for_buttons = delete.clone();
117                let edit_label = labels.edit.clone();
118                let delete_label = labels.delete.clone();
119
120                let create_buttons = move || {
121                    let delete_view_inner = if delete_for_buttons.is_some() {
122                        let delete_click = bind_rc!(state_for_buttons, || {
123                            state_for_buttons.set(RowState::View { confirm_delete: true });
124                        });
125
126                        dom! {
127                            <Button
128                                label={delete_label.clone()}
129                                on_click={delete_click}
130                                color={ButtonColor::Danger}
131                                variant={ButtonVariant::Outline}
132                            />
133                        }
134                    } else {
135                        dom! { <div /> }
136                    };
137
138                    dom! {
139                        <div data-testid="row-buttons" css={css! {"display: flex; gap: 12px;"}}>
140                            <Button
141                                label={edit_label.clone()}
142                                on_click={bind_rc!(state_for_buttons, || state_for_buttons.set(RowState::Edit))}
143                                color={ButtonColor::Primary}
144                                variant={ButtonVariant::Outline}
145                            />
146                            {delete_view_inner}
147                        </div>
148                    }
149                };
150
151                DomComment::dom_fragment(vec![
152                    render_view(&item, Rc::new(create_buttons), alert_view.clone()),
153                    dom! {
154                        <div>
155                            { confirm_view }
156                        </div>
157                    },
158                ])
159                .into()
160            }
161            RowState::Edit => {
162                let buttons = dom! {
163                    <div css={css! {"display: flex; gap: 8px; justify-content: flex-end;"}}>
164                        <Button
165                            label={process_label.clone()}
166                            on_click={bind_rc!(state, error, item, process, form_model, optimistic_item, || {
167                                let result = transaction(|ctx| {
168                                    update_model(&item, &form_model, ctx)
169                                });
170
171                                match result {
172                                    Ok(new_model) => {
173                                        optimistic_item.set(Some(new_model.clone()));
174                                        state.set(RowState::Processing);
175                                        bind_spawn!(state, error, optimistic_item, process, async move {
176                                            let result = process(new_model).await;
177                                            if let Some(err) = result {
178                                                error.set(Some(err));
179                                                optimistic_item.set(None);
180                                                state.set(RowState::Edit);
181                                            } else {
182                                                error.set(None);
183                                                state.set(RowState::View { confirm_delete: false });
184                                                optimistic_item.set(None);
185                                            }
186                                        })();
187                                    }
188                                    Err(errors) => {
189                                        error.set(Some(errors.join(", ")));
190                                    }
191                                }
192                            })}
193                            color={ButtonColor::Success}
194                            variant={ButtonVariant::Outline}
195                        />
196                        <Button
197                            label={cancel_label.clone()}
198                            on_click={bind_rc!(state, cancel, || {
199                                bind_spawn!(cancel, async move {
200                                    cancel().await;
201                                })();
202                                state.set(RowState::View { confirm_delete: false });
203                            })}
204                            color={ButtonColor::Danger}
205                            variant={ButtonVariant::Outline}
206                        />
207                    </div>
208                };
209
210                let rendered_form = render_form(&form_model, buttons);
211
212                dom! {
213                    <div>
214                        { rendered_form }
215                        { error_view }
216                    </div>
217                }
218            }
219            RowState::Processing => {
220                let processing_label = labels.processing.clone();
221                optimistic_item.clone().render_value(move |opt_item| {
222                    if let Some(opt_item) = opt_item {
223                        let no_alert = Computed::from(|_ctx| false);
224                        render_view(&opt_item, Rc::new(|| dom! { <div /> }), no_alert)
225                    } else {
226                        let label = processing_label.clone();
227                        dom! {
228                            <div css={css! {"
229                                padding: 24px 20px;
230                                text-align: center;
231                                color: #888;
232                                background: #f9f9f9;
233                                border-bottom: 1px solid #eee;
234                            "}}>
235                                <span css={css! {"
236                                    display: inline-block;
237                                    animation: spin 1s linear infinite;
238                                    margin-right: 8px;
239                                "}}>"↻"</span>
240                                {label}
241                            </div>
242                        }
243                    }
244                })
245            }
246        }
247    })
248}
249
250fn confirm_delete_view(
251    state: Value<RowState>,
252    on_confirm: Rc<dyn Fn() + 'static>,
253    labels: &ResourceTableLabels,
254) -> DomNode {
255    let confirm_question = labels.confirm_question.clone();
256    let confirm_delete_label = labels.confirm_delete.clone();
257    let cancel_label = labels.cancel.clone();
258
259    dom! {
260        <div css={css! {"
261            background: #fff0f0;
262            padding: 16px 20px;
263            border-bottom: 1px solid #ffcccc;
264
265            display: flex;
266            flex-direction: row;
267            justify-content: space-between;
268        "}}>
269            <div css={css! {"
270                font-weight: 600;
271                color: #b02a37;
272
273                display: flex;
274                align-items: center;
275            "}}>
276                {confirm_question}
277            </div>
278
279            <div css={css! {"
280                display: flex;
281                gap: 12px;
282                justify-content: flex-end;
283            "}}>
284                <Button
285                    label={confirm_delete_label}
286                    on_click={on_confirm}
287                    color={ButtonColor::Danger}
288                    variant={ButtonVariant::Outline}
289                />
290                <Button
291                    label={cancel_label}
292                    on_click={bind_rc!(state, || {
293                        state.set(RowState::View { confirm_delete: false });
294                    })}
295                    color={ButtonColor::Secondary}
296                    variant={ButtonVariant::Outline}
297                />
298            </div>
299        </div>
300    }
301}