vertigo_forms/resource_table/
row_form.rs1use 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}