Add K-5 Manage course button and tray

This change adds a slide-out course navigation tray and associated
button to the K-5 course home page for teachers. Also renames the
Overview tab to "Home".

closes LS-2028
flag = canvas_for_elementary

Test plan:
  - Enroll as a teacher in a K-5 course
  - Go to /courses/:course_id
  - Expect to see a "Manage" button in the upper-left
  - Expect clicking that button to open a tray inside the global nav
    bar with the course's nav links inside it
  - Expect the links to be the same as the classic Canvas nav for that
    course, including the icons showing which links are hidden from
    students
  - Expect the order of the links to also be the same
  - Click a link, expect it to take you to a classic Canvas page
  - Click the "home" link on the classic Canvas nav, and expect it to
    take you back to the K-5 course home page

  - Also expect the "Overview" tab to be called "Home" now

Change-Id: I08a375d70f98a25948073be624432edbcd1b6d04
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/262895
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Jonathan Guardado <jonathan.guardado@instructure.com>
QA-Review: Jonathan Guardado <jonathan.guardado@instructure.com>
Product-Review: Peyton Craighill <pcraighill@instructure.com>
This commit is contained in:
Jeff Largent 2021-04-14 09:29:09 -04:00
parent d98065dcfc
commit c71d37fc2b
11 changed files with 294 additions and 36 deletions

View File

@ -2177,6 +2177,7 @@ class CoursesController < ApplicationController
js_bundle :syllabus
css_bundle :syllabus, :tinymce
when 'k5_dashboard'
js_env(PERMISSIONS: { manage: @context.grants_right?(@current_user, session, :manage) })
js_env(STUDENT_PLANNER_ENABLED: planner_enabled?)
js_env(CONTEXT_MODULE_ASSIGNMENT_INFO_URL: context_url(@context, :context_context_modules_assignment_info_url))

View File

@ -111,6 +111,22 @@ if (!('IntersectionObserver' in window)) {
})
}
if (!('ResizeObserver' in window)) {
Object.defineProperty(window, 'ResizeObserver', {
writable: true,
configurable: true,
value: class IntersectionObserver {
observe() {
return null
}
unobserve() {
return null
}
}
})
}
if (!('matchMedia' in window)) {
window.matchMedia = () => ({
matches: false,

View File

@ -1581,6 +1581,13 @@ describe CoursesController do
expect(assigns[:js_env][:STUDENT_PLANNER_ENABLED]).to be_falsy
end
it "sets PERMISSIONS appropriately in js_env" do
user_session(@teacher)
get 'show', params: {:id => @course.id}
expect(assigns[:js_env][:PERMISSIONS]).to eq({manage: true})
end
it "loads announcements on home page when course is a k5 homeroom course" do
@course.homeroom_course = true
@course.save!

View File

@ -28,10 +28,11 @@ ready(() => {
if (courseContainer) {
ReactDOM.render(
<K5Course
canManage={ENV.PERMISSIONS.manage}
currentUser={ENV.current_user}
name={ENV.COURSE.name}
id={ENV.COURSE.id}
imageUrl={ENV.COURSE.image_url}
name={ENV.COURSE.name}
plannerEnabled={ENV.STUDENT_PLANNER_ENABLED}
timeZone={ENV.TIMEZONE}
courseOverview={ENV.COURSE.course_overview}

View File

@ -15,7 +15,7 @@
* 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, {useState, useEffect, useRef} from 'react'
import React, {useEffect, useRef, useState} from 'react'
import {connect, Provider} from 'react-redux'
import I18n from 'i18n!k5_course'
import PropTypes from 'prop-types'
@ -26,14 +26,17 @@ import {
store
} from '@instructure/canvas-planner'
import {
IconBankLine,
IconCalendarMonthLine,
IconEditSolid,
IconHomeLine,
IconModuleLine,
IconStarLightLine,
IconBankLine
IconStarLightLine
} from '@instructure/ui-icons'
import {ApplyTheme} from '@instructure/ui-themeable'
import {Button} from '@instructure/ui-buttons'
import {Heading} from '@instructure/ui-heading'
import {Mask} from '@instructure/ui-overlays'
import {TruncateText} from '@instructure/ui-truncate-text'
import {View} from '@instructure/ui-view'
@ -43,20 +46,21 @@ import SchedulePage from '@canvas/k5/react/SchedulePage'
import usePlanner from '@canvas/k5/react/hooks/usePlanner'
import useTabState from '@canvas/k5/react/hooks/useTabState'
import {mapStateToProps} from '@canvas/k5/redux/redux-helpers'
import {TAB_IDS, fetchCourseApps} from '@canvas/k5/react/utils'
import {fetchCourseApps, fetchCourseTabs, TAB_IDS} from '@canvas/k5/react/utils'
import k5Theme, {theme} from '@canvas/k5/react/k5-theme'
import AppsList from '@canvas/k5/react/AppsList'
import {showFlashError} from '@canvas/alerts/react/FlashAlert'
import OverviewPage from './OverviewPage'
import ManageCourseTray from './ManageCourseTray'
const DEFAULT_COLOR = k5Theme.variables.colors.backgroundMedium
const HERO_HEIGHT_PX = 400
const COURSE_TABS = [
{
id: TAB_IDS.OVERVIEW,
id: TAB_IDS.HOME,
icon: IconHomeLine,
label: I18n.t('Overview')
label: I18n.t('Home')
},
{
id: TAB_IDS.SCHEDULE,
@ -95,8 +99,7 @@ export function CourseHeaderHero({name, image, backgroundColor}) {
borderRadius: '8px',
minHeight: '25vh',
maxHeight: `${HERO_HEIGHT_PX}px`,
marginBottom: '1rem',
marginTop: '-1.25rem'
marginBottom: '1rem'
}}
aria-hidden="true"
data-testid="k5-course-header-hero"
@ -131,17 +134,20 @@ export function K5Course({
assignmentsDueToday,
assignmentsMissing,
assignmentsCompletedForToday,
courseOverview,
id,
imageUrl,
loadAllOpportunities,
name,
id,
timeZone,
defaultTab = TAB_IDS.OVERVIEW,
plannerEnabled = false,
courseOverview
canManage = false,
defaultTab = TAB_IDS.HOME,
plannerEnabled = false
}) {
const {activeTab, currentTab, handleTabChange} = useTabState(defaultTab)
const [courseNavLinks, setCourseNavLinks] = useState([])
const [tabsRef, setTabsRef] = useState(null)
const [trayOpen, setTrayOpen] = useState(false)
const plannerInitialized = usePlanner({
plannerEnabled,
isPlannerActive: () => activeTab.current === TAB_IDS.SCHEDULE,
@ -169,8 +175,14 @@ export function K5Course({
.then(setApps)
.catch(showFlashError(I18n.t('Failed to load apps for %{name}.', {name})))
.finally(() => setAppsLoading(false))
fetchCourseTabs(id)
.then(setCourseNavLinks)
.catch(showFlashError(I18n.t('Failed to load course navigation for %{name}.', {name})))
}, [id, name])
const handleOpenTray = () => setTrayOpen(true)
const handleCloseTray = () => setTrayOpen(false)
return (
<K5DashboardContext.Provider
value={{
@ -181,12 +193,28 @@ export function K5Course({
}}
>
<View as="section">
{trayOpen && <Mask onClick={handleCloseTray} fullscreen />}
{canManage && (
<ManageCourseTray navLinks={courseNavLinks} open={trayOpen} onClose={handleCloseTray} />
)}
<K5Tabs
currentTab={currentTab}
onTabChange={handleTabChange}
tabs={COURSE_TABS}
tabsRef={setTabsRef}
>
{canManage && (
<View
as="section"
borderWidth="0 0 small 0"
padding="0 0 medium 0"
margin="0 0 medium 0"
>
<Button onClick={handleOpenTray} renderIcon={<IconEditSolid />}>
{I18n.t('Manage')}
</Button>
</View>
)}
<CourseHeaderHero name={name} image={imageUrl} backgroundColor={DEFAULT_COLOR} />
</K5Tabs>
{currentTab === TAB_IDS.OVERVIEW && <OverviewPage content={courseOverview} />}
@ -202,10 +230,11 @@ K5Course.propTypes = {
assignmentsDueToday: PropTypes.object.isRequired,
assignmentsMissing: PropTypes.object.isRequired,
assignmentsCompletedForToday: PropTypes.object.isRequired,
id: PropTypes.string.isRequired,
loadAllOpportunities: PropTypes.func.isRequired,
name: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
timeZone: PropTypes.string.isRequired,
canManage: PropTypes.bool,
defaultTab: PropTypes.string,
imageUrl: PropTypes.string,
plannerEnabled: PropTypes.bool,

View File

@ -0,0 +1,101 @@
/*
* Copyright (C) 2021 - 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, {useEffect, useRef, useState} from 'react'
import I18n from 'i18n!k5_manage_course_tray'
import {CloseButton} from '@instructure/ui-buttons'
import {Flex} from '@instructure/ui-flex'
import {IconOffLine} from '@instructure/ui-icons'
import {Link} from '@instructure/ui-link'
import {Text} from '@instructure/ui-text'
import {Tray} from '@instructure/ui-tray'
import {Tooltip} from '@instructure/ui-tooltip'
import {View} from '@instructure/ui-view'
export default function ManageCourseTray({navLinks, onClose, open}) {
const globalNavRef = useRef()
const globalNavObserverRef = useRef()
const [offset, setOffset] = useState(0)
useEffect(() => {
globalNavRef.current = document.getElementById('header')
setOffset(globalNavRef.current?.getBoundingClientRect()?.width || 0)
globalNavObserverRef.current = new ResizeObserver(entries => {
entries.forEach(entry => setOffset(entry.contentRect.width))
})
globalNavObserverRef.current.observe(globalNavRef.current)
return () => globalNavObserverRef.current?.unobserve(globalNavRef.current)
}, [])
return (
<Tray
label={I18n.t('Course Navigation Tray')}
onDismiss={onClose}
open={open}
placement="start"
size="regular"
theme={{
regularWidth: '26em',
zIndex: 99
}}
>
<div style={{marginLeft: offset}}>
<View as="section" padding="small medium">
<Flex direction="row" justifyItems="end" margin="medium small">
<Flex.Item margin="0 0 0 small">
<CloseButton onClick={onClose}>{I18n.t('Close')}</CloseButton>
</Flex.Item>
</Flex>
{navLinks.map(link => (
<View as="div" margin="small" key={`course-nav-${link.id}`}>
<Flex direction="row">
<Flex.Item grow shrink>
<Link
href={link.html_url}
theme={{
hoverTextDecorationWithinText: 'underline',
textDecorationWithinText: 'none'
}}
>
<Text size="medium">{link.label}</Text>
</Link>
</Flex.Item>
{link.visibility === 'admins' && link.id !== 'settings' && (
<Flex.Item>
<Tooltip
renderTip={I18n.t('Disabled. Not visible to students')}
on={['hover', 'focus']}
offsetY={6}
>
<IconOffLine
size="small"
theme={{sizeSmall: '1.5rem'}}
data-testid="k5-course-nav-hidden-icon"
/>
</Tooltip>
</Flex.Item>
)}
</Flex>
</View>
))}
</View>
</div>
</Tray>
)
}

View File

@ -18,9 +18,10 @@
import React from 'react'
import moxios from 'moxios'
import {render, waitFor} from '@testing-library/react'
import {act, fireEvent, render, waitFor} from '@testing-library/react'
import {K5Course} from '../K5Course'
import fetchMock from 'fetch-mock'
import {MOCK_COURSE_APPS, MOCK_COURSE_TABS} from './mocks'
import {TAB_IDS} from '@canvas/k5/react/utils'
const currentUser = {
@ -45,22 +46,16 @@ const defaultProps = {
loadAllOpportunities: () => {},
name: 'Arts and Crafts',
id: '30',
timeZone: defaultEnv.TIMEZONE
timeZone: defaultEnv.TIMEZONE,
canManage: false
}
const fetchAppsResponse = [
{
id: '7',
course_navigation: {
text: 'Studio',
icon_url: 'studio.png'
}
}
]
const FETCH_APPS_URL = '/api/v1/courses/30/external_tools/visible_course_nav_tools'
const FETCH_TABS_URL = '/api/v1/courses/30/tabs'
beforeAll(() => {
moxios.install()
fetchMock.get(FETCH_APPS_URL, JSON.stringify(fetchAppsResponse))
fetchMock.get(FETCH_APPS_URL, JSON.stringify(MOCK_COURSE_APPS))
fetchMock.get(FETCH_TABS_URL, JSON.stringify(MOCK_COURSE_TABS))
})
afterAll(() => {
@ -99,16 +94,66 @@ describe('K-5 Subject Course', () => {
expect(getByText(defaultProps.name)).toBeInTheDocument()
})
it('shows Overview, Schedule, Modules, Grades, and Resources options', () => {
it('shows Home, Schedule, Modules, Grades, and Resources options', () => {
const {getByText} = render(<K5Course {...defaultProps} />)
;['Overview', 'Schedule', 'Modules', 'Grades', 'Resources'].forEach(label =>
;['Home', 'Schedule', 'Modules', 'Grades', 'Resources'].forEach(label =>
expect(getByText(label)).toBeInTheDocument()
)
})
it('defaults to the Overview tab', () => {
it('defaults to the Home tab', () => {
const {getByRole} = render(<K5Course {...defaultProps} />)
expect(getByRole('tab', {name: 'Overview', selected: true})).toBeInTheDocument()
expect(getByRole('tab', {name: 'Home', selected: true})).toBeInTheDocument()
})
})
describe('Manage course functionality', () => {
it('Shows a manage button when the user has manage permissions', () => {
const {getByRole} = render(<K5Course {...defaultProps} canManage />)
expect(getByRole('button', {name: 'Manage'})).toBeInTheDocument()
})
it('The manage button opens a slide-out tray with the course navigation tabs when clicked', async () => {
const {getByRole} = render(<K5Course {...defaultProps} canManage />)
const manageButton = getByRole('button', {name: 'Manage'})
act(() => manageButton.click())
const validateLink = (name, href) => {
const link = getByRole('link', {name})
expect(link).toBeInTheDocument()
expect(link.href).toBe(href)
}
await waitFor(() => {
validateLink('Home', 'http://localhost/courses/30')
validateLink('Modules', 'http://localhost/courses/30/modules')
validateLink('Assignments', 'http://localhost/courses/30/assignments')
validateLink('Settings', 'http://localhost/courses/30/settings')
})
})
it('Displays an icon indicating that a nav link is hidden from students', async () => {
const {findAllByTestId, getByRole, getByText} = render(
<K5Course {...defaultProps} canManage />
)
const manageButton = getByRole('button', {name: 'Manage'})
act(() => manageButton.click())
const hiddenIcons = await findAllByTestId('k5-course-nav-hidden-icon')
// Doesn't show the icon for settings, though
expect(hiddenIcons.length).toBe(1)
fireEvent.mouseOver(hiddenIcons[0])
await waitFor(() =>
expect(getByText('Disabled. Not visible to students')).toBeInTheDocument()
)
})
it('Does not show a manage button when the user does not have manage permissions', () => {
const {queryByRole} = render(<K5Course {...defaultProps} />)
expect(queryByRole('button', {name: 'Manage'})).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,55 @@
/*
* Copyright (C) 2021 - 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/>.
*/
export const MOCK_COURSE_APPS = [
{
id: '7',
course_navigation: {
text: 'Studio',
icon_url: 'studio.png'
}
}
]
export const MOCK_COURSE_TABS = [
{
id: 'home',
html_url: '/courses/30',
label: 'Home',
visibility: 'public'
},
{
id: 'modules',
html_url: '/courses/30/modules',
label: 'Modules',
visibility: 'public'
},
{
id: 'assignments',
html_url: '/courses/30/assignments',
label: 'Assignments',
visibility: 'admins',
hidden: true
},
{
id: 'settings',
html_url: '/courses/30/settings',
label: 'Settings',
visibility: 'admins'
}
]

View File

@ -64,9 +64,9 @@ const K5Tabs = ({children, currentTab, name, onTabChange, tabs, tabsRef}) => {
ref={containerRef}
style={{backgroundColor: k5Theme.variables.colors.background.backgroundLightest}}
>
<View as="div" padding="medium 0 0 0" borderWidth="none none small none">
<View as="div" borderWidth="none none small none">
{name && (
<Heading as="h1" level={sticky ? 'h2' : 'h1'} margin="0 0 small 0">
<Heading as="h1" level={sticky ? 'h2' : 'h1'} margin="medium 0 small 0">
{I18n.t('Welcome, %{name}!', {name})}
</Heading>
)}

View File

@ -75,10 +75,10 @@ describe('useTabState hook', () => {
expect(window.history.replaceState.mock.calls[0][0].id).toBe(TAB_IDS.RESOURCES)
expect(window.history.replaceState.mock.calls[0][2]).toBe('http://localhost/#resources')
act(() => result.current.handleTabChange(TAB_IDS.OVERVIEW))
act(() => result.current.handleTabChange(TAB_IDS.HOME))
expect(window.history.replaceState.mock.calls[1][0].id).toBe(TAB_IDS.OVERVIEW)
expect(window.history.replaceState.mock.calls[1][2]).toBe('http://localhost/#overview')
expect(window.history.replaceState.mock.calls[1][0].id).toBe(TAB_IDS.HOME)
expect(window.history.replaceState.mock.calls[1][2]).toBe('http://localhost/#home')
})
})
})

View File

@ -105,6 +105,9 @@ export const fetchCourseApps = courseId =>
)
)
export const fetchCourseTabs = courseId =>
asJson(window.fetch(`/api/v1/courses/${courseId}/tabs`, defaultFetchOptions))
export const readableRoleName = role => {
const ROLES = {
TeacherEnrollment: I18n.t('Teacher'),
@ -126,10 +129,10 @@ export const sendMessage = (recipientId, message, subject) =>
})
export const TAB_IDS = {
HOME: 'tab-home',
HOMEROOM: 'tab-homeroom',
SCHEDULE: 'tab-schedule',
GRADES: 'tab-grades',
RESOURCES: 'tab-resources',
OVERVIEW: 'tab-overview',
MODULES: 'tab-modules'
}