Skip to content

Commit

Permalink
Add roster sync indicator (#6899)
Browse files Browse the repository at this point in the history
* Add roster sync indicator

* Add message when assignment students do not come from roster (#6905)
  • Loading branch information
acelaya authored and marcospri committed Dec 10, 2024
1 parent 7e3d6d3 commit 385d981
Show file tree
Hide file tree
Showing 7 changed files with 198 additions and 48 deletions.
8 changes: 8 additions & 0 deletions lms/static/scripts/frontend_apps/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand Down
18 changes: 15 additions & 3 deletions lms/static/scripts/frontend_apps/components/RelativeTime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -203,6 +209,7 @@ export default function AssignmentActivity() {
},
},
),
last_updated: students.data?.last_updated ?? null,
});
}, [students]);

Expand Down Expand Up @@ -275,20 +282,26 @@ export default function AssignmentActivity() {
},
]}
/>
{lastSync.data && (
<div
className="flex gap-x-1 items-center text-color-text-light"
data-testid="last-sync-date"
>
<ClockIcon />
Grades last synced:{' '}
{lastSync.data.finish_date ? (
<RelativeTime dateTime={lastSync.data.finish_date} />
) : (
'syncing…'
)}
</div>
)}
<div className="flex gap-0.5">
{lastSync.data && (
<LastSyncIndicator
icon={
lastSync.data.status === 'failed' ? CautionIcon : ClockIcon
}
taskName="Grades"
dateTime={lastSync.data.finish_date}
data-testid="last-sync-date"
/>
)}
{students.data?.last_updated && (
<LastSyncIndicator
icon={FileGenericIcon}
taskName="Roster"
dateTime={students.data.last_updated}
data-testid="last-roster-date"
/>
)}
</div>
</div>
)}
<div className="flex justify-between items-center">
Expand Down Expand Up @@ -415,6 +428,19 @@ export default function AssignmentActivity() {
}
}}
/>
{!students.isLoading && !students.data?.last_updated && (
<Link
variant="text-light"
classes="flex items-center gap-1"
href="https://web.hypothes.is/help/student-roster-displays-in-the-lms-reporting-dashboards/"
target="_blank"
data-testid="missing-roster-message"
>
<InfoIcon />
Full roster data for this assignment is not available. This only shows
students who have previously launched it.
</Link>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={classnames(
'flex gap-x-1 items-center p-1.5',
'bg-grey-2 text-color-text-light cursor-default',
'first:rounded-l last:rounded-r',
)}
title={absoluteDate && `${taskName} last synced on ${absoluteDate}`}
data-testid="container"
>
<Icon />
<span className="font-bold">{taskName}:</span>
{dateTime ? (
<RelativeTime dateTime={dateTime} withTitle={false} />
) : (
<span data-testid="syncing">syncing…</span>
)}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
})),
}),
);
});

[
Expand Down Expand Up @@ -713,35 +716,30 @@ describe('AssignmentActivity', () => {
{
data: null,
shouldDisplayLastSyncInfo: false,
shouldDisplaySyncing: false,
},
{
data: { status: 'scheduled' },
shouldDisplayLastSyncInfo: true,
shouldDisplaySyncing: true,
},
{
data: { status: 'in_progress' },
shouldDisplayLastSyncInfo: true,
shouldDisplaySyncing: true,
},
{
data: {
status: 'error',
finish_date: '2024-10-02T14:24:15.677924+00:00',
},
shouldDisplayLastSyncInfo: true,
shouldDisplaySyncing: false,
},
{
data: {
status: 'finished',
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,
Expand All @@ -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);
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<LastSyncIndicator
icon={FileCodeIcon}
taskName="task"
dateTime={dateTime}
/>,
).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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,19 @@ describe('RelativeTime', () => {
$imports.$restore();
});

function createComponent() {
return mount(<RelativeTime dateTime={dateTime} />);
function createComponent(props = {}) {
return mount(<RelativeTime dateTime={dateTime} {...props} />);
}

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

0 comments on commit 385d981

Please sign in to comment.