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:
parent
5cf0922f94
commit
56ddf1597d
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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={() => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue