Personal to-do Editor for K5

A new modal component was added to the Canvas-planner, this modal
communicates with the UI and the redux store similar to how the tray
editor does in normal canvas, also slight changes were added
to support personal TO-DO edition when the user is in a
single-course view

fixes LS-2159
flag=canvas_for_elementary

Test Plan:

Scenario 1:
-Create some personal to-do in normal canvas, set a course for a
least one of them
- Enable k5 mode
- Access as student and go to the dashboard
- Look for one of the to-do you created in step 1
- Click it and expect to details be opened in a new modal
- Do some changes and click on Save.
-Expect to the changes be saved and the modal closed
- Open the To-do again and click on Delete.
- Expect to the To-do be deleted

Scenario 2:
- With k5 mode enable
- Go to a course that you used in step 1 of scenario 1 and access
to it as student.
- Look for one of the to-do created in step 1 of scenario 1
- Click it and expect the modal behaves as it does in scenario 1

Change-Id: I8a80857f67df18301a02c1371eb7d7beacd2fe73
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/265623
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Product-Review: Peyton Craighill <pcraighill@instructure.com>
Reviewed-by: Jeff Largent <jeff.largent@instructure.com>
QA-Review: Jeff Largent <jeff.largent@instructure.com>
This commit is contained in:
Jonathan Guardado 2021-05-23 18:14:54 -06:00
parent 35daddd215
commit f9d977fdac
12 changed files with 347 additions and 8 deletions

View File

@ -2091,6 +2091,7 @@ class CoursesController < ApplicationController
COURSE: {
id: @context.id.to_s,
name: @context.name,
long_name: "#{@context.name} - #{@context.short_name}",
image_url: @context.feature_enabled?(:course_card_images) ? @context.image : nil,
color: @context.elementary_subject_course? ? @context.course_color : nil,
pages_url: polymorphic_url([@context, :wiki_pages]),

View File

@ -129,7 +129,8 @@
"redux-devtools-extension": "^2.13.2",
"@testing-library/dom": "^7",
"@testing-library/jest-dom": "^5",
"@testing-library/react": "^11"
"@testing-library/react": "^11",
"@testing-library/user-event": "^12"
},
"lint-staged": {
"*.js": "eslint"

View File

@ -0,0 +1,170 @@
/*
* 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 moment from 'moment-timezone'
import React from 'react'
import {render, fireEvent, waitFor} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import TodoEditorModal from '../index'
import {initialize} from '../../../utilities/alertUtils'
const defaultProps = (options = {}) => ({
savePlannerItem: () => {},
deletePlannerItem: () => {},
onEdit: () => {},
onClose: () => {},
todoItem: null,
locale: 'en',
timeZone: 'America/Denver',
courses: [],
...options
})
const simpleTodoItem = (opts = {}) => ({
uniqueId: '1',
title: 'todo',
date: moment('2021-05-08T11:00:00Z'),
context: {id: null},
time: '11:00',
details: '',
...opts
})
const successFn = jest.fn()
const errorFn = jest.fn()
beforeAll(() => {
initialize({visualSuccessCallback: successFn, visualErrorCallback: errorFn})
})
it('does not show the editor modal when todoItem is null', () => {
const {queryByTestId} = render(<TodoEditorModal {...defaultProps()} />)
expect(queryByTestId('todo-editor-modal')).not.toBeInTheDocument()
})
it('shows the editor modal when todoItem is set', () => {
const mockOnEdit = jest.fn()
const {getByTestId} = render(
<TodoEditorModal {...defaultProps({onEdit: mockOnEdit, todoItem: simpleTodoItem()})} />
)
expect(getByTestId('todo-editor-modal')).toBeInTheDocument()
expect(mockOnEdit).toHaveBeenCalled()
})
it('calls onClose when the x is clicked ', () => {
const mockOnClose = jest.fn()
const newProps = {...defaultProps({onClose: mockOnClose, todoItem: simpleTodoItem()})}
const {getByRole} = render(<TodoEditorModal {...newProps} />)
const closeButton = getByRole('button', {name: 'Close'})
fireEvent.click(closeButton)
expect(mockOnClose).toHaveBeenCalled()
})
it('updates the planner item and then closes the editor when Save is clicked ', async () => {
const todoItem = simpleTodoItem()
const mockOnClose = jest.fn()
const mockSave = jest.fn(() => Promise.resolve())
const newProps = {
...defaultProps({
onClose: mockOnClose,
savePlannerItem: mockSave,
todoItem
})
}
const {getByRole} = render(<TodoEditorModal {...newProps} />)
const title = getByRole('textbox', {name: 'Title'})
const date = getByRole('textbox', {name: 'Date'})
const details = getByRole('textbox', {name: 'Details'})
userEvent.clear(date)
userEvent.type(date, 'May 30,2021')
userEvent.clear(title)
userEvent.type(title, 'Updated Todo')
fireEvent.change(details, {target: {value: 'These are the todo details'}})
const saveButton = getByRole('button', {name: 'Save'})
fireEvent.click(saveButton)
expect(mockSave).toHaveBeenCalledWith({
...todoItem,
title: 'Updated Todo',
date: moment('2021-05-30T06:00:00Z').toISOString(),
details: 'These are the todo details'
})
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled()
})
})
it('deletes the planner item and then closes the editor when Delete is clicked ', async () => {
const todoItem = simpleTodoItem()
const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(true)
const mockOnClose = jest.fn()
const mockDelete = jest.fn(() => Promise.resolve())
const newProps = {
...defaultProps({
onClose: mockOnClose,
deletePlannerItem: mockDelete,
todoItem
})
}
const {getByRole} = render(<TodoEditorModal {...newProps} />)
const deleteButton = getByRole('button', {name: 'Delete'})
fireEvent.click(deleteButton)
expect(confirmSpy).toHaveBeenCalled()
expect(mockDelete).toHaveBeenCalledWith(todoItem)
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled()
})
})
it('shows an error if To-do update fails', async () => {
const todoItem = simpleTodoItem()
const mockSave = jest.fn(() => Promise.reject())
const newProps = {
...defaultProps({
savePlannerItem: mockSave,
todoItem
})
}
const {getByRole} = render(<TodoEditorModal {...newProps} />)
const saveButton = getByRole('button', {name: 'Save'})
fireEvent.click(saveButton)
await waitFor(() => expect(errorFn).toHaveBeenCalledWith('Failed saving changes on todo.'))
})
it('shows an error if To-do deletion fails', async () => {
const todoItem = simpleTodoItem()
jest.spyOn(window, 'confirm').mockReturnValue(true)
const mockDelete = jest.fn(() => Promise.reject())
const newProps = {
...defaultProps({
deletePlannerItem: mockDelete,
todoItem
})
}
const {getByRole} = render(<TodoEditorModal {...newProps} />)
const deleteButton = getByRole('button', {name: 'Delete'})
fireEvent.click(deleteButton)
await waitFor(() => expect(errorFn).toHaveBeenCalledWith('Failed to delete todo.'))
})

View File

@ -0,0 +1,105 @@
/*
* 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} from 'react'
import {CloseButton} from '@instructure/ui-buttons'
import {Modal} from '@instructure/ui-modal'
import {Heading} from '@instructure/ui-heading'
import PropTypes from 'prop-types'
import formatMessage from '../../format-message'
import {UpdateItemTray as UpdateItemForm} from '../UpdateItemTray'
import {alert} from '../../utilities/alertUtils'
export default function TodoEditorModal({
locale,
timeZone,
todoItem,
courses,
onEdit,
onClose,
savePlannerItem,
deletePlannerItem
}) {
// tells dynamic-ui what just happened via onEdit/onClose
useEffect(() => {
if (todoItem) {
onEdit() // tell dynamic-ui we've started editing
}
}, [onEdit, todoItem])
const handleSavePlannerItem = plannerItem => {
savePlannerItem(plannerItem)
.then(onClose)
.catch(() =>
alert(formatMessage('Failed saving changes on {name}.', {name: todoItem?.title}), true)
)
}
const handleDeletePlannerItem = plannerItem => {
deletePlannerItem(plannerItem)
.then(onClose)
.catch(() => alert(formatMessage('Failed to delete {name}.', {name: todoItem?.title}), true))
}
const getModalLabel = () => {
if (todoItem?.title) {
return formatMessage('Edit {title}', {title: todoItem.title})
}
return formatMessage('To Do')
}
return (
<Modal
data-testid="todo-editor-modal"
label={getModalLabel()}
size="auto"
theme={{autoMinWidth: '25em'}}
open={!!todoItem}
onDismiss={onClose}
// clicking the calendar closes the Modal if we do not set this to false
shouldCloseOnDocumentClick={false}
>
<Modal.Header>
<Heading>{getModalLabel()}</Heading>
<CloseButton data-testid="close-editor-modal" placement="end" onClick={onClose}>
{formatMessage('Close')}
</CloseButton>
</Modal.Header>
<Modal.Body padding="none">
<UpdateItemForm
locale={locale}
timeZone={timeZone}
noteItem={todoItem}
onSavePlannerItem={handleSavePlannerItem}
onDeletePlannerItem={handleDeletePlannerItem}
courses={courses || []}
/>
</Modal.Body>
</Modal>
)
}
TodoEditorModal.propTypes = {
locale: PropTypes.string.isRequired,
timeZone: PropTypes.string.isRequired,
todoItem: PropTypes.object,
courses: PropTypes.arrayOf(PropTypes.object).isRequired,
onEdit: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
savePlannerItem: PropTypes.func.isRequired,
deletePlannerItem: PropTypes.func.isRequired
}

View File

@ -225,10 +225,10 @@ export class UpdateItemTray extends Component {
label: formatMessage('Optional: Add Course')
}
const courseOptions = (this.props.courses || [])
.filter(course => course.enrollmentType === 'StudentEnrollment')
.filter(course => course.enrollmentType === 'StudentEnrollment' || course.is_student)
.map(course => ({
value: course.id,
label: course.longName
label: course.longName || course.long_name
}))
const courseId = this.findCurrentValue('courseId')

View File

@ -190,3 +190,17 @@ describe('processFocusTarget', () => {
)
})
})
describe('personal to-dos', () => {
it('opens the to-do editor if todo updateitem prop is set', () => {
const todo = {
updateTodoItem: {
id: 10
}
}
const {queryByTestId, rerender} = render(<WeeklyPlannerHeader {...defaultProps()} />)
expect(queryByTestId('todo-editor-modal')).not.toBeInTheDocument()
rerender(<WeeklyPlannerHeader {...defaultProps({todo, openEditingPlannerItem: () => {}})} />)
expect(queryByTestId('todo-editor-modal')).toBeInTheDocument()
})
})

View File

@ -27,10 +27,20 @@ import {themeable} from '@instructure/ui-themeable'
import {Button, IconButton} from '@instructure/ui-buttons'
import {IconArrowOpenEndLine, IconArrowOpenStartLine} from '@instructure/ui-icons'
import {View} from '@instructure/ui-view'
import {loadNextWeekItems, loadPastWeekItems, loadThisWeekItems, scrollToToday} from '../../actions'
import {
loadNextWeekItems,
loadPastWeekItems,
loadThisWeekItems,
scrollToToday,
savePlannerItem,
deletePlannerItem,
cancelEditingPlannerItem,
openEditingPlannerItem
} from '../../actions'
import ErrorAlert from '../ErrorAlert'
import formatMessage from '../../format-message'
import {isInMomentRange} from '../../utilities/dateUtils'
import TodoEditorModal from '../TodoEditorModal'
import theme from './theme'
import styles from './styles.css'
@ -75,7 +85,19 @@ export class WeeklyPlannerHeader extends Component {
weekStartMoment: momentObj,
weekEndMoment: momentObj,
wayPastItemDate: PropTypes.string,
wayFutureItemDate: PropTypes.string
wayFutureItemDate: PropTypes.string,
locale: PropTypes.string.isRequired,
timeZone: PropTypes.string.isRequired,
todo: PropTypes.shape({
updateTodoItem: PropTypes.shape({
title: PropTypes.string
})
}),
savePlannerItem: PropTypes.func.isRequired,
deletePlannerItem: PropTypes.func.isRequired,
cancelEditingPlannerItem: PropTypes.func.isRequired,
openEditingPlannerItem: PropTypes.func.isRequired,
courses: PropTypes.arrayOf(PropTypes.object).isRequired
}
prevButtonRef = createRef()
@ -287,6 +309,16 @@ export class WeeklyPlannerHeader extends Component {
>
<IconArrowOpenEndLine />
</IconButton>
<TodoEditorModal
locale={this.props.locale}
timeZone={this.props.timeZone}
todoItem={this.props.todo?.updateTodoItem}
courses={this.props.courses}
onEdit={this.props.openEditingPlannerItem}
onClose={this.props.cancelEditingPlannerItem}
savePlannerItem={this.props.savePlannerItem}
deletePlannerItem={this.props.deletePlannerItem}
/>
</View>
</div>
)
@ -302,7 +334,9 @@ const mapStateToProps = state => {
weekStartMoment: state.weeklyDashboard.weekStart,
weekEndMoment: state.weeklyDashboard.weekEnd,
wayPastItemDate: state.weeklyDashboard.wayPastItemDate,
wayFutureItemDate: state.weeklyDashboard.wayFutureItemDate
wayFutureItemDate: state.weeklyDashboard.wayFutureItemDate,
todo: state.todo,
courses: state.courses
}
}
@ -310,7 +344,11 @@ const mapDispatchToProps = {
loadNextWeekItems,
loadPastWeekItems,
loadThisWeekItems,
scrollToToday
scrollToToday,
savePlannerItem,
deletePlannerItem,
cancelEditingPlannerItem,
openEditingPlannerItem
}
export default connect(mapStateToProps, mapDispatchToProps)(ThemedWeeklyPlannerHeader)

View File

@ -36,6 +36,7 @@ ready(() => {
name={ENV.COURSE.name}
plannerEnabled={ENV.STUDENT_PLANNER_ENABLED}
timeZone={ENV.TIMEZONE}
locale={ENV.LOCALE}
courseOverview={ENV.COURSE.course_overview}
userIsStudent={ENV.COURSE.is_student}
userIsInstructor={ENV.COURSE.is_instructor}

View File

@ -191,6 +191,7 @@ export function K5Course({
loadAllOpportunities,
name,
timeZone,
locale,
canManage = false,
plannerEnabled = false,
hideFinalGrades,
@ -304,6 +305,7 @@ export function K5Course({
plannerEnabled={plannerEnabled}
plannerInitialized={plannerInitialized}
timeZone={timeZone}
locale={locale}
userHasEnrollments
visible={currentTab === TAB_IDS.SCHEDULE}
/>
@ -341,6 +343,7 @@ K5Course.propTypes = {
loadAllOpportunities: PropTypes.func.isRequired,
name: PropTypes.string.isRequired,
timeZone: PropTypes.string.isRequired,
locale: PropTypes.string.isRequired,
canManage: PropTypes.bool,
color: PropTypes.string,
defaultTab: PropTypes.string,

View File

@ -31,6 +31,7 @@ ready(() => {
currentUser={ENV.current_user}
plannerEnabled={ENV.STUDENT_PLANNER_ENABLED}
timeZone={ENV.TIMEZONE}
locale={ENV.LOCALE}
createPermissions={
ENV.PERMISSIONS?.create_courses_as_admin
? 'admin'

View File

@ -84,6 +84,7 @@ export const K5Dashboard = ({
loadAllOpportunities,
switchToToday,
timeZone,
locale,
toggleMissing,
defaultTab = TAB_IDS.HOMEROOM,
plannerEnabled = false,
@ -200,6 +201,7 @@ export const K5Dashboard = ({
plannerEnabled={plannerEnabled}
plannerInitialized={plannerInitialized}
timeZone={timeZone}
locale={locale}
userHasEnrollments={cards?.length}
visible={currentTab === TAB_IDS.SCHEDULE}
/>
@ -230,6 +232,7 @@ K5Dashboard.propTypes = {
loadAllOpportunities: PropTypes.func.isRequired,
switchToToday: PropTypes.func.isRequired,
timeZone: PropTypes.string.isRequired,
locale: PropTypes.string.isRequired,
toggleMissing: PropTypes.func.isRequired,
defaultTab: PropTypes.string,
plannerEnabled: PropTypes.bool,

View File

@ -32,6 +32,7 @@ const SchedulePage = ({
plannerEnabled,
plannerInitialized,
timeZone,
locale,
userHasEnrollments,
visible
}) => {
@ -49,7 +50,7 @@ const SchedulePage = ({
if (plannerInitialized && isPlannerCreated) {
content = (
<>
{renderWeeklyPlannerHeader({visible})}
{renderWeeklyPlannerHeader({visible, timeZone, locale})}
{plannerApp.current}
<JumpToHeaderButton />
</>
@ -78,6 +79,7 @@ SchedulePage.propTypes = {
plannerEnabled: PropTypes.bool.isRequired,
plannerInitialized: PropTypes.oneOfType([PropTypes.bool, PropTypes.object]).isRequired,
timeZone: PropTypes.string.isRequired,
locale: PropTypes.string.isRequired,
userHasEnrollments: PropTypes.bool.isRequired,
visible: PropTypes.bool.isRequired
}