Skip to content

Commit

Permalink
Support single active items in dashboard filters (#6532)
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya authored Aug 7, 2024
1 parent c9e6018 commit a3e3622
Show file tree
Hide file tree
Showing 6 changed files with 282 additions and 121 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,18 @@ export default function AllCoursesActivity() {
<div className="flex flex-col gap-y-5">
<h2 className="text-lg text-brand font-semibold">All courses</h2>
<DashboardActivityFilters
selectedStudentIds={studentIds}
onStudentsChange={studentIds => updateFilters({ studentIds })}
selectedAssignmentIds={assignmentIds}
onAssignmentsChange={assignmentIds => updateFilters({ assignmentIds })}
selectedCourseIds={courseIds}
onCoursesChange={courseIds => updateFilters({ courseIds })}
students={{
selectedIds: studentIds,
onChange: studentIds => updateFilters({ studentIds }),
}}
assignments={{
selectedIds: assignmentIds,
onChange: assignmentIds => updateFilters({ assignmentIds }),
}}
courses={{
selectedIds: courseIds,
onChange: courseIds => updateFilters({ courseIds }),
}}
onClearSelection={() =>
updateFilters({ studentIds: [], assignmentIds: [], courseIds: [] })
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { AssignmentsMetricsResponse, Course } from '../../api-types';
import { useConfig } from '../../config';
import { useAPIFetch } from '../../utils/api';
import { useDashboardFilters } from '../../utils/dashboard/hooks';
import { assignmentURL, courseURL } from '../../utils/dashboard/navigation';
import { assignmentURL } from '../../utils/dashboard/navigation';
import { useDocumentTitle } from '../../utils/hooks';
import { replaceURLParams } from '../../utils/url';
import DashboardActivityFilters from './DashboardActivityFilters';
Expand Down Expand Up @@ -90,30 +90,25 @@ export default function CourseActivity() {
{course.data && title}
</h2>
</div>
<DashboardActivityFilters
selectedCourseIds={[courseId]}
onCoursesChange={newCourseIds => {
// When no courses are selected (which happens if either "All courses" is
// selected or the active course is deselected), navigate to "All courses"
// section and propagate the rest of the filters.
if (newCourseIds.length === 0) {
navigate(`?${search}`);
}

// When a course other than the "active" one (the one represented
// in the URL) is selected, navigate to that course and propagate
// the rest of the filters.
const firstDifferentCourse = newCourseIds.find(c => c !== courseId);
if (firstDifferentCourse) {
navigate(`${courseURL(firstDifferentCourse)}?${search}`);
}
}}
selectedAssignmentIds={assignmentIds}
onAssignmentsChange={assignmentIds => updateFilters({ assignmentIds })}
selectedStudentIds={studentIds}
onStudentsChange={studentIds => updateFilters({ studentIds })}
onClearSelection={hasSelection ? onClearSelection : undefined}
/>
{course.data && (
<DashboardActivityFilters
courses={{
activeItem: course.data,
// When selected course is cleared, navigate to the home page,
// AKA "All courses"
onClear: () => navigate(search.length > 0 ? `?${search}` : ''),
}}
assignments={{
selectedIds: assignmentIds,
onChange: assignmentIds => updateFilters({ assignmentIds }),
}}
students={{
selectedIds: studentIds,
onChange: studentIds => updateFilters({ studentIds }),
}}
onClearSelection={hasSelection ? onClearSelection : undefined}
/>
)}
<OrderableActivityTable
loading={assignments.isLoading}
title={course.isLoading ? 'Loading...' : title}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,29 @@ import type {
import { useConfig } from '../../config';
import { usePaginatedAPIFetch } from '../../utils/api';

/**
* Allow the user to select items from a paginated list of all items matching
* the current filters.
* The set of currently selected items are maintained by the parent component.
*/
export type ActivityFilterSelection = {
selectedIds: string[];
onChange: (newSelectedIds: string[]) => void;
};

/**
* Display two items in the dropdown, one for an active / selected item and one
* to clear the selection.
*/
export type ActivityFilterItem<T extends Course | Assignment> = {
activeItem: T;
onClear: () => void;
};

export type DashboardActivityFiltersProps = {
selectedCourseIds: string[];
onCoursesChange: (newCourseIds: string[]) => void;
selectedAssignmentIds: string[];
onAssignmentsChange: (newAssignmentIds: string[]) => void;
selectedStudentIds: string[];
onStudentsChange: (newStudentIds: string[]) => void;
courses: ActivityFilterSelection | ActivityFilterItem<Course>;
assignments: ActivityFilterSelection | ActivityFilterItem<Assignment>;
students: ActivityFilterSelection;
onClearSelection?: () => void;
};

Expand All @@ -46,49 +62,71 @@ function elementScrollIsAtBottom(element: HTMLElement, offset = 20): boolean {
* filter dashboard activity metrics.
*/
export default function DashboardActivityFilters({
selectedCourseIds,
onCoursesChange,
selectedAssignmentIds,
onAssignmentsChange,
selectedStudentIds,
onStudentsChange,
courses,
assignments,
students,
onClearSelection,
}: DashboardActivityFiltersProps) {
const hasSelection =
selectedStudentIds.length > 0 ||
selectedAssignmentIds.length > 0 ||
selectedCourseIds.length > 0;
const { dashboard } = useConfig(['dashboard']);
const { organizationPublicId } = useParams();
const { routes } = dashboard;

const [selectedCourseIds, activeCourse] = useMemo(() => {
const isSelection = 'selectedIds' in courses;

return isSelection
? [courses.selectedIds, null]
: [[`${courses.activeItem.id}`], courses.activeItem];
}, [courses]);
const [selectedAssignmentIds, activeAssignment] = useMemo(() => {
const isSelection = 'selectedIds' in assignments;
return isSelection
? [assignments.selectedIds, null]
: [[`${assignments.activeItem.id}`], assignments.activeItem];
}, [assignments]);

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

const coursesFilters = useMemo(
() => ({
h_userid: selectedStudentIds,
h_userid: students.selectedIds,
assignment_id: selectedAssignmentIds,
public_id: organizationPublicId,
}),
[organizationPublicId, selectedAssignmentIds, selectedStudentIds],
[organizationPublicId, selectedAssignmentIds, students.selectedIds],
);
const coursesResult = usePaginatedAPIFetch<
'courses',
Course[],
CoursesResponse
>('courses', routes.courses, coursesFilters);
>(
'courses',
// If an active course was provided, do not load list of courses
activeCourse ? null : routes.courses,
coursesFilters,
);

const assignmentFilters = useMemo(
() => ({
h_userid: selectedStudentIds,
h_userid: students.selectedIds,
course_id: selectedCourseIds,
public_id: organizationPublicId,
}),
[organizationPublicId, selectedCourseIds, selectedStudentIds],
[organizationPublicId, selectedCourseIds, students.selectedIds],
);
const assignmentsResults = usePaginatedAPIFetch<
'assignments',
Assignment[],
AssignmentsResponse
>('assignments', routes.assignments, assignmentFilters);
>(
'assignments',
// If an active assignment was provided, do not load list of assignments
activeAssignment ? null : routes.assignments,
assignmentFilters,
);

const studentsFilters = useMemo(
() => ({
Expand Down Expand Up @@ -120,11 +158,17 @@ export default function DashboardActivityFilters({
<MultiSelect
disabled={coursesResult.isLoadingFirstPage}
value={selectedCourseIds}
onChange={onCoursesChange}
onChange={newCourseIds =>
'onChange' in courses
? courses.onChange(newCourseIds)
: courses.onClear()
}
aria-label="Select courses"
containerClasses="!w-auto min-w-[180px]"
buttonContent={
coursesResult.isLoadingFirstPage ? (
activeCourse ? (
activeCourse.title
) : coursesResult.isLoadingFirstPage ? (
<>...</>
) : selectedCourseIds.length === 0 ? (
<>All courses</>
Expand All @@ -143,27 +187,44 @@ export default function DashboardActivityFilters({
}}
>
<MultiSelect.Option value={undefined}>All courses</MultiSelect.Option>
{coursesResult.data?.map(course => (
<MultiSelect.Option key={course.id} value={`${course.id}`}>
{course.title}
</MultiSelect.Option>
))}
{coursesResult.isLoading && !coursesResult.isLoadingFirstPage && (
<MultiSelect.Option disabled value={undefined}>
<span className="italic" data-testid="loading-more-courses">
Loading more courses...
</span>
{activeCourse ? (
<MultiSelect.Option
key={activeCourse.id}
value={`${activeCourse.id}`}
>
{activeCourse.title}
</MultiSelect.Option>
) : (
<>
{coursesResult.data?.map(course => (
<MultiSelect.Option key={course.id} value={`${course.id}`}>
{course.title}
</MultiSelect.Option>
))}
{coursesResult.isLoading && !coursesResult.isLoadingFirstPage && (
<MultiSelect.Option disabled value={undefined}>
<span className="italic" data-testid="loading-more-courses">
Loading more courses...
</span>
</MultiSelect.Option>
)}
</>
)}
</MultiSelect>
<MultiSelect
disabled={assignmentsResults.isLoadingFirstPage}
value={selectedAssignmentIds}
onChange={onAssignmentsChange}
onChange={newAssignmentIds =>
'onChange' in assignments
? assignments.onChange(newAssignmentIds)
: assignments.onClear()
}
aria-label="Select assignments"
containerClasses="!w-auto min-w-[180px]"
buttonContent={
assignmentsResults.isLoadingFirstPage ? (
activeAssignment ? (
activeAssignment.title
) : assignmentsResults.isLoadingFirstPage ? (
<>...</>
) : selectedAssignmentIds.length === 0 ? (
<>All assignments</>
Expand All @@ -185,37 +246,54 @@ export default function DashboardActivityFilters({
<MultiSelect.Option value={undefined}>
All assignments
</MultiSelect.Option>
{assignmentsResults.data?.map(assignment => (
<MultiSelect.Option key={assignment.id} value={`${assignment.id}`}>
{assignment.title}
{activeAssignment ? (
<MultiSelect.Option
key={activeAssignment.id}
value={`${activeAssignment.id}`}
>
{activeAssignment.title}
</MultiSelect.Option>
))}
{assignmentsResults.isLoading &&
!assignmentsResults.isLoadingFirstPage && (
<MultiSelect.Option disabled value={undefined}>
<span className="italic" data-testid="loading-more-assignments">
Loading more assignments...
</span>
</MultiSelect.Option>
)}
) : (
<>
{assignmentsResults.data?.map(assignment => (
<MultiSelect.Option
key={assignment.id}
value={`${assignment.id}`}
>
{assignment.title}
</MultiSelect.Option>
))}
{assignmentsResults.isLoading &&
!assignmentsResults.isLoadingFirstPage && (
<MultiSelect.Option disabled value={undefined}>
<span
className="italic"
data-testid="loading-more-assignments"
>
Loading more assignments...
</span>
</MultiSelect.Option>
)}
</>
)}
</MultiSelect>
<MultiSelect
disabled={studentsResult.isLoadingFirstPage}
value={selectedStudentIds}
onChange={onStudentsChange}
value={students.selectedIds}
onChange={students.onChange}
aria-label="Select students"
containerClasses="!w-auto min-w-[180px]"
buttonContent={
studentsResult.isLoadingFirstPage ? (
<>...</>
) : selectedStudentIds.length === 0 ? (
) : students.selectedIds.length === 0 ? (
<>All students</>
) : selectedStudentIds.length === 1 ? (
) : students.selectedIds.length === 1 ? (
studentsWithFallbackName?.find(
s => s.h_userid === selectedStudentIds[0],
s => s.h_userid === students.selectedIds[0],
)?.display_name ?? '1 student'
) : (
<>{selectedStudentIds.length} students</>
<>{students.selectedIds.length} students</>
)
}
data-testid="students-select"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,32 +169,32 @@ describe('AllCoursesActivity', () => {
it('allows metrics to be filtered', () => {
const wrapper = createComponent();
const filters = wrapper.find('DashboardActivityFilters');
const updateFilter = (changeCallback, arg) => {
act(() => filters.prop(changeCallback)(arg));
const updateFilter = (prop, arg) => {
act(() => filters.prop(prop).onChange(arg));
wrapper.update();
};
const assertCoursesFetched = query =>
assert.calledWith(fakeUseAPIFetch.lastCall, sinon.match.string, query);

// Every time the filters callbacks are invoked, the component will
// re-render and re-fetch metrics with updated query.
updateFilter('onStudentsChange', ['123', '456']);
updateFilter('students', ['123', '456']);
assertCoursesFetched({
h_userid: ['123', '456'],
assignment_id: [],
course_id: [],
public_id: undefined,
});

updateFilter('onAssignmentsChange', ['1', '2']);
updateFilter('assignments', ['1', '2']);
assertCoursesFetched({
h_userid: [],
assignment_id: ['1', '2'],
course_id: [],
public_id: undefined,
});

updateFilter('onCoursesChange', ['3', '8', '9']);
updateFilter('courses', ['3', '8', '9']);
assertCoursesFetched({
h_userid: [],
assignment_id: [],
Expand Down
Loading

0 comments on commit a3e3622

Please sign in to comment.