Polish up notification preferences course selector

This change adds grouping (by term) and sorting (by term name, then by
course name) to the course selector on the notification preferences page.

closes LS-2693
flag=notification_settings_course_selector

test plan:
- enable the `notification_settings_course_selector` feature
  - clear your redis cache if necessary
- enroll the current user in several courses, across multiple terms
- visit the account-level notification settings page
- verify that the course selection dropdown groups courses by term
- verify that terms are ordered alphabetically
- verify that courses are ordered alphabetically within their term
- un-publish one of the courses, causing its state to change to something
  other than `available`
- verify it no longer appears in the course selection list

Change-Id: Idfba560934d360649a67caf96e33e1b0ee300a36
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/274998
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Jeff Largent <jeff.largent@instructure.com>
QA-Review: Jeff Largent <jeff.largent@instructure.com>
Product-Review: Peyton Craighill <pcraighill@instructure.com>
This commit is contained in:
Isaac Moore 2021-10-01 13:51:48 -05:00
parent f8fe687c14
commit 4c5506eb4c
6 changed files with 89 additions and 32 deletions

View File

@ -87,12 +87,20 @@ module Types
"only return enrollments for this course",
required: false,
prepare: GraphQLHelpers.relay_or_legacy_id_prepare_func("Course")
argument :current_only, Boolean,
"Whether or not to restrict results to `active` enrollments in `available` courses",
required: false
argument :order_by, [String],
"The fields to order the results by",
required: false
end
def enrollments(course_id: nil)
def enrollments(course_id: nil, current_only: false, order_by: [])
course_ids = [course_id].compact
Loaders::UserCourseEnrollmentLoader.for(
course_ids: course_ids
course_ids: course_ids,
order_by: order_by,
current_only: current_only
).load(object.id).then do |enrollments|
(enrollments || []).select { |enrollment|
object == context[:current_user] ||
@ -316,13 +324,20 @@ end
module Loaders
class UserCourseEnrollmentLoader < Loaders::ForeignKeyLoader
def initialize(course_ids:)
def initialize(course_ids:, order_by: [], current_only: false)
scope = Enrollment.joins(:course)
.where.not(enrollments: { workflow_state: "deleted" })
.where.not(courses: { workflow_state: "deleted" })
scope = if current_only
scope.current.active_by_date
else
scope.where.not(enrollments: { workflow_state: "deleted" })
.where.not(courses: { workflow_state: "deleted" })
end
scope = scope.where(course_id: course_ids) if course_ids.present?
order_by.each { |o| scope = scope.order(o) }
super(scope, :user_id)
end
end

View File

@ -205,6 +205,33 @@ describe Types::UserType do
).to eq [@student.enrollments.first.to_param]
end
it "excludes unavailable courses when currentOnly is true" do
@course1.complete
expect(user_type.resolve("enrollments(currentOnly: true) { _id }")).to eq []
end
it "excludes concluded courses when currentOnly is true" do
@course1.start_at = 2.weeks.ago
@course1.conclude_at = 1.week.ago
@course1.restrict_enrollments_to_course_dates = true
@course1.save!
expect(user_type.resolve("enrollments(currentOnly: true) { _id }")).to eq []
end
it "sorts correctly when orderBy is provided" do
@course2.start_at = 1.week.ago
@course2.save!
expect(user_type.resolve('enrollments(orderBy: ["courses.start_at"]) {
_id
course {
_id
}
}', current_user: @student).map(&:to_i)).to eq [@course2.id, @course1.id]
end
it "doesn't return enrollments for courses the user doesn't have permission for" do
expect(
user_type.resolve(%|enrollments(courseId: "#{@course2.id}") { _id }|)

View File

@ -16,7 +16,7 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {createCache} from '@canvas/apollo'
import {render, screen} from '@testing-library/react'
import {render, screen, within} from '@testing-library/react'
import {MockedProvider} from '@apollo/react-testing'
import mockGraphqlQuery from '@canvas/graphql-query-mock'
import React from 'react'
@ -113,13 +113,17 @@ describe('Notification Settings page', () => {
).toBeInTheDocument()
})
it('does not include inactive enrollments in the dropdown', async () => {
const mocks = (await mockAccountNotificationsQuery({Node: {__typename: 'User'}})).concat(
await mockUserEnrollmentsQuery({Node: {__typename: 'User'}})
)
it('displays course terms in the dropdown', async () => {
const accountMocks = await mockAccountNotificationsQuery({Node: {__typename: 'User'}})
const enrollmentMocks = await mockUserEnrollmentsQuery({Node: {__typename: 'User'}})
enrollmentMocks[0].result.data.legacyNode.enrollments.forEach(e => {
e.course.name = 'Course Name'
e.course.term.name = 'Term Name'
e.course.term.id = '1'
})
const {findByLabelText} = render(
<MockedProvider mocks={mocks} cache={createCache()}>
<MockedProvider mocks={accountMocks.concat(enrollmentMocks)} cache={createCache()}>
<AccountNotificationSettingsView accountId="1" userId="1" courseSelectorEnabled />
</MockedProvider>
)
@ -127,7 +131,11 @@ describe('Notification Settings page', () => {
const dropdown = await findByLabelText('Settings for')
userEvent.click(dropdown)
expect(await screen.queryByText('Hello World')).not.toBeInTheDocument()
const terms = await screen.findAllByText('Term Name')
expect(terms.length).toEqual(1)
const termNameGroupQueries = within(terms[0].parentElement)
expect((await termNameGroupQueries.findAllByText('Course Name')).length).toEqual(2)
})
it('only shows a course with multiple enrollments once', async () => {

View File

@ -22,13 +22,15 @@ export const NOTIFICATION_PREFERENCES_CONTEXT_SELECT_QUERY = gql`
legacyNode(_id: $userId, type: User) {
... on User {
_id
enrollments {
enrollments(currentOnly: true, orderBy: ["courses.name", "courses.id"]) {
course {
id
_id
name
term {
_id
name
}
}
state
type
}
}

View File

@ -22,18 +22,15 @@ import {SimpleSelect} from '@instructure/ui-simple-select'
import {Flex} from '@instructure/ui-flex'
import {EnrollmentShape} from './Shape'
import I18n from 'i18n!*'
import {groupBy, sortBy, sortedUniqBy} from 'lodash'
export default function NotificationPreferencesContextSelect(props) {
const activeUniqueEnrollments = useMemo(() => {
const courseIds = new Set()
return (
props.enrollments?.filter(e => {
if (e.state !== 'active') return false
const duplicate = courseIds.has(e.course.id)
courseIds.add(e.course.id)
return !duplicate
}) || []
)
const sortedGroupedUniqueEnrollments = useMemo(() => {
if (!props.enrollments) return []
const uniqueEnrollments = sortedUniqBy(props.enrollments, 'course._id')
const groupedEnrollments = Object.entries(groupBy(uniqueEnrollments, 'course.term._id'))
return sortBy(groupedEnrollments, [([_, e]) => e[0].course.term.name])
}, [props.enrollments])
const handleChange = useCallback(
@ -53,10 +50,14 @@ export default function NotificationPreferencesContextSelect(props) {
<SimpleSelect.Option id="account" value="account">
{I18n.t('Account')}
</SimpleSelect.Option>
{activeUniqueEnrollments.map(e => (
<SimpleSelect.Option key={e.course.id} id={e.course.id} value={e.course._id}>
{e.course.name}
</SimpleSelect.Option>
{sortedGroupedUniqueEnrollments.map(([termId, enrollments]) => (
<SimpleSelect.Group renderLabel={enrollments[0].course.term.name} key={termId}>
{enrollments.map(e => (
<SimpleSelect.Option key={e.course._id} id={e.course._id} value={e.course._id}>
{e.course.name}
</SimpleSelect.Option>
))}
</SimpleSelect.Group>
))}
</SimpleSelect>
</Flex>

View File

@ -44,14 +44,18 @@ export const NotificationPreferencesShape = shape({
channels: arrayOf(ChannelShape)
})
const CourseShape = shape({
id: string.isRequired,
const TermShape = shape({
_id: string.isRequired,
name: string.isRequired
})
const CourseShape = shape({
_id: string.isRequired,
name: string.isRequired,
term: TermShape.isRequired
})
export const EnrollmentShape = shape({
course: CourseShape.isRequired,
state: string.isRequired,
type: string.isRequired
})