Support fetching sections and students

Fetch sections and students and display them as options when choosing
Custom access.

closes LF-641, LF-642
flag= differentiated_modules

Test plan
- In a course with several sections and students, go to the modules
page.
- Open the module menu.
- Click on the “Assign to” option.
- Click on the “Custom Access” option.
- Click on the “Assign to” multi-select.
- Expect to see course sections and students as available options.
- Try to filter options that are not listed.
- Expect the results to be searched in both endpoints (sections
and students)
- Select some options.
- Expect multiple selection to be allowed without duplicate values.
- Click on “Clear All”.
- Expect the selection to reset.

Change-Id: I285d2b9159878544764da783aed803c633122328
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/327852
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Product-Review: Jonathan Guardado <jonathan.guardado@instructure.com>
Reviewed-by: Sarah Gerard <sarah.gerard@instructure.com>
QA-Review: Sarah Gerard <sarah.gerard@instructure.com>
QA-Review: Robin Kuss <rkuss@instructure.com>
This commit is contained in:
jonathan 2023-09-14 19:21:16 -06:00 committed by Jonathan Guardado
parent d57e3d3101
commit fe82042d5e
10 changed files with 260 additions and 39 deletions

View File

@ -2531,6 +2531,7 @@ $(document).ready(function () {
initialTab='assign-to'
assignOnly={false}
moduleElement={moduleElement}
courseId={ENV.COURSE_ID}
{...settingsProps}
/>,
document.getElementById('differentiated-modules-mount-point')

View File

@ -32,6 +32,7 @@ const I18n = useI18nScope('differentiated_modules')
const {Item: FlexItem} = Flex as any
export interface AssignToPanelProps {
courseId: string
height: string
onDismiss: () => void
}
@ -56,7 +57,7 @@ const OPTIONS: Option[] = [
},
]
export default function AssignToPanel({height, onDismiss}: AssignToPanelProps) {
export default function AssignToPanel({courseId, height, onDismiss}: AssignToPanelProps) {
const [selectedOption, setSelectedOption] = useState<string>(OPTIONS[0].value)
return (
@ -92,7 +93,7 @@ export default function AssignToPanel({height, onDismiss}: AssignToPanelProps) {
</View>
{option.value === OPTIONS[1].value && selectedOption === OPTIONS[1].value && (
<View as="div" margin="small large none none">
<AssigneeSelector />
<AssigneeSelector courseId={courseId} />
</View>
)}
</FlexItem>

View File

@ -17,37 +17,100 @@
*/
import CanvasMultiSelect from '@canvas/multi-select/react'
import React, {ReactElement, useState} from 'react'
import React, {ReactElement, useEffect, useState} from 'react'
import {useScope as useI18nScope} from '@canvas/i18n'
import {Link} from '@instructure/ui-link'
import {View} from '@instructure/ui-view'
import doFetchApi from '@canvas/do-fetch-api-effect'
import {showFlashError} from '@canvas/alerts/react/FlashAlert'
import {debounce, uniqBy} from 'lodash'
import {ScreenReaderContent} from '@instructure/ui-a11y-content'
const {Option: CanvasMultiSelectOption} = CanvasMultiSelect as any
const I18n = useI18nScope('differentiated_modules')
interface Props {
courseId: string
}
interface Option {
id: string
value: string
group: string
}
export const OPTIONS = [
{id: '1', value: 'Section A'},
{id: '2', value: 'Section B'},
{id: '3', value: 'Section C'},
{id: '4', value: 'Section D'},
{id: '5', value: 'Section E'},
{id: '6', value: 'Section F'},
]
const AssigneeSelector = () => {
const AssigneeSelector = ({courseId}: Props) => {
const [selectedAssignees, setSelectedAssignees] = useState<Option[]>([])
const [searchTerm, setSearchTerm] = useState('')
const [options, setOptions] = useState<Option[]>([])
const [isLoading, setIsLoading] = useState(false)
const handleChange = (newSelected: string[]) => {
const newSelectedSet = new Set(newSelected)
const selected = OPTIONS.filter(option => newSelectedSet.has(option.id))
const selected = options.filter(option => newSelectedSet.has(option.id))
setSelectedAssignees(selected)
}
const handleInputChange = (value: string) => {
debounce(() => setSearchTerm(value), 300)()
}
useEffect(() => {
const params: Record<string, string> = {}
const shouldSearchTerm = searchTerm.length > 2
if (shouldSearchTerm || searchTerm === '') {
setIsLoading(true)
if (shouldSearchTerm) {
params.search_term = searchTerm
}
const fetchSections = doFetchApi({
path: `/api/v1/courses/${courseId}/sections`,
params,
})
const fetchStudents = doFetchApi({
path: `/api/v1/courses/${courseId}/users`,
params: {...params, enrollment_type: 'student'},
})
Promise.allSettled([fetchSections, fetchStudents])
.then(results => {
const sectionsResult = results[0]
const studentsResult = results[1]
let sectionsParsedResult = []
let studentsParsedResult = []
if (sectionsResult.status === 'fulfilled') {
sectionsParsedResult = sectionsResult.value.json.map(({id, name}: any) => ({
id: `section-${id}`,
value: name,
group: I18n.t('Sections'),
}))
} else {
showFlashError(I18n.t('Failed to load sections data'))(sectionsResult.reason)
}
if (studentsResult.status === 'fulfilled') {
studentsParsedResult = studentsResult.value.json.map(({id, name}: any) => ({
id: `student-${id}`,
value: name,
group: I18n.t('Students'),
}))
} else {
showFlashError(I18n.t('Failed to load students data'))(studentsResult.reason)
}
const newOptions = uniqBy(
[...options, ...sectionsParsedResult, ...studentsParsedResult],
'id'
)
setOptions(newOptions)
setIsLoading(false)
})
.catch(e => showFlashError(I18n.t('Something went wrong while fetching data'))(e))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [courseId, searchTerm])
return (
<>
<CanvasMultiSelect
@ -57,6 +120,9 @@ const AssigneeSelector = () => {
selectedOptionIds={selectedAssignees.map(val => val.id)}
onChange={handleChange}
renderAfterInput={<></>}
customOnInputChange={handleInputChange}
visibleOptionsCount={14}
isLoading={isLoading}
customRenderBeforeInput={tags =>
tags?.map((tag: ReactElement) => (
<View
@ -71,10 +137,15 @@ const AssigneeSelector = () => {
))
}
>
{OPTIONS.map(role => {
{options.map(option => {
return (
<CanvasMultiSelectOption id={role.id} value={role.id} key={role.id}>
{role.value}
<CanvasMultiSelectOption
id={option.id}
value={option.id}
key={option.id}
group={option.group}
>
{option.value}
</CanvasMultiSelectOption>
)
})}
@ -85,7 +156,8 @@ const AssigneeSelector = () => {
onClick={() => setSelectedAssignees([])}
isWithinText={false}
>
{I18n.t('Clear All')}
<span aria-hidden={true}>{I18n.t('Clear All')}</span>
<ScreenReaderContent>{I18n.t('Clear Assign To')}</ScreenReaderContent>
</Link>
</View>
</>

View File

@ -42,6 +42,7 @@ export interface DifferentiatedModulesTrayProps {
unlockAt?: string
prerequisites?: Module[]
moduleList?: Module[]
courseId: string
}
const SettingsPanel = React.lazy(() => import('./SettingsPanel'))
@ -54,6 +55,7 @@ export default function DifferentiatedModulesTray({
moduleId = '',
initialTab = 'assign-to',
assignOnly = true,
courseId,
...settingsProps
}: DifferentiatedModulesTrayProps) {
const [selectedTab, setSelectedTab] = useState<string | undefined>(initialTab)
@ -95,7 +97,7 @@ export default function DifferentiatedModulesTray({
return (
<React.Suspense fallback={<Fallback />}>
{assignOnly ? (
<AssignToPanel height={panelHeight} onDismiss={onDismiss} />
<AssignToPanel courseId={courseId} height={panelHeight} onDismiss={onDismiss} />
) : (
<Tabs onRequestTabChange={(_e: any, {id}: {id?: string}) => setSelectedTab(id)}>
<Tabs.Panel
@ -120,7 +122,7 @@ export default function DifferentiatedModulesTray({
isSelected={selectedTab === 'assign-to'}
padding="none"
>
<AssignToPanel height={panelHeight} onDismiss={onDismiss} />
<AssignToPanel courseId={courseId} height={panelHeight} onDismiss={onDismiss} />
</Tabs.Panel>
</Tabs>
)}

View File

@ -22,6 +22,7 @@ import AssignToPanel, {AssignToPanelProps} from '../AssignToPanel'
describe('AssignToPanel', () => {
const props: AssignToPanelProps = {
courseId: '1',
height: '500px',
onDismiss: () => {},
}

View File

@ -17,8 +17,19 @@
*/
import React from 'react'
import {act, render} from '@testing-library/react'
import AssigneeSelector, {OPTIONS} from '../AssigneeSelector'
import {act, fireEvent, render} from '@testing-library/react'
import AssigneeSelector from '../AssigneeSelector'
import fetchMock from 'fetch-mock'
import {FILTERED_SECTIONS_DATA, FILTERED_STUDENTS_DATA, SECTIONS_DATA, STUDENTS_DATA} from './mocks'
const props = {
courseId: '1',
}
const SECTIONS_URL = `/api/v1/courses/${props.courseId}/sections`
const STUDENTS_URL = `api/v1/courses/${props.courseId}/users?enrollment_type=student`
const FILTERED_SECTIONS_URL = `/api/v1/courses/${props.courseId}/sections?search_term=sec`
const FILTERED_STUDENTS_URL = `api/v1/courses/${props.courseId}/users?search_term=sec&enrollment_type=student`
describe('AssigneeSelector', () => {
beforeAll(() => {
@ -30,26 +41,60 @@ describe('AssigneeSelector', () => {
}
})
const renderComponent = () => render(<AssigneeSelector />)
it('renders', () => {
const {getByTestId} = renderComponent()
expect(getByTestId('assignee_selector')).toBeInTheDocument()
beforeEach(() => {
fetchMock.getOnce(SECTIONS_URL, SECTIONS_DATA)
fetchMock.getOnce(STUDENTS_URL, STUDENTS_DATA)
fetchMock.getOnce(FILTERED_SECTIONS_URL, FILTERED_SECTIONS_DATA)
fetchMock.getOnce(FILTERED_STUDENTS_URL, FILTERED_STUDENTS_DATA)
})
afterEach(() => {
fetchMock.restore()
})
it('selects multiple options', () => {
const {getByTestId, getByText, getAllByTestId} = renderComponent()
const renderComponent = () => render(<AssigneeSelector {...props} />)
it('displays sections and students as options', async () => {
const {getByTestId, findByText, getByText} = renderComponent()
act(() => getByTestId('assignee_selector').click())
act(() => getByText(OPTIONS[0].value).click())
await findByText(SECTIONS_DATA[0].name)
SECTIONS_DATA.forEach(section => {
expect(getByText(section.name)).toBeInTheDocument()
})
STUDENTS_DATA.forEach(student => {
expect(getByText(student.name)).toBeInTheDocument()
})
})
it('fetches filtered results from both APIs', async () => {
const {getByTestId, findByText, getByText} = renderComponent()
const assigneeSelector = getByTestId('assignee_selector')
act(() => assigneeSelector.click())
fireEvent.change(assigneeSelector, {target: {value: 'sec'}})
await findByText(FILTERED_SECTIONS_DATA[0].name)
FILTERED_SECTIONS_DATA.forEach(section => {
expect(getByText(section.name)).toBeInTheDocument()
})
FILTERED_STUDENTS_DATA.forEach(student => {
expect(getByText(student.name)).toBeInTheDocument()
})
})
it('selects multiple options', async () => {
const {getByTestId, findByText, getAllByTestId} = renderComponent()
act(() => getByTestId('assignee_selector').click())
act(() => getByText(OPTIONS[2].value).click())
const option1 = await findByText(SECTIONS_DATA[0].name)
act(() => option1.click())
act(() => getByTestId('assignee_selector').click())
const option2 = await findByText(SECTIONS_DATA[2].name)
act(() => option2.click())
expect(getAllByTestId('assignee_selector_option').length).toBe(2)
})
it('clears selection', () => {
const {getByTestId, queryAllByTestId, getByText} = renderComponent()
it('clears selection', async () => {
const {getByTestId, queryAllByTestId, findByText} = renderComponent()
act(() => getByTestId('assignee_selector').click())
act(() => getByText(OPTIONS[0].value).click())
const option = await findByText(STUDENTS_DATA[0].name)
act(() => option.click())
expect(queryAllByTestId('assignee_selector_option').length).toBe(1)
act(() => getByTestId('clear_selection_button').click())
expect(queryAllByTestId('assignee_selector_option').length).toBe(0)

View File

@ -29,6 +29,7 @@ describe('DifferentiatedModulesTray', () => {
moduleElement: document.createElement('div'),
initialTab: 'assign-to',
assignOnly: true,
courseId: '1',
}
const renderComponent = (overrides = {}) =>

View File

@ -0,0 +1,41 @@
/*
* Copyright (C) 2023 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export const SECTIONS_DATA = [
{id: '1', course_id: '1', name: 'Course 1', start_at: null, end_at: null},
{id: '2', course_id: '1', name: 'Section A', start_at: null, end_at: null},
{id: '3', course_id: '1', name: 'Section B', start_at: null, end_at: null},
{id: '4', course_id: '1', name: 'Section C', start_at: null, end_at: null},
]
export const FILTERED_SECTIONS_DATA = [
{id: '2', course_id: '1', name: 'Section A', start_at: null, end_at: null},
{id: '3', course_id: '1', name: 'Section B', start_at: null, end_at: null},
{id: '4', course_id: '1', name: 'Section C', start_at: null, end_at: null},
]
export const STUDENTS_DATA = [
{id: '1', name: 'Ben', created_at: '2023-01-01', sortable_name: 'Ben'},
{id: '2', name: 'Peter', created_at: '2023-01-01', sortable_name: 'Peter'},
{id: '3', name: 'Grace', created_at: '2023-01-01', sortable_name: 'Grace'},
{id: '4', name: 'Secilia', created_at: '2023-01-01', sortable_name: 'Secilia'},
]
export const FILTERED_STUDENTS_DATA = [
{id: '4', name: 'Secilia', created_at: '2023-01-01', sortable_name: 'Secilia'},
]

View File

@ -31,7 +31,7 @@ describe('CanvasMultiSelect', () => {
return renderFn(
<CanvasMultiSelect {...props}>
{options.map(o => (
<CanvasMultiSelect.Option id={o.id} key={o.id} value={o.id}>
<CanvasMultiSelect.Option id={o.id} key={o.id} value={o.id} group={o.group}>
{o.text}
</CanvasMultiSelect.Option>
))}
@ -71,6 +71,31 @@ describe('CanvasMultiSelect', () => {
expect(getByRole('option', {name: 'Broccoli'})).toBeInTheDocument()
})
it('categorizes by groups if set in the options', () => {
options = [
{id: '1', text: 'Cucumber', group: 'Vegetable'},
{id: '2', text: 'Broccoli', group: 'Vegetable'},
{id: '3', text: 'Apple', group: 'Fruit'},
]
const {getByRole} = renderComponent()
const combobox = getByRole('combobox', {name: 'Vegetables'})
fireEvent.click(combobox)
expect(getByRole('group', {name: 'Vegetable'})).toBeInTheDocument()
expect(getByRole('group', {name: 'Fruit'})).toBeInTheDocument()
expect(getByRole('option', {name: 'Apple'})).toBeInTheDocument()
})
it('does not categorize by groups if they are not set in the options', () => {
const {queryByRole} = renderComponent()
const combobox = queryByRole('combobox', {name: 'Vegetables'})
fireEvent.click(combobox)
expect(queryByRole('group', {name: 'Vegetable'})).not.toBeInTheDocument()
expect(queryByRole('group', {name: 'Fruit'})).not.toBeInTheDocument()
})
it('filters available options when text is input', () => {
const {getByRole, queryByRole} = renderComponent()
const combobox = getByRole('combobox', {name: 'Vegetables'})

View File

@ -16,15 +16,16 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, {useState, useRef, useMemo} from 'react'
import React, {useState, useEffect, useRef, useMemo} from 'react'
import {useScope as useI18nScope} from '@canvas/i18n'
import keycode from 'keycode'
import {Select} from '@instructure/ui-select'
import {Tag} from '@instructure/ui-tag'
import {func, string, node, arrayOf, oneOfType, bool} from 'prop-types'
import {func, string, node, arrayOf, oneOfType, bool, number} from 'prop-types'
import {matchComponentTypes} from '@instructure/ui-react-utils'
import {compact, uniqueId} from 'lodash'
import {Alert} from '@instructure/ui-alerts'
import {Spinner} from '@instructure/ui-spinner'
const I18n = useI18nScope('app_shared_components')
@ -33,6 +34,7 @@ const CanvasMultiSelectOption = () => <div />
CanvasMultiSelectOption.propTypes = {
id: string.isRequired, // eslint-disable-line react/no-unused-prop-types
value: string.isRequired, // eslint-disable-line react/no-unused-prop-types
group: string, // eslint-disable-line react/no-unused-prop-types
}
function alwaysArray(scalarOrArray) {
@ -57,6 +59,8 @@ function CanvasMultiSelect(props) {
disabled,
customRenderBeforeInput,
customMatcher,
customOnInputChange,
isLoading,
...otherProps
} = props
@ -84,6 +88,8 @@ function CanvasMultiSelect(props) {
}
function renderChildren() {
const groups = [...new Set(children.map(child => child.props.group))].filter(group => group);
function renderOption(child) {
const {id, children, ...optionProps} = child.props
return (
@ -101,7 +107,8 @@ function CanvasMultiSelect(props) {
function renderNoOptionsOption() {
return (
<Select.Option id={noOptionId.current} isHighlighted={false} isSelected={false}>
{noOptionsLabel}
{ isLoading ? <Spinner renderTitle="Loading" size="x-small" /> :
noOptionsLabel}
</Select.Option>
)
}
@ -119,7 +126,16 @@ function CanvasMultiSelect(props) {
})
)
return filteredChildren.length === 0 ? renderNoOptionsOption() : filteredChildren
function renderGroups() {
const groupsToRender = groups.filter(group => filteredChildren.some(child => child.props.group === group))
return groupsToRender.map((group) => <Select.Group key={group} renderLabel={group}>
{filteredChildren.filter(({props}) => props.group === group).map(option => renderOption(option))}
</Select.Group>)
}
if(filteredChildren.length === 0) return renderNoOptionsOption();
return groups.length === 0 ? filteredChildren : renderGroups()
}
function dismissTag(e, id) {
@ -156,8 +172,20 @@ function CanvasMultiSelect(props) {
return customRenderBeforeInput ? customRenderBeforeInput(tags) : tags
}
useEffect(() => {
if(inputValue !== ''){
filterOptions(inputValue)
}
}, [childProps])
function onInputChange(e) {
const {value} = e.target
filterOptions(value)
setInputValue(value)
customOnInputChange(value)
}
function filterOptions(value) {
const defaultMatcher = (option, term) => option.label.match(new RegExp(`^${term}`, 'i'))
const matcher = customMatcher || defaultMatcher
const filtered = childProps.filter(child => matcher(child, value.trim()))
@ -175,7 +203,6 @@ function CanvasMultiSelect(props) {
if (message && filtered.length > 0 && highlightedOptionId !== filtered[0].id) {
message = getChildById(filtered[0].id).label + '. ' + message
}
setInputValue(value)
setFilteredOptionIds(filtered.map(f => f.id))
if (filtered.length > 0) setHighlightedOptionId(filtered[0].id)
setIsShowingOptions(true)
@ -282,6 +309,9 @@ CanvasMultiSelect.propTypes = {
selectedOptionIds: arrayOf(string).isRequired,
noOptionsLabel: string,
size: string,
customOnInputChange: func,
visibleOptionsCount: number,
isLoading: bool,
}
CanvasMultiSelect.defaultProps = {
@ -290,6 +320,8 @@ CanvasMultiSelect.defaultProps = {
noOptionsLabel: '---',
selectedOptionIds: [],
disabled: false,
isLoading: false,
customOnInputChange: () => {}
}
CanvasMultiSelect.Option = CanvasMultiSelectOption