feat: implement interactive data table with sorting and column control

Replaces static course table with TanStack Table featuring sortable
columns, column visibility management, and server-side sort handling.
Adds reusable data-table primitives adapted for Svelte 5 runes.
This commit is contained in:
2026-01-29 01:04:18 -06:00
parent d108a41f91
commit 67d7c81ef4
13 changed files with 812 additions and 149 deletions
+46 -24
View File
@@ -21,10 +21,15 @@ pub async fn search_courses(
campus: Option<&str>,
limit: i32,
offset: i32,
order_by: &str,
) -> Result<(Vec<Course>, i64)> {
// Build WHERE clauses dynamically via parameter binding + COALESCE trick:
// each optional filter uses ($N IS NULL OR column = $N) so NULL means "no filter".
let courses = sqlx::query_as::<_, Course>(
//
// ORDER BY is interpolated as a string since column names can't be bound as
// parameters. The caller must provide a safe, pre-validated clause (see
// `sort_clause` in routes.rs).
let query = format!(
r#"
SELECT *
FROM courses
@@ -36,22 +41,24 @@ pub async fn search_courses(
AND ($6::bool = false OR max_enrollment > enrollment)
AND ($7::text IS NULL OR instructional_method = $7)
AND ($8::text IS NULL OR campus = $8)
ORDER BY subject, course_number, sequence_number
ORDER BY {order_by}
LIMIT $9 OFFSET $10
"#,
)
.bind(term_code)
.bind(subject)
.bind(title_query)
.bind(course_number_low)
.bind(course_number_high)
.bind(open_only)
.bind(instructional_method)
.bind(campus)
.bind(limit)
.bind(offset)
.fetch_all(db_pool)
.await?;
"#
);
let courses = sqlx::query_as::<_, Course>(&query)
.bind(term_code)
.bind(subject)
.bind(title_query)
.bind(course_number_low)
.bind(course_number_high)
.bind(open_only)
.bind(instructional_method)
.bind(campus)
.bind(limit)
.bind(offset)
.fetch_all(db_pool)
.await?;
let total: (i64,) = sqlx::query_as(
r#"
@@ -102,10 +109,25 @@ pub async fn get_course_by_crn(
pub async fn get_course_instructors(
db_pool: &PgPool,
course_id: i32,
) -> Result<Vec<(String, String, Option<String>, bool, Option<f32>, Option<i32>)>> {
let rows: Vec<(String, String, Option<String>, bool, Option<f32>, Option<i32>)> =
sqlx::query_as(
r#"
) -> Result<
Vec<(
String,
String,
Option<String>,
bool,
Option<f32>,
Option<i32>,
)>,
> {
let rows: Vec<(
String,
String,
Option<String>,
bool,
Option<f32>,
Option<i32>,
)> = sqlx::query_as(
r#"
SELECT i.banner_id, i.display_name, i.email, ci.is_primary,
rp.avg_rating, rp.num_ratings
FROM course_instructors ci
@@ -114,10 +136,10 @@ pub async fn get_course_instructors(
WHERE ci.course_id = $1
ORDER BY ci.is_primary DESC, i.display_name
"#,
)
.bind(course_id)
.fetch_all(db_pool)
.await?;
)
.bind(course_id)
.fetch_all(db_pool)
.await?;
Ok(rows)
}
+15 -15
View File
@@ -28,21 +28,22 @@ pub async fn batch_upsert_rmp_professors(
let legacy_ids: Vec<i32> = deduped.iter().map(|p| p.legacy_id).collect();
let graphql_ids: Vec<&str> = deduped.iter().map(|p| p.graphql_id.as_str()).collect();
let first_names: Vec<String> = deduped.iter().map(|p| p.first_name.trim().to_string()).collect();
let first_name_refs: Vec<&str> = first_names.iter().map(|s| s.as_str()).collect();
let last_names: Vec<String> = deduped.iter().map(|p| p.last_name.trim().to_string()).collect();
let last_name_refs: Vec<&str> = last_names.iter().map(|s| s.as_str()).collect();
let departments: Vec<Option<&str>> = deduped
let first_names: Vec<String> = deduped
.iter()
.map(|p| p.department.as_deref())
.map(|p| p.first_name.trim().to_string())
.collect();
let first_name_refs: Vec<&str> = first_names.iter().map(|s| s.as_str()).collect();
let last_names: Vec<String> = deduped
.iter()
.map(|p| p.last_name.trim().to_string())
.collect();
let last_name_refs: Vec<&str> = last_names.iter().map(|s| s.as_str()).collect();
let departments: Vec<Option<&str>> = deduped.iter().map(|p| p.department.as_deref()).collect();
let avg_ratings: Vec<Option<f32>> = deduped.iter().map(|p| p.avg_rating).collect();
let avg_difficulties: Vec<Option<f32>> = deduped.iter().map(|p| p.avg_difficulty).collect();
let num_ratings: Vec<i32> = deduped.iter().map(|p| p.num_ratings).collect();
let would_take_again_pcts: Vec<Option<f32>> = deduped
.iter()
.map(|p| p.would_take_again_pct)
.collect();
let would_take_again_pcts: Vec<Option<f32>> =
deduped.iter().map(|p| p.would_take_again_pct).collect();
sqlx::query(
r#"
@@ -129,11 +130,10 @@ pub async fn auto_match_instructors(db_pool: &PgPool) -> Result<u64> {
}
// Load all RMP professors
let professors: Vec<(i32, String, String)> = sqlx::query_as(
"SELECT legacy_id, first_name, last_name FROM rmp_professors",
)
.fetch_all(db_pool)
.await?;
let professors: Vec<(i32, String, String)> =
sqlx::query_as("SELECT legacy_id, first_name, last_name FROM rmp_professors")
.fetch_all(db_pool)
.await?;
// Build a lookup: (normalized_last, normalized_first) -> list of legacy_ids
let mut rmp_index: HashMap<(String, String), Vec<i32>> = HashMap::new();
+4 -15
View File
@@ -122,14 +122,8 @@ impl RmpClient {
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing id"))?
.to_string(),
first_name: node["firstName"]
.as_str()
.unwrap_or_default()
.to_string(),
last_name: node["lastName"]
.as_str()
.unwrap_or_default()
.to_string(),
first_name: node["firstName"].as_str().unwrap_or_default().to_string(),
last_name: node["lastName"].as_str().unwrap_or_default().to_string(),
department: node["department"].as_str().map(|s| s.to_string()),
avg_rating: node["avgRating"].as_f64().map(|v| v as f32),
avg_difficulty: node["avgDifficulty"].as_f64().map(|v| v as f32),
@@ -145,14 +139,9 @@ impl RmpClient {
break;
}
cursor = page_info["endCursor"]
.as_str()
.map(|s| s.to_string());
cursor = page_info["endCursor"].as_str().map(|s| s.to_string());
debug!(
fetched = all.len(),
"RMP pagination: fetching next page"
);
debug!(fetched = all.len(), "RMP pagination: fetching next page");
}
info!(total = all.len(), "Fetched all RMP professors");
+54
View File
@@ -313,12 +313,63 @@ struct SearchParams {
limit: i32,
#[serde(default)]
offset: i32,
sort_by: Option<SortColumn>,
sort_dir: Option<SortDirection>,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
enum SortColumn {
CourseCode,
Title,
Instructor,
Time,
Seats,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
enum SortDirection {
Asc,
Desc,
}
fn default_limit() -> i32 {
25
}
/// Build a safe ORDER BY clause from the validated sort column and direction.
fn sort_clause(column: Option<SortColumn>, direction: Option<SortDirection>) -> String {
let dir = match direction.unwrap_or(SortDirection::Asc) {
SortDirection::Asc => "ASC",
SortDirection::Desc => "DESC",
};
match column {
Some(SortColumn::CourseCode) => {
format!("subject {dir}, course_number {dir}, sequence_number {dir}")
}
Some(SortColumn::Title) => format!("title {dir}"),
Some(SortColumn::Instructor) => {
// Sort by primary instructor display name via a subquery
format!(
"(SELECT i.display_name FROM course_instructors ci \
JOIN instructors i ON i.banner_id = ci.instructor_id \
WHERE ci.course_id = courses.id AND ci.is_primary = true \
LIMIT 1) {dir} NULLS LAST"
)
}
Some(SortColumn::Time) => {
// Sort by first meeting time's begin_time via JSONB
format!("(meeting_times->0->>'begin_time') {dir} NULLS LAST")
}
Some(SortColumn::Seats) => {
format!("(max_enrollment - enrollment) {dir}")
}
None => "subject ASC, course_number ASC, sequence_number ASC".to_string(),
}
}
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
@@ -438,6 +489,8 @@ async fn search_courses(
let limit = params.limit.clamp(1, 100);
let offset = params.offset.max(0);
let order_by = sort_clause(params.sort_by, params.sort_dir);
let (courses, total_count) = crate::data::courses::search_courses(
&state.db_pool,
&params.term,
@@ -450,6 +503,7 @@ async fn search_courses(
params.campus.as_deref(),
limit,
offset,
&order_by,
)
.await
.map_err(|e| {