implement reply for conversation messages

fixes VICE-2141
flag=react_inbox

Test Plan:
- Enable the react inbox FF
- Navigate to the inbox
- Open a conversation and reply to a conversation message
  within the conversation
- Huzzah

Change-Id: If9d4baf4d538c4ea07f8df355651407276180279
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/276561
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Jeffrey Johnson <jeffrey.johnson@instructure.com>
QA-Review: Jeffrey Johnson <jeffrey.johnson@instructure.com>
Product-Review: Jeffrey Johnson <jeffrey.johnson@instructure.com>
This commit is contained in:
Matthew Lemon 2021-10-21 15:59:11 -06:00
parent 5cf0922f94
commit 56ddf1597d
10 changed files with 63 additions and 32 deletions

View File

@ -75,13 +75,13 @@ export const COURSES_QUERY = gql`
`
export const REPLY_CONVERSATION_QUERY = gql`
query ReplyConversationQuery($conversationID: ID!, $participants: [ID!]) {
query ReplyConversationQuery($conversationID: ID!, $participants: [ID!], $createdBefore: String) {
legacyNode(_id: $conversationID, type: Conversation) {
... on Conversation {
_id
contextName
subject
conversationMessagesConnection(participants: $participants) {
conversationMessagesConnection(participants: $participants, createdBefore: $createdBefore) {
nodes {
...ConversationMessage
}

View File

@ -17,13 +17,13 @@
*/
import {Button, IconButton} from '@instructure/ui-buttons'
import I18n from 'i18n!conversations_2'
import {IconMoreLine, IconReplyLine} from '@instructure/ui-icons'
import {Menu} from '@instructure/ui-menu'
import PropTypes from 'prop-types'
import React from 'react'
import {ScreenReaderContent} from '@instructure/ui-a11y-content'
import {Tooltip} from '@instructure/ui-tooltip'
import I18n from 'i18n!conversations_2'
export const MessageDetailActions = ({...props}) => {
return (
@ -33,7 +33,7 @@ export const MessageDetailActions = ({...props}) => {
size="small"
margin="0 x-small 0 0"
screenReaderLabel={I18n.t('Reply')}
onClick={() => props.handleOptionSelect('reply')}
onClick={props.onReply}
>
<IconReplyLine />
</IconButton>
@ -60,5 +60,6 @@ export const MessageDetailActions = ({...props}) => {
}
MessageDetailActions.propTypes = {
handleOptionSelect: PropTypes.func
handleOptionSelect: PropTypes.func,
onReply: PropTypes.func
}

View File

@ -22,24 +22,23 @@ import {MessageDetailActions} from '../MessageDetailActions'
describe('MessageDetailItem', () => {
it('sends the selected option to the provided callback function', () => {
const handleOptionSelectMock = jest.fn()
const props = {
handleOptionSelect: handleOptionSelectMock
handleOptionSelect: jest.fn(),
onReply: jest.fn()
}
const {getByRole, getByText} = render(<MessageDetailActions {...props} />)
const replyButton = getByRole(
(role, element) => role === 'button' && element.textContent === 'Reply'
)
fireEvent.click(replyButton)
expect(handleOptionSelectMock).toHaveBeenLastCalledWith('reply')
expect(props.onReply).toHaveBeenCalled()
const moreOptionsButton = getByRole(
(role, element) => role === 'button' && element.textContent === 'More options'
)
fireEvent.click(moreOptionsButton)
fireEvent.click(getByText('Reply All'))
expect(handleOptionSelectMock).toHaveBeenLastCalledWith('reply-all')
expect(props.handleOptionSelect).toHaveBeenLastCalledWith('reply-all')
})
})

View File

@ -38,7 +38,7 @@ export const MessageDetailHeader = ({...props}) => {
<IconButton
margin="0 x-small 0 0"
screenReaderLabel={I18n.t('Reply')}
onClick={() => props.handleOptionSelect('reply')}
onClick={() => props.onReply()}
>
<IconReplyLine />
</IconButton>
@ -71,7 +71,8 @@ export const MessageDetailHeader = ({...props}) => {
MessageDetailHeader.propTypes = {
text: PropTypes.string,
handleOptionSelect: PropTypes.func
handleOptionSelect: PropTypes.func,
onReply: PropTypes.func
}
MessageDetailHeader.defaultProps = {

View File

@ -27,21 +27,24 @@ describe('MessageDetailHeader', () => {
})
it('sends the selected option to the provided callback function', () => {
const handleOptionSelectMock = jest.fn()
const props = {text: 'Button Test', handleOptionSelect: handleOptionSelectMock}
const props = {
text: 'Button Test',
handleOptionSelect: jest.fn(),
onReply: jest.fn()
}
const {getByRole, getByText} = render(<MessageDetailHeader {...props} />)
const replyButton = getByRole(
(role, element) => role === 'button' && element.textContent === 'Reply'
)
fireEvent.click(replyButton)
expect(handleOptionSelectMock).toHaveBeenLastCalledWith('reply')
expect(props.onReply).toHaveBeenCalled()
const moreOptionsButton = getByRole(
(role, element) => role === 'button' && element.textContent === 'More options'
)
fireEvent.click(moreOptionsButton)
fireEvent.click(getByText('Forward'))
expect(handleOptionSelectMock).toHaveBeenLastCalledWith('forward')
expect(props.handleOptionSelect).toHaveBeenLastCalledWith('forward')
})
})

View File

@ -63,7 +63,10 @@ export const MessageDetailItem = ({...props}) => {
<View as="div" margin="none none x-small">
<Text weight="light">{createdAt}</Text>
</View>
<MessageDetailActions handleOptionSelect={props.handleOptionSelect} />
<MessageDetailActions
handleOptionSelect={props.handleOptionSelect}
onReply={() => props.onReply(props.conversationMessage)}
/>
</Flex.Item>
</Flex>
<Text>{props.conversationMessage.body}</Text>
@ -88,7 +91,8 @@ MessageDetailItem.propTypes = {
// TODO: not sure yet the exact shape of the data that will be fetched, so these will likely change
conversationMessage: PropTypes.object,
contextName: PropTypes.string,
handleOptionSelect: PropTypes.func
handleOptionSelect: PropTypes.func,
onReply: PropTypes.func
}
MessageDetailItem.defaultProps = {

View File

@ -70,7 +70,6 @@ describe('MessageDetailItem', () => {
})
it('sends the selected option to the provided callback function', () => {
const handleOptionSelectMock = jest.fn()
const props = {
conversationMessage: {
author: {name: 'Tom Thompson'},
@ -79,7 +78,8 @@ describe('MessageDetailItem', () => {
body: 'This is the body text for the message.'
},
contextName: 'Fake Course 1',
handleOptionSelect: handleOptionSelectMock
handleOptionSelect: jest.fn(),
onReply: jest.fn()
}
const {getByRole, getByText} = render(<MessageDetailItem {...props} />)
@ -88,13 +88,13 @@ describe('MessageDetailItem', () => {
(role, element) => role === 'button' && element.textContent === 'Reply'
)
fireEvent.click(replyButton)
expect(handleOptionSelectMock).toHaveBeenLastCalledWith('reply')
expect(props.onReply).toHaveBeenLastCalledWith(props.conversationMessage)
const moreOptionsButton = getByRole(
(role, element) => role === 'button' && element.textContent === 'More options'
)
fireEvent.click(moreOptionsButton)
fireEvent.click(getByText('Forward'))
expect(handleOptionSelectMock).toHaveBeenLastCalledWith('forward')
expect(props.handleOptionSelect).toHaveBeenLastCalledWith('forward')
})
})

View File

@ -29,6 +29,7 @@ const CanvasInbox = () => {
const [scope, setScope] = useState('inbox')
const [courseFilter, setCourseFilter] = useState()
const [selectedConversations, setSelectedConversations] = useState([])
const [selectedConversationMessage, setSelectedConversationMessage] = useState()
const [composeModal, setComposeModal] = useState(false)
const [deleteDisabled, setDeleteDisabled] = useState(true)
const [archiveDisabled, setArchiveDisabled] = useState(true)
@ -41,6 +42,7 @@ const CanvasInbox = () => {
setSelectedConversations(conversations)
setDeleteDisabled(conversations.length === 0)
setArchiveDisabled(conversations.length === 0)
setSelectedConversationMessage(null)
}
const removeFromSelectedConversations = conversations => {
@ -86,6 +88,7 @@ const CanvasInbox = () => {
selectedConversations={selectedConversations}
onCompose={() => setComposeModal(true)}
onReply={() => {
setSelectedConversationMessage(null)
setIsReply(true)
setComposeModal(true)
}}
@ -112,7 +115,14 @@ const CanvasInbox = () => {
</Flex.Item>
<Flex.Item shouldGrow shouldShrink height="100%" overflowY="auto">
{selectedConversations.length > 0 ? (
<MessageDetailContainer conversation={selectedConversations[0]} />
<MessageDetailContainer
conversation={selectedConversations[0]}
onReply={conversationMessage => {
setSelectedConversationMessage(conversationMessage)
setIsReply(true)
setComposeModal(true)
}}
/>
) : (
<View padding="small">
<NoSelectedConversation />
@ -124,6 +134,7 @@ const CanvasInbox = () => {
</Flex>
<ComposeModalManager
conversation={selectedConversations[0]}
conversationMessage={selectedConversationMessage}
isReply={isReply}
isReplyAll={isReplyAll}
onDismiss={() => {

View File

@ -20,6 +20,7 @@ import {ADD_CONVERSATION_MESSAGE, CREATE_CONVERSATION} from '../../../graphql/Mu
import {AlertManagerContext} from '@canvas/alerts/react/AlertManager'
import ComposeModalContainer from './ComposeModalContainer'
import {Conversation} from '../../../graphql/Conversation'
import {ConversationMessage} from '../../../graphql/ConversationMessage'
import {
CONVERSATIONS_QUERY,
COURSES_QUERY,
@ -43,20 +44,25 @@ const ComposeModalManager = props => {
})
const getParticipants = () => {
const lastAuthorId = props.conversation?.conversationMessagesConnection.nodes[0].author._id.toString()
const lastAuthorId = props.conversationMessage
? props.conversationMessage?.author._id.toString()
: props.conversation?.conversationMessagesConnection.nodes[0].author._id.toString()
if (props.isReply && lastAuthorId !== ENV.current_user_id.toString()) {
return [lastAuthorId]
} else {
return props.conversation?.conversationMessagesConnection.nodes[0].recipients.map(r =>
r._id.toString()
)
const recipients = props.conversationMessage
? props.conversationMessage?.recipients
: props.conversation?.conversationMessagesConnection?.nodes[0]?.recipients
return recipients?.map(r => r._id.toString())
}
}
const replyConversationQuery = useQuery(REPLY_CONVERSATION_QUERY, {
variables: {
conversationID: props.conversation?._id,
participants: getParticipants()
participants: getParticipants(),
...(props.conversationMessage && {createdBefore: props.conversationMessage.createdAt})
},
skip: !(props.isReply || props.isReplyAll)
})
@ -112,7 +118,8 @@ const ComposeModalManager = props => {
query: REPLY_CONVERSATION_QUERY,
variables: {
conversationID: props.conversation?._id,
participants: getParticipants()
participants: getParticipants(),
...(props.conversationMessage && {createdBefore: props.conversationMessage.createdAt})
}
})
)
@ -126,7 +133,8 @@ const ComposeModalManager = props => {
query: REPLY_CONVERSATION_QUERY,
variables: {
conversationID: props.conversation?._id,
participants: getParticipants()
participants: getParticipants(),
...(props.conversationMessage && {createdBefore: props.conversationMessage.createdAt})
},
data: {legacyNode: replyQueryResult.legacyNode}
})
@ -214,6 +222,7 @@ const ComposeModalManager = props => {
ComposeModalManager.propTypes = {
conversation: Conversation.shape,
conversationMessage: ConversationMessage.shape,
isReply: PropTypes.bool,
isReplyAll: PropTypes.bool,
onDismiss: PropTypes.func,

View File

@ -19,6 +19,7 @@
import {Conversation} from '../../../graphql/Conversation'
import {MessageDetailHeader} from '../../components/MessageDetailHeader/MessageDetailHeader'
import {MessageDetailItem} from '../../components/MessageDetailItem/MessageDetailItem'
import PropTypes from 'prop-types'
import React from 'react'
import {View} from '@instructure/ui-view'
@ -26,12 +27,13 @@ import {View} from '@instructure/ui-view'
export const MessageDetailContainer = props => {
return (
<>
<MessageDetailHeader text={props.conversation.subject} />
<MessageDetailHeader text={props.conversation.subject} onReply={props.onReply} />
{props.conversation.conversationMessagesConnection.nodes.map(message => (
<View as="div" borderWidth="small none none none" padding="small" key={message.id}>
<MessageDetailItem
conversationMessage={message}
context={props.conversation.contextName}
onReply={props.onReply}
/>
</View>
))}
@ -40,5 +42,6 @@ export const MessageDetailContainer = props => {
}
MessageDetailContainer.propTypes = {
conversation: Conversation.shape
conversation: Conversation.shape,
onReply: PropTypes.func
}