diff --git a/lms/static/scripts/frontend_apps/api-types.ts b/lms/static/scripts/frontend_apps/api-types.ts index c1ab4422b5..56651228df 100644 --- a/lms/static/scripts/frontend_apps/api-types.ts +++ b/lms/static/scripts/frontend_apps/api-types.ts @@ -147,8 +147,13 @@ export type YouTubeVideoInfo = { * Dashboard-related API types */ +/** + * Represents dates exposed by the backend as string, in ISO 8601 format + */ +export type ISODateTime = string; + export type AnnotationMetrics = { - last_activity: string | null; + last_activity: ISODateTime | null; annotations: number; replies: number; }; @@ -163,7 +168,7 @@ export type Course = { export type CourseMetrics = { assignments: number; - last_launched: string | null; + last_launched: ISODateTime | null; }; export type CourseWithMetrics = Course & { @@ -181,7 +186,7 @@ export type Assignment = { id: number; title: string; /** Date in which the assignment was created, in ISO format */ - created: string; + created: ISODateTime; }; export type Student = { @@ -196,7 +201,7 @@ export type AutoGradingGrade = { /** Grade that was last synced, if any */ last_grade: number | null; /** When did the last grade sync happen, if any */ - last_grade_date: number | null; + last_grade_date: ISODateTime | null; }; export type StudentWithMetrics = Student & { @@ -302,9 +307,37 @@ export type StudentsResponse = { pagination: Pagination; }; +export type StudentGradingSyncStatus = 'in_progress' | 'finished' | 'failed'; + +export type StudentGradingSync = { + h_userid: string; + status: StudentGradingSyncStatus; +}; + +export type GradingSyncStatus = 'scheduled' | StudentGradingSyncStatus; + /** * Response for `/api/dashboard/assignments/{assignment_id}/grading/sync` + * That endpoint returns a 404 when an assignment has never been synced. */ export type GradingSync = { - status: 'scheduled' | 'in_progress' | 'finished' | 'failed'; + /** + * Global sync status. + * If at least one student grade is syncing, this will be `in_progress`. + * If at least one student grade failed, this will be `failed`. + * If all student grades finished successfully, this will be `finished`. + */ + status: GradingSyncStatus; + + /** + * The date and time when syncing grades finished. + * It is null as long as status is `scheduled` or `in_progress`. + */ + finish_date: ISODateTime | null; + + /** + * Grading status for every individual student that was scheduled as part of + * this sync. + */ + grades: StudentGradingSync[]; }; diff --git a/lms/static/scripts/frontend_apps/components/RelativeTime.tsx b/lms/static/scripts/frontend_apps/components/RelativeTime.tsx new file mode 100644 index 0000000000..9f7ad1a5b5 --- /dev/null +++ b/lms/static/scripts/frontend_apps/components/RelativeTime.tsx @@ -0,0 +1,38 @@ +import { + decayingInterval, + formatRelativeDate, + formatDateTime, +} from '@hypothesis/frontend-shared'; +import { useEffect, useMemo, useState } from 'preact/hooks'; + +export type RelativeTimeProps = { + /** The reference date-time, in ISO format */ + dateTime: string; +}; + +/** + * Displays a date as a time relative to `now`, making sure it is updated at + * appropriate intervals + */ +export default function RelativeTime({ dateTime }: RelativeTimeProps) { + const [now, setNow] = useState(() => new Date()); + const absoluteDate = useMemo( + () => formatDateTime(dateTime, { includeWeekday: true }), + [dateTime], + ); + const relativeDate = useMemo( + () => formatRelativeDate(new Date(dateTime), now), + [dateTime, now], + ); + + // Refresh relative timestamp, at a frequency appropriate for the age. + useEffect(() => { + return decayingInterval(dateTime, () => setNow(new Date())); + }, [dateTime]); + + return ( + + ); +} diff --git a/lms/static/scripts/frontend_apps/components/dashboard/AssignmentActivity.tsx b/lms/static/scripts/frontend_apps/components/dashboard/AssignmentActivity.tsx index 68cf34326a..6092df7405 100644 --- a/lms/static/scripts/frontend_apps/components/dashboard/AssignmentActivity.tsx +++ b/lms/static/scripts/frontend_apps/components/dashboard/AssignmentActivity.tsx @@ -1,17 +1,20 @@ +import { ClockIcon } from '@hypothesis/frontend-shared'; import classnames from 'classnames'; -import { useCallback, useMemo } from 'preact/hooks'; +import { useCallback, useMemo, useState } from 'preact/hooks'; import { useLocation, useParams, useSearch } from 'wouter-preact'; import type { AssignmentDetails, + GradingSync, StudentsMetricsResponse, } from '../../api-types'; import { useConfig } from '../../config'; -import { useAPIFetch } from '../../utils/api'; +import { useAPIFetch, usePolledAPIFetch } from '../../utils/api'; import { useDashboardFilters } from '../../utils/dashboard/hooks'; import { courseURL } from '../../utils/dashboard/navigation'; import { useDocumentTitle } from '../../utils/hooks'; -import { replaceURLParams } from '../../utils/url'; +import { type QueryParams, replaceURLParams } from '../../utils/url'; +import RelativeTime from '../RelativeTime'; import type { DashboardActivityFiltersProps, SegmentsType, @@ -129,27 +132,49 @@ export default function AssignmentActivity() { grade: auto_grading_grade?.current_grade ?? 0, })); }, [isAutoGradingAssignment, students.data]); - const onSyncScheduled = useCallback( + + const syncURL = useMemo( () => - students.mutate({ - students: (students.data?.students ?? []).map( - ({ auto_grading_grade, ...rest }) => - !auto_grading_grade - ? rest - : { - ...rest, - auto_grading_grade: { - ...auto_grading_grade, - // Once a sync has been scheduled, update last_grade with - // current_grade value, so that students are no longer - // labelled as "New" - last_grade: auto_grading_grade.current_grade, - }, - }, - ), - }), - [students], + isAutoGradingAssignment + ? replaceURLParams(routes.assignment_grades_sync, { + assignment_id: assignmentId, + }) + : null, + [assignmentId, isAutoGradingAssignment, routes.assignment_grades_sync], ); + const [lastSyncParams, setLastSyncParams] = useState({}); + const lastSync = usePolledAPIFetch({ + 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 onSyncScheduled = useCallback(() => { + // Once the request succeeds, we update the params so that polling the + // status is triggered again + setLastSyncParams({ t: `${Date.now()}` }); + + students.mutate({ + students: (students.data?.students ?? []).map( + ({ auto_grading_grade, ...rest }) => + !auto_grading_grade + ? rest + : { + ...rest, + auto_grading_grade: { + ...auto_grading_grade, + // Once a sync has been scheduled, update last_grade with + // current_grade value, so that students are no longer + // labelled as "New" + last_grade: auto_grading_grade.current_grade, + }, + }, + ), + }); + }, [students]); const rows: StudentsTableRow[] = useMemo( () => @@ -204,7 +229,7 @@ export default function AssignmentActivity() {
{assignment.data && ( -
+
+ {lastSync.data && ( +
+ + Grades last synced:{' '} + {lastSync.data.finish_date ? ( + + ) : ( + 'syncing…' + )} +
+ )}
)}

@@ -265,6 +304,7 @@ export default function AssignmentActivity() { {isAutoGradingAssignment && auto_grading_sync_enabled && ( )} diff --git a/lms/static/scripts/frontend_apps/components/dashboard/DashboardBreadcrumbs.tsx b/lms/static/scripts/frontend_apps/components/dashboard/DashboardBreadcrumbs.tsx index 9a36ef94c4..d026c92f66 100644 --- a/lms/static/scripts/frontend_apps/components/dashboard/DashboardBreadcrumbs.tsx +++ b/lms/static/scripts/frontend_apps/components/dashboard/DashboardBreadcrumbs.tsx @@ -51,7 +51,7 @@ export default function DashboardBreadcrumbs({ return (
{linksWithHome.map(({ title, href }, index) => { diff --git a/lms/static/scripts/frontend_apps/components/dashboard/SyncGradesButton.tsx b/lms/static/scripts/frontend_apps/components/dashboard/SyncGradesButton.tsx index 9e14129a6b..0393f2d902 100644 --- a/lms/static/scripts/frontend_apps/components/dashboard/SyncGradesButton.tsx +++ b/lms/static/scripts/frontend_apps/components/dashboard/SyncGradesButton.tsx @@ -3,14 +3,14 @@ import { LeaveIcon, SpinnerCircleIcon, } from '@hypothesis/frontend-shared'; -import { useCallback, useMemo, useState } from 'preact/hooks'; +import { useCallback, useMemo } from 'preact/hooks'; import { useParams } from 'wouter-preact'; -import type { GradingSync } from '../../api-types'; +import type { GradingSync, GradingSyncStatus } from '../../api-types'; import { useConfig } from '../../config'; import { APIError } from '../../errors'; -import { apiCall, usePolledAPIFetch } from '../../utils/api'; -import type { QueryParams } from '../../utils/url'; +import { apiCall } from '../../utils/api'; +import type { FetchResult } from '../../utils/fetch'; import { replaceURLParams } from '../../utils/url'; export type SyncGradesButtonProps = { @@ -27,11 +27,15 @@ export type SyncGradesButtonProps = { * properly scheduled. */ onSyncScheduled: () => void; + + /** Result of fetching the status of last sync */ + lastSync: FetchResult; }; export default function SyncGradesButton({ studentsToSync, onSyncScheduled, + lastSync, }: SyncGradesButtonProps) { const { assignmentId } = useParams<{ assignmentId: string }>(); const { dashboard, api } = useConfig(['dashboard', 'api']); @@ -44,15 +48,15 @@ export default function SyncGradesButton({ }), [assignmentId, routes.assignment_grades_sync], ); - const [lastSyncParams, setLastSyncParams] = useState({}); - const lastSync = usePolledAPIFetch({ - 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 updateSyncStatus = useCallback( + (status: GradingSyncStatus) => + lastSync.data && + lastSync.mutate({ + ...lastSync.data, + status, + }), + [lastSync], + ); const buttonContent = useMemo(() => { if (!studentsToSync || (lastSync.isLoading && !lastSync.data)) { @@ -109,7 +113,7 @@ export default function SyncGradesButton({ studentsToSync.length === 0; const syncGrades = useCallback(async () => { - lastSync.mutate({ status: 'scheduled' }); + updateSyncStatus('scheduled'); apiCall({ authToken: api.authToken, @@ -119,14 +123,15 @@ export default function SyncGradesButton({ grades: studentsToSync, }, }) - .then(() => { - // Once the request succeeds, we update the params so that polling the - // status is triggered again - setLastSyncParams({ t: `${Date.now()}` }); - onSyncScheduled(); - }) - .catch(() => lastSync.mutate({ status: 'failed' })); - }, [api.authToken, lastSync, onSyncScheduled, studentsToSync, syncURL]); + .then(() => onSyncScheduled()) + .catch(() => updateSyncStatus('failed')); + }, [ + api.authToken, + onSyncScheduled, + studentsToSync, + syncURL, + updateSyncStatus, + ]); return (