diff --git a/lms/static/scripts/frontend_apps/api-types.ts b/lms/static/scripts/frontend_apps/api-types.ts index 00c872ca7b..52eb0bbfb5 100644 --- a/lms/static/scripts/frontend_apps/api-types.ts +++ b/lms/static/scripts/frontend_apps/api-types.ts @@ -218,6 +218,14 @@ export type StudentWithMetrics = Student & { */ export type StudentsMetricsResponse = { students: StudentWithMetrics[]; + + /** + * Indicates the last time the students roster was updated. + * + * `null` indicates we don't have roster data and the list is based on + * assignment launches. + */ + last_updated: ISODateTime | null; }; type AssignmentWithCourse = Assignment & { diff --git a/lms/static/scripts/frontend_apps/components/RelativeTime.tsx b/lms/static/scripts/frontend_apps/components/RelativeTime.tsx index 9f7ad1a5b5..c1f384a1ab 100644 --- a/lms/static/scripts/frontend_apps/components/RelativeTime.tsx +++ b/lms/static/scripts/frontend_apps/components/RelativeTime.tsx @@ -8,17 +8,29 @@ import { useEffect, useMemo, useState } from 'preact/hooks'; export type RelativeTimeProps = { /** The reference date-time, in ISO format */ dateTime: string; + + /** + * Whether a `title` attribute with the absolute date should be added. + * Defaults to `true`. + */ + withTitle?: boolean; }; /** * Displays a date as a time relative to `now`, making sure it is updated at * appropriate intervals */ -export default function RelativeTime({ dateTime }: RelativeTimeProps) { +export default function RelativeTime({ + dateTime, + withTitle = true, +}: RelativeTimeProps) { const [now, setNow] = useState(() => new Date()); const absoluteDate = useMemo( - () => formatDateTime(dateTime, { includeWeekday: true }), - [dateTime], + () => + withTitle + ? formatDateTime(dateTime, { includeWeekday: true }) + : undefined, + [dateTime, withTitle], ); const relativeDate = useMemo( () => formatRelativeDate(new Date(dateTime), now), diff --git a/lms/static/scripts/frontend_apps/components/dashboard/AssignmentActivity.tsx b/lms/static/scripts/frontend_apps/components/dashboard/AssignmentActivity.tsx index 9160900291..1c683963d7 100644 --- a/lms/static/scripts/frontend_apps/components/dashboard/AssignmentActivity.tsx +++ b/lms/static/scripts/frontend_apps/components/dashboard/AssignmentActivity.tsx @@ -1,4 +1,10 @@ -import { ClockIcon } from '@hypothesis/frontend-shared'; +import { + CautionIcon, + ClockIcon, + FileGenericIcon, + InfoIcon, + Link, +} from '@hypothesis/frontend-shared'; import classnames from 'classnames'; import { useCallback, useMemo, useState } from 'preact/hooks'; import { useLocation, useParams, useSearch } from 'wouter-preact'; @@ -17,7 +23,6 @@ import { courseURL } from '../../utils/dashboard/navigation'; import { rootViewTitle } from '../../utils/dashboard/root-view-title'; import { useDocumentTitle } from '../../utils/hooks'; import { type QueryParams, replaceURLParams } from '../../utils/url'; -import RelativeTime from '../RelativeTime'; import type { DashboardActivityFiltersProps, SegmentsType, @@ -26,6 +31,7 @@ import DashboardActivityFilters from './DashboardActivityFilters'; import DashboardBreadcrumbs from './DashboardBreadcrumbs'; import FormattedDate from './FormattedDate'; import GradeIndicator from './GradeIndicator'; +import LastSyncIndicator from './LastSyncIndicator'; import type { OrderableActivityTableColumn } from './OrderableActivityTable'; import OrderableActivityTable from './OrderableActivityTable'; import StudentStatusBadge from './StudentStatusBadge'; @@ -203,6 +209,7 @@ export default function AssignmentActivity() { }, }, ), + last_updated: students.data?.last_updated ?? null, }); }, [students]); @@ -275,20 +282,26 @@ export default function AssignmentActivity() { }, ]} /> - {lastSync.data && ( -
- - Grades last synced:{' '} - {lastSync.data.finish_date ? ( - - ) : ( - 'syncing…' - )} -
- )} +
+ {lastSync.data && ( + + )} + {students.data?.last_updated && ( + + )} +
)}
@@ -415,6 +428,19 @@ export default function AssignmentActivity() { } }} /> + {!students.isLoading && !students.data?.last_updated && ( + + + Full roster data for this assignment is not available. This only shows + students who have previously launched it. + + )}
); } diff --git a/lms/static/scripts/frontend_apps/components/dashboard/LastSyncIndicator.tsx b/lms/static/scripts/frontend_apps/components/dashboard/LastSyncIndicator.tsx new file mode 100644 index 0000000000..e757e32b25 --- /dev/null +++ b/lms/static/scripts/frontend_apps/components/dashboard/LastSyncIndicator.tsx @@ -0,0 +1,48 @@ +import type { IconComponent } from '@hypothesis/frontend-shared'; +import { formatDateTime } from '@hypothesis/frontend-shared'; +import classnames from 'classnames'; +import { useMemo } from 'preact/hooks'; + +import type { ISODateTime } from '../../api-types'; +import RelativeTime from '../RelativeTime'; + +export type LastSyncIndicatorProps = { + icon: IconComponent; + taskName: string; + dateTime: ISODateTime | null; +}; + +/** + * Represents the last time a task that syncs periodically happened + */ +export default function LastSyncIndicator({ + icon: Icon, + taskName, + dateTime, +}: LastSyncIndicatorProps) { + const absoluteDate = useMemo( + () => + dateTime ? formatDateTime(dateTime, { includeWeekday: true }) : undefined, + [dateTime], + ); + + return ( +
+ + {taskName}: + {dateTime ? ( + + ) : ( + syncing… + )} +
+ ); +} diff --git a/lms/static/scripts/frontend_apps/components/dashboard/test/AssignmentActivity-test.js b/lms/static/scripts/frontend_apps/components/dashboard/test/AssignmentActivity-test.js index 5224116218..d1e0ae55bc 100644 --- a/lms/static/scripts/frontend_apps/components/dashboard/test/AssignmentActivity-test.js +++ b/lms/static/scripts/frontend_apps/components/dashboard/test/AssignmentActivity-test.js @@ -668,15 +668,18 @@ describe('AssignmentActivity', () => { const wrapper = createComponent(); act(() => wrapper.find('SyncGradesButton').props().onSyncScheduled()); - assert.calledWith(fakeMutate, { - students: activeStudents.map(({ auto_grading_grade, ...rest }) => ({ - ...rest, - auto_grading_grade: { - ...auto_grading_grade, - last_grade: auto_grading_grade.current_grade, - }, - })), - }); + assert.calledWith( + fakeMutate, + sinon.match({ + students: activeStudents.map(({ auto_grading_grade, ...rest }) => ({ + ...rest, + auto_grading_grade: { + ...auto_grading_grade, + last_grade: auto_grading_grade.current_grade, + }, + })), + }), + ); }); [ @@ -713,17 +716,14 @@ describe('AssignmentActivity', () => { { data: null, shouldDisplayLastSyncInfo: false, - shouldDisplaySyncing: false, }, { data: { status: 'scheduled' }, shouldDisplayLastSyncInfo: true, - shouldDisplaySyncing: true, }, { data: { status: 'in_progress' }, shouldDisplayLastSyncInfo: true, - shouldDisplaySyncing: true, }, { data: { @@ -731,7 +731,6 @@ describe('AssignmentActivity', () => { finish_date: '2024-10-02T14:24:15.677924+00:00', }, shouldDisplayLastSyncInfo: true, - shouldDisplaySyncing: false, }, { data: { @@ -739,9 +738,8 @@ describe('AssignmentActivity', () => { finish_date: '2024-10-02T14:24:15.677924+00:00', }, shouldDisplayLastSyncInfo: true, - shouldDisplaySyncing: false, }, - ].forEach(({ data, shouldDisplayLastSyncInfo, shouldDisplaySyncing }) => { + ].forEach(({ data, shouldDisplayLastSyncInfo }) => { it('displays the last time grades were synced', () => { fakeUsePolledAPIFetch.returns({ data, @@ -752,13 +750,30 @@ describe('AssignmentActivity', () => { const lastSyncDate = wrapper.find('[data-testid="last-sync-date"]'); assert.equal(lastSyncDate.exists(), shouldDisplayLastSyncInfo); + }); + }); - if (shouldDisplayLastSyncInfo) { - assert.equal( - lastSyncDate.text().includes('syncing…'), - shouldDisplaySyncing, - ); - } + [ + { lastUpdated: undefined, shouldDisplayLastSyncInfo: false }, + { + lastUpdated: '2024-10-02T14:24:15.677924+00:00', + shouldDisplayLastSyncInfo: true, + }, + ].forEach(({ lastUpdated, shouldDisplayLastSyncInfo }) => { + it('displays the last time roster was synced', () => { + setUpFakeUseAPIFetch(activeAssignment, { + students: activeStudents, + last_updated: lastUpdated, + }); + + const wrapper = createComponent(); + const lastSyncDate = wrapper.find('[data-testid="last-roster-date"]'); + const missingRosterMessage = wrapper.find( + '[data-testid="missing-roster-message"]', + ); + + assert.equal(lastSyncDate.exists(), shouldDisplayLastSyncInfo); + assert.equal(missingRosterMessage.exists(), !shouldDisplayLastSyncInfo); }); }); diff --git a/lms/static/scripts/frontend_apps/components/dashboard/test/LastSyncIndicator-test.js b/lms/static/scripts/frontend_apps/components/dashboard/test/LastSyncIndicator-test.js new file mode 100644 index 0000000000..7d26716de6 --- /dev/null +++ b/lms/static/scripts/frontend_apps/components/dashboard/test/LastSyncIndicator-test.js @@ -0,0 +1,39 @@ +import { FileCodeIcon, formatDateTime } from '@hypothesis/frontend-shared'; +import { mount } from 'enzyme'; + +import LastSyncIndicator from '../LastSyncIndicator'; + +describe('LastSyncIndicator', () => { + function createComponent(dateTime) { + return mount( + , + ).find('[data-testid="container"]'); + } + + [ + { dateTime: null, expectedTitle: undefined }, + { + dateTime: '2024-10-02T14:24:15.677924+00:00', + expectedTitle: `task last synced on ${formatDateTime('2024-10-02T14:24:15.677924+00:00', { includeWeekday: true })}`, + }, + ].forEach(({ dateTime, expectedTitle }) => { + it('has title when dateTime is not null', () => { + const wrapper = createComponent(dateTime); + assert.equal(wrapper.prop('title'), expectedTitle); + }); + + it('shows syncing message when date time is null', () => { + const wrapper = createComponent(dateTime); + assert.equal(wrapper.exists('[data-testid="syncing"]'), !dateTime); + }); + + it('shows relative time when dateTime is not null', () => { + const wrapper = createComponent(dateTime); + assert.equal(wrapper.exists('RelativeTime'), !!dateTime); + }); + }); +}); diff --git a/lms/static/scripts/frontend_apps/components/test/RelativeTime-test.js b/lms/static/scripts/frontend_apps/components/test/RelativeTime-test.js index 8167ae25e7..721d191a45 100644 --- a/lms/static/scripts/frontend_apps/components/test/RelativeTime-test.js +++ b/lms/static/scripts/frontend_apps/components/test/RelativeTime-test.js @@ -32,17 +32,19 @@ describe('RelativeTime', () => { $imports.$restore(); }); - function createComponent() { - return mount(); + function createComponent(props = {}) { + return mount(); } - it('sets initial time values', () => { - const wrapper = createComponent(); - const time = wrapper.find('time'); + [{ withTitle: true }, { withTitle: false }].forEach(({ withTitle }) => { + it('sets initial time values', () => { + const wrapper = createComponent({ withTitle }); + const time = wrapper.find('time'); - assert.equal(time.prop('title'), 'absolute date'); - assert.equal(time.prop('dateTime'), dateTime); - assert.equal(wrapper.text(), 'relative date'); + assert.equal(time.prop('title'), withTitle ? 'absolute date' : undefined); + assert.equal(time.prop('dateTime'), dateTime); + assert.equal(wrapper.text(), 'relative date'); + }); }); it('is updated after time passes', () => {