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 { DragSource, DropTarget } from 'react-dnd';
import { findDOMNode } from 'react-dom' import { findDOMNode } from 'react-dom'
import { func, bool, string, arrayOf } from 'prop-types' import { func, bool, string, arrayOf } from 'prop-types'
import cx from 'classnames'
import $ from 'jquery' import $ from 'jquery'
import 'jquery.instructure_date_and_time' import 'jquery.instructure_date_and_time'
import Badge from '@instructure/ui-core/lib/components/Badge' import Badge from '@instructure/ui-core/lib/components/Badge'
import Container from '@instructure/ui-core/lib/components/Container' 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 IconAssignmentLine from 'instructure-icons/lib/Line/IconAssignmentLine'
import IconBookmarkLine from 'instructure-icons/lib/Line/IconBookmarkLine' import IconBookmarkLine from 'instructure-icons/lib/Line/IconBookmarkLine'
import IconBookmarkSolid from 'instructure-icons/lib/Solid/IconBookmarkSolid' import IconBookmarkSolid from 'instructure-icons/lib/Solid/IconBookmarkSolid'
import IconCopySolid from 'instructure-icons/lib/Solid/IconCopySolid' 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 IconLock from 'instructure-icons/lib/Line/IconLockLine'
import IconLtiLine from 'instructure-icons/lib/Line/IconLtiLine' 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 IconPinLine from 'instructure-icons/lib/Line/IconPinLine'
import IconPinSolid from 'instructure-icons/lib/Solid/IconPinSolid' import IconPinSolid from 'instructure-icons/lib/Solid/IconPinSolid'
import IconPublishSolid from 'instructure-icons/lib/Solid/IconPublishSolid' 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 IconUnlock from 'instructure-icons/lib/Line/IconUnlockLine'
import IconUnpublishedLine from 'instructure-icons/lib/Line/IconUnpublishedLine' import IconUnpublishedLine from 'instructure-icons/lib/Line/IconUnpublishedLine'
import IconUpdownLine from 'instructure-icons/lib/Line/IconUpdownLine' 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 ScreenReaderContent from '@instructure/ui-core/lib/components/ScreenReaderContent'
import Text from '@instructure/ui-core/lib/components/Text' import Text from '@instructure/ui-core/lib/components/Text'
import { MenuItem } from '@instructure/ui-core/lib/components/Menu' import { MenuItem } from '@instructure/ui-core/lib/components/Menu'
import DiscussionModel from 'compiled/models/DiscussionTopic' import DiscussionModel from 'compiled/models/DiscussionTopic'
import LockIconView from 'compiled/views/LockIconView'
import actions from '../actions' import actions from '../actions'
import compose from '../../shared/helpers/compose' import compose from '../../shared/helpers/compose'
import CourseItemRow from '../../shared/components/CourseItemRow' import CourseItemRow from '../../shared/components/CourseItemRow'
import CyoeHelper from '../../shared/conditional_release/CyoeHelper' import CyoeHelper from '../../shared/conditional_release/CyoeHelper'
import DiscussionManageMenu from '../../shared/components/DiscussionManageMenu'
import discussionShape from '../../shared/proptypes/discussion' import discussionShape from '../../shared/proptypes/discussion'
import masterCourseDataShape from '../../shared/proptypes/masterCourseData' import masterCourseDataShape from '../../shared/proptypes/masterCourseData'
import propTypes from '../propTypes' import propTypes from '../propTypes'
@ -60,7 +70,7 @@ import SectionsTooltip from '../../shared/SectionsTooltip'
import select from '../../shared/select' import select from '../../shared/select'
import ToggleIcon from '../../shared/components/ToggleIcon' import ToggleIcon from '../../shared/components/ToggleIcon'
import UnreadBadge from '../../shared/components/UnreadBadge' import UnreadBadge from '../../shared/components/UnreadBadge'
import { makeTimestamp } from '../../shared/date-utils' import { isPassedDelayedPostAt } from '../../shared/date-utils'
const dragTarget = { const dragTarget = {
beginDrag (props) { beginDrag (props) {
@ -111,6 +121,9 @@ export class DiscussionRow extends Component {
displayDuplicateMenuItem: bool.isRequired, displayDuplicateMenuItem: bool.isRequired,
displayLockMenuItem: bool.isRequired, displayLockMenuItem: bool.isRequired,
displayMasteryPathsMenuItem: bool, displayMasteryPathsMenuItem: bool,
displayMasteryPathsLink: bool,
displayMasteryPathsPill:bool,
masteryPathsPillLabel: string, // required if displayMasteryPathsPill is true
displayManageMenu: bool.isRequired, displayManageMenu: bool.isRequired,
displayPinMenuItem: bool.isRequired, displayPinMenuItem: bool.isRequired,
draggable: bool, draggable: bool,
@ -134,12 +147,42 @@ export class DiscussionRow extends Component {
isDragging: false, isDragging: false,
masterCourseData: null, masterCourseData: null,
displayMasteryPathsMenuItem: false, displayMasteryPathsMenuItem: false,
displayMasteryPathsLink: false,
displayMasteryPathsPill: false,
masteryPathsPillLabel: "",
moveCard: () => {}, moveCard: () => {},
onMoveDiscussion: null, onMoveDiscussion: null,
onSelectedChanged () {}, onSelectedChanged () {},
rowRef () {}, 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 }) => { onManageDiscussion = (e, { action, id, menuTool }) => {
switch (action) { switch (action) {
case 'duplicate': case 'duplicate':
@ -244,8 +287,7 @@ export class DiscussionRow extends Component {
} }
onToggleOn={() => this.props.updateDiscussion(this.props.discussion, {published: true}, {})} onToggleOn={() => this.props.updateDiscussion(this.props.discussion, {published: true}, {})}
onToggleOff={() => this.props.updateDiscussion(this.props.discussion, {published: false}, {})} onToggleOff={() => this.props.updateDiscussion(this.props.discussion, {published: false}, {})}
className="publish-button" className="publish-button"/>)
/>)
: null : null
) )
@ -388,17 +430,225 @@ export class DiscussionRow extends Component {
return menuList 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 = () => { renderSectionsTooltip = () => {
if (this.props.contextType === "group" || this.props.discussion.assignment || if (this.props.contextType === "group" || this.props.discussion.assignment ||
this.props.discussion.group_category_id) { this.props.discussion.group_category_id) {
return null return null
} }
return ( return (
<SectionsTooltip <GridRow vAlign="middle">
totalUserCount={this.props.discussion.user_count} <GridCol textAlign="start">
sections={this.props.discussion.sections} <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>} </Container>}
</GridCol> </GridCol>
<GridCol> <GridCol>
<CourseItemRow {this.renderDiscussion()}
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()]}
/>
</GridCol> </GridCol>
</GridRow> </GridRow>
</Grid> </Grid>
@ -500,6 +695,8 @@ const mapDispatch = (dispatch) => {
const mapState = (state, ownProps) => { const mapState = (state, ownProps) => {
const { discussion } = ownProps const { discussion } = ownProps
const cyoe = CyoeHelper.getItemData(discussion.assignment_id) const cyoe = CyoeHelper.getItemData(discussion.assignment_id)
const shouldShowMasteryPathsPill = cyoe.isReleased && cyoe.releasedLabel &&
(cyoe.releasedLabel !== "") && discussion.permissions.update
const propsFromState = { const propsFromState = {
canPublish: state.permissions.publish, canPublish: state.permissions.publish,
contextType: state.contextType, contextType: state.contextType,
@ -508,6 +705,9 @@ const mapState = (state, ownProps) => {
displayDuplicateMenuItem: state.permissions.manage_content, displayDuplicateMenuItem: state.permissions.manage_content,
displayLockMenuItem: discussion.can_lock, displayLockMenuItem: discussion.can_lock,
displayMasteryPathsMenuItem: cyoe.isCyoeAble, displayMasteryPathsMenuItem: cyoe.isCyoeAble,
displayMasteryPathsLink: cyoe.isTrigger && discussion.permissions.update,
displayMasteryPathsPill: shouldShowMasteryPathsPill,
masteryPathsPillLabel: cyoe.releasedLabel,
displayManageMenu: discussion.permissions.delete, displayManageMenu: discussion.permissions.delete,
displayPinMenuItem: state.permissions.moderate, displayPinMenuItem: state.permissions.moderate,
masterCourseData: state.masterCourseData, masterCourseData: state.masterCourseData,

View File

@ -16,6 +16,9 @@
* with this program. If not, see <http://www.gnu.org/licenses/>. * 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 I18n from 'i18n!shared_components'
import React, { Component } from 'react' import React, { Component } from 'react'
import { bool, node, string, func, shape, arrayOf, oneOf } from 'prop-types' 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 ( return (
<span className={className}> <span className={className}>
<Button <Button
variant="icon" variant="ghost"
size="small" size="small"
theme={{borderWidth: "0"}} theme={{borderWidth: "0"}}
disabled={disabled} disabled={disabled}

View File

@ -188,3 +188,24 @@
margin: direction-sides(0 -8px -8px -10px); margin: direction-sides(0 -8px -8px -10px);
transform: translateY(-5px); 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, locked: false,
html_url: '', html_url: '',
user_count: 10, user_count: 10,
last_reply_at: new Date(2018, 1, 14, 0, 0, 0, 0)
}, },
canPublish: false, canPublish: false,
masterCourseData: {}, masterCourseData: {},
@ -134,19 +135,57 @@ test('renders the publish ToggleIcon', () => {
}) })
test('renders "Delayed until" date label if discussion is delayed', () => { 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 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()) ok(node.exists())
}) })
test('renders "Posted on" date label if discussion is not delayed', () => { test('renders a last reply at date', () => {
const discussion = { delayed_post_at: null } const tree = mount(<DiscussionRow {...makeProps()} />)
const tree = mount(<DiscussionRow {...makeProps({ discussion })} />) const node = tree.find('.last-reply-at')
const node = tree.find('.ic-item-row__meta-content-heading') ok(node.exists())
ok(node.text().includes('Posted on')) 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', () => { test('renders the SectionsTooltip component', () => {
const discussion = { user_count: 200 } const discussion = { user_count: 200 }
const tree = mount(<DiscussionRow {...makeProps({ discussion })} />) 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') 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 discussion = { user_count: 200, assignment: true }
const tree = mount(<DiscussionRow {...makeProps({ discussion })} />) const tree = mount(<DiscussionRow {...makeProps({ discussion })} />)
const node = tree.find('SectionsTooltip') const node = tree.find('SectionsTooltip')
notOk(node.exists()) 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 discussion = { user_count: 200, group_category_id: 13 }
const tree = mount(<DiscussionRow {...makeProps({ discussion })} />) const tree = mount(<DiscussionRow {...makeProps({ discussion })} />)
const node = tree.find('SectionsTooltip') 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 tree = mount(<DiscussionRow {...makeProps({ discussion, contextType: "group" })} />)
const node = tree.find('SectionsTooltip') const node = tree.find('SectionsTooltip')
notOk(node.exists()) notOk(node.exists())
}) })
test('does not render master course lock icon if masterCourseData is not provided', (assert) => { test('does not render master course lock icon if masterCourseData is not provided', (assert) => {
const done = assert.async()
const masterCourseData = null const masterCourseData = null
const rowRef = (row) => { const rowRef = (row) => {
notOk(row.masterCourseLock) notOk(row.masterCourseLock)
done() 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) => { test('renders master course lock icon if masterCourseData is provided', (assert) => {
const done = assert.async()
const masterCourseData = { isMasterCourse: true, masterCourse: { id: '1' } } const masterCourseData = { isMasterCourse: true, masterCourse: { id: '1' } }
const rowRef = (row) => { const rowRef = (row) => {
ok(row.masterCourseLock) ok(row.masterCourseLock)
done() done()
} }
mount(<DiscussionRow {...makeProps({ masterCourseData, rowRef })} />) const tree = mount(<DiscussionRow {...makeProps({ masterCourseData, rowRef })} />)
ok(tree.instance().masterCourseLock)
}) })
test('renders drag icon', () => { test('renders drag icon', () => {
@ -209,23 +249,9 @@ test('renders drag icon', () => {
ok(node.exists()) 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', () => { test('does not render manage menu if not permitted', () => {
const tree = mount(<DiscussionRow {...makeProps({ displayManageMenu: false })} />) const tree = mount(<DiscussionRow {...makeProps({ displayManageMenu: false })} />)
const node = tree.find('PopoverMenu') const node = tree.find('DiscussionManageMenu')
notOk(node.exists()) notOk(node.exists())
}) })
@ -235,7 +261,7 @@ test('does not insert the manage menu list if we have not clicked it yet', () =>
onMoveDiscussion: ()=>{} onMoveDiscussion: ()=>{}
})} />) })} />)
// We still should show the menu thingy itself // We still should show the menu thingy itself
const menuNode = tree.find('PopoverMenu') const menuNode = tree.find('DiscussionManageMenu')
ok(menuNode.exists()) ok(menuNode.exists())
// We have to search the whole document because the items in instui // We have to search the whole document because the items in instui
// popover menu are appended to the end of the document rather than // 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, displayManageMenu: true,
onMoveDiscussion: ()=>{} onMoveDiscussion: ()=>{}
})} />) })} />)
const menuNode = tree.find('PopoverMenu') const menuNode = tree.find('DiscussionManageMenu')
ok(menuNode.exists()) ok(menuNode.exists())
menuNode.find('button').simulate('click') menuNode.find('button').simulate('click')
// We have to search the whole document because the items in instui // 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, displayManageMenu: true,
onMoveDiscussion: ()=>{} onMoveDiscussion: ()=>{}
})} />) })} />)
const courseItemRow = tree.find('CourseItemRow') const manageMenu = tree.find('DiscussionManageMenu')
const allKeys = courseItemRow.props().manageMenuOptions().map((option) => option.key) const allKeys = manageMenu.props().menuOptions().map((option) => option.key)
equal(allKeys.length, 1) equal(allKeys.length, 1)
equal(allKeys[0], 'moveTo') equal(allKeys[0], 'moveTo')
}) })
@ -275,8 +301,8 @@ test('renders pin item in manage menu if permitted', () => {
displayManageMenu: true, displayManageMenu: true,
displayPinMenuItem: true displayPinMenuItem: true
})} />) })} />)
const courseItemRow = tree.find('CourseItemRow') const manageMenu = tree.find('DiscussionManageMenu')
const allKeys = courseItemRow.props().manageMenuOptions().map((option) => option.key) const allKeys = manageMenu.props().menuOptions().map((option) => option.key)
equal(allKeys.length, 1) equal(allKeys.length, 1)
equal(allKeys[0], 'togglepinned') equal(allKeys[0], 'togglepinned')
}) })
@ -286,8 +312,8 @@ test('renders duplicate item in manage menu if permitted', () => {
displayManageMenu: true, displayManageMenu: true,
displayDuplicateMenuItem: true displayDuplicateMenuItem: true
})} />) })} />)
const courseItemRow = tree.find('CourseItemRow') const manageMenu = tree.find('DiscussionManageMenu')
const allKeys = courseItemRow.props().manageMenuOptions().map((option) => option.key) const allKeys = manageMenu.props().menuOptions().map((option) => option.key)
equal(allKeys.length, 1) equal(allKeys.length, 1)
equal(allKeys[0], 'duplicate') equal(allKeys[0], 'duplicate')
}) })
@ -297,8 +323,8 @@ test('renders delete item in manage menu if permitted', () => {
displayManageMenu: true, displayManageMenu: true,
displayDeleteMenuItem: true displayDeleteMenuItem: true
})} />) })} />)
const courseItemRow = tree.find('CourseItemRow') const manageMenu = tree.find('DiscussionManageMenu')
const allKeys = courseItemRow.props().manageMenuOptions().map((option) => option.key) const allKeys = manageMenu.props().menuOptions().map((option) => option.key)
equal(allKeys.length, 1) equal(allKeys.length, 1)
equal(allKeys[0], 'delete') equal(allKeys[0], 'delete')
}) })
@ -308,27 +334,42 @@ test('renders lock item in manage menu if permitted', () => {
displayManageMenu: true, displayManageMenu: true,
displayLockMenuItem: true displayLockMenuItem: true
})} />) })} />)
const courseItemRow = tree.find('CourseItemRow') const manageMenu = tree.find('DiscussionManageMenu')
const allKeys = courseItemRow.props().manageMenuOptions().map((option) => option.key) const allKeys = manageMenu.props().menuOptions().map((option) => option.key)
equal(allKeys.length, 1) equal(allKeys.length, 1)
equal(allKeys[0], 'togglelocked') equal(allKeys[0], 'togglelocked')
}) })
test('renders mastery paths menu item if permitted', () => { test('renders mastery paths menu item if permitted', () => {
const tree=mount(<DiscussionRow {...makeProps({ const tree=mount(<DiscussionRow {...makeProps({
displayManageMenu: true,
discussion: { discussion: {
assignment_id: 2 assignment_id: 2
}, },
displayMasteryPathsMenuItem: true displayMasteryPathsMenuItem: true
})} />) })} />)
const courseItemRow = tree.find('CourseItemRow') const manageMenu = tree.find('DiscussionManageMenu')
const allKeys = courseItemRow.props().manageMenuOptions().map((option) => option.key) const allKeys = manageMenu.props().menuOptions().map((option) => option.key)
equal(allKeys.length, 1) equal(allKeys.length, 1)
equal(allKeys[0], 'masterypaths') 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', () => { test('renders ltiTool menu if there are some', () => {
const tree=mount(<DiscussionRow {...makeProps({ const tree=mount(<DiscussionRow {...makeProps({
displayManageMenu: true,
discussionTopicMenuTools:[{ discussionTopicMenuTools:[{
base_url: "test.com", base_url: "test.com",
canvas_icon_class: "icon-lti", canvas_icon_class: "icon-lti",
@ -336,14 +377,15 @@ test('renders ltiTool menu if there are some', () => {
title: "discussion_topic_menu Text", title: "discussion_topic_menu Text",
}] }]
})} />) })} />)
const courseItemRow = tree.find('CourseItemRow') const manageMenu = tree.find('DiscussionManageMenu')
const allKeys = courseItemRow.props().manageMenuOptions().map((option) => option.key) const allKeys = manageMenu.props().menuOptions().map((option) => option.key)
equal(allKeys.length, 1) equal(allKeys.length, 1)
equal(allKeys[0], 'test.com') equal(allKeys[0], 'test.com')
}) })
test('renders multiple ltiTool menu if there are multiple', () => { test('renders multiple ltiTool menu if there are multiple', () => {
const tree=mount(<DiscussionRow {...makeProps({ const tree=mount(<DiscussionRow {...makeProps({
displayManageMenu: true,
discussionTopicMenuTools:[ discussionTopicMenuTools:[
{ {
base_url: "test.com", base_url: "test.com",
@ -359,8 +401,8 @@ test('renders multiple ltiTool menu if there are multiple', () => {
} }
] ]
})} />) })} />)
const courseItemRow = tree.find('CourseItemRow') const manageMenu = tree.find('DiscussionManageMenu')
const allKeys = courseItemRow.props().manageMenuOptions().map((option) => option.key) const allKeys = manageMenu.props().menuOptions().map((option) => option.key)
equal(allKeys.length, 2) equal(allKeys.length, 2)
equal(allKeys[1], 'test2.com') equal(allKeys[1], 'test2.com')
}) })

View File

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