Hook up MessageListContainer/Actions to GraphQL

fixes VICE-862
flag=react_inbox

How to test
1. Load Canvas
2. Validate Canvas React Inbox FF is enabled
3. Open Conversation Application
Expected: Should display new Canvas Inbox with only course and mailbox

Test Cases
Filter Messages by Course
1. Open Conversations Application
2. Select a course from course filter dropdown in upper left
Expected: Should filter messages by course which are not visible
in messageListHolder

Change Mailboxes
1. Open Conversations Application
2. Select the mailbox dropdown that should say 'Inbox'
3. Pick another mailbox
Expected: Should see new mailbox messages in the messageListHolder

Change-Id: Ifc185cc0727c81be2566b3a788e4e7d9c25c7353
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/256995
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Matthew Lemon <mlemon@instructure.com>
QA-Review: Matthew Lemon <mlemon@instructure.com>
Product-Review: Matthew Lemon <mlemon@instructure.com>
This commit is contained in:
Jeffrey Johnson 2021-01-19 12:14:37 -08:00
parent 2d605454cc
commit d0eb97ffdb
26 changed files with 919 additions and 81 deletions

BIN
app/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -17,15 +17,21 @@
*/
import gql from 'graphql-tag'
import {ConversationParticipantWithConversation} from '../graphqlData/ConversationParticipantWithConversation'
import {ConversationParticipantWithConversation} from './graphqlData/ConversationParticipantWithConversation'
import {Enrollments} from './graphqlData/Enrollments'
import {FavoriteCoursesConnection} from './graphqlData/FavoriteCoursesConnection'
import {FavoriteGroupsConnection} from './graphqlData/FavoriteGroupsConnection'
export const CONVERSATIONS_QUERY = gql`
query GetConversationsQuery($userID: ID!) {
query GetConversationsQuery($userID: ID!, $course: String, $scope: String = "") {
legacyNode(_id: $userID, type: User) {
... on User {
_id
id
conversationsConnection {
conversationsConnection(
scope: $scope # e.g. archived
filter: $course # e.g. course_1
) {
nodes {
...ConversationParticipantWithConversation
}
@ -35,3 +41,29 @@ export const CONVERSATIONS_QUERY = gql`
}
${ConversationParticipantWithConversation.fragment}
`
export const COURSES_QUERY = gql`
query GetUserCourses($userID: ID!) {
legacyNode(_id: $userID, type: User) {
... on User {
id
email
favoriteGroupsConnection {
nodes {
...FavoriteGroupsConnection
}
}
favoriteCoursesConnection {
nodes {
...FavoriteCoursesConnection
}
}
enrollments {
...Enrollments
}
}
}
}
${Enrollments.fragment}
${FavoriteCoursesConnection.fragment}
${FavoriteGroupsConnection.fragment}
`

View File

@ -24,64 +24,74 @@ import {ScreenReaderContent} from '@instructure/ui-a11y-content'
import {Select} from '@instructure/ui-select'
import I18n from 'i18n!conversations_2'
const filterOptions = (value, options) => {
const filteredOptions = {}
Object.keys(options).forEach(key => {
filteredOptions[key] = options[key].filter(option =>
option.contextName.toLowerCase().startsWith(value.toLowerCase())
)
})
return filteredOptions
}
export class CourseSelect extends React.Component {
static propTypes = {
mainPage: PropTypes.bool.isRequired,
options: PropTypes.shape({
favoriteCourses: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number,
_id: PropTypes.string,
contextName: PropTypes.string,
contextId: PropTypes.string
assetString: PropTypes.string
})
),
moreCourses: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number,
_id: PropTypes.string,
contextName: PropTypes.string,
contextId: PropTypes.string
assetString: PropTypes.string
})
),
concludedCourses: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number,
_id: PropTypes.string,
contextName: PropTypes.string,
contextId: PropTypes.string
assetString: PropTypes.string
})
),
groups: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number,
_id: PropTypes.string,
contextName: PropTypes.string,
contextId: PropTypes.string
assetString: PropTypes.string
})
)
}).isRequired,
onCourseFilterSelect: PropTypes.func
}
static getDerivedStateFromProps(props, state) {
if (props.options !== state.options) {
return {
filteredOptions: filterOptions(state.inputValue, props.options)
}
}
return null
}
state = {
inputValue: '',
isShowingOptions: false,
options: this.props.options,
filteredOptions: this.props.options,
highlightedOptionId: null,
selectedOptionId: null,
announcement: null
}
filterOptions = value => {
const filteredOptions = {}
Object.keys(this.props.options).forEach(key => {
filteredOptions[key] = this.props.options[key].filter(option =>
option.contextName.toLowerCase().startsWith(value.toLowerCase())
)
})
return filteredOptions
}
getDefaultHighlightedOption = newOptions => {
getDefaultHighlightedOption = (newOptions = []) => {
const options = Object.values(newOptions).flat()
return options.length > 0 ? options[0].contextId : null
return options.length > 0 ? options[0].assetString : null
}
getGroupChangedMessage = newOption => {
@ -104,7 +114,7 @@ export class CourseSelect extends React.Component {
if (!option) return
return this.getGroupLabel(
Object.keys(this.props.options).find(key =>
this.props.options[key].find(({contextId}) => contextId === option.contextId)
this.props.options[key].find(({assetString}) => assetString === option.assetString)
)
)
}
@ -112,7 +122,7 @@ export class CourseSelect extends React.Component {
getOptionById = id => {
return Object.values(this.props.options)
.flat()
.find(({contextId}) => id === contextId)
.find(({assetString}) => id === assetString)
}
handleBlur = () => {
@ -147,7 +157,7 @@ export class CourseSelect extends React.Component {
handleInputChange = event => {
const value = event.target.value
const newOptions = this.filterOptions(value)
const newOptions = filterOptions(value, this.props.options)
this.setState(state => ({
inputValue: value,
filteredOptions: newOptions,
@ -188,14 +198,14 @@ export class CourseSelect extends React.Component {
const {highlightedOptionId, selectedOptionId} = this.state
return Object.keys(options).map(key => {
return options[key].length > 0 ? (
return options[key]?.length > 0 ? (
<Select.Group key={key} renderLabel={this.getGroupLabel(key)}>
{options[key].map(option => (
<Select.Option
id={option.contextId}
key={option.contextId}
isHighlighted={option.contextId === highlightedOptionId}
isSelected={option.contextId === selectedOptionId}
id={option.assetString}
key={option.assetString}
isHighlighted={option.assetString === highlightedOptionId}
isSelected={option.assetString === selectedOptionId}
>
{option.contextName}
</Select.Option>

View File

@ -28,23 +28,23 @@ const Template = args => <CourseSelect {...args} />
const options = {
favoriteCourses: [
{id: 1, contextName: 'Charms', contextId: 'course_1'},
{id: 2, contextName: 'Transfiguration', contextId: 'course_2'}
{_id: 1, contextName: 'Charms', assetString: 'course_1'},
{_id: 2, contextName: 'Transfiguration', assetString: 'course_2'}
],
moreCourses: [
{id: 3, contextName: 'Potions', contextId: 'course_3'},
{id: 4, contextName: 'History of Magic', contextId: 'course_4'},
{id: 5, contextName: 'Herbology', contextId: 'course_5'},
{id: 6, contextName: 'Defense Against the Dark Arts', contextId: 'course_6'}
{_id: 3, contextName: 'Potions', assetString: 'course_3'},
{_id: 4, contextName: 'History of Magic', assetString: 'course_4'},
{_id: 5, contextName: 'Herbology', assetString: 'course_5'},
{_id: 6, contextName: 'Defense Against the Dark Arts', assetString: 'course_6'}
],
concludedCourses: [
{id: 7, contextName: 'Muggle Studies', contextId: 'course_7'},
{id: 8, contextName: 'Astronomy', contextId: 'course_8'}
{_id: 7, contextName: 'Muggle Studies', assetString: 'course_7'},
{_id: 8, contextName: 'Astronomy', assetString: 'course_8'}
],
groups: [
{id: 1, contextName: 'Gryffindor Bros', contextId: 'group_1'},
{id: 2, contextName: 'Quidditch', contextId: 'group_2'},
{id: 3, contextName: "Dumbledore's Army", contextId: 'group_3'}
{_id: 1, contextName: 'Gryffindor Bros', assetString: 'group_1'},
{_id: 2, contextName: 'Quidditch', assetString: 'group_2'},
{_id: 3, contextName: "Dumbledore's Army", assetString: 'group_3'}
]
}

View File

@ -25,23 +25,23 @@ const createProps = overrides => {
mainPage: true,
options: {
favoriteCourses: [
{id: 1, contextName: 'Charms', contextId: 'course_1'},
{id: 2, contextName: 'Transfiguration', contextId: 'course_2'}
{_id: 1, contextName: 'Charms', assetString: 'course_1'},
{_id: 2, contextName: 'Transfiguration', assetString: 'course_2'}
],
moreCourses: [
{id: 3, contextName: 'Potions', contextId: 'course_3'},
{id: 4, contextName: 'History of Magic', contextId: 'course_4'},
{id: 5, contextName: 'Herbology', contextId: 'course_5'},
{id: 6, contextName: 'Defense Against the Dark Arts', contextId: 'course_6'}
{_id: 3, contextName: 'Potions', assetString: 'course_3'},
{_id: 4, contextName: 'History of Magic', assetString: 'course_4'},
{_id: 5, contextName: 'Herbology', assetString: 'course_5'},
{_id: 6, contextName: 'Defense Against the Dark Arts', assetString: 'course_6'}
],
concludedCourses: [
{id: 7, contextName: 'Muggle Studies', contextId: 'course_7'},
{id: 8, contextName: 'Astronomy', contextId: 'course_8'}
{_id: 7, contextName: 'Muggle Studies', assetString: 'course_7'},
{_id: 8, contextName: 'Astronomy', assetString: 'course_8'}
],
groups: [
{id: 1, contextName: 'Gryffindor Bros', contextId: 'group_1'},
{id: 2, contextName: 'Quidditch', contextId: 'group_2'},
{id: 3, contextName: "Dumbledore's Army", contextId: 'group_3'}
{_id: 1, contextName: 'Gryffindor Bros', assetString: 'group_1'},
{_id: 2, contextName: 'Quidditch', assetString: 'group_2'},
{_id: 3, contextName: "Dumbledore's Army", assetString: 'group_3'}
]
},
onCourseFilterSelect: () => {},

View File

@ -17,24 +17,47 @@
*/
import {Flex} from '@instructure/ui-flex'
import React from 'react'
import React, {useState} from 'react'
import MessageListContainer from './MessageListContainer'
import MessageListActionContainer from './MessageListActionContainer'
const CanvasInbox = () => {
const [scope, setScope] = useState('inbox')
const [courseFilter, setCourseFilter] = useState()
const [selectedIds, setSelectedIds] = useState([])
const toggleSelectedMessages = conversation => {
const updatedSelectedIds = selectedIds
if (updatedSelectedIds.includes(conversation._id)) {
const index = updatedSelectedIds.indexOf(conversation._id)
updatedSelectedIds.splice(index, 1)
} else {
updatedSelectedIds.push(conversation._id)
}
setSelectedIds(updatedSelectedIds)
}
return (
<div className="canvas-inbox-container">
<Flex height="100vh" width="100%" as="div" direction="column">
<Flex.Item>
<MessageListActionContainer />
<MessageListActionContainer
onSelectMailbox={setScope}
onCourseFilterSelect={setCourseFilter}
selectdIds={selectedIds}
/>
</Flex.Item>
<Flex.Item shouldGrow shouldShrink>
<Flex height="100%" as="div" align="center" justifyItems="center">
<Flex.Item width="400px" height="100%">
<MessageListContainer />
<MessageListContainer
course={courseFilter}
scope={scope}
onSelectMessage={toggleSelectedMessages}
/>
</Flex.Item>
<Flex.Item shouldGrow shouldShrink height="100%">
{/* Message Content Goes Here */}
<div className="testing-class-name-canvas-inbox">Message Content Goes Here</div>
</Flex.Item>
</Flex>
</Flex.Item>

View File

@ -16,15 +16,63 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {AlertManagerContext} from 'jsx/shared/components/AlertManager'
import {COURSES_QUERY} from '../Queries'
import {CourseSelect} from '../components/CourseSelect/CourseSelect'
import {Flex} from '@instructure/ui-flex'
import I18n from 'i18n!conversations_2'
import {MailboxSelectionDropdown} from '../components/MailboxSelectionDropdown/MailboxSelectionDropdown'
import {MessageActionButtons} from '../components/MessageActionButtons/MessageActionButtons'
import PropTypes from 'prop-types'
import React from 'react'
import {useQuery} from 'react-apollo'
import React, {useContext} from 'react'
import {View} from '@instructure/ui-view'
const MessageListActionContainer = props => {
const {setOnFailure} = useContext(AlertManagerContext)
const userID = ENV.current_user_id?.toString()
const {loading, error, data} = useQuery(COURSES_QUERY, {
variables: {userID}
})
const reduceDuplicateCourses = (enrollments, favoriteCourses) => {
if (!enrollments || !favoriteCourses) {
return []
}
return enrollments
.map(c => {
return {
id: c.course.id,
contextName: c.course.contextName,
assetString: c.course.assetString
}
})
.filter(c => {
let isMatch
for (let i = 0; i < favoriteCourses.length; i++) {
isMatch = favoriteCourses[i].assetString === c.assetString
if (isMatch === true) {
break
}
}
return !isMatch
})
}
if (loading) {
return <span />
}
if (error) {
setOnFailure(I18n.t('Unable to load courses menu.'))
}
const moreCourses = reduceDuplicateCourses(
data?.legacyNode?.enrollments,
data?.legacyNode?.favoriteCoursesConnection?.nodes
)
return (
<View
as="div"
@ -36,15 +84,35 @@ const MessageListActionContainer = props => {
>
<Flex wrap="wrap">
<Flex.Item>
{/* // TODO: Wire up course select with container story */}
<CourseSelect options={[]} onCourseFilterSelect={props.onCourseFilterSelect} />
<CourseSelect
mainPage
options={{
favoriteCourses: data?.legacyNode?.favoriteCoursesConnection?.nodes,
moreCourses,
concludedCourses: [],
groups: data?.legacyNode?.favoriteGroupsConnection?.nodes
}}
onCourseFilterSelect={props.onCourseFilterSelect}
/>
</Flex.Item>
<Flex.Item padding="none none none xxx-small">
<MailboxSelectionDropdown onSelect={props.onSelectMailbox} />
<MailboxSelectionDropdown
activeMailbox={props.activeMailbox}
onSelect={props.onSelectMailbox}
/>
</Flex.Item>
<Flex.Item shouldGrow shouldShrink />
<Flex.Item>
<MessageActionButtons />
<MessageActionButtons
archive={() => {}}
compose={() => {}}
delete={() => {}}
forward={() => {}}
markAsUnread={() => {}}
reply={() => {}}
replyAll={() => {}}
star={() => {}}
/>
</Flex.Item>
</Flex>
</View>
@ -54,6 +122,7 @@ const MessageListActionContainer = props => {
export default MessageListActionContainer
MessageListActionContainer.propTypes = {
activeMailbox: PropTypes.string,
onCourseFilterSelect: PropTypes.func,
onSelectMailbox: PropTypes.func
}

View File

@ -16,14 +16,42 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {AlertManagerContext} from 'jsx/shared/components/AlertManager'
import {CONVERSATIONS_QUERY} from '../Queries'
import {MessageListHolder} from '../components/MessageListHolder/MessageListHolder'
import I18n from 'i18n!conversations_2'
import {Mask} from '@instructure/ui-overlays'
import PropTypes from 'prop-types'
import React from 'react'
import React, {useContext} from 'react'
import {Spinner} from '@instructure/ui-spinner'
import {useQuery} from 'react-apollo'
import {View} from '@instructure/ui-view'
const MessageListContainer = ({course, scope, onSelectMessage}) => {
const {setOnFailure} = useContext(AlertManagerContext)
const userID = ENV.current_user_id?.toString()
const {loading, error, data} = useQuery(CONVERSATIONS_QUERY, {
variables: {userID, scope, course}
})
if (loading) {
return (
<View as="div" style={{position: 'relative'}} height="100%">
<Mask>
<Spinner renderTitle={() => I18n.t('Loading Message List')} variant="inverse" />
</Mask>
</View>
)
}
if (error) {
setOnFailure(I18n.t('Unable to load messages. '))
}
const MessageListContainer = ({onSelectMessage}) => {
return (
<MessageListHolder
conversations={null}
conversations={data?.legacyNode?.conversationsConnection?.nodes}
onOpen={() => {}}
onSelect={onSelectMessage}
onStar={() => {}}
@ -34,9 +62,12 @@ const MessageListContainer = ({onSelectMessage}) => {
export default MessageListContainer
MessageListContainer.propTypes = {
course: PropTypes.string,
scope: PropTypes.string,
onSelectMessage: PropTypes.func
}
MessageListContainer.defaultProps = {
scope: 'inbox',
onSelectMessage: () => {}
}

View File

@ -16,15 +16,71 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {AlertManagerContext} from '../../../shared/components/AlertManager'
import CanvasInbox from '../CanvasInbox'
import {createCache} from '../../../canvas-apollo'
import {CONVERSATIONS_QUERY} from '../../Queries'
import {MockedProvider} from '@apollo/react-testing'
import React from 'react'
import {render} from '@testing-library/react'
import {mockQuery} from '../../mocks'
import waitForApolloLoading from '../../helpers/waitForApolloLoading'
const createGraphqlMocks = () => {
const mocks = [
{
request: {
query: CONVERSATIONS_QUERY,
variables: {
userID: '1',
scope: 'inbox'
},
overrides: {
Node: {
__typename: 'User'
}
}
}
}
]
const mockResults = Promise.all(
mocks.map(async m => {
const result = await mockQuery(m.request.query, m.request.overrides, m.request.variables)
return {
request: {query: m.request.query, variables: m.request.variables},
result
}
})
)
return mockResults
}
const setup = async () => {
const mocks = await createGraphqlMocks()
return render(
<AlertManagerContext.Provider value={{setOnFailure: jest.fn(), setOnSuccess: jest.fn()}}>
<MockedProvider mocks={mocks} cache={createCache()}>
<CanvasInbox />
</MockedProvider>
</AlertManagerContext.Provider>
)
}
describe('CanvasInbox App Container', () => {
beforeEach(() => {
window.ENV = {
current_user_id: 1
}
})
describe('rendering', () => {
it('should render', () => {
const component = render(<CanvasInbox />)
expect(component).toBeTruthy()
it('should render <CanvasInbox />', async () => {
const component = await setup()
await waitForApolloLoading()
expect(await component).toBeTruthy()
})
})
})

View File

@ -0,0 +1,164 @@
/*
* 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 MessageListActionContainer from '../MessageListActionContainer'
import {createCache} from '../../../canvas-apollo'
import {COURSES_QUERY} from '../../Queries'
import {MockedProvider} from '@apollo/react-testing'
import React from 'react'
import {render, fireEvent} from '@testing-library/react'
import {mockQuery} from '../../mocks'
import waitForApolloLoading from '../../helpers/waitForApolloLoading'
const createGraphqlMocks = () => {
const mocks = [
{
request: {
query: COURSES_QUERY,
variables: {
userID: '1'
},
overrides: {
Node: {
__typename: 'User'
}
}
}
}
]
const mockResults = Promise.all(
mocks.map(async m => {
const result = await mockQuery(m.request.query, m.request.overrides, m.request.variables)
return {
request: {query: m.request.query, variables: m.request.variables},
result
}
})
)
return mockResults
}
const setup = async overrideProps => {
const mocks = await createGraphqlMocks()
return render(
<MockedProvider mocks={mocks} cache={createCache()}>
<MessageListActionContainer {...overrideProps} />
</MockedProvider>
)
}
describe('MessageListActionContainer', () => {
beforeEach(() => {
window.ENV = {
current_user_id: 1
}
})
describe('rendering', () => {
it('should render', async () => {
const component = await setup()
await waitForApolloLoading()
expect(component.container).toBeTruthy()
})
it('should call onCourseFilterSelect when course selected ', async () => {
const mock = jest.fn()
const component = await setup({
onCourseFilterSelect: mock
})
await waitForApolloLoading()
const courseDropdown = await component.getByTestId('courseSelect')
fireEvent.click(courseDropdown)
await waitForApolloLoading()
const options = await component.queryAllByText('Hello World')
expect(options.length).toBe(6)
fireEvent.click(options[1])
expect(mock.mock.calls.length).toBe(1)
})
it('should callback to update mailbox when event fires', async () => {
const mock = jest.fn()
const component = await setup({
onSelectMailbox: mock
})
await waitForApolloLoading()
const mailboxDropdown = await component.findByLabelText('Mailbox Selection')
fireEvent.click(mailboxDropdown)
await waitForApolloLoading()
const option = await component.findByText('Sent')
expect(option).toBeTruthy()
fireEvent.click(option)
expect(mock.mock.calls.length).toBe(1)
})
it('should call onSelectMailbox when mailbox changed', async () => {
const mock = jest.fn()
const component = await setup({
onSelectMailbox: mock
})
await waitForApolloLoading()
const mailboxDropdown = await component.findByLabelText('Mailbox Selection')
fireEvent.click(mailboxDropdown)
await waitForApolloLoading()
const option = await component.findByText('Sent')
expect(option).toBeTruthy()
fireEvent.click(option)
expect(mock.mock.calls.length).toBe(1)
})
it('should load with selected mailbox set via props', async () => {
const component = await setup({
activeMailbox: 'sent'
})
await waitForApolloLoading()
const mailboxDropdown = await component.findByDisplayValue('Sent')
expect(mailboxDropdown).toBeTruthy()
})
})
})

View File

@ -0,0 +1,189 @@
/*
* 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 MessageListContainer from '../MessageListContainer'
import {createCache} from '../../../canvas-apollo'
import {CONVERSATIONS_QUERY} from '../../Queries'
import {MockedProvider} from '@apollo/react-testing'
import React from 'react'
import {render, fireEvent} from '@testing-library/react'
import {mockQuery} from '../../mocks'
import waitForApolloLoading from '../../helpers/waitForApolloLoading'
const createGraphqlMocks = () => {
const mocks = [
{
request: {
query: CONVERSATIONS_QUERY,
variables: {
userID: '1',
scope: 'inbox'
},
overrides: {
Node: {
__typename: 'User'
}
}
}
},
{
request: {
query: CONVERSATIONS_QUERY,
variables: {
userID: '1',
scope: 'inbox',
course: 'course_123'
},
overrides: {
Node: {
__typename: 'User'
},
Conversation: () => ({
_id: '1a',
contextType: 'context',
contextId: 2,
subject: 'Second Subject',
updateAt: new Date(),
conversationMessageConnections: [{}],
conversationParticipantsConnection: [{}]
})
}
}
},
{
request: {
query: CONVERSATIONS_QUERY,
variables: {
userID: '1',
scope: 'sent'
},
overrides: {
Node: {
__typename: 'User'
},
Conversation: () => ({
_id: '1a',
contextType: 'context',
contextId: 2,
subject: 'Second Subject',
updateAt: new Date(),
conversationMessageConnections: [{}],
conversationParticipantsConnection: [{}]
})
}
}
}
]
const mockResults = Promise.all(
mocks.map(async m => {
const result = await mockQuery(m.request.query, m.request.overrides, m.request.variables)
return {
request: {query: m.request.query, variables: m.request.variables},
result
}
})
)
return mockResults
}
const setup = async messageListContainerProps => {
const mocks = await createGraphqlMocks()
return render(
<MockedProvider mocks={mocks} cache={createCache()}>
<MessageListContainer {...messageListContainerProps} />
</MockedProvider>
)
}
describe('MessageListContainer', () => {
beforeEach(() => {
window.ENV = {
current_user_id: 1
}
})
describe('converation_query', () => {
it('should render query when successful', async () => {
const component = await setup()
expect(component).toBeTruthy()
})
it('should change list of messages when scope changes', async () => {
const component = await setup()
await waitForApolloLoading()
let messages = await component.queryAllByText('Mock Subject')
expect(messages.length).toBe(2)
component.rerender(
<MockedProvider mocks={await createGraphqlMocks()}>
<MessageListContainer scope="sent" />
</MockedProvider>
)
await waitForApolloLoading()
messages = await component.queryByText('Mock Subject')
expect(messages).toBeNull()
})
it('should change list of messaes when course and scope changes', async () => {
const component = await setup()
await waitForApolloLoading()
let messages = await component.queryAllByText('Mock Subject')
expect(messages.length).toBe(2)
component.rerender(
<MockedProvider mocks={await createGraphqlMocks()}>
<MessageListContainer course="course_123" />
</MockedProvider>
)
await waitForApolloLoading()
messages = await component.queryByText('Mock Subject')
expect(messages).toBeNull()
})
})
describe('Selected Messages', () => {
it('should track when messages are clicked', async () => {
const mock = jest.fn()
const messageList = await setup({
onSelectMessage: mock
})
await waitForApolloLoading()
const checkboxes = await messageList.findAllByText('not selected')
fireEvent(
checkboxes[0],
new MouseEvent('click', {
bubbles: true,
cancelable: true
})
)
expect(mock.mock.calls.length).toBe(1)
})
})
})

View File

@ -41,7 +41,7 @@ export const Attachment = {
}
export const DefaultMocks = {
Attachment: () => ({
File: () => ({
displayName: 'testing.csv',
mimeClass: 'file'
})

View File

@ -17,7 +17,7 @@
*/
import gql from 'graphql-tag'
import {shape, string} from 'prop-types'
import {number, shape, string} from 'prop-types'
import {ConversationMessage} from './ConversationMessage'
import {ConversationParticipant} from './ConversationParticipant'
@ -45,10 +45,22 @@ export const Conversation = {
shape: shape({
_id: string,
contextId: string,
contextId: number,
contextType: string,
subject: string,
conversationMessagesConnection: ConversationMessage.shape,
conversationParticipantsConnection: ConversationParticipant.shape
})
}
export const DefaultMocks = {
Conversation: () => ({
_id: '1a',
contextType: 'context',
contextId: 2,
subject: 'Mock Subject',
updatedAt: 'November 5, 2020 at 2:25pm',
conversationMessagesConnection: {edges: [{}]},
conversationParticipantsConnection: {edges: [{}]}
})
}

View File

@ -26,6 +26,7 @@ export const ConversationMessage = {
fragment: gql`
fragment ConversationMessage on ConversationMessage {
_id
id
createdAt
body
attachmentsConnection {
@ -54,3 +55,15 @@ export const ConversationMessage = {
mediaComment: MediaComment.shape
})
}
export const DefaultMocks = {
ConversationMessage: () => ({
_id: '1a',
conversationId: 'mockConversation',
body: 'This is the body of a mocked message',
createdAt: 'November 5, 2020 at 2:25pm',
author: {},
mediaComment: {},
attachmentConnection: [{}]
})
}

View File

@ -29,6 +29,7 @@ export const ConversationParticipant = {
user {
...User
}
workflowState
}
${User.fragment}
`,
@ -40,3 +41,14 @@ export const ConversationParticipant = {
user: User.shape
})
}
export const DefaultMocks = {
ConversationParticipant: () => ({
_id: '1a',
user_id: 'mockUserId',
workflowState: 'unread',
label: 'starred',
subscribed: false,
updatedAt: 'November 5, 2020 at 2:25pm'
})
}

View File

@ -41,3 +41,13 @@ export const ConversationParticipantWithConversation = {
user: User.shape
})
}
export const DefaultMocks = {
ConversationParticipant: () => ({
_id: 'mock_id',
id: 'mockId',
label: 'someLabel',
conversation: {},
user: {}
})
}

View File

@ -0,0 +1,48 @@
/*
* 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 gql from 'graphql-tag'
import {shape, string} from 'prop-types'
export const Enrollments = {
fragment: gql`
fragment Enrollments on Enrollment {
type
course {
_id
contextName: name
assetString
}
}
`,
shape: shape({
id: string,
contextName: string,
assetString: string
})
}
export const DefaultMocks = {
Enrollment: () => ({
course: {
_id: '1',
contextName: 'contextName',
assetString: 'contextId'
}
})
}

View File

@ -0,0 +1,43 @@
/*
* 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 gql from 'graphql-tag'
import {shape, string} from 'prop-types'
export const FavoriteCoursesConnection = {
fragment: gql`
fragment FavoriteCoursesConnection on Course {
_id
contextName: name
assetString
}
`,
shape: shape({
id: string,
contextName: string,
assetString: string
})
}
export const DefaultMocks = {
Course: () => ({
id: 'someId',
contextName: 'someString',
assetString: 'someId'
})
}

View File

@ -0,0 +1,43 @@
/*
* 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 gql from 'graphql-tag'
import {shape, string} from 'prop-types'
export const FavoriteGroupsConnection = {
fragment: gql`
fragment FavoriteGroupsConnection on Group {
_id
contextName: name
assetString
}
`,
shape: shape({
id: string,
contextName: string,
assetString: string
})
}
export const DefaultMocks = {
Group: () => ({
id: 'someId',
contextName: 'someContextName',
assetString: 'someContextId'
})
}

View File

@ -49,7 +49,7 @@ export const MediaComment = {
}
export const DefaultMocks = {
MediaComment: () => ({
MediaObject: () => ({
title: 'Test Media Comment Video',
canAddCaptions: true
})

View File

@ -35,3 +35,13 @@ export const MediaTrack = {
kind: string
})
}
export const DefaultMocks = {
MediaTrack: () => ({
kind: 'kindString',
local: 'en-us',
content: 'mockContent',
mediaObject: {},
webvttContent: 'webvttContent'
})
}

View File

@ -0,0 +1,21 @@
/*
* 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/>.
*/
export default async () => {
await new Promise(resolve => setTimeout(resolve, 0))
}

View File

@ -0,0 +1,46 @@
/*
* 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 glob from 'glob'
import mockGraphqlQuery from '../shared/graphql_query_mock'
let _dynamicDefaultMockImports = null
const loadDefaultMocks = async () => {
if (_dynamicDefaultMockImports !== null) {
return _dynamicDefaultMockImports
}
const filesToImport = glob.sync('./graphqlData/**.js', {cwd: './app/jsx/canvas_inbox'})
const defaultMocks = await Promise.all(
filesToImport.map(async file => {
const fileImport = await import(file)
return fileImport.DefaultMocks || {}
})
)
_dynamicDefaultMockImports = defaultMocks.filter(m => m !== undefined)
return _dynamicDefaultMockImports
}
export async function mockQuery(queryAST, overrides = [], variables = {}) {
if (!Array.isArray(overrides)) {
overrides = [overrides]
}
const defaultOverrides = await loadDefaultMocks()
const allOverrides = [...defaultOverrides, ...overrides]
return mockGraphqlQuery(queryAST, allOverrides, variables)
}

View File

@ -1,6 +1,21 @@
{
"__schema": {
"types": [
{
"kind": "INTERFACE",
"name": "AssetString",
"possibleTypes": [
{
"name": "Course"
},
{
"name": "Enrollment"
},
{
"name": "Group"
}
]
},
{
"kind": "UNION",
"name": "AssignmentOverrideSet",
@ -392,15 +407,6 @@
{
"name": "ContentTag"
},
{
"name": "Conversation"
},
{
"name": "ConversationMessage"
},
{
"name": "ConversationParticipant"
},
{
"name": "Course"
},