add ability to save media objects to canvas-media

fixes COMMS-2314
refs COMMS-2293

Test Plan
- create a media type assignment
- as a student in a2 with notorious
  running locally go to record a video/upload
  a video file
- notice it works and posts back to the containing dom

Change-Id: I6c00c688b33deee017b7babb3729ff57d3dd2114
Reviewed-on: https://gerrit.instructure.com/205605
Tested-by: Jenkins
Reviewed-by: Landon Gilbert-Bland <lbland@instructure.com>
QA-Review: Steven Burnett <sburnett@instructure.com>
Product-Review: Steven Burnett <sburnett@instructure.com>
This commit is contained in:
Steven Burnett 2019-08-19 08:46:53 -06:00
parent c8261d1476
commit e614f14517
10 changed files with 576 additions and 94 deletions

View File

@ -21,24 +21,40 @@ import Button from '@instructure/ui-buttons/lib/components/Button'
import closedCaptionLanguages from '../../../../shared/closedCaptionLanguages'
import I18n from 'i18n!assignments_2_text_entry'
import {IconAttachMediaLine} from '@instructure/ui-icons'
import React, {useState} from 'react'
import React from 'react'
import UploadMedia from '@instructure/canvas-media'
import {UploadMediaStrings, MediaCaptureStrings} from '../../../../shared/UploadMediaTranslations'
import View from '@instructure/ui-layout/lib/components/View'
export default function MediaAttempt() {
const [mediaModalOpen, setMediaModalOpen] = useState(false)
const languages = Object.keys(closedCaptionLanguages).map(key => {
return {id: key, label: closedCaptionLanguages[key]}
})
export default class MediaAttempt extends React.Component {
state = {
mediaModalOpen: false,
mediaObjectUrl: null
}
onDismiss = mediaObject => {
this.setState({mediaModalOpen: false, mediaObjectUrl: mediaObject.embedded_iframe_url})
}
render() {
if (this.state.mediaObjectUrl) {
// TODO: figure out how the heck we want to style this thing.
return <iframe title="meidathings" src={this.state.mediaObjectUrl} />
}
return (
<View as="div" borderWidth="small">
<UploadMedia
onDismiss={this.onDismiss}
contextId={this.props.assignment.env.courseId}
contextType="course"
open={this.state.mediaModalOpen}
tabs={{embed: false, record: true, upload: true}}
uploadMediaTranslations={{UploadMediaStrings, MediaCaptureStrings}}
onDismiss={() => setMediaModalOpen(false)}
open={mediaModalOpen}
liveRegion={() => document.getElementById('flash_screenreader_holder')}
languages={languages}
/>
@ -46,7 +62,11 @@ export default function MediaAttempt() {
heading={I18n.t('Add Media')}
hero={<IconAttachMediaLine color="brand" />}
message={
<Button size="small" variant="primary" onClick={() => setMediaModalOpen(true)}>
<Button
size="small"
variant="primary"
onClick={() => this.setState({mediaModalOpen: true})}
>
{I18n.t('Record/Upload')}
</Button>
}
@ -54,3 +74,4 @@ export default function MediaAttempt() {
</View>
)
}
}

View File

@ -44,6 +44,7 @@
"@instructure/ui-themes": "^6.8.1",
"@instructure/ui-toggle-details": "^6.8.1",
"@instructure/uid": "^6.8.1",
"axios": "^0.18.0",
"prop-types": "^15",
"react": "^16",
"react-dom": "^16"

View File

@ -26,6 +26,7 @@ export default function EmbedPanel({embedCode, setEmbedCode, label}) {
maxHeight="10rem"
label={label}
value={embedCode}
placeholder={label}
onChange={e => setEmbedCode(e.target.value)}
/>
)

View File

@ -20,12 +20,14 @@ import React from 'react'
import {Alert} from '@instructure/ui-alerts'
import {canUseMediaCapture, MediaCapture} from '@instructure/media-capture'
import {object, string} from 'prop-types'
import {func, object, string} from 'prop-types'
import saveMediaRecording from './saveMediaRecording'
export default class MediaRecorder extends React.Component {
// saveFile = (file) => {
// this.props.contentProps.saveMediaRecording(file, this.props.editor, this.props.dismiss)
// }
saveFile = file => {
saveMediaRecording(file, this.props.contextId, this.props.contextType, this.props.dismiss)
}
render() {
return (
@ -43,6 +45,9 @@ export default class MediaRecorder extends React.Component {
}
MediaRecorder.propTypes = {
contextId: string,
contextType: string,
dismiss: func,
errorMessage: string.isRequired,
MediaCaptureStrings: object // eslint-disable-line react/forbid-prop-types
}

View File

@ -0,0 +1,23 @@
/*
* Copyright (C) 2019 - 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/>.
*/
const screenfull = {
on() {},
off() {}
}
export default screenfull

View File

@ -0,0 +1,53 @@
/*
* Copyright (C) 2019 - 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 {fireEvent, render} from '@testing-library/react'
import React from 'react'
import EmbedPanel from '../EmbedPanel'
describe('EmbedPanel', () => {
it('renders with label', () => {
const {getByText} = render(
<EmbedPanel embedCode="" label="embed panel" setEmbedCode={() => {}} />
)
expect(getByText('embed panel')).toBeInTheDocument()
})
it('renders with value', () => {
const {getByText} = render(
<EmbedPanel embedCode="the best value of the embed" label="" setEmbedCode={() => {}} />
)
expect(getByText('the best value of the embed')).toBeInTheDocument()
})
it('on change calls setEmbedCode', () => {
const handleChange = jest.fn()
const {getByPlaceholderText} = render(
<EmbedPanel
embedCode="the best value of the embed"
label="embed label"
setEmbedCode={handleChange}
/>
)
const textArea = getByPlaceholderText('embed label')
fireEvent.change(textArea, {target: {value: 'TEST VALUE'}})
expect(handleChange).toHaveBeenCalledTimes(1)
expect(handleChange.mock.calls[0][0]).toBe('TEST VALUE')
})
})

View File

@ -0,0 +1,143 @@
/*
* Copyright (C) 2019 - 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 {render, fireEvent, waitForElement} from '@testing-library/react'
import ComputerPanel from '../ComputerPanel'
function makeTranslationProps() {
return {
UploadMediaStrings: {
CLEAR_FILE_TEXT: 'Clear File',
INVALID_FILE_TEXT: 'Invalid file type',
DRAG_DROP_CLICK_TO_BROWSE: 'drag and drop or clik to browse'
}
}
}
describe('UploadFile: ComputerPanel', () => {
it('shows a failure message if the file is rejected', () => {
const notAnImageFile = new File(['foo'], 'foo.txt', {
type: 'text/plain'
})
const handleSetFile = jest.fn()
const handleSetHasUploadedFile = jest.fn()
const {getByLabelText, getByText} = render(
<ComputerPanel
theFile={null}
setFile={handleSetFile}
hasUploadedFile={false}
setHasUploadedFile={handleSetHasUploadedFile}
accept="image/*"
uploadMediaTranslations={makeTranslationProps()}
label="Upload File"
/>
)
const dropZone = getByLabelText(/Upload File/, {selector: 'input'})
fireEvent.change(dropZone, {
target: {
files: [notAnImageFile]
}
})
expect(getByText('Invalid file type')).toBeVisible()
})
it('accepts file files', () => {
const aFile = new File(['foo'], 'foo.png', {
type: 'image/png'
})
const handleSetFile = jest.fn()
const handleSetHasUploadedFile = jest.fn()
const {getByLabelText, queryByText} = render(
<ComputerPanel
theFile={null}
setFile={handleSetFile}
hasUploadedFile={false}
setHasUploadedFile={handleSetHasUploadedFile}
accept="image/*"
uploadMediaTranslations={makeTranslationProps()}
label="Upload File"
/>
)
const dropZone = getByLabelText(/Upload File/, {selector: 'input'})
fireEvent.change(dropZone, {
target: {
files: [aFile]
}
})
expect(queryByText('Invalid file type')).toBeNull()
})
it('clears error messages if a valid file is added', () => {
const notAnImageFile = new File(['foo'], 'foo.txt', {
type: 'text/plain'
})
const aFile = new File(['foo'], 'foo.png', {
type: 'image/png'
})
const handleSetFile = jest.fn()
const handleSetHasUploadedFile = jest.fn()
const {getByLabelText, getByText, queryByText} = render(
<ComputerPanel
theFile={null}
setFile={handleSetFile}
hasUploadedFile={false}
setHasUploadedFile={handleSetHasUploadedFile}
uploadMediaTranslations={makeTranslationProps()}
accept="image/*"
label="Upload File"
/>
)
const dropZone = getByLabelText(/Upload File/, {selector: 'input'})
fireEvent.change(dropZone, {
target: {
files: [notAnImageFile]
}
})
expect(getByText('Invalid file type')).toBeVisible()
fireEvent.change(dropZone, {
target: {
files: [aFile]
}
})
expect(queryByText('Invalid file type')).toBeNull()
})
it('Renders a video player preview if a file type is a video', async () => {
global.URL.createObjectURL = jest.fn(() => 'www.blah.com')
const handleSetFile = jest.fn()
const handleSetHasUploadedFile = jest.fn()
const aFile = new File(['foo'], 'foo.mp4', {
type: 'video/mp4'
})
const {getByText} = render(
<ComputerPanel
theFile={aFile}
setFile={handleSetFile}
uploadMediaTranslations={makeTranslationProps()}
hasUploadedFile
setHasUploadedFile={handleSetHasUploadedFile}
accept="mp4"
label="Upload File"
/>
)
const playButton = await waitForElement(() => getByText('Play'))
expect(playButton).toBeInTheDocument()
})
})

View File

@ -0,0 +1,126 @@
/*
* Copyright (C) 2019 - 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 K5Uploader from '@instructure/k5uploader'
import moxios from 'moxios'
import sinon from 'sinon'
import saveMediaRecording from '../saveMediaRecording'
function mediaServerSession() {
return {
ks: 'averylongstring',
subp_id: '0',
partner_id: '9',
uid: '1234_567',
serverTime: 1234,
kaltura_setting: {
uploadUrl: 'url.url.url',
entryUrl: 'url.url.url',
uiconfUrl: 'url.url.url',
partnerData: 'data from our partners'
}
}
}
describe('saveMediaRecording', () => {
beforeEach(() => {
moxios.install()
})
afterEach(() => {
moxios.uninstall()
})
it('fails if request for kaltura session fails', async done => {
moxios.stubRequest('/api/v1/services/kaltura_session?include_upload_config=1', {
status: 500,
response: {error: 'womp womp'}
})
const doneFunction = jest.fn()
sinon.stub(K5Uploader.prototype, 'loadUiConf').callsFake(() => 'mock')
await saveMediaRecording({}, '1', 'course', doneFunction)
expect(doneFunction).toHaveBeenCalledTimes(1)
expect(doneFunction.mock.calls[0][0].message).toBe('Request failed with status code 500')
done()
})
it('returns error if k5.filreError is dispatched', () => {
moxios.stubRequest('/api/v1/services/kaltura_session?include_upload_config=1', {
status: 200,
response: mediaServerSession()
})
const doneFunction = jest.fn()
return saveMediaRecording({}, '1', 'course', doneFunction).then(uploader => {
uploader.dispatchEvent('K5.fileError', {error: 'womp womp'}, uploader)
expect(doneFunction).toHaveBeenCalledTimes(1)
expect(doneFunction.mock.calls[0][0].error).toBe('womp womp')
})
})
it('k5.ready calls uploaders uploadFile with file', () => {
moxios.stubRequest('/api/v1/services/kaltura_session?include_upload_config=1', {
status: 200,
response: mediaServerSession()
})
const doneFunction = jest.fn()
const uploadFileFunc = jest.fn()
return saveMediaRecording({file: 'thing'}, '1', 'course', doneFunction).then(uploader => {
uploader.uploadFile = uploadFileFunc
uploader.dispatchEvent('K5.ready', uploader)
expect(uploadFileFunc).toHaveBeenCalledTimes(1)
expect(uploadFileFunc.mock.calls[0][0].file).toBe('thing')
})
})
it('k5.complete calls done with canvasMediaObject data if succeeds', () => {
moxios.stubRequest('/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'}
})
const doneFunction2 = jest.fn()
return saveMediaRecording({file: 'thing'}, '1', 'course', doneFunction2).then(
async uploader => {
uploader.dispatchEvent('K5.complete', {stuff: 'datatatatatatatat'}, uploader)
await new Promise(setTimeout)
expect(doneFunction2).toHaveBeenCalledTimes(1)
expect(doneFunction2.mock.calls[0][0].data).toBe('media object data')
}
)
})
it('fails if request to create media object fails', async () => {
moxios.stubRequest('/api/v1/services/kaltura_session?include_upload_config=1', {
status: 200,
response: mediaServerSession()
})
moxios.stubRequest('/api/v1/media_objects', {
status: 500,
response: {error: 'womp womp'}
})
const doneFunction2 = jest.fn()
return saveMediaRecording({file: 'thing'}, '1', 'course', doneFunction2).then(
async uploader => {
uploader.dispatchEvent('K5.complete', {stuff: 'datatatatatatatat'}, uploader)
await new Promise(setTimeout)
expect(doneFunction2).toHaveBeenCalledTimes(1)
expect(doneFunction2.mock.calls[0][0].message).toBe('Request failed with status code 500')
}
)
})
})

View File

@ -25,6 +25,7 @@ import {Tabs} from '@instructure/ui-tabs'
import {View} from '@instructure/ui-layout'
import {ACCEPTED_FILE_TYPES} from './acceptedMediaFileTypes'
import saveMediaRecording from './saveMediaRecording'
import translationShape from './translationShape'
const ClosedCaptionPanel = React.lazy(() => import('./ClosedCaptionPanel'))
@ -39,24 +40,6 @@ export const PANELS = {
CLOSED_CAPTIONS: 3
}
export const handleSubmit = (editor, selectedPanel, uploadData, saveMediaRecording, onDismiss) => {
switch (selectedPanel) {
case PANELS.COMPUTER: {
const {theFile} = uploadData
saveMediaRecording(theFile, editor, onDismiss)
break
}
case PANELS.EMBED: {
const {embedCode} = uploadData
editor.insertContent(embedCode)
onDismiss()
break
}
default:
throw new Error('Selected Panel is invalid') // Should never get here
}
}
export default class UploadMedia extends React.Component {
static propTypes = {
languages: arrayOf(
@ -66,8 +49,15 @@ export default class UploadMedia extends React.Component {
})
),
liveRegion: func,
contextId: string,
contextType: string,
onDismiss: func,
open: bool,
tabs: shape({
embed: bool,
record: bool,
upload: bool
}),
uploadMediaTranslations: translationShape
}
@ -78,6 +68,26 @@ export default class UploadMedia extends React.Component {
theFile: null
}
handleSubmit = () => {
switch (this.state.selectedPanel) {
case PANELS.COMPUTER: {
saveMediaRecording(
this.state.theFile,
this.props.contextId,
this.props.contextType,
this.props.onDismiss
)
break
}
case PANELS.EMBED: {
this.props.onDismiss()
break
}
default:
throw new Error('Selected Panel is invalid') // Should never get here
}
}
renderFallbackSpinner = () => {
const {LOADING_MEDIA} = this.props.uploadMediaTranslations.UploadMediaStrings
return (
@ -104,6 +114,7 @@ export default class UploadMedia extends React.Component {
maxWidth="large"
onRequestTabChange={(_, {index}) => this.setState({selectedPanel: index})}
>
{this.props.tabs.upload && (
<Tabs.Panel
isSelected={this.state.selectedPanel === PANELS.COMPUTER}
renderTitle={() => COMPUTER_PANEL_TITLE}
@ -122,7 +133,8 @@ export default class UploadMedia extends React.Component {
/>
</Suspense>
</Tabs.Panel>
)}
{this.props.tabs.record && (
<Tabs.Panel
isSelected={this.state.selectedPanel === PANELS.RECORD}
renderTitle={() => RECORD_PANEL_TITLE}
@ -130,12 +142,15 @@ export default class UploadMedia extends React.Component {
<Suspense fallback={this.renderFallbackSpinner()}>
<MediaRecorder
MediaCaptureStrings={this.props.uploadMediaTranslations.MediaCaptureStrings}
contextType={this.props.contextType}
contextId={this.props.contextId}
errorMessage={UPLOADING_ERROR}
dismiss={this.props.onDismiss}
/>
</Suspense>
</Tabs.Panel>
)}
{this.props.tabs.embed && (
<Tabs.Panel
isSelected={this.state.selectedPanel === PANELS.EMBED}
renderTitle={() => EMBED_PANEL_TITLE}
@ -148,7 +163,7 @@ export default class UploadMedia extends React.Component {
/>
</Suspense>
</Tabs.Panel>
)}
<Tabs.Panel
isSelected={this.state.selectedPanel === PANELS.CLOSED_CAPTIONS}
renderTitle={() => CLOSED_CAPTIONS_PANEL_TITLE}
@ -178,7 +193,7 @@ export default class UploadMedia extends React.Component {
<Button
onClick={e => {
e.preventDefault()
handleSubmit()
this.handleSubmit()
}}
variant="primary"
type="submit"

View File

@ -0,0 +1,94 @@
/*
* Copyright (C) 2019 - 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 axios from 'axios'
import K5Uploader from '@instructure/k5uploader'
export const VIDEO_SIZE_OPTIONS = {height: '432px', width: '768px'}
function generateUploadOptions(mediatypes, sessionData) {
const sessionDataCopy = JSON.parse(JSON.stringify(sessionData))
delete sessionDataCopy.kaltura_setting
return {
kaltura_session: sessionDataCopy,
allowedMediaTypes: mediatypes,
uploadUrl: sessionData.kaltura_setting.uploadUrl,
entryUrl: sessionData.kaltura_setting.entryUrl,
uiconfUrl: sessionData.kaltura_setting.uiconfUrl,
entryDefaults: {
partnerData: sessionData.kaltura_setting.partner_data
}
}
}
function addUploaderReadyEventListeners(uploader, file) {
uploader.addEventListener('K5.ready', () => {
uploader.uploadFile(file)
})
}
function addUploaderFileErrorEventListeners(uploader, done) {
uploader.addEventListener('K5.fileError', error => {
done(error)
})
}
function addUploaderFileCompleteEventListeners(uploader, context, done) {
uploader.addEventListener('K5.complete', async mediaServerMediaObject => {
mediaServerMediaObject.contextCode = `${context.contextType}_${context.contextId}`
mediaServerMediaObject.type = `${context.contextType}_${context.contextId}`
const body = {
id: mediaServerMediaObject.entryId,
type:
{2: 'image', 5: 'audio'}[mediaServerMediaObject.mediaType] ||
mediaServerMediaObject.type.includes('audio')
? 'audio'
: 'video',
context_code: mediaServerMediaObject.contextCode,
title: mediaServerMediaObject.title,
user_entered_title: mediaServerMediaObject.userTitle
}
try {
const canvasMediaObject = await axios.post('/api/v1/media_objects', body)
done(canvasMediaObject.data)
} catch (e) {
done(e)
}
})
}
export default async function saveMediaRecording(file, contextId, contextType, done) {
try {
const mediaServerSession = await axios.post(
'/api/v1/services/kaltura_session?include_upload_config=1'
)
const session = generateUploadOptions(
['video', 'audio', 'webm', 'video/webm', 'audio/webm'],
mediaServerSession.data
)
const k5UploaderSession = new K5Uploader(session)
addUploaderReadyEventListeners(k5UploaderSession, file)
addUploaderFileErrorEventListeners(k5UploaderSession, done)
addUploaderFileCompleteEventListeners(k5UploaderSession, {contextId, contextType}, done)
return k5UploaderSession
} catch (err) {
done(err)
}
}