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:
parent
35daddd215
commit
f9d977fdac
|
@ -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]),
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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.'))
|
||||
})
|
|
@ -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
|
||||
}
|
|
@ -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')
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue