Add video options tray to the rce

closes COREFE-315

test plan:
  - load a page with the RCE and have a video in it
  - click on the video
  > expect the Options popup toolbar button
  - click it
  > expect the video options tray to open
  > expect the title to default to the video's file name
  > expect the default size to be Large (400px on the long side)
  - delete the title
  > expect the Done button to be disabled
  - set a new title
  - change the size to Custom
  - delete the width or height
  > expect an error message and the Done button to
    be disabled
  - enter a new size, or select one from the dropdown
  - click Done
  > expect the video to be resized to the new size
  - if you're using a screenreader, expect the video
    to be announced as "Video player for {your title}"

Change-Id: Id7e29520cc91c02645b92d666216e64f6619bbbb
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/211355
Product-Review: Lauren Williams <lcwilliams@instructure.com>
Tested-by: Jenkins
Reviewed-by: Clay Diffrient <cdiffrient@instructure.com>
QA-Review: Jeremy Putnam <jeremyp@instructure.com>
This commit is contained in:
Ed Schiebel 2019-09-25 16:31:47 -04:00
parent 518a0aa6fe
commit 67e551c5e5
29 changed files with 654 additions and 134 deletions

View File

@ -30,6 +30,10 @@ body {
font-family: inherit !important;
}
* {
box-sizing: border-box;
}
td {
margin: 0;
}

View File

@ -284,7 +284,7 @@
"build:css:watch": "brandable_css --watch",
"build:js": "yarn run webpack-development",
"build:js:watch": "yarn run webpack",
"build:packages": "yarn workspace-run build:canvas",
"build:packages": "yarn workspace-run-serial build:canvas",
"lint:browser-code": "es-check es8 ./public/dist/**/*.js",
"lint:staged": "lint-staged",
"lint:js:coffeescripts": "eslint ./app/coffeescripts/**/*.js",

View File

@ -27,3 +27,5 @@ if (!('MutationObserver' in window)) {
value: require('@sheerun/mutationobserver-shim')
})
}
window.scroll = () => {}

View File

@ -45,6 +45,7 @@
"@instructure/ui-themes": "^6.8.1",
"@instructure/ui-toggle-details": "^6.8.1",
"@instructure/uid": "^6.8.1",
"@sheerun/mutationobserver-shim": "^0.3.2",
"axios": "^0.18.0",
"prop-types": "^15",
"react": "^16",

View File

@ -37,7 +37,7 @@ if (!('MutationObserver' in window)) {
}
if (typeof window.URL.createObjectURL === 'undefined') {
Object.defineProperty(window.URL, 'createObjectURL', {value: () => {}})
Object.defineProperty(window.URL, 'createObjectURL', {value: () => 'http://example.com/whatever'})
}
window.scroll = () => {}

View File

@ -11,9 +11,9 @@
"lint:fix": "eslint --fix \"src/**/*.js\" \"test/**/*.js\"",
"_test": "Test cafe will be added back to test as part of CORE-2995",
"test": "yarn test:mocha && yarn test:jest",
"test:mocha": "BABEL_ENV=test-node mocha 'test/**/*.test.js' --require @instructure/canvas-theme --require @babel/register --timeout 5000 --reporter mocha-multi-reporters --reporter-options configFile=mocha-reporter-config.json",
"test:mocha:one": "BABEL_ENV=test-node mocha --require @instructure/canvas-theme --require @babel/register --timeout 5000 --reporter mocha-multi-reporters --reporter-options configFile=mocha-reporter-config.json",
"test:jest": "jest",
"test:mocha": "BABEL_ENV=test-node mocha 'test/**/*.test.js' --require @instructure/canvas-theme --require jsdom-global/register --require @babel/register --timeout 5000 --reporter mocha-multi-reporters --reporter-options configFile=mocha-reporter-config.json",
"test:mocha:one": "BABEL_ENV=test-node mocha --require @instructure/canvas-theme --require jsdom-global/register --require @babel/register --timeout 5000 --reporter mocha-multi-reporters --reporter-options configFile=mocha-reporter-config.json",
"test:jest": "jest --runInBand",
"test:cafe": "yarn build:cafe && yarn test:cafe:only",
"test:cafe:only": "testcafe chrome testcafe/**/*.test.js",
"test:cafe:all": "yarn build:cafe && testcafe chrome,firefox,safari testcafe/**/*.test.js",
@ -77,8 +77,9 @@
"@instructure/ui-themeable": "6",
"@instructure/ui-themes": "6",
"@instructure/ui-toggle-details": "6",
"@instructure/uid": "6",
"@instructure/ui-utils": "6",
"@instructure/uid": "6",
"@sheerun/mutationobserver-shim": "^0.3.2",
"@tinymce/tinymce-react": "^3.0.1",
"aphrodite": "^2",
"bloody-offset": "0.0.0",

View File

@ -277,7 +277,7 @@ describe('contentInsertion', () => {
const video = videoFromUpload()
const result = contentInsertion.insertVideo(editor, video)
expect(editor.insertContent).toHaveBeenCalledWith(
'<iframe allow="fullscreen" allowfullscreen="" data-media-id="m-media-id" src="/url/to/m-media-id?type=video" style="width:400px;height:225px;display:inline-block"></iframe>'
'<iframe allow="fullscreen" allowfullscreen="" data-media-id="m-media-id" data-media-type="video" src="/url/to/m-media-id?type=video" style="width:400px;height:225px;display:inline-block"></iframe>'
)
expect(result).toEqual('the inserted iframe')
})
@ -287,7 +287,7 @@ describe('contentInsertion', () => {
const video = videoFromTray()
const result = contentInsertion.insertVideo(editor, video)
expect(editor.insertContent).toHaveBeenCalledWith(
'<iframe allow="fullscreen" allowfullscreen="" data-media-id="17" src="/media_objects_iframe?mediahref=%2Furl%2Fto%2Fcourse%2Ffile&amp;type=video" style="width:400px;height:225px;display:inline-block"></iframe>'
'<iframe allow="fullscreen" allowfullscreen="" data-media-id="17" data-media-type="video" src="/media_objects_iframe?mediahref=%2Furl%2Fto%2Fcourse%2Ffile&amp;type=video" style="width:400px;height:225px;display:inline-block" title="filename.mov"></iframe>'
)
expect(result).toEqual('the inserted iframe')
})
@ -306,7 +306,7 @@ describe('contentInsertion', () => {
const audio = audioFromUpload()
const result = contentInsertion.insertAudio(editor, audio)
expect(editor.insertContent).toHaveBeenCalledWith(
'<iframe data-media-id="m-media-id" src="/url/to/m-media-id?type=audio" style="width:300px;height:2.813rem;display:inline-block"></iframe>'
'<iframe data-media-id="m-media-id" data-media-type="audio" src="/url/to/m-media-id?type=audio" style="width:300px;height:2.813rem;display:inline-block"></iframe>'
)
expect(result).toEqual('the inserted iframe')
})
@ -316,7 +316,7 @@ describe('contentInsertion', () => {
const audio = audioFromTray()
const result = contentInsertion.insertAudio(editor, audio)
expect(editor.insertContent).toHaveBeenCalledWith(
'<iframe data-media-id="29" src="/media_objects_iframe?mediahref=url%2Fto%2Fcourse%2Ffile&amp;type=audio" style="width:300px;height:2.813rem;display:inline-block"></iframe>'
'<iframe data-media-id="29" data-media-type="audio" src="/media_objects_iframe?mediahref=url%2Fto%2Fcourse%2Ffile&amp;type=audio" style="width:300px;height:2.813rem;display:inline-block" title="filename.mp3"></iframe>'
)
expect(result).toEqual('the inserted iframe')
})

View File

@ -157,7 +157,7 @@ describe('contentRendering', () => {
const video = videoFromTray()
const rendered = contentRendering.renderVideo(video)
expect(rendered).toEqual(
'<iframe allow="fullscreen" allowfullscreen="" data-media-id="17" src="/media_objects_iframe?mediahref=%2Furl%2Fto%2Fcourse%2Ffile&amp;type=video" style="width:400px;height:225px;display:inline-block"></iframe>'
'<iframe allow="fullscreen" allowfullscreen="" data-media-id="17" data-media-type="video" src="/media_objects_iframe?mediahref=%2Furl%2Fto%2Fcourse%2Ffile&amp;type=video" style="width:400px;height:225px;display:inline-block" title="filename.mov"></iframe>'
)
})
@ -165,7 +165,7 @@ describe('contentRendering', () => {
const video = videoFromUpload()
const rendered = contentRendering.renderVideo(video)
expect(rendered).toEqual(
'<iframe allow="fullscreen" allowfullscreen="" data-media-id="m-media-id" src="/url/to/m-media-id?type=video" style="width:400px;height:225px;display:inline-block"></iframe>'
'<iframe allow="fullscreen" allowfullscreen="" data-media-id="m-media-id" data-media-type="video" src="/url/to/m-media-id?type=video" style="width:400px;height:225px;display:inline-block"></iframe>'
)
})
})
@ -187,7 +187,7 @@ describe('contentRendering', () => {
const audio = audioFromTray()
const rendered = contentRendering.renderAudio(audio)
expect(rendered).toEqual(
'<iframe data-media-id="29" src="/media_objects_iframe?mediahref=url%2Fto%2Fcourse%2Ffile&amp;type=audio" style="width:300px;height:2.813rem;display:inline-block"></iframe>'
'<iframe data-media-id="29" data-media-type="audio" src="/media_objects_iframe?mediahref=url%2Fto%2Fcourse%2Ffile&amp;type=audio" style="width:300px;height:2.813rem;display:inline-block" title="filename.mp3"></iframe>'
)
})
@ -195,7 +195,7 @@ describe('contentRendering', () => {
const audio = audioFromUpload()
const rendered = contentRendering.renderAudio(audio)
expect(rendered).toEqual(
'<iframe data-media-id="m-media-id" src="/url/to/m-media-id?type=audio" style="width:300px;height:2.813rem;display:inline-block"></iframe>'
'<iframe data-media-id="m-media-id" data-media-type="audio" src="/url/to/m-media-id?type=audio" style="width:300px;height:2.813rem;display:inline-block"></iframe>'
)
})
})

View File

@ -108,13 +108,14 @@ function constructJSXVideoEmbedding(video) {
allow="fullscreen"
allowFullScreen
data-media-id={`${video.media_id || video.id}`}
data-media-type="video"
src={src}
style={{
width: VIDEO_SIZE_DEFAULT.width,
height: VIDEO_SIZE_DEFAULT.height,
display: 'inline-block'
}}
title={video.name}
title={video.name || video.text}
/>
)
}
@ -128,13 +129,14 @@ function constructJSXAudioEmbedding(audio) {
return (
<iframe
data-media-id={`${audio.media_id || audio.id}`}
data-media-type="audio"
src={src}
style={{
width: AUDIO_PLAYER_SIZE.width,
height: AUDIO_PLAYER_SIZE.height,
display: 'inline-block'
}}
title={audio.name}
title={audio.name || audio.text}
/>
)
}

View File

@ -29,6 +29,7 @@ export const EXTRA_LARGE = 'extra-large'
export const CUSTOM = 'custom'
export const imageSizes = [SMALL, MEDIUM, LARGE, EXTRA_LARGE, CUSTOM]
export const videoSizes = [MEDIUM, LARGE, EXTRA_LARGE, CUSTOM]
export const defaultImageSize = 320
const sizeByMaximumDimension = {
@ -74,6 +75,42 @@ export function fromImageEmbed($element) {
return imageOptions
}
export function fromVideoEmbed($element) {
// $element will be the <span> tinuymce wraps around the iframe
// that's hosting the video player
let $videoElem = null
let naturalWidth, naturalHeight
if ($element.firstElementChild.tagName === 'IFRAME') {
const videoDoc = $element.firstElementChild.contentDocument
if (videoDoc) {
$videoElem = videoDoc.querySelector('video')
}
if ($videoElem && ($videoElem.loadedmetadata || $videoElem.readyState >= 1)) {
naturalWidth = $videoElem.videoWidth
naturalHeight = $videoElem.videoHeight
}
}
// because tinymce doesn't always put the title attribute on the iframe,
// but it does maintain it on the span it adds around it.
const title =
$element.firstElementChild.getAttribute('data-titleText') ||
$element.getAttribute('data-mce-p-data-titleText')
const rect = $element.getBoundingClientRect()
const videoOptions = {
titleText: title || '',
appliedHeight: rect.height,
appliedWidth: rect.width,
naturalHeight,
naturalWidth,
source: $videoElem && $videoElem.querySelector('source')
}
videoOptions.videoSize = imageSizeFromKnownOptions(videoOptions)
return videoOptions
}
export function scaleImageForHeight(naturalWidth, naturalHeight, targetHeight) {
const constraints = {minHeight: MIN_HEIGHT, minWidth: MIN_WIDTH}
return scaleForHeight(naturalWidth, naturalHeight, targetHeight, constraints)

View File

@ -18,11 +18,9 @@
import React from 'react'
import ReactDOM from 'react-dom'
import {camelize} from '@instructure/ui-utils'
import bridge from '../../../../bridge'
import {asImageEmbed} from '../../shared/ContentSelection'
import {renderImage} from '../../../contentRendering'
import ImageOptionsTray from '.'
export const CONTAINER_ID = 'instructure-image-options-tray-container'
@ -67,24 +65,19 @@ export default class TrayController {
const {$img} = this
if (imageOptions.displayAs === 'embed') {
editor.dom.setAttribs($img, {
alt: imageOptions.isDecorativeImage ? '' : imageOptions.altText,
'data-is-decorative': imageOptions.isDecorativeImage ? 'true' : null,
width: imageOptions.appliedWidth,
height: imageOptions.appliedHeight
})
// when the image was first added to the rce, we applied
// max-width and max-height. Remove them from the style now
const style = this._parseStyle($img)
delete style.maxWidth
delete style.maxHeight
const newImg = renderImage({
id: $img.id,
url: $img.src,
alt_text: {
decorativeSelected: imageOptions.isDecorativeImage,
altText: imageOptions.altText
},
width: imageOptions.appliedWidth,
height: imageOptions.appliedHeight,
style
editor.dom.setStyles($img, {
'max-height': '',
'max-width': ''
})
editor.selection.setContent(newImg)
// tell tinymce so the context toolbar resets
editor.fire('ObjectResized', {
@ -137,18 +130,4 @@ export default class TrayController {
)
ReactDOM.render(element, this.$container)
}
_parseStyle(elem) {
const styl = elem.getAttribute('style')
if (styl) {
return styl.split(/;\s*/).reduce((sobj, oneStyle) => {
const [name, val] = oneStyle.split(/:\s*/)
if (name) {
sobj[camelize(name)] = val
}
return sobj
}, {})
}
return {}
}
}

View File

@ -19,11 +19,14 @@
import React from 'react'
import ReactDOM from 'react-dom'
import bridge from '../../../../bridge'
import formatMessage from '../../../../format-message'
import {asVideoElement} from '../../shared/ContentSelection'
import VideoOptionsTray from '.'
export const CONTAINER_ID = 'instructure-video-options-tray-container'
export const VIDEO_SIZE_DEFAULT = {height: '225px', width: '400px'}
export const VIDEO_SIZE_DEFAULT = {height: '225px', width: '400px'} // AKA "LARGE"
export const AUDIO_PLAYER_SIZE = {width: '300px', height: '2.813rem'}
export default class TrayController {
@ -50,6 +53,7 @@ export default class TrayController {
showTrayForEditor(editor) {
this._editor = editor
this.$videoContainer = editor.selection.getNode()
this._shouldOpen = true
this._renderTray()
}
@ -61,16 +65,40 @@ export default class TrayController {
}
_applyVideoOptions(videoOptions) {
const editor = this._editor
const $videoContainer = editor.selection.getNodeVIDEO_SIZE_DEFAULTVIDEO_SIZE_DEFAULT
$videoContainer.setAttribute(
'style',
`height: ${VIDEO_SIZE_DEFAULT[videoOptions.videoSize].height}; width:${VIDEO_SIZE_DEFAULT[videoOptions.videoSize].width}`
if (this.$videoContainer && this.$videoContainer.firstElementChild?.tagName === 'IFRAME') {
const styl = {
height: `${videoOptions.appliedHeight}px`,
width: `${videoOptions.appliedWidth}px`
}
this._editor.dom.setStyles(this.$videoContainer, styl)
this._editor.dom.setStyles(this.$videoContainer.firstElementChild, styl)
const title = formatMessage('Video player for {title}', {title: videoOptions.titleText})
this._editor.dom.setAttrib(this.$videoContainer, 'data-mce-p-title', title)
this._editor.dom.setAttrib(
this.$videoContainer,
'data-mce-p-data-titleText',
videoOptions.titleText
)
this._editor.dom.setAttrib(this.$videoContainer.firstElementChild, 'title', title)
this._editor.dom.setAttrib(
this.$videoContainer.firstElementChild,
'data-titleText',
videoOptions.titleText
)
// tell tinymce so the context toolbar resets
this._editor.fire('ObjectResized', {
target: this.$videoContainer,
width: videoOptions.appliedWidth,
height: videoOptions.appliedHeight
})
}
this._dismissTray()
}
_dismissTray() {
this._editor.selection.select(this.$videoContainer)
this._shouldOpen = false
this._renderTray()
this._editor = null
@ -92,10 +120,12 @@ export default class TrayController {
const element = (
<VideoOptionsTray
key={this._renderId}
videoOptions={asVideoElement(this.$videoContainer)}
onEntered={() => {
this._isOpen = true
}}
onExited={() => {
bridge.focusActiveEditor(false)
this._isOpen = false
}}
onSave={videoOptions => {

View File

@ -21,6 +21,51 @@ import ReactDOM from 'react-dom'
import TrayController, {CONTAINER_ID} from '../TrayController'
import FakeEditor from '../../../shared/__tests__/FakeEditor'
import VideoOptionsTrayDriver from './VideoOptionsTrayDriver'
import * as contentSelection from '../../../shared/ContentSelection'
const mockVideoPlayers = [
{
titleText: 'video title 0',
appliedWidth: 400,
appliedHeight: 300,
naturalWidth: 800,
naturalHeight: 600,
source: '/path/to/video0.mp4',
type: 'video-embed',
id: 'm-video-id0'
},
{
titleText: 'video title 1',
appliedWidth: 400,
appliedHeight: 300,
naturalWidth: 800,
naturalHeight: 600,
source: '/path/to/video1.mp4',
type: 'video-embed',
id: 'm-video-id1'
},
{
titleText: 'video title2',
appliedWidth: 400,
appliedHeight: 300,
naturalWidth: 800,
naturalHeight: 600,
source: '/path/to/video2.mp4',
type: 'video-embed',
id: 'm-video-id2'
}
]
beforeAll(() => {
contentSelection.asVideoElement = jest.fn(elem => {
const vid = elem.getAttribute('id')
return mockVideoPlayers.find(vp => vp.id === vid)
})
})
afterAll(() => {
jest.restoreAllMocks()
})
describe('RCE "Videos" Plugin > VideoOptionsTray > TrayController', () => {
let $videos
@ -30,9 +75,9 @@ describe('RCE "Videos" Plugin > VideoOptionsTray > TrayController', () => {
beforeEach(() => {
$videos = []
editors = [new FakeEditor(), new FakeEditor()]
editors.forEach(editor => {
editors.forEach((editor, i) => {
editor.initialize()
const $video = createVideo(320, 320)
const $video = createVideo(i)
$videos.push($video)
editor.appendElement($video)
editor.setSelectedNode($video)
@ -49,23 +94,37 @@ describe('RCE "Videos" Plugin > VideoOptionsTray > TrayController', () => {
}
})
function createVideo(height = 200, width = 200, id = '12345bseds') {
const $el = document.createElement('div')
$el.setAttribute('style', `height: ${height}; width:${width}`)
$el.id = id
return $el
function createVideo(i) {
const velem = document.createElement('div')
velem.setAttribute('id', mockVideoPlayers[i].id)
velem.setAttribute('title', mockVideoPlayers[i].titleText)
return velem
}
function getTray() {
return VideoOptionsTrayDriver.find()
}
function getVideoOptionsFromTray() {
const driver = VideoOptionsTrayDriver.find()
return {
titleText: driver.titleText,
displayAs: driver.displayAs,
size: driver.size
}
}
describe('#showTrayForEditor()', () => {
describe('when the tray is not already open', () => {
it('opens the tray', async () => {
trayController.showTrayForEditor(editors[0])
expect(getTray()).not.toBeNull()
})
it('uses the selected video from the editor', async () => {
trayController.showTrayForEditor(editors[0])
expect(getVideoOptionsFromTray().titleText).toEqual($videos[0].getAttribute('title'))
})
})
describe('when the tray is open for a different editor', () => {
@ -85,7 +144,7 @@ describe('RCE "Videos" Plugin > VideoOptionsTray > TrayController', () => {
beforeEach(async () => {
trayController.showTrayForEditor(editors[0])
$otherVideo = createVideo(210, 210)
$otherVideo = createVideo(0)
editors[0].setSelectedNode($otherVideo)
trayController.showTrayForEditor(editors[0])
})

View File

@ -30,7 +30,19 @@ describe('RCE "Videos" Plugin > VideoOptionsTray', () => {
props = {
onRequestClose: jest.fn(),
onSave: jest.fn(),
open: true
open: true,
videoOptions: {
$element: null,
appliedHeight: 180,
appliedWidth: 320,
id: 'm-video-id',
naturalHeight: 730,
naturalWidth: 1280,
source: {},
titleText: '',
type: 'video-embed',
videoSize: 'medium'
}
}
})
@ -55,4 +67,152 @@ describe('RCE "Videos" Plugin > VideoOptionsTray', () => {
renderComponent()
expect(tray.label).toEqual('Video Options Tray')
})
describe('Title field', () => {
it('uses the value of titleText in the given video options', () => {
props.videoOptions.titleText = 'A turtle in a party suit.'
renderComponent()
expect(tray.titleText).toEqual('A turtle in a party suit.')
})
it('is blank when the given video options titleText is blank', () => {
props.videoOptions.titleText = ''
renderComponent()
expect(tray.titleText).toEqual('')
})
it('is disabled when displaying the image as a link', () => {
renderComponent()
tray.setDisplayAs('link')
expect(tray.titleTextDisabled).toEqual(true)
})
})
describe('"Display Options" field', () => {
it('is set to "embed" by default', () => {
renderComponent()
expect(tray.displayAs).toEqual('embed')
})
it('can be set to "Display Text Link"', () => {
renderComponent()
tray.setDisplayAs('link')
expect(tray.displayAs).toEqual('link')
})
it('can be reset to "Embed Image"', () => {
renderComponent()
tray.setDisplayAs('link')
tray.setDisplayAs('embed')
expect(tray.displayAs).toEqual('embed')
})
})
describe('"Size" field', () => {
it('is set using the given image options', () => {
renderComponent()
expect(tray.size).toEqual('Medium')
})
it('can be re-set to "Medium"', async () => {
renderComponent()
await tray.setSize('Large')
await tray.setSize('Medium')
expect(tray.size).toEqual('Medium')
})
it('can be set to "Large"', async () => {
renderComponent()
await tray.setSize('Large')
expect(tray.size).toEqual('Large')
})
it('can be set to "Custom"', async () => {
renderComponent()
await tray.setSize('Custom')
expect(tray.size).toEqual('Custom')
})
})
describe('"Done" button', () => {
describe('when Title Text is present', () => {
beforeEach(() => {
renderComponent()
tray.setTitleText('A turtle in a party suit.')
})
it('is enabled', () => {
expect(tray.doneButtonDisabled).toEqual(false)
})
})
describe('when Title Text is not present', () => {
beforeEach(() => {
renderComponent()
tray.setTitleText('')
})
it('is disabled ', () => {
expect(tray.doneButtonDisabled).toEqual(true)
})
it('is enabled when "Display Text Link" is selected', () => {
tray.setDisplayAs('link')
expect(tray.doneButtonDisabled).toEqual(false)
})
})
})
describe('when clicked', () => {
beforeEach(() => {
renderComponent()
tray.setTitleText('A turtle in a party suit.')
})
it('prevents the default click handler', () => {
const preventDefault = jest.fn()
// Override preventDefault before event reaches image
tray.$doneButton.addEventListener(
'click',
event => {
Object.assign(event, {preventDefault})
},
true
)
tray.$doneButton.click()
expect(preventDefault).toHaveBeenCalledTimes(1)
})
it('calls the .onSave prop', () => {
tray.$doneButton.click()
expect(props.onSave).toHaveBeenCalledTimes(1)
})
describe('when calling the .onSave prop', () => {
it('includes the Title Text', () => {
tray.setTitleText('A turtle in a party suit.')
tray.$doneButton.click()
const [{titleText}] = props.onSave.mock.calls[0]
expect(titleText).toEqual('A turtle in a party suit.')
})
it('includes the "Display As" setting', () => {
tray.setDisplayAs('link')
tray.$doneButton.click()
const [{displayAs}] = props.onSave.mock.calls[0]
expect(displayAs).toEqual('link')
})
it('includes the size to be applied', async () => {
await tray.setSize('Large')
tray.$doneButton.click()
const [{appliedHeight, appliedWidth}] = props.onSave.mock.calls[0]
expect(appliedWidth).toEqual(400)
const expectedHt = Math.round(
(props.videoOptions.naturalHeight / props.videoOptions.naturalWidth) * 400
)
expect(appliedHeight).toEqual(expectedHt)
})
})
})
})

View File

@ -16,7 +16,7 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {getByLabelText, queryByLabelText, wait} from '@testing-library/dom'
import {fireEvent, getAllByText, getByLabelText, queryByLabelText, wait} from '@testing-library/dom'
function getSizeOptions($sizeSelect) {
const controlledId = $sizeSelect.getAttribute('aria-controls')
@ -44,6 +44,14 @@ export default class VideoOptionsTrayDriver {
return this.$element.getAttribute('aria-label')
}
get $titleTextField() {
return this.$element.querySelector('textarea')
}
get $displayAsField() {
return getAllByText(this.$element, 'Display Options')[0].closest('fieldset')
}
get $sizeSelect() {
return getByLabelText(this.$element, 'Size')
}
@ -54,6 +62,18 @@ export default class VideoOptionsTrayDriver {
)
}
get titleText() {
return this.$titleTextField.value
}
get titleTextDisabled() {
return this.$titleTextField.disabled
}
get displayAs() {
return this.$displayAsField.querySelector('input[type="radio"]:checked').value
}
get size() {
return this.$sizeSelect.value
}
@ -62,6 +82,15 @@ export default class VideoOptionsTrayDriver {
return this.$doneButton.disabled
}
setTitleText(titleText) {
fireEvent.change(this.$titleTextField, {target: {value: titleText}})
}
setDisplayAs(value) {
const $input = this.$displayAsField.querySelector(`input[type="radio"][value="${value}"]`)
$input.click()
}
async setSize(sizeText) {
this.$sizeSelect.click()
await wait(() => getSizeOptions(this.$sizeSelect))

View File

@ -16,25 +16,124 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {bool, func, shape, string} from 'prop-types'
import React, {useState} from 'react'
import {bool, func, number, shape, string} from 'prop-types'
import {ScreenReaderContent} from '@instructure/ui-a11y'
import {Button, CloseButton} from '@instructure/ui-buttons'
import {Flex} from '@instructure/ui-layout'
import {Heading} from '@instructure/ui-elements'
import React from 'react'
import {Tray} from '@instructure/ui-overlays'
import {RadioInput, RadioInputGroup, Select, TextArea} from '@instructure/ui-forms'
import {IconQuestionLine} from '@instructure/ui-icons'
import {Flex, View} from '@instructure/ui-layout'
import {Tooltip, Tray} from '@instructure/ui-overlays'
import {
CUSTOM,
MIN_HEIGHT,
MIN_WIDTH,
videoSizes,
labelForImageSize,
scaleToSize
} from '../../instructure_image/ImageEmbedOptions'
import formatMessage from '../../../../format-message'
import DimensionsInput, {useDimensionsState} from '../../shared/DimensionsInput'
export default function VideoOptionsTray(props) {
const {onRequestClose, open} = props
const {videoOptions, onRequestClose, open} = props
const {naturalHeight, naturalWidth} = videoOptions
const currentHeight = videoOptions.appliedHeight || naturalHeight
const currentWidth = videoOptions.appliedWidth || naturalWidth
const [titleText, setTitleText] = useState(videoOptions.titleText)
const [displayAs, setDisplayAs] = useState('embed')
const [videoSize, setVideoSize] = useState(videoOptions.videoSize)
const [videoHeight, setVideoHeight] = useState(currentHeight)
const [videoWidth, setVideoWidth] = useState(currentWidth)
const dimensionsState = useDimensionsState(videoOptions, {
minHeight: MIN_HEIGHT,
minWidth: MIN_WIDTH
})
const videoSizeOption = {label: labelForImageSize(videoSize), value: videoSize}
function handleTitleTextChange(event) {
setTitleText(event.target.value)
}
function handleDisplayAsChange(event) {
setDisplayAs(event.target.value)
}
function handleVideoSizeChange(event, selectedOption) {
setVideoSize(selectedOption.value)
if (selectedOption.value === CUSTOM) {
setVideoHeight(currentHeight)
setVideoWidth(currentWidth)
} else {
const {height, width} = scaleToSize(selectedOption.value, naturalWidth, naturalHeight)
setVideoHeight(height)
setVideoWidth(width)
}
}
function handleSave(event) {
event.preventDefault()
let appliedHeight = videoHeight
let appliedWidth = videoWidth
if (videoSize === CUSTOM) {
appliedHeight = dimensionsState.height
appliedWidth = dimensionsState.width
}
props.onSave({
titleText,
appliedHeight,
appliedWidth,
displayAs
})
}
const tooltipText = formatMessage('Used by screen readers to describe the video')
const textAreaLabel = (
<Flex alignItems="center">
<Flex.Item>{formatMessage('Title')}</Flex.Item>
<Flex.Item margin="0 0 0 xx-small">
<Tooltip
on={['hover', 'focus']}
placement="top"
tip={
<View display="block" id="alt-text-label-tooltip" maxWidth="14rem">
{tooltipText}
</View>
}
>
<Button icon={IconQuestionLine} size="small" variant="icon">
<ScreenReaderContent>{tooltipText}</ScreenReaderContent>
</Button>
</Tooltip>
</Flex.Item>
</Flex>
)
const messagesForSize = []
if (videoSize !== CUSTOM) {
messagesForSize.push({
text: formatMessage('{width} x {height}px', {height: videoHeight, width: videoWidth}),
type: 'hint'
})
}
const saveDisabled =
displayAs === 'embed' &&
(titleText === '' || (videoSize === CUSTOM && !dimensionsState.isValid))
return (
<Tray
data-mce-component
label={formatMessage('Video Options Tray')}
onDismiss={onRequestClose}
onEntered={props.onEntered}
@ -53,20 +152,84 @@ export default function VideoOptionsTray(props) {
</Flex.Item>
<Flex.Item>
<CloseButton onClick={onRequestClose}>{formatMessage('Close')}</CloseButton>
<CloseButton placemet="static" variant="icon" onClick={onRequestClose}>
{formatMessage('Close')}
</CloseButton>
</Flex.Item>
</Flex>
</Flex.Item>
<Flex.Item as="form" grow margin="none" shrink>
<Flex justifyItems="space-between" direction="column" height="100%">
<Flex.Item grow padding="small" shrink>
<Flex direction="column">
<Flex.Item padding="small">
<TextArea
aria-describedby="alt-text-label-tooltip"
disabled={displayAs === 'link'}
height="4rem"
label={textAreaLabel}
onChange={handleTitleTextChange}
placeholder={formatMessage('(Describe the video)')}
resize="vertical"
value={titleText}
/>
</Flex.Item>
<Flex.Item margin="small none none none" padding="small">
<RadioInputGroup
description={formatMessage('Display Options')}
name="display-video-as"
onChange={handleDisplayAsChange}
value={displayAs}
>
<RadioInput label={formatMessage('Embed Video')} value="embed" />
<RadioInput
label={formatMessage('Display Text Link (Opens in a new tab)')}
value="link"
/>
</RadioInputGroup>
</Flex.Item>
<Flex.Item margin="small none xx-small none">
<View as="div" padding="small small xx-small small">
<Select
disabled={displayAs !== 'embed'}
label={formatMessage('Size')}
messages={messagesForSize}
onChange={handleVideoSizeChange}
selectedOption={videoSizeOption}
>
{videoSizes.map(size => (
<option key={size} value={size}>
{labelForImageSize(size)}
</option>
))}
</Select>
</View>
{videoSize === CUSTOM && (
<View as="div" padding="xx-small small">
<DimensionsInput
dimensionsState={dimensionsState}
disabled={displayAs !== 'embed'}
minHeight={MIN_HEIGHT}
minWidth={MIN_WIDTH}
/>
</View>
)}
</Flex.Item>
</Flex>
</Flex.Item>
<Flex.Item
background="light"
borderWidth="small none none none"
padding="small medium"
textAlign="end"
>
<Button onClick={handleSave} variant="primary">
<Button disabled={saveDisabled} onClick={handleSave} variant="primary">
{formatMessage('Done')}
</Button>
</Flex.Item>
@ -78,6 +241,13 @@ export default function VideoOptionsTray(props) {
}
VideoOptionsTray.propTypes = {
videoOptions: shape({
titleText: string.isRequired,
appliedHeight: number,
appliedWidth: number,
naturalHeight: number.isRequired,
naturalWidth: number.isRequired
}).isRequired,
onEntered: func,
onExited: func,
onRequestClose: func.isRequired,

View File

@ -20,7 +20,7 @@ import clickCallback from './clickCallback'
import bridge from '../../../bridge'
import formatMessage from '../../../format-message'
import TrayController from './VideoOptionsTray/TrayController'
// import {isVideoElement} from '../shared/ContentSelection'
import {isVideoElement} from '../shared/ContentSelection'
const trayController = new TrayController()
@ -86,7 +86,7 @@ tinymce.create('tinymce.plugins.InstructureRecord', {
ed.ui.registry.addContextToolbar('instructure-video-toolbar', {
items: 'instructure-video-options',
position: 'node',
predicate: false,
predicate: isVideoElement,
scope: 'node'
})
},

View File

@ -16,7 +16,7 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {fromImageEmbed} from '../instructure_image/ImageEmbedOptions'
import {fromImageEmbed, fromVideoEmbed} from '../instructure_image/ImageEmbedOptions'
const FILE_DOWNLOAD_PATH_REGEX = /^\/(courses\/\d+\/)?files\/\d+\/download$/
@ -77,23 +77,24 @@ export function asLink($element, editor) {
}
}
function asVideoElement($element) {
if (!$element.id) {
return null
}
if ($element.childElementCount !== 1) {
return null
}
if (!$element.id.includes('media_object') || $element.children[0].tagName !== 'IFRAME') {
// the video element is a bit tricky.
// tinymce won't let me add many attributes to the iframe,
// even though I've listed them in tinymce.config.js
// extended_valid_elements.
// we have to rely on the span tinymce wraps around the iframe
// and it's attributes, even though this could change with future
// tinymce releases.
// see https://github.com/tinymce/tinymce/issues/5181
export function asVideoElement($element) {
if (!isVideoElement($element)) {
return null
}
return {
...fromVideoEmbed($element),
$element,
type: VIDEO_EMBED_TYPE,
id: $element.id.split('_')[2]
id: $element.getAttribute('data-mce-p-data-media-id')
}
}
@ -159,5 +160,25 @@ export function isImageEmbed($element) {
}
export function isVideoElement($element) {
return !!asVideoElement($element)
// the video is hosted in an iframe, but tinymce
// wraps it in a span with swizzled attribute names
if (!$element.getAttribute) {
return false
}
if ($element.firstElementChild?.tagName !== 'IFRAME') {
return false
}
const media_obj_id = $element.getAttribute('data-mce-p-data-media-id')
if (!media_obj_id) {
return false
}
const media_type = $element.getAttribute('data-mce-p-data-media-type')
if (media_type !== 'video') {
return false
}
return true
}

View File

@ -77,7 +77,7 @@ function buildContentOptions(userContextType) {
contentOptions.splice(
1,
0,
<option value="course_files" icon={IconFolderLine}>
<option key="course_files" value="course_files" icon={IconFolderLine}>
{fileLabelFromContext('course')}
</option>
)
@ -174,11 +174,6 @@ Filter.propTypes = {
*/
sortValue: string.isRequired,
/**
* `contextType` is the context in which we are querying for files
*/
contextType: oneOf(['user', 'course']), // 'group' some day
/**
* The user's context
*/

View File

@ -17,7 +17,6 @@
*/
import React from 'react'
import '@testing-library/jest-dom/extend-expect'
import {render, fireEvent, waitForElement, cleanup} from '@testing-library/react'
import {act} from 'react-dom/test-utils'
import ComputerPanel from '../ComputerPanel'
@ -124,6 +123,7 @@ describe('UploadFile: ComputerPanel', () => {
hasUploadedFile
setHasUploadedFile={handleSetHasUploadedFile}
accept="image/*"
label="Upload File"
/>
)
expect(getByText('Generating preview...')).toBeInTheDocument()

View File

@ -98,35 +98,34 @@ describe('RCE > Plugins > Shared > Content Selection', () => {
describe('when the given element is a video container element', () => {
beforeEach(() => {
$element = $container.appendChild(document.createElement('div'))
$element = $container.appendChild(document.createElement('span'))
$element.setAttribute('data-mce-p-data-media-id', '1234')
$element.setAttribute('data-mce-p-data-media-type', 'video')
$element.appendChild(document.createElement('iframe'))
})
it('returns None type if no id is present', () => {
$element.removeAttribute('data-mce-p-data-media-id')
expect(getContentFromElement($element).type).toEqual(NONE_TYPE)
})
it('returns None type if there are no children', () => {
$element.id = 'media_object_1234'
expect(getContentFromElement($element).type).toEqual(NONE_TYPE)
})
it('returns None type if there are more than one children', () => {
$element.id = 'media_object_1234'
$element.appendChild(document.createElement('iframe'))
$element.appendChild(document.createElement('span'))
$element.appendChild(document.createElement('span'))
it('returns None type if there is no iframe child', () => {
$element.innerHTML = ''
expect(getContentFromElement($element).type).toEqual(NONE_TYPE)
})
it('returns None type if children element is not an iframe', () => {
$element.id = 'media_object_1234'
$element.appendChild(document.createElement('span'))
$element.replaceChild(document.createElement('span'), $element.firstElementChild)
expect(getContentFromElement($element).type).toEqual(NONE_TYPE)
})
it('returns None if no type is present', () => {
$element.removeAttribute('data-mce-p-data-media-type')
expect(getContentFromElement($element).type).toEqual(NONE_TYPE)
})
it('returns id if iframe and div are set', () => {
$element.id = 'media_object_1234'
$element.appendChild(document.createElement('iframe'))
$element.firstElementChild.setAttribute('src', 'data:text/html;charset=utf-8,<video/>')
expect(getContentFromElement($element).id).toEqual('1234')
})
})
@ -287,8 +286,9 @@ describe('RCE > Plugins > Shared > Content Selection', () => {
})
it('detect a video element', () => {
const $selectedNode = document.createElement('div')
$selectedNode.id = 'foo_media_object'
const $selectedNode = document.createElement('span')
$selectedNode.setAttribute('data-mce-p-data-media-id', 'm-id')
$selectedNode.setAttribute('data-mce-p-data-media-type', 'video')
$selectedNode.innerHTML = '<iframe/>'
editor.setSelectedNode($selectedNode)
expect(isFileLink($selectedNode)).toBeFalsy()

View File

@ -39,7 +39,8 @@ export default class FakeEditor {
},
collapse: () => (this._collapsed = true),
isCollapsed: () => this._collapsed
isCollapsed: () => this._collapsed,
select: node => (this._selectedNode = node)
}
this.dom = {
@ -50,7 +51,17 @@ export default class FakeEditor {
return parent
}
return null
},
setAttribs: (elem, hash) => {
Object.keys(hash).forEach(k => {
if (hash[k] == undefined) {
elem.removeAttribute(k)
} else {
elem.setAttribute(k, hash[k])
}
})
},
setStyles: (_elem, _hash) => {}
}
}

View File

@ -249,15 +249,15 @@ class RceApiSource {
return this.finalizeUpload(preflightProps, uploadResults)
})
.then(normalizeFileData)
.catch(e => {
.catch(_e => {
this.alertFunc({
text: formatMessage(
'Something went wrong uploading, check your connection and try again.'
),
variant: 'error'
})
// eslint-disable-next-line no-console
console.error(e)
// console.error(e) // eslint-disable-line no-console
})
}
@ -380,21 +380,21 @@ class RceApiSource {
.then(checkStatus)
.then(parseResponse)
.catch(throwConnectionError)
.catch(e => {
.catch(_e => {
this.alertFunc({
text: formatMessage(
'Something went wrong uploading, check your connection and try again.'
),
variant: 'error'
})
console.error(e) // eslint-disable-line no-console
// console.error(e) // eslint-disable-line no-console
})
}
// @private
normalizeUriProtocol(uri, windowOverride) {
const windowHandle = windowOverride || (typeof window !== 'undefined' ? window : undefined)
if (windowHandle && windowHandle.location && windowHandle.location.protocol == 'https:') {
if (windowHandle && windowHandle.location && windowHandle.location.protocol === 'https:') {
return uri.replace('http://', 'https://')
}
return uri

View File

@ -715,9 +715,15 @@ export function initializeCollection(endpoint) {
}
}
export function initializeDocuments(props) {
export function initializeDocuments(_props) {
return {
[props.contextType]: {
course: {
files: [],
bookmark: 'documents1',
isLoading: false,
hasMore: true
},
user: {
files: [],
bookmark: 'documents1',
isLoading: false,
@ -726,9 +732,15 @@ export function initializeDocuments(props) {
}
}
export function initializeMedia(props) {
export function initializeMedia(_props) {
return {
[props.contextType]: {
course: {
files: [],
bookmark: 'media1',
isLoading: false,
hasMore: true
},
user: {
files: [],
bookmark: 'media1',
isLoading: false,
@ -808,7 +820,7 @@ export function fetchPage(uri) {
if (PAGES[uri]) {
resolve(PAGES[uri])
} else {
reject('bad page!')
reject(new Error('bad page!'))
}
}, 1000)
})
@ -820,7 +832,7 @@ export function searchFlickr(term) {
if (FLICKR_RESULTS[term]) {
resolve(FLICKR_RESULTS[term])
} else {
reject('No search results!')
reject(new Error('No search results!'))
}
}, 1000)
})
@ -832,7 +844,7 @@ export function searchUnsplash(term) {
if (UNSPLASH_RESULTS[term]) {
resolve(UNSPLASH_RESULTS[term])
} else {
reject('No search results!')
reject(new Error('No search results!'))
}
}, 1000)
})

View File

@ -59,7 +59,6 @@ describe('Upload reducer', () => {
thumbnail_url: 'http://some.url.example.com'
}
}
state.uploads = []
})
it('turns uploading off', () => {

View File

@ -305,7 +305,9 @@ describe('sources/api', () => {
it('throws an exception when an error occurs', () => {
fetchMock.mock(uri, 500)
assert.rejects(() => apiSource.preflightUpload(fileProps, apiProps))
return apiSource.preflightUpload(fileProps, apiProps).catch(e => {
assert(e)
})
})
})
@ -329,12 +331,15 @@ describe('sources/api', () => {
it('calls alertFunc if there is a problem', () => {
fetchMock.once(uploadUrl, 500, {overwriteRoutes: true})
return apiSource.uploadFRD(fileDomObject, preflightProps).then(() => {
return apiSource
.uploadFRD(fileDomObject, preflightProps)
.then(() => {
sinon.assert.calledWith(alertFuncSpy, {
text: 'Something went wrong uploading, check your connection and try again.',
variant: 'error'
})
})
.catch(() => {})
})
describe('files', () => {

View File

@ -37,6 +37,9 @@ const consoleMessagesToIgnore = {
// React 16.9+ generates these deprecation warnings but it doesn't do any good to hear about the ones for instUI. We can't do anything about them in this repo
// Put any others we can't control here.
/Please update the following components:[ (BaseTransition|Billboard|Button|Checkbox|CloseButton|Dialog|Expandable|FileDrop|Flex|FlexItem|FormFieldGroup|FormFieldLabel|FormFieldLayout|FormFieldMessage|FormFieldMessages|Grid|GridCol|GridRow|Heading|InlineSVG|Mask|ModalBody|ModalFooter|ModalHeader|NumberInput|Portal|Query|Responsive|SVGIcon|ScreenReaderContent|SelectOptionsList|SelectField|SelectMultiple|SelectOptionsList|SelectSingle|Spinner|Tab|TabList|TabPanel|Text|TextArea|TextInput|TinyMCE|ToggleDetails|ToggleFacade|Transition|TruncateText|View),?]+$/
// /is deprecated and will be removed/, // uncomment to remove instui deprecation messages
// /Translation for/ // uncomment to remove missing translation messages
]
}

File diff suppressed because one or more lines are too long

View File

@ -14574,7 +14574,7 @@ node-releases@^1.1.29:
dependencies:
semver "^5.3.0"
node-sass@4.7.2, node-sass@^4.5.0, node-sass@^4.7.2:
node-sass@^4.5.0, node-sass@^4.7.2:
version "4.7.2"
resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.7.2.tgz#9366778ba1469eb01438a9e8592f4262bcb6794e"
integrity sha512-CaV+wLqZ7//Jdom5aUFCpGNoECd7BbNhjuwdsX/LkXBrHl8eb1Wjw4HvWqcFvhr5KuNgAk8i/myf/MQ1YYeroA==