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:
Spencer Olson 2022-04-27 15:07:47 -05:00
parent 047816633a
commit 43e6393a7a
36 changed files with 571 additions and 178 deletions

View File

@ -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"]

View File

@ -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)
}

View File

@ -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,

View File

@ -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 {

View File

@ -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'
}
})

View File

@ -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

View File

@ -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)
})
})

View File

@ -176,7 +176,9 @@ QUnit.module('GradebookGrid AssignmentColumnHeader', suiteHooks => {
settingKey: 'grade'
},
submissionsLoaded: true
submissionsLoaded: true,
userId: '123'
}
})

View File

@ -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'))
}))
})

View File

@ -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'

View File

@ -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')

View File

@ -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 />')
})

View File

@ -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,

View File

@ -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,

View File

@ -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')))

View File

@ -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()}>

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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]}

View File

@ -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: () => {},

View File

@ -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(() => {

View File

@ -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()}>

View File

@ -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"
}
}

View File

@ -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,

View File

@ -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'
})
})
})
})

View File

@ -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()
})

View File

@ -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'

View File

@ -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>
)

View File

@ -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

View File

@ -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()
})
})

View File

@ -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: {

View File

@ -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
/>
</>
)
}

View File

@ -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()
})

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2019 - present Instructure, Inc.
* Copyright (C) 2022 - present Instructure, Inc.
*
* This file is part of Canvas.
*

View File

@ -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"
}
}