Add confirmation dialog on navigating away

Add confirmation dialog on navigating away from
mastery scales or calculation method

closes OUT-4090
flag=account_level_mastery_scales

TEST PLAN:
  - With account_level_mastery_scales FF enabled
  - Go to account outcomes > Mastery
  - Change some data
  - try to switch tab and assert a
    confirmation modal is present
  - Try to close browser's tab and
    asser the confirmation modal is present
  - Verify the confirm and cancel behavior is working
    correctly
  - Now save the data and verify you can switch tab or
    close browser's tab without any confirmation modal
  - Do the same for account outcomes > Calculation
  - Do the same for course outcomes

Change-Id: I31573a3290115d0d0e2de6a4343ad7d8cdce6d87
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/254003
Reviewed-by: Pat Renner <prenner@instructure.com>
QA-Review: Pat Renner <prenner@instructure.com>
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Product-Review: Jody Sailor
This commit is contained in:
Manoel Quirino Neto 2020-12-01 16:25:33 -03:00 committed by Manoel Quirino
parent 32a65e8dd0
commit 63eb21dfd9
14 changed files with 473 additions and 227 deletions

View File

@ -25,7 +25,7 @@ const ManagementHeader = () => {
const noop = () => {}
return (
<div className="management-header">
<div className="management-header" data-testid="managementHeader">
<div>
<h2 className="title">{I18n.t('Outcomes')}</h2>
</div>

View File

@ -180,15 +180,29 @@ const getModalText = contextType => {
)
}
const ProficiencyCalculation = ({method, update, updateError, canManage, contextType}) => {
const ProficiencyCalculation = ({
method,
update,
updateError,
canManage,
contextType,
onNotifyPendingChanges
}) => {
const {calculationMethod: initialMethodKey, calculationInt: initialInt} = method
const [calculationMethodKey, setCalculationMethodKey] = useState(initialMethodKey)
const [calculationInt, setCalculationInt] = useState(initialInt)
const [allowSave, setAllowSave] = useState(false)
const [allowSave, realSetAllowSave] = useState(false)
const [showConfirmation, setShowConfirmationModal] = useState(false)
const setAllowSave = newAllowSave => {
realSetAllowSave(newAllowSave)
if (onNotifyPendingChanges) {
onNotifyPendingChanges(newAllowSave)
}
}
useEffect(() => {
if (updateError) {
$.flashError(I18n.t('An error occurred updating the calculation method'))
@ -284,6 +298,7 @@ ProficiencyCalculation.propTypes = {
}),
canManage: PropTypes.bool,
update: PropTypes.func.isRequired,
onNotifyPendingChanges: PropTypes.func,
updateError: PropTypes.string,
contextType: PropTypes.string.isRequired
}

View File

@ -44,6 +44,7 @@ describe('ProficiencyCalculation', () => {
const makeProps = (overrides = {}) => ({
update: Function.prototype,
canManage: true,
contextType: 'Account',
...overrides,
method: {
calculationMethod: 'decaying_average',
@ -159,6 +160,22 @@ describe('ProficiencyCalculation', () => {
expect(update).toHaveBeenCalledWith('decaying_average', 41)
})
it('calls onNotifyPendingChanges when changes data', async () => {
const onNotifyPendingChangesSpy = jest.fn()
const {getByText, getByLabelText} = render(
<ProficiencyCalculation
{...makeProps({onNotifyPendingChanges: onNotifyPendingChangesSpy})}
/>
)
const parameter = getByLabelText('Parameter')
fireEvent.input(parameter, {target: {value: '22'}})
expect(onNotifyPendingChangesSpy).toHaveBeenCalledWith(true)
onNotifyPendingChangesSpy.mockClear()
fireEvent.click(getByText('Save Mastery Calculation'))
fireEvent.click(getByText('Save'))
expect(onNotifyPendingChangesSpy).toHaveBeenCalledWith(false)
})
it('save button is initially disabled', () => {
const {getByText} = render(<ProficiencyCalculation {...makeProps()} />)
expect(getByText('Save Mastery Calculation').closest('button').disabled).toEqual(true)

View File

@ -19,12 +19,9 @@
import React from 'react'
import {render, wait, fireEvent, waitForElementToBeRemoved} from '@testing-library/react'
import {MockedProvider} from '@apollo/react-testing'
import {
ACCOUNT_OUTCOME_PROFICIENCY_QUERY,
COURSE_OUTCOME_PROFICIENCY_QUERY,
SET_OUTCOME_CALCULATION_METHOD
} from '../api'
import {ACCOUNT_OUTCOME_CALCULATION_QUERY, SET_OUTCOME_CALCULATION_METHOD} from '../api'
import MasteryCalculation from '../index'
import {masteryCalculationGraphqlMocks as mocks} from '../../__tests__/mocks'
describe('MasteryCalculation', () => {
beforeEach(() => {
@ -55,50 +52,6 @@ describe('MasteryCalculation', () => {
window.ENV = null
})
const outcomeCalculationMethod = {
__typename: 'OutcomeCalculationMethod',
_id: '1',
contextType: 'Account',
contextId: 1,
calculationMethod: 'decaying_average',
calculationInt: 65
}
const mocks = [
{
request: {
query: ACCOUNT_OUTCOME_PROFICIENCY_QUERY,
variables: {
contextId: '11'
}
},
result: {
data: {
context: {
__typename: 'Account',
outcomeCalculationMethod
}
}
}
},
{
request: {
query: COURSE_OUTCOME_PROFICIENCY_QUERY,
variables: {
contextId: '12'
}
},
result: {
data: {
context: {
__typename: 'Course',
outcomeCalculationMethod
}
}
}
}
]
it('loads proficiency data', async () => {
const {getByText, queryByText, getByDisplayValue} = render(
<MockedProvider mocks={mocks}>
@ -153,7 +106,7 @@ describe('MasteryCalculation', () => {
const emptyMocks = [
{
request: {
query: ACCOUNT_OUTCOME_PROFICIENCY_QUERY,
query: ACCOUNT_OUTCOME_CALCULATION_QUERY,
variables: {
contextId: '11'
}

View File

@ -18,7 +18,7 @@
import {gql} from 'jsx/canvas-apollo'
export const ACCOUNT_OUTCOME_PROFICIENCY_QUERY = gql`
export const ACCOUNT_OUTCOME_CALCULATION_QUERY = gql`
query GetOutcomeProficiencyData($contextId: ID!) {
context: account(id: $contextId) {
outcomeCalculationMethod {
@ -32,7 +32,7 @@ export const ACCOUNT_OUTCOME_PROFICIENCY_QUERY = gql`
}
`
export const COURSE_OUTCOME_PROFICIENCY_QUERY = gql`
export const COURSE_OUTCOME_CALCULATION_QUERY = gql`
query GetOutcomeProficiencyData($contextId: ID!) {
context: course(id: $contextId) {
outcomeCalculationMethod {

View File

@ -24,15 +24,15 @@ import {Text} from '@instructure/ui-text'
import ProficiencyCalculation from './ProficiencyCalculation'
import RoleList from '../RoleList'
import {
ACCOUNT_OUTCOME_PROFICIENCY_QUERY,
COURSE_OUTCOME_PROFICIENCY_QUERY,
ACCOUNT_OUTCOME_CALCULATION_QUERY,
COURSE_OUTCOME_CALCULATION_QUERY,
SET_OUTCOME_CALCULATION_METHOD
} from './api'
import {useQuery, useMutation} from 'react-apollo'
const MasteryCalculation = ({contextType, contextId}) => {
const MasteryCalculation = ({contextType, contextId, onNotifyPendingChanges}) => {
const query =
contextType === 'Course' ? COURSE_OUTCOME_PROFICIENCY_QUERY : ACCOUNT_OUTCOME_PROFICIENCY_QUERY
contextType === 'Course' ? COURSE_OUTCOME_CALCULATION_QUERY : ACCOUNT_OUTCOME_CALCULATION_QUERY
const {loading, error, data} = useQuery(query, {
variables: {contextId}
})
@ -77,6 +77,7 @@ const MasteryCalculation = ({contextType, contextId}) => {
update={setCalculationMethod}
updateError={setCalculationMethodError}
canManage={canManage}
onNotifyPendingChanges={onNotifyPendingChanges}
/>
{accountRoles.length > 0 && (

View File

@ -57,7 +57,6 @@ const configToState = data => {
return {
rows,
savedRows: rows,
allowSave: false,
showConfirmation: false
}
}
@ -68,7 +67,8 @@ class ProficiencyTable extends React.Component {
update: PropTypes.func.isRequired,
focusTab: PropTypes.func,
breakpoints: breakpointsShape,
contextType: PropTypes.string.isRequired
contextType: PropTypes.string.isRequired,
onNotifyPendingChanges: PropTypes.func
}
static defaultProps = {
@ -96,11 +96,20 @@ class ProficiencyTable extends React.Component {
componentDidUpdate() {
if (this.fieldWithFocus()) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState(({rows}) => ({rows: rows.map(row => row.delete('focusField'))}))
this.setState(
({rows}) => ({rows: rows.map(row => row.delete('focusField'))}),
this.notifyPendingChanges
)
}
}
allowSave = () => {
notifyPendingChanges = () => {
if (this.props.onNotifyPendingChanges) {
this.props.onNotifyPendingChanges(this.hasPendingChanges())
}
}
hasPendingChanges = () => {
const {rows, savedRows} = this.state
return !_.isEqual(rows, savedRows)
@ -125,6 +134,7 @@ class ProficiencyTable extends React.Component {
return {rows: rows.push(newRow)}
},
() => {
this.notifyPendingChanges()
$.screenReaderFlashMessage(I18n.t('Added mastery level'))
}
)
@ -149,6 +159,7 @@ class ProficiencyTable extends React.Component {
}
},
() => {
this.notifyPendingChanges()
this.props
.update(this.stateToConfig())
.then(() => $.flashMessage(I18n.t(`Mastery scale saved`)))
@ -158,7 +169,7 @@ class ProficiencyTable extends React.Component {
message: e.message
})
)
this.setState({savedRows: oldRows})
this.setState({savedRows: oldRows}, this.notifyPendingChanges)
})
}
)
@ -171,7 +182,7 @@ class ProficiencyTable extends React.Component {
.setIn([masteryIndex, 'mastery'], false)
.setIn([index, 'mastery'], true)
return {rows: adjustedRows}
})
}, this.notifyPendingChanges)
})
handleDescriptionChange = _.memoize(index => value => {
@ -181,7 +192,7 @@ class ProficiencyTable extends React.Component {
}
rows = rows.setIn([index, 'description'], value)
return {rows}
})
}, this.notifyPendingChanges)
})
handlePointsChange = _.memoize(index => value => {
@ -192,13 +203,16 @@ class ProficiencyTable extends React.Component {
}
rows = rows.setIn([index, 'points'], parsed)
return {rows}
})
}, this.notifyPendingChanges)
})
handleColorChange = _.memoize(index => value => {
this.setState(({rows}) => ({
rows: rows.update(index, row => row.set('color', unformatColor(value)))
}))
this.setState(
({rows}) => ({
rows: rows.update(index, row => row.set('color', unformatColor(value)))
}),
this.notifyPendingChanges
)
})
handleDelete = _.memoize(index => () => {
@ -214,12 +228,17 @@ class ProficiencyTable extends React.Component {
}
if (index === 0) {
this.setState({rows})
this.setState({rows}, this.notifyPendingChanges)
if (this.props.focusTab) {
setTimeout(this.props.focusTab, 700)
}
} else {
this.setState({rows: rows.setIn([index - 1, 'focusField'], 'trash')})
this.setState(
{
rows: rows.setIn([index - 1, 'focusField'], 'trash')
},
this.notifyPendingChanges
)
}
$.screenReaderFlashMessage(I18n.t('Mastery level deleted'))
})
@ -289,7 +308,7 @@ class ProficiencyTable extends React.Component {
return r
})
if (changed) {
this.setState({rows})
this.setState({rows}, this.notifyPendingChanges)
}
return hasError
}
@ -395,7 +414,7 @@ class ProficiencyTable extends React.Component {
<div className="save">
<Button
variant="primary"
interaction={this.allowSave() ? 'enabled' : 'disabled'}
interaction={this.hasPendingChanges() ? 'enabled' : 'disabled'}
onClick={this.confirmSubmit}
>
{I18n.t('Save Mastery Scale')}

View File

@ -37,7 +37,7 @@ describe('default proficiency', () => {
})
it('renders the correct headers', () => {
const {getByText} = render(<ProficiencyTable {...defaultProps} />)
const {getByText} = render(<ProficiencyTable {...defaultProps()} />)
expect(getByText('Mastery')).not.toBeNull()
expect(getByText('Description')).not.toBeNull()
expect(getByText('Points')).not.toBeNull()
@ -45,13 +45,13 @@ describe('default proficiency', () => {
})
it('renders five ratings', () => {
const {getAllByLabelText} = render(<ProficiencyTable {...defaultProps} />)
const {getAllByLabelText} = render(<ProficiencyTable {...defaultProps()} />)
const inputs = getAllByLabelText(/Change description/)
expect(inputs.length).toEqual(5)
})
it('clicking button adds rating', () => {
const {getByText, getAllByLabelText} = render(<ProficiencyTable {...defaultProps} />)
const {getByText, getAllByLabelText} = render(<ProficiencyTable {...defaultProps()} />)
const button = getByText(/Add Mastery Level/)
fireEvent.click(button)
const inputs = getAllByLabelText(/Change description/)
@ -59,21 +59,21 @@ describe('default proficiency', () => {
})
it('clicking add rating button flashes SR message', () => {
const {getByText} = render(<ProficiencyTable {...defaultProps} />)
const {getByText} = render(<ProficiencyTable {...defaultProps()} />)
const button = getByText(/Add Mastery Level/)
fireEvent.click(button)
expect(srFlashMock).toHaveBeenCalledTimes(1)
})
it('handling delete rating removes rating and flashes SR message', () => {
const {getAllByText, getByText} = render(<ProficiencyTable {...defaultProps} />)
const {getAllByText, getByText} = render(<ProficiencyTable {...defaultProps()} />)
fireEvent.click(getAllByText(/Delete mastery level/)[0])
fireEvent.click(getByText(/Confirm/))
expect(srFlashMock).toHaveBeenCalledTimes(1)
})
it('setting blank description sets error and focus', async () => {
const {getByDisplayValue, getByText} = render(<ProficiencyTable {...defaultProps} />)
const {getByDisplayValue, getByText} = render(<ProficiencyTable {...defaultProps()} />)
const masteryField = getByDisplayValue('Mastery')
fireEvent.change(masteryField, {target: {value: ''}})
fireEvent.click(getByText('Save Mastery Scale'))
@ -85,7 +85,7 @@ describe('default proficiency', () => {
})
it('setting blank points sets error and focus', async () => {
const {getByDisplayValue, getByText} = render(<ProficiencyTable {...defaultProps} />)
const {getByDisplayValue, getByText} = render(<ProficiencyTable {...defaultProps()} />)
const pointsInput = getByDisplayValue('3')
fireEvent.change(pointsInput, {target: {value: ''}})
fireEvent.click(getByText('Save Mastery Scale'))
@ -95,7 +95,7 @@ describe('default proficiency', () => {
})
it('setting invalid points sets error and focus', async () => {
const {getByDisplayValue, getByText} = render(<ProficiencyTable {...defaultProps} />)
const {getByDisplayValue, getByText} = render(<ProficiencyTable {...defaultProps()} />)
const pointsInput = getByDisplayValue('3')
fireEvent.change(pointsInput, {target: {value: '1.1.1'}})
fireEvent.click(getByText('Save Mastery Scale'))
@ -105,7 +105,7 @@ describe('default proficiency', () => {
})
it('setting negative points sets error and focus', async () => {
const {getByDisplayValue, getByText} = render(<ProficiencyTable {...defaultProps} />)
const {getByDisplayValue, getByText} = render(<ProficiencyTable {...defaultProps()} />)
const pointsInput = getByDisplayValue('3')
fireEvent.change(pointsInput, {target: {value: '-1'}})
fireEvent.click(getByText('Save Mastery Scale'))
@ -115,7 +115,7 @@ describe('default proficiency', () => {
})
it('setting duplicate point values sets error and focus', async () => {
const {getByDisplayValue, getByText} = render(<ProficiencyTable {...defaultProps} />)
const {getByDisplayValue, getByText} = render(<ProficiencyTable {...defaultProps()} />)
const pointsInput = getByDisplayValue('3')
fireEvent.change(pointsInput, {target: {value: '4'}})
fireEvent.click(getByText('Save Mastery Scale'))
@ -125,7 +125,7 @@ describe('default proficiency', () => {
})
it('only sets focus on the first error', () => {
const {getByDisplayValue, getByText} = render(<ProficiencyTable {...defaultProps} />)
const {getByDisplayValue, getByText} = render(<ProficiencyTable {...defaultProps()} />)
const masteryField = getByDisplayValue('Mastery')
fireEvent.change(masteryField, {target: {value: ''}})
const pointsInput = getByDisplayValue('3')
@ -153,7 +153,7 @@ describe('default proficiency', () => {
it('does not call save when canceling on the confirmation modal', async () => {
const updateSpy = jest.fn(() => Promise.resolve())
const {getByDisplayValue, getByText} = render(
<ProficiencyTable {...defaultProps} update={updateSpy} />
<ProficiencyTable {...defaultProps()} update={updateSpy} />
)
const masteryField = getByDisplayValue('Mastery')
fireEvent.change(masteryField, {target: {value: 'Mastery2'}})
@ -165,7 +165,7 @@ describe('default proficiency', () => {
it('empty rating description does not call update', () => {
const updateSpy = jest.fn(() => Promise.resolve())
const {getByDisplayValue, getByText} = render(
<ProficiencyTable {...defaultProps} update={updateSpy} />
<ProficiencyTable {...defaultProps()} update={updateSpy} />
)
const masteryField = getByDisplayValue('Mastery')
fireEvent.change(masteryField, {target: {value: ''}})
@ -176,7 +176,7 @@ describe('default proficiency', () => {
it('empty rating points does not call update', () => {
const updateSpy = jest.fn(() => Promise.resolve())
const {getByDisplayValue, getByText} = render(
<ProficiencyTable {...defaultProps} update={updateSpy} />
<ProficiencyTable {...defaultProps()} update={updateSpy} />
)
const pointsInput = getByDisplayValue('3')
fireEvent.change(pointsInput, {target: {value: ''}})
@ -187,7 +187,7 @@ describe('default proficiency', () => {
it('invalid rating points does not call update', () => {
const updateSpy = jest.fn(() => Promise.resolve())
const {getByDisplayValue, getByText} = render(
<ProficiencyTable {...defaultProps} update={updateSpy} />
<ProficiencyTable {...defaultProps()} update={updateSpy} />
)
const pointsInput = getByDisplayValue('3')
fireEvent.change(pointsInput, {target: {value: '1.1.1'}})
@ -198,7 +198,7 @@ describe('default proficiency', () => {
it('increasing rating points does call update', () => {
const updateSpy = jest.fn(() => Promise.resolve())
const {getByDisplayValue, getByText} = render(
<ProficiencyTable {...defaultProps} update={updateSpy} />
<ProficiencyTable {...defaultProps()} update={updateSpy} />
)
const pointsInput = getByDisplayValue('3')
fireEvent.change(pointsInput, {target: {value: '1000'}})
@ -210,7 +210,7 @@ describe('default proficiency', () => {
it('negative rating points does not call update', () => {
const updateSpy = jest.fn(() => Promise.resolve())
const {getByDisplayValue, getByText} = render(
<ProficiencyTable {...defaultProps} update={updateSpy} />
<ProficiencyTable {...defaultProps()} update={updateSpy} />
)
const pointsInput = getByDisplayValue('3')
fireEvent.change(pointsInput, {target: {value: '-10'}})
@ -219,16 +219,13 @@ describe('default proficiency', () => {
})
it('save button is initially disabled', () => {
const {getByText} = render(<ProficiencyTable {...defaultProps} />)
const {getByText} = render(<ProficiencyTable {...defaultProps()} />)
const saveButton = getByText('Save Mastery Scale').closest('button')
expect(saveButton.disabled).toEqual(true)
})
it('save errors do not disable the save button', () => {
const updateSpy = jest.fn(() => Promise.reject())
const {getByText, getByDisplayValue} = render(
<ProficiencyTable {...defaultProps} update={updateSpy} />
)
const {getByText, getByDisplayValue} = render(<ProficiencyTable {...defaultProps()} />)
const pointsInput = getByDisplayValue('3')
fireEvent.change(pointsInput, {target: {value: '100'}})
@ -237,12 +234,37 @@ describe('default proficiency', () => {
const saveButton = getByText('Save Mastery Scale').closest('button')
expect(saveButton.disabled).toEqual(false)
})
it('calls onNotifyPendingChanges when changes data', async () => {
const onNotifyPendingChangesSpy = jest.fn()
const update = () => Promise.resolve()
const {getByText, getByDisplayValue} = render(
<ProficiencyTable
{...defaultProps()}
onNotifyPendingChanges={onNotifyPendingChangesSpy}
update={update}
/>
)
const pointsInput = getByDisplayValue('3')
fireEvent.change(pointsInput, {target: {value: '100'}})
fireEvent.click(getByText('Save Mastery Scale'))
fireEvent.click(getByText('Save'))
await wait(() => {
expect(onNotifyPendingChangesSpy.mock.calls.length).toBe(2)
// first call first argument
expect(onNotifyPendingChangesSpy.mock.calls[0][0]).toBe(true)
// second call first argument
expect(onNotifyPendingChangesSpy.mock.calls[1][0]).toBe(false)
})
})
})
describe('custom proficiency', () => {
it('renders two ratings that are deletable', () => {
const customProficiencyProps = {
...defaultProps,
...defaultProps(),
proficiency: {
proficiencyRatingsConnection: {
nodes: [
@ -290,7 +312,7 @@ describe('custom proficiency', () => {
mastery: false
}
const customProficiencyProps = {
...defaultProps,
...defaultProps(),
proficiency: {
proficiencyRatingsConnection: {
nodes: [defaultRating1, defaultRating2, defaultRating3]
@ -394,7 +416,6 @@ describe('custom proficiency', () => {
const pointsInput = getByDisplayValue('3')
const masteryButton = getAllByText(/Mastery.*for mastery level/)[2].closest('label')
const deleteButton = getAllByText(/Delete mastery level/)[0].closest('button')
fireEvent.change(pointsInput, {target: {value: '20'}})
fireEvent.click(masteryButton)
@ -415,7 +436,7 @@ describe('custom proficiency', () => {
it('renders one rating that is not deletable', () => {
const props = {
...defaultProps,
...defaultProps(),
proficiency: {
proficiencyRatingsConnection: {
nodes: [
@ -437,7 +458,7 @@ describe('custom proficiency', () => {
describe('can not manage', () => {
const props = {
...defaultProps,
...defaultProps(),
canManage: false,
proficiency: {
proficiencyRatingsConnection: {
@ -473,7 +494,7 @@ describe('custom proficiency', () => {
describe('confirmation modal', () => {
it('renders correct text for the Account context', () => {
const {getByDisplayValue, getByText} = render(<ProficiencyTable {...defaultProps} />)
const {getByDisplayValue, getByText} = render(<ProficiencyTable {...defaultProps()} />)
const pointsInput = getByDisplayValue('3')
fireEvent.change(pointsInput, {target: {value: '1000'}})
fireEvent.click(getByText('Save Mastery Scale'))
@ -483,7 +504,7 @@ describe('confirmation modal', () => {
it('renders correct text for the Course context', () => {
const {getByDisplayValue, getByText} = render(
<ProficiencyTable {...defaultProps} contextType="Course" />
<ProficiencyTable {...defaultProps()} contextType="Course" />
)
const pointsInput = getByDisplayValue('3')
fireEvent.change(pointsInput, {target: {value: '1000'}})

View File

@ -20,8 +20,9 @@ import React from 'react'
import {render, wait, fireEvent} from '@testing-library/react'
import {MockedProvider} from '@apollo/react-testing'
import moxios from 'moxios'
import {ACCOUNT_OUTCOME_PROFICIENCY_QUERY, COURSE_OUTCOME_PROFICIENCY_QUERY} from '../api'
import {ACCOUNT_OUTCOME_PROFICIENCY_QUERY} from '../api'
import MasteryScale from '../index'
import {masteryScalesGraphqlMocks as mocks} from '../../__tests__/mocks'
describe('MasteryScale', () => {
beforeEach(() => {
@ -52,70 +53,6 @@ describe('MasteryScale', () => {
window.ENV = null
})
const outcomeProficiency = {
__typename: 'OutcomeProficiency',
_id: '1',
contextId: 1,
contextType: 'Account',
locked: false,
proficiencyRatingsConnection: {
__typename: 'ProficiencyRatingConnection',
nodes: [
{
__typename: 'ProficiencyRating',
_id: '2',
color: '009606',
description: 'Rating A',
mastery: false,
points: 9
},
{
__typename: 'ProficiencyRating',
_id: '6',
color: 'EF4437',
description: 'Rating B',
mastery: false,
points: 6
}
]
}
}
const mocks = [
{
request: {
query: ACCOUNT_OUTCOME_PROFICIENCY_QUERY,
variables: {
contextId: '11'
}
},
result: {
data: {
context: {
__typename: 'Account',
outcomeProficiency
}
}
}
},
{
request: {
query: COURSE_OUTCOME_PROFICIENCY_QUERY,
variables: {
contextId: '12'
}
},
result: {
data: {
context: {
__typename: 'Course',
outcomeProficiency
}
}
}
}
]
it('loads proficiency data', async () => {
const {getByText, getByDisplayValue} = render(
<MockedProvider mocks={mocks}>

View File

@ -29,13 +29,13 @@ import {
} from './api'
import {useQuery} from 'react-apollo'
const MasteryScale = ({contextType, contextId}) => {
const MasteryScale = ({contextType, contextId, onNotifyPendingChanges}) => {
const query =
contextType === 'Course' ? COURSE_OUTCOME_PROFICIENCY_QUERY : ACCOUNT_OUTCOME_PROFICIENCY_QUERY
const {loading, error, data} = useQuery(query, {
variables: {contextId},
fetchPolicy: 'no-cache'
fetchPolicy: process.env.NODE_ENV === 'test' ? undefined : 'no-cache'
})
const [updateProficiencyRatingsError, setUpdateProficiencyRatingsError] = useState(null)
@ -78,8 +78,9 @@ const MasteryScale = ({contextType, contextId}) => {
const roles = ENV.PROFICIENCY_SCALES_ENABLED_ROLES || []
const accountRoles = roles.filter(role => role.is_account_role)
const canManage = ENV.PERMISSIONS.manage_proficiency_scales
return (
<div>
<div data-testid="masteryScales">
{canManage && contextType === 'Account' && (
<p>
<Text>
@ -95,6 +96,7 @@ const MasteryScale = ({contextType, contextId}) => {
proficiency={outcomeProficiency || undefined} // send undefined when value is null
update={updateProficiencyRatings}
updateError={updateProficiencyRatingsError}
onNotifyPendingChanges={onNotifyPendingChanges}
/>
{accountRoles.length > 0 && (

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, useMemo} from 'react'
import React, {useState, useEffect, useMemo, useRef} from 'react'
import I18n from 'i18n!OutcomeManagement'
import {Tabs} from '@instructure/ui-tabs'
import MasteryScale from 'jsx/outcomes/MasteryScale'
@ -40,25 +40,59 @@ export const OutcomePanel = () => {
return null
}
const OutcomeManagement = () => {
export const OutcomeManagementWithoutGraphql = () => {
const improvedManagement = ENV?.IMPROVED_OUTCOMES_MANAGEMENT
const [selectedIndex, setSelectedIndex] = useState(() => {
const tabs = {'#mastery_scale': 1, '#mastery_calculation': 2}
return window.location.hash in tabs ? tabs[window.location.hash] : 0
})
// Need to use a ref because a when normal setState is changed, this component will render
// By rendering again, the handleTabChange will be a new function.
// If we pass a new function to Tabs onRequestTabChange prop, it will recreate
// the childs components, and we don't want that
const hasUnsavedChangesRef = useRef(false)
const setHasUnsavedChanges = hasUnsavedChanges =>
(hasUnsavedChangesRef.current = hasUnsavedChanges)
const handleTabChange = (_, {index}) => {
setSelectedIndex(index)
if (hasUnsavedChangesRef.current) {
/* eslint-disable no-restricted-globals */
/* eslint-disable no-alert */
if (
confirm(I18n.t('Are you sure you want to proceed? Changes you made will not be saved.'))
) {
/* eslint-enable no-restricted-globals */
/* eslint-enable no-alert */
setHasUnsavedChanges(false)
setSelectedIndex(index)
}
} else {
setSelectedIndex(index)
}
}
const client = useMemo(() => createClient(), [])
// close tab / load link
useEffect(() => {
const onBeforeUnload = e => {
if (hasUnsavedChangesRef.current) {
e.preventDefault()
e.returnValue = true
}
}
window.addEventListener('beforeunload', onBeforeUnload)
return () => {
window.removeEventListener('beforeunload', onBeforeUnload)
}
}, [hasUnsavedChangesRef])
const [snakeContextType, contextId] = ENV.context_asset_string.split('_')
const contextType = snakeContextType === 'course' ? 'Course' : 'Account'
return (
<ApolloProvider client={client}>
<>
{improvedManagement && <ManagementHeader />}
<Tabs onRequestTabChange={handleTabChange}>
<Tabs.Panel renderTitle={I18n.t('Manage')} isSelected={selectedIndex === 0}>
@ -69,12 +103,30 @@ const OutcomeManagement = () => {
)}
</Tabs.Panel>
<Tabs.Panel renderTitle={I18n.t('Mastery')} isSelected={selectedIndex === 1}>
<MasteryScale contextType={contextType} contextId={contextId} />
<MasteryScale
onNotifyPendingChanges={setHasUnsavedChanges}
contextType={contextType}
contextId={contextId}
/>
</Tabs.Panel>
<Tabs.Panel renderTitle={I18n.t('Calculation')} isSelected={selectedIndex === 2}>
<MasteryCalculation contextType={contextType} contextId={contextId} />
<MasteryCalculation
onNotifyPendingChanges={setHasUnsavedChanges}
contextType={contextType}
contextId={contextId}
/>
</Tabs.Panel>
</Tabs>
</>
)
}
const OutcomeManagement = () => {
const client = useMemo(() => createClient(), [])
return (
<ApolloProvider client={client}>
<OutcomeManagementWithoutGraphql />
</ApolloProvider>
)
}

View File

@ -24,7 +24,7 @@ import React from 'react'
const OutcomeManagementPanel = ({contextType, contextId}) => {
const isCourse = contextType === 'Course'
return (
<div className="management-panel">
<div className="management-panel" data-testid="outcomeManagementPanel">
<Billboard
size="large"
headingLevel="h3"

View File

@ -17,45 +17,162 @@
*/
import React from 'react'
import {mount, shallow} from 'enzyme'
import OutcomeManagement, {OutcomePanel} from '../OutcomeManagement'
import {render, fireEvent, act} from '@testing-library/react'
import {MockedProvider} from '@apollo/react-testing'
import {
OutcomePanel,
OutcomeManagementWithoutGraphql as OutcomeManagement
} from '../OutcomeManagement'
import {masteryCalculationGraphqlMocks, masteryScalesGraphqlMocks} from './mocks'
jest.useFakeTimers()
describe('OutcomeManagement', () => {
const sharedExamples = () => {
it('renders the OutcomeManagement and shows the "outcomes" div', () => {
const wrapper = shallow(<OutcomeManagement />)
expect(wrapper.find('OutcomePanel').exists()).toBe(true)
document.body.innerHTML = '<div id="outcomes" style="display:none">Outcomes Tab</div>'
render(<OutcomeManagement />)
expect(document.getElementById('outcomes').style.display).toBe('block')
})
it('does not render ManagementHeader', () => {
const wrapper = shallow(<OutcomeManagement />)
expect(wrapper.find('ManagementHeader').exists()).toBe(false)
const {queryByTestId} = render(<OutcomeManagement />)
expect(queryByTestId('managementHeader')).not.toBeInTheDocument()
})
it('renders ManagementHeader when improved outcomes enabled', () => {
window.ENV.IMPROVED_OUTCOMES_MANAGEMENT = true
const wrapper = shallow(<OutcomeManagement />)
expect(wrapper.find('ManagementHeader').exists()).toBe(true)
delete window.ENV.IMPROVED_OUTCOMES_MANAGEMENT
})
it('renders OutcomeManagementPanel when improved outcomes enabled', () => {
window.ENV.IMPROVED_OUTCOMES_MANAGEMENT = true
const wrapper = shallow(<OutcomeManagement />)
expect(wrapper.find('OutcomeManagementPanel').exists()).toBe(true)
const {queryByTestId} = render(<OutcomeManagement />)
expect(queryByTestId('managementHeader')).toBeInTheDocument()
delete window.ENV.IMPROVED_OUTCOMES_MANAGEMENT
})
it('does not render OutcomeManagementPanel when improved outcomes disabled', () => {
const wrapper = shallow(<OutcomeManagement />)
expect(wrapper.find('OutcomeManagementPanel').exists()).toBe(false)
const {queryByTestId} = render(<OutcomeManagement />)
expect(queryByTestId('outcomeManagementPanel')).not.toBeInTheDocument()
})
it('renders OutcomeManagementPanel when improved outcomes enabled', () => {
window.ENV.IMPROVED_OUTCOMES_MANAGEMENT = true
const {queryByTestId} = render(<OutcomeManagement />)
expect(queryByTestId('outcomeManagementPanel')).toBeInTheDocument()
delete window.ENV.IMPROVED_OUTCOMES_MANAGEMENT
})
describe('Changes confirmation', () => {
let originalConfirm, originalAddEventListener, unloadEventListener
beforeAll(() => {
originalConfirm = window.confirm
originalAddEventListener = window.addEventListener
window.confirm = jest.fn(() => true)
window.addEventListener = (eventName, callback) => {
if (eventName === 'beforeunload') {
unloadEventListener = callback
}
}
})
afterAll(() => {
window.confirm = originalConfirm
window.addEventListener = originalAddEventListener
unloadEventListener = null
})
it("Doesn't ask to confirm tab change when there is not change", () => {
const {getByText} = render(
<MockedProvider mocks={[...masteryCalculationGraphqlMocks, ...masteryScalesGraphqlMocks]}>
<OutcomeManagement />
</MockedProvider>
)
fireEvent.click(getByText('Mastery'))
fireEvent.click(getByText('Calculation'))
expect(window.confirm).not.toHaveBeenCalled()
})
it('Asks to confirm tab change when there is changes', () => {
const {getByText, getByLabelText, getByTestId} = render(
<MockedProvider mocks={[...masteryCalculationGraphqlMocks, ...masteryScalesGraphqlMocks]}>
<OutcomeManagement />
</MockedProvider>
)
fireEvent.click(getByText('Calculation'))
act(() => jest.runAllTimers())
fireEvent.input(getByLabelText('Parameter'), {target: {value: ''}})
fireEvent.click(getByText('Mastery'))
act(() => jest.runAllTimers())
expect(window.confirm).toHaveBeenCalled()
expect(getByTestId('masteryScales')).toBeInTheDocument()
})
it("Doesn't change tabs when doesn't confirm", () => {
// mock decline from user
window.confirm = () => false
const {getByText, getByLabelText, queryByTestId} = render(
<MockedProvider mocks={[...masteryCalculationGraphqlMocks, ...masteryScalesGraphqlMocks]}>
<OutcomeManagement />
</MockedProvider>
)
fireEvent.click(getByText('Calculation'))
act(() => jest.runAllTimers())
fireEvent.input(getByLabelText('Parameter'), {target: {value: ''}})
fireEvent.click(getByText('Mastery'))
expect(queryByTestId('masteryScales')).not.toBeInTheDocument()
})
it("Allows to leave page when doesn't have changes", () => {
const {getByText} = render(
<MockedProvider mocks={[...masteryCalculationGraphqlMocks, ...masteryScalesGraphqlMocks]}>
<OutcomeManagement />
</MockedProvider>
)
const calculationButton = getByText('Calculation')
fireEvent.click(calculationButton)
act(() => jest.runAllTimers())
const e = jest.mock()
e.preventDefault = jest.fn()
unloadEventListener(e)
expect(e.preventDefault).not.toHaveBeenCalled()
})
it("Doesn't Allow to leave page when has changes", async () => {
const {getByText, getByLabelText} = render(
<MockedProvider mocks={[...masteryCalculationGraphqlMocks, ...masteryScalesGraphqlMocks]}>
<OutcomeManagement />
</MockedProvider>
)
const calculationButton = getByText('Calculation')
fireEvent.click(calculationButton)
act(() => jest.runAllTimers())
const parameter = getByLabelText(/Parameter/)
fireEvent.input(parameter, {target: {value: '88'}})
const e = jest.mock()
e.preventDefault = jest.fn()
unloadEventListener(e)
expect(e.preventDefault).toHaveBeenCalled()
})
})
}
describe('account', () => {
beforeEach(() => {
window.ENV = {
context_asset_string: 'account_1'
context_asset_string: 'account_11',
PERMISSIONS: {
manage_proficiency_calculations: true
}
}
})
@ -64,25 +181,15 @@ describe('OutcomeManagement', () => {
})
sharedExamples()
it('passes accountId to the ProficiencyTable component', () => {
const wrapper = shallow(<OutcomeManagement />)
expect(wrapper.find('MasteryScale').prop('contextType')).toBe('Account')
expect(wrapper.find('MasteryScale').prop('contextId')).toBe('1')
})
it('passes accountId to the OutcomeManagementPanel component', () => {
window.ENV.IMPROVED_OUTCOMES_MANAGEMENT = true
const wrapper = shallow(<OutcomeManagement />)
expect(wrapper.find('OutcomeManagementPanel').prop('contextType')).toBe('Account')
expect(wrapper.find('OutcomeManagementPanel').prop('contextId')).toBe('1')
})
})
describe('course', () => {
beforeEach(() => {
window.ENV = {
context_asset_string: 'course_2'
context_asset_string: 'course_12',
PERMISSIONS: {
manage_proficiency_calculations: true
}
}
})
@ -91,19 +198,6 @@ describe('OutcomeManagement', () => {
})
sharedExamples()
it('passes courseId to the ProficiencyTable component', () => {
const wrapper = shallow(<OutcomeManagement />)
expect(wrapper.find('MasteryScale').prop('contextType')).toBe('Course')
expect(wrapper.find('MasteryScale').prop('contextId')).toBe('2')
})
it('passes courseId to the OutcomeManagementPanel component', () => {
window.ENV.IMPROVED_OUTCOMES_MANAGEMENT = true
const wrapper = shallow(<OutcomeManagement />)
expect(wrapper.find('OutcomeManagementPanel').prop('contextType')).toBe('Course')
expect(wrapper.find('OutcomeManagementPanel').prop('contextId')).toBe('2')
})
})
})
@ -117,13 +211,13 @@ describe('OutcomePanel', () => {
})
it('sets style on mount', () => {
mount(<OutcomePanel />)
render(<OutcomePanel />)
expect(document.getElementById('outcomes').style.display).toBe('block')
})
it('sets style on unmount', () => {
const wrapper = mount(<OutcomePanel />)
wrapper.unmount()
const {unmount} = render(<OutcomePanel />)
unmount()
expect(document.getElementById('outcomes').style.display).toBe('none')
})
})

View File

@ -0,0 +1,135 @@
/*
* Copyright (C) 2020 - 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 {
ACCOUNT_OUTCOME_PROFICIENCY_QUERY,
COURSE_OUTCOME_PROFICIENCY_QUERY
} from '../MasteryScale/api'
import {
ACCOUNT_OUTCOME_CALCULATION_QUERY,
COURSE_OUTCOME_CALCULATION_QUERY
} from '../MasteryCalculation/api'
const outcomeCalculationMethod = {
__typename: 'OutcomeCalculationMethod',
_id: '1',
contextType: 'Account',
contextId: 1,
calculationMethod: 'decaying_average',
calculationInt: 65
}
const outcomeProficiency = {
__typename: 'OutcomeProficiency',
_id: '1',
contextId: 1,
contextType: 'Account',
locked: false,
proficiencyRatingsConnection: {
__typename: 'ProficiencyRatingConnection',
nodes: [
{
__typename: 'ProficiencyRating',
_id: '2',
color: '009606',
description: 'Rating A',
mastery: false,
points: 9
},
{
__typename: 'ProficiencyRating',
_id: '6',
color: 'EF4437',
description: 'Rating B',
mastery: false,
points: 6
}
]
}
}
export const masteryScalesGraphqlMocks = [
{
request: {
query: ACCOUNT_OUTCOME_PROFICIENCY_QUERY,
variables: {
contextId: '11'
}
},
result: {
data: {
context: {
__typename: 'Account',
outcomeProficiency
}
}
}
},
{
request: {
query: COURSE_OUTCOME_PROFICIENCY_QUERY,
variables: {
contextId: '12'
}
},
result: {
data: {
context: {
__typename: 'Course',
outcomeProficiency
}
}
}
}
]
export const masteryCalculationGraphqlMocks = [
{
request: {
query: ACCOUNT_OUTCOME_CALCULATION_QUERY,
variables: {
contextId: '11'
}
},
result: {
data: {
context: {
__typename: 'Account',
outcomeCalculationMethod
}
}
}
},
{
request: {
query: COURSE_OUTCOME_CALCULATION_QUERY,
variables: {
contextId: '12'
}
},
result: {
data: {
context: {
__typename: 'Course',
outcomeCalculationMethod
}
}
}
}
]