Merge pull request #319 from euzu/feature/table_sort

web ui table sort
This commit is contained in:
euzu
2025-08-11 11:40:54 +02:00
committed by GitHub
7 changed files with 204 additions and 22 deletions
+18
View File
@@ -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"
+11
View File
@@ -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;
}
}
}
}
@@ -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::<usize, bool>::from(move |_col| {
false
});
let on_sort = Callback::<Option<(usize, SortOrder)>, ()>::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::<InputRow> {
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,
}))
@@ -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::<usize, bool>::from(move |_col| {
false
});
let on_sort = Callback::<Option<(usize, SortOrder)>, ()>::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::<ConfigTargetDto> {
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,
}))
+72 -2
View File
@@ -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<T: PartialEq + Clone> {
pub items: Option<Rc<Vec<Rc<T>>>>,
pub num_cols: usize,
// Return true if a given column is sortable
pub is_sortable: Callback<usize, bool>,
pub render_header_cell: Callback<usize, Html>,
pub render_data_cell: Callback<(usize, usize, Rc<T>), Html>,
#[prop_or_else(Callback::noop)]
pub on_sort: Callback<Option<(usize, SortOrder)>, ()>,
}
#[derive(Properties, Clone, PartialEq)]
@@ -20,10 +25,34 @@ pub fn Table<T: PartialEq + Clone>(props: &TableProps<T>) -> 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::<Option<(usize, SortOrder)>, _>(|| 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! {
<div class={"tp__table"}>
<div class={"tp__table__container"}>
@@ -32,8 +61,49 @@ pub fn Table<T: PartialEq + Clone>(props: &TableProps<T>) -> Html {
<tr>
{
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!{ <AppIcon name="SortAsc"/> },
Some((c, SortOrder::Desc)) if c == col_index => html!{ <AppIcon name="SortDesc"/> },
_ => html!{ <AppIcon name="Sort"/> }, // 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!{
<th> {render_header_cell.emit(col_index)}</th>
<th
class={classes!(
if sortable { Some("tp__table__th--sortable") } else { None }
)}
onclick={if sortable { Some(on_click_col) } else { None }}
role={if sortable { Some("button") } else { None }}
aria-sort={
if let Some((c, order)) = &*sort_state {
if *c == col_index {
Some(match order {
SortOrder::Asc => "ascending",
SortOrder::Desc => "descending",
}.to_string())
} else { Some("none".to_string()) }
} else { Some("none".to_string()) }
}
>
<span class="tp__table-header">
{render_header_cell.emit(col_index)}
{icon_html}
</span>
</th>
}
})
}
+1 -1
View File
@@ -31,7 +31,7 @@ pub fn UserlistList() -> Html {
let userlist_body = if let Some(data) = userlist_ctx.get_users().as_ref() {
html! {
<div class="tp__userlist-list__user">
<UserTable targets={if data.is_empty() {None} else {Some(data.clone())}} />
<UserTable users={if data.is_empty() {None} else {Some(data.clone())}} />
</div>
}
} else {
@@ -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<Rc<Vec<Rc<TargetUser>>>>,
pub users: Option<Rc<Vec<Rc<TargetUser>>>>,
}
#[function_component]
@@ -46,6 +65,16 @@ pub fn UserTable(props: &UserTableProps) -> Html {
let popup_anchor_ref = use_state(|| None::<web_sys::Element>);
let popup_is_open = use_state(|| false);
let selected_dto = use_state(|| None::<Rc<TargetUser>>);
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! { <UserStatus status={ dto.credentials.status } /> },
3 => html! { dto.target.as_str() },
4 => html! { dto.credentials.username.as_str() },
5 => html! { <HideContent content={&dto.credentials.password.to_string()}></HideContent> },
5 => html! { <HideContent content={dto.credentials.password.to_string()}></HideContent> },
6 => html! { dto.credentials.token.as_ref().map_or_else(|| html!{}, |token| html! { <HideContent content={token.to_string()}></HideContent>}) },
7 => html! {<ProxyTypeView value={dto.credentials.proxy} /> },
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! { <MaxConnections value={dto.credentials.max_connections} /> },
10 => html! { <Chip class={ convert_bool_to_chip_style(dto.credentials.ui_enabled ) }
label={if dto.credentials.ui_enabled {translator.t("LABEL.ENABLED")} else { translator.t("LABEL.DISABLED")} }
/> },
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! { <AppIcon name="Unlimited" /> }),
.map(|s| html! { { s } }).unwrap_or_else(|| html! { <AppIcon name="Unlimited" /> }),
13 => dto.credentials.exp_date.as_ref().and_then(|ts| unix_ts_to_str(*ts))
.map(|s| html! { { s } }).unwrap_or_else(|| html! { <AppIcon name="Unlimited" /> }),
.map(|s| html! { { s } }).unwrap_or_else(|| html! { <AppIcon name="Unlimited" /> }),
14 => dto.credentials.comment.as_ref()
.map_or_else(|| html!{},
|comment|html! { <RevealContent preview={Some(html! {comment.substring(0, 50)})}>{comment}</RevealContent> }),
.map_or_else(|| html! {},
|comment| html! { <RevealContent preview={Some(html! {comment.substring(0, 50)})}>{comment}</RevealContent> }),
_ => html! {""},
}
})
};
let is_sortable = Callback::<usize, bool>::from(move |col| {
is_col_sortable(col)
});
let on_sort = {
let users = props.users.clone();
let user_list = user_list.clone();
Callback::<Option<(usize, SortOrder)>, ()>::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::<TargetUser> {
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::<TargetUser> {
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();