Skip to content

Commit

Permalink
Add button to sync grades in dashboard assignment view (#6700)
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya authored Sep 26, 2024
1 parent b40c4a7 commit dafd78a
Show file tree
Hide file tree
Showing 6 changed files with 518 additions and 48 deletions.
7 changes: 7 additions & 0 deletions lms/static/scripts/frontend_apps/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,3 +292,10 @@ export type StudentsResponse = {
students: Student[];
pagination: Pagination;
};

/**
* Response for `/api/dashboard/assignments/{assignment_id}/grading/sync`
*/
export type GradingSync = {
status: 'scheduled' | 'in_progress' | 'finished' | 'failed';
};
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import FormattedDate from './FormattedDate';
import GradeIndicator from './GradeIndicator';
import type { OrderableActivityTableColumn } from './OrderableActivityTable';
import OrderableActivityTable from './OrderableActivityTable';
import SyncGradesButton from './SyncGradesButton';

type StudentsTableRow = {
lms_id: string;
Expand All @@ -37,7 +38,11 @@ type StudentsTableRow = {
*/
export default function AssignmentActivity() {
const { dashboard } = useConfig(['dashboard']);
const { routes, assignment_segments_filter_enabled } = dashboard;
const {
routes,
auto_grading_sync_enabled,
assignment_segments_filter_enabled,
} = dashboard;
const { assignmentId, organizationPublicId } = useParams<{
assignmentId: string;
organizationPublicId?: string;
Expand All @@ -51,14 +56,14 @@ export default function AssignmentActivity() {
const assignment = useAPIFetch<AssignmentDetails>(
replaceURLParams(routes.assignment, { assignment_id: assignmentId }),
);
const autoGradingEnabled = !!assignment.data?.auto_grading_config;
const isAutoGradingAssignment = !!assignment.data?.auto_grading_config;
const segments = useMemo((): DashboardActivityFiltersProps['segments'] => {
const { data } = assignment;
if (
!data ||
// Display the segments filter only for auto-grading assignments, or
// assignments where the feature was explicitly enabled
(!assignment_segments_filter_enabled && !autoGradingEnabled)
(!assignment_segments_filter_enabled && !isAutoGradingAssignment)
) {
return undefined;
}
Expand All @@ -85,7 +90,7 @@ export default function AssignmentActivity() {
}, [
assignment,
assignment_segments_filter_enabled,
autoGradingEnabled,
isAutoGradingAssignment,
segmentIds,
updateFilters,
]);
Expand All @@ -99,14 +104,24 @@ export default function AssignmentActivity() {
org_public_id: organizationPublicId,
},
);
const studentsToSync = useMemo(() => {
if (!isAutoGradingAssignment || !students.data) {
return undefined;
}

// TODO Filter out students whose grades didn't change
return students.data.students.map(
({ h_userid, auto_grading_grade = 0 }) => ({
h_userid,
grade: auto_grading_grade,
}),
);
}, [isAutoGradingAssignment, students.data]);
const rows: StudentsTableRow[] = useMemo(
() =>
(students.data?.students ?? []).map(
({ lms_id, display_name, auto_grading_grade, annotation_metrics }) => ({
lms_id,
display_name,
auto_grading_grade,
({ annotation_metrics, ...rest }) => ({
...rest,
...annotation_metrics,
}),
),
Expand Down Expand Up @@ -137,15 +152,15 @@ export default function AssignmentActivity() {
},
];

if (autoGradingEnabled) {
if (isAutoGradingAssignment) {
firstColumns.push({
field: 'auto_grading_grade',
label: 'Grade',
});
}

return [...firstColumns, ...lastColumns];
}, [autoGradingEnabled]);
}, [isAutoGradingAssignment]);

const title = assignment.data?.title ?? 'Untitled assignment';
useDocumentTitle(title);
Expand Down Expand Up @@ -175,42 +190,47 @@ export default function AssignmentActivity() {
{assignment.data && title}
</h2>
</div>
{assignment.data && (
<DashboardActivityFilters
courses={{
activeItem: assignment.data.course,
// When the active course is cleared, navigate to home, but keep
// active assignment and students
onClear: () =>
navigate(
urlWithFilters(
{ studentIds, assignmentIds: [assignmentId] },
{ path: '' },
<div className="flex justify-between items-end gap-x-4">
{assignment.data && (
<DashboardActivityFilters
courses={{
activeItem: assignment.data.course,
// When the active course is cleared, navigate to home, but keep
// active assignment and students
onClear: () =>
navigate(
urlWithFilters(
{ studentIds, assignmentIds: [assignmentId] },
{ path: '' },
),
),
),
}}
assignments={{
activeItem: assignment.data,
// When active assignment is cleared, navigate to its course page,
// but keep other query params intact
onClear: () => {
const query = search.length === 0 ? '' : `?${search}`;
navigate(`${courseURL(assignment.data!.course.id)}${query}`);
},
}}
students={{
selectedIds: studentIds,
onChange: studentIds => updateFilters({ studentIds }),
}}
segments={segments}
onClearSelection={
studentIds.length > 0 ||
(segments && segments.selectedIds.length > 0)
? () => updateFilters({ studentIds: [], segmentIds: [] })
: undefined
}
/>
)}
}}
assignments={{
activeItem: assignment.data,
// When active assignment is cleared, navigate to its course page,
// but keep other query params intact
onClear: () => {
const query = search.length === 0 ? '' : `?${search}`;
navigate(`${courseURL(assignment.data!.course.id)}${query}`);
},
}}
students={{
selectedIds: studentIds,
onChange: studentIds => updateFilters({ studentIds }),
}}
segments={segments}
onClearSelection={
studentIds.length > 0 ||
(segments && segments.selectedIds.length > 0)
? () => updateFilters({ studentIds: [], segmentIds: [] })
: undefined
}
/>
)}
{isAutoGradingAssignment && auto_grading_sync_enabled && (
<SyncGradesButton studentsToSync={studentsToSync} />
)}
</div>
<OrderableActivityTable
loading={students.isLoading}
title={assignment.isLoading ? 'Loading...' : title}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import {
Button,
LeaveIcon,
SpinnerCircleIcon,
} from '@hypothesis/frontend-shared';
import { useCallback, useMemo, useState } from 'preact/hooks';
import { useParams } from 'wouter-preact';

import type { GradingSync } from '../../api-types';
import { useConfig } from '../../config';
import { apiCall, usePolledAPIFetch } from '../../utils/api';
import type { QueryParams } from '../../utils/url';
import { replaceURLParams } from '../../utils/url';

export type SyncGradesButtonProps = {
/**
* List of students and their grades, which should be synced when the button
* is clicked.
* Passing `undefined` means students are not yet known and/or being loaded.
*/
studentsToSync?: Array<{ h_userid: string; grade: number }>;
};

export default function SyncGradesButton({
studentsToSync,
}: SyncGradesButtonProps) {
const { assignmentId } = useParams<{ assignmentId: string }>();
const { dashboard, api } = useConfig(['dashboard', 'api']);
const { routes } = dashboard;
const [schedulingSyncFailed, setSchedulingSyncFailed] = useState(false);

const syncURL = useMemo(
() =>
replaceURLParams(routes.assignment_grades_sync, {
assignment_id: assignmentId,
}),
[assignmentId, routes.assignment_grades_sync],
);
const [lastSyncParams, setLastSyncParams] = useState<QueryParams>({});
const lastSync = usePolledAPIFetch<GradingSync>({
path: syncURL,
params: lastSyncParams,
// Keep polling as long as sync is in progress
shouldRefresh: result =>
!result.data || ['scheduled', 'in_progress'].includes(result.data.status),
});

const buttonContent = useMemo(() => {
if (!studentsToSync || (lastSync.isLoading && !lastSync.data)) {
return 'Loading...';
}

if (
lastSync.data &&
['scheduled', 'in_progress'].includes(lastSync.data.status)
) {
return (
<>
Syncing grades
<SpinnerCircleIcon className="ml-1.5" />
</>
);
}

// TODO Maybe these should be represented differently
if (
schedulingSyncFailed ||
lastSync.error ||
lastSync.data?.status === 'failed'
) {
return (
<>
Error syncing. Click to retry
<LeaveIcon />
</>
);
}

if (studentsToSync.length > 0) {
return `Sync ${studentsToSync.length} grades`;
}

return 'Grades synced';
}, [
studentsToSync,
lastSync.isLoading,
lastSync.data,
lastSync.error,
schedulingSyncFailed,
]);

const buttonDisabled =
lastSync.isLoading ||
lastSync.data?.status === 'scheduled' ||
lastSync.data?.status === 'in_progress' ||
!studentsToSync ||
studentsToSync.length === 0;

const syncGrades = useCallback(async () => {
lastSync.mutate({ status: 'scheduled' });
setSchedulingSyncFailed(false);

await apiCall({
authToken: api.authToken,
path: syncURL,
method: 'POST',
data: {
grades: (studentsToSync ?? []).map(({ grade, h_userid }) => ({
h_userid,
// FIXME This will be fixed separately, but the BE is currently
// returning grades from 0 to 100, but expects them to be sent back
// from 0 to 1.
grade: grade / 100,
})),
},
}).catch(() => setSchedulingSyncFailed(true));

// Once the request succeeds, we update the params so that polling the
// status is triggered again
setLastSyncParams({ t: `${Date.now()}` });
}, [api.authToken, lastSync, studentsToSync, syncURL]);

return (
<Button variant="primary" onClick={syncGrades} disabled={buttonDisabled}>
{buttonContent}
</Button>
);
}
Loading

0 comments on commit dafd78a

Please sign in to comment.