Add updated "message students who" dialog

Add a basic component for an updated "Message Students Who" dialog,
viewable in Storybook but not yet accessible from Gradebook. This is
meant to give us a base to begin adding functionality and does not
presume to be the correct or final version of the UI.

closes EVAL-2056
flag=message_observers_of_students_who

Test plan:
- Test that the component appears as expected in Storybook

Change-Id: Ice349c68d4c717809d4fcbae4c8cae0a5fe7a5f1
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/276525
Reviewed-by: Spencer Olson <solson@instructure.com>
Reviewed-by: Kai Bjorkman <kbjorkman@instructure.com>
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
QA-Review: Adrian Packel <apackel@instructure.com>
Product-Review: Syed Hussain <shussain@instructure.com>
This commit is contained in:
Adrian Packel 2021-10-20 15:20:40 -05:00
parent 0e69a91cf3
commit f23e6259a4
3 changed files with 572 additions and 0 deletions

View File

@ -0,0 +1,105 @@
/*
* Copyright (C) 2021 - 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/>.
*/
import React from 'react'
import MessageStudentsWhoDialog from './MessageStudentsWhoDialog'
const students = [
{
id: '100',
name: 'Adam Jones',
sortableName: 'Jones, Adam'
},
{
id: '101',
name: 'Betty Ford',
sortableName: 'Ford, Betty'
},
{
id: '102',
name: 'Charlie Xi',
sortableName: 'Xi, Charlie'
},
{
id: '103',
name: 'Dana Smith',
sortableName: 'Smith, Dana'
}
]
export default {
title: 'Examples/Evaluate/Shared/MessageStudentsWhoDialog',
component: MessageStudentsWhoDialog,
args: {
assignment: {
gradingType: 'points',
id: '100',
name: 'Some assignment',
nonDigitalSubmission: false
},
students
},
argTypes: {
onClose: {action: 'closed'}
}
}
const Template = args => <MessageStudentsWhoDialog {...args} />
export const ScoredAssignment = Template.bind({})
ScoredAssignment.args = {
assignment: {
gradingType: 'points',
id: '100',
name: 'A pointed assignment',
nonDigitalSubmission: false
},
students
}
export const UngradedAssignment = Template.bind({})
UngradedAssignment.args = {
assignment: {
gradingType: 'not_graded',
id: '200',
name: 'A pointless assignment',
nonDigitalSubmission: false
},
students
}
export const PassFailAssignment = Template.bind({})
PassFailAssignment.args = {
assignment: {
gradingType: 'pass_fail',
id: '300',
name: 'A pass-fail assignment',
nonDigitalSubmission: false
},
students
}
export const UnsubmittableAssignment = Template.bind({})
UnsubmittableAssignment.args = {
assignment: {
gradingType: 'no_submission',
id: '400',
name: 'An unsubmittable assignment',
nonDigitalSubmission: true
},
students
}

View File

@ -0,0 +1,275 @@
/*
* Copyright (C) 2021 - 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/>.
*/
import React, {useState} from 'react'
import I18n from 'i18n!public_message_students_who'
import {Button, CloseButton, IconButton} from '@instructure/ui-buttons'
import {Checkbox} from '@instructure/ui-checkbox'
import {Flex} from '@instructure/ui-flex'
import {Heading} from '@instructure/ui-heading'
import {IconArrowOpenDownLine, IconArrowOpenUpLine, IconPaperclipLine} from '@instructure/ui-icons'
import {Link} from '@instructure/ui-link'
import {Modal} from '@instructure/ui-modal'
import {NumberInput} from '@instructure/ui-number-input'
import {ScreenReaderContent} from '@instructure/ui-a11y-content'
import {SimpleSelect} from '@instructure/ui-simple-select'
import {Table} from '@instructure/ui-table'
import {Tag} from '@instructure/ui-tag'
import {Text} from '@instructure/ui-text'
import {TextArea} from '@instructure/ui-text-area'
import {TextInput} from '@instructure/ui-text-input'
// Doing this to avoid TS2339 errors-- remove once we're on InstUI 8
const {Item} = Flex as any
const {Header: ModalHeader, Body: ModalBody, Footer: ModalFooter} = Modal as any
const {Option} = SimpleSelect as any
const {Body: TableBody, Cell, ColHeader, Head: TableHead, Row} = Table as any
export type Student = {
id: string
name: string
sortableName: string
}
export type Assignment = {
gradingType: string
id: string
name: string
nonDigitalSubmission: boolean
}
export type Props = {
assignment: Assignment
onClose: () => void
students: Student[]
}
type FilterCriterion = {
readonly requiresCutoff: boolean
readonly shouldShow: (assignment: Assignment) => boolean
readonly title: string
readonly value: string
}
const isScored = (assignment: Assignment) =>
['points', 'percent', 'letter_grade', 'gpa_scale'].includes(assignment.gradingType)
const filterCriteria: FilterCriterion[] = [
{
requiresCutoff: false,
shouldShow: assignment => !assignment.nonDigitalSubmission,
title: I18n.t('Have not yet submitted'),
value: 'unsubmitted'
},
{
requiresCutoff: false,
shouldShow: () => true,
title: I18n.t('Have not been graded'),
value: 'ungraded'
},
{
requiresCutoff: true,
shouldShow: isScored,
title: I18n.t('Scored more than'),
value: 'scored_more_than'
},
{
requiresCutoff: true,
shouldShow: isScored,
title: I18n.t('Scored less than'),
value: 'scored_less_than'
},
{
requiresCutoff: false,
shouldShow: assignment => assignment.gradingType === 'pass_fail',
title: I18n.t('Marked incomplete'),
value: 'marked_incomplete'
},
{
requiresCutoff: false,
shouldShow: () => true,
title: I18n.t('Reassigned'),
value: 'reassigned'
}
]
const MessageStudentsWhoDialog: React.FC<Props> = ({assignment, onClose, students}) => {
const [open, setOpen] = useState(true)
const close = () => setOpen(false)
const availableCriteria = filterCriteria.filter(criterion => criterion.shouldShow(assignment))
const [showTable, setShowTable] = useState(false)
const [selectedCriterion, setSelectedCriterion] = useState(availableCriteria[0])
const [cutoff, setCutoff] = useState(0.0)
const sortedStudents = [...students].sort((a, b) => a.sortableName.localeCompare(b.sortableName))
const handleCriterionSelected = (_e, {value}) => {
const newCriterion = filterCriteria.find(criterion => criterion.value === value)
if (newCriterion != null) {
setSelectedCriterion(newCriterion)
}
}
// TODO: get observers from GraphQL eventually
const observers = []
return (
<Modal
open={open}
label={I18n.t('Compose Message')}
onDismiss={close}
onExited={onClose}
overflow="scroll"
shouldCloseOnDocumentClick={false}
size="large"
>
<ModalHeader>
<CloseButton
placement="end"
offset="small"
onClick={close}
screenReaderLabel={I18n.t('Close')}
/>
<Heading>{I18n.t('Compose Message')}</Heading>
</ModalHeader>
<ModalBody>
<Flex alignItems="end">
<Item>
<SimpleSelect
renderLabel={I18n.t('For students who…')}
onChange={handleCriterionSelected}
value={selectedCriterion.value}
>
{availableCriteria.map(criterion => (
<Option id={criterion.value} key={criterion.value} value={criterion.value}>
{criterion.title}
</Option>
))}
</SimpleSelect>
</Item>
{selectedCriterion.requiresCutoff && (
<Item margin="0 0 0 small">
<NumberInput
value={cutoff}
onChange={(_e, value) => {
setCutoff(value)
}}
showArrows={false}
renderLabel={
<ScreenReaderContent>{I18n.t('Enter score cutoff')}</ScreenReaderContent>
}
width="5em"
/>
</Item>
)}
</Flex>
<br />
<Flex>
<Item>
<Text weight="bold">{I18n.t('Send Message To:')}</Text>
</Item>
<Item margin="0 0 0 medium">
<Checkbox
label={
<Text weight="bold">
{I18n.t('%{studentCount} Students', {studentCount: students.length})}
</Text>
}
/>
</Item>
<Item margin="0 0 0 medium">
<Checkbox
label={
<Text weight="bold">
{I18n.t('%{observerCount} Observers', {observerCount: observers.length})}
</Text>
}
/>
</Item>
<Item as="div" shouldGrow textAlign="end">
<Link
onClick={() => setShowTable(!showTable)}
renderIcon={showTable ? <IconArrowOpenUpLine /> : <IconArrowOpenDownLine />}
iconPlacement="end"
>
{showTable ? I18n.t('Hide all recipients') : I18n.t('Show all recipients')}
</Link>
</Item>
</Flex>
{showTable && (
<Table caption={I18n.t('List of students and observers')}>
<TableHead>
<Row>
<ColHeader id="students">{I18n.t('Students')}</ColHeader>
<ColHeader id="observers">{I18n.t('Observers')}</ColHeader>
</Row>
</TableHead>
<TableBody>
{sortedStudents.map(student => (
<Row key={student.id}>
<Cell>
<Tag text={student.name} />
</Cell>
<Cell>{/* observers will go here */}</Cell>
</Row>
))}
</TableBody>
</Table>
)}
<br />
<TextInput renderLabel={I18n.t('Subject')} placeholder={I18n.t('Type Something…')} />
<br />
<TextArea
height="200px"
label={I18n.t('Message')}
placeholder={I18n.t('Type your message here…')}
/>
</ModalBody>
<ModalFooter>
<Flex justifyItems="space-between" width="100%">
<Item>
<IconButton screenReaderLabel={I18n.t('Add attachment')}>
<IconPaperclipLine />
</IconButton>
</Item>
<Item>
<Flex>
<Item>
<Button focusColor="info" color="primary-inverse" onClick={close}>
{I18n.t('Cancel')}
</Button>
</Item>
<Item margin="0 0 0 x-small">
<Button color="primary" onClick={close}>
{I18n.t('Send')}
</Button>
</Item>
</Flex>
</Item>
</Flex>
</ModalFooter>
</Modal>
)
}
export default MessageStudentsWhoDialog

View File

@ -0,0 +1,192 @@
/*
* Copyright (C) 2021 - 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/>.
*/
import React from 'react'
import {fireEvent, render} from '@testing-library/react'
import MessageStudentsWhoDialog, {
Assignment,
Props as ComponentProps,
Student
} from '../MessageStudentsWhoDialog'
const students: Student[] = [
{
id: '100',
name: 'Adam Jones',
sortableName: 'Jones, Adam'
},
{
id: '101',
name: 'Betty Ford',
sortableName: 'Ford, Betty'
},
{
id: '102',
name: 'Charlie Xi',
sortableName: 'Xi, Charlie'
},
{
id: '103',
name: 'Dana Smith',
sortableName: 'Smith, Dana'
}
]
const scoredAssignment: Assignment = {
gradingType: 'points',
id: '100',
name: 'A pointed assignment',
nonDigitalSubmission: false
}
const ungradedAssignment: Assignment = {
gradingType: 'not_graded',
id: '200',
name: 'A pointless assignment',
nonDigitalSubmission: false
}
const passFailAssignment: Assignment = {
gradingType: 'pass_fail',
id: '300',
name: 'A pass-fail assignment',
nonDigitalSubmission: false
}
const unsubmittableAssignment: Assignment = {
gradingType: 'no_submission',
id: '400',
name: 'An unsubmittable assignment',
nonDigitalSubmission: true
}
function makeProps(overrides: object = {}): ComponentProps {
return {
assignment: scoredAssignment,
students,
onClose: () => {},
...overrides
}
}
describe('MessageStudentsWhoDialog', () => {
it('hides the list of students initially', () => {
const {queryByRole} = render(<MessageStudentsWhoDialog {...makeProps()} />)
expect(queryByRole('table')).not.toBeInTheDocument()
})
it('shows students sorted by sortable name when the table is shown', () => {
const {getByRole, getAllByRole} = render(<MessageStudentsWhoDialog {...makeProps()} />)
fireEvent.click(getByRole('button', {name: 'Show all recipients'}))
expect(getByRole('table')).toBeInTheDocument()
const tableRows = getAllByRole('row') as HTMLTableRowElement[]
const studentCells = tableRows.map(row => row.cells[0])
// first cell will be the header
expect(studentCells).toHaveLength(5)
expect(studentCells[0]).toHaveTextContent('Students')
expect(studentCells[1]).toHaveTextContent('Betty Ford')
expect(studentCells[2]).toHaveTextContent('Adam Jones')
expect(studentCells[3]).toHaveTextContent('Dana Smith')
expect(studentCells[4]).toHaveTextContent('Charlie Xi')
})
it('includes the total number of students in the checkbox label', () => {
const {getByRole} = render(<MessageStudentsWhoDialog {...makeProps()} />)
expect(getByRole('checkbox', {name: /Students/})).toHaveAccessibleName('4 Students')
})
describe('available criteria', () => {
it('includes score-related options but no "Marked incomplete" option for point-based assignments', () => {
const {getAllByRole, getByLabelText} = render(<MessageStudentsWhoDialog {...makeProps()} />)
fireEvent.click(getByLabelText(/For students who/))
const criteriaLabels = getAllByRole('option').map(option => option.textContent)
expect(criteriaLabels).toContain('Scored more than')
expect(criteriaLabels).toContain('Scored less than')
expect(criteriaLabels).not.toContain('Marked incomplete')
})
it('includes "Marked incomplete" but no score-related options for pass-fail assignments', () => {
const {getAllByRole, getByLabelText} = render(
<MessageStudentsWhoDialog {...makeProps({assignment: passFailAssignment})} />
)
fireEvent.click(getByLabelText(/For students who/))
const criteriaLabels = getAllByRole('option').map(option => option.textContent)
expect(criteriaLabels).toContain('Marked incomplete')
expect(criteriaLabels).not.toContain('Scored more than')
expect(criteriaLabels).not.toContain('Scored less than')
})
it('does not include "Marked incomplete" or score-related options for ungraded assignments', () => {
const {getAllByRole, getByLabelText} = render(
<MessageStudentsWhoDialog {...makeProps({assignment: ungradedAssignment})} />
)
fireEvent.click(getByLabelText(/For students who/))
const criteriaLabels = getAllByRole('option').map(option => option.textContent)
expect(criteriaLabels).not.toContain('Marked incomplete')
expect(criteriaLabels).not.toContain('Scored more than')
expect(criteriaLabels).not.toContain('Scored less than')
})
it('includes "Have not yet submitted" if the assignment accepts digital submissions', () => {
const {getAllByRole, getByLabelText} = render(<MessageStudentsWhoDialog {...makeProps()} />)
fireEvent.click(getByLabelText(/For students who/))
const criteriaLabels = getAllByRole('option').map(option => option.textContent)
expect(criteriaLabels).toContain('Have not yet submitted')
})
it('does not include "Have not yet submitted" if the assignment does not accept digital submissions', () => {
const {getAllByRole, getByLabelText} = render(
<MessageStudentsWhoDialog {...makeProps({assignment: unsubmittableAssignment})} />
)
fireEvent.click(getByLabelText(/For students who/))
const criteriaLabels = getAllByRole('option').map(option => option.textContent)
expect(criteriaLabels).not.toContain('Have not yet submitted')
})
})
describe('cutoff input', () => {
it('is shown only when "Scored more than" or "Scored less than" is selected', () => {
const {getByLabelText, getByRole, queryByLabelText} = render(
<MessageStudentsWhoDialog {...makeProps()} />
)
expect(queryByLabelText('Enter score cutoff')).not.toBeInTheDocument()
const selector = getByLabelText(/For students who/)
fireEvent.click(selector)
fireEvent.click(getByRole('option', {name: 'Scored more than'}))
expect(getByLabelText('Enter score cutoff')).toBeInTheDocument()
fireEvent.click(selector)
fireEvent.click(getByRole('option', {name: 'Scored less than'}))
expect(getByLabelText('Enter score cutoff')).toBeInTheDocument()
fireEvent.click(selector)
fireEvent.click(getByRole('option', {name: 'Reassigned'}))
expect(queryByLabelText('Enter score cutoff')).not.toBeInTheDocument()
})
})
})