mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 02:23:34 -06:00
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:
+46
-24
@@ -21,10 +21,15 @@ pub async fn search_courses(
|
|||||||
campus: Option<&str>,
|
campus: Option<&str>,
|
||||||
limit: i32,
|
limit: i32,
|
||||||
offset: i32,
|
offset: i32,
|
||||||
|
order_by: &str,
|
||||||
) -> Result<(Vec<Course>, i64)> {
|
) -> Result<(Vec<Course>, i64)> {
|
||||||
// Build WHERE clauses dynamically via parameter binding + COALESCE trick:
|
// Build WHERE clauses dynamically via parameter binding + COALESCE trick:
|
||||||
// each optional filter uses ($N IS NULL OR column = $N) so NULL means "no filter".
|
// 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#"
|
r#"
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM courses
|
FROM courses
|
||||||
@@ -36,22 +41,24 @@ pub async fn search_courses(
|
|||||||
AND ($6::bool = false OR max_enrollment > enrollment)
|
AND ($6::bool = false OR max_enrollment > enrollment)
|
||||||
AND ($7::text IS NULL OR instructional_method = $7)
|
AND ($7::text IS NULL OR instructional_method = $7)
|
||||||
AND ($8::text IS NULL OR campus = $8)
|
AND ($8::text IS NULL OR campus = $8)
|
||||||
ORDER BY subject, course_number, sequence_number
|
ORDER BY {order_by}
|
||||||
LIMIT $9 OFFSET $10
|
LIMIT $9 OFFSET $10
|
||||||
"#,
|
"#
|
||||||
)
|
);
|
||||||
.bind(term_code)
|
|
||||||
.bind(subject)
|
let courses = sqlx::query_as::<_, Course>(&query)
|
||||||
.bind(title_query)
|
.bind(term_code)
|
||||||
.bind(course_number_low)
|
.bind(subject)
|
||||||
.bind(course_number_high)
|
.bind(title_query)
|
||||||
.bind(open_only)
|
.bind(course_number_low)
|
||||||
.bind(instructional_method)
|
.bind(course_number_high)
|
||||||
.bind(campus)
|
.bind(open_only)
|
||||||
.bind(limit)
|
.bind(instructional_method)
|
||||||
.bind(offset)
|
.bind(campus)
|
||||||
.fetch_all(db_pool)
|
.bind(limit)
|
||||||
.await?;
|
.bind(offset)
|
||||||
|
.fetch_all(db_pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let total: (i64,) = sqlx::query_as(
|
let total: (i64,) = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
@@ -102,10 +109,25 @@ pub async fn get_course_by_crn(
|
|||||||
pub async fn get_course_instructors(
|
pub async fn get_course_instructors(
|
||||||
db_pool: &PgPool,
|
db_pool: &PgPool,
|
||||||
course_id: i32,
|
course_id: i32,
|
||||||
) -> Result<Vec<(String, String, Option<String>, bool, Option<f32>, Option<i32>)>> {
|
) -> Result<
|
||||||
let rows: Vec<(String, String, Option<String>, bool, Option<f32>, Option<i32>)> =
|
Vec<(
|
||||||
sqlx::query_as(
|
String,
|
||||||
r#"
|
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,
|
SELECT i.banner_id, i.display_name, i.email, ci.is_primary,
|
||||||
rp.avg_rating, rp.num_ratings
|
rp.avg_rating, rp.num_ratings
|
||||||
FROM course_instructors ci
|
FROM course_instructors ci
|
||||||
@@ -114,10 +136,10 @@ pub async fn get_course_instructors(
|
|||||||
WHERE ci.course_id = $1
|
WHERE ci.course_id = $1
|
||||||
ORDER BY ci.is_primary DESC, i.display_name
|
ORDER BY ci.is_primary DESC, i.display_name
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
.fetch_all(db_pool)
|
.fetch_all(db_pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-15
@@ -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 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 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_names: Vec<String> = deduped
|
||||||
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()
|
.iter()
|
||||||
.map(|p| p.department.as_deref())
|
.map(|p| p.first_name.trim().to_string())
|
||||||
.collect();
|
.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_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 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 num_ratings: Vec<i32> = deduped.iter().map(|p| p.num_ratings).collect();
|
||||||
let would_take_again_pcts: Vec<Option<f32>> = deduped
|
let would_take_again_pcts: Vec<Option<f32>> =
|
||||||
.iter()
|
deduped.iter().map(|p| p.would_take_again_pct).collect();
|
||||||
.map(|p| p.would_take_again_pct)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
@@ -129,11 +130,10 @@ pub async fn auto_match_instructors(db_pool: &PgPool) -> Result<u64> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load all RMP professors
|
// Load all RMP professors
|
||||||
let professors: Vec<(i32, String, String)> = sqlx::query_as(
|
let professors: Vec<(i32, String, String)> =
|
||||||
"SELECT legacy_id, first_name, last_name FROM rmp_professors",
|
sqlx::query_as("SELECT legacy_id, first_name, last_name FROM rmp_professors")
|
||||||
)
|
.fetch_all(db_pool)
|
||||||
.fetch_all(db_pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Build a lookup: (normalized_last, normalized_first) -> list of legacy_ids
|
// Build a lookup: (normalized_last, normalized_first) -> list of legacy_ids
|
||||||
let mut rmp_index: HashMap<(String, String), Vec<i32>> = HashMap::new();
|
let mut rmp_index: HashMap<(String, String), Vec<i32>> = HashMap::new();
|
||||||
|
|||||||
+4
-15
@@ -122,14 +122,8 @@ impl RmpClient {
|
|||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing id"))?
|
.ok_or_else(|| anyhow::anyhow!("Missing id"))?
|
||||||
.to_string(),
|
.to_string(),
|
||||||
first_name: node["firstName"]
|
first_name: node["firstName"].as_str().unwrap_or_default().to_string(),
|
||||||
.as_str()
|
last_name: node["lastName"].as_str().unwrap_or_default().to_string(),
|
||||||
.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()),
|
department: node["department"].as_str().map(|s| s.to_string()),
|
||||||
avg_rating: node["avgRating"].as_f64().map(|v| v as f32),
|
avg_rating: node["avgRating"].as_f64().map(|v| v as f32),
|
||||||
avg_difficulty: node["avgDifficulty"].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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
cursor = page_info["endCursor"]
|
cursor = page_info["endCursor"].as_str().map(|s| s.to_string());
|
||||||
.as_str()
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
|
|
||||||
debug!(
|
debug!(fetched = all.len(), "RMP pagination: fetching next page");
|
||||||
fetched = all.len(),
|
|
||||||
"RMP pagination: fetching next page"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(total = all.len(), "Fetched all RMP professors");
|
info!(total = all.len(), "Fetched all RMP professors");
|
||||||
|
|||||||
@@ -313,12 +313,63 @@ struct SearchParams {
|
|||||||
limit: i32,
|
limit: i32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
offset: i32,
|
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 {
|
fn default_limit() -> i32 {
|
||||||
25
|
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)]
|
#[derive(Serialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
@@ -438,6 +489,8 @@ async fn search_courses(
|
|||||||
let limit = params.limit.clamp(1, 100);
|
let limit = params.limit.clamp(1, 100);
|
||||||
let offset = params.offset.max(0);
|
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(
|
let (courses, total_count) = crate::data::courses::search_courses(
|
||||||
&state.db_pool,
|
&state.db_pool,
|
||||||
¶ms.term,
|
¶ms.term,
|
||||||
@@ -450,6 +503,7 @@ async fn search_courses(
|
|||||||
params.campus.as_deref(),
|
params.campus.as_deref(),
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
|
&order_by,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"@sveltejs/kit": "^2.16.0",
|
"@sveltejs/kit": "^2.16.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@tanstack/table-core": "^8.21.3",
|
||||||
"@types/node": "^25.1.0",
|
"@types/node": "^25.1.0",
|
||||||
"bits-ui": "^1.3.7",
|
"bits-ui": "^1.3.7",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -227,6 +228,8 @@
|
|||||||
|
|
||||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
|
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
|
||||||
|
|
||||||
|
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
|
||||||
|
|
||||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||||
|
|
||||||
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@sveltejs/kit": "^2.16.0",
|
"@sveltejs/kit": "^2.16.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@tanstack/table-core": "^8.21.3",
|
||||||
"@types/node": "^25.1.0",
|
"@types/node": "^25.1.0",
|
||||||
"bits-ui": "^1.3.7",
|
"bits-ui": "^1.3.7",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ export interface MetricsResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Client-side only — not generated from Rust
|
// Client-side only — not generated from Rust
|
||||||
|
export type SortColumn = "course_code" | "title" | "instructor" | "time" | "seats";
|
||||||
|
export type SortDirection = "asc" | "desc";
|
||||||
|
|
||||||
export interface SearchParams {
|
export interface SearchParams {
|
||||||
term: string;
|
term: string;
|
||||||
subject?: string;
|
subject?: string;
|
||||||
@@ -51,6 +54,8 @@ export interface SearchParams {
|
|||||||
open_only?: boolean;
|
open_only?: boolean;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
sort_by?: SortColumn;
|
||||||
|
sort_dir?: SortDirection;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BannerApiClient {
|
export class BannerApiClient {
|
||||||
@@ -92,6 +97,8 @@ export class BannerApiClient {
|
|||||||
if (params.open_only) query.set("open_only", "true");
|
if (params.open_only) query.set("open_only", "true");
|
||||||
if (params.limit !== undefined) query.set("limit", String(params.limit));
|
if (params.limit !== undefined) query.set("limit", String(params.limit));
|
||||||
if (params.offset !== undefined) query.set("offset", String(params.offset));
|
if (params.offset !== undefined) query.set("offset", String(params.offset));
|
||||||
|
if (params.sort_by) query.set("sort_by", params.sort_by);
|
||||||
|
if (params.sort_dir) query.set("sort_dir", params.sort_dir);
|
||||||
return this.request<SearchResponse>(`/courses/search?${query.toString()}`);
|
return this.request<SearchResponse>(`/courses/search?${query.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,51 @@ import {
|
|||||||
isTimeTBA,
|
isTimeTBA,
|
||||||
} from "$lib/course";
|
} from "$lib/course";
|
||||||
import CourseDetail from "./CourseDetail.svelte";
|
import CourseDetail from "./CourseDetail.svelte";
|
||||||
|
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||||
|
import {
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
type ColumnDef,
|
||||||
|
type SortingState,
|
||||||
|
type VisibilityState,
|
||||||
|
type Updater,
|
||||||
|
} from "@tanstack/table-core";
|
||||||
|
import { ArrowUp, ArrowDown, ArrowUpDown, Columns3, Check, RotateCcw } from "@lucide/svelte";
|
||||||
|
import { DropdownMenu, ContextMenu } from "bits-ui";
|
||||||
|
import { fade, fly } from "svelte/transition";
|
||||||
|
|
||||||
let { courses, loading }: { courses: CourseResponse[]; loading: boolean } = $props();
|
let {
|
||||||
|
courses,
|
||||||
|
loading,
|
||||||
|
sorting = [],
|
||||||
|
onSortingChange,
|
||||||
|
manualSorting = false,
|
||||||
|
}: {
|
||||||
|
courses: CourseResponse[];
|
||||||
|
loading: boolean;
|
||||||
|
sorting?: SortingState;
|
||||||
|
onSortingChange?: (sorting: SortingState) => void;
|
||||||
|
manualSorting?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let expandedCrn: string | null = $state(null);
|
let expandedCrn: string | null = $state(null);
|
||||||
|
|
||||||
|
// Column visibility state
|
||||||
|
let columnVisibility: VisibilityState = $state({});
|
||||||
|
|
||||||
|
const DEFAULT_VISIBILITY: VisibilityState = {};
|
||||||
|
|
||||||
|
function resetColumnVisibility() {
|
||||||
|
columnVisibility = { ...DEFAULT_VISIBILITY };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVisibilityChange(updater: Updater<VisibilityState>) {
|
||||||
|
const newVisibility = typeof updater === "function" ? updater(columnVisibility) : updater;
|
||||||
|
columnVisibility = newVisibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
// visibleColumnIds and hasCustomVisibility derived after column definitions below
|
||||||
|
|
||||||
function toggleRow(crn: string) {
|
function toggleRow(crn: string) {
|
||||||
expandedCrn = expandedCrn === crn ? null : crn;
|
expandedCrn = expandedCrn === crn ? null : crn;
|
||||||
}
|
}
|
||||||
@@ -60,101 +100,353 @@ function timeIsTBA(course: CourseResponse): boolean {
|
|||||||
const mt = course.meetingTimes[0];
|
const mt = course.meetingTimes[0];
|
||||||
return isMeetingTimeTBA(mt) && isTimeTBA(mt);
|
return isMeetingTimeTBA(mt) && isTimeTBA(mt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Column definitions
|
||||||
|
const columns: ColumnDef<CourseResponse, unknown>[] = [
|
||||||
|
{
|
||||||
|
id: "crn",
|
||||||
|
accessorKey: "crn",
|
||||||
|
header: "CRN",
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "course_code",
|
||||||
|
accessorFn: (row) => `${row.subject} ${row.courseNumber}`,
|
||||||
|
header: "Course",
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "title",
|
||||||
|
accessorKey: "title",
|
||||||
|
header: "Title",
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "instructor",
|
||||||
|
accessorFn: (row) => primaryInstructorDisplay(row),
|
||||||
|
header: "Instructor",
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "time",
|
||||||
|
accessorFn: (row) => {
|
||||||
|
if (row.meetingTimes.length === 0) return "";
|
||||||
|
const mt = row.meetingTimes[0];
|
||||||
|
return `${formatMeetingDays(mt)} ${formatTime(mt.begin_time)}`;
|
||||||
|
},
|
||||||
|
header: "Time",
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "location",
|
||||||
|
accessorFn: (row) => formatLocation(row) ?? "",
|
||||||
|
header: "Location",
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "seats",
|
||||||
|
accessorFn: (row) => openSeats(row),
|
||||||
|
header: "Seats",
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Column IDs that are currently visible */
|
||||||
|
let visibleColumnIds = $derived(
|
||||||
|
columns.map((c) => c.id!).filter((id) => columnVisibility[id] !== false)
|
||||||
|
);
|
||||||
|
|
||||||
|
let hasCustomVisibility = $derived(Object.values(columnVisibility).some((v) => v === false));
|
||||||
|
|
||||||
|
function handleSortingChange(updater: Updater<SortingState>) {
|
||||||
|
const newSorting = typeof updater === "function" ? updater(sorting) : updater;
|
||||||
|
onSortingChange?.(newSorting);
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = createSvelteTable({
|
||||||
|
get data() {
|
||||||
|
return courses;
|
||||||
|
},
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
get sorting() {
|
||||||
|
return sorting;
|
||||||
|
},
|
||||||
|
get columnVisibility() {
|
||||||
|
return columnVisibility;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onSortingChange: handleSortingChange,
|
||||||
|
onColumnVisibilityChange: handleVisibilityChange,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
get getSortedRowModel() {
|
||||||
|
return manualSorting ? undefined : getSortedRowModel<CourseResponse>();
|
||||||
|
},
|
||||||
|
get manualSorting() {
|
||||||
|
return manualSorting;
|
||||||
|
},
|
||||||
|
enableSortingRemoval: true,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#snippet columnVisibilityItems(variant: "dropdown" | "context")}
|
||||||
|
{#if variant === "dropdown"}
|
||||||
|
<DropdownMenu.Group>
|
||||||
|
<DropdownMenu.GroupHeading class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
Toggle columns
|
||||||
|
</DropdownMenu.GroupHeading>
|
||||||
|
{#each columns as col}
|
||||||
|
{@const id = col.id!}
|
||||||
|
{@const label = typeof col.header === "string" ? col.header : id}
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
checked={columnVisibility[id] !== false}
|
||||||
|
closeOnSelect={false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
columnVisibility = { ...columnVisibility, [id]: checked };
|
||||||
|
}}
|
||||||
|
class="relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
|
||||||
|
>
|
||||||
|
{#snippet children({ checked })}
|
||||||
|
<span class="flex size-4 items-center justify-center rounded-sm border border-border">
|
||||||
|
{#if checked}
|
||||||
|
<Check class="size-3" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
{/snippet}
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
{/each}
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
{#if hasCustomVisibility}
|
||||||
|
<DropdownMenu.Separator class="mx-1 my-1 h-px bg-border" />
|
||||||
|
<DropdownMenu.Item
|
||||||
|
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
|
||||||
|
onSelect={resetColumnVisibility}
|
||||||
|
>
|
||||||
|
<RotateCcw class="size-3.5" />
|
||||||
|
Reset to default
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<ContextMenu.Group>
|
||||||
|
<ContextMenu.GroupHeading class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
Toggle columns
|
||||||
|
</ContextMenu.GroupHeading>
|
||||||
|
{#each columns as col}
|
||||||
|
{@const id = col.id!}
|
||||||
|
{@const label = typeof col.header === "string" ? col.header : id}
|
||||||
|
<ContextMenu.CheckboxItem
|
||||||
|
checked={columnVisibility[id] !== false}
|
||||||
|
closeOnSelect={false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
columnVisibility = { ...columnVisibility, [id]: checked };
|
||||||
|
}}
|
||||||
|
class="relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
|
||||||
|
>
|
||||||
|
{#snippet children({ checked })}
|
||||||
|
<span class="flex size-4 items-center justify-center rounded-sm border border-border">
|
||||||
|
{#if checked}
|
||||||
|
<Check class="size-3" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
{/snippet}
|
||||||
|
</ContextMenu.CheckboxItem>
|
||||||
|
{/each}
|
||||||
|
</ContextMenu.Group>
|
||||||
|
{#if hasCustomVisibility}
|
||||||
|
<ContextMenu.Separator class="mx-1 my-1 h-px bg-border" />
|
||||||
|
<ContextMenu.Item
|
||||||
|
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
|
||||||
|
onSelect={resetColumnVisibility}
|
||||||
|
>
|
||||||
|
<RotateCcw class="size-3.5" />
|
||||||
|
Reset to default
|
||||||
|
</ContextMenu.Item>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<!-- Toolbar: View columns button -->
|
||||||
|
<div class="flex items-center justify-end pb-2">
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Columns3 class="size-3.5" />
|
||||||
|
View
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
class="z-50 min-w-[160px] rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg"
|
||||||
|
align="end"
|
||||||
|
sideOffset={4}
|
||||||
|
forceMount
|
||||||
|
>
|
||||||
|
{#snippet child({ wrapperProps, props, open })}
|
||||||
|
{#if open}
|
||||||
|
<div {...wrapperProps}>
|
||||||
|
<div {...props} transition:fly={{ duration: 150, y: -10 }}>
|
||||||
|
{@render columnVisibilityItems("dropdown")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table with context menu on header -->
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full border-collapse text-sm">
|
<ContextMenu.Root>
|
||||||
<thead>
|
<ContextMenu.Trigger class="contents">
|
||||||
<tr class="border-b border-border text-left text-muted-foreground">
|
<table class="w-full border-collapse text-sm">
|
||||||
<th class="py-2 px-2 font-medium">CRN</th>
|
<thead>
|
||||||
<th class="py-2 px-2 font-medium">Course</th>
|
{#each table.getHeaderGroups() as headerGroup}
|
||||||
<th class="py-2 px-2 font-medium">Title</th>
|
<tr class="border-b border-border text-left text-muted-foreground">
|
||||||
<th class="py-2 px-2 font-medium">Instructor</th>
|
{#each headerGroup.headers as header}
|
||||||
<th class="py-2 px-2 font-medium">Time</th>
|
{#if header.column.getIsVisible()}
|
||||||
<th class="py-2 px-2 font-medium">Location</th>
|
<th
|
||||||
<th class="py-2 px-2 font-medium text-right">Seats</th>
|
class="py-2 px-2 font-medium {header.id === 'seats' ? 'text-right' : ''}"
|
||||||
</tr>
|
class:cursor-pointer={header.column.getCanSort()}
|
||||||
</thead>
|
class:select-none={header.column.getCanSort()}
|
||||||
<tbody>
|
onclick={header.column.getToggleSortingHandler()}
|
||||||
{#if loading && courses.length === 0}
|
>
|
||||||
{#each Array(5) as _}
|
{#if header.column.getCanSort()}
|
||||||
<tr class="border-b border-border">
|
<span class="inline-flex items-center gap-1">
|
||||||
<td class="py-2.5 px-2"><div class="h-4 w-10 bg-muted rounded animate-pulse"></div></td>
|
{#if typeof header.column.columnDef.header === "string"}
|
||||||
<td class="py-2.5 px-2"><div class="h-4 w-24 bg-muted rounded animate-pulse"></div></td>
|
{header.column.columnDef.header}
|
||||||
<td class="py-2.5 px-2"><div class="h-4 w-40 bg-muted rounded animate-pulse"></div></td>
|
{:else}
|
||||||
<td class="py-2.5 px-2"><div class="h-4 w-20 bg-muted rounded animate-pulse"></div></td>
|
<FlexRender content={header.column.columnDef.header} context={header.getContext()} />
|
||||||
<td class="py-2.5 px-2"><div class="h-4 w-28 bg-muted rounded animate-pulse"></div></td>
|
{/if}
|
||||||
<td class="py-2.5 px-2"><div class="h-4 w-16 bg-muted rounded animate-pulse"></div></td>
|
{#if header.column.getIsSorted() === "asc"}
|
||||||
<td class="py-2.5 px-2"><div class="h-4 w-14 bg-muted rounded animate-pulse ml-auto"></div></td>
|
<ArrowUp class="size-3.5" />
|
||||||
</tr>
|
{:else if header.column.getIsSorted() === "desc"}
|
||||||
{/each}
|
<ArrowDown class="size-3.5" />
|
||||||
{:else if courses.length === 0}
|
{:else}
|
||||||
<tr>
|
<ArrowUpDown class="size-3.5 text-muted-foreground/40" />
|
||||||
<td colspan="7" class="py-12 text-center text-muted-foreground">
|
{/if}
|
||||||
No courses found. Try adjusting your filters.
|
</span>
|
||||||
</td>
|
{:else if typeof header.column.columnDef.header === "string"}
|
||||||
</tr>
|
{header.column.columnDef.header}
|
||||||
{:else}
|
{:else}
|
||||||
{#each courses as course (course.crn)}
|
<FlexRender content={header.column.columnDef.header} context={header.getContext()} />
|
||||||
<tr
|
{/if}
|
||||||
class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap {expandedCrn === course.crn ? 'bg-muted/30' : ''}"
|
</th>
|
||||||
onclick={() => toggleRow(course.crn)}
|
|
||||||
>
|
|
||||||
<td class="py-2 px-2 font-mono text-xs text-muted-foreground/70">{course.crn}</td>
|
|
||||||
<td class="py-2 px-2 whitespace-nowrap">
|
|
||||||
<span class="font-semibold">{course.subject} {course.courseNumber}</span>{#if course.sequenceNumber}<span class="text-muted-foreground">-{course.sequenceNumber}</span>{/if}
|
|
||||||
</td>
|
|
||||||
<td class="py-2 px-2 font-medium">{course.title}</td>
|
|
||||||
<td class="py-2 px-2 whitespace-nowrap">
|
|
||||||
{primaryInstructorDisplay(course)}
|
|
||||||
{#if primaryRating(course)}
|
|
||||||
{@const r = primaryRating(course)!}
|
|
||||||
<span
|
|
||||||
class="ml-1 text-xs font-medium {ratingColor(r.rating)}"
|
|
||||||
title="{r.rating.toFixed(1)}/5 ({r.count} ratings)"
|
|
||||||
>{r.rating.toFixed(1)}★</span>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="py-2 px-2 whitespace-nowrap">
|
|
||||||
{#if timeIsTBA(course)}
|
|
||||||
<span class="text-xs text-muted-foreground/60">TBA</span>
|
|
||||||
{:else}
|
|
||||||
{@const mt = course.meetingTimes[0]}
|
|
||||||
{#if !isMeetingTimeTBA(mt)}
|
|
||||||
<span class="font-mono font-medium">{formatMeetingDays(mt)}</span>
|
|
||||||
{" "}
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if !isTimeTBA(mt)}
|
{/each}
|
||||||
<span class="text-muted-foreground">{formatTime(mt.begin_time)}–{formatTime(mt.end_time)}</span>
|
</tr>
|
||||||
{:else}
|
{/each}
|
||||||
<span class="text-xs text-muted-foreground/60">TBA</span>
|
</thead>
|
||||||
{/if}
|
<tbody>
|
||||||
{/if}
|
{#if loading && courses.length === 0}
|
||||||
</td>
|
{#each Array(5) as _}
|
||||||
<td class="py-2 px-2 whitespace-nowrap">
|
<tr class="border-b border-border">
|
||||||
{#if formatLocation(course)}
|
{#each table.getVisibleLeafColumns() as col}
|
||||||
<span class="text-muted-foreground">{formatLocation(course)}</span>
|
<td class="py-2.5 px-2">
|
||||||
{:else}
|
<div class="h-4 bg-muted rounded animate-pulse {col.id === 'seats' ? 'w-14 ml-auto' : col.id === 'title' ? 'w-40' : col.id === 'crn' ? 'w-10' : 'w-20'}"></div>
|
||||||
<span class="text-xs text-muted-foreground/50">—</span>
|
</td>
|
||||||
{/if}
|
{/each}
|
||||||
</td>
|
</tr>
|
||||||
<td class="py-2 px-2 text-right whitespace-nowrap">
|
{/each}
|
||||||
<span class="inline-flex items-center gap-1.5">
|
{:else if courses.length === 0}
|
||||||
<span class="size-1.5 rounded-full {seatsDotColor(course)} shrink-0"></span>
|
|
||||||
<span class="{seatsColor(course)} font-medium tabular-nums">{#if openSeats(course) === 0}Full{:else}{openSeats(course)} open{/if}</span>
|
|
||||||
<span class="text-muted-foreground/60 tabular-nums">{course.enrollment}/{course.maxEnrollment}{#if course.waitCount > 0} · WL {course.waitCount}/{course.waitCapacity}{/if}</span>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{#if expandedCrn === course.crn}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="p-0">
|
<td colspan={visibleColumnIds.length} class="py-12 text-center text-muted-foreground">
|
||||||
<CourseDetail {course} />
|
No courses found. Try adjusting your filters.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{:else}
|
||||||
|
{#each table.getRowModel().rows as row (row.id)}
|
||||||
|
{@const course = row.original}
|
||||||
|
<tr
|
||||||
|
class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap {expandedCrn === course.crn ? 'bg-muted/30' : ''}"
|
||||||
|
onclick={() => toggleRow(course.crn)}
|
||||||
|
>
|
||||||
|
{#each row.getVisibleCells() as cell (cell.id)}
|
||||||
|
{@const colId = cell.column.id}
|
||||||
|
{#if colId === "crn"}
|
||||||
|
<td class="py-2 px-2 font-mono text-xs text-muted-foreground/70">{course.crn}</td>
|
||||||
|
{:else if colId === "course_code"}
|
||||||
|
<td class="py-2 px-2 whitespace-nowrap">
|
||||||
|
<span class="font-semibold">{course.subject} {course.courseNumber}</span>{#if course.sequenceNumber}<span class="text-muted-foreground">-{course.sequenceNumber}</span>{/if}
|
||||||
|
</td>
|
||||||
|
{:else if colId === "title"}
|
||||||
|
<td class="py-2 px-2 font-medium">{course.title}</td>
|
||||||
|
{:else if colId === "instructor"}
|
||||||
|
<td class="py-2 px-2 whitespace-nowrap">
|
||||||
|
{primaryInstructorDisplay(course)}
|
||||||
|
{#if primaryRating(course)}
|
||||||
|
{@const r = primaryRating(course)!}
|
||||||
|
<span
|
||||||
|
class="ml-1 text-xs font-medium {ratingColor(r.rating)}"
|
||||||
|
title="{r.rating.toFixed(1)}/5 ({r.count} ratings)"
|
||||||
|
>{r.rating.toFixed(1)}★</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
{:else if colId === "time"}
|
||||||
|
<td class="py-2 px-2 whitespace-nowrap">
|
||||||
|
{#if timeIsTBA(course)}
|
||||||
|
<span class="text-xs text-muted-foreground/60">TBA</span>
|
||||||
|
{:else}
|
||||||
|
{@const mt = course.meetingTimes[0]}
|
||||||
|
{#if !isMeetingTimeTBA(mt)}
|
||||||
|
<span class="font-mono font-medium">{formatMeetingDays(mt)}</span>
|
||||||
|
{" "}
|
||||||
|
{/if}
|
||||||
|
{#if !isTimeTBA(mt)}
|
||||||
|
<span class="text-muted-foreground">{formatTime(mt.begin_time)}–{formatTime(mt.end_time)}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs text-muted-foreground/60">TBA</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
{:else if colId === "location"}
|
||||||
|
<td class="py-2 px-2 whitespace-nowrap">
|
||||||
|
{#if formatLocation(course)}
|
||||||
|
<span class="text-muted-foreground">{formatLocation(course)}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs text-muted-foreground/50">—</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
{:else if colId === "seats"}
|
||||||
|
<td class="py-2 px-2 text-right whitespace-nowrap">
|
||||||
|
<span class="inline-flex items-center gap-1.5">
|
||||||
|
<span class="size-1.5 rounded-full {seatsDotColor(course)} shrink-0"></span>
|
||||||
|
<span class="{seatsColor(course)} font-medium tabular-nums">{#if openSeats(course) === 0}Full{:else}{openSeats(course)} open{/if}</span>
|
||||||
|
<span class="text-muted-foreground/60 tabular-nums">{course.enrollment}/{course.maxEnrollment}{#if course.waitCount > 0} · WL {course.waitCount}/{course.waitCapacity}{/if}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{#if expandedCrn === course.crn}
|
||||||
|
<tr>
|
||||||
|
<td colspan={visibleColumnIds.length} class="p-0">
|
||||||
|
<CourseDetail {course} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
</tbody>
|
||||||
{/if}
|
</table>
|
||||||
</tbody>
|
</ContextMenu.Trigger>
|
||||||
</table>
|
<ContextMenu.Portal>
|
||||||
|
<ContextMenu.Content
|
||||||
|
class="z-50 min-w-[160px] rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg"
|
||||||
|
forceMount
|
||||||
|
>
|
||||||
|
{#snippet child({ wrapperProps, props, open })}
|
||||||
|
{#if open}
|
||||||
|
<div {...wrapperProps}>
|
||||||
|
<div {...props} in:fade={{ duration: 100 }} out:fade={{ duration: 100 }}>
|
||||||
|
{@render columnVisibilityItems("context")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</ContextMenu.Content>
|
||||||
|
</ContextMenu.Portal>
|
||||||
|
</ContextMenu.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import {
|
||||||
|
type RowData,
|
||||||
|
type TableOptions,
|
||||||
|
type TableOptionsResolved,
|
||||||
|
type TableState,
|
||||||
|
createTable,
|
||||||
|
} from "@tanstack/table-core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a reactive TanStack table for Svelte 5 using runes.
|
||||||
|
*
|
||||||
|
* Adapted from shadcn-svelte's data-table wrapper — uses `$state` and
|
||||||
|
* `$effect.pre` instead of Svelte stores for reactivity.
|
||||||
|
*/
|
||||||
|
export function createSvelteTable<TData extends RowData>(options: TableOptions<TData>) {
|
||||||
|
const resolvedOptions: TableOptionsResolved<TData> = mergeObjects(
|
||||||
|
{
|
||||||
|
state: {},
|
||||||
|
onStateChange() {},
|
||||||
|
renderFallbackValue: null,
|
||||||
|
mergeOptions: (
|
||||||
|
defaultOptions: TableOptions<TData>,
|
||||||
|
options: Partial<TableOptions<TData>>
|
||||||
|
) => {
|
||||||
|
return mergeObjects(defaultOptions, options);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
options
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = createTable(resolvedOptions);
|
||||||
|
let state = $state<Partial<TableState>>(table.initialState);
|
||||||
|
|
||||||
|
function updateOptions() {
|
||||||
|
table.setOptions((prev) => {
|
||||||
|
return mergeObjects(prev, options, {
|
||||||
|
state: mergeObjects(state, options.state || {}),
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
onStateChange: (updater: any) => {
|
||||||
|
if (updater instanceof Function) state = updater(state);
|
||||||
|
else state = mergeObjects(state, updater as Partial<TableState>);
|
||||||
|
|
||||||
|
options.onStateChange?.(updater);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOptions();
|
||||||
|
|
||||||
|
$effect.pre(() => {
|
||||||
|
updateOptions();
|
||||||
|
});
|
||||||
|
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MaybeThunk<T extends object> = T | (() => T | null | undefined);
|
||||||
|
type Intersection<T extends readonly unknown[]> = (T extends [infer H, ...infer R]
|
||||||
|
? H & Intersection<R>
|
||||||
|
: unknown) & {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily merges several objects (or thunks) while preserving
|
||||||
|
* getter semantics from every source. Proxy-based.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function mergeObjects<Sources extends readonly MaybeThunk<any>[]>(
|
||||||
|
...sources: Sources
|
||||||
|
): Intersection<{ [K in keyof Sources]: Sources[K] }> {
|
||||||
|
const resolve = <T extends object>(src: MaybeThunk<T>): T | undefined =>
|
||||||
|
typeof src === "function" ? (src() ?? undefined) : src;
|
||||||
|
|
||||||
|
const findSourceWithKey = (key: PropertyKey) => {
|
||||||
|
for (let i = sources.length - 1; i >= 0; i--) {
|
||||||
|
const obj = resolve(sources[i]);
|
||||||
|
if (obj && key in obj) return obj;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Proxy(Object.create(null), {
|
||||||
|
get(_, key) {
|
||||||
|
const src = findSourceWithKey(key);
|
||||||
|
return src?.[key as never];
|
||||||
|
},
|
||||||
|
|
||||||
|
has(_, key) {
|
||||||
|
return !!findSourceWithKey(key);
|
||||||
|
},
|
||||||
|
|
||||||
|
ownKeys(): (string | symbol)[] {
|
||||||
|
const all = new Set<string | symbol>();
|
||||||
|
for (const s of sources) {
|
||||||
|
const obj = resolve(s);
|
||||||
|
if (obj) {
|
||||||
|
for (const k of Reflect.ownKeys(obj) as (string | symbol)[]) {
|
||||||
|
all.add(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...all];
|
||||||
|
},
|
||||||
|
|
||||||
|
getOwnPropertyDescriptor(_, key) {
|
||||||
|
const src = findSourceWithKey(key);
|
||||||
|
if (!src) return undefined;
|
||||||
|
return {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
value: (src as any)[key],
|
||||||
|
writable: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}) as Intersection<{ [K in keyof Sources]: Sources[K] }>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export type FlexRenderProps<TProps = any> = {
|
||||||
|
content: unknown;
|
||||||
|
context: TProps;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { isRenderComponentConfig, isRenderSnippetConfig, mountComponent } from "./render-helpers.js";
|
||||||
|
|
||||||
|
let { content, context }: FlexRenderProps = $props();
|
||||||
|
|
||||||
|
function renderAction(node: HTMLElement, contentVal: typeof content) {
|
||||||
|
let cleanup: (() => void) | undefined;
|
||||||
|
|
||||||
|
function render(c: typeof content) {
|
||||||
|
cleanup?.();
|
||||||
|
node.textContent = "";
|
||||||
|
|
||||||
|
if (isRenderComponentConfig(c)) {
|
||||||
|
cleanup = mountComponent(c.component, node, c.props as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(contentVal);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(newContent: typeof content) {
|
||||||
|
render(newContent);
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
cleanup?.();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isRenderSnippetConfig(content)}
|
||||||
|
{@render content.snippet(content.props)}
|
||||||
|
{:else if isRenderComponentConfig(content)}
|
||||||
|
<div use:renderAction={content}></div>
|
||||||
|
{:else if typeof content === "function"}
|
||||||
|
{@const result = content(context)}
|
||||||
|
{#if isRenderComponentConfig(result)}
|
||||||
|
<div use:renderAction={result}></div>
|
||||||
|
{:else if isRenderSnippetConfig(result)}
|
||||||
|
{@render result.snippet(result.props)}
|
||||||
|
{:else if typeof result === "string" || typeof result === "number"}
|
||||||
|
{result}
|
||||||
|
{/if}
|
||||||
|
{:else if typeof content === "string" || typeof content === "number"}
|
||||||
|
{content}
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as FlexRender } from "./flex-render.svelte";
|
||||||
|
export { renderComponent, renderSnippet } from "./render-helpers.js";
|
||||||
|
export { createSvelteTable } from "./data-table.svelte.js";
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { type Component, type Snippet, mount, unmount } from "svelte";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a Svelte component so TanStack Table can render it as a column
|
||||||
|
* header or cell. Returns a `RenderComponentConfig` that `FlexRender`
|
||||||
|
* picks up.
|
||||||
|
*/
|
||||||
|
export function renderComponent<
|
||||||
|
TProps extends Record<string, unknown>,
|
||||||
|
TComp extends Component<TProps>,
|
||||||
|
>(component: TComp, props: TProps) {
|
||||||
|
return {
|
||||||
|
component,
|
||||||
|
props,
|
||||||
|
[RENDER_COMPONENT_SYMBOL]: true,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a Svelte 5 raw snippet for use in TanStack Table column defs.
|
||||||
|
*/
|
||||||
|
export function renderSnippet<TProps>(snippet: Snippet<[TProps]>, props: TProps) {
|
||||||
|
return {
|
||||||
|
snippet,
|
||||||
|
props,
|
||||||
|
[RENDER_SNIPPET_SYMBOL]: true,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Symbols for FlexRender to detect render types
|
||||||
|
export const RENDER_COMPONENT_SYMBOL = Symbol("renderComponent");
|
||||||
|
export const RENDER_SNIPPET_SYMBOL = Symbol("renderSnippet");
|
||||||
|
|
||||||
|
export type RenderComponentConfig<
|
||||||
|
TProps extends Record<string, unknown> = Record<string, unknown>,
|
||||||
|
> = {
|
||||||
|
component: Component<TProps>;
|
||||||
|
props: TProps;
|
||||||
|
[RENDER_COMPONENT_SYMBOL]: true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RenderSnippetConfig<TProps = unknown> = {
|
||||||
|
snippet: Snippet<[TProps]>;
|
||||||
|
props: TProps;
|
||||||
|
[RENDER_SNIPPET_SYMBOL]: true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isRenderComponentConfig(value: unknown): value is RenderComponentConfig {
|
||||||
|
return typeof value === "object" && value !== null && RENDER_COMPONENT_SYMBOL in value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRenderSnippetConfig(value: unknown): value is RenderSnippetConfig {
|
||||||
|
return typeof value === "object" && value !== null && RENDER_SNIPPET_SYMBOL in value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount a Svelte component imperatively into a target element.
|
||||||
|
* Used by FlexRender for component-type cells.
|
||||||
|
*/
|
||||||
|
export function mountComponent<TProps extends Record<string, unknown>>(
|
||||||
|
component: Component<TProps>,
|
||||||
|
target: HTMLElement,
|
||||||
|
props: TProps
|
||||||
|
) {
|
||||||
|
const instance = mount(component, { target, props });
|
||||||
|
return () => unmount(instance);
|
||||||
|
}
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { type Subject, type SearchResponse, client } from "$lib/api";
|
import {
|
||||||
|
type Subject,
|
||||||
|
type SearchResponse,
|
||||||
|
type SortColumn,
|
||||||
|
type SortDirection,
|
||||||
|
client,
|
||||||
|
} from "$lib/api";
|
||||||
|
import type { SortingState } from "@tanstack/table-core";
|
||||||
import SearchFilters from "$lib/components/SearchFilters.svelte";
|
import SearchFilters from "$lib/components/SearchFilters.svelte";
|
||||||
import CourseTable from "$lib/components/CourseTable.svelte";
|
import CourseTable from "$lib/components/CourseTable.svelte";
|
||||||
import Pagination from "$lib/components/Pagination.svelte";
|
import Pagination from "$lib/components/Pagination.svelte";
|
||||||
@@ -19,6 +26,29 @@ let openOnly = $state(initialParams.get("open") === "true");
|
|||||||
let offset = $state(Number(initialParams.get("offset")) || 0);
|
let offset = $state(Number(initialParams.get("offset")) || 0);
|
||||||
const limit = 25;
|
const limit = 25;
|
||||||
|
|
||||||
|
// Sorting state — maps TanStack column IDs to server sort params
|
||||||
|
const SORT_COLUMN_MAP: Record<string, SortColumn> = {
|
||||||
|
course_code: "course_code",
|
||||||
|
title: "title",
|
||||||
|
instructor: "instructor",
|
||||||
|
time: "time",
|
||||||
|
seats: "seats",
|
||||||
|
};
|
||||||
|
|
||||||
|
let sorting: SortingState = $state(
|
||||||
|
(() => {
|
||||||
|
const sortBy = initialParams.get("sort_by");
|
||||||
|
const sortDir = initialParams.get("sort_dir");
|
||||||
|
if (!sortBy) return [];
|
||||||
|
return [{ id: sortBy, desc: sortDir === "desc" }];
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSortingChange(newSorting: SortingState) {
|
||||||
|
sorting = newSorting;
|
||||||
|
offset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Data state
|
// Data state
|
||||||
let subjects: Subject[] = $state([]);
|
let subjects: Subject[] = $state([]);
|
||||||
let searchResult: SearchResponse | null = $state(null);
|
let searchResult: SearchResponse | null = $state(null);
|
||||||
@@ -45,10 +75,11 @@ $effect(() => {
|
|||||||
const q = query;
|
const q = query;
|
||||||
const open = openOnly;
|
const open = openOnly;
|
||||||
const off = offset;
|
const off = offset;
|
||||||
|
const sort = sorting;
|
||||||
|
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
searchTimeout = setTimeout(() => {
|
searchTimeout = setTimeout(() => {
|
||||||
performSearch(term, subject, q, open, off);
|
performSearch(term, subject, q, open, off, sort);
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
return () => clearTimeout(searchTimeout);
|
return () => clearTimeout(searchTimeout);
|
||||||
@@ -64,11 +95,23 @@ $effect(() => {
|
|||||||
prevFilters = key;
|
prevFilters = key;
|
||||||
});
|
});
|
||||||
|
|
||||||
async function performSearch(term: string, subject: string, q: string, open: boolean, off: number) {
|
async function performSearch(
|
||||||
|
term: string,
|
||||||
|
subject: string,
|
||||||
|
q: string,
|
||||||
|
open: boolean,
|
||||||
|
off: number,
|
||||||
|
sort: SortingState
|
||||||
|
) {
|
||||||
if (!term) return;
|
if (!term) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
|
// Derive server sort params from TanStack sorting state
|
||||||
|
const sortBy = sort.length > 0 ? SORT_COLUMN_MAP[sort[0].id] : undefined;
|
||||||
|
const sortDir: SortDirection | undefined =
|
||||||
|
sort.length > 0 ? (sort[0].desc ? "desc" : "asc") : undefined;
|
||||||
|
|
||||||
// Sync URL
|
// Sync URL
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set("term", term);
|
params.set("term", term);
|
||||||
@@ -76,6 +119,8 @@ async function performSearch(term: string, subject: string, q: string, open: boo
|
|||||||
if (q) params.set("q", q);
|
if (q) params.set("q", q);
|
||||||
if (open) params.set("open", "true");
|
if (open) params.set("open", "true");
|
||||||
if (off > 0) params.set("offset", String(off));
|
if (off > 0) params.set("offset", String(off));
|
||||||
|
if (sortBy) params.set("sort_by", sortBy);
|
||||||
|
if (sortDir && sortBy) params.set("sort_dir", sortDir);
|
||||||
goto(`?${params.toString()}`, { replaceState: true, noScroll: true, keepFocus: true });
|
goto(`?${params.toString()}`, { replaceState: true, noScroll: true, keepFocus: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -86,6 +131,8 @@ async function performSearch(term: string, subject: string, q: string, open: boo
|
|||||||
open_only: open || undefined,
|
open_only: open || undefined,
|
||||||
limit,
|
limit,
|
||||||
offset: off,
|
offset: off,
|
||||||
|
sort_by: sortBy,
|
||||||
|
sort_dir: sortDir,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : "Search failed";
|
error = e instanceof Error ? e.message : "Search failed";
|
||||||
@@ -121,14 +168,20 @@ function handlePageChange(newOffset: number) {
|
|||||||
<div class="text-center py-8">
|
<div class="text-center py-8">
|
||||||
<p class="text-status-red">{error}</p>
|
<p class="text-status-red">{error}</p>
|
||||||
<button
|
<button
|
||||||
onclick={() => performSearch(selectedTerm, selectedSubject, query, openOnly, offset)}
|
onclick={() => performSearch(selectedTerm, selectedSubject, query, openOnly, offset, sorting)}
|
||||||
class="mt-2 text-sm text-muted-foreground hover:underline"
|
class="mt-2 text-sm text-muted-foreground hover:underline"
|
||||||
>
|
>
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<CourseTable courses={searchResult?.courses ?? []} {loading} />
|
<CourseTable
|
||||||
|
courses={searchResult?.courses ?? []}
|
||||||
|
{loading}
|
||||||
|
{sorting}
|
||||||
|
onSortingChange={handleSortingChange}
|
||||||
|
manualSorting={true}
|
||||||
|
/>
|
||||||
|
|
||||||
{#if searchResult}
|
{#if searchResult}
|
||||||
<Pagination
|
<Pagination
|
||||||
|
|||||||
Reference in New Issue
Block a user