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