Skip to main content

vertigo_forms/resource_table/
mod.rs

1use std::rc::Rc;
2use vertigo::{Computed, Css, DomNode, Resource, Value, bind_rc, css, dom};
3
4use crate::button::TableButton;
5
6pub mod row_from_data_section;
7pub use row_from_data_section::row_from_data_section;
8pub mod row_form;
9
10pub use row_form::{
11    AsyncResult, CancelCallback, CreateFn, ProcessCallback, ResourceTableLabels, RowResult,
12    RowState, row_form,
13};
14
15type ComputedListItem<T> = Computed<Option<T>>;
16type ComputedList<T> = Computed<Resource<Rc<Vec<ComputedListItem<T>>>>>;
17
18/// A generic CRUD table component with inline add/edit forms.
19///
20/// `Model` is the read-only row type; `ModelForm` is the editable form state created from it via
21/// `create_form_model`.
22///
23/// ## Feeding `list` from `LazyListCache`
24///
25/// [`vertigo::LazyListCache::granular`] returns `Resource<Rc<Vec<Computed<Option<T::Value>>>>>`,
26/// which matches `list`'s inner type. Wrap it in [`Computed::from`] to get the right shape:
27///
28/// ```rust,ignore
29/// let list = {
30///     let cache = my_cache.clone();
31///     Computed::from(move |ctx| cache.granular(ctx, None as Option<fn(&MyModel) -> bool>))
32/// };
33///
34/// ResourceTable {
35///     list,
36///     // ...
37/// }
38/// ```
39#[derive(Clone)]
40pub struct ResourceTable<Model: Clone + PartialEq + Default + 'static, ModelForm: Clone + 'static> {
41    pub list: ComputedList<Model>,
42    pub title: String,
43    pub add_label: String,
44    pub table_css: Css,
45    pub render_header: fn() -> DomNode,
46    pub render_filters: Option<fn() -> DomNode>,
47    pub create_new_model: Rc<dyn Fn() -> Model>,
48    pub create_form_model: fn(&Model) -> ModelForm,
49    pub update_model: fn(&Model, &ModelForm, &vertigo::Context) -> RowResult<Model>,
50    pub render_row_view: fn(&Model, create_buttons: CreateFn, alert: Computed<bool>) -> DomNode,
51    pub render_row_form: fn(&ModelForm, buttons: DomNode) -> DomNode,
52    pub on_create: ProcessCallback<Model>,
53    pub on_update: ProcessCallback<Model>,
54    pub on_delete: Option<ProcessCallback<Model>>,
55    pub labels: ResourceTableLabels,
56}
57
58impl<Model: Clone + PartialEq + Default + 'static, ModelForm: Clone + 'static>
59    ResourceTable<Model, ModelForm>
60{
61    pub fn into_component(self) -> ResourceTable<Model, ModelForm> {
62        self
63    }
64    pub fn mount(self) -> DomNode {
65        let props = Rc::new(self);
66
67        let props2 = props.clone();
68        let list = props.list.render_value(move |list| {
69            let mut rows = Vec::new();
70            let props = props2.clone();
71            match list {
72                Resource::Ready(list) => {
73                    for item in &*list {
74                        let props = props.clone();
75                        let row = item.render_value_option(move |item| {
76                            item.map(|item| {
77                                row_form(
78                                    RowState::View {
79                                        confirm_delete: false,
80                                    },
81                                    &item,
82                                    props.create_form_model,
83                                    props.update_model,
84                                    props.render_row_view,
85                                    props.render_row_form,
86                                    props.labels.save.clone(),
87                                    props.on_update.clone(),
88                                    props.labels.cancel.clone(),
89                                    Rc::new(|| Box::pin(async { None })),
90                                    props.on_delete.clone(),
91                                    props.labels.clone(),
92                                )
93                            })
94                        });
95
96                        rows.push(row);
97                    }
98
99                    dom! {
100                        <div>
101                            { ..rows }
102                        </div>
103                    }
104                }
105                Resource::Loading => {
106                    dom! { <div>"Loading..."</div> }
107                }
108                Resource::Error(err) => {
109                    dom! { <div>{err}</div> }
110                }
111            }
112        });
113
114        let is_adding = Value::new(None);
115
116        let table_css = css! {"
117            width: 100%;
118            max-width: 1600px;
119            background: #ffffff;
120            border: 1px solid #e0e0e0;
121            border-radius: 12px;
122            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
123            overflow: hidden;
124            margin: 0 auto;
125        "} + &props.table_css;
126
127        dom! {
128            <div css={css! {"
129                    padding: 10px;
130                    width: 100%;
131                "}}>
132                <div css={table_css}>
133                    <div css={css! {"
134                            display: flex;
135                            justify-content: space-between;
136                            align-items: center;
137                            padding: 20px;
138                            border-bottom: 1px solid #eee;
139                            gap: 10px;
140                        "}}>
141                        <h2 css={css! {"margin: 0; font-size: 1.2rem; color: #333;"}}>{props.title.clone()}</h2>
142
143                        {..props.render_filters.map(|render_filters| render_filters())}
144
145                        <TableButton
146                            label={props.add_label.clone()}
147                            on_click={bind_rc!(is_adding, props.create_new_model, || {
148                                is_adding.set(Some(create_new_model()))
149                            })}
150                        />
151
152                    </div>
153
154                    { (props.render_header)() }
155
156                    <div>
157                        { render_add(props.clone(), is_adding) }
158                        { list }
159                    </div>
160                </div>
161            </div>
162        }
163    }
164}
165
166fn render_add<Model: Clone + PartialEq + Default + 'static, ModelForm: Clone + 'static>(
167    props: Rc<ResourceTable<Model, ModelForm>>,
168    is_adding: Value<Option<Model>>,
169) -> DomNode {
170    is_adding.clone().render_value({
171        let is_adding = is_adding.clone();
172        let props = props.clone();
173
174        move |current_is_adding| {
175            if let Some(item) = current_is_adding {
176                let is_adding = is_adding.clone();
177                let props = props.clone();
178
179                row_form(
180                    RowState::Edit,
181                    &item,
182                    props.create_form_model,
183                    props.update_model,
184                    |_, _, _| dom! { <div /> },
185                    props.render_row_form,
186                    props.add_label.clone(),
187                    Rc::new({
188                        let is_adding = is_adding.clone();
189                        let on_create = props.on_create.clone();
190
191                        move |item| {
192                            let is_adding = is_adding.clone();
193                            let on_create = on_create.clone();
194
195                            Box::pin(async move {
196                                let result = on_create(item).await;
197                                if result.is_none() {
198                                    is_adding.set(None);
199                                }
200                                result
201                            })
202                        }
203                    }),
204                    props.labels.cancel.clone(),
205                    Rc::new({
206                        let is_adding = is_adding.clone();
207                        move || {
208                            is_adding.set(None);
209                            Box::pin(async move { None })
210                        }
211                    }),
212                    None,
213                    props.labels.clone(),
214                )
215            } else {
216                dom! { <div /> }
217            }
218        }
219    })
220}
221
222pub fn base_header_css() -> Css {
223    css! {"
224        display: grid;
225        background: #f8f9fa;
226        padding: 12px 20px;
227        align-items: center;
228
229        font-size: 0.85rem;
230        font-weight: 700;
231        color: #666;
232        border-bottom: 1px solid #eee;
233    "}
234}
235
236pub fn base_row_css() -> Css {
237    css! {"
238        display: grid;
239        padding: 2px 10px;
240        align-items: center;
241        gap: 5px;
242
243        transition: background 0.2s;
244        :hover { background: #f8f8f8; }
245    "}
246}
247
248pub fn edit_input_css() -> Css {
249    css! {"
250        width: 90%; padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px;
251    "}
252}
253
254pub fn read_only_col_css() -> Css {
255    css! {"
256        font-family: monospace; color: #888;
257    "}
258}
259
260pub fn main_col_css() -> Css {
261    css! {"
262        font-weight: 500; color: #333;
263    "}
264}
265
266pub fn normal_col_css() -> Css {
267    css! {"
268        color: #333; font-size: 0.9rem;
269    "}
270}
271
272pub fn low_prio_col_css() -> Css {
273    css! {"
274        color: #666; font-size: 0.9rem;
275    "}
276}