implement tab navigation for speedgrader navigation

fixes EGG-82
flag=react_discussion
flag=speed_grader

Test Plan:
0) Can fully navigate all of a student's
entries, with TAB navigation buttons.
1) Open a speedgrader discussion.
2) Go to highlighted entry,
3) Press tab PAST the reply and Mark as Read buttons
and the speedgrader buttons will appear!

Change-Id: I93b4f0d6dca29f82b6c2f67b75e2c41060297eb4
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/357932
Reviewed-by: Dave Wenzlick <david.wenzlick@instructure.com>
Reviewed-by: Omar Soto-Fortuño <omar.soto@instructure.com>
QA-Review: Jason Gillett <jason.gillett@instructure.com>
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Product-Review: Sam Garza <sam.garza@instructure.com>
This commit is contained in:
Chawn Neal 2024-09-19 12:37:40 -04:00
parent 3c55379a03
commit 596188ba60
8 changed files with 239 additions and 90 deletions

View File

@ -152,5 +152,66 @@ describe "Screenreader Gradebook grading" do
wait_for_ajaximations
end
end
it "next tab keeps focus" do
get "/courses/#{@course.id}/gradebook/speed_grader?assignment_id=#{@checkpointed_assignment.id}&student_id=#{@student2.id}&entry_id=#{@entry.id}"
in_frame("speedgrader_iframe") do
in_frame("discussion_preview_iframe") do
# tab to prev button
driver.action.send_keys(:tab).perform
driver.action.send_keys(:tab).perform
driver.action.send_keys(:tab).perform
driver.action.send_keys(:tab).perform
driver.action.send_keys(:tab).perform
# tab to next button
driver.action.send_keys(:tab).perform
3.times do |i|
expect(f("div[data-testid='isHighlighted']").text).to include(@student2.name)
expect(f("div[data-testid='isHighlighted']").text).to include("reply to topic j#{2 - i}")
# page 1
expect(f("body").text).to include("reply to topic j2")
expect(f("body").text).to_not include("reply to topic i0")
# notice enter key successfully navigates entries
driver.action.send_keys(:enter).perform
end
# page 2
expect(f("body").text).to include("reply to topic i0")
expect(f("body").text).to_not include("reply to topic j2")
end
end
end
it "prev tab keeps focus" do
get "/courses/#{@course.id}/gradebook/speed_grader?assignment_id=#{@checkpointed_assignment.id}&student_id=#{@student2.id}&entry_id=#{@entry.id}"
in_frame("speedgrader_iframe") do
in_frame("discussion_preview_iframe") do
# tab to prev button
driver.action.send_keys(:tab).perform
driver.action.send_keys(:tab).perform
driver.action.send_keys(:tab).perform
driver.action.send_keys(:tab).perform
driver.action.send_keys(:tab).perform
expect(f("div[data-testid='isHighlighted']").text).to include(@student2.name)
expect(f("div[data-testid='isHighlighted']").text).to include("reply to topic j2")
# page 1 is selected
expect(f("body").text).to include("reply to topic j2")
expect(f("body").text).to_not include("reply to topic i0")
3.times do |i|
# notice enter key successfully navigates entries
driver.action.send_keys(:enter).perform
expect(f("div[data-testid='isHighlighted']").text).to include(@student2.name)
expect(f("div[data-testid='isHighlighted']").text).to include("reply to entry i#{i}")
# page 2 is selected
expect(f("body").text).to include("reply to topic i0")
expect(f("body").text).to_not include("reply to topic j2")
end
end
end
end
end
end

View File

@ -104,7 +104,8 @@ export const STUDENT_DISCUSSION_QUERY = gql`
}
discussionEntriesConnection(sortOrder: $sort, userSearchId: $userSearchId) {
nodes {
...DiscussionEntry
_id
rootEntryId
anonymousAuthor {
...AnonymousUser
}
@ -119,7 +120,6 @@ export const STUDENT_DISCUSSION_QUERY = gql`
}
${AnonymousUser.fragment}
${Discussion.fragment}
${DiscussionEntry.fragment}
${PageInfo.fragment}
`

View File

@ -16,7 +16,7 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {DISCUSSION_QUERY, STUDENT_DISCUSSION_QUERY} from '../graphql/Queries'
import {DISCUSSION_QUERY} from '../graphql/Queries'
import {DiscussionTopicToolbarContainer} from './containers/DiscussionTopicToolbarContainer/DiscussionTopicToolbarContainer'
import {DiscussionTopicRepliesContainer} from './containers/DiscussionTopicRepliesContainer/DiscussionTopicRepliesContainer'
import {DiscussionTopicHeaderContainer} from './containers/DiscussionTopicHeaderContainer/DiscussionTopicHeaderContainer'
@ -66,6 +66,7 @@ const DiscussionTopicManager = props => {
const [showTranslationControl, setShowTranslationControl] = useState(false)
// Start as null, populate when ready.
const [translateTargetLanguage, setTranslateTargetLanguage] = useState(null)
const [focusSelector, setFocusSelector] = useState('')
const searchContext = {
searchTerm,
@ -82,6 +83,8 @@ const DiscussionTopicManager = props => {
setAllThreadsStatus,
expandedThreads,
setExpandedThreads,
discussionID: props.discussionTopicId,
perPage: ENV.per_page,
}
const [userSplitScreenPreference, setUserSplitScreenPreference] = useState(
ENV.DISCUSSION?.preferences?.discussions_splitscreen_view || false
@ -133,6 +136,11 @@ const DiscussionTopicManager = props => {
setUserSplitScreenPreference,
highlightEntryId,
setHighlightEntryId,
setPageNumber,
expandedThreads,
setExpandedThreads,
focusSelector,
setFocusSelector,
setIsGradedDiscussion,
isGradedDiscussion,
usedThreadingToolbarChildRef,
@ -231,72 +239,25 @@ const DiscussionTopicManager = props => {
skip: waitForUnreadFilter,
})
const speedGraderHook = useSpeedGrader()
const studenTopicVariables = {
discussionID: discussionTopicQuery?.data?.legacyNode?._id,
userSearchId: speedGraderHook.currentStudentId,
perPage: ENV.per_page,
sort,
}
const studentTopicQuery = useQuery(STUDENT_DISCUSSION_QUERY, {
variables: studenTopicVariables,
fetchPolicy: 'cache-and-network',
skip: !(
speedGraderHook.isInSpeedGrader &&
speedGraderHook.currentStudentId &&
studenTopicVariables.discussionID
),
})
const onMessage = useCallback(
e => {
const message = e.data
if (highlightEntryId) {
switch (message.subject) {
case 'DT.previousStudentReply': {
const previousStudentEntry = speedGraderHook.getStudentPreviousEntry(
highlightEntryId,
studentTopicQuery
)
setHighlightEntryId(previousStudentEntry?._id)
setPageNumber(previousStudentEntry?.rootEntryPageNumber)
if (previousStudentEntry?.rootEntryId){
setExpandedThreads([...expandedThreads, previousStudentEntry.rootEntryId])
}
break
}
case 'DT.nextStudentReply': {
const nextStudentEntry = speedGraderHook.getStudentNextEntry(
highlightEntryId,
studentTopicQuery
)
setHighlightEntryId(nextStudentEntry?._id)
setPageNumber(nextStudentEntry?.rootEntryPageNumber)
if (nextStudentEntry?.rootEntryId){
setExpandedThreads([...expandedThreads, nextStudentEntry.rootEntryId])
}
break
}
}
}
},
[studentTopicQuery.loading, highlightEntryId]
)
useEffect(() => {
if (highlightEntryId && !isPersistEnabled) {
setTimeout(() => {
setHighlightEntryId(null)
}, HIGHLIGHT_TIMEOUT)
}
}, [highlightEntryId, discussionTopicQuery.loading])
window.addEventListener('message', onMessage)
return () => {
window.removeEventListener('message', onMessage)
}
}, [highlightEntryId, discussionTopicQuery.loading, onMessage])
useSpeedGrader({
highlightEntryId,
setHighlightEntryId,
setPageNumber,
expandedThreads,
setExpandedThreads,
setFocusSelector,
discussionID: props.discussionTopicId,
perPage: ENV.per_page,
sort,
})
useEffect(() => {
setIsGradedDiscussion(!!discussionTopicQuery?.data?.legacyNode?.assignment)

View File

@ -18,7 +18,8 @@
import classNames from 'classnames'
import PropTypes from 'prop-types'
import React, {useLayoutEffect, useRef} from 'react'
import React, {useLayoutEffect, useRef, useContext} from 'react'
import {DiscussionManagerUtilityContext} from '../../utils/constants'
import theme from '@instructure/canvas-theme'
export function Highlight({...props}) {
@ -26,12 +27,37 @@ export function Highlight({...props}) {
const urlParams = new URLSearchParams(window.location.search)
const isPersistEnabled = urlParams.get('persist') === '1'
const className = isPersistEnabled ? 'highlight-discussion' : 'highlight-fadeout'
const {focusSelector, setFocusSelector} = useContext(DiscussionManagerUtilityContext)
const triggerFocus = element => {
let eventType = "onfocusin" in element ? "focusin" : "focus";
let bubbles = "onfocusin" in element;
let event;
if ("createEvent" in document) {
event = document.createEvent("Event");
event.initEvent(eventType, bubbles, true);
}
else if ("Event" in window) {
event = new Event(eventType, { bubbles: bubbles, cancelable: true });
}
element.focus();
element.dispatchEvent(event);
}
useLayoutEffect(() => {
if (props.isHighlighted && highlightRef.current) {
setTimeout(() => {
highlightRef.current?.scrollIntoView({behavior: 'smooth', block: 'center'})
highlightRef.current?.querySelector('button').focus({preventScroll: true})
if (focusSelector) {
const speedGraderDiv = highlightRef.current?.querySelector('#speedgrader-navigator')
triggerFocus(speedGraderDiv)
highlightRef.current?.querySelector(focusSelector).focus({preventScroll: true})
setFocusSelector('')
} else {
highlightRef.current?.querySelector('button').focus({preventScroll: true})
}
}, 0)
}
}, [props.isHighlighted, highlightRef])

View File

@ -17,10 +17,11 @@
*/
import {useScope as useI18nScope} from '@canvas/i18n'
import React, {useState, useRef} from 'react'
import React, {useState, useRef, useContext} from 'react'
import {Flex} from '@instructure/ui-flex'
import {Button} from '@instructure/ui-buttons'
import useSpeedGrader from '../../hooks/useSpeedGrader'
import {DiscussionManagerUtilityContext, SearchContext} from '../../utils/constants'
const I18n = useI18nScope('speed_grader')
@ -40,16 +41,34 @@ export const SpeedGraderNavigator = () => {
const containerRef = useRef(null)
const {
handlePreviousStudentReply,
handleNextStudentReply,
handleJumpFocusToSpeedGrader
} = useSpeedGrader()
highlightEntryId,
setHighlightEntryId,
setPageNumber,
expandedThreads,
setExpandedThreads,
setFocusSelector,
} = useContext(DiscussionManagerUtilityContext)
const {sort, perPage, discussionID} = useContext(SearchContext)
const {handlePreviousStudentReply, handleNextStudentReply, handleJumpFocusToSpeedGrader} =
useSpeedGrader({
highlightEntryId,
setHighlightEntryId,
setPageNumber,
expandedThreads,
setExpandedThreads,
setFocusSelector,
discussionID,
perPage,
sort,
})
const handleFocus = () => {
setIsVisible(true)
}
const handleBlur = (event) => {
const handleBlur = event => {
if (!containerRef.current?.contains(event.relatedTarget)) {
setIsVisible(false)
}
@ -59,7 +78,7 @@ export const SpeedGraderNavigator = () => {
if (!handler) return null
return (
<Flex.Item padding="0 x-small">
<Button data-testid={testId} onClick={handler}>
<Button data-testid={testId} id={testId} onClick={handler}>
{text}
</Button>
</Flex.Item>
@ -69,6 +88,7 @@ export const SpeedGraderNavigator = () => {
return (
<div
ref={containerRef}
id={'speedgrader-navigator'}
style={isVisible ? {} : visuallyHiddenStyles}
aria-hidden={!isVisible}
onFocus={handleFocus}

View File

@ -23,6 +23,7 @@ import {responsiveQuerySizes} from '../../../utils'
import {ThreadingToolbar} from '../ThreadingToolbar'
jest.mock('../../../utils')
jest.mock('../../../hooks/useSpeedGrader', () => jest.fn(() => false))
beforeAll(() => {
window.matchMedia = jest.fn().mockImplementation(() => {
@ -42,7 +43,7 @@ beforeEach(() => {
}))
})
describe('PostToolbar', () => {
describe('ThreadingToolbar', () => {
it('renders "Go to Reply" button when filter is set to unread', () => {
const {getByText} = render(
<ThreadingToolbar searchTerm="" filter="unread">

View File

@ -16,9 +16,21 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {useEffect, useState} from 'react'
import {useEffect, useState, useCallback} from 'react'
import {STUDENT_DISCUSSION_QUERY} from '../../graphql/Queries'
import {useQuery} from 'react-apollo'
export default function useSpeedGrader() {
export default function useSpeedGrader({
highlightEntryId = '',
setHighlightEntryId = () => {},
setPageNumber = () => {},
expandedThreads,
setExpandedThreads = () => {},
setFocusSelector = () => {},
sort = 'desc',
discussionID = '',
perPage = 20,
} = {}) {
const [isInSpeedGrader, setIsInSpeedGrader] = useState(false)
const [currentStudentId, setCurrentStudentId] = useState(null)
@ -37,7 +49,21 @@ export default function useSpeedGrader() {
checkSpeedGrader()
}, [])
const getStudentEntries = studentTopicQuery => {
// perPage and sort should match discussionTopicQuery
const studentTopicVariables = {
discussionID,
userSearchId: currentStudentId,
perPage,
sort,
}
const studentTopicQuery = useQuery(STUDENT_DISCUSSION_QUERY, {
variables: studentTopicVariables,
fetchPolicy: 'cache-and-network',
skip: !(isInSpeedGrader && currentStudentId && studentTopicVariables.discussionID),
})
const getStudentEntries = useCallback(() => {
if (!currentStudentId) {
return []
}
@ -50,31 +76,68 @@ export default function useSpeedGrader() {
studentTopicQuery?.data?.legacyNode?.discussionEntriesConnection?.nodes || []
return studentEntries
}
}, [studentTopicQuery, currentStudentId])
function getStudentPreviousEntry(currentEntryId, studentTopicQuery) {
const navigateToEntry = useCallback(
newEntry => {
setHighlightEntryId(newEntry?._id || highlightEntryId)
setPageNumber(newEntry?.rootEntryPageNumber)
if (newEntry?.rootEntryId) {
setExpandedThreads([...expandedThreads, newEntry.rootEntryId])
}
},
[expandedThreads, highlightEntryId, setExpandedThreads, setHighlightEntryId, setPageNumber]
)
const getStudentPreviousEntry = useCallback(() => {
const studentEntries = getStudentEntries(studentTopicQuery)
const studentEntriesIds = studentEntries.map(entry => entry._id)
const currentEntryIndex = studentEntriesIds.indexOf(currentEntryId)
const currentEntryIndex = studentEntriesIds.indexOf(highlightEntryId)
let prevEntryIndex = currentEntryIndex - 1
if (currentEntryIndex === 0) {
prevEntryIndex = studentEntriesIds.length - 1
}
const previousEntry = studentEntries[prevEntryIndex]
return previousEntry || studentEntries[currentEntryId]
}
navigateToEntry(previousEntry)
}, [getStudentEntries, studentTopicQuery, highlightEntryId, navigateToEntry])
function getStudentNextEntry(currentEntryId, studentTopicQuery) {
const getStudentNextEntry = useCallback(() => {
const studentEntries = getStudentEntries(studentTopicQuery)
const studentEntriesIds = studentEntries.map(entry => entry._id)
const currentEntryIndex = studentEntriesIds.indexOf(currentEntryId)
const currentEntryIndex = studentEntriesIds.indexOf(highlightEntryId)
let nextEntryIndex = currentEntryIndex + 1
if (currentEntryIndex === studentEntriesIds.length - 1) {
nextEntryIndex = 0
}
const nextEntry = studentEntries[nextEntryIndex]
return nextEntry || studentEntries[currentEntryId]
}
navigateToEntry(nextEntry)
}, [getStudentEntries, studentTopicQuery, highlightEntryId, navigateToEntry])
const onMessage = useCallback(
e => {
const message = e.data
if (highlightEntryId) {
switch (message.subject) {
case 'DT.previousStudentReply': {
getStudentPreviousEntry()
break
}
case 'DT.nextStudentReply': {
getStudentNextEntry()
break
}
}
}
},
[highlightEntryId, getStudentPreviousEntry, getStudentNextEntry]
)
useEffect(() => {
window.addEventListener('message', onMessage)
return () => {
window.removeEventListener('message', onMessage)
}
}, [highlightEntryId, onMessage])
const handleJumpFocusToSpeedGrader = () => {
window.top.postMessage(
@ -85,14 +148,23 @@ export default function useSpeedGrader() {
)
}
// These will be implemented later
const handlePreviousStudentReply = null
const handleNextStudentReply = null
function sendPostMessage(message) {
window.postMessage(message, '*')
}
function handlePreviousStudentReply() {
const message = {subject: 'DT.previousStudentReply'}
sendPostMessage(message)
setFocusSelector('#previous-in-speedgrader')
}
const handleNextStudentReply = () => {
const message = {subject: 'DT.nextStudentReply'}
sendPostMessage(message)
setFocusSelector('#next-in-speedgrader')
}
return {
currentStudentId,
getStudentPreviousEntry,
getStudentNextEntry,
isInSpeedGrader,
handlePreviousStudentReply,
handleNextStudentReply,

View File

@ -48,6 +48,9 @@ const searchFilter = {
setAllThreadsStatus: () => {},
expandedThreads: [],
setExpandedThreads: () => {},
sort: 'desc',
perPage: '',
discussionID: '',
}
export const SearchContext = React.createContext(searchFilter)
@ -58,6 +61,11 @@ const discussionManagerUtilityContext = {
setUserSplitScreenPreference: () => {},
highlightEntryId: '',
setHighlightEntryId: () => {},
expandedThreads: '',
setExpandedThreads: () => {},
focusSelector: '',
setFocusSelector: () => {},
setPageNumber: () => {},
isGradedDiscussion: false,
setIsGradedDiscussion: () => {},
usedThreadingToolbarChildRef: null,