leptab/lib.rs
1
2pub mod model;
3use model::*;
4use leptos::*;
5use serde_json::Value;
6
7
8/// Data table component
9///
10/// # Arguments
11///
12/// * `headers` - headers with extra data for the table
13/// * `data` - data to display in the table
14/// * `offset` - data offset for pagination
15/// * `search` - search string
16/// * `sort` - sort ascending (false), descending (true)
17/// * `sort_by` - sort by column name
18/// * `limit` - number of rows to display per page
19/// * `total` - total number of rows
20/// * `current_page` - current page number
21/// * `allow_download` - allow download of the table data
22/// * `download_filename` - filename for the downloaded file
23/// * `download_resource` - resource for downloading the data
24#[allow(non_snake_case)]
25#[component]
26pub fn DataTable(
27 headers: RwSignal<Vec<TableHeader>>,
28 data: Signal<Vec<Value>>,
29 offset: RwSignal<u32>,
30 search: RwSignal<String>,
31 sort: RwSignal<bool>,
32 sort_by: RwSignal<String>,
33 limit: RwSignal<u32>,
34 total: RwSignal<u32>,
35 current_page: RwSignal<u32>,
36 allow_download: RwSignal<bool>,
37 download_filename: RwSignal<String>,
38 download_resource: Resource<DownloadDataRequest, Result<String, ServerFnError>>
39) -> impl IntoView {
40 let pages_entries = RwSignal::new(vec![5, 10, 15, 20, 25, 50, 100]);
41 view! {
42 <div class="p-1">
43 <div class="flex justify-between my-2">
44 <div class="flex flex-auto justify-start gap-2 items-center">
45 <select
46 class="text-xs border-gray-800 rounded shadow-md select-xs hover:shadow-sm hover:shadow-success bg-base-100"
47 name="row_slice"
48 on:change=move |e| {
49 let val = event_target_value(&e).parse::<u32>().unwrap();
50 limit.set(val);
51 current_page.set(1);
52 offset.set(0)
53 }
54 >
55
56 {move || {
57 pages_entries
58 .get()
59 .into_iter()
60 .map(|page_entry| {
61 view! {
62 <option
63 prop:selected=limit.get() == page_entry
64 value=page_entry.to_string()
65 >
66 {page_entry}
67 </option>
68 }
69 })
70 .collect_view()
71 }}
72
73 </select>
74 <Suspense
75 fallback = move || view! {<div class = "flex justify start gap-2 items-center"><span class = "loading loading-spinner loading-xs"></span><span class = "text-xs opacity-50 font-extralight">"Loading File"</span></div>}
76 >
77 <ErrorBoundary
78 fallback = move |_| view! {<div class = "flex justify start gap-2 items-center"><span class = "text-xs/3 text-error opacity-50 font-extralight">"Error Loading Download File"</span></div>}
79 >
80 {
81 move || {
82 download_resource.and_then(|d| {
83 match allow_download.get() && total.get().gt(&0u32){
84 true => {
85 view! {
86 <DownloadCsvAnchor
87 content=d.clone()
88 file_name=download_filename.get()
89 />
90 }
91 }
92 false => view! {}.into_view(),
93 }
94
95 })
96 }
97 }
98 </ErrorBoundary>
99 </Suspense>
100 </div>
101 <div class="flex flex-auto justify-end gap-1">
102 <div class = "flex gap-1 items-center">
103 <span class="text-xs font-light">"Search : "</span>
104 <input
105 type="text"
106 class="input input-xs rounded input-info focus:outline-none focus:shadow-outline"
107 placeholder=""
108 prop:value=search
109 on:blur=move |event| {
110 search.set(event_target_value(&event));
111 current_page.set(1);
112 offset.set(0);
113 }
114 />
115
116 </div>
117 </div>
118 </div>
119 <table class="table table-xs table-zebra-zebra mt-1">
120 <thead>
121 <tr>
122
123 {move || {
124 headers
125 .get()
126 .into_iter()
127 .map(|i| {
128 let header = RwSignal::new(i);
129 view! { <TableHeader sort_by=sort_by header=header sort=sort/> }
130 })
131 .collect_view()
132 }}
133
134 </tr>
135 </thead>
136 <tbody>
137 {move || {
138 match data.get().is_empty() {
139 true => {
140 view! {
141 <tr>
142 <td colspan=headers.get().len() class="text-center">
143 <span class="opacity-50 font-extralight">
144 No data available
145 </span>
146 </td>
147 </tr>
148 }
149 .into_view()
150 }
151 false => {
152 view! {
153 {move || {
154 {
155 data.get()
156 .into_iter()
157 .map(|value| {
158 view! {
159 <tr>
160
161 {move || {
162 headers
163 .get()
164 .into_iter()
165 .map(|header| {
166 view! {
167 <td>
168 {
169 match header.prefix {
170 Some(ref p) => view! { <span class="text-xs opacity-50 text-xs/3">{format!("{} ", p)}</span> }.into_view(),
171 None => view! {}.into_view()
172 }
173 }
174 {
175 let number_style = if header.is_number_styled {
176 match header
177 .find(&value)
178 .parse::<f64>()
179 .ok()
180 {
181 Some(parsed_value) if parsed_value >= 0.0 => "text-success",
182 Some(_) => "text-error",
183 None => "",
184 }
185 }else {""};
186 let style_when_success = match header
187 .find(&value)
188 .to_uppercase().contains(&header.style_when_success.to_uppercase()) && !header.style_when_success.is_empty()
189 {
190 true => "text-success",
191 false => "",
192 };
193 let style_when_error = match header
194 .find(&value)
195 .to_uppercase().contains(&header.style_when_error.to_uppercase()) && !header.style_when_error.is_empty()
196 {
197 true => "text-error",
198 false => "",
199 };
200 let case_style = match header.to_uppercase {
201 true => "uppercase",
202 false => "",
203 };
204 let style = format!(
205 "{} {} {} {}",
206 number_style,
207 style_when_success,
208 style_when_error,
209 case_style,
210 );
211 view! { <span class=style>{header.find(&value)}</span> }
212 }
213 {match header.is_currency {
214 true => {
215 let has_value = header.find(&value).parse::<f64>().is_ok();
216 if has_value {
217 view! {
218 <span class="text-xs opacity-50 text-xs/3">
219 {format!(" {}", header.find_currency(&value))}
220 </span>
221 }
222 }else{
223 view! { <span></span> }
224 }
225
226 }
227 false => view! { <span></span> },
228 }}
229
230 </td>
231 }
232 })
233 .collect_view()
234 }}
235
236 </tr>
237 }
238 })
239 .collect_view()
240 }
241 .into_view()
242 }}
243 }
244 .into_view()
245 }
246 }
247 }}
248
249 </tbody>
250 <tfoot>
251 <tr>
252 <td colspan=move || headers.get().len()>
253 <TablePagination
254 total=total
255 limit=limit
256 offset=offset
257 current_page=current_page
258 />
259 </td>
260 </tr>
261 </tfoot>
262 </table>
263 </div>
264 }
265}
266
267#[allow(non_snake_case)]
268#[component]
269fn TableHeader(sort_by: RwSignal<String>, header: RwSignal<TableHeader>, sort: RwSignal<bool>) -> impl IntoView {
270 view! {
271 <th
272 class="cursor-pointer text-sm text-white bg-opacity-50 bg-success uppercase"
273 on:click=move |_| {
274 sort.update(|i| *i = !*i);
275 sort_by.set(header.get().sort_name);
276 }
277 >
278 <div class="flex justify-between">
279 <span class="flex-0">{move || header.get().display_name}</span>
280 <span class="flex-0">
281 <svg
282 xmlns="http://www.w3.org/2000/svg"
283 viewBox="0 0 20 20"
284 fill="currentColor"
285 class="w-5 h-5"
286 >
287 <path
288 fill-rule="evenodd"
289 d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z"
290 clip-rule="evenodd"
291 ></path>
292 </svg>
293 </span>
294 </div>
295 </th>
296 }
297}
298
299#[allow(non_snake_case)]
300#[component]
301fn TableRow(sort_by: RwSignal<String>, header: RwSignal<TableHeader>, sort: RwSignal<bool>) -> impl IntoView {
302 view! {
303 <th
304 class="cursor-pointer"
305 on:click=move |_| {
306 sort.update(|i| *i = !*i);
307 sort_by.set(header.get().sort_name);
308 }
309 >
310 {move || header.get().display_name}
311 </th>
312 }
313}
314
315/// Pagination component for the DirectusDataTable
316///
317/// # Arguments
318///
319/// * `total` - Total number of rows in the table
320/// * `limit` - Number of rows to display per page
321/// * `offset` - The current offset of the table
322/// * `current_page` - The current page number
323#[allow(non_snake_case)]
324#[component]
325pub fn TablePagination(
326 total: RwSignal<u32>,
327 limit: RwSignal<u32>,
328 offset: RwSignal<u32>,
329 current_page: RwSignal<u32>
330) -> impl IntoView {
331 let previous_disabled = move || current_page.get() == 1;
332 let aggregated_button = move || {
333 // let mut buttons = vec![];
334 let page_count = total.get() / limit.get();
335 let total_page = if total.get() % limit.get() != 0 {page_count + 1} else {page_count};
336 generate_button_numbers(current_page.get(), total_page)
337 // for i in 1..=total_page {
338 // buttons.push(i);
339 // }
340 // if total.get() % limit.get() != 0 {
341 // buttons.push(total_page + 1);
342 // }
343 // buttons
344 };
345 let total_page_count = move || {
346 let page_count = total.get() / limit.get();
347 if total.get() % limit.get() != 0 {
348 page_count + 1
349 } else {
350 page_count
351 }
352 };
353 let row_from = move || offset.get() + 1;
354 let row_to = move || {
355 let r = row_from() + limit.get() - 1;
356 if r > total.get() {
357 total.get()
358 } else {
359 r
360 }
361 };
362
363 let show_pagination = move || limit.get() < total.get();
364 let next_disabled = move || current_page.get() == total_page_count();
365
366 view! {
367 <div class="flex justify-between w-full">
368 <div class="flex-auto">
369 <span>
370 {move || {
371 format!("Showing {} to {} of {} entries", row_from(), row_to(), total.get())
372 }}
373 </span>
374 </div>
375 <Show when=move || show_pagination()>
376 <div class="flex flex-auto justify-end">
377 <button
378 class="btn btn-ghost btn-xs"
379 prop:disabled=move || previous_disabled()
380 on:click=move |_| {
381 current_page.update(|i| *i = *i - 1);
382 offset.set((current_page.get() - 1) * limit.get());
383 }
384 >
385
386 Previous
387 </button>
388
389 {move || {
390 aggregated_button()
391 .into_iter()
392 .map(|i| {
393 view! {
394 <button
395 class="btn btn-square btn-xs"
396 prop:disabled=move || current_page.get() == i
397 on:click=move |_| {
398 current_page.set(i);
399 offset.set((current_page.get() - 1) * limit.get());
400 }
401 >
402 {i}
403 </button>
404 }
405 })
406 .collect_view()
407 }}
408
409 <button
410 class="btn btn-ghost btn-xs"
411 on:click=move |_| {
412 current_page.update(|i| *i = *i + 1);
413 offset.set((current_page.get() - 1) * limit.get());
414 }
415
416 prop:disabled=move || next_disabled()
417 >
418 Next
419 </button>
420 </div>
421 </Show>
422 </div>
423 }
424}
425
426
427#[allow(non_snake_case)]
428#[component]
429pub fn DownloadCsvAnchor(
430 content: String,
431 file_name: String,
432 #[prop(optional)] button_name: String,
433) -> impl IntoView {
434 use wasm_bindgen::JsValue;
435 use web_sys::{
436 js_sys::{Array, Uint8Array},
437 Blob, BlobPropertyBag,
438 };
439 let new_file_name = move || {
440 let utc = chrono::Utc::now();
441 let utc_local = utc.with_timezone(&chrono::Local);
442 let formatted_local = utc_local.format("%Y%m%d_%H%M%S").to_string();
443 format!("{}_{}.csv", formatted_local, file_name)
444 };
445 let button_placeholder = move || match button_name.len() > 0 {
446 true => button_name,
447 false => String::from("CSV"),
448 };
449 let download = move || {
450 let uint8arr = Uint8Array::new(&unsafe { Uint8Array::view(&content.as_bytes()) }.into());
451 let array = Array::new();
452 array.push(&uint8arr.buffer());
453 let file = Blob::new_with_u8_array_sequence_and_options(
454 &JsValue::from(array),
455 BlobPropertyBag::new().type_("text/csv"),
456 )
457 .unwrap();
458 let doc = leptos_dom::document();
459 let hyperlink = wasm_bindgen::JsCast::dyn_into::<web_sys::HtmlAnchorElement>(
460 doc.create_element("a").unwrap(),
461 )
462 .unwrap();
463 hyperlink.set_download(new_file_name().as_str());
464 let url = web_sys::Url::create_object_url_with_blob(&file).unwrap();
465 hyperlink.set_href(&url);
466 hyperlink.click();
467 hyperlink.remove();
468 };
469 view! {
470 <button
471 class="font-normal btn btn-xs btn-ghost bg-base-100 rounded text-xs"
472 on:click=move |_| download()
473 >
474 <div class="flex gap-2 justify-normal text-center items-center content-center">
475 <span>
476 <svg
477 xmlns="http://www.w3.org/2000/svg"
478 viewBox="0 0 20 20"
479 fill="currentColor"
480 class="w-3 h-3"
481 >
482 <path
483 fill-rule="evenodd"
484 d="M4.5 2A1.5 1.5 0 003 3.5v13A1.5 1.5 0 004.5 18h11a1.5 1.5 0 001.5-1.5V7.621a1.5 1.5 0 00-.44-1.06l-4.12-4.122A1.5 1.5 0 0011.378 2H4.5zm4.75 6.75a.75.75 0 011.5 0v2.546l.943-1.048a.75.75 0 011.114 1.004l-2.25 2.5a.75.75 0 01-1.114 0l-2.25-2.5a.75.75 0 111.114-1.004l.943 1.048V8.75z"
485 clip-rule="evenodd"
486 ></path>
487 </svg>
488 </span>
489 <span class="font-extralight">{button_placeholder()}</span>
490 </div>
491 </button>
492 }
493}
494
495
496
497/// Fix maximum button number to 5
498fn generate_button_numbers(current_page: u32, total_page: u32) -> Vec<u32> {
499 if total_page <= 5 {
500 return (1..=total_page).collect();
501 }
502 let start = if current_page <= 3 {
503 1
504 } else if (current_page + 2) <= total_page {
505 current_page - 2
506 } else {
507 total_page - 4
508 };
509 (start..=start + 4.min(total_page - start)).collect()
510}
511