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 js_bundle :syllabus
css_bundle :syllabus, :tinymce css_bundle :syllabus, :tinymce
when 'k5_dashboard' when 'k5_dashboard'
js_env(PERMISSIONS: { manage: @context.grants_right?(@current_user, session, :manage) })
js_env(STUDENT_PLANNER_ENABLED: planner_enabled?) js_env(STUDENT_PLANNER_ENABLED: planner_enabled?)
js_env(CONTEXT_MODULE_ASSIGNMENT_INFO_URL: context_url(@context, :context_context_modules_assignment_info_url)) 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)) { if (!('matchMedia' in window)) {
window.matchMedia = () => ({ window.matchMedia = () => ({
matches: false, matches: false,

View File

@ -1581,6 +1581,13 @@ describe CoursesController do
expect(assigns[:js_env][:STUDENT_PLANNER_ENABLED]).to be_falsy expect(assigns[:js_env][:STUDENT_PLANNER_ENABLED]).to be_falsy
end 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 it "loads announcements on home page when course is a k5 homeroom course" do
@course.homeroom_course = true @course.homeroom_course = true
@course.save! @course.save!

View File

@ -28,10 +28,11 @@ ready(() => {
if (courseContainer) { if (courseContainer) {
ReactDOM.render( ReactDOM.render(
<K5Course <K5Course
canManage={ENV.PERMISSIONS.manage}
currentUser={ENV.current_user} currentUser={ENV.current_user}
name={ENV.COURSE.name}
id={ENV.COURSE.id} id={ENV.COURSE.id}
imageUrl={ENV.COURSE.image_url} imageUrl={ENV.COURSE.image_url}
name={ENV.COURSE.name}
plannerEnabled={ENV.STUDENT_PLANNER_ENABLED} plannerEnabled={ENV.STUDENT_PLANNER_ENABLED}
timeZone={ENV.TIMEZONE} timeZone={ENV.TIMEZONE}
courseOverview={ENV.COURSE.course_overview} 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 * 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/>. * 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 {connect, Provider} from 'react-redux'
import I18n from 'i18n!k5_course' import I18n from 'i18n!k5_course'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
@ -26,14 +26,17 @@ import {
store store
} from '@instructure/canvas-planner' } from '@instructure/canvas-planner'
import { import {
IconBankLine,
IconCalendarMonthLine, IconCalendarMonthLine,
IconEditSolid,
IconHomeLine, IconHomeLine,
IconModuleLine, IconModuleLine,
IconStarLightLine, IconStarLightLine
IconBankLine
} from '@instructure/ui-icons' } from '@instructure/ui-icons'
import {ApplyTheme} from '@instructure/ui-themeable' import {ApplyTheme} from '@instructure/ui-themeable'
import {Button} from '@instructure/ui-buttons'
import {Heading} from '@instructure/ui-heading' import {Heading} from '@instructure/ui-heading'
import {Mask} from '@instructure/ui-overlays'
import {TruncateText} from '@instructure/ui-truncate-text' import {TruncateText} from '@instructure/ui-truncate-text'
import {View} from '@instructure/ui-view' 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 usePlanner from '@canvas/k5/react/hooks/usePlanner'
import useTabState from '@canvas/k5/react/hooks/useTabState' import useTabState from '@canvas/k5/react/hooks/useTabState'
import {mapStateToProps} from '@canvas/k5/redux/redux-helpers' 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 k5Theme, {theme} from '@canvas/k5/react/k5-theme'
import AppsList from '@canvas/k5/react/AppsList' import AppsList from '@canvas/k5/react/AppsList'
import {showFlashError} from '@canvas/alerts/react/FlashAlert' import {showFlashError} from '@canvas/alerts/react/FlashAlert'
import OverviewPage from './OverviewPage' import OverviewPage from './OverviewPage'
import ManageCourseTray from './ManageCourseTray'
const DEFAULT_COLOR = k5Theme.variables.colors.backgroundMedium const DEFAULT_COLOR = k5Theme.variables.colors.backgroundMedium
const HERO_HEIGHT_PX = 400 const HERO_HEIGHT_PX = 400
const COURSE_TABS = [ const COURSE_TABS = [
{ {
id: TAB_IDS.OVERVIEW, id: TAB_IDS.HOME,
icon: IconHomeLine, icon: IconHomeLine,
label: I18n.t('Overview') label: I18n.t('Home')
}, },
{ {
id: TAB_IDS.SCHEDULE, id: TAB_IDS.SCHEDULE,
@ -95,8 +99,7 @@ export function CourseHeaderHero({name, image, backgroundColor}) {
borderRadius: '8px', borderRadius: '8px',
minHeight: '25vh', minHeight: '25vh',
maxHeight: `${HERO_HEIGHT_PX}px`, maxHeight: `${HERO_HEIGHT_PX}px`,
marginBottom: '1rem', marginBottom: '1rem'
marginTop: '-1.25rem'
}} }}
aria-hidden="true" aria-hidden="true"
data-testid="k5-course-header-hero" data-testid="k5-course-header-hero"
@ -131,17 +134,20 @@ export function K5Course({
assignmentsDueToday, assignmentsDueToday,
assignmentsMissing, assignmentsMissing,
assignmentsCompletedForToday, assignmentsCompletedForToday,
courseOverview,
id,
imageUrl, imageUrl,
loadAllOpportunities, loadAllOpportunities,
name, name,
id,
timeZone, timeZone,
defaultTab = TAB_IDS.OVERVIEW, canManage = false,
plannerEnabled = false, defaultTab = TAB_IDS.HOME,
courseOverview plannerEnabled = false
}) { }) {
const {activeTab, currentTab, handleTabChange} = useTabState(defaultTab) const {activeTab, currentTab, handleTabChange} = useTabState(defaultTab)
const [courseNavLinks, setCourseNavLinks] = useState([])
const [tabsRef, setTabsRef] = useState(null) const [tabsRef, setTabsRef] = useState(null)
const [trayOpen, setTrayOpen] = useState(false)
const plannerInitialized = usePlanner({ const plannerInitialized = usePlanner({
plannerEnabled, plannerEnabled,
isPlannerActive: () => activeTab.current === TAB_IDS.SCHEDULE, isPlannerActive: () => activeTab.current === TAB_IDS.SCHEDULE,
@ -169,8 +175,14 @@ export function K5Course({
.then(setApps) .then(setApps)
.catch(showFlashError(I18n.t('Failed to load apps for %{name}.', {name}))) .catch(showFlashError(I18n.t('Failed to load apps for %{name}.', {name})))
.finally(() => setAppsLoading(false)) .finally(() => setAppsLoading(false))
fetchCourseTabs(id)
.then(setCourseNavLinks)
.catch(showFlashError(I18n.t('Failed to load course navigation for %{name}.', {name})))
}, [id, name]) }, [id, name])
const handleOpenTray = () => setTrayOpen(true)
const handleCloseTray = () => setTrayOpen(false)
return ( return (
<K5DashboardContext.Provider <K5DashboardContext.Provider
value={{ value={{
@ -181,12 +193,28 @@ export function K5Course({
}} }}
> >
<View as="section"> <View as="section">
{trayOpen && <Mask onClick={handleCloseTray} fullscreen />}
{canManage && (
<ManageCourseTray navLinks={courseNavLinks} open={trayOpen} onClose={handleCloseTray} />
)}
<K5Tabs <K5Tabs
currentTab={currentTab} currentTab={currentTab}
onTabChange={handleTabChange} onTabChange={handleTabChange}
tabs={COURSE_TABS} tabs={COURSE_TABS}
tabsRef={setTabsRef} 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} /> <CourseHeaderHero name={name} image={imageUrl} backgroundColor={DEFAULT_COLOR} />
</K5Tabs> </K5Tabs>
{currentTab === TAB_IDS.OVERVIEW && <OverviewPage content={courseOverview} />} {currentTab === TAB_IDS.OVERVIEW && <OverviewPage content={courseOverview} />}
@ -202,10 +230,11 @@ K5Course.propTypes = {
assignmentsDueToday: PropTypes.object.isRequired, assignmentsDueToday: PropTypes.object.isRequired,
assignmentsMissing: PropTypes.object.isRequired, assignmentsMissing: PropTypes.object.isRequired,
assignmentsCompletedForToday: PropTypes.object.isRequired, assignmentsCompletedForToday: PropTypes.object.isRequired,
id: PropTypes.string.isRequired,
loadAllOpportunities: PropTypes.func.isRequired, loadAllOpportunities: PropTypes.func.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
timeZone: PropTypes.string.isRequired, timeZone: PropTypes.string.isRequired,
canManage: PropTypes.bool,
defaultTab: PropTypes.string, defaultTab: PropTypes.string,
imageUrl: PropTypes.string, imageUrl: PropTypes.string,
plannerEnabled: PropTypes.bool, 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 React from 'react'
import moxios from 'moxios' 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 {K5Course} from '../K5Course'
import fetchMock from 'fetch-mock' import fetchMock from 'fetch-mock'
import {MOCK_COURSE_APPS, MOCK_COURSE_TABS} from './mocks'
import {TAB_IDS} from '@canvas/k5/react/utils' import {TAB_IDS} from '@canvas/k5/react/utils'
const currentUser = { const currentUser = {
@ -45,22 +46,16 @@ const defaultProps = {
loadAllOpportunities: () => {}, loadAllOpportunities: () => {},
name: 'Arts and Crafts', name: 'Arts and Crafts',
id: '30', 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_APPS_URL = '/api/v1/courses/30/external_tools/visible_course_nav_tools'
const FETCH_TABS_URL = '/api/v1/courses/30/tabs'
beforeAll(() => { beforeAll(() => {
moxios.install() 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(() => { afterAll(() => {
@ -99,16 +94,66 @@ describe('K-5 Subject Course', () => {
expect(getByText(defaultProps.name)).toBeInTheDocument() 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} />) const {getByText} = render(<K5Course {...defaultProps} />)
;['Overview', 'Schedule', 'Modules', 'Grades', 'Resources'].forEach(label => ;['Home', 'Schedule', 'Modules', 'Grades', 'Resources'].forEach(label =>
expect(getByText(label)).toBeInTheDocument() expect(getByText(label)).toBeInTheDocument()
) )
}) })
it('defaults to the Overview tab', () => { it('defaults to the Home tab', () => {
const {getByRole} = render(<K5Course {...defaultProps} />) 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} ref={containerRef}
style={{backgroundColor: k5Theme.variables.colors.background.backgroundLightest}} 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 && ( {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})} {I18n.t('Welcome, %{name}!', {name})}
</Heading> </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][0].id).toBe(TAB_IDS.RESOURCES)
expect(window.history.replaceState.mock.calls[0][2]).toBe('http://localhost/#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][0].id).toBe(TAB_IDS.HOME)
expect(window.history.replaceState.mock.calls[1][2]).toBe('http://localhost/#overview') 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 => { export const readableRoleName = role => {
const ROLES = { const ROLES = {
TeacherEnrollment: I18n.t('Teacher'), TeacherEnrollment: I18n.t('Teacher'),
@ -126,10 +129,10 @@ export const sendMessage = (recipientId, message, subject) =>
}) })
export const TAB_IDS = { export const TAB_IDS = {
HOME: 'tab-home',
HOMEROOM: 'tab-homeroom', HOMEROOM: 'tab-homeroom',
SCHEDULE: 'tab-schedule', SCHEDULE: 'tab-schedule',
GRADES: 'tab-grades', GRADES: 'tab-grades',
RESOURCES: 'tab-resources', RESOURCES: 'tab-resources',
OVERVIEW: 'tab-overview',
MODULES: 'tab-modules' MODULES: 'tab-modules'
} }