diff --git a/frontend/public/assets/icons.json b/frontend/public/assets/icons.json index 1b3df15e1..8326bdcf3 100644 --- a/frontend/public/assets/icons.json +++ b/frontend/public/assets/icons.json @@ -119,6 +119,24 @@ ], "path": "m7 14 5-5 5 5z" }, + { + "keys": [ + "SortAsc" + ], + "path": "m20 12-1.41-1.41L13 16.17V4h-2v12.17l-5.58-5.59L4 12l8 8z" + }, + { + "keys": [ + "SortDesc" + ], + "path": "M 20,12 18.59,13.41 13,7.83 V 20 H 11 V 7.83 L 5.42,13.42 4,12 12,4 Z" + }, + { + "keys": [ + "Sort" + ], + "path": "M 16.222224,17.566656 V 9.7777638 H 14 V 17.566656 H 10.666667 L 15.11111,22 19.555557,17.566656 Z M 8.444443,2 4,6.4333018 H 7.3333333 V 14.222194 H 9.555557 V 6.4333018 h 3.333333 z" + }, { "keys": [ "CloudDownload" diff --git a/frontend/scss/app/components/_table.scss b/frontend/scss/app/components/_table.scss index bc2dd4b69..564f21f67 100644 --- a/frontend/scss/app/components/_table.scss +++ b/frontend/scss/app/components/_table.scss @@ -34,6 +34,17 @@ background-color: var(--table-header-background-color); box-shadow: inset 0 -1px 0 var(--border-color); } + .tp__table-header { + display: flex; + flex-flow: row; + gap: var(--gap-small); + align-items: center; + cursor: pointer; + .svg-icon { + width: 1rem; + height: 1rem; + } + } } } diff --git a/frontend/src/app/components/playlist/input_table.rs b/frontend/src/app/components/playlist/input_table.rs index ee0fdd5a6..f66e49656 100644 --- a/frontend/src/app/components/playlist/input_table.rs +++ b/frontend/src/app/components/playlist/input_table.rs @@ -7,7 +7,7 @@ use yew::platform::spawn_local; use yew::prelude::*; use yew_i18n::use_translation; use shared::error::{create_tuliprox_error_result, TuliproxError, TuliproxErrorKind}; -use shared::model::{ConfigInputAliasDto, ConfigInputDto}; +use shared::model::{ConfigInputAliasDto, ConfigInputDto, SortOrder}; use crate::app::components::menu_item::MenuItem; use crate::model::DialogResult; use crate::services::{DialogService}; @@ -182,15 +182,26 @@ pub fn InputTable(props: &InputTableProps) -> Html { }) }; + let is_sortable = Callback::::from(move |_col| { + false + }); + + let on_sort = Callback::, ()>::from(move |_args| { + }); + let table_definition = { let render_header_cell_cb = render_header_cell.clone(); let render_data_cell_cb = render_data_cell.clone(); + let is_sortable = is_sortable.clone(); + let on_sort = on_sort.clone(); let num_cols = HEADERS.len(); use_memo(props.inputs.clone(), |inputs| inputs.as_ref().map(|list| Rc::new(TableDefinition:: { items: if list.is_empty() {None} else {Some(Rc::new(list.clone()))}, num_cols, + is_sortable, + on_sort, render_header_cell: render_header_cell_cb, render_data_cell: render_data_cell_cb, })) diff --git a/frontend/src/app/components/playlist/target_table.rs b/frontend/src/app/components/playlist/target_table.rs index d17fdb250..512c42a42 100644 --- a/frontend/src/app/components/playlist/target_table.rs +++ b/frontend/src/app/components/playlist/target_table.rs @@ -1,11 +1,12 @@ use crate::app::components::menu_item::MenuItem; use crate::app::components::popup_menu::PopupMenu; -use crate::app::components::{convert_bool_to_chip_style, AppIcon, Chip, FilterView, PlaylistMappings, PlaylistProcessing, RevealContent, Table, TableDefinition, TargetOptions, TargetOutput, TargetRename, TargetSort, TargetWatch}; +use crate::app::components::{convert_bool_to_chip_style, AppIcon, Chip, FilterView, PlaylistMappings, + PlaylistProcessing, RevealContent, Table, TableDefinition, TargetOptions, TargetOutput, TargetRename, TargetSort, TargetWatch}; use crate::hooks::use_service_context; use crate::model::DialogResult; use crate::services::DialogService; use shared::error::{create_tuliprox_error_result, TuliproxError, TuliproxErrorKind}; -use shared::model::ConfigTargetDto; +use shared::model::{ConfigTargetDto, SortOrder}; use std::fmt::Display; use std::rc::Rc; use std::str::FromStr; @@ -109,16 +110,27 @@ pub fn TargetTable(props: &TargetTableProps) -> Html { }) }; + let is_sortable = Callback::::from(move |_col| { + false + }); + + let on_sort = Callback::, ()>::from(move |_args| { + }); + let table_definition = { // first register for config update let render_header_cell_cb = render_header_cell.clone(); let render_data_cell_cb = render_data_cell.clone(); + let is_sortable = is_sortable.clone(); + let on_sort = on_sort.clone(); let num_cols = HEADERS.len(); use_memo(props.targets.clone(), move |targets| { targets.as_ref().map(|list| Rc::new(TableDefinition:: { items: if list.is_empty() {None} else {Some(Rc::new(list.clone()))}, num_cols, + is_sortable, + on_sort, render_header_cell: render_header_cell_cb, render_data_cell: render_data_cell_cb, })) diff --git a/frontend/src/app/components/table.rs b/frontend/src/app/components/table.rs index eb647eb1c..409ccaf63 100644 --- a/frontend/src/app/components/table.rs +++ b/frontend/src/app/components/table.rs @@ -1,13 +1,18 @@ use std::rc::Rc; use yew::prelude::*; -use crate::app::components::{NoContent}; +use crate::app::components::{NoContent, AppIcon}; +use shared::model::SortOrder; #[derive(Properties, PartialEq)] pub struct TableDefinition { pub items: Option>>>, pub num_cols: usize, + // Return true if a given column is sortable + pub is_sortable: Callback, pub render_header_cell: Callback, pub render_data_cell: Callback<(usize, usize, Rc), Html>, + #[prop_or_else(Callback::noop)] + pub on_sort: Callback, ()>, } #[derive(Properties, Clone, PartialEq)] @@ -20,10 +25,34 @@ pub fn Table(props: &TableProps) -> Html { let TableDefinition { items, num_cols, + is_sortable, + on_sort, render_header_cell, render_data_cell, } = &*props.definition; + // Local sort state: None = neutral; Some((col, order)) = sorted column and order + let sort_state = use_state::, _>(|| None); + + let on_header_click = { + let sort_state = sort_state.clone(); + let is_sortable = is_sortable.clone(); + let on_sort = on_sort.clone(); + Callback::from(move |col_index: usize| { + if !is_sortable.emit(col_index) { + return; + } + let state = match *sort_state { + Some((c, SortOrder::Asc)) if c == col_index => Some((col_index, SortOrder::Desc)), + Some((c, SortOrder::Desc)) if c == col_index => None, + _ => Some((col_index, SortOrder::Asc)), + }; + + sort_state.set(state); + on_sort.emit(state); + }) + }; + html! {
@@ -32,8 +61,49 @@ pub fn Table(props: &TableProps) -> Html { { for (0..*num_cols).map(|col_index| { + // Determine if this column is sortable + let sortable = is_sortable.emit(col_index); + + // Decide which icon to show for this column + let icon_html = if sortable { + match *sort_state { + Some((c, SortOrder::Asc)) if c == col_index => html!{ }, + Some((c, SortOrder::Desc)) if c == col_index => html!{ }, + _ => html!{ }, // neutral + } + } else { + html!{} + }; + + // Click handler per column + let on_click_col = { + let on_header_click = on_header_click.clone(); + Callback::from(move |_| on_header_click.emit(col_index)) + }; + html!{ - {render_header_cell.emit(col_index)} + "ascending", + SortOrder::Desc => "descending", + }.to_string()) + } else { Some("none".to_string()) } + } else { Some("none".to_string()) } + } + > + + {render_header_cell.emit(col_index)} + {icon_html} + + } }) } diff --git a/frontend/src/app/components/userlist/list.rs b/frontend/src/app/components/userlist/list.rs index e85350fae..772cdca94 100644 --- a/frontend/src/app/components/userlist/list.rs +++ b/frontend/src/app/components/userlist/list.rs @@ -31,7 +31,7 @@ pub fn UserlistList() -> Html { let userlist_body = if let Some(data) = userlist_ctx.get_users().as_ref() { html! {
- +
} } else { diff --git a/frontend/src/app/components/userlist/user_table.rs b/frontend/src/app/components/userlist/user_table.rs index 488fc8f70..0b3e11968 100644 --- a/frontend/src/app/components/userlist/user_table.rs +++ b/frontend/src/app/components/userlist/user_table.rs @@ -1,19 +1,21 @@ +use std::borrow::Cow; use crate::app::components::menu_item::MenuItem; use crate::app::components::popup_menu::PopupMenu; use crate::app::components::{convert_bool_to_chip_style, AppIcon, Chip, HideContent, MaxConnections, ProxyTypeView, RevealContent, Table, TableDefinition, UserStatus, UserlistContext, UserlistPage}; +use crate::app::context::TargetUser; use crate::model::DialogResult; +use crate::services::DialogService; use shared::error::{create_tuliprox_error_result, TuliproxError, TuliproxErrorKind}; +use shared::model::SortOrder; +use shared::utils::{unix_ts_to_str, Substring}; use std::fmt::Display; use std::rc::Rc; use std::str::FromStr; use yew::platform::spawn_local; use yew::prelude::*; use yew_i18n::use_translation; -use shared::utils::{unix_ts_to_str, Substring}; -use crate::app::context::TargetUser; -use crate::services::DialogService; const HEADERS: [&str; 15] = [ "TABLE.EMPTY", @@ -33,9 +35,26 @@ const HEADERS: [&str; 15] = [ "TABLE.COMMENT", ]; +fn get_cell_value(user: &TargetUser, col: usize) -> Cow<'_, str> { + match col { + 1 => Cow::Owned(user.credentials.is_active().to_string()), + 2 => Cow::Owned(user.credentials.status.as_ref().map_or_else(String::new, ToString::to_string)), + 3 => Cow::Borrowed(user.target.as_str()), + 4 => Cow::Borrowed(user.credentials.username.as_str()), + 7 => Cow::Owned(user.credentials.proxy.to_string()), + 8 => Cow::Owned(user.credentials.server.as_ref().map_or_else(String::new, Clone::clone)), + _ => Cow::Owned(String::new()) + } +} + +fn is_col_sortable(col: usize) -> bool { + matches!(col, 1 | 2 | 3 | 4 | 7 | 8) +} + + #[derive(Properties, PartialEq, Clone)] pub struct UserTableProps { - pub targets: Option>>>, + pub users: Option>>>, } #[function_component] @@ -46,6 +65,16 @@ pub fn UserTable(props: &UserTableProps) -> Html { let popup_anchor_ref = use_state(|| None::); let popup_is_open = use_state(|| false); let selected_dto = use_state(|| None::>); + let user_list = use_state(|| props.users.clone()); + + { + let user_list = user_list.clone(); + let users = props.users.clone(); + use_effect_with(users, move |users| { + user_list.set(users.clone()); + || () + }); + } let handle_popup_close = { let set_is_open = popup_is_open.clone(); @@ -105,37 +134,69 @@ pub fn UserTable(props: &UserTableProps) -> Html { 2 => html! { }, 3 => html! { dto.target.as_str() }, 4 => html! { dto.credentials.username.as_str() }, - 5 => html! { }, + 5 => html! { }, 6 => html! { dto.credentials.token.as_ref().map_or_else(|| html!{}, |token| html! { }) }, 7 => html! { }, - 8 => dto.credentials.server.as_ref().map_or_else(|| html! {}, |s| html! { s } ), + 8 => dto.credentials.server.as_ref().map_or_else(|| html! {}, |s| html! { s }), 9 => html! { }, 10 => html! { }, - 11 => dto.credentials.epg_timeshift.as_ref().map_or_else(|| html! {}, |s| html! { s } ), + 11 => dto.credentials.epg_timeshift.as_ref().map_or_else(|| html! {}, |s| html! { s }), 12 => dto.credentials.created_at.as_ref().and_then(|ts| unix_ts_to_str(*ts)) - .map(|s| html! { { s } }).unwrap_or_else(|| html! { }), + .map(|s| html! { { s } }).unwrap_or_else(|| html! { }), 13 => dto.credentials.exp_date.as_ref().and_then(|ts| unix_ts_to_str(*ts)) - .map(|s| html! { { s } }).unwrap_or_else(|| html! { }), + .map(|s| html! { { s } }).unwrap_or_else(|| html! { }), 14 => dto.credentials.comment.as_ref() - .map_or_else(|| html!{}, - |comment|html! { {comment} }), + .map_or_else(|| html! {}, + |comment| html! { {comment} }), _ => html! {""}, } }) }; + let is_sortable = Callback::::from(move |col| { + is_col_sortable(col) + }); + + let on_sort = { + let users = props.users.clone(); + let user_list = user_list.clone(); + Callback::, ()>::from(move |args| { + if let Some((col, order)) = args { + if let Some(new_user_list)= users.as_ref() { + let mut new_user_list = new_user_list.as_ref().clone(); + new_user_list.sort_by(|a, b| { + let a_value = get_cell_value(a, col); + let b_value = get_cell_value(b, col); + match order { + SortOrder::Asc => a_value.cmp(&b_value), + SortOrder::Desc => b_value.cmp(&a_value), + } + }); + user_list.set(Some(Rc::new(new_user_list))); + } + } else { + user_list.set(users.clone()); + } + }) + }; + let table_definition = { // first register for config update let render_header_cell_cb = render_header_cell.clone(); let render_data_cell_cb = render_data_cell.clone(); + let on_sort = on_sort.clone(); + let is_sortable = is_sortable.clone(); let num_cols = HEADERS.len(); - use_memo(props.targets.clone(), move |targets| { - let items = targets.clone(); - TableDefinition:: { + let user_list_clone = user_list.clone(); + use_memo(user_list_clone.clone(), move |targets| { + let items = if (*targets).as_ref().is_none_or(|l| l.is_empty()) {None} else {(**targets).clone()}; + TableDefinition:: { items, num_cols, + is_sortable, + on_sort, render_header_cell: render_header_cell_cb, render_data_cell: render_data_cell_cb, } @@ -158,8 +219,7 @@ pub fn UserTable(props: &UserTableProps) -> Html { ul_context.active_page.set(UserlistPage::Edit); } } - TableAction::Refresh => { - } + TableAction::Refresh => {} TableAction::Delete => { let confirm = confirm.clone(); let translator = translate.clone();