From 67d7c81ef4c5cb4bb693b480164b999a1296b949 Mon Sep 17 00:00:00 2001 From: Xevion Date: Thu, 29 Jan 2026 01:04:18 -0600 Subject: [PATCH] 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. --- src/data/courses.rs | 70 ++- src/data/rmp.rs | 30 +- src/rmp/mod.rs | 19 +- src/web/routes.rs | 54 ++ web/bun.lock | 3 + web/package.json | 1 + web/src/lib/api.ts | 7 + web/src/lib/components/CourseTable.svelte | 472 ++++++++++++++---- .../ui/data-table/data-table.svelte.ts | 118 +++++ .../ui/data-table/flex-render.svelte | 54 ++ web/src/lib/components/ui/data-table/index.ts | 3 + .../ui/data-table/render-helpers.ts | 67 +++ web/src/routes/+page.svelte | 63 ++- 13 files changed, 812 insertions(+), 149 deletions(-) create mode 100644 web/src/lib/components/ui/data-table/data-table.svelte.ts create mode 100644 web/src/lib/components/ui/data-table/flex-render.svelte create mode 100644 web/src/lib/components/ui/data-table/index.ts create mode 100644 web/src/lib/components/ui/data-table/render-helpers.ts diff --git a/src/data/courses.rs b/src/data/courses.rs index 1aeceda..7183b51 100644 --- a/src/data/courses.rs +++ b/src/data/courses.rs @@ -21,10 +21,15 @@ pub async fn search_courses( campus: Option<&str>, limit: i32, offset: i32, + order_by: &str, ) -> Result<(Vec, 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, bool, Option, Option)>> { - let rows: Vec<(String, String, Option, bool, Option, Option)> = - sqlx::query_as( - r#" +) -> Result< + Vec<( + String, + String, + Option, + bool, + Option, + Option, + )>, +> { + let rows: Vec<( + String, + String, + Option, + bool, + Option, + Option, + )> = 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) } diff --git a/src/data/rmp.rs b/src/data/rmp.rs index e215448..2e7804e 100644 --- a/src/data/rmp.rs +++ b/src/data/rmp.rs @@ -28,21 +28,22 @@ pub async fn batch_upsert_rmp_professors( let legacy_ids: Vec = 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 = 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 = 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> = deduped + let first_names: Vec = 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 = 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> = deduped.iter().map(|p| p.department.as_deref()).collect(); let avg_ratings: Vec> = deduped.iter().map(|p| p.avg_rating).collect(); let avg_difficulties: Vec> = deduped.iter().map(|p| p.avg_difficulty).collect(); let num_ratings: Vec = deduped.iter().map(|p| p.num_ratings).collect(); - let would_take_again_pcts: Vec> = deduped - .iter() - .map(|p| p.would_take_again_pct) - .collect(); + let would_take_again_pcts: Vec> = + 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 { } // 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> = HashMap::new(); diff --git a/src/rmp/mod.rs b/src/rmp/mod.rs index b5d9e0d..6668cd6 100644 --- a/src/rmp/mod.rs +++ b/src/rmp/mod.rs @@ -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"); diff --git a/src/web/routes.rs b/src/web/routes.rs index dc98342..649b066 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -313,12 +313,63 @@ struct SearchParams { limit: i32, #[serde(default)] offset: i32, + sort_by: Option, + sort_dir: Option, +} + +#[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, direction: Option) -> 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, ¶ms.term, @@ -450,6 +503,7 @@ async fn search_courses( params.campus.as_deref(), limit, offset, + &order_by, ) .await .map_err(|e| { diff --git a/web/bun.lock b/web/bun.lock index 3f3107a..75d0294 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -12,6 +12,7 @@ "@sveltejs/kit": "^2.16.0", "@sveltejs/vite-plugin-svelte": "^5.0.3", "@tailwindcss/vite": "^4.0.0", + "@tanstack/table-core": "^8.21.3", "@types/node": "^25.1.0", "bits-ui": "^1.3.7", "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=="], + "@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/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], diff --git a/web/package.json b/web/package.json index cfbbe82..861ed9f 100644 --- a/web/package.json +++ b/web/package.json @@ -19,6 +19,7 @@ "@sveltejs/kit": "^2.16.0", "@sveltejs/vite-plugin-svelte": "^5.0.3", "@tailwindcss/vite": "^4.0.0", + "@tanstack/table-core": "^8.21.3", "@types/node": "^25.1.0", "bits-ui": "^1.3.7", "clsx": "^2.1.1", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index d4bfc42..b805b3b 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -44,6 +44,9 @@ export interface MetricsResponse { } // Client-side only — not generated from Rust +export type SortColumn = "course_code" | "title" | "instructor" | "time" | "seats"; +export type SortDirection = "asc" | "desc"; + export interface SearchParams { term: string; subject?: string; @@ -51,6 +54,8 @@ export interface SearchParams { open_only?: boolean; limit?: number; offset?: number; + sort_by?: SortColumn; + sort_dir?: SortDirection; } export class BannerApiClient { @@ -92,6 +97,8 @@ export class BannerApiClient { if (params.open_only) query.set("open_only", "true"); if (params.limit !== undefined) query.set("limit", String(params.limit)); 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(`/courses/search?${query.toString()}`); } diff --git a/web/src/lib/components/CourseTable.svelte b/web/src/lib/components/CourseTable.svelte index 25fdcfe..a5ec02e 100644 --- a/web/src/lib/components/CourseTable.svelte +++ b/web/src/lib/components/CourseTable.svelte @@ -10,11 +10,51 @@ import { isTimeTBA, } from "$lib/course"; 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); +// Column visibility state +let columnVisibility: VisibilityState = $state({}); + +const DEFAULT_VISIBILITY: VisibilityState = {}; + +function resetColumnVisibility() { + columnVisibility = { ...DEFAULT_VISIBILITY }; +} + +function handleVisibilityChange(updater: Updater) { + const newVisibility = typeof updater === "function" ? updater(columnVisibility) : updater; + columnVisibility = newVisibility; +} + +// visibleColumnIds and hasCustomVisibility derived after column definitions below + function toggleRow(crn: string) { expandedCrn = expandedCrn === crn ? null : crn; } @@ -60,101 +100,353 @@ function timeIsTBA(course: CourseResponse): boolean { const mt = course.meetingTimes[0]; return isMeetingTimeTBA(mt) && isTimeTBA(mt); } + +// Column definitions +const columns: ColumnDef[] = [ + { + 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) { + 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(); + }, + get manualSorting() { + return manualSorting; + }, + enableSortingRemoval: true, +}); +{#snippet columnVisibilityItems(variant: "dropdown" | "context")} + {#if variant === "dropdown"} + + + Toggle columns + + {#each columns as col} + {@const id = col.id!} + {@const label = typeof col.header === "string" ? col.header : id} + { + 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 })} + + {#if checked} + + {/if} + + {label} + {/snippet} + + {/each} + + {#if hasCustomVisibility} + + + + Reset to default + + {/if} + {:else} + + + Toggle columns + + {#each columns as col} + {@const id = col.id!} + {@const label = typeof col.header === "string" ? col.header : id} + { + 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 })} + + {#if checked} + + {/if} + + {label} + {/snippet} + + {/each} + + {#if hasCustomVisibility} + + + + Reset to default + + {/if} + {/if} +{/snippet} + + +
+ + + + View + + + + {#snippet child({ wrapperProps, props, open })} + {#if open} +
+
+ {@render columnVisibilityItems("dropdown")} +
+
+ {/if} + {/snippet} +
+
+
+
+ +
- - - - - - - - - - - - - - {#if loading && courses.length === 0} - {#each Array(5) as _} - - - - - - - - - - {/each} - {:else if courses.length === 0} - - - - {:else} - {#each courses as course (course.crn)} - toggleRow(course.crn)} - > - - - - - +
CRNCourseTitleInstructorTimeLocationSeats
- No courses found. Try adjusting your filters. -
{course.crn} - {course.subject} {course.courseNumber}{#if course.sequenceNumber}-{course.sequenceNumber}{/if} - {course.title} - {primaryInstructorDisplay(course)} - {#if primaryRating(course)} - {@const r = primaryRating(course)!} - {r.rating.toFixed(1)}★ - {/if} - - {#if timeIsTBA(course)} - TBA - {:else} - {@const mt = course.meetingTimes[0]} - {#if !isMeetingTimeTBA(mt)} - {formatMeetingDays(mt)} - {" "} + + + + + {#each table.getHeaderGroups() as headerGroup} + + {#each headerGroup.headers as header} + {#if header.column.getIsVisible()} + {/if} - {#if !isTimeTBA(mt)} - {formatTime(mt.begin_time)}–{formatTime(mt.end_time)} - {:else} - TBA - {/if} - {/if} - - - - - {#if expandedCrn === course.crn} + {/each} + + {/each} + + + {#if loading && courses.length === 0} + {#each Array(5) as _} + + {#each table.getVisibleLeafColumns() as col} + + {/each} + + {/each} + {:else if courses.length === 0} - + {:else} + {#each table.getRowModel().rows as row (row.id)} + {@const course = row.original} + toggleRow(course.crn)} + > + {#each row.getVisibleCells() as cell (cell.id)} + {@const colId = cell.column.id} + {#if colId === "crn"} + + {:else if colId === "course_code"} + + {:else if colId === "title"} + + {:else if colId === "instructor"} + + {:else if colId === "time"} + + {:else if colId === "location"} + + {:else if colId === "seats"} + + {/if} + {/each} + + {#if expandedCrn === course.crn} + + + + {/if} + {/each} {/if} - {/each} - {/if} - -
+ {#if header.column.getCanSort()} + + {#if typeof header.column.columnDef.header === "string"} + {header.column.columnDef.header} + {:else} + + {/if} + {#if header.column.getIsSorted() === "asc"} + + {:else if header.column.getIsSorted() === "desc"} + + {:else} + + {/if} + + {:else if typeof header.column.columnDef.header === "string"} + {header.column.columnDef.header} + {:else} + + {/if} + - {#if formatLocation(course)} - {formatLocation(course)} - {:else} - - {/if} - - - - {#if openSeats(course) === 0}Full{:else}{openSeats(course)} open{/if} - {course.enrollment}/{course.maxEnrollment}{#if course.waitCount > 0} · WL {course.waitCount}/{course.waitCapacity}{/if} - -
+
+
- + + No courses found. Try adjusting your filters.
{course.crn} + {course.subject} {course.courseNumber}{#if course.sequenceNumber}-{course.sequenceNumber}{/if} + {course.title} + {primaryInstructorDisplay(course)} + {#if primaryRating(course)} + {@const r = primaryRating(course)!} + {r.rating.toFixed(1)}★ + {/if} + + {#if timeIsTBA(course)} + TBA + {:else} + {@const mt = course.meetingTimes[0]} + {#if !isMeetingTimeTBA(mt)} + {formatMeetingDays(mt)} + {" "} + {/if} + {#if !isTimeTBA(mt)} + {formatTime(mt.begin_time)}–{formatTime(mt.end_time)} + {:else} + TBA + {/if} + {/if} + + {#if formatLocation(course)} + {formatLocation(course)} + {:else} + + {/if} + + + + {#if openSeats(course) === 0}Full{:else}{openSeats(course)} open{/if} + {course.enrollment}/{course.maxEnrollment}{#if course.waitCount > 0} · WL {course.waitCount}/{course.waitCapacity}{/if} + +
+ +
+
+ + + + {#snippet child({ wrapperProps, props, open })} + {#if open} +
+
+ {@render columnVisibilityItems("context")} +
+
+ {/if} + {/snippet} +
+
+
diff --git a/web/src/lib/components/ui/data-table/data-table.svelte.ts b/web/src/lib/components/ui/data-table/data-table.svelte.ts new file mode 100644 index 0000000..5858c90 --- /dev/null +++ b/web/src/lib/components/ui/data-table/data-table.svelte.ts @@ -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(options: TableOptions) { + const resolvedOptions: TableOptionsResolved = mergeObjects( + { + state: {}, + onStateChange() {}, + renderFallbackValue: null, + mergeOptions: ( + defaultOptions: TableOptions, + options: Partial> + ) => { + return mergeObjects(defaultOptions, options); + }, + }, + options + ); + + const table = createTable(resolvedOptions); + let state = $state>(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); + + options.onStateChange?.(updater); + }, + }); + }); + } + + updateOptions(); + + $effect.pre(() => { + updateOptions(); + }); + + return table; +} + +type MaybeThunk = T | (() => T | null | undefined); +type Intersection = (T extends [infer H, ...infer R] + ? H & Intersection + : 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: Sources +): Intersection<{ [K in keyof Sources]: Sources[K] }> { + const resolve = (src: MaybeThunk): 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(); + 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] }>; +} diff --git a/web/src/lib/components/ui/data-table/flex-render.svelte b/web/src/lib/components/ui/data-table/flex-render.svelte new file mode 100644 index 0000000..5f1abca --- /dev/null +++ b/web/src/lib/components/ui/data-table/flex-render.svelte @@ -0,0 +1,54 @@ + + + + +{#if isRenderSnippetConfig(content)} + {@render content.snippet(content.props)} +{:else if isRenderComponentConfig(content)} +
+{:else if typeof content === "function"} + {@const result = content(context)} + {#if isRenderComponentConfig(result)} +
+ {: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} diff --git a/web/src/lib/components/ui/data-table/index.ts b/web/src/lib/components/ui/data-table/index.ts new file mode 100644 index 0000000..5f4e77e --- /dev/null +++ b/web/src/lib/components/ui/data-table/index.ts @@ -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"; diff --git a/web/src/lib/components/ui/data-table/render-helpers.ts b/web/src/lib/components/ui/data-table/render-helpers.ts new file mode 100644 index 0000000..0e98c8b --- /dev/null +++ b/web/src/lib/components/ui/data-table/render-helpers.ts @@ -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, + TComp extends Component, +>(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(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 = Record, +> = { + component: Component; + props: TProps; + [RENDER_COMPONENT_SYMBOL]: true; +}; + +export type RenderSnippetConfig = { + 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>( + component: Component, + target: HTMLElement, + props: TProps +) { + const instance = mount(component, { target, props }); + return () => unmount(instance); +} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 790f277..c298222 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,7 +1,14 @@