Skip to content

Commit

Permalink
Add segments filter in assignment view
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed Sep 18, 2024
1 parent 95c279a commit 78007d3
Show file tree
Hide file tree
Showing 6 changed files with 288 additions and 50 deletions.
31 changes: 24 additions & 7 deletions lms/static/scripts/frontend_apps/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,16 +230,33 @@ export type AutoGradingConfig = {
};

/**
* Response for `/api/dashboard/assignments/{assignment_id}` call.
* Represents an assignment group or section, which maps to an h group.
*/
export type AssignmentDetails = AssignmentWithCourse & {
/**
* If defined, it indicates this assignment was configured with auto grading
* enabled.
*/
auto_grading_config?: AutoGradingConfig;
export type AssignmentSegment = {
h_authority_provided_id: string;
name: string;
};

/**
* Assignments will have either sections, groups or none of them, but never
* both.
*/
type WithSegments =
| { sections?: AssignmentSegment[] }
| { groups?: AssignmentSegment[] };

/**
* Response for `/api/dashboard/assignments/{assignment_id}` call.
*/
export type AssignmentDetails = AssignmentWithCourse &
WithSegments & {
/**
* If defined, it indicates this assignment was configured with auto grading
* enabled.
*/
auto_grading_config?: AutoGradingConfig;
};

export type AssignmentWithMetrics = AssignmentWithCourse & {
annotation_metrics: AnnotationMetrics;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useDashboardFilters } from '../../utils/dashboard/hooks';
import { courseURL } from '../../utils/dashboard/navigation';
import { useDocumentTitle } from '../../utils/hooks';
import { replaceURLParams } from '../../utils/url';
import type { DashboardActivityFiltersProps } from './DashboardActivityFilters';
import DashboardActivityFilters from './DashboardActivityFilters';
import DashboardBreadcrumbs from './DashboardBreadcrumbs';
import FormattedDate from './FormattedDate';
Expand Down Expand Up @@ -40,19 +41,48 @@ export default function AssignmentActivity() {
}>();

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

const assignment = useAPIFetch<AssignmentDetails>(
replaceURLParams(routes.assignment, { assignment_id: assignmentId }),
);
const autoGradingEnabled = !!assignment.data?.auto_grading_config;
const segments = useMemo((): DashboardActivityFiltersProps['segments'] => {
const { data } = assignment;
// For now, we want to display the segments filter only for auto-grading
// assignments, but this will eventually change
if (!data || !autoGradingEnabled) {
return undefined;
}

const hasSections = 'sections' in data;
const entries = hasSections
? data.sections
: 'groups' in data
? data.groups
: undefined;

// If the assignment doesn't have neither sections nor groups, we won't
// display the segments filter
if (!entries || entries.length === 0) {
return undefined;
}

return {
type: hasSections ? 'sections' : 'groups',
entries,
selectedIds: segmentIds,
onChange: segmentIds => updateFilters({ segmentIds }),
};
}, [assignment, autoGradingEnabled, segmentIds, updateFilters]);

const students = useAPIFetch<StudentsMetricsResponse>(
routes.students_metrics,
{
h_userid: studentIds,
segment_authority_provided_id: segmentIds,
assignment_id: assignmentId,
org_public_id: organizationPublicId,
},
Expand Down Expand Up @@ -160,9 +190,11 @@ export default function AssignmentActivity() {
selectedIds: studentIds,
onChange: studentIds => updateFilters({ studentIds }),
}}
segments={segments}
onClearSelection={
studentIds.length > 0
? () => updateFilters({ studentIds: [] })
studentIds.length > 0 ||
(segments && segments.selectedIds.length > 0)
? () => updateFilters({ studentIds: [], segmentIds: [] })
: undefined
}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useParams } from 'wouter-preact';

import type {
Assignment,
AssignmentSegment,
AssignmentsResponse,
Course,
CoursesResponse,
Expand Down Expand Up @@ -53,6 +54,19 @@ export type ActivityFilterItem<T extends Course | Assignment> = {
export type DashboardActivityFiltersProps = {
courses: ActivityFilterSelection | ActivityFilterItem<Course>;
assignments: ActivityFilterSelection | ActivityFilterItem<Assignment>;

/**
* The segments filter is relevant only when listing students, so not
* providing this will prevent that filter to be rendered or taken into
* consideration in any way.
* As opposed to other filters, the list of entries is not loaded separately,
* but provided here.
*/
segments?: ActivityFilterSelection & {
type: 'groups' | 'sections';
entries: AssignmentSegment[];
};

students: ActivityFilterSelection;
onClearSelection?: () => void;
};
Expand Down Expand Up @@ -113,6 +127,7 @@ function StudentOption({
export default function DashboardActivityFilters({
courses,
assignments,
segments,
students,
onClearSelection,
}: DashboardActivityFiltersProps) {
Expand All @@ -133,10 +148,15 @@ export default function DashboardActivityFilters({
? [assignments.selectedIds, null]
: [[`${assignments.activeItem.id}`], assignments.activeItem];
}, [assignments]);
const selectedSegmentIds = useMemo(
() => segments?.selectedIds ?? [],
[segments?.selectedIds],
);

const hasSelection =
students.selectedIds.length > 0 ||
selectedAssignmentIds.length > 0 ||
selectedSegmentIds.length > 0 ||
selectedCourseIds.length > 0;

const coursesFilters = useMemo(
Expand Down Expand Up @@ -180,10 +200,16 @@ export default function DashboardActivityFilters({
const studentsFilters = useMemo(
() => ({
assignment_id: selectedAssignmentIds,
segment_authority_provided_id: selectedSegmentIds,
course_id: selectedCourseIds,
org_public_id: organizationPublicId,
}),
[organizationPublicId, selectedAssignmentIds, selectedCourseIds],
[
organizationPublicId,
selectedAssignmentIds,
selectedCourseIds,
selectedSegmentIds,
],
);
const studentsResult = usePaginatedAPIFetch<
'students',
Expand Down Expand Up @@ -262,6 +288,39 @@ export default function DashboardActivityFilters({
/>
)}
/>
{segments && (
<MultiSelect
aria-label={`Select ${segments.type}`}
containerClasses="!w-auto min-w-[180px]"
value={segments.selectedIds}
onChange={newSegmentIds => segments.onChange(newSegmentIds)}
buttonContent={
segments.selectedIds.length === 0 ? (
<>All {segments.type}</>
) : segments.selectedIds.length === 1 ? (
segments.entries.find(
s => s.h_authority_provided_id === segments.selectedIds[0],
)?.name ?? `1 ${segments.type.slice(0, -1)}`
) : (
<>
{segments.selectedIds.length} {segments.type}
</>
)
}
>
<MultiSelect.Option value={undefined}>
All {segments.type}
</MultiSelect.Option>
{segments.entries.map(entry => (
<MultiSelect.Option
key={entry.h_authority_provided_id}
value={entry.h_authority_provided_id}
>
{entry.name}
</MultiSelect.Option>
))}
</MultiSelect>
)}
<PaginatedMultiSelect
entity="students"
data-testid="students-select"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,13 @@ describe('AssignmentActivity', () => {
}));
}

function setCurrentURL(url) {
history.replaceState(null, '', url);
}

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

fakeUseAPIFetch = sinon.stub();
setUpFakeUseAPIFetch();

Expand Down Expand Up @@ -196,37 +202,45 @@ 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');
[
{
extraQuery: '',
expectedSegments: [],
},
{
extraQuery: '&segment_id=foo',
expectedSegments: ['foo'],
},
{
extraQuery: '&segment_id=bar&segment_id=baz',
expectedSegments: ['bar', 'baz'],
},
].forEach(({ expectedSegments, extraQuery }) => {
it('initializes expected filters', () => {
setCurrentURL(`?student_id=1&student_id=2${extraQuery}`);

const wrapper = createComponent();
const filters = wrapper.find('DashboardActivityFilters');
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',
org_public_id: undefined,
// 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'],
segment_authority_provided_id: expectedSegments,
assignment_id: '123',
org_public_id: undefined,
});
});
});

Expand Down Expand Up @@ -255,8 +269,10 @@ describe('AssignmentActivity', () => {
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');
it('clears selected students and segments on clear selection', () => {
setCurrentURL(
'?foo=bar&student_id=8&student_id=20&student_id=32&segment_id=foo',
);

const wrapper = createComponent();
const filters = wrapper.find('DashboardActivityFilters');
Expand Down Expand Up @@ -392,6 +408,70 @@ describe('AssignmentActivity', () => {
);
});

context('when assignment has segments', () => {
it('sets no segments when auto-grading is not enabled', () => {
const wrapper = createComponent();
const filters = wrapper.find('DashboardActivityFilters');

assert.isUndefined(filters.prop('segments'));
});

[{}, { sections: [] }, { groups: [] }].forEach(assignmentExtra => {
it('sets no segments when assignment has no groups nor sections', () => {
setUpFakeUseAPIFetch({
...activeAssignment,
...assignmentExtra,
auto_grading_config: {},
});

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

assert.isUndefined(filters.prop('segments'));
});
});

[
{
assignmentExtra: { sections: [{}, {}] },
expectedType: 'sections',
},
{
assignmentExtra: { groups: [{}, {}, {}] },
expectedType: 'groups',
},
].forEach(({ assignmentExtra, expectedType }) => {
it('sets the right type of segment based on assignment shape', () => {
setUpFakeUseAPIFetch({
...activeAssignment,
...assignmentExtra,
auto_grading_config: {},
});

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

assert.equal(segments.type, expectedType);
assert.equal(segments.entries, assignmentExtra[expectedType]);
});
});

it('filters have `onClearSelection` if at least one segment is selected', () => {
setCurrentURL('?segment_id=foo');
setUpFakeUseAPIFetch({
...activeAssignment,
groups: [{}, {}],
auto_grading_config: {},
});

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

assert.isDefined(filters.prop('onClearSelection'));
});
});

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

0 comments on commit 78007d3

Please sign in to comment.