Convert DashboardCard to TypeScript
refs DE-1083 Change-Id: I2df4456fd58878dbed4366f52e32acdccd141aa5 Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/286065 Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com> Reviewed-by: Aaron Ogata <aogata@instructure.com> QA-Review: Isaac Moore <isaac.moore@instructure.com> Product-Review: Isaac Moore <isaac.moore@instructure.com>
This commit is contained in:
parent
fa4ac3875d
commit
139bfe60a7
|
@ -95,6 +95,7 @@
|
|||
"@sentry/fullstory": "^1.1.7",
|
||||
"@sentry/react": "^6.16.1",
|
||||
"@sentry/tracing": "^6.17.2",
|
||||
"@types/react-dnd": "2.0.36",
|
||||
"apollo-cache": "^1.3.2",
|
||||
"apollo-cache-inmemory": "^1.6.3",
|
||||
"apollo-cache-persist": "^0.1.1",
|
||||
|
|
|
@ -18,12 +18,10 @@
|
|||
|
||||
import $ from 'jquery'
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import TestUtils from 'react-dom/test-utils'
|
||||
import moxios from 'moxios'
|
||||
import sinon from 'sinon'
|
||||
import {moxiosWait} from 'jest-moxios-utils'
|
||||
import {waitFor} from '@testing-library/react'
|
||||
import {act, cleanup, render, waitFor} from '@testing-library/react'
|
||||
|
||||
import DashboardCard from '@canvas/dashboard-card/react/DashboardCard'
|
||||
import CourseActivitySummaryStore from '@canvas/dashboard-card/react/CourseActivitySummaryStore'
|
||||
|
@ -50,6 +48,15 @@ QUnit.module('DashboardCard', {
|
|||
href: '/courses/1',
|
||||
courseCode: '101',
|
||||
id: '1',
|
||||
links: [
|
||||
{
|
||||
css_class: 'discussions',
|
||||
hidden: false,
|
||||
icon: 'icon-discussion',
|
||||
label: 'Discussions',
|
||||
path: '/courses/1/discussion_topics'
|
||||
}
|
||||
],
|
||||
backgroundColor: '#EF4437',
|
||||
image: null,
|
||||
isFavorited: true,
|
||||
|
@ -57,12 +64,14 @@ QUnit.module('DashboardCard', {
|
|||
connectDropTarget: c => c
|
||||
}
|
||||
moxios.install()
|
||||
return sandbox.stub(CourseActivitySummaryStore, 'getStateForCourse').returns({})
|
||||
return (this.getStateForCourseStub = sandbox
|
||||
.stub(CourseActivitySummaryStore, 'getStateForCourse')
|
||||
.returns({}))
|
||||
},
|
||||
teardown() {
|
||||
moxios.uninstall()
|
||||
localStorage.clear()
|
||||
ReactDOM.unmountComponentAtNode(ReactDOM.findDOMNode(this.component).parentNode)
|
||||
cleanup()
|
||||
if (this.wrapper) {
|
||||
return this.wrapper.remove()
|
||||
}
|
||||
|
@ -75,61 +84,41 @@ function errorRendered() {
|
|||
}
|
||||
}
|
||||
|
||||
test('render', function () {
|
||||
const DashCard = <DashboardCard {...this.props} />
|
||||
this.component = TestUtils.renderIntoDocument(DashCard)
|
||||
const $html = $(ReactDOM.findDOMNode(this.component))
|
||||
ok($html.attr('class').match(/DashboardCard/))
|
||||
const renderSpy = sandbox.spy(this.component, 'render')
|
||||
ok(!renderSpy.called, 'precondition')
|
||||
CourseActivitySummaryStore.setState({streams: {1: {stream: this.stream}}})
|
||||
ok(renderSpy.called, 'should re-render on state update')
|
||||
test('obtains new course activity when course activity is updated', function (assert) {
|
||||
const {getByText} = render(<DashboardCard {...this.props} />)
|
||||
|
||||
assert.notEqual(getByText(`${this.props.links[0].label} - ${this.props.shortName}`), undefined)
|
||||
assert.ok(this.getStateForCourseStub.calledOnce)
|
||||
|
||||
act(() => CourseActivitySummaryStore.setState({streams: {1: {stream: this.stream}}}))
|
||||
|
||||
assert.ok(this.getStateForCourseStub.calledTwice)
|
||||
})
|
||||
|
||||
// eslint-disable-next-line qunit/resolve-async
|
||||
test('it should be accessible', function (assert) {
|
||||
const DashCard = <DashboardCard {...this.props} />
|
||||
test('is accessible', function (assert) {
|
||||
this.wrapper = $('<div>').appendTo('body')[0]
|
||||
this.component = ReactDOM.render(DashCard, this.wrapper)
|
||||
const $html = $(ReactDOM.findDOMNode(this.component))
|
||||
const {container} = render(<DashboardCard {...this.props} />, this.wrapper)
|
||||
const $html = $(container.firstChild)
|
||||
const done = assert.async()
|
||||
assertions.isAccessible($html, done)
|
||||
})
|
||||
|
||||
test('unreadCount', function () {
|
||||
const DashCard = <DashboardCard {...this.props} />
|
||||
this.component = TestUtils.renderIntoDocument(DashCard)
|
||||
ok(!this.component.unreadCount('icon-discussion', []), 'should not blow up without a stream')
|
||||
equal(
|
||||
this.component.unreadCount('icon-discussion', this.stream),
|
||||
2,
|
||||
'should pass down unread count if stream item corresponding to icon has unread count'
|
||||
)
|
||||
test('does not have an image when a url is not provided', function (assert) {
|
||||
const {getByText, queryByText} = render(<DashboardCard {...this.props} />)
|
||||
|
||||
assert.equal(queryByText(`Course image for ${this.props.shortName}`), undefined)
|
||||
assert.notEqual(getByText(`Course card color region for ${this.props.shortName}`), undefined)
|
||||
})
|
||||
|
||||
test('does not have image attribute when a url is not provided', function () {
|
||||
const DashCard = <DashboardCard {...this.props} />
|
||||
this.component = TestUtils.renderIntoDocument(DashCard)
|
||||
strictEqual(
|
||||
TestUtils.scryRenderedDOMComponentsWithClass(this.component, 'ic-DashboardCard__header_image')
|
||||
.length,
|
||||
0,
|
||||
'image attribute should not be present'
|
||||
)
|
||||
})
|
||||
|
||||
test('has image attribute when url is provided', function () {
|
||||
test('has an image when a url is provided', function (assert) {
|
||||
this.props.image = 'http://coolUrl'
|
||||
const DashCard = <DashboardCard {...this.props} />
|
||||
this.component = TestUtils.renderIntoDocument(DashCard)
|
||||
const $html = TestUtils.findRenderedDOMComponentWithClass(
|
||||
this.component,
|
||||
'ic-DashboardCard__header_image'
|
||||
)
|
||||
ok($html, 'image showing')
|
||||
const {getByText} = render(<DashboardCard {...this.props} />)
|
||||
|
||||
assert.notEqual(getByText(`Course image for ${this.props.shortName}`), undefined)
|
||||
})
|
||||
|
||||
test('#removeCourseFromFavorites succeeds', function () {
|
||||
test('handles success removing course from favorites', async function (assert) {
|
||||
const handleRerenderSpy = sinon.spy()
|
||||
this.props.onConfirmUnfavorite = handleRerenderSpy
|
||||
|
||||
|
@ -139,25 +128,30 @@ test('#removeCourseFromFavorites succeeds', function () {
|
|||
}
|
||||
}
|
||||
|
||||
const DashCard = <DashboardCard {...this.props} />
|
||||
this.component = TestUtils.renderIntoDocument(DashCard)
|
||||
this.component.removeCourseFromFavorites()
|
||||
const {getByText} = render(<DashboardCard {...this.props} />)
|
||||
act(() =>
|
||||
getByText(
|
||||
`Choose a color or course nickname or move course card for ${this.props.shortName}`
|
||||
).click()
|
||||
)
|
||||
act(() => getByText('Move').click())
|
||||
act(() => getByText('Unfavorite').click())
|
||||
act(() => getByText('Submit').click())
|
||||
|
||||
return moxiosWait(function () {
|
||||
await moxiosWait(() => {
|
||||
const request = moxios.requests.mostRecent()
|
||||
request.respondWith({
|
||||
status: 200,
|
||||
response: []
|
||||
})
|
||||
}).then(async function () {
|
||||
await waitFor(() => waitForResponse())
|
||||
ok(handleRerenderSpy.calledOnce)
|
||||
})
|
||||
|
||||
await waitFor(() => waitForResponse())
|
||||
assert.ok(handleRerenderSpy.calledOnce)
|
||||
})
|
||||
|
||||
test('#removeCourseFromFavorites fails', function () {
|
||||
const handleRerenderSpy = sinon.spy()
|
||||
this.props.onConfirmUnfavorite = handleRerenderSpy
|
||||
test('handles failure removing course from favorites', async function (assert) {
|
||||
this.props.onConfirmUnfavorite = sinon.spy()
|
||||
|
||||
function waitForAlert() {
|
||||
if (errorRendered) {
|
||||
|
@ -165,18 +159,24 @@ test('#removeCourseFromFavorites fails', function () {
|
|||
}
|
||||
}
|
||||
|
||||
const DashCard = <DashboardCard {...this.props} />
|
||||
this.component = TestUtils.renderIntoDocument(DashCard)
|
||||
this.component.removeCourseFromFavorites()
|
||||
const {getByText} = render(<DashboardCard {...this.props} />)
|
||||
act(() =>
|
||||
getByText(
|
||||
`Choose a color or course nickname or move course card for ${this.props.shortName}`
|
||||
).click()
|
||||
)
|
||||
act(() => getByText('Move').click())
|
||||
act(() => getByText('Unfavorite').click())
|
||||
act(() => getByText('Submit').click())
|
||||
|
||||
return moxiosWait(function () {
|
||||
await moxiosWait(() => {
|
||||
const request = moxios.requests.mostRecent()
|
||||
request.respondWith({
|
||||
status: 403,
|
||||
response: []
|
||||
})
|
||||
}).then(async function () {
|
||||
await waitFor(() => waitForAlert())
|
||||
ok(errorRendered)
|
||||
})
|
||||
|
||||
await waitFor(() => waitForAlert())
|
||||
assert.ok(errorRendered)
|
||||
})
|
||||
|
|
|
@ -24,6 +24,7 @@ import DashboardCard from '@canvas/dashboard-card/react/DashboardCard'
|
|||
import DraggableDashboardCard from '@canvas/dashboard-card/react/DraggableDashboardCard'
|
||||
import getDroppableDashboardCardBox from '@canvas/dashboard-card/react/getDroppableDashboardCardBox'
|
||||
import fakeENV from 'helpers/fakeENV'
|
||||
import {render} from '@testing-library/react'
|
||||
|
||||
let cards
|
||||
let fakeServer
|
||||
|
@ -84,8 +85,8 @@ test('it renders', () => {
|
|||
ok(root)
|
||||
})
|
||||
|
||||
test('cards have opacity of 0 while moving', () => {
|
||||
const card = TestUtils.renderIntoDocument(
|
||||
test('cards have opacity of 0 while moving', assert => {
|
||||
const {container} = render(
|
||||
<DashboardCard
|
||||
cardComponent={DashboardCard}
|
||||
{...cards[0]}
|
||||
|
@ -94,8 +95,8 @@ test('cards have opacity of 0 while moving', () => {
|
|||
isDragging
|
||||
/>
|
||||
)
|
||||
const div = TestUtils.findRenderedDOMComponentWithClass(card, 'ic-DashboardCard')
|
||||
equal(div.style.opacity, 0)
|
||||
|
||||
assert.equal(container.firstChild.style.opacity, 0)
|
||||
})
|
||||
|
||||
test('moving a card adjusts the position property', () => {
|
||||
|
|
|
@ -1,403 +0,0 @@
|
|||
/*
|
||||
* Copyright (C) 2015 - 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, {Component} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useScope as useI18nScope } from '@canvas/i18n';
|
||||
import axios from '@canvas/axios'
|
||||
|
||||
import DashboardCardAction from './DashboardCardAction'
|
||||
import CourseActivitySummaryStore from './CourseActivitySummaryStore'
|
||||
import DashboardCardMenu from './DashboardCardMenu'
|
||||
import PublishButton from './PublishButton'
|
||||
import {showConfirmUnfavorite} from './ConfirmUnfavoriteCourseModal'
|
||||
import {showFlashError} from '@canvas/alerts/react/FlashAlert'
|
||||
import instFSOptimizedImageUrl from '../util/instFSOptimizedImageUrl'
|
||||
|
||||
const I18n = useI18nScope('dashcards');
|
||||
|
||||
export function DashboardCardHeaderHero({image, backgroundColor, hideColorOverlays, onClick}) {
|
||||
if (image) {
|
||||
return (
|
||||
<div
|
||||
className="ic-DashboardCard__header_image"
|
||||
style={{backgroundImage: `url(${instFSOptimizedImageUrl(image, {x: 262, y: 146})})`}}
|
||||
>
|
||||
<div
|
||||
className="ic-DashboardCard__header_hero"
|
||||
style={{backgroundColor, opacity: hideColorOverlays ? 0 : 0.6}}
|
||||
onClick={onClick}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ic-DashboardCard__header_hero"
|
||||
style={{backgroundColor}}
|
||||
onClick={onClick}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default class DashboardCard extends Component {
|
||||
// ===============
|
||||
// CONFIG
|
||||
// ===============
|
||||
|
||||
static propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
backgroundColor: PropTypes.string,
|
||||
shortName: PropTypes.string.isRequired,
|
||||
originalName: PropTypes.string.isRequired,
|
||||
courseCode: PropTypes.string.isRequired,
|
||||
assetString: PropTypes.string.isRequired,
|
||||
term: PropTypes.string,
|
||||
href: PropTypes.string.isRequired,
|
||||
links: PropTypes.arrayOf(PropTypes.object),
|
||||
image: PropTypes.string,
|
||||
handleColorChange: PropTypes.func,
|
||||
hideColorOverlays: PropTypes.bool,
|
||||
isDragging: PropTypes.bool,
|
||||
isFavorited: PropTypes.bool,
|
||||
connectDragSource: PropTypes.func,
|
||||
connectDropTarget: PropTypes.func,
|
||||
moveCard: PropTypes.func,
|
||||
onConfirmUnfavorite: PropTypes.func,
|
||||
totalCards: PropTypes.number,
|
||||
position: PropTypes.oneOfType([PropTypes.number, PropTypes.func]),
|
||||
enrollmentType: PropTypes.string,
|
||||
observee: PropTypes.string,
|
||||
published: PropTypes.bool,
|
||||
canChangeCoursePublishState: PropTypes.bool,
|
||||
defaultView: PropTypes.string,
|
||||
pagesUrl: PropTypes.string,
|
||||
frontPageTitle: PropTypes.string
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
backgroundColor: '#394B58',
|
||||
term: null,
|
||||
links: [],
|
||||
hideColorOverlays: false,
|
||||
handleColorChange: () => {},
|
||||
image: '',
|
||||
isDragging: false,
|
||||
connectDragSource: c => c,
|
||||
connectDropTarget: c => c,
|
||||
moveCard: () => {},
|
||||
totalCards: 0,
|
||||
position: 0,
|
||||
published: false,
|
||||
canChangeCoursePublishState: false
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super()
|
||||
|
||||
this.state = {
|
||||
nicknameInfo: this.nicknameInfo(props.shortName, props.originalName, props.id),
|
||||
...CourseActivitySummaryStore.getStateForCourse(props.id)
|
||||
}
|
||||
|
||||
this.removeCourseFromFavorites = this.removeCourseFromFavorites.bind(this)
|
||||
}
|
||||
|
||||
// ===============
|
||||
// LIFECYCLE
|
||||
// ===============
|
||||
|
||||
componentDidMount() {
|
||||
CourseActivitySummaryStore.addChangeListener(this.handleStoreChange)
|
||||
this.parentNode = this.cardDiv
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
CourseActivitySummaryStore.removeChangeListener(this.handleStoreChange)
|
||||
}
|
||||
|
||||
// ===============
|
||||
// ACTIONS
|
||||
// ===============
|
||||
|
||||
settingsClick = e => {
|
||||
if (e) {
|
||||
e.preventDefault()
|
||||
}
|
||||
this.toggleEditing()
|
||||
}
|
||||
|
||||
getCardPosition() {
|
||||
return typeof this.props.position === 'function' ? this.props.position() : this.props.position
|
||||
}
|
||||
|
||||
handleNicknameChange = nickname => {
|
||||
this.setState({
|
||||
nicknameInfo: this.nicknameInfo(nickname, this.props.originalName, this.props.id)
|
||||
})
|
||||
}
|
||||
|
||||
handleStoreChange = () => {
|
||||
this.setState(CourseActivitySummaryStore.getStateForCourse(this.props.id))
|
||||
}
|
||||
|
||||
toggleEditing = () => {
|
||||
const currentState = !!this.state.editing
|
||||
this.setState({editing: !currentState})
|
||||
}
|
||||
|
||||
headerClick = e => {
|
||||
if (e) {
|
||||
e.preventDefault()
|
||||
}
|
||||
window.location = this.props.href
|
||||
}
|
||||
|
||||
doneEditing = () => {
|
||||
this.setState({editing: false})
|
||||
this.settingsToggle.focus()
|
||||
}
|
||||
|
||||
handleColorChange = color => {
|
||||
const hexColor = `#${color}`
|
||||
this.props.handleColorChange(hexColor)
|
||||
}
|
||||
|
||||
handleMove = (assetString, atIndex) => {
|
||||
if (typeof this.props.moveCard === 'function') {
|
||||
this.props.moveCard(assetString, atIndex, () => {
|
||||
this.settingsToggle.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
handleUnfavorite = () => {
|
||||
const modalProps = {
|
||||
courseId: this.props.id,
|
||||
courseName: this.props.originalName,
|
||||
onConfirm: this.removeCourseFromFavorites,
|
||||
onClose: this.handleClose,
|
||||
onEntered: this.handleEntered
|
||||
}
|
||||
showConfirmUnfavorite(modalProps)
|
||||
}
|
||||
// ===============
|
||||
// HELPERS
|
||||
// ===============
|
||||
|
||||
nicknameInfo(nickname, originalName, courseId) {
|
||||
return {
|
||||
nickname,
|
||||
originalName,
|
||||
courseId,
|
||||
onNicknameChange: this.handleNicknameChange
|
||||
}
|
||||
}
|
||||
|
||||
unreadCount(icon, stream) {
|
||||
const activityType = {
|
||||
'icon-announcement': 'Announcement',
|
||||
'icon-assignment': 'Message',
|
||||
'icon-discussion': 'DiscussionTopic'
|
||||
}[icon]
|
||||
|
||||
const itemStream = stream || []
|
||||
const streamItem = itemStream.find(
|
||||
item =>
|
||||
// only return 'Message' type if category is 'Due Date' (for assignments)
|
||||
item.type === activityType &&
|
||||
(activityType !== 'Message' || item.notification_category === I18n.t('Due Date'))
|
||||
)
|
||||
|
||||
// TODO: unread count is always 0 for assignments (see CNVS-21227)
|
||||
return streamItem ? streamItem.unread_count : 0
|
||||
}
|
||||
|
||||
calculateMenuOptions() {
|
||||
const position = this.getCardPosition()
|
||||
const isFirstCard = position === 0
|
||||
const isLastCard = position === this.props.totalCards - 1
|
||||
return {
|
||||
canMoveLeft: !isFirstCard,
|
||||
canMoveRight: !isLastCard,
|
||||
canMoveToBeginning: !isFirstCard,
|
||||
canMoveToEnd: !isLastCard
|
||||
}
|
||||
}
|
||||
|
||||
removeCourseFromFavorites() {
|
||||
const url = `/api/v1/users/self/favorites/courses/${this.props.id}`
|
||||
axios
|
||||
.delete(url)
|
||||
.then(response => {
|
||||
if (response.status === 200) {
|
||||
this.props.onConfirmUnfavorite(this.props.id)
|
||||
}
|
||||
})
|
||||
.catch(() =>
|
||||
showFlashError(I18n.t('We were unable to remove this course from your favorites.'))
|
||||
)
|
||||
}
|
||||
|
||||
// ===============
|
||||
// RENDERING
|
||||
// ===============
|
||||
|
||||
linksForCard() {
|
||||
return this.props.links.map(link => {
|
||||
if (!link.hidden) {
|
||||
const screenReaderLabel = `${link.label} - ${this.state.nicknameInfo.nickname}`
|
||||
return (
|
||||
<DashboardCardAction
|
||||
unreadCount={this.unreadCount(link.icon, this.state.stream)}
|
||||
iconClass={link.icon}
|
||||
linkClass={link.css_class}
|
||||
path={link.path}
|
||||
screenReaderLabel={screenReaderLabel}
|
||||
key={link.path}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
renderHeaderButton() {
|
||||
const {backgroundColor, hideColorOverlays} = this.props
|
||||
|
||||
const reorderingProps = {
|
||||
handleMove: this.handleMove,
|
||||
currentPosition: this.getCardPosition(),
|
||||
lastPosition: this.props.totalCards - 1,
|
||||
menuOptions: this.calculateMenuOptions()
|
||||
}
|
||||
|
||||
const nickname = this.state.nicknameInfo.nickname
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="ic-DashboardCard__header-button-bg"
|
||||
style={{backgroundColor, opacity: hideColorOverlays ? 1 : 0}}
|
||||
/>
|
||||
<DashboardCardMenu
|
||||
afterUpdateColor={this.handleColorChange}
|
||||
currentColor={this.props.backgroundColor}
|
||||
nicknameInfo={this.state.nicknameInfo}
|
||||
assetString={this.props.assetString}
|
||||
onUnfavorite={this.handleUnfavorite}
|
||||
isFavorited={this.props.isFavorited}
|
||||
{...reorderingProps}
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
className="Button Button--icon-action-rev ic-DashboardCard__header-button"
|
||||
ref={c => {
|
||||
this.settingsToggle = c
|
||||
}}
|
||||
>
|
||||
<i className="icon-more" aria-hidden="true" />
|
||||
<span className="screenreader-only">
|
||||
{I18n.t('Choose a color or course nickname or move course card for %{course}', {
|
||||
course: nickname
|
||||
})}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const dashboardCard = (
|
||||
<div
|
||||
className="ic-DashboardCard"
|
||||
ref={c => {
|
||||
this.cardDiv = c
|
||||
}}
|
||||
style={{opacity: this.props.isDragging ? 0 : 1}}
|
||||
aria-label={this.props.originalName}
|
||||
>
|
||||
<div className="ic-DashboardCard__header">
|
||||
<span className="screenreader-only">
|
||||
{this.props.image
|
||||
? I18n.t('Course image for %{course}', {course: this.state.nicknameInfo.nickname})
|
||||
: I18n.t('Course card color region for %{course}', {
|
||||
course: this.state.nicknameInfo.nickname
|
||||
})}
|
||||
</span>
|
||||
<DashboardCardHeaderHero
|
||||
image={this.props.image}
|
||||
backgroundColor={this.props.backgroundColor}
|
||||
hideColorOverlays={this.props.hideColorOverlays}
|
||||
onClick={this.headerClick}
|
||||
/>
|
||||
<a href={this.props.href} className="ic-DashboardCard__link">
|
||||
<div className="ic-DashboardCard__header_content">
|
||||
<h3
|
||||
className="ic-DashboardCard__header-title ellipsis"
|
||||
title={this.props.originalName}
|
||||
>
|
||||
<span style={{color: this.props.backgroundColor}}>
|
||||
{this.state.nicknameInfo.nickname}
|
||||
</span>
|
||||
</h3>
|
||||
<div
|
||||
className="ic-DashboardCard__header-subtitle ellipsis"
|
||||
title={this.props.courseCode}
|
||||
>
|
||||
{this.props.courseCode}
|
||||
</div>
|
||||
<div className="ic-DashboardCard__header-term ellipsis" title={this.props.term}>
|
||||
{this.props.term ? this.props.term : null}
|
||||
</div>
|
||||
{this.props.enrollmentType === 'ObserverEnrollment' && this.props.observee && (
|
||||
<div className="ic-DashboardCard__header-term ellipsis" title={this.props.observee}>
|
||||
{I18n.t('Observing: %{observee}', {observee: this.props.observee})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
{!this.props.published && this.props.canChangeCoursePublishState && (
|
||||
<PublishButton
|
||||
courseNickname={this.state.nicknameInfo.nickname}
|
||||
defaultView={this.props.defaultView}
|
||||
pagesUrl={this.props.pagesUrl}
|
||||
frontPageTitle={this.props.frontPageTitle}
|
||||
courseId={this.props.id}
|
||||
/>
|
||||
)}
|
||||
{this.renderHeaderButton()}
|
||||
</div>
|
||||
<nav
|
||||
className="ic-DashboardCard__action-container"
|
||||
aria-label={I18n.t('Actions for %{course}', {course: this.state.nicknameInfo.nickname})}
|
||||
>
|
||||
{this.linksForCard()}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
|
||||
const {connectDragSource, connectDropTarget} = this.props
|
||||
return connectDragSource(connectDropTarget(dashboardCard))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,355 @@
|
|||
/*
|
||||
* Copyright (C) 2015 - 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, {MouseEventHandler, useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {useScope as useI18nScope} from '@canvas/i18n'
|
||||
import axios from '@canvas/axios'
|
||||
|
||||
import DashboardCardAction from './DashboardCardAction'
|
||||
import CourseActivitySummaryStore from './CourseActivitySummaryStore'
|
||||
import DashboardCardMenu from './DashboardCardMenu'
|
||||
import PublishButton from './PublishButton'
|
||||
import {showConfirmUnfavorite} from './ConfirmUnfavoriteCourseModal'
|
||||
import {showFlashError} from '@canvas/alerts/react/FlashAlert'
|
||||
import instFSOptimizedImageUrl from '../util/instFSOptimizedImageUrl'
|
||||
import {ConnectDragSource, ConnectDropTarget} from 'react-dnd'
|
||||
|
||||
const I18n = useI18nScope('dashcards')
|
||||
|
||||
export type DashboardCardHeaderHeroProps = {
|
||||
image?: string
|
||||
backgroundColor?: string
|
||||
hideColorOverlays?: boolean
|
||||
onClick?: MouseEventHandler<HTMLElement>
|
||||
}
|
||||
|
||||
export const DashboardCardHeaderHero = ({
|
||||
image,
|
||||
backgroundColor,
|
||||
hideColorOverlays,
|
||||
onClick
|
||||
}: DashboardCardHeaderHeroProps) => {
|
||||
if (image) {
|
||||
return (
|
||||
<div
|
||||
className="ic-DashboardCard__header_image"
|
||||
style={{backgroundImage: `url(${instFSOptimizedImageUrl(image, {x: 262, y: 146})})`}}
|
||||
>
|
||||
<div
|
||||
className="ic-DashboardCard__header_hero"
|
||||
style={{backgroundColor, opacity: hideColorOverlays ? 0 : 0.6}}
|
||||
onClick={onClick}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ic-DashboardCard__header_hero"
|
||||
style={{backgroundColor}}
|
||||
onClick={onClick}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type DashboardCardProps = {
|
||||
id: string
|
||||
backgroundColor?: string
|
||||
shortName: string
|
||||
originalName: string
|
||||
courseCode: string
|
||||
assetString: string
|
||||
term?: string
|
||||
href: string
|
||||
links: any[] // TODO: improve type
|
||||
image?: string
|
||||
handleColorChange?: (color: string) => void
|
||||
hideColorOverlays?: boolean
|
||||
isDragging?: boolean
|
||||
isFavorited?: boolean
|
||||
connectDragSource?: ConnectDragSource
|
||||
connectDropTarget?: ConnectDropTarget
|
||||
moveCard?: (assetString: string, atIndex: number, callback: () => void) => void
|
||||
onConfirmUnfavorite: (id: string) => void
|
||||
totalCards?: number
|
||||
position?: number | (() => number)
|
||||
enrollmentType?: string
|
||||
observee?: string
|
||||
published?: boolean
|
||||
canChangeCoursePublishState?: boolean
|
||||
defaultView?: string
|
||||
pagesUrl?: string
|
||||
frontPageTitle?: string
|
||||
}
|
||||
|
||||
export const DashboardCard = ({
|
||||
id,
|
||||
backgroundColor = '#394B58',
|
||||
shortName,
|
||||
originalName,
|
||||
courseCode,
|
||||
assetString,
|
||||
term,
|
||||
href,
|
||||
links = [],
|
||||
image,
|
||||
handleColorChange = () => {},
|
||||
hideColorOverlays,
|
||||
isDragging,
|
||||
isFavorited,
|
||||
connectDragSource = c => c,
|
||||
connectDropTarget = c => c,
|
||||
moveCard = () => {},
|
||||
onConfirmUnfavorite,
|
||||
totalCards = 0,
|
||||
position = 0,
|
||||
enrollmentType,
|
||||
observee,
|
||||
published,
|
||||
canChangeCoursePublishState,
|
||||
defaultView,
|
||||
pagesUrl,
|
||||
frontPageTitle
|
||||
}: DashboardCardProps) => {
|
||||
const handleNicknameChange = nickname => setNicknameInfo(getNicknameInfo(nickname))
|
||||
|
||||
const getNicknameInfo = (nickname: string) => ({
|
||||
nickname,
|
||||
originalName,
|
||||
courseId: id,
|
||||
onNicknameChange: handleNicknameChange
|
||||
})
|
||||
|
||||
const [nicknameInfo, setNicknameInfo] = useState(getNicknameInfo(shortName))
|
||||
const [course, setCourse] = useState(CourseActivitySummaryStore.getStateForCourse(id))
|
||||
const settingsToggle = useRef<HTMLButtonElement | null>()
|
||||
|
||||
const handleStoreChange = useCallback(
|
||||
() => setCourse(CourseActivitySummaryStore.getStateForCourse(id)),
|
||||
[id]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
CourseActivitySummaryStore.addChangeListener(handleStoreChange)
|
||||
return () => CourseActivitySummaryStore.removeChangeListener(handleStoreChange)
|
||||
}, [handleStoreChange])
|
||||
|
||||
// ===============
|
||||
// ACTIONS
|
||||
// ===============
|
||||
|
||||
const getCardPosition = () => (typeof position === 'function' ? position() : position)
|
||||
|
||||
const headerClick: MouseEventHandler = e => {
|
||||
e.preventDefault()
|
||||
window.location.assign(href)
|
||||
}
|
||||
|
||||
const handleMove = (asset: string, atIndex: number) => {
|
||||
if (moveCard) {
|
||||
moveCard(asset, atIndex, () => settingsToggle.current?.focus())
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnfavorite = () => {
|
||||
const modalProps = {
|
||||
courseId: id,
|
||||
courseName: originalName,
|
||||
onConfirm: removeCourseFromFavorites
|
||||
}
|
||||
showConfirmUnfavorite(modalProps)
|
||||
}
|
||||
|
||||
// ===============
|
||||
// HELPERS
|
||||
// ===============
|
||||
|
||||
const unreadCount = (icon: string, stream?: any[]) => {
|
||||
const activityType = {
|
||||
'icon-announcement': 'Announcement',
|
||||
'icon-assignment': 'Message',
|
||||
'icon-discussion': 'DiscussionTopic'
|
||||
}[icon]
|
||||
|
||||
const itemStream = stream || []
|
||||
const streamItem = itemStream.find(
|
||||
item =>
|
||||
// only return 'Message' type if category is 'Due Date' (for assignments)
|
||||
item.type === activityType &&
|
||||
(activityType !== 'Message' || item.notification_category === I18n.t('Due Date'))
|
||||
)
|
||||
|
||||
// TODO: unread count is always 0 for assignments (see CNVS-21227)
|
||||
return streamItem ? streamItem.unread_count : 0
|
||||
}
|
||||
|
||||
const calculateMenuOptions = () => {
|
||||
const cardPosition = getCardPosition()
|
||||
const isFirstCard = cardPosition === 0
|
||||
const isLastCard = cardPosition === totalCards - 1
|
||||
return {
|
||||
canMoveLeft: !isFirstCard,
|
||||
canMoveRight: !isLastCard,
|
||||
canMoveToBeginning: !isFirstCard,
|
||||
canMoveToEnd: !isLastCard
|
||||
}
|
||||
}
|
||||
|
||||
const removeCourseFromFavorites = () => {
|
||||
const url = `/api/v1/users/self/favorites/courses/${id}`
|
||||
axios
|
||||
.delete(url)
|
||||
.then(response => {
|
||||
if (response.status === 200) {
|
||||
onConfirmUnfavorite(id)
|
||||
}
|
||||
})
|
||||
.catch(() =>
|
||||
showFlashError(I18n.t('We were unable to remove this course from your favorites.'))
|
||||
)
|
||||
}
|
||||
|
||||
// ===============
|
||||
// RENDERING
|
||||
// ===============
|
||||
|
||||
const linksForCard = () =>
|
||||
links.map(link => {
|
||||
if (link.hidden) return null
|
||||
|
||||
const screenReaderLabel = `${link.label} - ${nicknameInfo.nickname}`
|
||||
return (
|
||||
<DashboardCardAction
|
||||
unreadCount={unreadCount(link.icon, course.stream)}
|
||||
iconClass={link.icon}
|
||||
linkClass={link.css_class}
|
||||
path={link.path}
|
||||
screenReaderLabel={screenReaderLabel}
|
||||
key={link.path}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const renderHeaderButton = () => {
|
||||
const reorderingProps = {
|
||||
handleMove,
|
||||
currentPosition: getCardPosition(),
|
||||
lastPosition: totalCards - 1,
|
||||
menuOptions: calculateMenuOptions()
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="ic-DashboardCard__header-button-bg"
|
||||
style={{backgroundColor, opacity: hideColorOverlays ? 1 : 0}}
|
||||
/>
|
||||
<DashboardCardMenu
|
||||
afterUpdateColor={(c: string) => handleColorChange(`#${c}`)}
|
||||
currentColor={backgroundColor}
|
||||
nicknameInfo={nicknameInfo}
|
||||
assetString={assetString}
|
||||
onUnfavorite={handleUnfavorite}
|
||||
isFavorited={isFavorited}
|
||||
{...reorderingProps}
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
className="Button Button--icon-action-rev ic-DashboardCard__header-button"
|
||||
ref={c => {
|
||||
settingsToggle.current = c
|
||||
}}
|
||||
>
|
||||
<i className="icon-more" aria-hidden="true" />
|
||||
<span className="screenreader-only">
|
||||
{I18n.t('Choose a color or course nickname or move course card for %{course}', {
|
||||
course: nicknameInfo.nickname
|
||||
})}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const dashboardCard = (
|
||||
<div
|
||||
className="ic-DashboardCard"
|
||||
style={{opacity: isDragging ? 0 : 1}}
|
||||
aria-label={originalName}
|
||||
>
|
||||
<div className="ic-DashboardCard__header">
|
||||
<span className="screenreader-only">
|
||||
{image
|
||||
? I18n.t('Course image for %{course}', {course: nicknameInfo.nickname})
|
||||
: I18n.t('Course card color region for %{course}', {
|
||||
course: nicknameInfo.nickname
|
||||
})}
|
||||
</span>
|
||||
<DashboardCardHeaderHero
|
||||
image={image}
|
||||
backgroundColor={backgroundColor}
|
||||
hideColorOverlays={hideColorOverlays}
|
||||
onClick={headerClick}
|
||||
/>
|
||||
<a href={href} className="ic-DashboardCard__link">
|
||||
<div className="ic-DashboardCard__header_content">
|
||||
<h3 className="ic-DashboardCard__header-title ellipsis" title={originalName}>
|
||||
<span style={{color: backgroundColor}}>{nicknameInfo.nickname}</span>
|
||||
</h3>
|
||||
<div className="ic-DashboardCard__header-subtitle ellipsis" title={courseCode}>
|
||||
{courseCode}
|
||||
</div>
|
||||
<div className="ic-DashboardCard__header-term ellipsis" title={term}>
|
||||
{term || null}
|
||||
</div>
|
||||
{enrollmentType === 'ObserverEnrollment' && observee && (
|
||||
<div className="ic-DashboardCard__header-term ellipsis" title={observee}>
|
||||
{I18n.t('Observing: %{observee}', {observee})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
{!published && canChangeCoursePublishState && (
|
||||
<PublishButton
|
||||
courseNickname={nicknameInfo.nickname}
|
||||
defaultView={defaultView}
|
||||
pagesUrl={pagesUrl}
|
||||
frontPageTitle={frontPageTitle}
|
||||
courseId={id}
|
||||
/>
|
||||
)}
|
||||
{renderHeaderButton()}
|
||||
</div>
|
||||
<nav
|
||||
className="ic-DashboardCard__action-container"
|
||||
aria-label={I18n.t('Actions for %{course}', {course: nicknameInfo.nickname})}
|
||||
>
|
||||
{linksForCard()}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
|
||||
return connectDragSource(connectDropTarget(dashboardCard))
|
||||
}
|
||||
|
||||
export default DashboardCard
|
13
yarn.lock
13
yarn.lock
|
@ -5742,6 +5742,13 @@
|
|||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-dnd@2.0.36":
|
||||
version "2.0.36"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-dnd/-/react-dnd-2.0.36.tgz#67e08a3608f112a3af27201d1fb6f79334d43214"
|
||||
integrity sha512-jA95HjQxuHNSnr0PstVBjRwVcFJZoinxbtsS4bpi5nwAL5GUOtjrLrq1bDi4WNYxW+77KHvqSAZ2EgA2q9evdA==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-dom@>=16.9.0", "@types/react-dom@^17.0.9":
|
||||
version "17.0.9"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.9.tgz#441a981da9d7be117042e1a6fd3dac4b30f55add"
|
||||
|
@ -21783,9 +21790,9 @@ redux@^3.6.0, redux@^3.7.1, redux@^3.7.2:
|
|||
symbol-observable "^1.0.3"
|
||||
|
||||
redux@^4, redux@^4.0.1:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/redux/-/redux-4.1.0.tgz#eb049679f2f523c379f1aff345c8612f294c88d4"
|
||||
integrity sha512-uI2dQN43zqLWCt6B/BMGRMY6db7TTY4qeHHfGeKb3EOhmOKjU3KdWvNLJyqaHRksv/ErdNH7cFZWg9jXtewy4g==
|
||||
version "4.1.2"
|
||||
resolved "https://registry.yarnpkg.com/redux/-/redux-4.1.2.tgz#140f35426d99bb4729af760afcf79eaaac407104"
|
||||
integrity sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.9.2"
|
||||
|
||||
|
|
Loading…
Reference in New Issue