feat(readarr): handle key events for the authors library table
Add LibraryHandler, which owns the authors table and its sort, search, and filter overlays through TableHandlingConfig, along with the add, edit, toggle monitoring, update all, and refresh key actions and the delete prompt. Dispatch into it from ReadarrHandler and add the main tab left and right movement helper. authors_sorting_options offers eight sorts: name, status, quality profile, metadata profile, books, size, monitored, and tags. It omits the type and tracks sorts that the artists table has. GET /author never returns authorType, so that column would be empty on every row, and author statistics carry no track count. Also cover the Readarr arm of handle_events, which was previously unreachable from any test: neutering the arm entirely left the suite green.
This commit is contained in:
5 files changed
+1458
-2
No files matched your search
@@ -3,6 +3,7 @@ mod tests {
|
||||
use crate::assert_modal_absent;
|
||||
use crate::assert_navigation_pushed;
|
||||
use crate::models::radarr_models::Movie;
|
||||
use crate::models::readarr_models::Author;
|
||||
use crate::models::sonarr_models::Series;
|
||||
use crate::{assert_modal_present, assert_navigation_popped};
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -41,6 +42,7 @@ mod tests {
|
||||
#[rstest]
|
||||
#[case(ActiveRadarrBlock::Movies.into(), ActiveRadarrBlock::SearchMovie.into())]
|
||||
#[case(ActiveSonarrBlock::Series.into(), ActiveSonarrBlock::SearchSeries.into())]
|
||||
#[case(ActiveReadarrBlock::Authors.into(), ActiveReadarrBlock::SearchAuthors.into())]
|
||||
fn test_handle_events(#[case] base_block: Route, #[case] top_block: Route) {
|
||||
let mut app = App::test_default();
|
||||
app.push_navigation_stack(base_block);
|
||||
@@ -55,6 +57,11 @@ mod tests {
|
||||
.radarr_data
|
||||
.movies
|
||||
.set_items(vec![Movie::default()]);
|
||||
app
|
||||
.data
|
||||
.readarr_data
|
||||
.authors
|
||||
.set_items(vec![Author::default()]);
|
||||
|
||||
handle_events(DEFAULT_KEYBINDINGS.esc.key, &mut app);
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,278 @@
|
||||
use crate::{
|
||||
app::App,
|
||||
event::Key,
|
||||
handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle},
|
||||
matches_key,
|
||||
models::{
|
||||
BlockSelectionState, HorizontallyScrollableText,
|
||||
readarr_models::Author,
|
||||
servarr_data::readarr::readarr_data::{
|
||||
ActiveReadarrBlock, DELETE_AUTHOR_SELECTION_BLOCKS, EDIT_AUTHOR_SELECTION_BLOCKS,
|
||||
LIBRARY_BLOCKS,
|
||||
},
|
||||
stateful_table::SortOption,
|
||||
},
|
||||
network::readarr_network::ReadarrEvent,
|
||||
};
|
||||
|
||||
use super::handle_change_tab_left_right_keys;
|
||||
use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
|
||||
use crate::models::Route;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "library_handler_tests.rs"]
|
||||
mod library_handler_tests;
|
||||
|
||||
pub(super) struct LibraryHandler<'a, 'b> {
|
||||
key: Key,
|
||||
app: &'a mut App<'b>,
|
||||
active_readarr_block: ActiveReadarrBlock,
|
||||
context: Option<ActiveReadarrBlock>,
|
||||
}
|
||||
|
||||
impl LibraryHandler<'_, '_> {
|
||||
fn extract_author_id(&self) -> i64 {
|
||||
self.app.data.readarr_data.authors.current_selection().id
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for LibraryHandler<'a, 'b> {
|
||||
fn handle(&mut self) {
|
||||
let authors_table_handling_config =
|
||||
TableHandlingConfig::new(ActiveReadarrBlock::Authors.into())
|
||||
.sorting_block(ActiveReadarrBlock::AuthorsSortPrompt.into())
|
||||
.sort_options(authors_sorting_options())
|
||||
.searching_block(ActiveReadarrBlock::SearchAuthors.into())
|
||||
.search_error_block(ActiveReadarrBlock::SearchAuthorsError.into())
|
||||
.search_field_fn(|author| &author.author_name.text)
|
||||
.filtering_block(ActiveReadarrBlock::FilterAuthors.into())
|
||||
.filter_error_block(ActiveReadarrBlock::FilterAuthorsError.into())
|
||||
.filter_field_fn(|author| &author.author_name.text);
|
||||
|
||||
if !handle_table(
|
||||
self,
|
||||
|app| &mut app.data.readarr_data.authors,
|
||||
authors_table_handling_config,
|
||||
) {
|
||||
self.handle_key_event();
|
||||
}
|
||||
}
|
||||
|
||||
fn accepts(active_block: ActiveReadarrBlock) -> bool {
|
||||
LIBRARY_BLOCKS.contains(&active_block)
|
||||
}
|
||||
|
||||
fn ignore_special_keys(&self) -> bool {
|
||||
self.app.ignore_special_keys_for_textbox_input
|
||||
}
|
||||
|
||||
fn new(
|
||||
key: Key,
|
||||
app: &'a mut App<'b>,
|
||||
active_block: ActiveReadarrBlock,
|
||||
context: Option<ActiveReadarrBlock>,
|
||||
) -> LibraryHandler<'a, 'b> {
|
||||
LibraryHandler {
|
||||
key,
|
||||
app,
|
||||
active_readarr_block: active_block,
|
||||
context,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_key(&self) -> Key {
|
||||
self.key
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
!self.app.is_loading && !self.app.data.readarr_data.authors.is_empty()
|
||||
}
|
||||
|
||||
fn handle_scroll_up(&mut self) {}
|
||||
|
||||
fn handle_scroll_down(&mut self) {}
|
||||
|
||||
fn handle_home(&mut self) {}
|
||||
|
||||
fn handle_end(&mut self) {}
|
||||
|
||||
fn handle_delete(&mut self) {
|
||||
if self.active_readarr_block == ActiveReadarrBlock::Authors {
|
||||
self
|
||||
.app
|
||||
.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into());
|
||||
self.app.data.readarr_data.selected_block =
|
||||
BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_left_right_action(&mut self) {
|
||||
match self.active_readarr_block {
|
||||
ActiveReadarrBlock::Authors => handle_change_tab_left_right_keys(self.app, self.key),
|
||||
ActiveReadarrBlock::UpdateAllAuthorsPrompt => handle_prompt_toggle(self.app, self.key),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_submit(&mut self) {
|
||||
match self.active_readarr_block {
|
||||
ActiveReadarrBlock::Authors => {
|
||||
self
|
||||
.app
|
||||
.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into());
|
||||
}
|
||||
ActiveReadarrBlock::UpdateAllAuthorsPrompt => {
|
||||
if self.app.data.readarr_data.prompt_confirm {
|
||||
self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::UpdateAllAuthors);
|
||||
}
|
||||
|
||||
self.app.pop_navigation_stack();
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_esc(&mut self) {
|
||||
match self.active_readarr_block {
|
||||
ActiveReadarrBlock::UpdateAllAuthorsPrompt => {
|
||||
self.app.pop_navigation_stack();
|
||||
self.app.data.readarr_data.prompt_confirm = false;
|
||||
}
|
||||
_ => {
|
||||
handle_clear_errors(self.app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_char_key_event(&mut self) {
|
||||
let key = self.key;
|
||||
match self.active_readarr_block {
|
||||
ActiveReadarrBlock::Authors => match key {
|
||||
_ if matches_key!(add, key) => {
|
||||
self
|
||||
.app
|
||||
.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchInput.into());
|
||||
self.app.data.readarr_data.add_author_search =
|
||||
Some(HorizontallyScrollableText::default());
|
||||
self.app.ignore_special_keys_for_textbox_input = true;
|
||||
}
|
||||
_ if matches_key!(toggle_monitoring, key) => {
|
||||
self.app.data.readarr_data.prompt_confirm = true;
|
||||
self.app.data.readarr_data.prompt_confirm_action = Some(
|
||||
ReadarrEvent::ToggleAuthorMonitoring(self.extract_author_id()),
|
||||
);
|
||||
|
||||
self
|
||||
.app
|
||||
.pop_and_push_navigation_stack(self.active_readarr_block.into());
|
||||
}
|
||||
_ if matches_key!(edit, key) => {
|
||||
self.app.data.readarr_data.edit_author_modal = Some((&self.app.data.readarr_data).into());
|
||||
self
|
||||
.app
|
||||
.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into());
|
||||
self.app.data.readarr_data.selected_block =
|
||||
BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS);
|
||||
}
|
||||
_ if matches_key!(update, key) => {
|
||||
self
|
||||
.app
|
||||
.push_navigation_stack(ActiveReadarrBlock::UpdateAllAuthorsPrompt.into());
|
||||
}
|
||||
_ if matches_key!(refresh, key) => {
|
||||
self.app.should_refresh = true;
|
||||
}
|
||||
_ => (),
|
||||
},
|
||||
ActiveReadarrBlock::UpdateAllAuthorsPrompt => {
|
||||
if matches_key!(confirm, key) {
|
||||
self.app.data.readarr_data.prompt_confirm = true;
|
||||
self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::UpdateAllAuthors);
|
||||
|
||||
self.app.pop_navigation_stack();
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
fn app_mut(&mut self) -> &mut App<'b> {
|
||||
self.app
|
||||
}
|
||||
|
||||
fn current_route(&self) -> Route {
|
||||
self.app.get_current_route()
|
||||
}
|
||||
}
|
||||
|
||||
fn authors_sorting_options() -> Vec<SortOption<Author>> {
|
||||
vec![
|
||||
SortOption {
|
||||
name: "Name",
|
||||
cmp_fn: Some(|a, b| {
|
||||
a.author_name
|
||||
.text
|
||||
.to_lowercase()
|
||||
.cmp(&b.author_name.text.to_lowercase())
|
||||
}),
|
||||
},
|
||||
SortOption {
|
||||
name: "Status",
|
||||
cmp_fn: Some(|a, b| {
|
||||
a.status
|
||||
.to_string()
|
||||
.to_lowercase()
|
||||
.cmp(&b.status.to_string().to_lowercase())
|
||||
}),
|
||||
},
|
||||
SortOption {
|
||||
name: "Quality Profile",
|
||||
cmp_fn: Some(|a, b| a.quality_profile_id.cmp(&b.quality_profile_id)),
|
||||
},
|
||||
SortOption {
|
||||
name: "Metadata Profile",
|
||||
cmp_fn: Some(|a, b| a.metadata_profile_id.cmp(&b.metadata_profile_id)),
|
||||
},
|
||||
SortOption {
|
||||
name: "Books",
|
||||
cmp_fn: Some(|a, b| {
|
||||
a.statistics
|
||||
.as_ref()
|
||||
.map_or(0, |stats| stats.book_count)
|
||||
.cmp(&b.statistics.as_ref().map_or(0, |stats| stats.book_count))
|
||||
}),
|
||||
},
|
||||
SortOption {
|
||||
name: "Size",
|
||||
cmp_fn: Some(|a, b| {
|
||||
a.statistics
|
||||
.as_ref()
|
||||
.map_or(0, |stats| stats.size_on_disk)
|
||||
.cmp(&b.statistics.as_ref().map_or(0, |stats| stats.size_on_disk))
|
||||
}),
|
||||
},
|
||||
SortOption {
|
||||
name: "Monitored",
|
||||
cmp_fn: Some(|a, b| a.monitored.cmp(&b.monitored)),
|
||||
},
|
||||
SortOption {
|
||||
name: "Tags",
|
||||
cmp_fn: Some(|a, b| {
|
||||
let a_str = a
|
||||
.tags
|
||||
.iter()
|
||||
.map(|tag| tag.as_i64().unwrap().to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join(",");
|
||||
let b_str = b
|
||||
.tags
|
||||
.iter()
|
||||
.map(|tag| tag.as_i64().unwrap().to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join(",");
|
||||
|
||||
a_str.cmp(&b_str)
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
use super::KeyEventHandler;
|
||||
use crate::handlers::readarr_handlers::library::LibraryHandler;
|
||||
use crate::models::Route;
|
||||
use crate::{
|
||||
app::App, event::Key, models::servarr_data::readarr::readarr_data::ActiveReadarrBlock,
|
||||
app::App, event::Key, matches_key,
|
||||
models::servarr_data::readarr::readarr_data::ActiveReadarrBlock,
|
||||
};
|
||||
|
||||
mod library;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "readarr_handler_tests.rs"]
|
||||
mod readarr_handler_tests;
|
||||
@@ -16,6 +20,15 @@ pub(super) struct ReadarrHandler<'a, 'b> {
|
||||
}
|
||||
|
||||
impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for ReadarrHandler<'a, 'b> {
|
||||
fn handle(&mut self) {
|
||||
match self.active_readarr_block {
|
||||
_ if LibraryHandler::accepts(self.active_readarr_block) => {
|
||||
LibraryHandler::new(self.key, self.app, self.active_readarr_block, self.context).handle();
|
||||
}
|
||||
_ => self.handle_key_event(),
|
||||
}
|
||||
}
|
||||
|
||||
fn accepts(_active_block: ActiveReadarrBlock) -> bool {
|
||||
true
|
||||
}
|
||||
@@ -72,3 +85,18 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for ReadarrHandler<'a,
|
||||
self.app.get_current_route()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_change_tab_left_right_keys(app: &mut App<'_>, key: Key) {
|
||||
let key_ref = key;
|
||||
match key_ref {
|
||||
_ if matches_key!(left, key, app.ignore_special_keys_for_textbox_input) => {
|
||||
app.data.readarr_data.main_tabs.previous();
|
||||
app.pop_and_push_navigation_stack(app.data.readarr_data.main_tabs.get_active_route());
|
||||
}
|
||||
_ if matches_key!(right, key, app.ignore_special_keys_for_textbox_input) => {
|
||||
app.data.readarr_data.main_tabs.next();
|
||||
app.pop_and_push_navigation_stack(app.data.readarr_data.main_tabs.get_active_route());
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,9 @@ mod tests {
|
||||
use crate::app::App;
|
||||
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
|
||||
use crate::handlers::KeyEventHandler;
|
||||
use crate::handlers::readarr_handlers::ReadarrHandler;
|
||||
use crate::handlers::readarr_handlers::{ReadarrHandler, handle_change_tab_left_right_keys};
|
||||
use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock;
|
||||
use crate::{assert_navigation_pushed, test_handler_delegation};
|
||||
use pretty_assertions::assert_eq;
|
||||
use rstest::rstest;
|
||||
use strum::IntoEnumIterator;
|
||||
@@ -49,4 +50,130 @@ mod tests {
|
||||
assert!(ReadarrHandler::accepts(readarr_block));
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(0, ActiveReadarrBlock::System, ActiveReadarrBlock::Downloads)]
|
||||
#[case(1, ActiveReadarrBlock::Authors, ActiveReadarrBlock::Blocklist)]
|
||||
#[case(2, ActiveReadarrBlock::Downloads, ActiveReadarrBlock::History)]
|
||||
#[case(3, ActiveReadarrBlock::Blocklist, ActiveReadarrBlock::RootFolders)]
|
||||
#[case(4, ActiveReadarrBlock::History, ActiveReadarrBlock::Indexers)]
|
||||
#[case(5, ActiveReadarrBlock::RootFolders, ActiveReadarrBlock::System)]
|
||||
#[case(6, ActiveReadarrBlock::Indexers, ActiveReadarrBlock::Authors)]
|
||||
fn test_readarr_handler_change_tab_left_right_keys(
|
||||
#[case] index: usize,
|
||||
#[case] left_block: ActiveReadarrBlock,
|
||||
#[case] right_block: ActiveReadarrBlock,
|
||||
) {
|
||||
let mut app = App::test_default();
|
||||
app.data.readarr_data.main_tabs.set_index(index);
|
||||
|
||||
handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.left.key);
|
||||
|
||||
assert_eq!(
|
||||
app.data.readarr_data.main_tabs.get_active_route(),
|
||||
left_block.into()
|
||||
);
|
||||
assert_navigation_pushed!(app, left_block.into());
|
||||
|
||||
app.data.readarr_data.main_tabs.set_index(index);
|
||||
|
||||
handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.right.key);
|
||||
|
||||
assert_eq!(
|
||||
app.data.readarr_data.main_tabs.get_active_route(),
|
||||
right_block.into()
|
||||
);
|
||||
assert_navigation_pushed!(app, right_block.into());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(0, ActiveReadarrBlock::System, ActiveReadarrBlock::Downloads)]
|
||||
#[case(1, ActiveReadarrBlock::Authors, ActiveReadarrBlock::Blocklist)]
|
||||
#[case(2, ActiveReadarrBlock::Downloads, ActiveReadarrBlock::History)]
|
||||
#[case(3, ActiveReadarrBlock::Blocklist, ActiveReadarrBlock::RootFolders)]
|
||||
#[case(4, ActiveReadarrBlock::History, ActiveReadarrBlock::Indexers)]
|
||||
#[case(5, ActiveReadarrBlock::RootFolders, ActiveReadarrBlock::System)]
|
||||
#[case(6, ActiveReadarrBlock::Indexers, ActiveReadarrBlock::Authors)]
|
||||
fn test_readarr_handler_change_tab_left_right_keys_alt_navigation(
|
||||
#[case] index: usize,
|
||||
#[case] left_block: ActiveReadarrBlock,
|
||||
#[case] right_block: ActiveReadarrBlock,
|
||||
) {
|
||||
let mut app = App::test_default();
|
||||
app.data.readarr_data.main_tabs.set_index(index);
|
||||
|
||||
handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.left.alt.unwrap());
|
||||
|
||||
assert_eq!(
|
||||
app.data.readarr_data.main_tabs.get_active_route(),
|
||||
left_block.into()
|
||||
);
|
||||
assert_navigation_pushed!(app, left_block.into());
|
||||
|
||||
app.data.readarr_data.main_tabs.set_index(index);
|
||||
|
||||
handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.right.alt.unwrap());
|
||||
|
||||
assert_eq!(
|
||||
app.data.readarr_data.main_tabs.get_active_route(),
|
||||
right_block.into()
|
||||
);
|
||||
assert_navigation_pushed!(app, right_block.into());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(0, ActiveReadarrBlock::Authors)]
|
||||
#[case(1, ActiveReadarrBlock::Downloads)]
|
||||
#[case(2, ActiveReadarrBlock::Blocklist)]
|
||||
#[case(3, ActiveReadarrBlock::History)]
|
||||
#[case(4, ActiveReadarrBlock::RootFolders)]
|
||||
#[case(5, ActiveReadarrBlock::Indexers)]
|
||||
#[case(6, ActiveReadarrBlock::System)]
|
||||
fn test_readarr_handler_change_tab_left_right_keys_alt_navigation_no_op_when_ignoring_quit_key(
|
||||
#[case] index: usize,
|
||||
#[case] block: ActiveReadarrBlock,
|
||||
) {
|
||||
let mut app = App::test_default();
|
||||
app.push_navigation_stack(block.into());
|
||||
app.ignore_special_keys_for_textbox_input = true;
|
||||
app.data.readarr_data.main_tabs.set_index(index);
|
||||
|
||||
handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.left.alt.unwrap());
|
||||
|
||||
assert_eq!(
|
||||
app.data.readarr_data.main_tabs.get_active_route(),
|
||||
block.into()
|
||||
);
|
||||
assert_eq!(app.get_current_route(), block.into());
|
||||
|
||||
app.data.readarr_data.main_tabs.set_index(index);
|
||||
|
||||
handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.right.alt.unwrap());
|
||||
|
||||
assert_eq!(
|
||||
app.data.readarr_data.main_tabs.get_active_route(),
|
||||
block.into()
|
||||
);
|
||||
assert_eq!(app.get_current_route(), block.into());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_delegates_library_blocks_to_library_handler(
|
||||
#[values(
|
||||
ActiveReadarrBlock::Authors,
|
||||
ActiveReadarrBlock::AuthorsSortPrompt,
|
||||
ActiveReadarrBlock::FilterAuthors,
|
||||
ActiveReadarrBlock::FilterAuthorsError,
|
||||
ActiveReadarrBlock::SearchAuthors,
|
||||
ActiveReadarrBlock::SearchAuthorsError,
|
||||
ActiveReadarrBlock::UpdateAllAuthorsPrompt
|
||||
)]
|
||||
active_readarr_block: ActiveReadarrBlock,
|
||||
) {
|
||||
test_handler_delegation!(
|
||||
ReadarrHandler,
|
||||
ActiveReadarrBlock::Authors,
|
||||
active_readarr_block
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user