Render media files in the CanvasContentTray

The files are there, though the visuals are incorrect. I just
lifted the documents Link component to get them displayed.
Rendering per the design will be a later ticket

closes COREFE-40

test plan:
  - load the rce in a course as a teacher
  - select Media > Course Media
  > expect course audio and video files displayed in the tray
  - switch to My Files
  > expect user audio and video files in the tray
  > expect the files to have their respective icons

  - load the rce in a course as a student
  - select Media > My Media (expect it to be the only option)
  > expect the students media files to be diplayed in the tray
  > expect Course Files not to be an option

Change-Id: I8836c4e207163b6e0c0dff0d68be12d819ca5bc6
Reviewed-on: https://gerrit.instructure.com/207042
Tested-by: Jenkins
Reviewed-by: Clay Diffrient <cdiffrient@instructure.com>
QA-Review: Clay Diffrient <cdiffrient@instructure.com>
Product-Review: Ed Schiebel <eschiebel@instructure.com>
This commit is contained in:
Ed Schiebel 2019-08-27 17:13:26 -04:00
parent b5984c7034
commit 56d8210984
18 changed files with 661 additions and 43 deletions

View File

@ -101,6 +101,16 @@ describe('RCE "Documents" Plugin > Document', () => {
expect(queryIconByName(container, 'IconPdf')).toBeInTheDocument()
})
it('the video icon', () => {
const {container} = renderComponent({content_type: 'video/mp4'})
expect(queryIconByName(container, 'IconVideo')).toBeInTheDocument()
})
it('the audio icon', () => {
const {container} = renderComponent({content_type: 'audio/mp3'})
expect(queryIconByName(container, 'IconAudio')).toBeInTheDocument()
})
it('the drag handle only on hover', () => {
const {container, getByTestId} = renderComponent()

View File

@ -60,7 +60,10 @@ export default function Images(props) {
}, [contextType, files.length, hasMore, isLoading, fetchInitialImages])
return (
<>
<View
as="div"
data-testid="instructure_links-ImagesPanel"
>
<Flex alignItems="center" direction="column" justifyItems="space-between" height="100%">
<Flex.Item overflowY="visible" width="100%">
<ImageList images={files} lastItemRef={lastItemRef} onImageClick={props.onImageEmbed} />
@ -86,7 +89,7 @@ export default function Images(props) {
<Text color="error">{formatMessage("Loading failed.")}</Text>
</View>
)}
</>
</View>
)
}

View File

@ -28,7 +28,8 @@ describe('Instructure Image Plugin: clickCallback', () => {
initializeUpload () {},
initializeFlickr () {},
initializeImages() {},
initializeDocuments() {}
initializeDocuments() {},
initializeMedia() {}
}
}
})

View File

@ -0,0 +1,141 @@
/*
* 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, {useEffect, useRef} from 'react';
import {arrayOf, bool, func, objectOf, shape, string} from 'prop-types';
import {fileShape} from '../../shared/fileShape'
import formatMessage from '../../../../format-message';
import {Text} from '@instructure/ui-elements'
import {View} from '@instructure/ui-layout'
import Link from '../../instructure_documents/components/Link'
import {
LoadMoreButton,
LoadingIndicator,
LoadingStatus,
useIncrementalLoading
} from '../../../../common/incremental-loading'
function hasFiles(media) {
return media.files.length > 0
}
function isEmpty(media) {
return (
!hasFiles(media) &&
!media.hasMore &&
!media.isLoading
);
}
function renderLinks(files, handleClick, lastItemRef) {
return files.map((f, index) => {
let focusRef = null
if (index === files.length -1) {
focusRef = lastItemRef
}
return (
<Link
key={f.id}
{...f}
onClick={handleClick}
focusRef={focusRef}
/>
)
})
}
function renderLoadingError(_error) {
return (
<View as="div" role="alert" margin="medium">
<Text color="error">{formatMessage("Loading failed.")}</Text>
</View>
);
}
export default function MediaPanel(props) {
const {fetchInitialMedia, fetchNextMedia, contextType} = props
const media = props.media[contextType]
const {hasMore, isLoading, error, files} = media
const lastItemRef = useRef(null)
const loader = useIncrementalLoading({
hasMore,
isLoading,
lastItemRef,
onLoadInitial() {
fetchInitialMedia()
},
onLoadMore() {
fetchNextMedia()
},
records: files
})
useEffect(() => {
if (hasMore && !isLoading && files.length === 0) {
fetchInitialMedia()
}
}, [contextType, files.length, hasMore, isLoading, fetchInitialMedia])
const handleFileClick = file => {
props.onLinkClick(file)
}
return (
<View
as="div"
data-testid="instructure_links-MediaPanel"
>
{renderLinks(files, handleFileClick, lastItemRef)}
{loader.isLoading && <LoadingIndicator loader={loader} />}
{!loader.isLoading && loader.hasMore && <LoadMoreButton loader={loader} />}
<LoadingStatus loader={loader} />
{error && renderLoadingError(error)}
{isEmpty(media) && (
<View as="div" padding="medium">
{formatMessage("No results.")}
</View>
)}
</View>
);
}
MediaPanel.propTypes = {
contextType: string.isRequired,
fetchInitialMedia: func.isRequired,
fetchNextMedia: func.isRequired,
onLinkClick: func.isRequired,
media: objectOf(shape({
files: arrayOf(shape(fileShape)).isRequired,
bookmark: string,
hasMore: bool,
isLoading: bool,
error: string
})).isRequired
}

View File

@ -63,7 +63,7 @@ const thePanels = {
links: React.lazy(() => import('../instructure_links/components/LinksPanel')),
images: React.lazy(() => import('../instructure_image/Images')),
documents: React.lazy(() => import('../instructure_documents/components/DocumentsPanel')),
media: React.lazy(() => import('./UnknownFileTypePanel')),
media: React.lazy(() => import('../instructure_record/MediaPanel')),
unknown: React.lazy(() => import('./UnknownFileTypePanel'))
}
/**

View File

@ -17,7 +17,7 @@
*/
import React from 'react'
import {render, fireEvent, act, waitForElement, prettyDOM} from '@testing-library/react'
import {render, fireEvent, act, waitForElement} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {UploadFile, handleSubmit} from '../UploadFile'
@ -31,7 +31,8 @@ describe('UploadFile', () => {
initializeUpload () {},
initializeFlickr () {},
initializeImages() {},
initializeDocuments() {}
initializeDocuments() {},
initializeMedia() {}
}
}
fakeEditor = {}

View File

@ -110,6 +110,31 @@ describe('RCE Plugins > CanvasContentTray', () => {
})
})
describe('content panel', () => {
beforeEach(() => {
renderComponent()
})
it('is the links panel for links content types', async () => {
await showTrayForPlugin('links')
expect(component.getByTestId('instructure_links-LinksPanel')).toBeInTheDocument()
})
it('is the documents panel for document content types', async () => {
await showTrayForPlugin('course_documents')
expect(component.getByTestId('instructure_links-DocumentsPanel')).toBeInTheDocument()
})
it('is the images panel for image content types', async () => {
await showTrayForPlugin('images')
expect(component.getByTestId('instructure_links-ImagesPanel')).toBeInTheDocument()
})
it.only('is the media panel for media content types', async () => {
await showTrayForPlugin('course_media')
expect(component.getByTestId('instructure_links-MediaPanel')).toBeInTheDocument()
})
})
describe('focus', () => {
beforeEach(() => {
renderComponent()

View File

@ -21,10 +21,17 @@ import {
IconMsExcelLine,
IconMsPptLine,
IconMsWordLine,
IconPdfLine
IconPdfLine,
IconVideoLine,
IconAudioLine
} from '@instructure/ui-icons'
export function getIconFromType(type) {
if (isVideo(type)) {
return IconVideoLine
} else if (isAudio(type)) {
return IconAudioLine
}
switch(type) {
case 'application/msword':
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':

View File

@ -0,0 +1,74 @@
/*
* 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/>.
*/
export const REQUEST_MEDIA = "REQUEST_MEDIA";
export const RECEIVE_MEDIA = "RECEIVE_MEDIA";
export const FAIL_MEDIA = "FAIL_MEDIA";
export function requestMedia(contextType) {
return { type: REQUEST_MEDIA, payload: {contextType} };
}
export function receiveMedia({response, contextType}) {
const { files, bookmark } = response;
return { type: RECEIVE_MEDIA, payload: {files, bookmark, contextType }};
}
export function failMedia({error, contextType}) {
return { type: FAIL_MEDIA, payload: {error, contextType}};
}
// dispatches the start of the load, requests a page for the collection from
// the source, then dispatches the loaded page to the store on success or
// clears the load on failure
export function fetchMedia() {
return (dispatch, getState) => {
const state = getState();
dispatch(requestMedia(state.contextType));
return state.source
.fetchMedia(state)
.then(response => dispatch(receiveMedia({response, contextType: state.contextType})))
.catch(error => dispatch(failMedia({error, contextType: state.contextType})));
};
}
// fetches a page only if a page is not already being loaded and the
// collection is not yet completely loaded
export function fetchNextMedia() {
return (dispatch, getState) => {
const state = getState();
const media = state.media[state.contextType]
if (media && !media.isLoading && media.hasMore) {
return dispatch(fetchMedia());
}
};
}
// fetches the next page (subject to conditions on fetchNextMedia) only if the
// collection is currently empty
export function fetchInitialMedia() {
return (dispatch, getState) => {
const state = getState();
const media = state.media[state.contextType]
if (media && media.hasMore && !media.isLoading && media.files && media.files.length === 0) {
return dispatch(fetchMedia());
}
};
}

View File

@ -0,0 +1,227 @@
/*
* Copyright (C) 2018 - 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 PropTypes from "prop-types";
import React, { Component } from "react";
import { ToggleDetails } from '@instructure/ui-toggle-details'
import { View } from '@instructure/ui-layout'
import LinkSet from "./LinkSet";
import NavigationPanel from "./NavigationPanel";
import LinkToNewPage from "./LinkToNewPage";
import formatMessage from "../../format-message";
function AccordionSection({
collection,
children,
onChange,
selectedIndex,
summary
}) {
return (
<View as="div" margin="xx-small none">
<ToggleDetails
variant="filled"
summary={summary}
expanded={selectedIndex === collection}
onToggle={(e, expanded) => onChange(expanded ? collection : "")}
>
<div style={{maxHeight: '20em', overflow: 'auto'}}>{children}</div>
</ToggleDetails>
</View>
);
}
AccordionSection.propTypes = {
collection: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
selectedIndex: PropTypes.string,
summary: ToggleDetails.propTypes.summary
};
function CollectionPanel(props) {
return (
<AccordionSection {...props}>
<LinkSet
fetchInitialPage={
props.fetchInitialPage &&
(() => props.fetchInitialPage(props.collection))
}
fetchNextPage={
props.fetchNextPage && (() => props.fetchNextPage(props.collection))
}
collection={props.collections[props.collection]}
onLinkClick={props.onLinkClick}
suppressRenderEmpty={props.suppressRenderEmpty}
/>
{props.renderNewPageLink && (
<LinkToNewPage
onLinkClick={props.onLinkClick}
toggleNewPageForm={props.toggleNewPageForm}
newPageLinkExpanded={props.newPageLinkExpanded}
contextId={props.contextId}
contextType={props.contextType}
/>
)}
</AccordionSection>
);
}
CollectionPanel.propTypes = {
collection: PropTypes.string.isRequired,
renderNewPageLink: PropTypes.bool,
suppressRenderEmpty: PropTypes.bool
};
CollectionPanel.defaultProps = {
renderNewPageLink: false,
suppressRenderEmpty: false
};
function LinksPanel(props) {
const isCourse = props.contextType === "course";
const isGroup = props.contextType === "group";
const navigationSummary = isCourse
? formatMessage({
default: "Course Navigation",
description:
"Title of Sidebar accordion tab containing links to course pages."
})
: isGroup
? formatMessage({
default: "Group Navigation",
description:
"Title of Sidebar accordion tab containing links to group pages."
})
: "";
return (
<div data-testid="instructure_links-LinksPanel">
<p>
{props.contextType === "course"
? formatMessage("Link to other content in the course.")
: props.contextType === "group"
? formatMessage("Link to other content in the group.")
: ""}
{formatMessage("Click any page to insert a link to that page.")}
</p>
<div>
{(isCourse || isGroup) && (
<CollectionPanel
{...props}
collection="wikiPages"
summary={formatMessage({
default: "Pages",
description:
"Title of Sidebar accordion tab containing links to wiki pages."
})}
renderNewPageLink={props.canCreatePages !== false}
suppressRenderEmpty={props.canCreatePages !== false}
/>
)}
{isCourse && (
<CollectionPanel
{...props}
collection="assignments"
summary={formatMessage({
default: "Assignments",
description:
"Title of Sidebar accordion tab containing links to assignments."
})}
/>
)}
{isCourse && (
<CollectionPanel
{...props}
collection="quizzes"
summary={formatMessage({
default: "Quizzes",
description:
"Title of Sidebar accordion tab containing links to quizzes."
})}
/>
)}
{(isCourse || isGroup) && (
<CollectionPanel
{...props}
collection="announcements"
summary={formatMessage({
default: "Announcements",
description:
"Title of Sidebar accordion tab containing links to announcements."
})}
/>
)}
{(isCourse || isGroup) && (
<CollectionPanel
{...props}
collection="discussions"
summary={formatMessage({
default: "Discussions",
description:
"Title of Sidebar accordion tab containing links to discussions."
})}
/>
)}
{isCourse && (
<CollectionPanel
{...props}
collection="modules"
summary={formatMessage({
default: "Modules",
description:
"Title of Sidebar accordion tab containing links to course modules."
})}
/>
)}
<AccordionSection
{...props}
collection="navigation"
summary={navigationSummary}
>
<NavigationPanel
contextType={props.contextType}
contextId={props.contextId}
onLinkClick={props.onLinkClick}
/>
</AccordionSection>
</div>
</div>
);
}
LinksPanel.propTypes = {
selectedIndex: PropTypes.string,
onChange: PropTypes.func,
contextType: PropTypes.string.isRequired,
contextId: PropTypes.string.isRequired,
collections: PropTypes.object.isRequired,
fetchInitialPage: PropTypes.func,
fetchNextPage: PropTypes.func,
onLinkClick: PropTypes.func,
toggleNewPageForm: LinkToNewPage.propTypes.toggleNewPageForm,
newPageLinkExpanded: PropTypes.bool,
canCreatePages: PropTypes.bool
};
LinksPanel.defaultProps = {
selectedIndex: ""
};
export default LinksPanel;

View File

@ -25,6 +25,7 @@ export function propsFromState(state) {
files,
images,
documents,
media,
folders,
rootFolderId,
flickr,
@ -52,6 +53,7 @@ export function propsFromState(state) {
files,
images,
documents,
media,
folders,
rootFolderId,
flickr,

View File

@ -31,6 +31,7 @@ import { searchFlickr, openOrCloseFlickrForm } from "../actions/flickr";
import { toggle as toggleFolder } from "../actions/files";
import { openOrCloseNewPageForm } from "../actions/links";
import { fetchInitialDocs, fetchNextDocs } from "../actions/documents";
import { fetchInitialMedia, fetchNextMedia } from "../actions/media";
import { changeContext } from "../actions/context";
export default function propsFromDispatch(dispatch) {
@ -55,6 +56,8 @@ export default function propsFromDispatch(dispatch) {
saveMediaRecording: (file, editor, dismiss) => dispatch(saveMediaRecording(file, editor, dismiss)),
fetchInitialDocs: () => dispatch(fetchInitialDocs()),
fetchNextDocs: () => dispatch(fetchNextDocs()),
fetchInitialMedia: () => dispatch(fetchInitialMedia()),
fetchNextMedia: () => dispatch(fetchNextMedia()),
onChangeContext: (newContext) => dispatch(changeContext(newContext))
};
}

View File

@ -24,6 +24,7 @@ import folders from "./folders";
import rootFolderId from "./rootFolderId";
import imagesReducer from "./images";
import documentsReducer from "./documents";
import mediaReducer from "./media";
import upload from "./upload";
import flickrReducer from "./flickr";
import session from "./session";
@ -46,6 +47,7 @@ export default combineReducers({
rootFolderId,
images: imagesReducer,
documents: documentsReducer,
media: mediaReducer,
upload,
flickr: flickrReducer,
session,

View File

@ -0,0 +1,66 @@
/*
* 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 { REQUEST_MEDIA, RECEIVE_MEDIA, FAIL_MEDIA } from "../actions/media";
import { CHANGE_CONTEXT } from "../actions/context"
// manages the state for a specific collection. assumes the action is intended
// for this collection (see collections.js)
export default function mediaReducer(prevState = {}, action) {
const ctxt = action.payload && action.payload.contextType
const state = {...prevState}
if (ctxt && !state[ctxt]) {
state[ctxt] = {
files: [],
bookmark: null,
isLoading: false,
hasMore: true
}
}
switch (action.type) {
case REQUEST_MEDIA:
state[ctxt].isLoading = true
return state
case RECEIVE_MEDIA:
state[ctxt] = {
files: state[ctxt].files.concat(action.payload.files),
bookmark: action.payload.bookmark,
isLoading: false,
hasMore: !!action.payload.bookmark
}
return state
case FAIL_MEDIA: {
state[ctxt] = {
isLoading: false,
error: action.payload.error
};
if (action.payload.files && action.payload.files.length === 0) {
state[ctxt].bookmark = null;
}
return state
}
case CHANGE_CONTEXT:
return state;
default:
return prevState;
}
}

View File

@ -131,6 +131,10 @@ class RceApiSource {
}
}
initializeMedia(props) {
return this.initializeDocuments(props)
}
initializeFlickr() {
return {
searchResults: [],
@ -150,6 +154,12 @@ class RceApiSource {
return this.apiFetch(uri, headerFor(this.jwt))
}
fetchMedia(props) {
const media = props.media[props.contextType]
const uri = media.bookmark || this.uriFor('media', props)
return this.apiFetch(uri, headerFor(this.jwt))
}
fetchFiles(uri) {
return this.fetchPage(uri).then(({bookmark, files}) => {
return {
@ -369,9 +379,8 @@ class RceApiSource {
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
})
}

View File

@ -299,36 +299,40 @@ const FLICKR_RESULTS = {
]
};
const DOCUMENTS = {
documents1: {
files: [1,2,3].map(i => {
return {
id: i,
filename: `file${i}.txt`,
content_type: 'text/plain',
display_name: `file${i}`,
href: `http://the.net/${i}`,
date: `2019-05-25T13:0${i}:00Z`,
}
}),
bookmark: 'documents2',
hasMore: true
},
documents2: {
files: [4,5,6].map(i => {
return {
id: i,
filename: `file${i}.txt`,
content_type: 'text/plain',
display_name: `file${i}`,
href: `http://the.net/${i}`,
date: `2019-05-25T13:0${i}:00Z`,
}
}),
bookmark: null,
hasMore: false
function makeFiles(bookmark_base, extension, content_type) {
return {
[`${bookmark_base}1`]: {
files: [1,2,3].map(i => {
return {
id: i,
filename: `file${i}.${extension}`,
content_type,
display_name: `file${i}`,
href: `http://the.net/${i}`,
date: `2019-05-25T13:0${i}:00Z`,
}
}),
bookmark: `${bookmark_base}2`,
hasMore: true
},
[`${bookmark_base}2`]: {
files: [4,5,6].map(i => {
return {
id: i,
filename: `file${i}.${extension}`,
content_type,
display_name: `file${i}`,
href: `http://the.net/${i}`,
date: `2019-05-25T13:0${i}:00Z`,
}
}),
bookmark: null,
hasMore: false
}
}
}
const DOCUMENTS = makeFiles('documents', 'txt', 'text/plain')
const MEDIA = makeFiles('media', 'mp3', 'audio/mp3')
const UNSPLASH_RESULTS = {
kittens1: {
@ -609,11 +613,25 @@ export function initializeCollection(endpoint) {
};
}
export function initializeDocuments() {
export function initializeDocuments(props) {
return {
files: [],
bookmark: 'documents1',
isLoading: false
[props.contextType]: {
files: [],
bookmark: 'documents1',
isLoading: false,
hasMore: true
}
}
}
export function initializeMedia(props) {
return {
[props.contextType]: {
files: [],
bookmark: 'media1',
isLoading: false,
hasMore: true
}
}
}
@ -737,10 +755,11 @@ export function getFile(id) {
});
}
export function fetchDocs(bookmark) {
export function fetchDocs(state) {
return new Promise((resolve, reject) => {
setTimeout(() => {
let response
const bookmark = state.documents[state.contextType] && state.documents[state.contextType].bookmark
if (bookmark) {
response = DOCUMENTS[bookmark]
}
@ -753,3 +772,22 @@ export function fetchDocs(bookmark) {
}, FAKE_TIMEOUT)
});
}
export function fetchMedia(state) {
return new Promise((resolve, reject) => {
setTimeout(() => {
let response
const bookmark = state.media[state.contextType] && state.media[state.contextType].bookmark
if (bookmark) {
response = MEDIA[bookmark]
}
if (response) {
resolve(response)
} else {
reject(new Error('Invalid bookmark'))
}
}, FAKE_TIMEOUT)
});
}

View File

@ -50,6 +50,7 @@ export default function(props = {}) {
upload,
images,
documents,
media,
flickr,
newPageLinkExpanded
} = props;
@ -95,6 +96,10 @@ export default function(props = {}) {
documents = source.initializeDocuments(props);
}
if (media === undefined) {
media = source.initializeMedia(props);
}
if (newPageLinkExpanded === undefined) {
newPageLinkExpanded = false;
}
@ -111,6 +116,7 @@ export default function(props = {}) {
upload,
images,
documents,
media,
flickr,
newPageLinkExpanded
};

View File

@ -39,6 +39,9 @@ describe("Sidebar initialState", () => {
},
initializeDocuments() {
return {}
},
initializeMedia() {
return {}
}
};
apiSource = new RceApiSource();