Filter by category when viewing saved B&I

Closes MAT-633
flag=buttons_and_icons

Test Plan
- Enable buttons & icons in your site admin
  account if needed
- Build packages/canvas-rce and re-bundle Canvas
  JS with webpack
- In a course, create more than 50 buttons & icons.
  this can be done programatically. Chat with Weston
  if you would like directions.
- In that course, open the "Saved Buttons & Icons" tray
  by clicking the small arrow next to the buttons and
  icons tool.
- Verify buttons and icons are loaded (50) with an option
  to load more at the bottom of the tray.
- Click "Load More" and verify the rest of the buttons and
  icons are rendered.
- Click one of the buttons and icons and verify it is embedded
  into the RCE
- Focus the embedded button and icon in the RCE. Verify an "Edit"
  option appears in the floating toolbar. Verify you can edit the
  button and icon
- Navigate to the files UI for the course and navigate to the
  Buttons and Icons folder
- Select the menu button on one of the svg files (the vertical "...")
- Choose "Move" and move the button and icon svg to a new folder
  in the course (not nested in the "Buttons and Icons" folder)
- Navigate to an RCE in the course and open the "Saved Buttons & Icons"
  tray again.
- Verify the button that is no longer in the primary "Buttons and Icons"
  folder still shows up and is embedable.

Change-Id: I583f32d59330b8e3bf067ab606eac348882173ee
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/283795
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
QA-Review: Jacob DeWar <jacob.dewar@instructure.com>
Product-Review: David Lyons <lyons@instructure.com>
Reviewed-by: Jacob DeWar <jacob.dewar@instructure.com>
This commit is contained in:
Weston Dransfield 2022-01-27 15:48:44 -07:00
parent 700c2a59c1
commit 7f20fd23e2
11 changed files with 197 additions and 341 deletions

View File

@ -16,95 +16,39 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, {useEffect, useState} from 'react'
import {func, shape, string} from 'prop-types'
import React from 'react'
import {BTN_AND_ICON_ATTRIBUTE} from '../../instructure_buttons/registerEditToolbar'
import Images from '../../instructure_image/Images'
import {View} from '@instructure/ui-view'
import ImageList from '../../instructure_image/Images'
import {useStoreProps} from '../../shared/StoreContext'
import {BUTTONS_AND_ICONS} from '../registerEditToolbar'
export function rceToFile({createdAt, id, name, thumbnailUrl, type, url}) {
return {
content_type: type,
date: createdAt,
display_name: name,
filename: name,
href: url,
id,
thumbnail_url: thumbnailUrl,
[BTN_AND_ICON_ATTRIBUTE]: true
}
}
const SavedButtonList = ({context, onImageEmbed, searchString, sortBy, source}) => {
const [buttonsAndIconsBookmark, setButtonsAndIconsBookmark] = useState(null)
const [buttonsAndIcons, setButtonsAndIcons] = useState([])
const [hasMore, setHasMore] = useState(true)
const [isLoading, setIsLoading] = useState(true)
const resetState = () => {
setButtonsAndIconsBookmark(null)
setButtonsAndIcons([])
setHasMore(true)
setIsLoading(true)
}
const onLoadedImages = ({bookmark, files}) => {
setButtonsAndIconsBookmark(bookmark)
setHasMore(bookmark !== null)
setIsLoading(false)
setButtonsAndIcons(prevButtonsAndIcons => [
...prevButtonsAndIcons,
...files.filter(({type}) => type === 'image/svg+xml').map(rceToFile)
])
}
const fetchButtonsAndIcons = bookmark => {
setIsLoading(true)
source.fetchButtonsAndIcons(
{contextId: context.id, contextType: context.type},
bookmark,
searchString,
sortBy,
onLoadedImages
)
}
useEffect(() => {
resetState()
}, [searchString, sortBy.order, sortBy.sort])
const SavedButtonList = ({onImageEmbed}) => {
const storeProps = useStoreProps()
const {files, bookmark, isLoading, hasMore} = storeProps.images[storeProps.contextType]
return (
<Images
contextType={context.type}
fetchInitialImages={() => {
fetchButtonsAndIcons()
}}
fetchNextImages={() => {
fetchButtonsAndIcons(buttonsAndIconsBookmark)
}}
images={{[context.type]: {error: null, files: buttonsAndIcons, hasMore, isLoading}}}
onImageEmbed={onImageEmbed}
searchString={searchString}
sortBy={sortBy}
/>
)
<View>
<ImageList
fetchInitialImages={() => storeProps.fetchInitialImages({category: BUTTONS_AND_ICONS})}
fetchNextImages={() => storeProps.fetchNextImages({category: BUTTONS_AND_ICONS})}
contextType={storeProps.contextType}
images={{
[storeProps.contextType]: {
files,
bookmark,
hasMore,
isLoading
}
SavedButtonList.propTypes = {
context: shape({
id: string.isRequired,
type: string.isRequired
}),
onImageEmbed: func.isRequired,
searchString: string,
sortBy: shape({
order: string,
sort: string
}),
source: shape({
fetchButtonsAndIcons: func.isRequired
})
}}
sortBy={{
sort: 'date_added',
order: 'desc'
}}
onImageEmbed={onImageEmbed}
/>
</View>
)
}
export default SavedButtonList

View File

@ -17,127 +17,126 @@
*/
import React from 'react'
import {render, waitFor} from '@testing-library/react'
import sinon from 'sinon'
import {render, fireEvent} from '@testing-library/react'
import SavedButtonList from '../SavedButtonList'
import RceApiSource from '../../../../../rcs/api'
import SavedButtonList, {rceToFile} from '../SavedButtonList'
describe('RCE "Buttons and Icons" Plugin > SavedButtonList', () => {
let defaultProps, fetchPageStub, globalFetchStub
const apiSource = new RceApiSource({
alertFunc: () => {},
jwt: 'theJWT'
jest.mock('../../../shared/StoreContext', () => {
return {
useStoreProps: () => ({
images: {
Course: {
files: [
{
id: 722,
filename: 'grid.png',
thumbnail_url:
'http://canvas.docker/images/thumbnails/722/E6uaQSJaQYl95XaVMnoqYU7bOlt0WepMsTB9MJ8b',
display_name: 'image_one.png',
href: 'http://canvas.docker/courses/21/files/722?wrap=1',
download_url: 'http://canvas.docker/files/722/download?download_frd=1',
content_type: 'image/png',
published: true,
hidden_to_user: true,
locked_for_user: false,
unlock_at: null,
lock_at: null,
date: '2021-11-03T19:21:27Z',
uuid: 'E6uaQSJaQYl95XaVMnoqYU7bOlt0WepMsTB9MJ8b'
},
{
id: 716,
filename: '1635371359_565__0266554465.jpeg',
thumbnail_url:
'http://canvas.docker/images/thumbnails/716/9zLFcMIFlNPVtkTHulDGRS1bhiBg8hsL0ms6VeMt',
display_name: 'image_two.jpg',
href: 'http://canvas.docker/courses/21/files/716?wrap=1',
download_url: 'http://canvas.docker/files/716/download?download_frd=1',
content_type: 'image/jpeg',
published: true,
hidden_to_user: false,
locked_for_user: false,
unlock_at: null,
lock_at: null,
date: '2021-10-27T21:49:19Z',
uuid: '9zLFcMIFlNPVtkTHulDGRS1bhiBg8hsL0ms6VeMt'
},
{
id: 715,
filename: '1635371358_548__h3zmqPb-6dw.jpg',
thumbnail_url:
'http://canvas.docker/images/thumbnails/715/rIlrdxCJ1h5Ff18Y4C6KJf7HIvCDn5ZAbtnVpNcw',
display_name: 'image_three.jpg',
href: 'http://canvas.docker/courses/21/files/715?wrap=1',
download_url: 'http://canvas.docker/files/715/download?download_frd=1',
content_type: 'image/jpeg',
published: true,
hidden_to_user: false,
locked_for_user: false,
unlock_at: null,
lock_at: null,
date: '2021-10-27T21:49:18Z',
uuid: 'rIlrdxCJ1h5Ff18Y4C6KJf7HIvCDn5ZAbtnVpNcw'
}
],
bookmark: 'bookmark',
isLoading: false,
hasMore: false
}
},
contextType: 'Course',
fetchInitialImages: jest.fn(),
fetchNextImages: jest.fn()
})
}
})
describe('SavedButtonList()', () => {
let props
const subject = () => render(<SavedButtonList {...props} />)
beforeEach(() => {
globalFetchStub = sinon.stub(global, 'fetch')
const context = {id: '101', type: 'course'}
const buttonsAndIconsFolder = {filesUrl: 'http://rce.example.com/api/folders/52', id: '1'}
const buttonAndIcon = {
createdAt: '',
id: 1,
name: 'button.svg',
thumbnailUrl: '',
type: 'image/svg+xml',
url: ''
}
const otherImage = {
createdAt: '',
id: 2,
name: 'screenshot.jpg',
thumbnailUrl: '',
type: 'image/jpeg',
url: ''
}
const folders = [buttonsAndIconsFolder]
fetchPageStub = sinon.stub(apiSource, 'fetchPage')
fetchPageStub
.withArgs(`/api/folders/buttons_and_icons?contextType=course&contextId=${context.id}`)
.returns(Promise.resolve({folders}))
fetchPageStub
.withArgs(
'http://rce.example.com/api/folders/52?per_page=25&sort=created_at&order=desc',
'theJWT'
)
.returns(Promise.resolve({bookmark: '', files: [buttonAndIcon, otherImage]}))
defaultProps = {
context,
onImageEmbed: () => {},
searchString: '',
sortBy: {order: 'desc', sort: 'date_added'},
source: apiSource
props = {
onImageEmbed: jest.fn()
}
})
afterEach(() => {
fetchPageStub.restore()
globalFetchStub.restore()
afterEach(() => jest.clearAllMocks())
it('renders the image list', () => {
const {getByTitle} = subject()
expect(getByTitle('Click to embed image_one.png')).toBeInTheDocument()
expect(getByTitle('Click to embed image_two.jpg')).toBeInTheDocument()
expect(getByTitle('Click to embed image_three.jpg')).toBeInTheDocument()
})
const renderComponent = componentProps => {
return render(<SavedButtonList {...defaultProps} {...componentProps} />)
describe('when an image is clicked', () => {
beforeEach(() => {
const {getByTitle} = subject()
// Click the first image
fireEvent.click(getByTitle('Click to embed image_one.png'))
})
it('dispatches a "loading" action', () => {
expect(props.onImageEmbed.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"content_type": "image/png",
"date": "2021-11-03T19:21:27Z",
"display_name": "image_one.png",
"download_url": "http://canvas.docker/files/722/download?download_frd=1",
"filename": "grid.png",
"hidden_to_user": true,
"href": "http://canvas.docker/courses/21/files/722?wrap=1",
"id": 722,
"lock_at": null,
"locked_for_user": false,
"published": true,
"thumbnail_url": "http://canvas.docker/images/thumbnails/722/E6uaQSJaQYl95XaVMnoqYU7bOlt0WepMsTB9MJ8b",
"unlock_at": null,
"uuid": "E6uaQSJaQYl95XaVMnoqYU7bOlt0WepMsTB9MJ8b",
}
it('loads and displays svgs', async () => {
const {getByAltText} = renderComponent()
await waitFor(() => expect(getByAltText('button.svg')).toBeInTheDocument())
})
it('ignores non-svg files', async () => {
const {queryByAltText} = renderComponent()
await waitFor(() => queryByAltText('button.svg') != null)
expect(queryByAltText('screenshot.jpg')).toBeNull()
`)
})
})
describe('rceToFile', () => {
const rceFile = {
createdAt: '2021-08-12T18:30:53Z',
id: '101',
name: 'kitten.gif',
thumbnailUrl: 'http://example.com/kitten.png',
type: 'image/gif',
url: 'http://example.com/kitten.gif'
}
it('returns an object with type as content_type', () => {
expect(rceToFile(rceFile).content_type).toStrictEqual('image/gif')
})
it('returns an object with createdAt as date', () => {
expect(rceToFile(rceFile).date).toStrictEqual('2021-08-12T18:30:53Z')
})
it('returns an object with name as display_name', () => {
expect(rceToFile(rceFile).display_name).toStrictEqual('kitten.gif')
})
it('returns an object with name as filename', () => {
expect(rceToFile(rceFile).filename).toStrictEqual('kitten.gif')
})
it('returns an object with url as href', () => {
expect(rceToFile(rceFile).href).toStrictEqual('http://example.com/kitten.gif')
})
it('returns an object with id as id', () => {
expect(rceToFile(rceFile).id).toStrictEqual('101')
})
it('returns an object with thumbnailUrl as thumbnail_url', () => {
expect(rceToFile(rceFile).thumbnail_url).toStrictEqual('http://example.com/kitten.png')
})
it('returns an object with the buttons/icons attr set to true', () => {
expect(rceToFile(rceFile)['data-inst-buttons-and-icons']).toEqual(true)
})
})

View File

@ -22,8 +22,9 @@ const BUTTON_ID = 'inst-button-and-icons-edit'
const TOOLBAR_ID = 'inst-button-and-icons-edit-toolbar'
export const BTN_AND_ICON_ATTRIBUTE = 'data-inst-buttons-and-icons'
export const BUTTONS_AND_ICONS = 'buttons_and_icons'
export const shouldShowEditButton = (node) => !!node?.getAttribute(BTN_AND_ICON_ATTRIBUTE)
export const shouldShowEditButton = node => !!node?.getAttribute(BTN_AND_ICON_ATTRIBUTE)
export default function registerEditToolbar(editor, onAction) {
addButton(editor, onAction)

View File

@ -30,6 +30,7 @@ import formatMessage from '../../../format-message'
import Filter, {useFilterSettings} from './Filter'
import {StoreProvider} from './StoreContext'
import {getTrayHeight} from './trayUtils'
import {BUTTONS_AND_ICONS} from '../instructure_buttons/registerEditToolbar'
/**
* Returns the translated tray label
@ -46,7 +47,7 @@ function getTrayLabel(contentType, contentSubtype, contextType) {
}
switch (contentSubtype) {
case 'buttons_and_icons':
case BUTTONS_AND_ICONS:
return formatMessage('Buttons and Icons')
case 'images':
if (contentType === 'course_files') return formatMessage('Course Images')
@ -188,7 +189,7 @@ const FILTER_SETTINGS_BY_PLUGIN = {
list_buttons_and_icons: {
contextType: 'course',
contentType: 'course_files',
contentSubtype: 'buttons_and_icons',
contentSubtype: BUTTONS_AND_ICONS,
sortValue: 'date_added',
sortDir: 'desc',
searchString: ''

View File

@ -25,6 +25,7 @@ import {TextInput} from '@instructure/ui-text-input'
import {SimpleSelect} from '@instructure/ui-simple-select'
import {IconButton} from '@instructure/ui-buttons'
import {ScreenReaderContent} from '@instructure/ui-a11y-content'
import {BUTTONS_AND_ICONS} from '../instructure_buttons/registerEditToolbar'
import {
IconLinkLine,
IconFolderLine,
@ -100,7 +101,7 @@ function renderTypeOptions(contentType, contentSubtype, userContextType) {
}
// Buttons and Icons are only stored in course folders.
if (contentSubtype !== 'buttons_and_icons') {
if (contentSubtype !== BUTTONS_AND_ICONS) {
options.push(
<SimpleSelect.Option
key="user_files"
@ -231,7 +232,7 @@ export default function Filter(props) {
// when flipped to All, the context needs to be user
// so we can get media_objects, which are all returned in the user context
changed.contentType = 'user_files'
} else if (changed.contentSubtype === 'buttons_and_icons') {
} else if (changed.contentSubtype === BUTTONS_AND_ICONS) {
// Buttons and Icons only belong to Courses.
changed.contentType = 'course_files'
}

View File

@ -302,34 +302,6 @@ class RceApiSource {
return this.fetchPage(uri)
}
fetchButtonsAndIcons(
{contextId, contextType},
bookmark = null,
searchString = null,
sortBy,
onSuccess
) {
const onSuccessWithFixedFileData = data => {
onSuccess({
...data,
files: data.files.map(file => fixupFileUrl(contextType, contextId, file))
})
}
if (bookmark) {
this.fetchFilesForFolder(null, bookmark).then(onSuccessWithFixedFileData)
} else {
this.fetchButtonsAndIconsFolder({contextId, contextType}).then(({folders}) => {
this.fetchFilesForFolder({
filesUrl: folders[0].filesUrl,
perPage: 25,
searchString,
sortBy
}).then(onSuccessWithFixedFileData)
})
}
}
fetchMediaFolder(props) {
let uri
if (props.contextType === 'user') {
@ -347,6 +319,7 @@ class RceApiSource {
fetchImages(props) {
const images = props.images[props.contextType]
const uri = images.bookmark || this.uriFor('images', props)
const headers = headerFor(this.jwt)
return this.apiFetch(uri, headers).then(({bookmark, files}) => {
return {
@ -471,11 +444,13 @@ class RceApiSource {
if (!this.hasSession) {
await this.getSession()
}
return this.apiReallyFetch(uri, headers, options)
}
apiReallyFetch(uri, headers, options = {}) {
uri = this.normalizeUriProtocol(uri)
return fetch(uri, {headers})
.then(response => {
if (response.status === 401) {

View File

@ -735,8 +735,6 @@ export function initializeMedia(_props) {
}
}
export function fetchButtonsAndIcons() {}
export function fetchFolders() {
return new Promise(resolve => {
setTimeout(() => {

View File

@ -16,6 +16,11 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {
BUTTONS_AND_ICONS,
BTN_AND_ICON_ATTRIBUTE
} from '../../rce/plugins/instructure_buttons/registerEditToolbar'
export const ADD_IMAGE = 'action.images.add_image'
export const REQUEST_INITIAL_IMAGES = 'action.images.request_initial_images'
export const REQUEST_IMAGES = 'action.images.request_images'
@ -46,15 +51,29 @@ export function requestImages(contextType) {
return {type: REQUEST_IMAGES, payload: {contextType}}
}
export function receiveImages({response, contextType}) {
export function receiveImages({response, contextType, opts = {}}) {
const {files, bookmark, searchString} = response
return {type: RECEIVE_IMAGES, payload: {files, bookmark, contextType, searchString}}
return {
type: RECEIVE_IMAGES,
payload: {files: files.map(f => applyAttributes(f, opts)), bookmark, contextType, searchString}
}
}
export function failImagesLoad({error, contextType}) {
return {type: FAIL_IMAGES_LOAD, payload: {error, contextType}}
}
export const applyAttributes = (file, opts) => {
const augmentedFile = {...file}
if (opts.category === BUTTONS_AND_ICONS) {
augmentedFile[BTN_AND_ICON_ATTRIBUTE] = true
}
return augmentedFile
}
// 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
@ -65,7 +84,7 @@ export function fetchImages(opts = {}) {
const state = getState()
return state.source
.fetchImages({...state, category})
.then(response => dispatch(receiveImages({response, contextType: state.contextType})))
.then(response => dispatch(receiveImages({response, contextType: state.contextType, opts})))
.catch(error => dispatch(failImagesLoad({error, contextType: state.contextType})))
}
}

View File

@ -25,6 +25,7 @@ import {fileEmbed} from '../../common/mimeClass'
import {isPreviewable} from '../../rce/plugins/shared/Previewable'
import {isImage, isAudioOrVideo} from '../../rce/plugins/shared/fileTypeUtils'
import {fixupFileUrl} from '../../common/fileUrl'
import {BUTTONS_AND_ICONS} from '../../rce/plugins/instructure_buttons/registerEditToolbar'
export const COMPLETE_FILE_UPLOAD = 'COMPLETE_FILE_UPLOAD'
export const FAIL_FILE_UPLOAD = 'FAIL_FILE_UPLOAD'
@ -41,8 +42,6 @@ export const STOP_LOADING = 'STOP_LOADING'
export const STOP_MEDIA_UPLOADING = 'STOP_MEDIA_UPLOADING'
export const TOGGLE_UPLOAD_FORM = 'TOGGLE_UPLOAD_FORM'
export const BUTTONS_AND_ICONS = 'buttons_and_icons'
export function startLoading() {
return {type: START_LOADING}
}

View File

@ -418,112 +418,6 @@ describe('sources/api', () => {
})
})
describe('fetchButtonsAndIcons', () => {
const props = {contextId: '1', contextType: 'course'}
beforeEach(() => {
const fetchPageResponseBody = {
bookmark: 'http://example.com/?p=3',
files: [
{
id: '101',
name: 'button.svg',
thumbnailUrl: '/files/1/download',
type: 'image/svg+xml',
url: '/files/1/download/'
}
]
}
sinon.stub(apiSource, 'fetchPage').returns(Promise.resolve(fetchPageResponseBody))
})
afterEach(() => {
apiSource.fetchPage.restore()
})
describe('when a bookmark is present', () => {
it('fetches with the bookmark', () => {
apiSource.fetchButtonsAndIcons(props, 'http://example.com/?p=2', () => {})
sinon.assert.calledWith(apiSource.fetchPage, 'http://example.com/?p=2')
})
it('calls the onSuccess arg with the returned bookmark', () => {
const onSuccess = ({bookmark}) => {
assert.strictEqual(bookmark, fetchPageResponseBody.bookmark)
}
apiSource.fetchButtonsAndIcons(props, 'http://example.com/?p=2', onSuccess)
})
it('calls the onSuccess arg with the returned files', () => {
const onSuccess = ({files}) => {
assert.deepEqual(files, [
{
id: '101',
name: 'button.svg',
thumbnailUrl: '/files/1/download',
type: 'image/svg+xml',
url: '/courses/1/files/1?wrap=1' // url is normalized to include the context
}
])
}
apiSource.fetchButtonsAndIcons(props, 'http://example.com/?p=2', onSuccess)
})
})
describe('when a bookmark is not present', () => {
const filesUrl = 'http://example.com'
const folderResponseBody = {
bookmark: 'http://example.com/?p=2',
folders: [{filesUrl, id: 24}]
}
let fetchButtonsAndIconsFolderPromise
beforeEach(() => {
fetchButtonsAndIconsFolderPromise = Promise.resolve(folderResponseBody)
sinon
.stub(apiSource, 'fetchButtonsAndIconsFolder')
.returns(fetchButtonsAndIconsFolderPromise)
})
afterEach(() => {
apiSource.fetchButtonsAndIconsFolder.restore()
})
it('fetches the buttons and icons folder, then fetches the files within that folder', async () => {
apiSource.fetchButtonsAndIcons(props, null, () => {})
await fetchButtonsAndIconsFolderPromise
sinon.assert.calledWith(apiSource.fetchPage, `${filesUrl}?per_page=25`)
})
it('calls the onSuccess arg with the returned bookmark', () => {
const onSuccess = ({bookmark}) => {
assert.strictEqual(bookmark, folderResponseBody.bookmark)
}
apiSource.fetchButtonsAndIcons(props, null, onSuccess)
})
it('calls the onSuccess arg with the returned files', () => {
const onSuccess = ({files}) => {
assert.deepEqual(files, [
{
id: '101',
name: 'button.svg',
thumbnailUrl: '/files/1/download',
type: 'image/svg+xml',
url: '/courses/1/files/1?wrap=1' // url is normalized to include the context
}
])
}
apiSource.fetchButtonsAndIcons(props, null, onSuccess)
})
})
})
describe('fetchMediaFolder', () => {
let files
beforeEach(() => {

View File

@ -58,6 +58,31 @@ describe('Image dispatch shapes', () => {
assert(payload.searchString === 'panda')
})
})
describe('when the "category" is set to "buttons_and_icons', () => {
let buttonAndIconsResponse, opts
const subject = () => actions.receiveImages(buttonAndIconsResponse)
beforeEach(() => {
buttonAndIconsResponse = {
response: {
files: [{id: 1}, {id: 2}, {id: 3}]
},
contextType,
opts: {
category: 'buttons_and_icons'
}
}
})
it('applies the buttons and icons attribute to each file', () => {
assert.deepEqual(
subject().payload.files.map(f => f['data-inst-buttons-and-icons']),
[true, true, true]
)
})
})
})
})