Rearrange discussion layout.

This takes the functionality in CourseItemRow and instead
implements the required parts in DiscussionRow.  This
amounts to a complete rewrite of CourseItemRow, and as
such will require thorough regression QA.

I made a bit of an effort to keep things shareable between
the two but it wasn't all working and I gave up to get this
out.  In time we should extract small shareable pieces
between this and announcements and then refactor
AnnouncementRow to no longer use CourseItemRow.  Also
leverage INSTUI better to not have so much manual CSS.

Closes COMMS-1059

Test Plan:
* Compare page to designs at [designs link] make sure the
  appearance matches.  Well some of it, since some of
  stuff they're asking for doesn't really make sense
  (i.e. having assignments with specific sections)
* Things should still work.  Since this was a substantial
  change and refactoring, please test this thoroughly.
* I didn't touch CourseItemRow or AnnouncementRow, but it
  wouldn't be a terrible idea to smoketest.

Change-Id: I86e2e4f9ab5deeb3a4fe51d63da199c94321b8c5
Reviewed-on: https://gerrit.instructure.com/148195
Tested-by: Jenkins
Reviewed-by: Landon Gilbert-Bland <lbland@instructure.com>
Reviewed-by: Aaron Kc Hsu <ahsu@instructure.com>
QA-Review: Aaron Kc Hsu <ahsu@instructure.com>
Product-Review: Kendall Chadwick <kchadwick@instructure.com>
This commit is contained in:
Venk Natarajan 2018-04-25 13:53:54 -06:00
parent aed929745b
commit 41ec03439a
7 changed files with 450 additions and 113 deletions

View File

@ -24,19 +24,25 @@ import { connect } from 'react-redux'
import { DragSource, DropTarget } from 'react-dnd';
import { findDOMNode } from 'react-dom'
import { func, bool, string, arrayOf } from 'prop-types'
import cx from 'classnames'
import $ from 'jquery'
import 'jquery.instructure_date_and_time'
import Badge from '@instructure/ui-core/lib/components/Badge'
import Container from '@instructure/ui-core/lib/components/Container'
import Grid, { GridCol, GridRow} from '@instructure/ui-core/lib/components/Grid'
import Flex, { FlexItem } from '@instructure/ui-layout/lib/components/Flex'
import Grid, { GridCol, GridRow} from '@instructure/ui-layout/lib/components/Grid'
import Heading from '@instructure/ui-core/lib/components/Heading'
import IconAssignmentLine from 'instructure-icons/lib/Line/IconAssignmentLine'
import IconBookmarkLine from 'instructure-icons/lib/Line/IconBookmarkLine'
import IconBookmarkSolid from 'instructure-icons/lib/Solid/IconBookmarkSolid'
import IconCopySolid from 'instructure-icons/lib/Solid/IconCopySolid'
import IconDragHandleLine from 'instructure-icons/lib/Line/IconDragHandleLine'
import IconLock from 'instructure-icons/lib/Line/IconLockLine'
import IconLtiLine from 'instructure-icons/lib/Line/IconLtiLine'
import IconPeerReviewLine from 'instructure-icons/lib/Line/IconPeerReviewLine'
import IconPinLine from 'instructure-icons/lib/Line/IconPinLine'
import IconPinSolid from 'instructure-icons/lib/Solid/IconPinSolid'
import IconPublishSolid from 'instructure-icons/lib/Solid/IconPublishSolid'
@ -44,15 +50,19 @@ import IconTrashSolid from 'instructure-icons/lib/Solid/IconTrashSolid'
import IconUnlock from 'instructure-icons/lib/Line/IconUnlockLine'
import IconUnpublishedLine from 'instructure-icons/lib/Line/IconUnpublishedLine'
import IconUpdownLine from 'instructure-icons/lib/Line/IconUpdownLine'
import Pill from '@instructure/ui-core/lib/components/Pill'
import ScreenReaderContent from '@instructure/ui-core/lib/components/ScreenReaderContent'
import Text from '@instructure/ui-core/lib/components/Text'
import { MenuItem } from '@instructure/ui-core/lib/components/Menu'
import DiscussionModel from 'compiled/models/DiscussionTopic'
import LockIconView from 'compiled/views/LockIconView'
import actions from '../actions'
import compose from '../../shared/helpers/compose'
import CourseItemRow from '../../shared/components/CourseItemRow'
import CyoeHelper from '../../shared/conditional_release/CyoeHelper'
import DiscussionManageMenu from '../../shared/components/DiscussionManageMenu'
import discussionShape from '../../shared/proptypes/discussion'
import masterCourseDataShape from '../../shared/proptypes/masterCourseData'
import propTypes from '../propTypes'
@ -60,7 +70,7 @@ import SectionsTooltip from '../../shared/SectionsTooltip'
import select from '../../shared/select'
import ToggleIcon from '../../shared/components/ToggleIcon'
import UnreadBadge from '../../shared/components/UnreadBadge'
import { makeTimestamp } from '../../shared/date-utils'
import { isPassedDelayedPostAt } from '../../shared/date-utils'
const dragTarget = {
beginDrag (props) {
@ -111,6 +121,9 @@ export class DiscussionRow extends Component {
displayDuplicateMenuItem: bool.isRequired,
displayLockMenuItem: bool.isRequired,
displayMasteryPathsMenuItem: bool,
displayMasteryPathsLink: bool,
displayMasteryPathsPill:bool,
masteryPathsPillLabel: string, // required if displayMasteryPathsPill is true
displayManageMenu: bool.isRequired,
displayPinMenuItem: bool.isRequired,
draggable: bool,
@ -134,12 +147,42 @@ export class DiscussionRow extends Component {
isDragging: false,
masterCourseData: null,
displayMasteryPathsMenuItem: false,
displayMasteryPathsLink: false,
displayMasteryPathsPill: false,
masteryPathsPillLabel: "",
moveCard: () => {},
onMoveDiscussion: null,
onSelectedChanged () {},
rowRef () {},
}
componentDidMount = () => {
this.onFocusManage(this.props)
}
componentWillReceiveProps = (nextProps) => {
this.onFocusManage(nextProps)
}
// TODO: Move this to a common file so announcements can use this also.
onFocusManage = (props) => {
if (props.discussion.focusOn) {
switch (props.discussion.focusOn) {
case 'title':
this._titleElement.focus()
break;
case 'manageMenu':
this._manageMenu.focus()
break;
case 'toggleButton':
break;
default:
throw new Error(I18n.t('Illegal element focus request'))
}
this.props.cleanDiscussionFocus()
}
}
onManageDiscussion = (e, { action, id, menuTool }) => {
switch (action) {
case 'duplicate':
@ -244,8 +287,7 @@ export class DiscussionRow extends Component {
}
onToggleOn={() => this.props.updateDiscussion(this.props.discussion, {published: true}, {})}
onToggleOff={() => this.props.updateDiscussion(this.props.discussion, {published: false}, {})}
className="publish-button"
/>)
className="publish-button"/>)
: null
)
@ -388,17 +430,225 @@ export class DiscussionRow extends Component {
return menuList
}
renderDragHandleIfAppropriate = () => {
if (this.props.draggable && this.props.connectDragSource) {
return (
<div className="ic-item-row__drag-col">
<span>
<Text color="secondary" size="large">
<IconDragHandleLine />
</Text>
</span>
</div>
)
} else {
return null
}
}
renderSectionsTooltip = () => {
if (this.props.contextType === "group" || this.props.discussion.assignment ||
this.props.discussion.group_category_id) {
this.props.discussion.group_category_id) {
return null
}
return (
<SectionsTooltip
totalUserCount={this.props.discussion.user_count}
sections={this.props.discussion.sections}
/>
<GridRow vAlign="middle">
<GridCol textAlign="start">
<SectionsTooltip
totalUserCount={this.props.discussion.user_count}
sections={this.props.discussion.sections}
/>
</GridCol>
</GridRow>
)
}
renderTitle = () => {
const refFn = (c) => { this._titleElement = c }
const linkUrl = this.props.discussion.html_url
return (
<div className="ic-item-row__content-col">
<Heading level="h3" margin="0">
<a style={{color:"inherit"}} className="discussion-title" ref={refFn} href={linkUrl}>
{this.props.discussion.title}
</a>
</Heading>
</div>
)
}
renderLastReplyAt = () => {
const datetimeString = $.datetimeString(this.props.discussion.last_reply_at)
return (
<div className="ic-item-row__content-col ic-discussion-row__content last-reply-at">
{ I18n.t('Last post at %{date}', { date: datetimeString }) }
</div>
)
}
renderDueDate = () => {
const assignment = this.props.discussion.assignment // eslint-disable-line
const dueDateString = assignment && assignment.due_at
? I18n.t('Due %{date}', { date: $.datetimeString(assignment.due_at) })
: null
return (
<div className="ic-discussion-row__content">
{ dueDateString }
</div>
)
}
renderAvailabilityDate = () => {
const availabilityBegin = this.props.discussion.delayed_post_at
const availabilityEnd = this.props.discussion.lock_at
// Check if we are too early for the topic to be available
if (availabilityBegin && !isPassedDelayedPostAt({ checkDate: null, delayedDate: availabilityBegin })) {
return (
<div className="discussion-delayed-until ic-item-row__content-col ic-discussion-row__content">
{ I18n.t('Not available until %{date}',
{date: $.datetimeString(availabilityBegin)})}
</div>
)
}
if (availabilityEnd) {
if (isPassedDelayedPostAt({ checkDate: null, delayedDate: availabilityEnd })) {
return (
<div className="discussion-was-locked ic-item-row__content-col ic-discussion-row__content">
{I18n.t('Was locked at %{date}',
{date: $.datetimeString(availabilityEnd)})}
</div>
)
} else {
return (
<div className="discussion-available-until ic-item-row__content-col ic-discussion-row__content">
{I18n.t('Available until %{date}',
{date: $.datetimeString(availabilityEnd)})}
</div>
)
}
}
return null
}
unmountMasterCourseLock = () => {
if (this.masterCourseLock) {
this.masterCourseLock.remove()
this.masterCourseLock = null
}
}
initializeMasterCourseIcon = (container) => {
const masterCourse = {
courseData: this.props.masterCourseData || {},
getLockOptions: () => ({
model: new DiscussionModel(this.props.discussion),
unlockedText: I18n.t('%{title} is unlocked. Click to lock.', {title: this.props.discussion.title}),
lockedText: I18n.t('%{title} is locked. Click to unlock', {title: this.props.discussion.title}),
course_id: this.props.masterCourseData.masterCourse.id,
content_id: this.props.discussion.id,
content_type: 'discussion_topic',
}),
}
const { courseData = {}, getLockOptions } = masterCourse || {}
if (container && (courseData.isMasterCourse || courseData.isChildCourse)) {
this.unmountMasterCourseLock()
const opts = getLockOptions()
// initialize master course lock icon, which is a Backbone view
// I know, I know, backbone in react is grosssss but wachagunnado
this.masterCourseLock = new LockIconView({ ...opts, el: container })
this.masterCourseLock.render()
}
}
renderUpperRightBadges = () => {
const assignment = this.props.discussion.assignment // eslint-disable-line
const peerReview = assignment ? assignment.peer_reviews : false
const maybeRenderPeerReviewIcon = peerReview ? (
<span className="ic-item-row__peer_review">
<Text color="success" size="medium">
<IconPeerReviewLine />
</Text>
</span>
) : null
const maybeDisplayManageMenu = this.props.displayManageMenu ? (
<span display="inline-block">
<DiscussionManageMenu
menuRefFn = {(c) => {this._manageMenu = c }}
onSelect={this.onManageDiscussion}
entityTitle={this.props.discussion.title}
menuOptions={this.renderMenuList} />
</span>
) : null
const returnTo = encodeURIComponent(window.location.pathname)
const discussionId = this.props.discussion.id
const maybeRenderMasteryPathsPill = this.props.displayMasteryPathsPill ? (
<span display="inline-block" className="discussion-row-mastery-paths-pill">
<Pill text={this.props.masteryPathsPillLabel} />
</span>
) : null
const maybeRenderMasteryPathsLink = this.props.displayMasteryPathsLink ? (
<a href={`discussion_topics/${discussionId}/edit?return_to=${returnTo}#mastery-paths-editor`}
className="discussion-index-mastery-paths-link">
{I18n.t('Mastery Paths')}
</a>
) : null
const actionsContent = [this.readCount(), this.publishButton(), this.subscribeButton()]
return (
<div>
<div className="ic-item-row__meta-actions">
{maybeRenderMasteryPathsPill}
{maybeRenderMasteryPathsLink}
{maybeRenderPeerReviewIcon}
{actionsContent}
<span ref={this.initializeMasterCourseIcon} className="ic-item-row__master-course-lock" />
{maybeDisplayManageMenu}
</div>
</div>
)
}
renderDiscussion = () => {
const classes = cx('ic-item-row')
return (
this.props.connectDropTarget(this.props.connectDragSource(
<div style={{ opacity: (this.props.isDragging) ? 0 : 1 }} className={`${classes} ic-discussion-row`}>
<Flex width="100%">
<FlexItem shrink padding="xx-small">
{this.renderDragHandleIfAppropriate()}
</FlexItem>
<FlexItem shrink padding="xx-small">
{this.renderIcon()}
</FlexItem>
<FlexItem padding="xx-small" grow shrink>
<Grid startAt="medium" vAlign="middle" rowSpacing="none" colSpacing="none">
<GridRow vAlign="middle">
<GridCol textAlign="start">
{this.renderTitle()}
</GridCol>
<GridCol textAlign="end">
{this.renderUpperRightBadges()}
</GridCol>
</GridRow>
{this.renderSectionsTooltip()} {/* This is wrapped in a GridRow if present */}
<GridRow>
<GridCol textAlign="start">
{this.renderLastReplyAt()}
</GridCol>
<GridCol textAlign="center">
{this.renderAvailabilityDate()}
</GridCol>
<GridCol textAlign="end">
{this.renderDueDate()}
</GridCol>
</GridRow>
</Grid>
</FlexItem>
</Flex>
</div>, {dropEffect: 'copy'}
))
)
}
@ -423,62 +673,7 @@ export class DiscussionRow extends Component {
</Container>}
</GridCol>
<GridCol>
<CourseItemRow
ref={this.props.rowRef}
className="ic-discussion-row"
key={this.props.discussion.id}
id={this.props.discussion.id}
isDragging={this.props.isDragging}
focusOn={this.props.discussion.focusOn}
draggable={this.props.draggable}
connectDragSource={this.props.connectDragSource}
connectDropTarget={this.props.connectDropTarget}
icon={this.renderIcon() }
isRead
author={this.props.discussion.author}
title={this.props.discussion.title}
body={textContent ? <div className="ic-discussion-row__content">{textContent}</div> : null}
sectionToolTip={this.renderSectionsTooltip()}
itemUrl={this.props.discussion.html_url}
onSelectedChanged={this.props.onSelectedChanged}
peerReview={this.props.discussion.assignment ? this.props.discussion.assignment.peer_reviews : false}
showManageMenu={this.props.displayManageMenu}
onManageMenuSelect={this.onManageDiscussion}
clearFocusDirectives={this.props.cleanDiscussionFocus}
manageMenuOptions={this.renderMenuList}
masterCourse={{
courseData: this.props.masterCourseData || {},
getLockOptions: () => ({
model: new DiscussionModel(this.props.discussion),
unlockedText: I18n.t('%{title} is unlocked. Click to lock.', {title: this.props.discussion.title}),
lockedText: I18n.t('%{title} is locked. Click to unlock', {title: this.props.discussion.title}),
course_id: this.props.masterCourseData.masterCourse.id,
content_id: this.props.discussion.id,
content_type: 'discussion_topic',
}),
}}
metaContent={
<div>
<span className="ic-item-row__meta-content-heading">
<Text size="small" as="p">{
makeTimestamp(this.props.discussion,
delayedLabel,
postedAtLabel
).title
}</Text>
</span>
<Text color="secondary" size="small" as="p">
{$.datetimeString(
makeTimestamp(this.props.discussion,
delayedLabel,
postedAtLabel
).date, {format: 'medium'}
)}
</Text>
</div>
}
actionsContent={[this.readCount(), this.publishButton(), this.subscribeButton()]}
/>
{this.renderDiscussion()}
</GridCol>
</GridRow>
</Grid>
@ -500,6 +695,8 @@ const mapDispatch = (dispatch) => {
const mapState = (state, ownProps) => {
const { discussion } = ownProps
const cyoe = CyoeHelper.getItemData(discussion.assignment_id)
const shouldShowMasteryPathsPill = cyoe.isReleased && cyoe.releasedLabel &&
(cyoe.releasedLabel !== "") && discussion.permissions.update
const propsFromState = {
canPublish: state.permissions.publish,
contextType: state.contextType,
@ -508,6 +705,9 @@ const mapState = (state, ownProps) => {
displayDuplicateMenuItem: state.permissions.manage_content,
displayLockMenuItem: discussion.can_lock,
displayMasteryPathsMenuItem: cyoe.isCyoeAble,
displayMasteryPathsLink: cyoe.isTrigger && discussion.permissions.update,
displayMasteryPathsPill: shouldShowMasteryPathsPill,
masteryPathsPillLabel: cyoe.releasedLabel,
displayManageMenu: discussion.permissions.delete,
displayPinMenuItem: state.permissions.moderate,
masterCourseData: state.masterCourseData,

View File

@ -16,6 +16,9 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
// TODO: Get rid of this component. AnnouncementRow should manage its own layout
// with the shared utilities created in g/something.
import I18n from 'i18n!shared_components'
import React, { Component } from 'react'
import { bool, node, string, func, shape, arrayOf, oneOf } from 'prop-types'

View File

@ -0,0 +1,71 @@
/*
* 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 I18n from 'i18n!shared_components'
import React, { Component } from 'react'
import { func, string } from 'prop-types'
import Button from '@instructure/ui-core/lib/components/Button'
import IconMore from 'instructure-icons/lib/Line/IconMoreLine'
import PopoverMenu from '@instructure/ui-core/lib/components/PopoverMenu'
import ScreenReaderContent from '@instructure/ui-core/lib/components/ScreenReaderContent'
export default class DiscussionManageMenu extends Component {
static propTypes = {
onSelect: func.isRequired,
// This should be a *function* that returns an array of MenuList
// components; this way we don't actually create a gargantuan array
// of things until we actually need them (i.e. when this menu is
// clicked). This somewhat benefits performance in discussions, where
// we might have hundreds on the page.
menuOptions: func.isRequired,
entityTitle: string.isRequired,
// Use this if you want the calling component to have a handle to this menu
menuRefFn: func
}
static defaultProps = {
menuRefFn: (_) => {}
}
state = {
manageMenuOpen: false
}
toggleManageMenuOpen = (shown, _) => {
this.setState({ manageMenuOpen: shown })
}
render () {
return (
<span className="ic-item-row__manage-menu">
<PopoverMenu
ref={this.props.menuRefFn}
onSelect={this.props.onSelect}
onToggle={this.toggleManageMenuOpen}
trigger={
<Button variant="icon" size="small">
<IconMore />
<ScreenReaderContent>{I18n.t('Manage options for %{name}', { name: this.props.entityTitle })}</ScreenReaderContent>
</Button>
}>
{this.state.manageMenuOpen ? this.props.menuOptions() : null}
</PopoverMenu>
</span>
)
}
}

View File

@ -26,7 +26,7 @@ export default function ToggleIcon ({ toggled, OnIcon, OffIcon, onToggleOn,
return (
<span className={className}>
<Button
variant="icon"
variant="ghost"
size="small"
theme={{borderWidth: "0"}}
disabled={disabled}

View File

@ -188,3 +188,24 @@
margin: direction-sides(0 -8px -8px -10px);
transform: translateY(-5px);
}
.discussion-title {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
text-decoration: none;
}
@include ic-focus-base;
&:focus {
@include ic-focus-variant;
}
}
// TODO: We should try to not need this CSS
.discussion-row-mastery-paths-pill {
margin-#{direction(left)}: 6px;
margin-#{direction(right)}: 6px;
margin-top: -4px;
}

View File

@ -44,6 +44,7 @@ const makeProps = (props = {}) => _.merge({
locked: false,
html_url: '',
user_count: 10,
last_reply_at: new Date(2018, 1, 14, 0, 0, 0, 0)
},
canPublish: false,
masterCourseData: {},
@ -134,19 +135,57 @@ test('renders the publish ToggleIcon', () => {
})
test('renders "Delayed until" date label if discussion is delayed', () => {
const discussion = { delayed_post_at: (new Date).toString() }
const delayedDate = new Date
delayedDate.setYear(delayedDate.getFullYear() + 1)
const discussion = { delayed_post_at: delayedDate.toString() }
const tree = mount(<DiscussionRow {...makeProps({ discussion })} />)
const node = tree.find('.ic-item-row__meta-content-heading')
const node = tree.find('.discussion-delayed-until')
ok(node.exists())
})
test('renders "Posted on" date label if discussion is not delayed', () => {
const discussion = { delayed_post_at: null }
const tree = mount(<DiscussionRow {...makeProps({ discussion })} />)
const node = tree.find('.ic-item-row__meta-content-heading')
ok(node.text().includes('Posted on'))
test('renders a last reply at date', () => {
const tree = mount(<DiscussionRow {...makeProps()} />)
const node = tree.find('.last-reply-at')
ok(node.exists())
ok(node.text().includes('Last post at'))
ok(node.text().includes('Feb'))
})
test('renders available until if approprate', () => {
const futureDate = new Date
futureDate.setYear(futureDate.getFullYear() + 1)
const discussion = { lock_at: futureDate }
const tree = mount(<DiscussionRow {...makeProps({ discussion })} />)
const node = tree.find('.discussion-available-until')
ok(node.exists())
ok(node.text().includes('Available until'))
// We need a relative date to ensure future-ness, so we can't really insist
// on a given date element appearing this time
})
test('renders locked at if appropriate', () => {
const pastDate = new Date
pastDate.setYear(pastDate.getFullYear() - 1)
const discussion = { lock_at: pastDate }
const tree = mount(<DiscussionRow {...makeProps({ discussion })} />)
const node = tree.find('.discussion-was-locked')
ok(node.exists())
ok(node.text().includes('Was locked at'))
// We need a relative date to ensure past-ness, so we can't really insist
// on a given date element appearing this time
})
test('renders nothing if currently available and no end date', () => {
const tree = mount(<DiscussionRow {...makeProps()} />)
let node = tree.find('.discussion-available-until')
notOk(node.exists())
node = tree.find('.discussion-delayed-until')
notOk(node.exists())
node = tree.find('.discussion-was-locked')
notOk(node.exists())
})
test('renders the SectionsTooltip component', () => {
const discussion = { user_count: 200 }
const tree = mount(<DiscussionRow {...makeProps({ discussion })} />)
@ -162,14 +201,14 @@ test('renders the SectionsTooltip component with sections', () => {
equal(tree.find('SectionsTooltip Text').text(), '2 Sectionssection 4section 2')
})
test('does not renders the SectionsTooltip component on a graded discussion', () => {
test('does not render the SectionsTooltip component on a graded discussion', () => {
const discussion = { user_count: 200, assignment: true }
const tree = mount(<DiscussionRow {...makeProps({ discussion })} />)
const node = tree.find('SectionsTooltip')
notOk(node.exists())
})
test('does not renders the SectionsTooltip component on a group discussion', () => {
test('does not render the SectionsTooltip component on a group discussion', () => {
const discussion = { user_count: 200, group_category_id: 13 }
const tree = mount(<DiscussionRow {...makeProps({ discussion })} />)
const node = tree.find('SectionsTooltip')
@ -181,26 +220,27 @@ test('does not renders the SectionsTooltip component within a group context', ()
const tree = mount(<DiscussionRow {...makeProps({ discussion, contextType: "group" })} />)
const node = tree.find('SectionsTooltip')
notOk(node.exists())
})
test('does not render master course lock icon if masterCourseData is not provided', (assert) => {
const done = assert.async()
const masterCourseData = null
const rowRef = (row) => {
notOk(row.masterCourseLock)
done()
}
mount(<DiscussionRow {...makeProps({ masterCourseData, rowRef })} />)
const tree = mount(<DiscussionRow {...makeProps({ masterCourseData, rowRef })} />)
notOk(tree.instance().masterCourseLock)
})
test('renders master course lock icon if masterCourseData is provided', (assert) => {
const done = assert.async()
const masterCourseData = { isMasterCourse: true, masterCourse: { id: '1' } }
const rowRef = (row) => {
ok(row.masterCourseLock)
done()
}
mount(<DiscussionRow {...makeProps({ masterCourseData, rowRef })} />)
const tree = mount(<DiscussionRow {...makeProps({ masterCourseData, rowRef })} />)
ok(tree.instance().masterCourseLock)
})
test('renders drag icon', () => {
@ -209,23 +249,9 @@ test('renders drag icon', () => {
ok(node.exists())
})
test('removes non-text content from discussion message', () => {
const messageHtml = `
<p>Hello World!</p>
<img src="/images/stuff/things.png" />
<p>foo bar</p>
`
const tree = mount(<DiscussionRow {...makeProps({ discussion: { message: messageHtml } })} />)
const node = tree.find('.ic-discussion-row__content').getDOMNode()
equal(node.childNodes.length, 1)
equal(node.childNodes[0].nodeType, 3) // nodeType === 3 is text node type
ok(node.textContent.includes('Hello World!'))
ok(node.textContent.includes('foo bar'))
})
test('does not render manage menu if not permitted', () => {
const tree = mount(<DiscussionRow {...makeProps({ displayManageMenu: false })} />)
const node = tree.find('PopoverMenu')
const node = tree.find('DiscussionManageMenu')
notOk(node.exists())
})
@ -235,7 +261,7 @@ test('does not insert the manage menu list if we have not clicked it yet', () =>
onMoveDiscussion: ()=>{}
})} />)
// We still should show the menu thingy itself
const menuNode = tree.find('PopoverMenu')
const menuNode = tree.find('DiscussionManageMenu')
ok(menuNode.exists())
// We have to search the whole document because the items in instui
// popover menu are appended to the end of the document rather than
@ -249,7 +275,7 @@ test('manage menu items do appear upon click', () => {
displayManageMenu: true,
onMoveDiscussion: ()=>{}
})} />)
const menuNode = tree.find('PopoverMenu')
const menuNode = tree.find('DiscussionManageMenu')
ok(menuNode.exists())
menuNode.find('button').simulate('click')
// We have to search the whole document because the items in instui
@ -264,8 +290,8 @@ test('renders move-to in manage menu if permitted', () => {
displayManageMenu: true,
onMoveDiscussion: ()=>{}
})} />)
const courseItemRow = tree.find('CourseItemRow')
const allKeys = courseItemRow.props().manageMenuOptions().map((option) => option.key)
const manageMenu = tree.find('DiscussionManageMenu')
const allKeys = manageMenu.props().menuOptions().map((option) => option.key)
equal(allKeys.length, 1)
equal(allKeys[0], 'moveTo')
})
@ -275,8 +301,8 @@ test('renders pin item in manage menu if permitted', () => {
displayManageMenu: true,
displayPinMenuItem: true
})} />)
const courseItemRow = tree.find('CourseItemRow')
const allKeys = courseItemRow.props().manageMenuOptions().map((option) => option.key)
const manageMenu = tree.find('DiscussionManageMenu')
const allKeys = manageMenu.props().menuOptions().map((option) => option.key)
equal(allKeys.length, 1)
equal(allKeys[0], 'togglepinned')
})
@ -286,8 +312,8 @@ test('renders duplicate item in manage menu if permitted', () => {
displayManageMenu: true,
displayDuplicateMenuItem: true
})} />)
const courseItemRow = tree.find('CourseItemRow')
const allKeys = courseItemRow.props().manageMenuOptions().map((option) => option.key)
const manageMenu = tree.find('DiscussionManageMenu')
const allKeys = manageMenu.props().menuOptions().map((option) => option.key)
equal(allKeys.length, 1)
equal(allKeys[0], 'duplicate')
})
@ -297,8 +323,8 @@ test('renders delete item in manage menu if permitted', () => {
displayManageMenu: true,
displayDeleteMenuItem: true
})} />)
const courseItemRow = tree.find('CourseItemRow')
const allKeys = courseItemRow.props().manageMenuOptions().map((option) => option.key)
const manageMenu = tree.find('DiscussionManageMenu')
const allKeys = manageMenu.props().menuOptions().map((option) => option.key)
equal(allKeys.length, 1)
equal(allKeys[0], 'delete')
})
@ -308,27 +334,42 @@ test('renders lock item in manage menu if permitted', () => {
displayManageMenu: true,
displayLockMenuItem: true
})} />)
const courseItemRow = tree.find('CourseItemRow')
const allKeys = courseItemRow.props().manageMenuOptions().map((option) => option.key)
const manageMenu = tree.find('DiscussionManageMenu')
const allKeys = manageMenu.props().menuOptions().map((option) => option.key)
equal(allKeys.length, 1)
equal(allKeys[0], 'togglelocked')
})
test('renders mastery paths menu item if permitted', () => {
const tree=mount(<DiscussionRow {...makeProps({
displayManageMenu: true,
discussion: {
assignment_id: 2
},
displayMasteryPathsMenuItem: true
})} />)
const courseItemRow = tree.find('CourseItemRow')
const allKeys = courseItemRow.props().manageMenuOptions().map((option) => option.key)
const manageMenu = tree.find('DiscussionManageMenu')
const allKeys = manageMenu.props().menuOptions().map((option) => option.key)
equal(allKeys.length, 1)
equal(allKeys[0], 'masterypaths')
})
test('renders mastery paths link if permitted', () => {
const tree=mount(<DiscussionRow {...makeProps({
displayManageMenu: true,
discussion: {
assignment_id: 2
},
displayMasteryPathsLink: true
})} />)
const node = tree.find('.discussion-index-mastery-paths-link')
ok(node.exists())
ok(node.text().includes('Mastery Paths'))
})
test('renders ltiTool menu if there are some', () => {
const tree=mount(<DiscussionRow {...makeProps({
displayManageMenu: true,
discussionTopicMenuTools:[{
base_url: "test.com",
canvas_icon_class: "icon-lti",
@ -336,14 +377,15 @@ test('renders ltiTool menu if there are some', () => {
title: "discussion_topic_menu Text",
}]
})} />)
const courseItemRow = tree.find('CourseItemRow')
const allKeys = courseItemRow.props().manageMenuOptions().map((option) => option.key)
const manageMenu = tree.find('DiscussionManageMenu')
const allKeys = manageMenu.props().menuOptions().map((option) => option.key)
equal(allKeys.length, 1)
equal(allKeys[0], 'test.com')
})
test('renders multiple ltiTool menu if there are multiple', () => {
const tree=mount(<DiscussionRow {...makeProps({
displayManageMenu: true,
discussionTopicMenuTools:[
{
base_url: "test.com",
@ -359,8 +401,8 @@ test('renders multiple ltiTool menu if there are multiple', () => {
}
]
})} />)
const courseItemRow = tree.find('CourseItemRow')
const allKeys = courseItemRow.props().manageMenuOptions().map((option) => option.key)
const manageMenu = tree.find('DiscussionManageMenu')
const allKeys = manageMenu.props().menuOptions().map((option) => option.key)
equal(allKeys.length, 2)
equal(allKeys[1], 'test2.com')
})

View File

@ -83,7 +83,7 @@ class DiscussionsIndex
end
def discussion_title(title)
f('h3', discussion(title))
f('a', discussion(title))
end
def discussion_sections(title)