Set Mastery Level Independent from Individual Proficiency Ratings

closes OUT-4921
flag=individual_outcome_rating_and_calculation

Test plan:
- Enable Improved Outcomes Management FF
- Enable Individual Outcome Rating and Calculation FF
- Disable Account Level Mastery Scales FF
- Go to Account > Outcomes and select an outcome group
- Click on Create button and in Create modal enter outcome name
and delete the default rating with 0 points
- Try to delete mastery points or enter a value that is below
min rating or above max rating
- Verify that relevant error messages appear below the mastery
points input field and that Create button is disabled
- Enter value for mastery points between min and max rating and
verify that error msg disappears and Create button is enabled
- Click on Create button, when outcome is created expand its
description and verify that ratings & mastery points are correct
- Verify that the ratings in expanded description look like the
figma mock referenced in the ticket
- Click on kebab menu -> Edit and verify that the ratings in
Edit modal looks like the figma mock referenced in the ticket
- Add a rating, change mastery points then click Save
- Verify that the outcome is updated with correct data
- Select a global outcome added to Account context and click
on its kebab menu -> Edit
- Verify that ratings are displayed but user does not have
ability to change them
- Go to Course > Outcomes, repeat the above tests and verify
that results are the same

Change-Id: I315a617c236b0db816c7711c08017283ede0ac81
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/283610
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Kyle Rosenbaum <krosenbaum@instructure.com>
QA-Review: Marcus Pompeu <marcus.pompeu@instructure.com>
Product-Review: Ben Friedman <ben.friedman@instructure.com>
This commit is contained in:
Martin Yosifov 2022-02-01 09:12:18 -08:00
parent ca31b6bd2a
commit 23d5f3593b
13 changed files with 484 additions and 225 deletions

View File

@ -44,8 +44,11 @@ import OutcomesRceField from './shared/OutcomesRceField'
import ProficiencyCalculation, {
defaultProficiencyCalculation
} from './MasteryCalculation/ProficiencyCalculation'
import useRatings, {defaultOutcomesManagementRatings} from '@canvas/outcomes/react/hooks/useRatings'
import {convertRatings} from '@canvas/outcomes/react/helpers/ratingsHelpers'
import useRatings, {
defaultRatings,
defaultMasteryPoints
} from '@canvas/outcomes/react/hooks/useRatings'
import {processRatingsAndMastery} from '@canvas/outcomes/react/helpers/ratingsHelpers'
import Ratings from './Management/Ratings'
const CreateOutcomeModal = ({isOpen, onCloseHandler, onSuccess, starterGroupId}) => {
@ -69,9 +72,15 @@ const CreateOutcomeModal = ({isOpen, onCloseHandler, onSuccess, starterGroupId})
})
const {
ratings,
masteryPoints,
setRatings,
setMasteryPoints,
hasError: proficiencyRatingsError
} = useRatings({initialRatings: defaultOutcomesManagementRatings})
} = useRatings({
initialRatings: defaultRatings,
initialMasteryPoints: defaultMasteryPoints
})
const [selectedGroup, setSelectedGroup] = useState(null)
const [selectedGroupAncestorIds, setSelectedGroupAncestorIds] = useState([])
const [proficiencyCalculation, setProficiencyCalculation] = useState(
@ -133,8 +142,9 @@ const CreateOutcomeModal = ({isOpen, onCloseHandler, onSuccess, starterGroupId})
if (individualOutcomeRatingAndCalculationFF) {
input.calculationMethod = proficiencyCalculation.calculationMethod
input.calculationInt = proficiencyCalculation.calculationInt
const {masteryPoints, ratings: inputRatings} = convertRatings(ratings)
input.masteryPoints = masteryPoints
const {masteryPoints: inputMasteryPoints, ratings: inputRatings} =
processRatingsAndMastery(ratings, masteryPoints.value)
input.masteryPoints = inputMasteryPoints
input.ratings = inputRatings
}
const createLearningOutcomeResult = await createLearningOutcome({
@ -257,7 +267,13 @@ const CreateOutcomeModal = ({isOpen, onCloseHandler, onSuccess, starterGroupId})
)}
{individualOutcomeRatingAndCalculationFF && (
<View as="div" padding="small 0 0">
<Ratings ratings={ratings} onChangeRatings={setRatings} canManage />
<Ratings
ratings={ratings}
onChangeRatings={setRatings}
masteryPoints={masteryPoints}
onChangeMasteryPoints={setMasteryPoints}
canManage
/>
<View as="div" minHeight="14rem">
<hr style={{margin: '1rem 0 0'}} />
<ProficiencyCalculation

View File

@ -25,7 +25,7 @@ import {PresentationContent, ScreenReaderContent} from '@instructure/ui-a11y-con
import {stripHtmlTags} from '@canvas/outcomes/stripHtmlTags'
import useCanvasContext from '@canvas/outcomes/react/hooks/useCanvasContext'
import ProficiencyCalculation from '../MasteryCalculation/ProficiencyCalculation'
import {prepareRatings} from '@canvas/outcomes/react/helpers/ratingsHelpers'
import {prepareRatings} from '@canvas/outcomes/react/hooks/useRatings'
import Ratings from './Ratings'
import {ratingsShape} from './shapes'
@ -114,7 +114,14 @@ const OutcomeDescription = ({
{!truncated && individualOutcomeRatingAndCalculationFF && (
<View>
<Ratings ratings={prepareRatings(ratings, masteryPoints)} canManage={false} />
<Ratings
ratings={prepareRatings(ratings)}
masteryPoints={{
value: masteryPoints,
error: null
}}
canManage={false}
/>
<ProficiencyCalculation
method={{calculationMethod, calculationInt}}
individualOutcome="display"

View File

@ -41,7 +41,7 @@ import {useMutation} from 'react-apollo'
import OutcomesRceField from '../shared/OutcomesRceField'
import ProficiencyCalculation from '../MasteryCalculation/ProficiencyCalculation'
import useRatings from '@canvas/outcomes/react/hooks/useRatings'
import {convertRatings, prepareRatings} from '@canvas/outcomes/react/helpers/ratingsHelpers'
import {processRatingsAndMastery} from '@canvas/outcomes/react/helpers/ratingsHelpers'
import Ratings from './Ratings'
import {outcomeEditShape} from './shapes'
@ -57,10 +57,15 @@ const OutcomeEditModal = ({outcome, isOpen, onCloseHandler, onEditLearningOutcom
useCanvasContext()
const {
ratings,
masteryPoints,
setRatings,
setMasteryPoints,
hasError: proficiencyRatingsError,
hasChanged: proficiencyRatingsChanged
} = useRatings({initialRatings: prepareRatings(outcome.ratings, outcome.masteryPoints)})
} = useRatings({
initialRatings: outcome.ratings,
initialMasteryPoints: outcome.masteryPoints
})
const [updateLearningOutcomeMutation] = useMutation(UPDATE_LEARNING_OUTCOME)
const [setOutcomeFriendlyDescription] = useMutation(SET_OUTCOME_FRIENDLY_DESCRIPTION_MUTATION)
let attributesEditable = {
@ -127,8 +132,9 @@ const OutcomeEditModal = ({outcome, isOpen, onCloseHandler, onEditLearningOutcom
input.calculationInt = calculationInt
}
if (proficiencyRatingsChanged) {
const {masteryPoints, ratings: inputRatings} = convertRatings(ratings)
input.masteryPoints = masteryPoints
const {masteryPoints: inputMasteryPoints, ratings: inputRatings} =
processRatingsAndMastery(ratings, masteryPoints.value)
input.masteryPoints = inputMasteryPoints
input.ratings = inputRatings
}
}
@ -265,6 +271,8 @@ const OutcomeEditModal = ({outcome, isOpen, onCloseHandler, onEditLearningOutcom
<View as="div" padding="small 0 0">
<Ratings
ratings={ratings}
masteryPoints={masteryPoints}
onChangeMasteryPoints={setMasteryPoints}
onChangeRatings={setRatings}
canManage={!!attributesEditable.individualRatings}
/>

View File

@ -23,17 +23,27 @@ import {Flex} from '@instructure/ui-flex'
import {IconPlusLine} from '@instructure/ui-icons'
import I18n from 'i18n!OutcomeManagement'
import {View} from '@instructure/ui-view'
import {Text} from '@instructure/ui-text'
import {TextInput} from '@instructure/ui-text-input'
import {ScreenReaderContent} from '@instructure/ui-a11y-content'
import {createRating} from '@canvas/outcomes/react/hooks/useRatings'
import useCanvasContext from '@canvas/outcomes/react/hooks/useCanvasContext'
import ProficiencyRating from '../MasteryScale/ProficiencyRating'
const ratingsShape = PropTypes.shape({
key: PropTypes.string,
description: PropTypes.string,
points: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
mastery: PropTypes.bool
points: PropTypes.number,
descriptionError: PropTypes.string,
pointsError: PropTypes.string
})
const Ratings = ({ratings, onChangeRatings, canManage}) => {
const masteryPointsShape = PropTypes.shape({
value: PropTypes.number,
error: PropTypes.string
})
const Ratings = ({ratings, masteryPoints, onChangeRatings, onChangeMasteryPoints, canManage}) => {
const {isMobileView} = useCanvasContext()
const addRow = () => {
@ -99,30 +109,34 @@ const Ratings = ({ratings, onChangeRatings, canManage}) => {
[onChangeRatings]
)
const renderBorder = () => (
<View
width="100%"
textAlign="start"
margin="0 0 small 0"
as="div"
borderWidth="none none small none"
/>
const handleMasteryPointsChange = e => onChangeMasteryPoints(e.target.value)
const renderDisplayMasteryPoints = () => (
<View as="div" padding="small none none">
<Flex wrap="wrap" direction="row" padding="none small small none">
<Flex.Item
as="div"
padding="none xx-small none none"
data-testid="read-only-mastery-points"
>
<Text weight="bold">{I18n.t('Mastery at:')}</Text>
</Flex.Item>
<Flex.Item>
<Text color="primary">{I18n.t('%{points} points', {points: masteryPoints.value})}</Text>
</Flex.Item>
</Flex>
</View>
)
return (
<>
<Flex
width="100%"
padding={isMobileView ? '0 0 small 0' : '0 small small small'}
padding={isMobileView ? '0 0 small 0' : '0 small small 0'}
margin="medium none none"
data-testid="outcome-management-ratings"
>
<Flex.Item size={isMobileView ? '25%' : '15%'} padding="0 medium 0 0">
<div aria-hidden="true" className="header">
{I18n.t('Mastery')}
</div>
</Flex.Item>
<Flex.Item size={isMobileView ? '75%' : '65%'}>
<Flex.Item size={isMobileView ? '75%' : canManage ? '80%' : '60%'}>
<div aria-hidden="true" className="header">
{I18n.t('Proficiency Rating')}
</div>
@ -135,41 +149,73 @@ const Ratings = ({ratings, onChangeRatings, canManage}) => {
</Flex.Item>
)}
</Flex>
{renderBorder()}
{ratings.map(({key, description, descriptionError, pointsError, mastery, points}, index) => (
<React.Fragment key={key}>
<ProficiencyRating
description={description}
descriptionError={descriptionError}
disableDelete={ratings.length === 1}
mastery={mastery}
onDelete={() => handleDelete(index)}
onDescriptionChange={value => onRatingFieldChange('description', value, index)}
onMasteryChange={() => onRatingFieldChange('mastery', true, index)}
onPointsChange={value => onRatingFieldChange('points', value, index)}
points={points?.toString()}
pointsError={pointsError}
isMobileView={isMobileView}
position={index + 1}
canManage={canManage}
individualOutcome
/>
{renderBorder()}
</React.Fragment>
{ratings.map(({key, description, descriptionError, pointsError, points}, index) => (
<ProficiencyRating
key={key}
description={description}
descriptionError={descriptionError}
disableDelete={ratings.length === 1}
onDelete={() => handleDelete(index)}
onDescriptionChange={value => onRatingFieldChange('description', value, index)}
onMasteryChange={() => onRatingFieldChange('mastery', true, index)}
onPointsChange={value => onRatingFieldChange('points', value, index)}
points={points?.toString()}
pointsError={pointsError}
isMobileView={isMobileView}
position={index + 1}
canManage={canManage}
individualOutcome
/>
))}
{canManage && (
<View textAlign="center" padding="small" as="div">
<IconButton
onClick={addRow}
withBorder={false}
color="primary"
size="large"
shape="circle"
screenReaderLabel={I18n.t('Add Mastery Level')}
>
<IconPlusLine />
</IconButton>
</View>
{canManage ? (
<Flex width="100%" padding="small" alignItems="start">
<Flex.Item size="80%">
<Flex padding="0 small 0 0" textAlign="end">
<Flex.Item size="60%">
<IconButton
onClick={addRow}
withBorder={false}
color="primary"
size="medium"
shape="circle"
screenReaderLabel={I18n.t('Add Mastery Level')}
>
<IconPlusLine />
</IconButton>
</Flex.Item>
<Flex.Item size="40%">
<Text weight="bold">{I18n.t('Mastery at')}</Text>
</Flex.Item>
</Flex>
</Flex.Item>
<Flex.Item size="20%">
<Flex alignItems="start">
<Flex.Item>
<div className="points" data-testid="mastery-points-input">
<TextInput
type="text"
messages={
masteryPoints.error ? [{text: masteryPoints.error, type: 'error'}] : null
}
renderLabel={
<ScreenReaderContent>{I18n.t('Change mastery points')}</ScreenReaderContent>
}
onChange={handleMasteryPointsChange}
defaultValue={I18n.n(masteryPoints.value)}
width="4rem"
/>
</div>
</Flex.Item>
<Flex.Item>
<div style={{paddingTop: '0.45rem', paddingLeft: '0.60rem'}}>
{I18n.t('points')}
</div>
</Flex.Item>
</Flex>
</Flex.Item>
</Flex>
) : (
renderDisplayMasteryPoints()
)}
</>
)
@ -177,12 +223,20 @@ const Ratings = ({ratings, onChangeRatings, canManage}) => {
Ratings.propTypes = {
ratings: PropTypes.arrayOf(ratingsShape).isRequired,
masteryPoints: masteryPointsShape.isRequired,
canManage: PropTypes.bool,
onChangeRatings: PropTypes.func
onChangeRatings: PropTypes.func,
onChangeMasteryPoints: PropTypes.func
}
Ratings.defaultProps = {
onChangeRatings: () => {}
onChangeRatings: () => {},
onChangeMasteryPoints: () => {},
ratings: [],
masteryPoints: {
value: null,
error: null
}
}
export default Ratings

View File

@ -28,7 +28,7 @@ import {
updateLearningOutcomeMocks,
setFriendlyDescriptionOutcomeMock
} from '@canvas/outcomes/mocks/Management'
import {defaultOutcomesManagementRatings} from '@canvas/outcomes/react/hooks/useRatings'
import {defaultRatings, defaultMasteryPoints} from '@canvas/outcomes/react/hooks/useRatings'
jest.useFakeTimers()
@ -46,8 +46,8 @@ describe('OutcomeEditModal', () => {
contextId: '1',
calculationMethod: 'decaying_average',
calculationInt: 65,
masteryPoints: 3,
ratings: defaultOutcomesManagementRatings.map(rating => pick(rating, ['description', 'points']))
masteryPoints: defaultMasteryPoints,
ratings: defaultRatings.map(rating => pick(rating, ['description', 'points']))
}
const defaultProps = (props = {}) => ({
@ -328,7 +328,7 @@ describe('OutcomeEditModal', () => {
})
})
describe('with Individual Outcome Proficiency and Calculation Feature Flag enabled', () => {
describe('Individual Outcome Proficiency and Calculation Feature Flag', () => {
describe('when feature flag enabled', () => {
it('displays calculation method selection form if outcome is created in same context', async () => {
const {getByLabelText} = renderWithProvider({
@ -414,7 +414,7 @@ describe('OutcomeEditModal', () => {
const mocks = updateLearningOutcomeMocks({
description: 'Updated description',
displayName: 'Updated friendly outcome name',
ratings: defaultOutcomesManagementRatings
ratings: defaultRatings
.filter(r => r.points !== 0)
.map(rating => pick(rating, ['description', 'points'])),
individualRatings: true

View File

@ -17,7 +17,7 @@
*/
import React from 'react'
import {render as realRender, fireEvent} from '@testing-library/react'
import {render as realRender, fireEvent, within} from '@testing-library/react'
import Ratings from '../Ratings'
import {createRating} from '@canvas/outcomes/react/hooks/useRatings'
import OutcomesContext from '@canvas/outcomes/react/contexts/OutcomesContext'
@ -30,19 +30,23 @@ const render = (children, {isMobileView = false} = {}) => {
describe('Ratings', () => {
let onChangeRatingsMock
let onChangeMasteryPointsMock
const defaultProps = (props = {}) => ({
onChangeRatings: onChangeRatingsMock,
onChangeMasteryPoints: onChangeMasteryPointsMock,
canManage: true,
ratings: [
createRating('Exceeds Mastery', 4, '127A1B'),
createRating('Mastery', 3, '00AC18', true)
],
ratings: [createRating('Exceeds Mastery', 4), createRating('Mastery', 3)],
masteryPoints: {
value: 3,
error: null
},
...props
})
beforeEach(() => {
onChangeRatingsMock = jest.fn()
onChangeMasteryPointsMock = jest.fn()
})
afterEach(() => {
@ -59,16 +63,6 @@ describe('Ratings', () => {
expect(newRatings[1].description).toEqual('New value for description')
})
it('call onChangeRatings with correct mastery when change mastery', () => {
const {getByLabelText} = render(<Ratings {...defaultProps()} />)
const mastery = getByLabelText('Mastery false for mastery level 1').closest('input')
fireEvent.click(mastery)
expect(onChangeRatingsMock).toHaveBeenCalled()
const newRatings = onChangeRatingsMock.mock.calls[0][0](defaultProps().ratings)
// assert set false to previous mastery too
expect(newRatings.map(r => r.mastery)).toStrictEqual([true, false])
})
it('calls onChangeRatings when Add Mastery Level is clicked', () => {
const {getByText} = render(<Ratings {...defaultProps()} />)
fireEvent.click(getByText('Add Mastery Level'))
@ -87,14 +81,30 @@ describe('Ratings', () => {
expect(newRatings[0].description).toEqual('Mastery')
})
it('calls onChangeRatings when delete a mastery rating with new mastery', () => {
const {getByText} = render(<Ratings {...defaultProps()} />)
fireEvent.click(getByText('Delete mastery level 2'))
expect(onChangeRatingsMock).toHaveBeenCalled()
const newRatings = onChangeRatingsMock.mock.calls[0][0](defaultProps().ratings)
expect(newRatings.length).toEqual(1)
expect(newRatings[0].description).toEqual('Exceeds Mastery')
expect(newRatings[0].mastery).toBeTruthy()
it('call onChangeMasteryPoints with new value of points when mastery points are changed', () => {
const {getByLabelText} = render(<Ratings {...defaultProps()} />)
fireEvent.change(getByLabelText('Change mastery points').closest('input'), {
target: {value: '5'}
})
expect(onChangeMasteryPointsMock).toHaveBeenCalled()
expect(onChangeMasteryPointsMock).toHaveBeenCalledWith('5')
})
it('displays error message for mastery points if validation error', async () => {
const {getByDisplayValue} = render(
<Ratings
{...defaultProps({
masteryPoints: {
value: 11,
error: 'Invalid points'
}
})}
/>
)
const masteryPointsInput = getByDisplayValue('11')
expect(
within(masteryPointsInput.closest('.points')).getByText('Invalid points')
).toBeInTheDocument()
})
})
@ -104,6 +114,11 @@ describe('Ratings', () => {
const {queryByText} = render(<Ratings {...defaultProps({canManage: false})} />)
expect(queryByText(/Add Mastery Level/)).not.toBeInTheDocument()
})
it('shows read only view of mastery points', () => {
const {getByTestId} = render(<Ratings {...defaultProps({canManage: false})} />)
expect(getByTestId('read-only-mastery-points')).toBeInTheDocument()
})
})
describe('when canManage is true', () => {
@ -111,6 +126,11 @@ describe('Ratings', () => {
const {queryByText} = render(<Ratings {...defaultProps()} />)
expect(queryByText(/Add Mastery Level/)).toBeInTheDocument()
})
it('shows mastery points input', () => {
const {getByTestId} = render(<Ratings {...defaultProps()} />)
expect(getByTestId('mastery-points-input')).toBeInTheDocument()
})
})
})
})

View File

@ -45,7 +45,7 @@ class ProficiencyRating extends React.Component {
descriptionError: PropTypes.string,
disableDelete: PropTypes.bool.isRequired,
focusField: PropTypes.oneOf(['description', 'points', 'mastery', 'trash']),
mastery: PropTypes.bool.isRequired,
mastery: requiredIf(({individualOutcome}) => !individualOutcome, PropTypes.bool),
onColorChange: requiredIf(({individualOutcome}) => !individualOutcome, PropTypes.func),
onDelete: PropTypes.func.isRequired,
onDescriptionChange: PropTypes.func.isRequired,
@ -364,21 +364,24 @@ class ProficiencyRating extends React.Component {
const {isMobileView, canManage, individualOutcome} = this.props
return (
<Flex
padding={`${isMobileView ? '0 0 small 0' : '0 small small small'}`}
padding={`${
isMobileView
? '0 0 small 0'
: individualOutcome
? '0 small small 0'
: '0 small small small'
}`}
width="100%"
alignItems={isMobileView ? 'center' : 'start'}
>
<Flex.Item
align="start"
textAlign="center"
padding="0 medium 0 0"
size={isMobileView ? '25%' : '15%'}
>
{this.renderMastery()}
</Flex.Item>
{!individualOutcome && (
<Flex.Item textAlign="center" padding="0 medium 0 0" size={isMobileView ? '25%' : '15%'}>
{this.renderMastery()}
</Flex.Item>
)}
<Flex.Item
padding="0 small 0 0"
size={isMobileView ? '75%' : individualOutcome ? '65%' : '40%'}
size={isMobileView ? '75%' : individualOutcome ? (canManage ? '80%' : '60%') : '40%'}
align="start"
>
{this.renderDescription()}

View File

@ -14,7 +14,6 @@ exports[`ProficiencyRating can manage renders the ProficiencyRating component 1`
wrap="no-wrap"
>
<Item
align="start"
as="span"
elementRef={[Function]}
padding="0 medium 0 0"
@ -346,7 +345,6 @@ exports[`ProficiencyRating can not manage renders the ProficiencyRating componen
wrap="no-wrap"
>
<Item
align="start"
as="span"
elementRef={[Function]}
padding="0 medium 0 0"

View File

@ -28,15 +28,14 @@ import {
IMPORT_OUTCOMES,
CREATE_LEARNING_OUTCOME_GROUP
} from '../graphql/Management'
import {defaultOutcomesManagementRatings} from '../react/hooks/useRatings'
import {defaultRatings, defaultMasteryPoints} from '../react/hooks/useRatings'
import {pick, uniq, flattenDeep} from 'lodash'
const ratingsWithTypename = ratings =>
ratings
.map(rating => pick(rating, ['description', 'points', 'mastery']))
.map(r => ({...r, __typename: 'ProficiencyRating'}))
const testRatings = defaultRatings.map(rating => pick(rating, ['description', 'points']))
const getMasteryPoints = ratings => ratings.filter(rating => rating.mastery)[0].points
const ratingsWithTypename = ratings => ratings.map(r => ({...r, __typename: 'ProficiencyRating'}))
const maxPoints = ratings => ratings.sort((a, b) => b.points - a.points)[0].points
export const accountMocks = ({childGroupsCount = 10, accountId = '1'} = {}) => [
{
@ -237,8 +236,8 @@ export const treeGroupMocks = ({
const childrenOutcomes = (detailsStructure[gid] || []).map(toString)
const calculationMethod = 'decaying_average'
const calculationInt = 65
const masteryPoints = getMasteryPoints(defaultOutcomesManagementRatings)
const ratings = ratingsWithTypename(defaultOutcomesManagementRatings)
const masteryPoints = defaultMasteryPoints
const ratings = ratingsWithTypename(testRatings)
return {
request: {
@ -484,8 +483,8 @@ const createSearchGroupOutcomesOutcomeMocks = (
) => {
const calculationMethod = 'decaying_average'
const calculationInt = 65
const masteryPoints = getMasteryPoints(defaultOutcomesManagementRatings)
const ratings = ratingsWithTypename(defaultOutcomesManagementRatings)
const masteryPoints = defaultMasteryPoints
const ratings = ratingsWithTypename(testRatings)
// Tech Debt - see OUT-4776 - need to switch this over to a dynamic array like the below code
// for now too many tests are dependant on the number of outcomes and the order
@ -594,8 +593,8 @@ export const groupDetailMocks = ({
} = {}) => {
const calculationMethod = 'decaying_average'
const calculationInt = 65
const masteryPoints = getMasteryPoints(defaultOutcomesManagementRatings)
const ratings = ratingsWithTypename(defaultOutcomesManagementRatings)
const masteryPoints = defaultMasteryPoints
const ratings = ratingsWithTypename(testRatings)
return [
{
@ -1137,8 +1136,8 @@ export const groupDetailMocksFetchMore = ({
} = {}) => {
const calculationMethod = 'decaying_average'
const calculationInt = 65
const masteryPoints = getMasteryPoints(defaultOutcomesManagementRatings)
const ratings = ratingsWithTypename(defaultOutcomesManagementRatings)
const masteryPoints = defaultMasteryPoints
const ratings = ratingsWithTypename(testRatings)
return [
{
@ -1328,8 +1327,8 @@ export const findOutcomesMocks = ({
} = {}) => {
const calculationMethod = 'decaying_average'
const calculationInt = 65
const masteryPoints = getMasteryPoints(defaultOutcomesManagementRatings)
const ratings = ratingsWithTypename(defaultOutcomesManagementRatings)
const masteryPoints = defaultMasteryPoints
const ratings = ratingsWithTypename(testRatings)
return [
{
@ -1545,13 +1544,12 @@ export const createLearningOutcomeMock = ({
calculationMethod = 'decaying_average',
calculationInt = 65,
individualCalculation = false,
masteryPoints = 3,
ratings = defaultOutcomesManagementRatings,
masteryPoints = defaultMasteryPoints,
ratings = testRatings,
individualRatings = false
} = {}) => {
const inputRatings = ratings.map(rating => pick(rating, ['description', 'points']))
const pointsPossible = ratings.sort((a, b) => b.points - a.points)[0].points
const outputRatings = inputRatings.map(r => ({...r, __typename: 'ProficiencyRating'}))
const pointsPossible = maxPoints(ratings)
const outputRatings = ratingsWithTypename(ratings)
const successfulResponse = {
data: {
@ -1621,7 +1619,7 @@ export const createLearningOutcomeMock = ({
}
if (individualRatings) {
input.masteryPoints = masteryPoints
input.ratings = inputRatings
input.ratings = ratings
}
return {
@ -1643,13 +1641,12 @@ export const updateLearningOutcomeMocks = ({
calculationMethod = 'decaying_average',
calculationInt = 65,
individualCalculation = false,
masteryPoints = 3,
ratings = defaultOutcomesManagementRatings,
masteryPoints = defaultMasteryPoints,
ratings = testRatings,
individualRatings = false
} = {}) => {
const inputRatings = ratings.map(rating => pick(rating, ['description', 'points']))
const pointsPossible = ratings.sort((a, b) => b.points - a.points)[0].points
const outputRatings = inputRatings.map(r => ({...r, __typename: 'ProficiencyRating'}))
const pointsPossible = maxPoints(ratings)
const outputRatings = ratingsWithTypename(ratings)
const input = {
title,
@ -1662,7 +1659,7 @@ export const updateLearningOutcomeMocks = ({
}
if (individualRatings) {
input.masteryPoints = masteryPoints
input.ratings = inputRatings
input.ratings = ratings
}
const output = {
...input,

View File

@ -16,7 +16,7 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {convertRatings, prepareRatings} from '../ratingsHelpers'
import {processRatingsAndMastery} from '../ratingsHelpers'
describe('Ratings Helpers', () => {
const testMasteryPoints = 3
@ -34,47 +34,31 @@ describe('Ratings Helpers', () => {
points: 3
}
]
const testConvertRatings = testRatings.map(({description, points}) => ({
description,
points,
mastery: Number(points) === Number(testMasteryPoints)
}))
describe('convertRatings', () => {
it('converts masteryPoints', () => {
const {masteryPoints} = convertRatings(testConvertRatings)
describe('processRatingsAndMastery', () => {
it('processes masteryPoints provided as number', () => {
const {masteryPoints} = processRatingsAndMastery(testRatings, testMasteryPoints)
expect(masteryPoints).toEqual(3)
})
it('converts points provided as number', () => {
const {ratings} = convertRatings(testConvertRatings)
it('processes masteryPoints provided as string', () => {
const {masteryPoints} = processRatingsAndMastery(testRatings, '123.45')
expect(masteryPoints).toEqual(123.45)
})
it('rounds masteryPoints to 2 decimal places', () => {
const {masteryPoints} = processRatingsAndMastery(testRatings, 3.125)
expect(masteryPoints).toEqual(3.13)
})
it('processes points provided as number', () => {
const {ratings} = processRatingsAndMastery(testRatings, testMasteryPoints)
expect(ratings[0].points).toEqual(1.25)
})
it('converts points provided as string', () => {
const {ratings} = convertRatings(testConvertRatings)
it('processes points provided as string', () => {
const {ratings} = processRatingsAndMastery(testRatings, testMasteryPoints)
expect(ratings[1].points).toEqual(2.5)
})
})
describe('prepareRatings', () => {
it('adds mastery prop to ratings', () => {
const result = prepareRatings(testRatings, testMasteryPoints)
expect(result[0].mastery).toBeFalsy()
expect(result[1].mastery).toBeFalsy()
expect(result[2].mastery).toBeTruthy()
})
it('adds key prop to ratings', () => {
const result = prepareRatings(testRatings, testMasteryPoints)
expect(result[0].key).not.toBeNull()
expect(result[1].key).not.toBeNull()
expect(result[2].key).not.toBeNull()
})
it('handles null ratings argument', () => {
const result = prepareRatings(null, testMasteryPoints)
expect(result.length).toBe(0)
})
})
})

View File

@ -16,22 +16,7 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import uuid from 'uuid/v1'
export const convertRatings = rawRatings => {
let masteryPoints
const ratings = rawRatings.map(({description, points, mastery}) => {
const pointsFloat = parseFloat(points)
if (mastery) masteryPoints = pointsFloat
return {description, points: pointsFloat}
})
return {masteryPoints, ratings}
}
export const prepareRatings = (ratings, masteryPoints) =>
(ratings || []).map(({description, points}) => ({
description,
points,
mastery: Number(points) === Number(masteryPoints),
key: uuid()
}))
export const processRatingsAndMastery = (ratings, masteryPoints) => ({
ratings: ratings.map(({description, points}) => ({description, points: parseFloat(points)})),
masteryPoints: Number(parseFloat(masteryPoints).toFixed(2))
})

View File

@ -17,7 +17,7 @@
*/
import {renderHook} from '@testing-library/react-hooks/dom'
import useRatings, {defaultOutcomesManagementRatings} from '../useRatings'
import useRatings, {defaultRatings, defaultMasteryPoints, prepareRatings} from '../useRatings'
const expectDescriptions = (result, expectedDescriptions) => {
expect(result.current.ratings.map(r => r.description)).toStrictEqual(expectedDescriptions)
@ -47,11 +47,22 @@ const changeRating = (result, ratingIndex, attrs) => {
result.current.setRatings(newRatings)
}
const changeMasteryPoints = (result, points) => result.current.setMasteryPoints(points)
const expectMasteryPoints = (result, expectedMasteryPoints) => {
expect(result.current.masteryPoints.value).toEqual(expectedMasteryPoints)
}
const expectMasteryPointsErrors = (result, expectedMasteryPointsError) => {
expect(result.current.masteryPoints.error).toEqual(expectedMasteryPointsError)
}
describe('useRatings', () => {
const initialRatings = defaultOutcomesManagementRatings
const initialRatings = defaultRatings
const initialMasteryPoints = defaultMasteryPoints
test('should create custom hook with initialRatings', () => {
const {result} = renderHook(() => useRatings({initialRatings}))
const {result} = renderHook(() => useRatings({initialRatings, initialMasteryPoints}))
expectDescriptions(result, [
'Exceeds Mastery',
'Mastery',
@ -60,11 +71,12 @@ describe('useRatings', () => {
'Well Below Mastery'
])
expectPoints(result, [4, 3, 2, 1, 0])
expect(result.current.masteryPoints.value).toEqual(initialMasteryPoints)
})
describe('Updating', () => {
it('should reflect changes in ratings', () => {
const {result} = renderHook(() => useRatings({initialRatings}))
const {result} = renderHook(() => useRatings({initialRatings, initialMasteryPoints}))
changeRating(result, 0, {description: 'New Exceeds Mastery', points: 5})
changeRating(result, 1, {description: 'New Mastery', points: 4})
@ -80,23 +92,39 @@ describe('useRatings', () => {
expectPoints(result, [5, 4, 2, 1, 0])
})
it('returns false for hasChanged if ratings have not changed', () => {
const {result} = renderHook(() => useRatings({initialRatings}))
it('should reflect changes in mastery points', () => {
const {result} = renderHook(() => useRatings({initialRatings, initialMasteryPoints}))
changeMasteryPoints(result, 11)
expectMasteryPoints(result, 11)
})
it('returns false for hasChanged if neither ratings nor mastery points have changed', () => {
const {result} = renderHook(() => useRatings({initialRatings, initialMasteryPoints}))
expect(result.current.hasChanged).toBe(false)
})
it('returns true for hasChanged if ratings have changed', () => {
const {result} = renderHook(() => useRatings({initialRatings}))
const {result} = renderHook(() => useRatings({initialRatings, initialMasteryPoints}))
changeRating(result, 0, {description: 'New Exceeds Mastery', points: 5})
changeRating(result, 1, {description: 'New Mastery', points: 4})
expect(result.current.hasChanged).toBe(true)
})
it('returns true for hasChanged if mastery points have changed', () => {
const {result} = renderHook(() => useRatings({initialRatings, initialMasteryPoints}))
changeMasteryPoints(result, 11)
expect(result.current.hasChanged).toBe(true)
})
})
describe('Validations', () => {
describe('Description validations', () => {
it('should be ok when receiving good data', () => {
const {result} = renderHook(() => useRatings({initialRatings}))
const {result} = renderHook(() => useRatings({initialRatings, initialMasteryPoints}))
changeRating(result, 0, {description: 'Any description'})
@ -105,7 +133,7 @@ describe('useRatings', () => {
})
it('should validate blank', () => {
const {result} = renderHook(() => useRatings({initialRatings}))
const {result} = renderHook(() => useRatings({initialRatings, initialMasteryPoints}))
changeRating(result, 0, {description: ''})
@ -116,7 +144,7 @@ describe('useRatings', () => {
describe('Points validations', () => {
it('should be ok for positive integer', () => {
const {result} = renderHook(() => useRatings({initialRatings}))
const {result} = renderHook(() => useRatings({initialRatings, initialMasteryPoints}))
changeRating(result, 0, {points: '10'})
@ -125,7 +153,7 @@ describe('useRatings', () => {
})
it('should be ok for positive float', () => {
const {result} = renderHook(() => useRatings({initialRatings}))
const {result} = renderHook(() => useRatings({initialRatings, initialMasteryPoints}))
changeRating(result, 0, {points: '10.5'})
@ -134,7 +162,7 @@ describe('useRatings', () => {
})
it('should validate blank points', () => {
const {result} = renderHook(() => useRatings({initialRatings}))
const {result} = renderHook(() => useRatings({initialRatings, initialMasteryPoints}))
changeRating(result, 0, {points: ''})
@ -143,7 +171,7 @@ describe('useRatings', () => {
})
it('should validate invalid points', () => {
const {result} = renderHook(() => useRatings({initialRatings}))
const {result} = renderHook(() => useRatings({initialRatings, initialMasteryPoints}))
changeRating(result, 0, {points: 'lorem'})
@ -152,7 +180,7 @@ describe('useRatings', () => {
})
it('should validate points with comma as invalid points', () => {
const {result} = renderHook(() => useRatings({initialRatings}))
const {result} = renderHook(() => useRatings({initialRatings, initialMasteryPoints}))
changeRating(result, 0, {points: '0,5'})
@ -161,7 +189,7 @@ describe('useRatings', () => {
})
it('should validate negative points', () => {
const {result} = renderHook(() => useRatings({initialRatings}))
const {result} = renderHook(() => useRatings({initialRatings, initialMasteryPoints}))
changeRating(result, 0, {points: '-1'})
@ -170,7 +198,7 @@ describe('useRatings', () => {
})
it('should validate unique points', () => {
const {result} = renderHook(() => useRatings({initialRatings}))
const {result} = renderHook(() => useRatings({initialRatings, initialMasteryPoints}))
// change the first points to match the second
changeRating(result, 0, {points: '3'})
@ -180,5 +208,114 @@ describe('useRatings', () => {
expect(result.current.hasError).toBeTruthy()
})
})
describe('Mastery points validations', () => {
it('should pass validations for positive integer', () => {
const {result} = renderHook(() => useRatings({initialRatings, initialMasteryPoints}))
changeMasteryPoints(result, 3)
expectMasteryPointsErrors(result, null)
expect(result.current.hasError).toBeFalsy()
})
it('should pass validations for positive float', () => {
const {result} = renderHook(() => useRatings({initialRatings, initialMasteryPoints}))
changeMasteryPoints(result, 3.5)
expectMasteryPointsErrors(result, null)
expect(result.current.hasError).toBeFalsy()
})
it('should generate error if no points', () => {
const {result} = renderHook(() => useRatings({initialRatings, initialMasteryPoints}))
changeMasteryPoints(result, '')
expectMasteryPointsErrors(result, 'Missing required points')
expect(result.current.hasError).toBeTruthy()
})
it('should generate error if invalid points', () => {
const {result} = renderHook(() => useRatings({initialRatings, initialMasteryPoints}))
changeMasteryPoints(result, 'lorem')
expectMasteryPointsErrors(result, 'Invalid points')
expect(result.current.hasError).toBeTruthy()
})
it('should generate error if points have comma delimiter', () => {
const {result} = renderHook(() => useRatings({initialRatings, initialMasteryPoints}))
changeMasteryPoints(result, '0,5')
expectMasteryPointsErrors(result, 'Invalid points')
expect(result.current.hasError).toBeTruthy()
})
it('should generate error if negative points', () => {
const {result} = renderHook(() => useRatings({initialRatings, initialMasteryPoints}))
changeMasteryPoints(result, '-1')
expectMasteryPointsErrors(result, 'Negative points')
expect(result.current.hasError).toBeTruthy()
})
it('should generate error if mastery points greater than max rating', () => {
const {result} = renderHook(() => useRatings({initialRatings, initialMasteryPoints}))
changeMasteryPoints(result, 11)
expectMasteryPointsErrors(result, 'Above max rating')
expect(result.current.hasError).toBeTruthy()
})
it('should generate error if mastery points less than min rating', () => {
const {result} = renderHook(() =>
useRatings({
initialRatings: defaultRatings.filter(r => r.points !== 0),
initialMasteryPoints
})
)
changeMasteryPoints(result, 0)
expectMasteryPointsErrors(result, 'Below min rating')
expect(result.current.hasError).toBeTruthy()
})
it('should bypass mastery point validations if no ratings', () => {
const {result} = renderHook(() =>
useRatings({
initialRatings: null,
initialMasteryPoints
})
)
changeMasteryPoints(result, 0)
expectMasteryPointsErrors(result, null)
expect(result.current.hasError).toBeFalsy()
})
})
})
describe('prepareRatings', () => {
it('should add a key prop to each rating', () => {
const result = prepareRatings(defaultRatings)
expect(result[0].key).not.toBeNull()
expect(result[1].key).not.toBeNull()
expect(result[2].key).not.toBeNull()
expect(result[3].key).not.toBeNull()
expect(result[4].key).not.toBeNull()
})
it('should return an empty array if no ratings', () => {
const result = prepareRatings()
expect(result.length).toBe(0)
})
})
})

View File

@ -16,32 +16,38 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {useMemo, useState} from 'react'
import {useMemo, useState, useCallback} from 'react'
import I18n from 'i18n!ProficiencyTable'
import uuid from 'uuid/v1'
import useBoolean from './useBoolean'
const floatRegex = /^[+-]?\d+(\.\d+)?$/
export const createRating = (description, points, color, mastery = false) => ({
export const createRating = (description, points) => ({
description,
points,
key: uuid(),
color,
mastery
key: uuid()
})
export const defaultOutcomesManagementRatings = [
createRating(I18n.t('Exceeds Mastery'), 4, '127A1B'),
createRating(I18n.t('Mastery'), 3, '00AC18', true),
createRating(I18n.t('Near Mastery'), 2, 'FAB901'),
createRating(I18n.t('Below Mastery'), 1, 'FD5D10'),
createRating(I18n.t('Well Below Mastery'), 0, 'EE0612')
export const defaultRatings = [
createRating(I18n.t('Exceeds Mastery'), 4),
createRating(I18n.t('Mastery'), 3),
createRating(I18n.t('Near Mastery'), 2),
createRating(I18n.t('Below Mastery'), 1),
createRating(I18n.t('Well Below Mastery'), 0)
]
export const defaultMasteryPoints = 3
export const prepareRatings = ratings =>
(ratings || []).map(({description, points}) => createRating(description, points))
const invalidDescription = description => (description?.trim() || '').length === 0
const useRatings = ({initialRatings}) => {
const [ratings, setRatings] = useState(initialRatings)
const useRatings = ({initialRatings, initialMasteryPoints}) => {
const [ratings, setRatings] = useState(() => prepareRatings(initialRatings))
const [masteryPoints, setMasteryPoints] = useState(initialMasteryPoints)
const [hasChanged, setHasChanged] = useBoolean(false)
const ratingsWithValidations = useMemo(() => {
const allPoints = ratings.map(r => r.points)
@ -70,25 +76,69 @@ const useRatings = ({initialRatings}) => {
return {
...r,
points,
pointsError,
descriptionError
}
})
}, [ratings])
const hasError = useMemo(() => {
return ratingsWithValidations.some(r => r.pointsError || r.descriptionError)
}, [ratingsWithValidations])
const masteryPointsWithValidations = useMemo(() => {
let error = null
const masteryPointsFloat = masteryPoints ? parseFloat(masteryPoints) : null
const hasChanged = useMemo(
() => JSON.stringify(ratings) !== JSON.stringify(initialRatings),
// eslint-disable-next-line react-hooks/exhaustive-deps
[ratings]
if (ratings.length > 0) {
const sortedRatings = [...ratings].sort((a, b) => parseFloat(a.points) - parseFloat(b.points))
const minRatingPoints = parseFloat(sortedRatings[0].points)
const maxRatingPoints = parseFloat(sortedRatings[sortedRatings.length - 1].points)
if ([null, undefined, ''].includes(masteryPoints)) {
error = I18n.t('Missing required points')
} else if (!floatRegex.test(masteryPoints)) {
error = I18n.t('Invalid points')
} else if (masteryPointsFloat < 0) {
error = I18n.t('Negative points')
} else if (masteryPointsFloat > maxRatingPoints) {
error = I18n.t('Above max rating')
} else if (masteryPointsFloat < minRatingPoints) {
error = I18n.t('Below min rating')
}
}
return {
value: masteryPointsFloat,
error
}
}, [ratings, masteryPoints])
const hasError = useMemo(() => {
return (
ratingsWithValidations.some(r => r.pointsError || r.descriptionError) ||
masteryPointsWithValidations.error
)
}, [ratingsWithValidations, masteryPointsWithValidations])
const changeRatings = useCallback(
value => {
if (!hasChanged) setHasChanged()
setRatings(value)
},
[hasChanged, setHasChanged]
)
const changeMasteryPoints = useCallback(
value => {
if (!hasChanged) setHasChanged()
setMasteryPoints(value)
},
[hasChanged, setHasChanged]
)
return {
ratings: ratingsWithValidations,
setRatings,
masteryPoints: masteryPointsWithValidations,
setRatings: changeRatings,
setMasteryPoints: changeMasteryPoints,
hasError,
hasChanged
}