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', () => {