vertigo_forms/resource_table/
mod.rs1use 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#[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}