add media comments to message students who
closes EVAL-2185 flag=message_observers_of_students_who Test Plan: 1. Enable the Site Admin Flag "Message Observers of Students Who..." 2. In Gradebook, verify you can now add media comments when using "Message Students Who...". Things to check: - You can capture video/audio, or upload an existing video/audio file. - You can add caption files along with your video, and they show up as expected when playing the video. - After a media file is attached, a playable preview of the video shows up under the message. The video can be set to full-screen. - If a custom name is provided for an uploaded media file, that name is shown under the media preview (and not the original file name). - After actually sending the message, verify in Canvas Inbox as a student that the received message includes the media file. Change-Id: I0dfba779c55ba6dbe88c100f3b45f5715038e3d6 Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/291759 Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com> Reviewed-by: Kai Bjorkman <kbjorkman@instructure.com> Reviewed-by: Aaron Shafovaloff <ashafovaloff@instructure.com> QA-Review: Kai Bjorkman <kbjorkman@instructure.com> Product-Review: Jody Sailor
This commit is contained in:
parent
047816633a
commit
43e6393a7a
|
@ -352,7 +352,7 @@ class ConversationsController < ApplicationController
|
|||
# uploaded to the sender's "conversation attachments" folder.
|
||||
#
|
||||
# @argument media_comment_id [String]
|
||||
# Media comment id of an audio of video file to be associated with this
|
||||
# Media comment id of an audio or video file to be associated with this
|
||||
# message.
|
||||
#
|
||||
# @argument media_comment_type [String, "audio"|"video"]
|
||||
|
|
|
@ -153,6 +153,7 @@ export default class UploadMedia extends React.Component {
|
|||
uploadFile(file) {
|
||||
this.setState({uploading: true}, () => {
|
||||
this.props.onStartUpload && this.props.onStartUpload(file)
|
||||
file.userEnteredTitle = file.title
|
||||
saveMediaRecording(
|
||||
file,
|
||||
this.props.rcsConfig,
|
||||
|
@ -171,15 +172,16 @@ export default class UploadMedia extends React.Component {
|
|||
this.props.onUploadComplete && this.props.onUploadComplete(err, data)
|
||||
} else {
|
||||
try {
|
||||
let captions
|
||||
if (this.state.selectedPanel === PANELS.COMPUTER && this.state.subtitles.length > 0) {
|
||||
await saveClosedCaptions(
|
||||
captions = await saveClosedCaptions(
|
||||
data.mediaObject.media_object.media_id,
|
||||
this.state.subtitles,
|
||||
this.props.rcsConfig,
|
||||
RCS_MAX_BODY_SIZE - RCS_REQUEST_SIZE_BUFFER
|
||||
)
|
||||
}
|
||||
this.props.onUploadComplete && this.props.onUploadComplete(null, data)
|
||||
this.props.onUploadComplete && this.props.onUploadComplete(null, data, captions?.data)
|
||||
} catch (ex) {
|
||||
this.props.onUploadComplete && this.props.onUploadComplete(ex, null)
|
||||
}
|
||||
|
|
|
@ -114,6 +114,52 @@ describe('saveMediaRecording', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('uploads with the user entered title, if one is provided', () => {
|
||||
moxios.stubRequest('http://host:port/api/v1/services/kaltura_session?include_upload_config=1', {
|
||||
status: 200,
|
||||
response: mediaServerSession()
|
||||
})
|
||||
moxios.stubRequest('/api/v1/media_objects', {
|
||||
status: 200,
|
||||
response: {data: 'media object data'}
|
||||
})
|
||||
|
||||
return saveMediaRecording(
|
||||
{name: 'hi', userEnteredTitle: 'my awesome video'},
|
||||
rcsConfig,
|
||||
() => {},
|
||||
() => {}
|
||||
).then(async uploader => {
|
||||
uploader.dispatchEvent('K5.complete', {stuff: 'datatatatatatatat'}, uploader)
|
||||
await new Promise(setTimeout)
|
||||
const {data} = moxios.requests.mostRecent().config
|
||||
expect(JSON.parse(data).user_entered_title).toEqual('my awesome video')
|
||||
})
|
||||
})
|
||||
|
||||
it('uploads with the file name if no user entered title is provided', () => {
|
||||
moxios.stubRequest('http://host:port/api/v1/services/kaltura_session?include_upload_config=1', {
|
||||
status: 200,
|
||||
response: mediaServerSession()
|
||||
})
|
||||
moxios.stubRequest('/api/v1/media_objects', {
|
||||
status: 200,
|
||||
response: {data: 'media object data'}
|
||||
})
|
||||
|
||||
return saveMediaRecording(
|
||||
{name: 'hi'},
|
||||
rcsConfig,
|
||||
() => {},
|
||||
() => {}
|
||||
).then(async uploader => {
|
||||
uploader.dispatchEvent('K5.complete', {stuff: 'datatatatatatatat'}, uploader)
|
||||
await new Promise(setTimeout)
|
||||
const {data} = moxios.requests.mostRecent().config
|
||||
expect(JSON.parse(data).user_entered_title).toEqual('hi')
|
||||
})
|
||||
})
|
||||
|
||||
it('k5.complete calls done with canvasMediaObject data if succeeds', () => {
|
||||
moxios.stubRequest('http://host:port/api/v1/services/kaltura_session?include_upload_config=1', {
|
||||
status: 200,
|
||||
|
|
|
@ -72,7 +72,7 @@ function addUploaderFileCompleteEventListeners(uploader, context, file, done, on
|
|||
: 'video',
|
||||
context_code: mediaServerMediaObject.contextCode,
|
||||
title: file.name,
|
||||
user_entered_title: file.name
|
||||
user_entered_title: file.userEnteredTitle || file.name
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
@ -65,7 +65,7 @@ QUnit.module('GradebookHeaderMenu#hideMenuActionsWithUnmetDependencies', {
|
|||
submissions_downloads: 1
|
||||
}
|
||||
this.gradebook = {
|
||||
options: {gradebook_is_editable: true}
|
||||
options: {gradebook_is_editable: true, currentUserId: '123'}
|
||||
}
|
||||
this.menuElement = document.createElement('ul')
|
||||
this.createMenu(this.menuElement)
|
||||
|
@ -146,7 +146,7 @@ test('hides the curveGrades menu item when @assignment.points_possible is 0', fu
|
|||
})
|
||||
|
||||
test('does not hide the downloadSubmissions menu item when @assignment.submission_types is online_text_entry or online_url', function () {
|
||||
;['online_text_entry', 'online_url'].forEach(submission_type => {
|
||||
;['online_text_entry', 'online_url'].forEach(_submission_type => {
|
||||
this.assignment.submission_types = 'online_text_entry'
|
||||
this.hideMenuActionsWithUnmetDependencies(this.menu)
|
||||
ok(this.visibleMenuItemNames(this.menu).includes('downloadSubmissions'))
|
||||
|
@ -183,7 +183,7 @@ QUnit.module('GradebookHeaderMenu#disableUnavailableMenuActions', {
|
|||
this.createMenu(this.menuElement)
|
||||
this.menu = $(this.menuElement)
|
||||
this.gradebook = {
|
||||
options: {gradebook_is_editable: true}
|
||||
options: {gradebook_is_editable: true, currentUserId: '123'}
|
||||
}
|
||||
},
|
||||
teardown() {
|
||||
|
@ -437,7 +437,8 @@ QUnit.module('GradebookHeaderMenu#messageStudentsWho', () => {
|
|||
score: 4,
|
||||
submittedAt: undefined
|
||||
}
|
||||
]
|
||||
],
|
||||
userId: '1'
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
@ -1268,7 +1268,7 @@ QUnit.module('Gradebook#toggleViewUngradedAsZero', hooks => {
|
|||
})
|
||||
})
|
||||
|
||||
QUnit.module('Gradebook#sendMesssageStudentsWho', hooks => {
|
||||
QUnit.module('Gradebook#sendMessageStudentsWho', hooks => {
|
||||
let gradebook
|
||||
let apiRequestStub
|
||||
|
||||
|
@ -1307,7 +1307,7 @@ QUnit.module('Gradebook#sendMesssageStudentsWho', hooks => {
|
|||
sandbox.stub(FlashAlert, 'showFlashSuccess')
|
||||
sandbox.stub(FlashAlert, 'showFlashError')
|
||||
|
||||
apiRequestStub = sinon.stub(GradebookApi, 'sendMesssageStudentsWho').resolves()
|
||||
apiRequestStub = sinon.stub(GradebookApi, 'sendMessageStudentsWho').resolves()
|
||||
})
|
||||
|
||||
hooks.afterEach(() => {
|
||||
|
@ -1316,8 +1316,8 @@ QUnit.module('Gradebook#sendMesssageStudentsWho', hooks => {
|
|||
FlashAlert.showFlashError.restore()
|
||||
})
|
||||
|
||||
test('sends the messages via Gradebook.sendMesssageStudentsWho', async () => {
|
||||
await gradebook.sendMesssageStudentsWho({
|
||||
test('sends the messages via Gradebook.sendMessageStudentsWho', async () => {
|
||||
await gradebook.sendMessageStudentsWho({
|
||||
recipientsIds,
|
||||
subject,
|
||||
body
|
||||
|
@ -1327,7 +1327,7 @@ QUnit.module('Gradebook#sendMesssageStudentsWho', hooks => {
|
|||
})
|
||||
|
||||
test('includes recipientsIds as the first parameter', async () => {
|
||||
await gradebook.sendMesssageStudentsWho({
|
||||
await gradebook.sendMessageStudentsWho({
|
||||
recipientsIds,
|
||||
subject,
|
||||
body
|
||||
|
@ -1337,7 +1337,7 @@ QUnit.module('Gradebook#sendMesssageStudentsWho', hooks => {
|
|||
})
|
||||
|
||||
test('includes subject as the second parameter', async () => {
|
||||
await gradebook.sendMesssageStudentsWho({
|
||||
await gradebook.sendMessageStudentsWho({
|
||||
recipientsIds,
|
||||
subject,
|
||||
body
|
||||
|
@ -1347,7 +1347,7 @@ QUnit.module('Gradebook#sendMesssageStudentsWho', hooks => {
|
|||
})
|
||||
|
||||
test('includes body as the third parameter', async () => {
|
||||
await gradebook.sendMesssageStudentsWho({
|
||||
await gradebook.sendMessageStudentsWho({
|
||||
recipientsIds,
|
||||
subject,
|
||||
body
|
||||
|
@ -1358,7 +1358,7 @@ QUnit.module('Gradebook#sendMesssageStudentsWho', hooks => {
|
|||
|
||||
test('shows a success flash alert when the process succeeds', async () => {
|
||||
const message = 'Message sent successfully'
|
||||
await gradebook.sendMesssageStudentsWho({
|
||||
await gradebook.sendMessageStudentsWho({
|
||||
recipientsIds,
|
||||
subject,
|
||||
body
|
||||
|
@ -1370,7 +1370,7 @@ QUnit.module('Gradebook#sendMesssageStudentsWho', hooks => {
|
|||
let errorThrown = false
|
||||
apiRequestStub.rejects(new Error(':-/'))
|
||||
try {
|
||||
await gradebook.sendMesssageStudentsWho({
|
||||
await gradebook.sendMessageStudentsWho({
|
||||
recipientsIds,
|
||||
subject,
|
||||
body
|
||||
|
|
|
@ -878,10 +878,10 @@ QUnit.module('GradebookGrid AssignmentColumnHeaderRenderer', suiteHooks => {
|
|||
const contextCode = '1'
|
||||
|
||||
buildGradebook()
|
||||
sinon.stub(gradebook, 'sendMesssageStudentsWho')
|
||||
sinon.stub(gradebook, 'sendMessageStudentsWho')
|
||||
render()
|
||||
component.props.onSendMesssageStudentsWho(recipientsIds, subject, body, contextCode)
|
||||
strictEqual(gradebook.sendMesssageStudentsWho.callCount, 1)
|
||||
component.props.onSendMessageStudentsWho(recipientsIds, subject, body, contextCode)
|
||||
strictEqual(gradebook.sendMessageStudentsWho.callCount, 1)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -176,7 +176,9 @@ QUnit.module('GradebookGrid AssignmentColumnHeader', suiteHooks => {
|
|||
settingKey: 'grade'
|
||||
},
|
||||
|
||||
submissionsLoaded: true
|
||||
submissionsLoaded: true,
|
||||
|
||||
userId: '123'
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
@ -237,19 +237,19 @@ QUnit.module('GradebookApi.updateSubmission', hooks => {
|
|||
}))
|
||||
})
|
||||
|
||||
QUnit.module('GradebookApi.sendMesssageStudentsWho', hooks => {
|
||||
QUnit.module('GradebookApi.sendMessageStudentsWho', hooks => {
|
||||
const recipientsIds = [1, 2, 3, 4]
|
||||
const subject = 'foo'
|
||||
const body = 'bar'
|
||||
const contextCode = '1'
|
||||
const sendMesssageStudentsWhoUrl = `/api/v1/conversations`
|
||||
const sendMessageStudentsWhoUrl = `/api/v1/conversations`
|
||||
const data = {}
|
||||
let server
|
||||
|
||||
hooks.beforeEach(() => {
|
||||
server = sinon.fakeServer.create({respondImmediately: true})
|
||||
const responseBody = JSON.stringify(data)
|
||||
server.respondWith('POST', sendMesssageStudentsWhoUrl, [
|
||||
server.respondWith('POST', sendMessageStudentsWhoUrl, [
|
||||
200,
|
||||
{'Content-Type': 'application/json'},
|
||||
responseBody
|
||||
|
@ -262,18 +262,18 @@ QUnit.module('GradebookApi.sendMesssageStudentsWho', hooks => {
|
|||
|
||||
function getRequest() {
|
||||
// filter requests to eliminate spec pollution from unrelated specs
|
||||
return _.find(server.requests, request => request.url.includes(sendMesssageStudentsWhoUrl))
|
||||
return _.find(server.requests, request => request.url.includes(sendMessageStudentsWhoUrl))
|
||||
}
|
||||
|
||||
test('sends a post request to the "conversations" url', () =>
|
||||
GradebookApi.sendMesssageStudentsWho(recipientsIds, subject, body, contextCode).then(() => {
|
||||
GradebookApi.sendMessageStudentsWho(recipientsIds, subject, body, contextCode).then(() => {
|
||||
const request = getRequest()
|
||||
strictEqual(request.method, 'POST')
|
||||
strictEqual(request.url, sendMesssageStudentsWhoUrl)
|
||||
strictEqual(request.url, sendMessageStudentsWhoUrl)
|
||||
}))
|
||||
|
||||
test('sends async for mode parameter', () =>
|
||||
GradebookApi.sendMesssageStudentsWho(recipientsIds, subject, body, contextCode)
|
||||
GradebookApi.sendMessageStudentsWho(recipientsIds, subject, body, contextCode)
|
||||
.then(() => {})
|
||||
.then(() => {
|
||||
const bodyData = JSON.parse(getRequest().requestBody)
|
||||
|
@ -281,14 +281,31 @@ QUnit.module('GradebookApi.sendMesssageStudentsWho', hooks => {
|
|||
}))
|
||||
|
||||
test('sends true for group_conversation parameter', () =>
|
||||
GradebookApi.sendMesssageStudentsWho(recipientsIds, subject, body, contextCode).then(() => {
|
||||
GradebookApi.sendMessageStudentsWho(recipientsIds, subject, body, contextCode).then(() => {
|
||||
const bodyData = JSON.parse(getRequest().requestBody)
|
||||
deepEqual(bodyData.group_conversation, true)
|
||||
}))
|
||||
|
||||
test('sends true for bulk_message parameter', () =>
|
||||
GradebookApi.sendMesssageStudentsWho(recipientsIds, subject, body, contextCode).then(() => {
|
||||
GradebookApi.sendMessageStudentsWho(recipientsIds, subject, body, contextCode).then(() => {
|
||||
const bodyData = JSON.parse(getRequest().requestBody)
|
||||
deepEqual(bodyData.bulk_message, true)
|
||||
}))
|
||||
|
||||
test('includes media comment params if passed a media file', () =>
|
||||
GradebookApi.sendMessageStudentsWho(recipientsIds, subject, body, contextCode, {
|
||||
id: '123',
|
||||
type: 'video'
|
||||
}).then(() => {
|
||||
const bodyData = JSON.parse(getRequest().requestBody)
|
||||
strictEqual(bodyData.media_comment_id, '123')
|
||||
strictEqual(bodyData.media_comment_type, 'video')
|
||||
}))
|
||||
|
||||
test('does not include media comment params if not passed a media file', () =>
|
||||
GradebookApi.sendMessageStudentsWho(recipientsIds, subject, body, contextCode).then(() => {
|
||||
const bodyData = JSON.parse(getRequest().requestBody)
|
||||
notOk(Object.keys(bodyData).includes('media_comment_id'))
|
||||
notOk(Object.keys(bodyData).includes('media_comment_type'))
|
||||
}))
|
||||
})
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
*/
|
||||
|
||||
import {AlertManagerContext} from '@canvas/alerts/react/AlertManager'
|
||||
import {getAutoTrack} from '../../../../media_player_iframe_content/react/CanvasMediaPlayer'
|
||||
import {getAutoTrack} from '@canvas/canvas-media-player'
|
||||
import {Assignment} from '@canvas/assignments/graphql/student/Assignment'
|
||||
import {bool, func} from 'prop-types'
|
||||
import closedCaptionLanguages from '@canvas/util/closedCaptionLanguages'
|
||||
|
@ -38,7 +38,7 @@ import {
|
|||
UploadMediaStrings,
|
||||
MediaCaptureStrings,
|
||||
SelectStrings
|
||||
} from '../../helpers/UploadMediaTranslations'
|
||||
} from '@canvas/upload-media-translations'
|
||||
import WithBreakpoints, {breakpointsShape} from 'with-breakpoints'
|
||||
|
||||
import {Button} from '@instructure/ui-buttons'
|
||||
|
|
|
@ -35,7 +35,7 @@ import LoadingIndicator from '@canvas/loading-indicator'
|
|||
import {SUBMISSION_COMMENT_QUERY} from '@canvas/assignments/graphql/student/Queries'
|
||||
import {submissionCommentAttachmentsUpload} from '@canvas/upload-file'
|
||||
import {Submission} from '@canvas/assignments/graphql/student/Submission'
|
||||
import {UploadMediaStrings, MediaCaptureStrings} from '../../helpers/UploadMediaTranslations'
|
||||
import {UploadMediaStrings, MediaCaptureStrings} from '@canvas/upload-media-translations'
|
||||
import {EmojiPicker, EmojiQuickPicker} from '@canvas/emoji'
|
||||
|
||||
const I18n = useI18nScope('assignments_2')
|
||||
|
|
|
@ -98,7 +98,8 @@ function mockContext(children) {
|
|||
)
|
||||
}
|
||||
|
||||
describe('CommentsTrayBody', () => {
|
||||
// To be unskipped in EVAL-2477
|
||||
describe.skip('CommentsTrayBody', () => {
|
||||
beforeAll(() => {
|
||||
$('body').append('<div role="alert" id=flash_screenreader_holder />')
|
||||
})
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
|
||||
import axios from '@canvas/axios'
|
||||
|
||||
export function sendMesssageStudentsWho({recipientLids, subject, body, contextCode}) {
|
||||
export function sendMessageStudentsWho({recipientLids, subject, body, contextCode}) {
|
||||
const apiParams = {
|
||||
recipients: recipientLids,
|
||||
subject,
|
||||
|
|
|
@ -20,12 +20,9 @@ import React from 'react'
|
|||
import {bool, func} from 'prop-types'
|
||||
import {useScope as useI18nScope} from '@canvas/i18n'
|
||||
import {showFlashAlert} from '@canvas/alerts/react/FlashAlert'
|
||||
import {sendMesssageStudentsWho} from '../api'
|
||||
import {sendMessageStudentsWho} from '../api'
|
||||
|
||||
import {
|
||||
hasSubmitted,
|
||||
hasSubmission
|
||||
} from '@canvas/grading/messageStudentsWhoHelper'
|
||||
import {hasSubmitted, hasSubmission} from '@canvas/grading/messageStudentsWhoHelper'
|
||||
|
||||
import {TeacherAssignmentShape} from '../assignmentData'
|
||||
|
||||
|
@ -156,7 +153,7 @@ export default class MessageStudentsWhoDialog extends React.Component {
|
|||
handleSend = () => {
|
||||
this.setState({sendingMessagesNow: true})
|
||||
showFlashAlert({message: I18n.t('Sending messages'), srOnly: true})
|
||||
sendMesssageStudentsWho({
|
||||
sendMessageStudentsWho({
|
||||
recipientLids: this.state.selectedStudents,
|
||||
subject: this.state.subject,
|
||||
body: this.state.body,
|
||||
|
|
|
@ -4717,12 +4717,13 @@ class Gradebook extends React.Component<GradebookProps, GradebookState> {
|
|||
})
|
||||
}
|
||||
|
||||
sendMesssageStudentsWho = args => {
|
||||
return GradebookApi.sendMesssageStudentsWho(
|
||||
sendMessageStudentsWho = args => {
|
||||
return GradebookApi.sendMessageStudentsWho(
|
||||
args.recipientsIds,
|
||||
args.subject,
|
||||
args.body,
|
||||
`course_${this.options.context_id}`
|
||||
`course_${this.options.context_id}`,
|
||||
args.mediaFile
|
||||
)
|
||||
.then(FlashAlert.showFlashSuccess(I18n.t('Message sent successfully')))
|
||||
.catch(FlashAlert.showFlashError(I18n.t('There was an error sending the message')))
|
||||
|
|
|
@ -127,7 +127,8 @@ type Props = {
|
|||
showUnpostedMenuItem: any
|
||||
sortBySetting: any
|
||||
submissionsLoaded: boolean
|
||||
onSendMesssageStudentsWho: any
|
||||
onSendMessageStudentsWho: any
|
||||
userId: string
|
||||
}
|
||||
|
||||
type State = {
|
||||
|
@ -233,7 +234,8 @@ export default class AssignmentColumnHeader extends ColumnHeader<Props, State> {
|
|||
showMessageStudentsWithObserversDialog: bool.isRequired,
|
||||
showUnpostedMenuItem: bool.isRequired,
|
||||
messageAttachmentUploadFolderId: string.isRequired,
|
||||
onSendMesssageStudentsWho: func.isRequired
|
||||
onSendMessageStudentsWho: func.isRequired,
|
||||
userId: string.isRequired
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
|
@ -313,7 +315,7 @@ export default class AssignmentColumnHeader extends ColumnHeader<Props, State> {
|
|||
}
|
||||
|
||||
handleSendMessageStudentsWho = args => {
|
||||
this.props.onSendMesssageStudentsWho(args)
|
||||
this.props.onSendMessageStudentsWho(args)
|
||||
}
|
||||
|
||||
showMessageStudentsWhoDialog = async () => {
|
||||
|
@ -341,7 +343,8 @@ export default class AssignmentColumnHeader extends ColumnHeader<Props, State> {
|
|||
this.focusAtEnd()
|
||||
},
|
||||
onSend: this.handleSendMessageStudentsWho,
|
||||
messageAttachmentUploadFolderId: this.props.messageAttachmentUploadFolderId
|
||||
messageAttachmentUploadFolderId: this.props.messageAttachmentUploadFolderId,
|
||||
userId: this.props.userId
|
||||
}
|
||||
ReactDOM.render(
|
||||
<ApolloProvider client={createClient()}>
|
||||
|
|
|
@ -192,8 +192,9 @@ function getProps(column, gradebook, options) {
|
|||
|
||||
submissionsLoaded: gradebook.contentLoadStates.submissionsLoaded,
|
||||
messageAttachmentUploadFolderId: gradebook.options.message_attachment_upload_folder_id,
|
||||
userId: gradebook.options.currentUserId,
|
||||
|
||||
onSendMesssageStudentsWho: args => gradebook.sendMesssageStudentsWho(args)
|
||||
onSendMessageStudentsWho: args => gradebook.sendMessageStudentsWho(args)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -90,7 +90,7 @@ function updateGradebookFilter(courseId, filter) {
|
|||
})
|
||||
}
|
||||
|
||||
function sendMesssageStudentsWho(recipientsIds, subject, body, contextCode) {
|
||||
function sendMessageStudentsWho(recipientsIds, subject, body, contextCode, mediaFile) {
|
||||
const params = {
|
||||
recipients: recipientsIds,
|
||||
subject,
|
||||
|
@ -100,6 +100,12 @@ function sendMesssageStudentsWho(recipientsIds, subject, body, contextCode) {
|
|||
group_conversation: true,
|
||||
bulk_message: true
|
||||
}
|
||||
|
||||
if (mediaFile) {
|
||||
params.media_comment_id = mediaFile.id
|
||||
params.media_comment_type = mediaFile.type
|
||||
}
|
||||
|
||||
return axios.post('/api/v1/conversations', params)
|
||||
}
|
||||
|
||||
|
@ -113,5 +119,5 @@ export default {
|
|||
updateGradebookFilter,
|
||||
updateSubmission,
|
||||
updateTeacherNotesColumn,
|
||||
sendMesssageStudentsWho
|
||||
sendMessageStudentsWho
|
||||
}
|
||||
|
|
|
@ -33,11 +33,11 @@ import {responsiveQuerySizes} from '../../../util/utils'
|
|||
import {uploadFiles} from '@canvas/upload-file'
|
||||
import UploadMedia from '@instructure/canvas-media'
|
||||
import {
|
||||
MediaCaptureStrings,
|
||||
SelectStrings,
|
||||
UploadMediaStrings,
|
||||
ConversationContext
|
||||
} from '../../../util/constants'
|
||||
MediaCaptureStrings,
|
||||
SelectStrings
|
||||
} from '@canvas/upload-media-translations'
|
||||
import {ConversationContext} from '../../../util/constants'
|
||||
|
||||
const I18n = useI18nScope('conversations_2')
|
||||
|
||||
|
@ -330,11 +330,7 @@ const ComposeModalContainer = props => {
|
|||
onDismiss={() => setMediaUploadOpen(false)}
|
||||
open={mediaUploadOpen}
|
||||
tabs={{embed: false, record: true, upload: true}}
|
||||
uploadMediaTranslations={{
|
||||
UploadMediaStrings: UploadMediaStrings(),
|
||||
MediaCaptureStrings: MediaCaptureStrings(),
|
||||
SelectStrings: SelectStrings()
|
||||
}}
|
||||
uploadMediaTranslations={{UploadMediaStrings, MediaCaptureStrings, SelectStrings}}
|
||||
liveRegion={() => document.getElementById('flash_screenreader_holder')}
|
||||
languages={Object.keys(closedCaptionLanguages).map(key => {
|
||||
return {id: key, label: closedCaptionLanguages[key]}
|
||||
|
|
|
@ -22,67 +22,6 @@ const I18n = useI18nScope('conversations_2')
|
|||
|
||||
export const PARTICIPANT_EXPANSION_THRESHOLD = 2
|
||||
|
||||
export const MediaCaptureStrings = () => ({
|
||||
ARIA_VIDEO_LABEL: I18n.t('Video Player'),
|
||||
ARIA_VOLUME: I18n.t('Current Volume Level'),
|
||||
ARIA_RECORDING: I18n.t('Recording'),
|
||||
DEFAULT_ERROR: I18n.t('Something went wrong accessing your mic or webcam.'),
|
||||
DEVICE_AUDIO: I18n.t('Mic'),
|
||||
DEVICE_VIDEO: I18n.t('Webcam'),
|
||||
FILE_PLACEHOLDER: I18n.t('Untitled'),
|
||||
FINISH: I18n.t('Finish'),
|
||||
NO_WEBCAM: I18n.t('No Video'),
|
||||
NOT_ALLOWED_ERROR: I18n.t('Please allow Canvas to access your microphone and webcam.'),
|
||||
NOT_READABLE_ERROR: I18n.t('Your webcam may already be in use.'),
|
||||
PLAYBACK_PAUSE: I18n.t('Pause'),
|
||||
PLAYBACK_PLAY: I18n.t('Play'),
|
||||
PREVIEW: I18n.t('PREVIEW'),
|
||||
SAVE: I18n.t('Save'),
|
||||
SR_FILE_INPUT: I18n.t('File name'),
|
||||
START: I18n.t('Start Recording'),
|
||||
START_OVER: I18n.t('Start Over')
|
||||
})
|
||||
|
||||
export const UploadMediaStrings = () => ({
|
||||
LOADING_MEDIA: I18n.t('Loading Media'),
|
||||
PROGRESS_LABEL: I18n.t('Uploading media Progress'),
|
||||
ADD_CLOSED_CAPTIONS_OR_SUBTITLES: I18n.t('Add CC/Subtitle'),
|
||||
COMPUTER_PANEL_TITLE: I18n.t('Computer'),
|
||||
DRAG_FILE_TEXT: I18n.t('Drag a File Here'),
|
||||
RECORD_PANEL_TITLE: I18n.t('Record'),
|
||||
EMBED_PANEL_TITLE: I18n.t('Embed'),
|
||||
SUBMIT_TEXT: I18n.t('Submit'),
|
||||
CLOSE_TEXT: I18n.t('Close'),
|
||||
UPLOAD_MEDIA_LABEL: I18n.t('Upload Media'),
|
||||
CLEAR_FILE_TEXT: I18n.t('Clear selected file'),
|
||||
INVALID_FILE_TEXT: I18n.t('Invalid file type'),
|
||||
DRAG_DROP_CLICK_TO_BROWSE: I18n.t('Drag and drop, or click to browse your computer'),
|
||||
EMBED_VIDEO_CODE_TEXT: I18n.t('Embed Video Code'),
|
||||
UPLOADING_ERROR: I18n.t('Error uploading video/audio recording'),
|
||||
CLOSED_CAPTIONS_PANEL_TITLE: I18n.t('CC/Subtitles'),
|
||||
CLOSED_CAPTIONS_LANGUAGE_HEADER: I18n.t('Language'),
|
||||
CLOSED_CAPTIONS_FILE_NAME_HEADER: I18n.t('File Name'),
|
||||
CLOSED_CAPTIONS_ACTIONS_HEADER: I18n.t('Actions'),
|
||||
CLOSED_CAPTIONS_ADD_SUBTITLE: I18n.t('Subtitle'),
|
||||
CLOSED_CAPTIONS_ADD_SUBTITLE_SCREENREADER: I18n.t('Add Subtitle'),
|
||||
CLOSED_CAPTIONS_CHOOSE_FILE: I18n.t('Choose File'),
|
||||
CLOSED_CAPTIONS_SELECT_LANGUAGE: I18n.t('Select Language'),
|
||||
MEDIA_RECORD_NOT_AVAILABLE: I18n.t('Media record not available'),
|
||||
ADDED_CAPTION: I18n.t('Added caption'),
|
||||
DELETED_CAPTION: I18n.t('Deleted caption'),
|
||||
REMOVE_FILE: I18n.t('Remove file'),
|
||||
NO_FILE_CHOSEN: I18n.t('No file selected'),
|
||||
SUPPORTED_FILE_TYPES: I18n.t('Supported file types: .vtt, .srt'),
|
||||
ADD_NEW_CAPTION_OR_SUBTITLE: I18n.t('Add new caption or subtitle')
|
||||
})
|
||||
|
||||
export const SelectStrings = () => ({
|
||||
USE_ARROWS: I18n.t('Use Arrows'),
|
||||
LIST_COLLAPSED: I18n.t('List Collapsed'),
|
||||
LIST_EXPANDED: I18n.t('List Expanded'),
|
||||
OPTION_SELECTED: I18n.t('{option} Selected')
|
||||
})
|
||||
|
||||
const conversationContextDefaultValues = {
|
||||
multiselect: false,
|
||||
setMultiselect: () => {},
|
||||
|
|
|
@ -20,7 +20,7 @@ import React from 'react'
|
|||
import ReactDOM from 'react-dom'
|
||||
import {parse} from 'url'
|
||||
import ready from '@instructure/ready'
|
||||
import CanvasMediaPlayer from './react/CanvasMediaPlayer'
|
||||
import CanvasMediaPlayer from '@canvas/canvas-media-player'
|
||||
import closedCaptionLanguages from '@canvas/util/closedCaptionLanguages'
|
||||
|
||||
ready(() => {
|
||||
|
|
|
@ -181,7 +181,7 @@ export default class GradebookHeaderMenu {
|
|||
}
|
||||
|
||||
handleSendMessageStudentsWho = args => {
|
||||
this.gradebook.sendMesssageStudentsWho(args)
|
||||
this.gradebook.sendMessageStudentsWho(args)
|
||||
}
|
||||
|
||||
messageStudentsWho(
|
||||
|
@ -191,12 +191,13 @@ export default class GradebookHeaderMenu {
|
|||
this.gradebook.students,
|
||||
this.assignment
|
||||
),
|
||||
onSend: this.handleSendMessageStudentsWho
|
||||
onSend: this.handleSendMessageStudentsWho,
|
||||
userId: this.gradebook.options.currentUserId
|
||||
}
|
||||
) {
|
||||
let {students} = opts
|
||||
const {assignment} = opts
|
||||
const {onSend} = opts
|
||||
const {onSend, userId} = opts
|
||||
students = _.filter(students, student => {
|
||||
return !student.is_inactive
|
||||
})
|
||||
|
@ -229,7 +230,8 @@ export default class GradebookHeaderMenu {
|
|||
ReactDOM.unmountComponentAtNode(mountPoint)
|
||||
},
|
||||
onSend,
|
||||
students
|
||||
students,
|
||||
userId
|
||||
}
|
||||
ReactDOM.render(
|
||||
<ApolloProvider client={createClient()}>
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"name": "@canvas/canvas-media-player",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"author": "Spencer Olson <solson@instructure.com>",
|
||||
"main": "./react/CanvasMediaPlayer.js",
|
||||
"canvas": {
|
||||
"component": "canvas-media"
|
||||
}
|
||||
}
|
|
@ -16,7 +16,7 @@
|
|||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {number, oneOf, string} from 'prop-types'
|
||||
import {bool, number, oneOf, string} from 'prop-types'
|
||||
import {useScope as useI18nScope} from '@canvas/i18n'
|
||||
import {LoadingIndicator, isAudio, sizeMediaPlayer} from '@instructure/canvas-media'
|
||||
import {MediaPlayer} from '@instructure/ui-media-player'
|
||||
|
@ -104,17 +104,23 @@ export default function CanvasMediaPlayer(props) {
|
|||
const playerParent = containerRef.current
|
||||
? containerRef.current.parentElement
|
||||
: window.frameElement
|
||||
setPlayerSize(player, props.type, boundingBox(), window.frameElement || playerParent)
|
||||
setPlayerSize(
|
||||
player,
|
||||
props.type,
|
||||
boundingBox(),
|
||||
window.frameElement || playerParent,
|
||||
props.resizeContainer
|
||||
)
|
||||
},
|
||||
[props.type]
|
||||
[props.resizeContainer, props.type]
|
||||
)
|
||||
|
||||
const handlePlayerSize = useCallback(
|
||||
_event => {
|
||||
const player = window.document.body.querySelector('video')
|
||||
setPlayerSize(player, props.type, boundingBox(), null)
|
||||
setPlayerSize(player, props.type, boundingBox(), null, props.resizeContainer)
|
||||
},
|
||||
[props.type]
|
||||
[props.type, props.resizeContainer]
|
||||
)
|
||||
|
||||
const fetchSources = useCallback(
|
||||
|
@ -227,9 +233,14 @@ export default function CanvasMediaPlayer(props) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} data-tracks={JSON.stringify(media_tracks)}>
|
||||
<div
|
||||
style={{height: props.fluidHeight ? '100%' : 'auto'}}
|
||||
ref={containerRef}
|
||||
data-tracks={JSON.stringify(media_tracks)}
|
||||
>
|
||||
{media_sources.length ? (
|
||||
<MediaPlayer
|
||||
fluidHeight={props.fluidHeight}
|
||||
ref={mediaPlayerRef}
|
||||
sources={media_sources}
|
||||
tracks={props.media_tracks}
|
||||
|
@ -246,7 +257,7 @@ export default function CanvasMediaPlayer(props) {
|
|||
)
|
||||
}
|
||||
|
||||
export function setPlayerSize(player, type, boundingBox, playerContainer) {
|
||||
export function setPlayerSize(player, type, boundingBox, playerContainer, resizeContainer = true) {
|
||||
const {width, height} = sizeMediaPlayer(player, type, boundingBox)
|
||||
player.style.width = width
|
||||
player.style.height = height
|
||||
|
@ -254,7 +265,7 @@ export function setPlayerSize(player, type, boundingBox, playerContainer) {
|
|||
player.classList.add(isAudio(type) ? 'audio-player' : 'video-player')
|
||||
|
||||
// videos that are wide-and-short portrait need to shrink the parent
|
||||
if (playerContainer && player.videoWidth > player.videoHeight) {
|
||||
if (resizeContainer && playerContainer && player.videoWidth > player.videoHeight) {
|
||||
playerContainer.style.width = width
|
||||
playerContainer.style.height = height
|
||||
|
||||
|
@ -271,10 +282,22 @@ export function setPlayerSize(player, type, boundingBox, playerContainer) {
|
|||
}
|
||||
}
|
||||
|
||||
export function formatTracksForMediaPlayer(tracks) {
|
||||
return tracks.map(track => ({
|
||||
id: track.id,
|
||||
src: `/media_objects/${track.media_object_id}/media_tracks/${track.id}`,
|
||||
label: track.locale,
|
||||
type: track.kind,
|
||||
language: track.locale
|
||||
}))
|
||||
}
|
||||
|
||||
CanvasMediaPlayer.propTypes = {
|
||||
fluidHeight: bool,
|
||||
media_id: string.isRequired,
|
||||
media_sources: MediaPlayer.propTypes.sources,
|
||||
media_tracks: MediaPlayer.propTypes.tracks,
|
||||
resizeContainer: bool,
|
||||
type: oneOf(['audio', 'video']),
|
||||
MAX_RETRY_ATTEMPTS: number,
|
||||
SHOW_BE_PATIENT_MSG_AFTER_ATTEMPTS: number,
|
||||
|
@ -282,7 +305,9 @@ CanvasMediaPlayer.propTypes = {
|
|||
}
|
||||
|
||||
CanvasMediaPlayer.defaultProps = {
|
||||
fluidHeight: false,
|
||||
media_sources: [],
|
||||
resizeContainer: true,
|
||||
type: 'video',
|
||||
MAX_RETRY_ATTEMPTS: DEFAULT_MAX_RETRY_ATTEMPTS,
|
||||
SHOW_BE_PATIENT_MSG_AFTER_ATTEMPTS: DEFAULT_SHOW_BE_PATIENT_MSG_AFTER_ATTEMPTS,
|
|
@ -23,7 +23,11 @@
|
|||
import React from 'react'
|
||||
import {render, waitFor, fireEvent, act} from '@testing-library/react'
|
||||
import {queries as domQueries} from '@testing-library/dom'
|
||||
import CanvasMediaPlayer, {setPlayerSize, getAutoTrack} from '../CanvasMediaPlayer'
|
||||
import CanvasMediaPlayer, {
|
||||
setPlayerSize,
|
||||
getAutoTrack,
|
||||
formatTracksForMediaPlayer
|
||||
} from '../CanvasMediaPlayer'
|
||||
import {uniqueId} from 'lodash'
|
||||
|
||||
const defaultMediaObject = (overrides = {}) => ({
|
||||
|
@ -431,6 +435,19 @@ describe('CanvasMediaPlayer', () => {
|
|||
}
|
||||
}
|
||||
|
||||
it('does not resize the container when passed resizeContainer = false', () => {
|
||||
const container = document.createElement('div')
|
||||
container.style.height = '300px'
|
||||
container.style.width = '500px'
|
||||
const player = makePlayer(1000, 500)
|
||||
setPlayerSize(player, 'audio/*', {width: 400, height: 200}, container, false)
|
||||
expect(player.classList.add).toHaveBeenCalledWith('audio-player')
|
||||
expect(player.style.width).toBe('320px')
|
||||
expect(player.style.height).toBe('14.25rem')
|
||||
expect(container.style.width).toBe('500px')
|
||||
expect(container.style.height).toBe('300px')
|
||||
})
|
||||
|
||||
it('when the media is audio', () => {
|
||||
const container = document.createElement('div')
|
||||
const player = makePlayer(1000, 500)
|
||||
|
@ -602,4 +619,18 @@ describe('CanvasMediaPlayer', () => {
|
|||
expect(found).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatTracksForMediaPlayer', () => {
|
||||
it('returns an object with id, src, label, type, and language', () => {
|
||||
const rawTracks = [{id: '456', media_object_id: '123', locale: 'en', kind: 'subtitles'}]
|
||||
const track = formatTracksForMediaPlayer(rawTracks)[0]
|
||||
expect(track).toEqual({
|
||||
id: '456',
|
||||
src: '/media_objects/123/media_tracks/456',
|
||||
label: 'en',
|
||||
type: 'subtitles',
|
||||
language: 'en'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -36,7 +36,7 @@ describe('lti.showAlert handler', () => {
|
|||
message = {}
|
||||
})
|
||||
|
||||
it('sends bad request postMesssage', () => {
|
||||
it('sends bad request postMessage', () => {
|
||||
handler({message, responseMessages})
|
||||
expect(responseMessages.sendBadRequestError).toHaveBeenCalledWith(
|
||||
"Missing required 'body' field"
|
||||
|
@ -49,7 +49,7 @@ describe('lti.showAlert handler', () => {
|
|||
message = {body, alertType: 'bad'}
|
||||
})
|
||||
|
||||
it('sends bad request postMesssage', () => {
|
||||
it('sends bad request postMessage', () => {
|
||||
handler({message, responseMessages})
|
||||
expect(responseMessages.sendBadRequestError).toHaveBeenCalledWith(
|
||||
"Unsupported value for 'alertType' field"
|
||||
|
@ -78,7 +78,7 @@ describe('lti.showAlert handler', () => {
|
|||
expect($[method]).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends success postMesssage', () => {
|
||||
it('sends success postMessage', () => {
|
||||
handler({message, responseMessages})
|
||||
expect(responseMessages.sendSuccess).toHaveBeenCalled()
|
||||
})
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
*/
|
||||
|
||||
export {AttachmentDisplay} from './react/components/AttachmentDisplay/AttachmentDisplay'
|
||||
export {MediaAttachment} from './react/components/MediaAttachment/MediaAttachment'
|
||||
export {AttachmentUploadSpinner} from './react/components/AttachmentUploadSpinner/AttachmentUploadSpinner'
|
||||
export {FileAttachmentUpload} from './react/components/FileAttachmentUpload/FileAttachmentUpload'
|
||||
export {addAttachmentsFn, removeAttachmentFn} from './util/attachments'
|
||||
|
|
|
@ -23,14 +23,15 @@ import {Attachment, attachmentProp} from './Attachment'
|
|||
|
||||
export const AttachmentDisplay = ({...props}) => {
|
||||
return (
|
||||
<Flex>
|
||||
<Flex alignItems="start" wrap="wrap">
|
||||
{props.attachments.map(a => (
|
||||
<Attachment
|
||||
key={a.id}
|
||||
attachment={a}
|
||||
onReplace={props.onReplaceItem.bind(null, a.id)}
|
||||
onDelete={props.onDeleteItem.bind(null, a.id)}
|
||||
/>
|
||||
<Flex.Item key={a.id}>
|
||||
<Attachment
|
||||
attachment={a}
|
||||
onReplace={props.onReplaceItem.bind(null, a.id)}
|
||||
onDelete={props.onDeleteItem.bind(null, a.id)}
|
||||
/>
|
||||
</Flex.Item>
|
||||
))}
|
||||
</Flex>
|
||||
)
|
||||
|
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* Copyright (C) 2022 - 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 React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import CanvasMediaPlayer from '@canvas/canvas-media-player'
|
||||
import {RemovableItem} from '../RemovableItem/RemovableItem'
|
||||
import {useScope as useI18nScope} from '@canvas/i18n'
|
||||
import {View} from '@instructure/ui-view'
|
||||
import {colors} from '@instructure/canvas-theme'
|
||||
|
||||
const I18n = useI18nScope('conversations_2')
|
||||
|
||||
export function MediaAttachment(props) {
|
||||
return (
|
||||
<>
|
||||
<RemovableItem
|
||||
onRemove={props.onRemoveMediaComment}
|
||||
screenReaderLabel={I18n.t('Remove media comment')}
|
||||
childrenAriaLabel={I18n.t('Media comment content')}
|
||||
>
|
||||
<View
|
||||
as="div"
|
||||
borderRadius="large"
|
||||
overflowX="hidden"
|
||||
overflowY="hidden"
|
||||
height="11.25rem"
|
||||
width="20rem"
|
||||
margin="small small small none"
|
||||
position="relative"
|
||||
shadow="above"
|
||||
>
|
||||
<CanvasMediaPlayer
|
||||
fluidHeight
|
||||
resizeContainer={false}
|
||||
media_id={props.file.mediaID}
|
||||
media_sources={[{label: props.file.title, src: props.file.src, type: props.file.type}]}
|
||||
media_tracks={props.file.mediaTracks}
|
||||
type={props.file.type}
|
||||
aria_label={props.file.title}
|
||||
/>
|
||||
</View>
|
||||
</RemovableItem>
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: '20rem',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
color: colors.ash
|
||||
}}
|
||||
>
|
||||
{props.file.title}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
MediaAttachment.propTypes = {
|
||||
onRemoveMediaComment: PropTypes.func.isRequired,
|
||||
file: PropTypes.shape({
|
||||
mediaID: PropTypes.string.isRequired,
|
||||
mediaTracks: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
src: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
type: PropTypes.string.isRequired,
|
||||
language: PropTypes.string.isRequired
|
||||
})
|
||||
),
|
||||
title: PropTypes.string.isRequired,
|
||||
src: PropTypes.string.isRequired,
|
||||
type: PropTypes.oneOf(['audio', 'video']).isRequired
|
||||
}).isRequired
|
||||
}
|
||||
|
||||
export default MediaAttachment
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* Copyright (C) 2022 - 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 React from 'react'
|
||||
import {fireEvent, render} from '@testing-library/react'
|
||||
import {MediaAttachment} from '../MediaAttachment'
|
||||
|
||||
jest.mock('@canvas/canvas-media-player', () => () => <div>Media Content</div>)
|
||||
|
||||
describe('MediaAttachment', () => {
|
||||
let props
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
file: {mediaID: '123', title: 'my-awesome-video.mp4', src: 'somesrc.test', type: 'video'},
|
||||
onRemoveMediaComment: jest.fn()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not show the remove button by default', () => {
|
||||
const {queryByRole} = render(<MediaAttachment {...props} />)
|
||||
expect(queryByRole('button', {name: 'Remove media comment'})).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the remove button when the element is hovered', () => {
|
||||
const {getByTestId, getByRole} = render(<MediaAttachment {...props} />)
|
||||
fireEvent.mouseOver(getByTestId('removable-item'))
|
||||
expect(getByRole('button', {name: 'Remove media comment'})).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onRemoveMediaComment when the button is clicked', () => {
|
||||
const {getByTestId, getByRole} = render(<MediaAttachment {...props} />)
|
||||
fireEvent.mouseOver(getByTestId('removable-item'))
|
||||
const button = getByRole('button', {name: 'Remove media comment'})
|
||||
fireEvent.click(button)
|
||||
expect(props.onRemoveMediaComment).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('displays the media title', () => {
|
||||
const {getByText} = render(<MediaAttachment {...props} />)
|
||||
expect(getByText(props.file.title)).toBeInTheDocument()
|
||||
})
|
||||
})
|
|
@ -18,10 +18,9 @@
|
|||
|
||||
import React from 'react'
|
||||
import MessageStudentsWhoDialog from './MessageStudentsWhoDialog'
|
||||
import {ApolloProvider, useQuery} from 'react-apollo'
|
||||
import {ApolloProvider} from 'react-apollo'
|
||||
import {createClient} from '@canvas/apollo'
|
||||
|
||||
|
||||
const students = [
|
||||
{
|
||||
id: '100',
|
||||
|
@ -55,6 +54,7 @@ export default {
|
|||
name: 'Some assignment',
|
||||
nonDigitalSubmission: false
|
||||
},
|
||||
userId: '123',
|
||||
students
|
||||
},
|
||||
argTypes: {
|
||||
|
@ -62,7 +62,11 @@ export default {
|
|||
}
|
||||
}
|
||||
|
||||
const Template = args => <ApolloProvider client={createClient()}><MessageStudentsWhoDialog {...args} /></ApolloProvider>
|
||||
const Template = args => (
|
||||
<ApolloProvider client={createClient()}>
|
||||
<MessageStudentsWhoDialog {...args} />
|
||||
</ApolloProvider>
|
||||
)
|
||||
export const ScoredAssignment = Template.bind({})
|
||||
ScoredAssignment.args = {
|
||||
assignment: {
|
||||
|
|
|
@ -18,13 +18,26 @@
|
|||
|
||||
import {useScope as useI18nScope} from '@canvas/i18n'
|
||||
import React, {useState, useContext, useEffect} from 'react'
|
||||
import {Button, CloseButton} from '@instructure/ui-buttons'
|
||||
import {Button, CloseButton, IconButton} from '@instructure/ui-buttons'
|
||||
import {Checkbox} from '@instructure/ui-checkbox'
|
||||
import {Flex} from '@instructure/ui-flex'
|
||||
import {Heading} from '@instructure/ui-heading'
|
||||
import {IconArrowOpenDownLine, IconArrowOpenUpLine} from '@instructure/ui-icons'
|
||||
import {
|
||||
IconArrowOpenDownLine,
|
||||
IconArrowOpenUpLine,
|
||||
IconAttachMediaLine
|
||||
} from '@instructure/ui-icons'
|
||||
import UploadMedia from '@instructure/canvas-media'
|
||||
import closedCaptionLanguages from '@canvas/util/closedCaptionLanguages'
|
||||
import {formatTracksForMediaPlayer} from '@canvas/canvas-media-player'
|
||||
import {Tooltip} from '@instructure/ui-tooltip'
|
||||
import {Link} from '@instructure/ui-link'
|
||||
import LoadingIndicator from '@canvas/loading-indicator'
|
||||
import {
|
||||
UploadMediaStrings,
|
||||
MediaCaptureStrings,
|
||||
SelectStrings
|
||||
} from '@canvas/upload-media-translations'
|
||||
import {Modal} from '@instructure/ui-modal'
|
||||
import {NumberInput} from '@instructure/ui-number-input'
|
||||
import {ScreenReaderContent} from '@instructure/ui-a11y-content'
|
||||
|
@ -45,6 +58,7 @@ import {
|
|||
FileAttachmentUpload,
|
||||
AttachmentUploadSpinner,
|
||||
AttachmentDisplay,
|
||||
MediaAttachment,
|
||||
addAttachmentsFn,
|
||||
removeAttachmentFn
|
||||
} from '@canvas/message-attachments'
|
||||
|
@ -83,6 +97,27 @@ export type Props = {
|
|||
students: Student[]
|
||||
onSend: (args: SendArgs) => void
|
||||
messageAttachmentUploadFolderId: string
|
||||
userId: string
|
||||
}
|
||||
|
||||
type MediaFile = {
|
||||
id: string
|
||||
type: string
|
||||
}
|
||||
|
||||
type MediaTrack = {
|
||||
id: string
|
||||
src: string
|
||||
label: string
|
||||
type: string
|
||||
language: string
|
||||
}
|
||||
|
||||
type MediaUploadFile = {
|
||||
media_id: string
|
||||
title: string
|
||||
media_type: string
|
||||
media_tracks?: MediaTrack[]
|
||||
}
|
||||
|
||||
type FilterCriterion = {
|
||||
|
@ -96,6 +131,7 @@ type SendArgs = {
|
|||
recipientsIds: number[]
|
||||
subject: string
|
||||
body: string
|
||||
mediaFile?: MediaFile
|
||||
}
|
||||
|
||||
const isScored = (assignment: Assignment) =>
|
||||
|
@ -167,12 +203,12 @@ function filterStudents(criterion, students, cutoff) {
|
|||
}
|
||||
break
|
||||
case 'scored_more_than':
|
||||
if (parseInt(student.score) > cutoff) {
|
||||
if (parseInt(student.score, 10) > cutoff) {
|
||||
newfilteredStudents.push(student)
|
||||
}
|
||||
break
|
||||
case 'scored_less_than':
|
||||
if (parseInt(student.score) < cutoff) {
|
||||
if (parseInt(student.score, 10) < cutoff) {
|
||||
newfilteredStudents.push(student)
|
||||
}
|
||||
break
|
||||
|
@ -196,7 +232,8 @@ const MessageStudentsWhoDialog: React.FC<Props> = ({
|
|||
onClose,
|
||||
students,
|
||||
onSend,
|
||||
messageAttachmentUploadFolderId
|
||||
messageAttachmentUploadFolderId,
|
||||
userId
|
||||
}) => {
|
||||
const {setOnFailure, setOnSuccess} = useContext(AlertManagerContext)
|
||||
const [open, setOpen] = useState(true)
|
||||
|
@ -204,8 +241,8 @@ const MessageStudentsWhoDialog: React.FC<Props> = ({
|
|||
const [subject, setSubject] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const initializeSelectedObservers = (students) =>
|
||||
students.reduce((map, student) => {
|
||||
const initializeSelectedObservers = studentCollection =>
|
||||
studentCollection.reduce((map, student) => {
|
||||
map[student.id] = []
|
||||
return map
|
||||
}, {})
|
||||
|
@ -218,7 +255,10 @@ const MessageStudentsWhoDialog: React.FC<Props> = ({
|
|||
const [isCheckedObserversCheckbox, setIsCheckedObserversCheckbox] = useState(false)
|
||||
const [isDisabledStudentsCheckbox, setIsDisabledStudentsCheckbox] = useState(false)
|
||||
const [isDisabledObserversCheckbox, setIsDisabledObserversCheckbox] = useState(false)
|
||||
|
||||
const [mediaUploadOpen, setMediaUploadOpen] = useState<boolean>(false)
|
||||
const [mediaUploadFile, setMediaUploadFile] = useState<null | MediaUploadFile>(null)
|
||||
const [mediaPreviewURL, setMediaPreviewURL] = useState<null | string>(null)
|
||||
const [mediaTitle, setMediaTitle] = useState<string>('')
|
||||
const close = () => setOpen(false)
|
||||
|
||||
const {loading, data} = useQuery(OBSERVER_ENROLLMENTS_QUERY, {
|
||||
|
@ -259,7 +299,9 @@ const MessageStudentsWhoDialog: React.FC<Props> = ({
|
|||
)
|
||||
setIsIndeterminateStudentsCheckbox(partialStudentSelection)
|
||||
setIsDisabledStudentsCheckbox(filteredStudents.length === 0)
|
||||
setIsCheckedStudentsCheckbox(filteredStudents.length > 0 && selectedStudents.length === filteredStudents.length)
|
||||
setIsCheckedStudentsCheckbox(
|
||||
filteredStudents.length > 0 && selectedStudents.length === filteredStudents.length
|
||||
)
|
||||
}, [selectedStudents, filteredStudents])
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -274,7 +316,9 @@ const MessageStudentsWhoDialog: React.FC<Props> = ({
|
|||
)
|
||||
setIsIndeterminateObserversCheckbox(partialObserverSelection)
|
||||
setIsDisabledObserversCheckbox(observerCountValue === 0)
|
||||
setIsCheckedObserversCheckbox(observerCountValue > 0 && selectedObserverCount === observerCountValue)
|
||||
setIsCheckedObserversCheckbox(
|
||||
observerCountValue > 0 && selectedObserverCount === observerCountValue
|
||||
)
|
||||
}, [filteredStudents, observersByStudentID, selectedObservers])
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -302,6 +346,14 @@ const MessageStudentsWhoDialog: React.FC<Props> = ({
|
|||
}
|
||||
}, [loading, data, selectedCriterion, sortedStudents, cutoff, observersByStudentID])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (mediaPreviewURL) {
|
||||
URL.revokeObjectURL(mediaPreviewURL)
|
||||
}
|
||||
}
|
||||
}, [mediaPreviewURL])
|
||||
|
||||
if (loading) {
|
||||
return <LoadingIndicator />
|
||||
}
|
||||
|
@ -323,13 +375,25 @@ const MessageStudentsWhoDialog: React.FC<Props> = ({
|
|||
// which then calls onSend() when pendingUploads are complete.
|
||||
setSending(true)
|
||||
} else {
|
||||
const recipientsIds = [...selectedStudents, ...Object.values(selectedObservers).flat()]
|
||||
const uniqueRecipientsIds = [...new Set(recipientsIds)]
|
||||
const args = {
|
||||
const recipientsIds = [
|
||||
...selectedStudents,
|
||||
...Object.values(selectedObservers).flat()
|
||||
] as number[]
|
||||
const uniqueRecipientsIds: number[] = [...new Set(recipientsIds)]
|
||||
|
||||
const args: SendArgs = {
|
||||
recipientsIds: uniqueRecipientsIds,
|
||||
subject,
|
||||
body: message
|
||||
}
|
||||
|
||||
if (mediaUploadFile) {
|
||||
args.mediaFile = {
|
||||
id: mediaUploadFile.media_id,
|
||||
type: mediaUploadFile.media_type
|
||||
}
|
||||
}
|
||||
|
||||
onSend(args)
|
||||
onClose()
|
||||
}
|
||||
|
@ -347,6 +411,30 @@ const MessageStudentsWhoDialog: React.FC<Props> = ({
|
|||
onDeleteAttachment(id)
|
||||
onAddAttachment(e)
|
||||
}
|
||||
const onRemoveMediaComment = () => {
|
||||
if (mediaPreviewURL) {
|
||||
URL.revokeObjectURL(mediaPreviewURL)
|
||||
setMediaPreviewURL(null)
|
||||
}
|
||||
setMediaUploadFile(null)
|
||||
}
|
||||
|
||||
const onMediaUploadStart = file => {
|
||||
setMediaTitle(file.title)
|
||||
}
|
||||
|
||||
const onMediaUploadComplete = (err, mediaData, captionData) => {
|
||||
if (err) {
|
||||
setOnFailure(I18n.t('There was an error uploading the media.'))
|
||||
} else {
|
||||
const file = mediaData.mediaObject.media_object
|
||||
if (captionData && file) {
|
||||
file.media_tracks = formatTracksForMediaPlayer(captionData)
|
||||
}
|
||||
setMediaUploadFile(file)
|
||||
setMediaPreviewURL(URL.createObjectURL(mediaData.uploadedFile))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelection = (id: string, array: Array<string>) => {
|
||||
const index = array.indexOf(id)
|
||||
|
@ -547,25 +635,58 @@ const MessageStudentsWhoDialog: React.FC<Props> = ({
|
|||
<br />
|
||||
<TextArea
|
||||
data-testid="message-input"
|
||||
isRequired={true}
|
||||
isRequired
|
||||
height="200px"
|
||||
label={I18n.t('Message')}
|
||||
placeholder={I18n.t('Type your message here…')}
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
/>
|
||||
<AttachmentDisplay
|
||||
attachments={[...attachments, ...pendingUploads]}
|
||||
onDeleteItem={onDeleteAttachment}
|
||||
onReplaceItem={onReplaceAttachment}
|
||||
/>
|
||||
|
||||
<Flex alignItems="start">
|
||||
{mediaUploadFile && mediaPreviewURL && (
|
||||
<Item>
|
||||
<MediaAttachment
|
||||
file={{
|
||||
mediaID: mediaUploadFile.media_id,
|
||||
src: mediaPreviewURL,
|
||||
title: mediaTitle || mediaUploadFile.title,
|
||||
type: mediaUploadFile.media_type,
|
||||
mediaTracks: mediaUploadFile.media_tracks
|
||||
}}
|
||||
onRemoveMediaComment={onRemoveMediaComment}
|
||||
/>
|
||||
</Item>
|
||||
)}
|
||||
|
||||
<Item shouldShrink>
|
||||
<AttachmentDisplay
|
||||
attachments={[...attachments, ...pendingUploads]}
|
||||
onDeleteItem={onDeleteAttachment}
|
||||
onReplaceItem={onReplaceAttachment}
|
||||
/>
|
||||
</Item>
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Flex justifyItems="space-between" width="100%">
|
||||
<Item>
|
||||
<FileAttachmentUpload onAddItem={onAddAttachment} />
|
||||
|
||||
<Tooltip renderTip={I18n.t('Record an audio or video comment')} placement="top">
|
||||
<IconButton
|
||||
screenReaderLabel={I18n.t('Record an audio or video comment')}
|
||||
onClick={() => setMediaUploadOpen(true)}
|
||||
margin="xx-small"
|
||||
data-testid="media-upload"
|
||||
interaction={mediaUploadFile ? 'disabled' : 'enabled'}
|
||||
>
|
||||
<IconAttachMediaLine />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Item>
|
||||
|
||||
<Item>
|
||||
<Flex>
|
||||
<Item>
|
||||
|
@ -574,7 +695,11 @@ const MessageStudentsWhoDialog: React.FC<Props> = ({
|
|||
</Button>
|
||||
</Item>
|
||||
<Item margin="0 0 0 x-small">
|
||||
<Button interaction={isFormDataValid ? 'enabled' : 'disabled'} color="primary" onClick={handleSendButton}>
|
||||
<Button
|
||||
interaction={isFormDataValid ? 'enabled' : 'disabled'}
|
||||
color="primary"
|
||||
onClick={handleSendButton}
|
||||
>
|
||||
{I18n.t('Send')}
|
||||
</Button>
|
||||
</Item>
|
||||
|
@ -588,6 +713,22 @@ const MessageStudentsWhoDialog: React.FC<Props> = ({
|
|||
isMessageSending={sending}
|
||||
pendingUploads={pendingUploads}
|
||||
/>
|
||||
<UploadMedia
|
||||
key={mediaUploadFile?.media_id}
|
||||
onStartUpload={onMediaUploadStart}
|
||||
onUploadComplete={onMediaUploadComplete}
|
||||
onDismiss={() => setMediaUploadOpen(false)}
|
||||
open={mediaUploadOpen}
|
||||
tabs={{embed: false, record: true, upload: true}}
|
||||
uploadMediaTranslations={{UploadMediaStrings, MediaCaptureStrings, SelectStrings}}
|
||||
liveRegion={() => document.getElementById('flash_screenreader_holder')}
|
||||
languages={Object.keys(closedCaptionLanguages).map(key => ({
|
||||
id: key,
|
||||
label: closedCaptionLanguages[key]
|
||||
}))}
|
||||
rcsConfig={{contextId: userId, contextType: 'user'}}
|
||||
disableSubmitWhileUploading
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -115,6 +115,7 @@ function makeProps(overrides: object = {}): ComponentProps {
|
|||
onClose: () => {},
|
||||
onSend: () => {},
|
||||
messageAttachmentUploadFolderId: '1',
|
||||
userId: '345',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
@ -226,8 +227,8 @@ describe.skip('MessageStudentsWhoDialog', () => {
|
|||
expect(observerCells[0]).toHaveTextContent('Observers')
|
||||
expect(observerCells[1]).toHaveTextContent('Observer0')
|
||||
expect(observerCells[2]).toHaveTextContent('Observer1')
|
||||
expect(observerCells[3]).toBeNull
|
||||
expect(observerCells[4]).toBeNull
|
||||
expect(observerCells[3]).toBeNull()
|
||||
expect(observerCells[4]).toBeNull()
|
||||
})
|
||||
|
||||
it('shows observers in the same cell sorted by the sortable name when observing the same student', async () => {
|
||||
|
@ -248,9 +249,9 @@ describe.skip('MessageStudentsWhoDialog', () => {
|
|||
expect(observerCells).toHaveLength(5)
|
||||
expect(observerCells[0]).toHaveTextContent('Observers')
|
||||
expect(observerCells[1]).toHaveTextContent('Observer0Observer1')
|
||||
expect(observerCells[2]).toBeNull
|
||||
expect(observerCells[3]).toBeNull
|
||||
expect(observerCells[4]).toBeNull
|
||||
expect(observerCells[2]).toBeNull()
|
||||
expect(observerCells[3]).toBeNull()
|
||||
expect(observerCells[4]).toBeNull()
|
||||
})
|
||||
|
||||
it('includes the total number of students in the checkbox label', async () => {
|
||||
|
@ -740,9 +741,9 @@ describe.skip('MessageStudentsWhoDialog', () => {
|
|||
it('sets the students checkbox as disabled when the students list is empty', async () => {
|
||||
const mocks = await makeMocks()
|
||||
|
||||
const {findByRole, getByRole} = render(
|
||||
const {findByRole} = render(
|
||||
<MockedProvider mocks={mocks} cache={createCache()}>
|
||||
<MessageStudentsWhoDialog {...makeProps({students:[]})} />
|
||||
<MessageStudentsWhoDialog {...makeProps({students: []})} />
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
|
@ -1121,7 +1122,9 @@ describe.skip('MessageStudentsWhoDialog', () => {
|
|||
const sendButton = await findByRole('button', {name: 'Send'})
|
||||
fireEvent.click(sendButton)
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith(expect.objectContaining({recipientsIds: ["101", "102", "103"]}))
|
||||
expect(onSend).toHaveBeenCalledWith(
|
||||
expect.objectContaining({recipientsIds: ['101', '102', '103']})
|
||||
)
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
@ -1153,7 +1156,9 @@ describe.skip('MessageStudentsWhoDialog', () => {
|
|||
const sendButton = await findByRole('button', {name: 'Send'})
|
||||
fireEvent.click(sendButton)
|
||||
|
||||
const observerIds = mocks[0].result.data.course.enrollmentsConnection.nodes.map(node => node.user._id)
|
||||
const observerIds = mocks[0].result.data.course.enrollmentsConnection.nodes.map(
|
||||
node => node.user._id
|
||||
)
|
||||
expect(onSend).toHaveBeenCalledWith(expect.objectContaining({recipientsIds: observerIds}))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (C) 2019 - present Instructure, Inc.
|
||||
* Copyright (C) 2022 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"name": "@canvas/upload-media-translations",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"author": "Spencer Olson <solson@instructure.com>",
|
||||
"main": "./index.js",
|
||||
"canvas": {
|
||||
"component": "canvas-media"
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue