Skip to content

Commit

Permalink
Add button to sync grades
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed Sep 20, 2024
1 parent 1e003b5 commit 2b9f46c
Show file tree
Hide file tree
Showing 6 changed files with 191 additions and 135 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 @@ -19,6 +19,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 @@ -34,7 +35,7 @@ type StudentsTableRow = {
*/
export default function AssignmentActivity() {
const { dashboard } = useConfig(['dashboard']);
const { routes } = dashboard;
const { routes, auto_grading_sync_enabled } = dashboard;
const { assignmentId, organizationPublicId } = useParams<{
assignmentId: string;
organizationPublicId?: string;
Expand Down Expand Up @@ -88,13 +89,24 @@ export default function AssignmentActivity() {
},
);

const studentsToSync = useMemo(() => {
if (!autoGradingEnabled) {
return [];
}

// 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,
}),
);
}, [autoGradingEnabled, students.data?.students]);
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 @@ -163,42 +175,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
}
/>
)}
{autoGradingEnabled && 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,93 @@
import { Button, 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 { replaceURLParams } from '../../utils/url';

export type SyncGradesButtonProps = {
/**
* List of students and their grades, which should be synced when the button
* is clicked.
*/
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 [syncing, setSyncing] = useState(false);

const syncUrl = useMemo(
() =>
replaceURLParams(routes.assignment_grades_sync ?? ':assignment_id', {
assignment_id: assignmentId,
}),
[assignmentId, routes.assignment_grades_sync],
);
const lastSync = usePolledAPIFetch<GradingSync>({
path: syncUrl,
// Keep polling as long as sync is in progress
shouldRetry: result =>
!!result.data &&
['scheduled', 'in_progress'].includes(result.data.status),
});

const buttonContent = useMemo(() => {
if (lastSync.isLoading) {
return 'Loading...';
}

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

if (lastSync.data?.status === 'failed') {
return 'Error syncing';
}

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

return 'Grades synced';
}, [lastSync.isLoading, lastSync.data, syncing, studentsToSync.length]);

const buttonDisabled = useMemo(
() => syncing || lastSync.isLoading || studentsToSync.length === 0,
[syncing, lastSync.isLoading, studentsToSync.length],
);

const syncGrades = useCallback(() => {
setSyncing(true);

apiCall({
authToken: api.authToken,
path: syncUrl,
method: 'POST',
data: {
grades: studentsToSync,
},
}).finally(() => setSyncing(false));
}, [api.authToken, studentsToSync, syncUrl]);

return (
<Button variant="primary" onClick={syncGrades} disabled={buttonDisabled}>
{buttonContent}
</Button>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ describe('AssignmentActivity', () => {
assignment: '/api/assignments/:assignment_id',
students_metrics: '/api/students/metrics',
},
auto_grading_sync_enabled: true,
},
};

Expand Down Expand Up @@ -421,6 +422,29 @@ describe('AssignmentActivity', () => {
});
},
);

[
{ syncEnabled: true, autoGradingEnabled: true, shouldShowButton: true },
{ syncEnabled: false, autoGradingEnabled: true, shouldShowButton: false },
{ syncEnabled: true, autoGradingEnabled: false, shouldShowButton: false },
{
syncEnabled: false,
autoGradingEnabled: false,
shouldShowButton: false,
},
].forEach(({ autoGradingEnabled, syncEnabled, shouldShowButton }) => {
it('shows sync button when both sync and auto-grading are enabled', () => {
setUpFakeUseAPIFetch({
...activeAssignment,
auto_grading_config: autoGradingEnabled ? {} : null,
});
fakeConfig.dashboard.auto_grading_sync_enabled = syncEnabled;

const wrapper = createComponent();

assert.equal(wrapper.exists('SyncGradesButton'), shouldShowButton);
});
});
});

context('when assignment has segments', () => {
Expand Down
4 changes: 4 additions & 0 deletions lms/static/scripts/frontend_apps/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,9 @@ export type DashboardRoutes = {
assignments: string;
/** Fetch list of students */
students: string;

/** Sync grades (POST) or check sync status (GET) */
assignment_grades_sync: string;
};

export type DashboardUser = {
Expand All @@ -279,6 +282,7 @@ export type DashboardUser = {
export type DashboardConfig = {
routes: DashboardRoutes;
user: DashboardUser;
auto_grading_sync_enabled: boolean;
};

/**
Expand Down
Loading

0 comments on commit 2b9f46c

Please sign in to comment.