Skip to content

Commit

Permalink
Add filters to dashboard assignment section (#6538)
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed Aug 8, 2024
1 parent cc6a420 commit 012f998
Show file tree
Hide file tree
Showing 3 changed files with 188 additions and 14 deletions.
5 changes: 4 additions & 1 deletion lms/static/scripts/frontend_apps/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,11 @@ export type StudentsMetricsResponse = {
students: StudentWithMetrics[];
};

export type AssignmentWithMetrics = Assignment & {
export type AssignmentWithCourse = Assignment & {
course: Course;
};

export type AssignmentWithMetrics = AssignmentWithCourse & {
annotation_metrics: AnnotationMetrics;
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { useMemo } from 'preact/hooks';
import { useParams } from 'wouter-preact';
import { useLocation, useParams, useSearch } from 'wouter-preact';

import type {
AssignmentWithMetrics,
AssignmentWithCourse,
StudentsMetricsResponse,
} from '../../api-types';
import { useConfig } from '../../config';
import { urlPath, useAPIFetch } 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 { recordToQueryString, replaceURLParams } from '../../utils/url';
import DashboardActivityFilters from './DashboardActivityFilters';
import DashboardBreadcrumbs from './DashboardBreadcrumbs';
import FormattedDate from './FormattedDate';
import OrderableActivityTable from './OrderableActivityTable';
Expand All @@ -31,12 +34,20 @@ export default function AssignmentActivity() {
assignmentId: string;
organizationPublicId?: string;
}>();
const assignment = useAPIFetch<AssignmentWithMetrics>(

const { filters, updateFilters } = useDashboardFilters();
const { studentIds } = filters;
const search = useSearch();
const [, navigate] = useLocation();

const assignment = useAPIFetch<AssignmentWithCourse>(
replaceURLParams(routes.assignment, { assignment_id: assignmentId }),
);

const students = useAPIFetch<StudentsMetricsResponse>(
routes.students_metrics,
{
h_userid: studentIds,
assignment_id: assignmentId,
public_id: organizationPublicId,
},
Expand Down Expand Up @@ -78,6 +89,40 @@ 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(
recordToQueryString({
student_id: studentIds,
assignment_id: assignmentId,
}),
),
}}
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 }),
}}
onClearSelection={
studentIds.length > 0
? () => updateFilters({ studentIds: [] })
: undefined
}
/>
)}
<OrderableActivityTable
loading={students.isLoading}
title={assignment.isLoading ? 'Loading...' : title}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
mockImportedComponents,
} from '@hypothesis/frontend-testing';
import { mount } from 'enzyme';
import { act } from 'preact/test-utils';
import sinon from 'sinon';

import { Config } from '../../../config';
Expand Down Expand Up @@ -36,22 +37,28 @@ describe('AssignmentActivity', () => {
},
},
];
const activeAssignment = {
id: 123,
title: 'The title',
course: {
id: 12,
title: 'The course',
},
};

let fakeUseAPIFetch;
let fakeNavigate;
let fakeUseSearch;
let fakeConfig;
let wrappers;

beforeEach(() => {
fakeUseAPIFetch = sinon.stub().callsFake(url => ({
isLoading: false,
data: url.endsWith('metrics')
? { students }
: {
title: 'The title',
course: {
title: 'The course',
},
},
data: url.endsWith('metrics') ? { students } : activeAssignment,
}));
fakeNavigate = sinon.stub();
fakeUseSearch = sinon.stub().returns('current=query');
fakeConfig = {
dashboard: {
routes: {
Expand All @@ -61,6 +68,8 @@ describe('AssignmentActivity', () => {
},
};

wrappers = [];

$imports.$mock(mockImportedComponents());
$imports.$restore({
// Do not mock FormattedDate, for consistency when checking
Expand All @@ -71,19 +80,28 @@ describe('AssignmentActivity', () => {
'../../utils/api': {
useAPIFetch: fakeUseAPIFetch,
},
'wouter-preact': {
useParams: sinon.stub().returns({ assignmentId: '123' }),
useSearch: fakeUseSearch,
useLocation: sinon.stub().returns(['', fakeNavigate]),
},
});
});

afterEach(() => {
wrappers.forEach(wrapper => wrapper.unmount());
$imports.$restore();
});

function createComponent() {
return mount(
const wrapper = mount(
<Config.Provider value={fakeConfig}>
<AssignmentActivity />
</Config.Provider>,
);
wrappers.push(wrapper);

return wrapper;
}

it('shows loading indicators while data is loading', () => {
Expand Down Expand Up @@ -171,6 +189,114 @@ describe('AssignmentActivity', () => {
});
});

context('when filters are set', () => {
function setCurrentURL(url) {
history.replaceState(null, '', url);
}

beforeEach(() => {
setCurrentURL('?');
});

it('initializes expected filters', () => {
setCurrentURL('?student_id=1&student_id=2');

const wrapper = createComponent();
const filters = wrapper.find('DashboardActivityFilters');

// Active course and assignment are set from the route
assert.deepEqual(
filters.prop('courses').activeItem,
activeAssignment.course,
);
assert.deepEqual(
filters.prop('assignments').activeItem,
activeAssignment,
);
// Students are set from the query
assert.deepEqual(filters.prop('students').selectedIds, ['1', '2']);

// Selected filters are propagated when loading assignment metrics
assert.calledWith(fakeUseAPIFetch.lastCall, sinon.match.string, {
h_userid: ['1', '2'],
assignment_id: '123',
public_id: undefined,
});
});

[
{ query: '', expectedHasSelection: false },
{ query: '?foo=bar', expectedHasSelection: false },
{ query: '?student_id=3', expectedHasSelection: true },
{ query: '?student_id=1&student_id=3', expectedHasSelection: true },
].forEach(({ query, expectedHasSelection }) => {
it('has `onClearSelection` if at least one student is selected', () => {
setCurrentURL(query);

const wrapper = createComponent();
const filters = wrapper.find('DashboardActivityFilters');

assert.equal(!!filters.prop('onClearSelection'), expectedHasSelection);
});
});

it('updates query when selected students change', () => {
const wrapper = createComponent();
const filters = wrapper.find('DashboardActivityFilters');

act(() => filters.prop('students').onChange(['3', '7']));

assert.equal(location.search, '?student_id=3&student_id=7');
});

it('clears selected students on clear selection', () => {
setCurrentURL('?foo=bar&student_id=8&student_id=20&student_id=32');

const wrapper = createComponent();
const filters = wrapper.find('DashboardActivityFilters');

act(() => filters.props().onClearSelection());

assert.equal(location.search, '?foo=bar');
});

it('navigates to home page preserving assignment and students when course is cleared', () => {
setCurrentURL('?student_id=8&student_id=20&student_id=32');

const wrapper = createComponent();
const filters = wrapper.find('DashboardActivityFilters');

act(() => filters.prop('courses').onClear());

assert.calledWith(
fakeNavigate,
'?student_id=8&student_id=20&student_id=32&assignment_id=123',
);
});

[
{
currentSearch: 'current=query',
expectedDestination: '/courses/12?current=query',
},
{
currentSearch: '',
expectedDestination: '/courses/12',
},
].forEach(({ currentSearch, expectedDestination }) => {
it('navigates to course preserving current query when selected assignment is cleared', () => {
fakeUseSearch.returns(currentSearch);

const wrapper = createComponent();
const filters = wrapper.find('DashboardActivityFilters');

act(() => filters.prop('assignments').onClear());

assert.calledWith(fakeNavigate, expectedDestination);
});
});
});

it(
'should pass a11y checks',
checkAccessibility({
Expand Down

0 comments on commit 012f998

Please sign in to comment.