add steps component to canvas

fixes COMMS-1724 COMMS-1723

Test Plan:
- Open up assignments 2.0 student view
- notice steps component is integrated and accessible

Change-Id: Ie5a381a74162d8dd68a521bc7d8d8d4a4decea83
Reviewed-on: https://gerrit.instructure.com/172987
Reviewed-by: Landon Gilbert-Bland <lbland@instructure.com>
Reviewed-by: Aaron Hsu <ahsu@instructure.com>
Tested-by: Jenkins
QA-Review: Steven Burnett <sburnett@instructure.com>
Product-Review: Steven Burnett <sburnett@instructure.com>
This commit is contained in:
Steven Burnett 2018-11-19 21:06:23 -07:00
parent c8dd356848
commit 0c0af583e6
11 changed files with 668 additions and 1 deletions

View File

@ -0,0 +1,68 @@
/*
* 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 React from 'react'
import ReactDOM from 'react-dom'
import StepItem from '../index'
import $ from 'jquery'
beforeAll(() => {
const found = document.getElementById('fixtures')
if (!found) {
const fixtures = document.createElement('div')
fixtures.setAttribute('id', 'fixtures')
document.body.appendChild(fixtures)
}
})
afterEach(() => {
ReactDOM.unmountComponentAtNode(document.getElementById('fixtures'))
})
it('should render', async () => {
ReactDOM.render(<StepItem label={() => {}} />, document.getElementById('fixtures'))
const component = $('.step-item-step')
expect(component).toHaveLength(1)
})
it('should render complete status', async () => {
ReactDOM.render(
<StepItem status="complete" label="Test label" />,
document.getElementById('fixtures')
)
const component = $('.step-item-step')
expect(component.hasClass('complete')).toBeTruthy()
})
it('should render in-progress status', async () => {
ReactDOM.render(
<StepItem status="in-progress" label="Test label" />,
document.getElementById('fixtures')
)
const component = $('.step-item-step')
expect(component.hasClass('in-progress')).toBeTruthy()
})
it('should render label correctly', async () => {
ReactDOM.render(
<StepItem status="complete" label={status => `progress 2 ${status}`} />,
document.getElementById('fixtures')
)
const component = $('.step-item-step')
expect(component.text()).toEqual('progress 2 complete')
})

View File

@ -0,0 +1,106 @@
/*
* 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 React, {Component} from 'react'
import PropTypes from 'prop-types'
import IconCheckMark from '@instructure/ui-icons/lib/Solid/IconCheckMark'
import {omitProps} from '@instructure/ui-utils/lib/react/passthroughProps'
import classNames from 'classnames'
import px from '@instructure/ui-utils/lib/px'
class StepItem extends Component {
static propTypes = {
status: PropTypes.oneOf(['complete', 'in-progress']),
label: PropTypes.oneOfType([PropTypes.func, PropTypes.string]).isRequired,
icon: PropTypes.element,
pinSize: PropTypes.string,
placement: PropTypes.oneOf(['first', 'last', 'interior'])
}
static defaultProps = {
placement: 'interior'
}
renderIcon() {
const Icon = this.props.icon
if (!Icon && this.props.status === 'complete') {
return <IconCheckMark color="primary-inverse" />
} else if (typeof this.props.icon === 'function') {
return <Icon />
} else if (Icon) {
return Icon
} else {
return null
}
}
pinSize = () => {
if (this.props.status === 'complete') {
return Math.round(px(this.props.pinSize) / 1.5)
} else if (this.props.status === 'in-progress') {
return px(this.props.pinSize)
} else {
return Math.round(px(this.props.pinSize) / 2.25)
}
}
renderLabel = () => {
const {label, status} = this.props
if (typeof label === 'function') {
return label(status)
} else {
return label
}
}
render() {
const {status, placement} = this.props
const classes = {
'step-item-step': true,
[status]: true,
[`placement--${placement}`]: true
}
return (
<span className={classNames(classes)} {...omitProps(this.props, StepItem.propTypes)}>
<span
className="pinLayout"
style={{
height: px(this.props.pinSize)
}}
>
<span
aria-hidden="true"
style={{
width: `${this.pinSize()}px`,
height: `${this.pinSize()}px`
}}
className="step-item-pin"
>
{this.renderIcon()}
</span>
</span>
<span className="step-item-label">{this.renderLabel()}</span>
</span>
)
}
}
export default StepItem

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 React from 'react'
import ReactDOM from 'react-dom'
import Steps from '../index'
import StepItem from '../StepItem'
import $ from 'jquery'
beforeAll(() => {
const found = document.getElementById('fixtures')
if (!found) {
const fixtures = document.createElement('div')
fixtures.setAttribute('id', 'fixtures')
document.body.appendChild(fixtures)
}
})
afterEach(() => {
ReactDOM.unmountComponentAtNode(document.getElementById('fixtures'))
})
it('should render', async () => {
ReactDOM.render(<Steps />, document.getElementById('fixtures'))
const element = $('[data-test-id="assignment-2-step-index"]')
expect(element).toHaveLength(1)
})
it('should render with StepItems', async () => {
ReactDOM.render(
<Steps label="Settings">
<StepItem label={status => `Phase one ${status}`} status="complete" />
<StepItem label={status => `Phase two ${status}`} status="in-progress" />
<StepItem label="Phase three" />
</Steps>,
document.getElementById('fixtures')
)
const element = $('li')
expect(element).toHaveLength(3)
})
it('should render aria-current for the item that is in progress', async () => {
ReactDOM.render(
<Steps label="Settings">
<StepItem label={status => `Phase one ${status}`} status="complete" />
<StepItem label={status => `Phase two ${status}`} status="in-progress" />
<StepItem label="Phase three" />
</Steps>,
document.getElementById('fixtures')
)
const items = $('li')
expect(items[0].getAttribute('aria-current')).toEqual('false')
expect(items[1].getAttribute('aria-current')).toEqual('true')
expect(items[2].getAttribute('aria-current')).toEqual('false')
})

View File

@ -0,0 +1,122 @@
/*
* 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 React, {Component} from 'react'
import View from '@instructure/ui-layout/lib/components/View'
import {omitProps} from '@instructure/ui-utils/lib/react/passthroughProps'
import safeCloneElement from '@instructure/ui-utils/lib/react/safeCloneElement'
class Steps extends Component {
static propTypes = {
children: props => {
const inProgressArr = []
for (const child in props.children) {
if (!props.children[child]) {
continue
}
if (props.children[child].props.status === 'in-progress') {
inProgressArr.push(props.children[child])
}
if (props.children[child].type.displayName !== 'StepItem') {
new Error("Warning Step has children that aren't StepItem components")
}
}
if (inProgressArr.length > 1) {
new Error('Warning: Step has two StepItems with a status of in-progress')
}
}
}
static findInProgressChild(element) {
return element.props.status === 'in-progress'
}
calculateProgressionScale = children => {
const inProgressIndex = children.findIndex(Steps.findInProgressChild)
if (inProgressIndex !== -1) {
const successProgresssionX = inProgressIndex / (children.length - 1)
return successProgresssionX
} else {
let completeIndex = 0
for (let i = children.length - 1; i !== 0; i--) {
if (children[i].props.status === 'complete') {
completeIndex = i
break
}
}
return completeIndex / (children.length - 1)
}
}
handlePlacement(numSteps, index) {
const step = index + 1
if (step === 1) {
return 'first'
} else if (step === numSteps) {
return 'last'
} else {
return 'interior'
}
}
render() {
let progressionScale = 0
let filteredChildren
if (this.props.children) {
filteredChildren = this.props.children.filter(prop => prop !== null)
progressionScale = this.calculateProgressionScale(filteredChildren)
}
return (
<View
{...omitProps(this.props, {...Steps.propTypes, ...View.propTypes})}
margin={this.props.margin}
data-test-id="assignment-2-step-index"
as="div"
>
<div className="progressionContainer" aria-hidden="true">
<span className="progression" />
<span
style={{transform: `scaleX(${progressionScale})`}}
className="completeProgression"
/>
</div>
<ol className="steps">
{React.Children.map(filteredChildren, (child, index) => (
<li
className="step"
aria-current={child.props.status === 'in-progress' ? 'true' : 'false'}
>
{safeCloneElement(child, {
pinSize: '32px',
placement: this.handlePlacement(filteredChildren.length, index)
})}
</li>
))}
</ol>
</View>
)
}
}
export default Steps

View File

@ -0,0 +1,51 @@
/*
* 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!assignments_2_student_header_date_title'
import React from 'react'
import Steps from '../../shared/Steps'
import StepItem from '../../shared/Steps/StepItem'
function StepContainer() {
return (
<div className="steps-container">
<Steps>
<StepItem label={I18n.t('Avaible: ')} status="complete" />
<StepItem
status="in-progress"
label={status =>
status && status !== 'in-progress' ? I18n.t('Uploaded') : I18n.t('Upload')
}
/>
<StepItem
label={status =>
status && status !== 'in-progress' ? I18n.t('Submitted') : I18n.t('Submit')
}
/>
<StepItem
label={status =>
status && status !== 'in-progress' ? I18n.t('Graded') : I18n.t('Not Graded')
}
/>
</Steps>
</div>
)
}
export default React.memo(StepContainer)

View File

@ -22,6 +22,7 @@ import Flex, {FlexItem} from '@instructure/ui-layout/lib/components/Flex'
import AssignmentGroupModuleNav from './AssignmentGroupModuleNav'
import StudentDateTitle from './StudentDateTitle'
import PointsDisplay from './PointsDisplay'
import StepContainer from './StepContainer'
function StudentHeader() {
return (
@ -30,7 +31,7 @@ function StudentHeader() {
module={{name: 'Egypt Economy Research Module: Week 1', link: 'www.google.com'}}
assignmentGroup={{name: 'Research Assignments', link: 'www.yahoo.com'}}
/>
<Flex>
<Flex margin="0 0 xx-large 0">
<FlexItem grow>
<StudentDateTitle
title="Egypt Economy Research"
@ -41,6 +42,7 @@ function StudentHeader() {
<PointsDisplay receivedPoints={null} possiblePoints={32} />
</FlexItem>
</Flex>
<StepContainer />
</div>
)
}

View File

@ -0,0 +1,22 @@
/*
* 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 "base/environment";
@import "pages/assignments2_student/steps.scss";
@import "pages/assignments2_student/step_items.scss";
@import "pages/assignments2_student/student_header.scss";

View File

@ -0,0 +1,131 @@
/*
* 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/>.
*/
@keyframes pulse {
100% {
opacity: 1;
transform: scale(1);
}
}
.step-item-step {
display: block;
}
.step-item-label {
display: block;
margin-top: 0.5rem;
font-size: 0.875rem;
line-height: 1.25;
}
.pinLayout {
display: flex;
align-items: center;
}
.step-item-pin {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
background: #8B969E;
border-radius: 100%;
position: relative;
}
.complete {
.step-item-pin {
font-size: 0.75rem; /* sets the icon size bc we need smaller than theme can provide */
background: #00AC18;
}
}
.in-progress {
.step-item-label {
font-weight: 700;
color: #2D3B45;
}
.step-item-pin {
background: #FFFFFF;
border: 0.125rem solid #00AC18;
/* if pin is complete, it pulses - should we make this an opt-in prop? */
position: relative;
&::before {
content: "";
box-sizing: border-box;
position: absolute;
top: -0.5rem;
bottom: -0.5rem;
left: -0.5rem; /* stylelint-disable-line property-blacklist */
right: -0.5rem; /* stylelint-disable-line property-blacklist */
border-width: 0.125rem;
border-style: solid;
border-color: #00AC18;
border-radius: 100%;
/* set initial properties to animate in the pulse animation */
opacity: 0;
transform: scale(0.5);
/* animation */
animation-name: pulse;
animation-duration: 2s;
animation-iteration-count: infinite;
animation-direction: alternate;
animation-timing-function: ease-out;
}
}
}
.placement--first {
.pinLayout {
justify-content: flex-start;
}
.step-item-label {
text-align: start;
}
}
.placement--last {
.pinLayout {
justify-content: flex-end;
}
.step-item-label {
text-align: end;
}
}
.placement--interior {
.pinLayout {
justify-content: center;
}
.step-item-label {
text-align: center;
margin-inline-start: auto;
margin-inline-end: auto;
max-width: 50%; /* makes all text labels equal width (first and last are reduced by flex: 0.5 rule above) */
}
}

View File

@ -0,0 +1,70 @@
/*
* 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/>.
*/
.steps {
list-style-type: none;
margin: 0;
padding: 0;
display: flex;
justify-content: space-between;
position: relative;
}
.step {
margin: -32px 0 0;
padding: 0;
min-width: 0.0625rem;
flex: 1;
&:last-of-type {
flex: 0.5;
}
&:first-of-type {
flex: 0.5;
}
}
.progressionContainer {
position: relative;
height: 32px;
display: flex;
align-items: center;
}
.progression {
flex: 1;
height: 0.0625rem;
background: #8B969E;
}
.completeProgression {
position: absolute;
display: block;
width: 100%;
height: 0.125rem;
offset-inline-start: 0;
top: calc(50% - (0.125rem / 2));
background: #00AC18;
transition: transform 0.2s;
transform-origin: left center;
}
[dir="rtl"] .completeProgression {
transform-origin: right center;
}

View File

@ -0,0 +1,21 @@
/*
* 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/>.
*/
.steps-container {
margin-bottom: 60px;
}

View File

@ -20,7 +20,10 @@
<% if @context.root_account.feature_enabled?(:assignments_2) && value_to_boolean(params[:assignments_2]) %>
<% if can_do(@context, @current_user, :read_as_admin) %>
<% js_bundle :assignments_2_show_teacher %>
<% else %>
<% css_bundle :assignments_2_student %>
<% js_bundle :assignments_2_show_student %>
<% end %>
<div id="assignments_2"></div>