Show list of submission comments threads on the left pane

closes VICE-2527

flag=react_inbox

test plan:
  - Specs pass
  - Go to a Course -> Grade and leave submission comments to a student.
  - Login as the student and go answer back to the teacher
      (in the same place).
  - Login back as teacher and go to Inbox and click on the
      scope Submission Comments.
  - The threads should show on the left pane with a preview.

qa risk: low

Change-Id: Ic90b47a06b99e55c5eee3ffd813d45106efcabb6
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/285329
Reviewed-by: Jason Gillett <jason.gillett@instructure.com>
Product-Review: Jason Gillett <jason.gillett@instructure.com>
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
QA-Review: Chawn Neal <chawn.neal@instructure.com>
This commit is contained in:
Omar Gerardo Soto-Fortuño 2022-02-16 13:46:25 -05:00 committed by Omar Soto-Fortuño
parent 495e98ea51
commit c4470f564c
8 changed files with 273 additions and 73 deletions

View File

@ -44,6 +44,8 @@ module Types
implements Interfaces::TimestampInterface
implements Interfaces::LegacyIDInterface
field :submission_id, ID, null: false
field :created_at, Types::DateTimeType, null: false
field :comment, String, null: true
field :author, Types::UserType, null: true

View File

@ -800,6 +800,9 @@ describe Types::UserType do
... on User {
submissionCommentsConnection(first: 10) {
nodes {
_id
submissionId
createdAt
comment
assignment {
name
@ -829,11 +832,11 @@ describe Types::UserType do
final_grader: @teacher
)
assignment.grade_student(student, grade: 1, grader: @teacher, provisional: true)
submission = assignment.submissions.find_by(user: student)
@submission = assignment.submissions.find_by(user: student)
submission.add_comment(author: student, comment: "First comment")
submission.add_comment(author: @teacher, comment: "Second comment")
submission.add_comment(author: @teacher, comment: "Third comment")
@sc1 = @submission.add_comment(author: student, comment: "First comment")
@sc2 = @submission.add_comment(author: @teacher, comment: "Second comment")
@sc3 = @submission.add_comment(author: @teacher, comment: "Third comment")
end
it "can get comments" do
@ -847,6 +850,30 @@ describe Types::UserType do
expect(nodes.map { |c| c["comment"] }).to match_array ["First comment", "Second comment", "Third comment"]
end
it "can get submissionId" do
result = CanvasSchema.execute(
submission_comments_mutation_str(@teacher.id),
context: { current_user: @teacher }
)
nodes = result.dig("data", "legacyNode", "submissionCommentsConnection", "nodes")
expect(nodes.map { |c| c["submissionId"] }).to match_array [@submission.id.to_s, @submission.id.to_s, @submission.id.to_s]
end
it "can get createdAt" do
result = CanvasSchema.execute(
submission_comments_mutation_str(@teacher.id),
context: { current_user: @teacher }
)
nodes = result.dig("data", "legacyNode", "submissionCommentsConnection", "nodes")
[@sc1, @sc2, @sc3].each do |sc|
expect(Time.parse(nodes.find { |n| n["_id"] == sc.id.to_s }["createdAt"])).to be_within(1.minute).of(sc.created_at)
end
end
it "can get assignment names" do
result = CanvasSchema.execute(
submission_comments_mutation_str(@teacher.id),

View File

@ -163,7 +163,7 @@ export const REPLY_CONVERSATION_QUERY = gql`
`
export const SUBMISSION_COMMENTS_QUERY = gql`
query SubmissionCommentsQuery($userID: ID!) {
legacyNote(_id: $userID, type: User) {
legacyNode(_id: $userID, type: User) {
... on User {
_id
id

View File

@ -27,6 +27,8 @@ export const SubmissionComment = {
fragment SubmissionComment on SubmissionComment {
_id
id
submissionId
createdAt
attempt
author {
...User
@ -48,11 +50,38 @@ export const SubmissionComment = {
shape: shape({
_id: string,
id: string,
submissionId: string,
createdAt: string,
attempt: number,
author: User.shape,
assignment: Assignment.shape,
comment: string,
course: Course.shape,
read: bool
}),
mock: ({
_id = '9',
id = 'U3VibWlzc2lvbkNvbW1lbnQtOQ==',
submissionId = '15',
createdAt = '2022-02-15T06:50:54-07:00',
attempt = 0,
author = User.mock(),
assignment = Assignment.mock(),
comment = 'Hey!',
course = Course.mock(),
read = true
} = {}) => ({
_id,
id,
submissionId,
createdAt,
attempt,
author,
assignment,
comment,
course,
read,
__typename: 'SubmissionComment'
})
}

View File

@ -28,7 +28,7 @@ import {Text} from '@instructure/ui-text'
import {useMutation} from 'react-apollo'
import {View} from '@instructure/ui-view'
import {ConversationListItem, conversationProp} from './ConversationListItem'
import {ConversationListItem} from './ConversationListItem'
import {UPDATE_CONVERSATION_PARTICIPANTS} from '../../../graphql/Mutations'
export const ConversationListHolder = ({...props}) => {
@ -38,7 +38,7 @@ export const ConversationListHolder = ({...props}) => {
const provideConversationsForOnSelect = conversationIds => {
const matchedConversations = props.conversations
.filter(c => conversationIds.includes(c._id))
?.filter(c => conversationIds.includes(c._id))
.map(c => c.conversation)
props.onSelect(matchedConversations)
}
@ -155,16 +155,27 @@ export const ConversationListHolder = ({...props}) => {
{props.conversations?.map(conversation => {
return (
<ConversationListItem
id={conversation._id}
conversation={conversation.conversation}
isStarred={conversation.label === 'starred'}
isSelected={selectedMessages.includes(conversation._id)}
isUnread={conversation.workflowState === 'unread'}
id={props.isSubmissionComments ? conversation[0].submissionId : conversation._id}
conversation={props.isSubmissionComments ? undefined : conversation.conversation}
submissionComments={props.isSubmissionComments ? conversation : undefined}
isStarred={props.isSubmissionComments ? false : conversation.label === 'starred'}
isSelected={
props.isSubmissionComments
? selectedMessages.includes(conversation[0].submissionId)
: selectedMessages.includes(conversation._id)
}
isUnread={
props.isSubmissionComments
? !conversation[0].read
: conversation.workflowState === 'unread'
}
onOpen={props.onOpen}
onSelect={handleItemSelection}
onStar={props.onStar}
key={conversation._id}
readStateChangeConversationParticipants={readStateChangeConversationParticipants}
onStar={props.isSubmissionComments ? () => {} : props.onStar}
key={props.isSubmissionComments ? conversation[0].submissionId : conversation._id}
readStateChangeConversationParticipants={
props.isSubmissionComments ? () => {} : readStateChangeConversationParticipants
}
/>
)
})}
@ -199,24 +210,18 @@ export const ConversationListHolder = ({...props}) => {
)
}
const conversationParticipantsProp = PropTypes.shape({
id: PropTypes.string,
_id: PropTypes.string,
workflowState: PropTypes.string,
conversation: conversationProp,
label: PropTypes.string
})
ConversationListHolder.propTypes = {
conversations: PropTypes.arrayOf(conversationParticipantsProp),
conversations: PropTypes.arrayOf(PropTypes.object),
id: PropTypes.string,
onOpen: PropTypes.func,
onSelect: PropTypes.func,
onStar: PropTypes.func
onStar: PropTypes.func,
isSubmissionComments: PropTypes.bool
}
ConversationListHolder.defaultProps = {
onOpen: () => {},
onSelect: () => {},
onStar: () => {}
onStar: () => {},
isSubmissionComments: false
}

View File

@ -41,6 +41,8 @@ import {colors} from '@instructure/canvas-theme'
export const ConversationListItem = ({...props}) => {
const [isHovering, setIsHovering] = useState(false)
const isSubmissionComments = props.submissionComments && !props.conversation
const handleConversationClick = e => {
e.nativeEvent.stopImmediatePropagation()
e.stopPropagation()
@ -52,9 +54,19 @@ export const ConversationListItem = ({...props}) => {
}
if (e.metaKey || e.ctrlKey || e.shiftKey) {
props.onSelect(e, props.id, props.conversation, true)
props.onSelect(
e,
props.id,
isSubmissionComments ? props.submissionComments : props.conversation,
true
)
} else {
props.onSelect(e, props.id, props.conversation, false)
props.onSelect(
e,
props.id,
isSubmissionComments ? props.submissionComments : props.conversation,
false
)
props.onOpen()
}
}
@ -72,13 +84,16 @@ export const ConversationListItem = ({...props}) => {
}
const formatParticipants = () => {
const participantsStr = props.conversation.conversationParticipantsConnection.nodes
.filter(
p => p.user.name !== props.conversation.conversationMessagesConnection.nodes[0].author.name
)
.reduce((prev, curr) => {
return prev + ', ' + curr.user.name
}, '')
const participantsStr = isSubmissionComments
? ''
: props.conversation.conversationParticipantsConnection.nodes
.filter(
p =>
p.user.name !== props.conversation.conversationMessagesConnection.nodes[0].author.name
)
.reduce((prev, curr) => {
return prev + ', ' + curr.user.name
}, '')
return (
<Responsive
@ -105,7 +120,11 @@ export const ConversationListItem = ({...props}) => {
data-testid={responsiveProps.datatestid}
>
<TruncateText>
<b>{props.conversation.conversationMessagesConnection.nodes[0].author.name}</b>
<b>
{isSubmissionComments
? props.submissionComments[0].author.name
: props.conversation.conversationMessagesConnection.nodes[0].author.name}
</b>
{participantsStr}
</TruncateText>
</Text>
@ -207,13 +226,19 @@ export const ConversationListItem = ({...props}) => {
<Grid.Col>
<Text color="brand" size={responsiveProps.date.size}>
{formatDate(
props.conversation.conversationMessagesConnection.nodes[0]?.createdAt
isSubmissionComments
? props.submissionComments[0].createdAt
: props.conversation.conversationMessagesConnection.nodes[0]?.createdAt
)}
</Text>
</Grid.Col>
<Grid.Col width="auto">
<Badge
count={props.conversation.conversationMessagesConnection.nodes?.length}
count={
isSubmissionComments
? props.submissionComments.length
: props.conversation.conversationMessagesConnection.nodes?.length
}
countUntil={99}
standalone
theme={{
@ -257,7 +282,13 @@ export const ConversationListItem = ({...props}) => {
</Grid.Col>
<Grid.Col>
<Text weight="normal" size={responsiveProps.subject.size}>
<TruncateText>{props.conversation.subject}</TruncateText>
<TruncateText>
{isSubmissionComments
? props.submissionComments[0].course.contextName +
' - ' +
props.submissionComments[0].assignment.name
: props.conversation.subject}
</TruncateText>
</Text>
</Grid.Col>
</Grid.Row>
@ -268,30 +299,20 @@ export const ConversationListItem = ({...props}) => {
<Grid.Col>
<Text color="secondary" size={responsiveProps.message.size}>
<TruncateText>
{props.conversation.conversationMessagesConnection?.nodes[0]?.body}
{isSubmissionComments
? props.submissionComments[0].comment
: props.conversation.conversationMessagesConnection?.nodes[0]?.body}
</TruncateText>
</Text>
</Grid.Col>
<Grid.Col width="auto">
<View textAlign="center" as="div" width={30} height={30} margin="0 small 0 0">
<Focusable>
{({focused}) => {
return (
<div>
{focused || isHovering || props.isStarred ? (
<IconButton
size="small"
withBackground={false}
withBorder={false}
renderIcon={props.isStarred ? IconStarSolid : IconStarLightLine}
screenReaderLabel={
props.isStarred ? I18n.t('starred') : I18n.t('not starred')
}
onClick={handleConversationStarClick}
data-testid="visible-star"
/>
) : (
<ScreenReaderContent>
{!isSubmissionComments && (
<View textAlign="center" as="div" width={30} height={30} margin="0 small 0 0">
<Focusable>
{({focused}) => {
return (
<div>
{focused || isHovering || props.isStarred ? (
<IconButton
size="small"
withBackground={false}
@ -301,14 +322,28 @@ export const ConversationListItem = ({...props}) => {
props.isStarred ? I18n.t('starred') : I18n.t('not starred')
}
onClick={handleConversationStarClick}
data-testid="visible-star"
/>
</ScreenReaderContent>
)}
</div>
)
}}
</Focusable>
</View>
) : (
<ScreenReaderContent>
<IconButton
size="small"
withBackground={false}
withBorder={false}
renderIcon={props.isStarred ? IconStarSolid : IconStarLightLine}
screenReaderLabel={
props.isStarred ? I18n.t('starred') : I18n.t('not starred')
}
onClick={handleConversationStarClick}
/>
</ScreenReaderContent>
)}
</div>
)
}}
</Focusable>
</View>
)}
</Grid.Col>
</Grid.Row>
<Grid.Row>
@ -362,6 +397,7 @@ export const conversationProp = PropTypes.shape({
ConversationListItem.propTypes = {
conversation: conversationProp,
submissionComments: PropTypes.arrayOf(PropTypes.object),
id: PropTypes.string,
isSelected: PropTypes.bool,
isStarred: PropTypes.bool,

View File

@ -20,6 +20,7 @@ import {render, fireEvent} from '@testing-library/react'
import React from 'react'
import {ConversationListItem} from '../ConversationListItem'
import {responsiveQuerySizes} from '../../../../util/utils'
import {SubmissionComment} from '../../../../graphql/SubmissionComment'
jest.mock('../../../../util/utils', () => ({
...jest.requireActual('../../../../util/utils'),
@ -209,4 +210,66 @@ describe('ConversationListItem', () => {
})
})
})
describe('submission comments', () => {
it('renders subject', () => {
const props = createProps({
conversation: undefined,
submissionComments: [
SubmissionComment.mock(),
SubmissionComment.mock(),
SubmissionComment.mock(),
SubmissionComment.mock()
]
})
const {getByText} = render(<ConversationListItem {...props} />)
expect(getByText('XavierSchool - This is an Assignment')).toBeTruthy()
})
it('renders create date', () => {
const props = createProps({
conversation: undefined,
submissionComments: [
SubmissionComment.mock(),
SubmissionComment.mock(),
SubmissionComment.mock(),
SubmissionComment.mock()
]
})
const {getByText} = render(<ConversationListItem {...props} />)
expect(getByText('Tue Feb 15 2022')).toBeTruthy()
})
it('renders author', () => {
const props = createProps({
conversation: undefined,
submissionComments: [
SubmissionComment.mock(),
SubmissionComment.mock(),
SubmissionComment.mock(),
SubmissionComment.mock()
]
})
const {getByText} = render(<ConversationListItem {...props} />)
expect(getByText('Hank Mccoy')).toBeTruthy()
})
it('renders comment', () => {
const props = createProps({
conversation: undefined,
submissionComments: [
SubmissionComment.mock(),
SubmissionComment.mock(),
SubmissionComment.mock(),
SubmissionComment.mock()
]
})
const {getByText} = render(<ConversationListItem {...props} />)
expect(getByText('Hey!')).toBeTruthy()
})
})
})

View File

@ -17,19 +17,20 @@
*/
import {AlertManagerContext} from '@canvas/alerts/react/AlertManager'
import {CONVERSATIONS_QUERY} from '../../graphql/Queries'
import {CONVERSATIONS_QUERY, SUBMISSION_COMMENTS_QUERY} from '../../graphql/Queries'
import {UPDATE_CONVERSATION_PARTICIPANTS} from '../../graphql/Mutations'
import {ConversationListHolder} from '../components/ConversationListHolder/ConversationListHolder'
import I18n from 'i18n!conversations_2'
import {Mask} from '@instructure/ui-overlays'
import PropTypes from 'prop-types'
import React, {useContext} from 'react'
import React, {useContext, useEffect, useState} from 'react'
import {Spinner} from '@instructure/ui-spinner'
import {useQuery, useMutation} from 'react-apollo'
import {View} from '@instructure/ui-view'
const ConversationListContainer = ({course, scope, onSelectConversation, userFilter}) => {
const {setOnFailure, setOnSuccess} = useContext(AlertManagerContext)
const [submissionComments, setSubmissionComments] = useState([])
const userID = ENV.current_user_id?.toString()
const [starChangeConversationParticipants] = useMutation(UPDATE_CONVERSATION_PARTICIPANTS, {
@ -61,12 +62,44 @@ const ConversationListContainer = ({course, scope, onSelectConversation, userFil
})
}
const {loading, error, data} = useQuery(CONVERSATIONS_QUERY, {
const scopeIsSubmissionComments = scope === 'submission_comments'
const conversationsQuery = useQuery(CONVERSATIONS_QUERY, {
variables: {userID, scope, filter: [userFilter, course]},
fetchPolicy: 'cache-and-network'
fetchPolicy: 'cache-and-network',
skip: scopeIsSubmissionComments
})
if (loading) {
const submissionCommentsQuery = useQuery(SUBMISSION_COMMENTS_QUERY, {
variables: {userID},
skip: !scopeIsSubmissionComments
})
useEffect(() => {
if (
scopeIsSubmissionComments &&
submissionCommentsQuery.data &&
!submissionCommentsQuery.loading
) {
const groupedSubmissionComments = {}
const submissionComments =
submissionCommentsQuery.data.legacyNode.submissionCommentsConnection.nodes
submissionComments.forEach(submissionComment => {
const key = submissionComment.submissionId + '-' + submissionComment.attempt
if (!groupedSubmissionComments[key]) {
groupedSubmissionComments[key] = []
}
groupedSubmissionComments[key].push(submissionComment)
})
setSubmissionComments(Object.entries(groupedSubmissionComments).map(e => e[1]))
}
}, [scopeIsSubmissionComments, submissionCommentsQuery.data, submissionCommentsQuery.loading])
if (conversationsQuery.loading || submissionCommentsQuery.loading) {
return (
<View as="div" style={{position: 'relative'}} height="100%">
<Mask>
@ -76,16 +109,21 @@ const ConversationListContainer = ({course, scope, onSelectConversation, userFil
)
}
if (error) {
if (conversationsQuery.error || submissionCommentsQuery.error) {
setOnFailure(I18n.t('Unable to load messages.'))
}
return (
<ConversationListHolder
conversations={data?.legacyNode?.conversationsConnection?.nodes}
conversations={
!scopeIsSubmissionComments
? conversationsQuery.data?.legacyNode?.conversationsConnection?.nodes
: submissionComments
}
onOpen={() => {}}
onSelect={onSelectConversation}
onStar={handleStar}
isSubmissionComments={scopeIsSubmissionComments}
/>
)
}