Skip to content

Commit 2bee4d3

Browse files
committed
course: switch student page to use virtualization -- see #6698
- involves a lot of subtle state issues, though
1 parent f476907 commit 2bee4d3

File tree

9 files changed

+281
-206
lines changed

9 files changed

+281
-206
lines changed

src/packages/frontend/components/scrollable-list.tsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ positioned are in an LRU cache, so use a bounded amount of memory.
1010
import { ReactNode, useEffect, useRef } from "react";
1111
import LRU from "lru-cache";
1212
import { useDebouncedCallback } from "use-debounce";
13+
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
14+
import useVirtuosoScrollHook from "@cocalc/frontend/components/virtuoso-scroll-hook";
1315

1416
const cache = new LRU<string, number>({ max: 250 });
1517

@@ -19,9 +21,38 @@ interface Props {
1921
rowKey: (index: number) => string;
2022
// used to cache scroll position between unmounting and remounting
2123
cacheId?: string;
24+
virtualize?: boolean;
2225
}
2326

24-
export default function ScrollableList({
27+
export default function ScrollableList(props: Props) {
28+
if (props.virtualize) {
29+
return <VirtualizedScrollableList {...props} />;
30+
} else {
31+
return <NonVirtualizedScrollableList {...props} />;
32+
}
33+
}
34+
35+
function VirtualizedScrollableList({
36+
rowCount,
37+
rowRenderer,
38+
rowKey,
39+
cacheId,
40+
}: Props) {
41+
const virtuosoRef = useRef<VirtuosoHandle>(null);
42+
const virtuosoScroll = useVirtuosoScrollHook({ cacheId });
43+
return (
44+
<div className={"smc-vfill"}>
45+
<Virtuoso
46+
ref={virtuosoRef}
47+
totalCount={rowCount}
48+
itemContent={(index) => rowRenderer({ index, key: rowKey(index) })}
49+
{...virtuosoScroll}
50+
/>
51+
</div>
52+
);
53+
}
54+
55+
function NonVirtualizedScrollableList({
2556
rowCount,
2657
rowRenderer,
2758
rowKey,

src/packages/frontend/course/actions.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { ProjectsStore } from "../projects/store";
2626
import { bind_methods } from "@cocalc/util/misc";
2727
// React libraries
2828
import { Actions, TypedMap } from "../app-framework";
29+
import { Map as iMap } from "immutable";
2930

3031
export const primary_key = {
3132
students: "student_id",
@@ -392,4 +393,21 @@ export class CourseActions extends Actions<CourseState> {
392393
}
393394
this.setState({ [field_name]: adjusted });
394395
};
396+
397+
setPageFilter = (page: string, filter: string) => {
398+
const store = this.get_store();
399+
if (!store) return;
400+
let pageFilter = store.get("pageFilter");
401+
if (pageFilter == null) {
402+
if (filter) {
403+
pageFilter = iMap({ [page]: filter });
404+
this.setState({
405+
pageFilter,
406+
});
407+
}
408+
return;
409+
}
410+
pageFilter = pageFilter.set(page, filter);
411+
this.setState({ pageFilter });
412+
};
395413
}

src/packages/frontend/course/store.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,10 @@ export interface CourseState {
202202
unsaved?: boolean;
203203
terminal_command?: TerminalCommand;
204204
nbgrader_run_info?: NBgraderRunInfo;
205+
// map from student_id to a filter string.
206+
assignmentFilter?: Map<string, string>;
207+
// each page -- students, assignments, handouts (etc.?) has a filter. This is the state of that filter.
208+
pageFilter?: Map<string, string>;
205209
}
206210

207211
export class CourseStore extends Store<CourseState> {

src/packages/frontend/course/students/actions.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { reuseInFlight } from "@cocalc/util/reuse-in-flight";
1616
import { CourseActions } from "../actions";
1717
import { CourseStore, StudentRecord } from "../store";
1818
import { SyncDBRecordStudent } from "../types";
19+
import { Map as iMap } from "immutable";
1920

2021
const STUDENT_STATUS_UPDATE_MS = 60 * 1000;
2122

@@ -245,7 +246,7 @@ export class StudentsActions {
245246

246247
// columns: first_name, last_name, email, last_active, hosting
247248
// Toggles ascending/decending order
248-
public set_active_student_sort(column_name: string): void {
249+
set_active_student_sort = (column_name: string): void => {
249250
let is_descending: boolean;
250251
const store = this.get_store();
251252
const current_column = store.getIn(["active_student_sort", "column_name"]);
@@ -257,12 +258,12 @@ export class StudentsActions {
257258
this.course_actions.setState({
258259
active_student_sort: { column_name, is_descending },
259260
});
260-
}
261+
};
261262

262-
public async set_internal_student_info(
263+
set_internal_student_info = async (
263264
student_id: string,
264265
info: { first_name: string; last_name: string; email_address?: string },
265-
): Promise<void> {
266+
): Promise<void> => {
266267
const { student } = this.course_actions.resolve({ student_id });
267268
if (student == null) return;
268269

@@ -282,23 +283,23 @@ export class StudentsActions {
282283

283284
// since they may get removed from shared project, etc.
284285
await this.course_actions.student_projects.configure_all_projects();
285-
}
286+
};
286287

287-
public set_student_note(student_id: string, note: string): void {
288+
set_student_note = (student_id: string, note: string): void => {
288289
this.course_actions.set({
289290
note,
290291
table: "students",
291292
student_id,
292293
});
293-
}
294+
};
294295

295296
/*
296297
Function to "catch up a student" by pushing out all (non-deleted) handouts and assignments to
297298
this student that have been pushed to at least one student so far.
298299
*/
299-
public async push_missing_handouts_and_assignments(
300+
push_missing_handouts_and_assignments = async (
300301
student_id: string,
301-
): Promise<void> {
302+
): Promise<void> => {
302303
const { student, store } = this.course_actions.resolve({ student_id });
303304
if (student == null) {
304305
throw Error("no such student");
@@ -334,5 +335,22 @@ export class StudentsActions {
334335
} finally {
335336
this.course_actions.set_activity({ id });
336337
}
337-
}
338+
};
339+
340+
setAssignmentFilter = (student_id: string, filter: string) => {
341+
const store = this.get_store();
342+
if (!store) return;
343+
let assignmentFilter = store.get("assignmentFilter");
344+
if (assignmentFilter == null) {
345+
if (filter) {
346+
assignmentFilter = iMap({ [student_id]: filter });
347+
this.course_actions.setState({
348+
assignmentFilter,
349+
});
350+
}
351+
return;
352+
}
353+
assignmentFilter = assignmentFilter.set(student_id, filter);
354+
this.course_actions.setState({ assignmentFilter });
355+
};
338356
}

src/packages/frontend/course/students/students-panel-student.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import {
1919
Tooltip,
2020
} from "antd";
2121
import { useEffect, useState } from "react";
22-
import { DebounceInput } from "react-debounce-input";
2322
import { CourseActions } from "../actions";
2423
import { StudentAssignmentInfo, StudentAssignmentInfoHeader } from "../common";
2524
import {
@@ -61,6 +60,7 @@ interface StudentProps {
6160
display_account_name?: boolean;
6261
active_feedback_edits: IsGradingMap;
6362
nbgrader_run_info?: NBgraderRunInfo;
63+
assignmentFilter?;
6464
}
6565

6666
export function Student({
@@ -77,6 +77,7 @@ export function Student({
7777
display_account_name,
7878
active_feedback_edits,
7979
nbgrader_run_info,
80+
assignmentFilter,
8081
}: StudentProps) {
8182
const actions: CourseActions = redux.getActions(name);
8283
const store = actions.get_store();
@@ -98,15 +99,13 @@ export function Student({
9899
student.get("email_address") || "",
99100
);
100101
const [more, set_more] = useState<boolean>(false);
101-
const [assignment_search, set_assignment_search] = useState<string>("");
102-
103102
function reset_initial_state() {
104103
set_editing_student(false);
105104
set_edited_first_name(student_name.first || "");
106105
set_edited_last_name(student_name.last || "");
107106
set_edited_email_address(student.get("email_address") || "");
108107
set_more(false);
109-
set_assignment_search("");
108+
actions.students.setAssignmentFilter(student_id, "");
110109
}
111110

112111
useEffect(() => {
@@ -339,13 +338,14 @@ export function Student({
339338

340339
function render_search_assignment() {
341340
return (
342-
<DebounceInput
341+
<Input.Search
342+
allowClear
343343
style={{ width: "100%" }}
344-
debounceTimeout={500}
345-
element={Input as any}
346-
placeholder={"Find assignments..."}
347-
value={assignment_search}
348-
onChange={(e) => set_assignment_search(e.target.value)}
344+
placeholder={"Filter assignments..."}
345+
value={assignmentFilter ?? ""}
346+
onChange={(e) =>
347+
actions.students.setAssignmentFilter(student_id, e.target.value)
348+
}
349349
/>
350350
);
351351
}
@@ -465,7 +465,7 @@ export function Student({
465465

466466
function render_assignments_info_rows() {
467467
const result: any[] = [];
468-
const terms = search_split(assignment_search);
468+
const terms = search_split(assignmentFilter ?? "");
469469
// TODO instead of accessing the store, use the state to react to data changes -- that's why we chech in "isSame" above.
470470
for (const assignment of store.get_sorted_assignments()) {
471471
if (terms.length > 0) {

0 commit comments

Comments
 (0)