Skip to content

Commit b4a0beb

Browse files
committed
feat: enhance sponsor and proposal management with filtering and interactive listing
- Added filtering by status and JSON output option to the sponsor listing command. - Implemented interactive listing for sponsors with navigation and detail viewing. - Refactored sponsor fetching logic into a separate function for reusability. - Updated proposal detail rendering to support scrollable views and improved formatting. - Introduced utility functions for handling terminal output and pagination. - Enhanced configuration to include an optional name field for user identification. - Added tests for new features and ensured proper handling of null values in JSON deserialization.
1 parent 68b836a commit b4a0beb

17 files changed

Lines changed: 976 additions & 874 deletions

File tree

src/client.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,11 @@ impl TrpcClient {
116116
}
117117

118118
fn truncate_body(body: &str, max: usize) -> String {
119-
if body.len() <= max {
119+
if body.chars().count() <= max {
120120
body.to_string()
121121
} else {
122-
format!("{}…", &body[..max])
122+
let truncated: String = body.chars().take(max).collect();
123+
format!("{truncated}…")
123124
}
124125
}
125126

src/commands/login.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ pub fn run() -> Result<()> {
4343
token: result.token,
4444
conference_id: result.conference_id.unwrap_or_default(),
4545
conference_title: title.to_string(),
46+
name: result.name.clone(),
4647
};
4748
config::save(&cfg)?;
4849

src/commands/proposals.rs

Lines changed: 240 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ use anyhow::Result;
22
use clap::Args;
33
use colored::Colorize;
44
use console::{Key, Term};
5-
use dialoguer::{FuzzySelect, MultiSelect, Select};
5+
use dialoguer::{Confirm, FuzzySelect, Input, MultiSelect, Select};
66

77
use super::require_client;
88
use crate::client::TrpcClient;
99
use crate::display;
10-
use crate::types::{Proposal, ReviewScore};
11-
use crate::ui;
10+
use crate::types::{Proposal, ReviewInput, ReviewScore};
11+
use crate::{config, ui};
1212

1313
// ---------------------------------------------------------------------------
1414
// CLI argument types (clap::Args — used directly from main.rs)
@@ -37,6 +37,28 @@ pub struct ListArgs {
3737
pub asc: bool,
3838
}
3939

40+
#[derive(Args)]
41+
pub struct ReviewArgs {
42+
/// Proposal ID
43+
pub id: String,
44+
45+
/// Content score (1–5)
46+
#[arg(long, value_parser = clap::value_parser!(u8).range(1..=5))]
47+
pub content: Option<u8>,
48+
49+
/// Relevance score (1–5)
50+
#[arg(long, value_parser = clap::value_parser!(u8).range(1..=5))]
51+
pub relevance: Option<u8>,
52+
53+
/// Speaker score (1–5)
54+
#[arg(long, value_parser = clap::value_parser!(u8).range(1..=5))]
55+
pub speaker: Option<u8>,
56+
57+
/// Review comment
58+
#[arg(long)]
59+
pub comment: Option<String>,
60+
}
61+
4062
impl ListArgs {
4163
fn has_cli_filters(&self) -> bool {
4264
self.status.is_some() || self.format.is_some() || self.sort != "created" || self.asc
@@ -162,8 +184,9 @@ fn avg_rating(p: &Proposal) -> f64 {
162184
.count()
163185
.max(1);
164186
#[allow(clippy::cast_precision_loss)]
165-
let result = total / count as f64;
166-
result
187+
{
188+
total / count as f64
189+
}
167190
}
168191

169192
// ---------------------------------------------------------------------------
@@ -325,6 +348,12 @@ pub async fn fetch_one(client: &TrpcClient, id: &str) -> Result<Proposal> {
325348
client.query("proposal.admin.getById", Some(&input)).await
326349
}
327350

351+
pub async fn submit_review(client: &TrpcClient, input: &ReviewInput) -> Result<serde_json::Value> {
352+
client
353+
.mutate("proposal.admin.submitReview", &serde_json::to_value(input)?)
354+
.await
355+
}
356+
328357
// ---------------------------------------------------------------------------
329358
// Command entry points
330359
// ---------------------------------------------------------------------------
@@ -361,6 +390,151 @@ pub async fn get(id: &str, json: bool) -> Result<()> {
361390
Ok(())
362391
}
363392

393+
pub async fn review(args: ReviewArgs) -> Result<()> {
394+
let client = require_client()?;
395+
let reviewer_name = config::load().ok().and_then(|c| c.name);
396+
397+
let sp = ui::spinner("Fetching proposal…");
398+
let proposal = fetch_one(&client, &args.id).await?;
399+
sp.finish_and_clear();
400+
401+
display::print_proposal_detail(&proposal);
402+
println!();
403+
404+
// If all scores and comment are provided, submit non-interactively
405+
if let (Some(content), Some(relevance), Some(speaker), Some(comment)) =
406+
(args.content, args.relevance, args.speaker, args.comment)
407+
{
408+
let input = ReviewInput {
409+
id: args.id,
410+
comment,
411+
score: ReviewScore {
412+
content: f64::from(content),
413+
relevance: f64::from(relevance),
414+
speaker: f64::from(speaker),
415+
},
416+
};
417+
418+
let sp = ui::spinner("Submitting review…");
419+
submit_review(&client, &input).await?;
420+
sp.finish_and_clear();
421+
422+
println!("Review submitted ({:.0}/15)", input.score.total());
423+
} else {
424+
prompt_and_submit_review(&client, &proposal, reviewer_name.as_deref()).await?;
425+
}
426+
427+
Ok(())
428+
}
429+
430+
async fn prompt_and_submit_review(
431+
client: &TrpcClient,
432+
proposal: &Proposal,
433+
reviewer_name: Option<&str>,
434+
) -> Result<()> {
435+
// Find the user's existing review to pre-fill defaults
436+
let existing = reviewer_name.and_then(|name| {
437+
proposal.reviews.iter().find(|r| {
438+
r.reviewer
439+
.as_ref()
440+
.is_some_and(|rev| rev.name.eq_ignore_ascii_case(name))
441+
})
442+
});
443+
444+
if existing.is_some() {
445+
println!("{}", "Updating your existing review.".dimmed());
446+
}
447+
448+
let prev_score = existing.and_then(|r| r.score.as_ref());
449+
let prev_comment = existing.and_then(|r| r.comment.as_deref()).unwrap_or("");
450+
451+
// Prompt scores (Esc to cancel at any step)
452+
let Some(content) = prompt_score("Content", score_default(prev_score, |s| s.content))? else {
453+
println!("{}", "Review cancelled.".dimmed());
454+
return Ok(());
455+
};
456+
let Some(relevance) = prompt_score("Relevance", score_default(prev_score, |s| s.relevance))?
457+
else {
458+
println!("{}", "Review cancelled.".dimmed());
459+
return Ok(());
460+
};
461+
let Some(speaker) = prompt_score("Speaker", score_default(prev_score, |s| s.speaker))? else {
462+
println!("{}", "Review cancelled.".dimmed());
463+
return Ok(());
464+
};
465+
466+
let comment: String = Input::new()
467+
.with_prompt("Comment")
468+
.with_initial_text(prev_comment)
469+
.allow_empty(true)
470+
.interact_text()?;
471+
472+
// Show summary and confirm
473+
let total = f64::from(content) + f64::from(relevance) + f64::from(speaker);
474+
println!(
475+
"\n Content: {content} Relevance: {relevance} Speaker: {speaker} Total: {total:.0}/15"
476+
);
477+
if !comment.is_empty() {
478+
println!(" Comment: {comment}");
479+
}
480+
481+
if !Confirm::new()
482+
.with_prompt("Submit review?")
483+
.default(true)
484+
.interact()?
485+
{
486+
println!("{}", "Review cancelled.".dimmed());
487+
return Ok(());
488+
}
489+
490+
let input = ReviewInput {
491+
id: proposal.id.clone(),
492+
comment,
493+
score: ReviewScore {
494+
content: f64::from(content),
495+
relevance: f64::from(relevance),
496+
speaker: f64::from(speaker),
497+
},
498+
};
499+
500+
let sp = ui::spinner("Submitting review…");
501+
submit_review(client, &input).await?;
502+
sp.finish_and_clear();
503+
504+
println!(
505+
"{} Review submitted ({:.0}/15)",
506+
"✔".green().bold(),
507+
input.score.total()
508+
);
509+
Ok(())
510+
}
511+
512+
fn score_default(prev: Option<&ReviewScore>, f: impl Fn(&ReviewScore) -> f64) -> usize {
513+
prev.map_or(2, |s| {
514+
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
515+
let v = f(s) as usize;
516+
v.saturating_sub(1).min(4)
517+
})
518+
}
519+
520+
const SCORE_LABELS: &[&str] = &[
521+
"1 - Poor",
522+
"2 - Fair",
523+
"3 - Good",
524+
"4 - Very good",
525+
"5 - Excellent",
526+
];
527+
528+
fn prompt_score(category: &str, default: usize) -> Result<Option<u8>> {
529+
let selection = Select::new()
530+
.with_prompt(format!("{category} (1–5, esc to cancel)"))
531+
.items(SCORE_LABELS)
532+
.default(default)
533+
.interact_opt()?;
534+
#[allow(clippy::cast_possible_truncation)]
535+
Ok(selection.map(|idx| (idx + 1) as u8))
536+
}
537+
364538
// ---------------------------------------------------------------------------
365539
// Output modes
366540
// ---------------------------------------------------------------------------
@@ -450,47 +624,84 @@ async fn show_detail_loop(
450624
proposal_ids: &[&str],
451625
start: usize,
452626
) -> Result<usize> {
453-
let term = Term::stderr();
627+
let reviewer_name = config::load().ok().and_then(|c| c.name);
454628
let mut idx = start;
455629
let total = proposal_ids.len();
456630

457631
loop {
458-
term.clear_screen()?;
459-
println!("{}", format!("[{}/{}]", idx + 1, total).dimmed());
460-
461632
let sp = ui::spinner("Loading…");
462633
let proposal = fetch_one(client, proposal_ids[idx]).await?;
463634
sp.finish_and_clear();
464635

465-
display::print_proposal_detail(&proposal);
636+
let content = display::render_proposal_detail(&proposal);
466637

467-
let mut nav_parts = vec![];
638+
// Build nav hints — scroll hints added dynamically by the pager
639+
let mut nav = vec![];
468640
if idx > 0 {
469-
nav_parts.push("← prev");
641+
nav.push("← prev");
470642
}
471643
if idx + 1 < total {
472-
nav_parts.push("→ next");
644+
nav.push("→ next");
473645
}
474-
nav_parts.push("any other key to go back");
475-
println!("\n{}", nav_parts.join(" · ").dimmed());
646+
// Use the longest possible hint string for viewport sizing
647+
let mut nav_full = nav.clone();
648+
nav_full.extend(["↑↓/jk scroll", "^u/^d half-page", "r review", "q/esc back"]);
649+
let footer_measure = nav_full.join(" · ");
476650

477-
match term.read_key()? {
478-
Key::ArrowLeft | Key::Char('h' | 'k') => {
479-
idx = idx.saturating_sub(1);
480-
}
481-
Key::ArrowRight | Key::Char('l' | 'j') => {
482-
if idx + 1 < total {
483-
idx += 1;
484-
}
485-
}
486-
_ => {
487-
term.clear_screen()?;
488-
break;
651+
let mut pager = ui::Pager::new(&content, &footer_measure);
652+
653+
// Build the actual footer shown to the user
654+
if pager.is_scrollable() {
655+
nav.push("↑↓/jk scroll");
656+
nav.push("^u/^d half-page");
657+
}
658+
nav.push("r review");
659+
nav.push("q/esc back");
660+
let footer = nav.join(" · ").dimmed().to_string();
661+
662+
loop {
663+
let header = if pager.is_scrollable() {
664+
format!(
665+
"[{}/{}] ↕ {}/{}",
666+
idx + 1,
667+
total,
668+
pager.scroll_offset() + 1,
669+
pager.line_count()
670+
)
671+
} else {
672+
format!("[{}/{}]", idx + 1, total)
673+
};
674+
675+
pager.render(&header.dimmed().to_string(), &footer)?;
676+
677+
match pager.handle_key()? {
678+
ui::pager::Action::Redraw => {}
679+
ui::pager::Action::Custom(key) => match key {
680+
Key::ArrowLeft | Key::Char('h') => {
681+
idx = idx.saturating_sub(1);
682+
break;
683+
}
684+
Key::ArrowRight | Key::Char('l') => {
685+
if idx + 1 < total {
686+
idx += 1;
687+
}
688+
break;
689+
}
690+
Key::Char('r') => {
691+
println!();
692+
prompt_and_submit_review(client, &proposal, reviewer_name.as_deref())
693+
.await?;
694+
break;
695+
}
696+
Key::Escape | Key::Char('q') => {
697+
pager.clear()?;
698+
return Ok(idx);
699+
}
700+
_ => {}
701+
},
489702
}
490703
}
491704
}
492-
493-
Ok(idx)
494705
}
495706

496707
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)