rubric redesign add load criteria

this commit removes the hard coded rubric criteria data and loads any
existing criteria in the UI. It also allows a user to open the criteria
modal by clicking on the "Edit" icon (ratings not loaded yet). It also
adds the ratings accordion that when clicked will show the rating.
details for a criterion. It also allows the rubric to be saved without
losing any criterion data.

closes EVAL-3940
closes EVAL-3633
flag=enhanced_rubrics

test plan:
- navigate to /rubrics
- click on a rubric for Edit
- verify that any existing criterion is loaded correctly and that the
  "Edit" icon is clickable and opens the criteria modal
- verify that the ratings accordion is clickable and shows the rating
  details for a criterion
- verify that the last option is the correctly numbered row with the
  "Draft New Criterion" button & "Create from Outcome button"
- verify that the rubric can be saved without losing any criterion data
- go back to /rubrics and click on "Create New Rubric"
- verify that no criterion is listed and only the buttons for creating
  new criterion are visible

Change-Id: I34e74b52c06ec25db84947f7c1566c554ec7425e
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/340042
Reviewed-by: Derek Williams <derek.williams@instructure.com>
Reviewed-by: Kai Bjorkman <kbjorkman@instructure.com>
QA-Review: Derek Williams <derek.williams@instructure.com>
Product-Review: Ravi Koll <ravi.koll@instructure.com>
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
This commit is contained in:
Chris Soto 2024-02-08 10:05:27 -07:00 committed by Christopher Soto
parent 04adf804f5
commit 9bb60b874c
11 changed files with 369 additions and 45 deletions

View File

@ -48,6 +48,7 @@ module Types
Loaders::IDLoader.for(LearningOutcome).load(object[:learning_outcome_id])
end
field :learning_outcome_id, ID, null: true
field :long_description, String, null: true
field :mastery_points, Float, null: true
field :points, Float, null: true

View File

@ -80,5 +80,14 @@ describe Types::RubricCriterionType do
rubric_type.resolve("criteria { ratings { _id }}")
).to eq(rubric.criteria.map { |c| c[:ratings].map { |r| r[:id].to_s } })
end
it "learning_outcome_id" do
rubric.criteria[0][:learning_outcome_id] = learning_outcome.id
rubric.save!
expect(
rubric_type.resolve("criteria { learningOutcomeId }")
).to eq rubric.criteria.pluck(:learning_outcome_id).map(&:to_s)
end
end
end

View File

@ -34,31 +34,31 @@ const I18n = useI18nScope('rubrics-criterion-modal')
export const DEFAULT_RUBRIC_RATINGS: RubricRating[] = [
{
id: '-1',
id: '',
points: 4,
description: I18n.t('Exceeds'),
longDescription: '',
},
{
id: '-1',
id: '',
points: 3,
description: I18n.t('Mastery'),
longDescription: '',
},
{
id: '-1',
id: '',
points: 2,
description: I18n.t('Near'),
longDescription: '',
},
{
id: '-1',
id: '',
points: 1,
description: I18n.t('Below'),
longDescription: '',
},
{
id: '-1',
id: '',
points: 0,
description: I18n.t('No Evidence'),
longDescription: '',
@ -110,8 +110,8 @@ export const CriterionModal = ({isOpen, onDismiss}: CriterionModalProps) => {
<Heading>{I18n.t('Create New Criterion')}</Heading>
</Modal.Header>
<Modal.Body>
<View as="div" margin="x-small 0">
<View as="span" margin="0 small 0 0">
<View as="div" margin="0">
<View as="span" margin="0 small 0 0" themeOverride={{marginSmall: '1rem'}}>
<TextInput
renderLabel={I18n.t('Criterion Name')}
placeholder={I18n.t('Enter the name')}
@ -148,8 +148,10 @@ export const CriterionModal = ({isOpen, onDismiss}: CriterionModalProps) => {
{I18n.t('Rating Name')}
</View>
</Flex.Item>
<Flex.Item margin="0 0 0 small">
<View as="div">{I18n.t('Rating Description')}</View>
<Flex.Item>
<View as="div" margin="0 0 0 small" themeOverride={{marginSmall: '1rem'}}>
{I18n.t('Rating Description')}
</View>
</Flex.Item>
</Flex>
</View>
@ -239,7 +241,7 @@ const RatingRow = ({rating, scale, onRemove}: RatingRowProps) => {
</View>
</Flex.Item>
<Flex.Item shouldGrow={true} shouldShrink={true}>
<View as="div" margin="0 small">
<View as="div" margin="0 small" themeOverride={{marginSmall: '1rem'}}>
<TextInput
renderLabel={<ScreenReaderContent>{I18n.t('Rating Description')}</ScreenReaderContent>}
display="inline-block"

View File

@ -32,9 +32,13 @@ import {Pill} from '@instructure/ui-pill'
import {View} from '@instructure/ui-view'
import {CriterionModal} from './CriterionModal'
const I18n = useI18nScope('rubrics-criteria-row')
const I18n = useI18nScope('rubrics-criteria-new-row')
export const NewCriteriaRow = () => {
type NewCriteriaRowProps = {
rowIndex: number
}
export const NewCriteriaRow = ({rowIndex}: NewCriteriaRowProps) => {
const [isCriterionModalOpen, setIsCriterionModalOpen] = React.useState(false)
return (
@ -45,7 +49,7 @@ export const NewCriteriaRow = () => {
/>
<Flex>
<Flex.Item align="start" margin="small 0 0 0">
<Text weight="bold">{I18n.t('2.')}</Text>
<Text weight="bold">{rowIndex}.</Text>
</Flex.Item>
<Flex.Item margin="0 small" align="start" shouldGrow={true}>
<Button renderIcon={IconEditLine} onClick={() => setIsCriterionModalOpen(true)}>

View File

@ -16,7 +16,10 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import React, {useState} from 'react'
import {useScope as useI18nScope} from '@canvas/i18n'
import type {RubricCriterion, RubricRating} from '@canvas/rubrics/react/types/rubric'
import {possibleString} from '@canvas/rubrics/react/Points'
import {AccessibleContent} from '@instructure/ui-a11y-content'
import {Flex} from '@instructure/ui-flex'
import {Tag} from '@instructure/ui-tag'
@ -25,20 +28,35 @@ import {View} from '@instructure/ui-view'
import {Pill} from '@instructure/ui-pill'
import {IconButton} from '@instructure/ui-buttons'
import {
IconArrowOpenDownLine,
IconArrowOpenEndLine,
IconDragHandleLine,
IconDuplicateLine,
IconEditLine,
IconTrashLine,
} from '@instructure/ui-icons'
import {CriterionModal} from './CriterionModal'
const I18n = useI18nScope('rubrics-criteria-row')
type RubricCriteriaRowProps = {
criterion: RubricCriterion
rowIndex: number
}
export const RubricCriteriaRow = ({criterion, rowIndex}: RubricCriteriaRowProps) => {
const {description, longDescription, points} = criterion
const [isCriterionModalOpen, setIsCriterionModalOpen] = useState(false)
export const RubricCriteriaRow = () => {
return (
<>
<Flex>
<Flex.Item align="start">
<Text weight="bold">1.</Text>
<Flex data-testid="rubric-criteria-row">
<Flex.Item align="start" shouldShrink={true}>
<Text weight="bold" data-testid="rubric-criteria-row-index">
{rowIndex}.
</Text>
</Flex.Item>
<Flex.Item margin="0 small" align="start" shouldGrow={true}>
<Flex.Item margin="0 small" align="start" shouldGrow={true} shouldShrink={true}>
<View as="div">
<Tag
text={
@ -56,13 +74,11 @@ export const RubricCriteriaRow = () => {
}}
/>
</View>
<View as="div" margin="small 0 0 0">
<Text weight="bold">Effective Use of Space</Text>
<View as="div" margin="small 0 0 0" data-testid="rubric-criteria-row-description">
<Text weight="bold">{description}</Text>
</View>
<View as="div">
<Text>
Great use of space to show depth with use of foreground, middleground, and background.{' '}
</Text>
<View as="div" data-testid="rubric-criteria-row-long-description">
<Text>{longDescription}</Text>
</View>
</Flex.Item>
<Flex.Item align="start">
@ -75,24 +91,146 @@ export const RubricCriteriaRow = () => {
infoColor: 'white',
}}
>
<Text size="x-small">10 pts</Text>
<Text data-testid="rubric-criteria-row-points" size="x-small">
{possibleString(points)}
</Text>
</Pill>
<IconButton withBackground={false} withBorder={false} screenReaderLabel="" size="small">
<IconButton
withBackground={false}
withBorder={false}
screenReaderLabel={I18n.t('Move Criterion')}
size="small"
>
<IconDragHandleLine />
</IconButton>
<IconButton withBackground={false} withBorder={false} screenReaderLabel="" size="small">
<IconButton
withBackground={false}
withBorder={false}
screenReaderLabel={I18n.t('Edit Criterion')}
onClick={() => setIsCriterionModalOpen(true)}
size="small"
>
<IconEditLine />
</IconButton>
<IconButton withBackground={false} withBorder={false} screenReaderLabel="" size="small">
<IconButton
withBackground={false}
withBorder={false}
screenReaderLabel={I18n.t('Delete Criterion')}
size="small"
>
<IconTrashLine />
</IconButton>
<IconButton withBackground={false} withBorder={false} screenReaderLabel="" size="small">
<IconButton
withBackground={false}
withBorder={false}
screenReaderLabel={I18n.t('Duplicate Criterion')}
size="small"
>
<IconDuplicateLine />
</IconButton>
</Flex.Item>
</Flex>
<RatingScaleAccordion ratings={criterion.ratings} />
<CriterionModal
isOpen={isCriterionModalOpen}
onDismiss={() => setIsCriterionModalOpen(false)}
/>
<View as="hr" margin="medium 0 small 0" />
</>
)
}
type RatingScaleAccordionProps = {
ratings: RubricRating[]
}
const RatingScaleAccordion = ({ratings}: RatingScaleAccordionProps) => {
const [ratingsOpen, setRatingsOpen] = useState(false)
return (
<View as="div" padding="medium 0 0 medium" themeOverride={{paddingMedium: '1.5rem'}}>
<View
as="button"
cursor="pointer"
onClick={() => setRatingsOpen(!ratingsOpen)}
background="transparent"
display="block"
borderWidth="none"
textAlign="start"
type="button"
position="relative"
>
{ratingsOpen ? (
<IconArrowOpenDownLine width="18" height="18" />
) : (
<IconArrowOpenEndLine width="18" height="18" />
)}
<View as="span" margin="0 0 0 small" data-testid="criterion-row-rating-accordion">
<Text>
{I18n.t('Rating Scale')}: {ratings.length}
</Text>
</View>
</View>
{ratingsOpen &&
ratings.map((rating, index) => {
const scale = ratings.length - (index + 1)
const spacing = index === 0 ? '1.5rem' : '2.25rem'
return (
<RatingScaleAccordionItem
rating={rating}
key={`rating-scale-item-${rating.id}`}
scale={scale}
spacing={spacing}
/>
)
})}
</View>
)
}
type RatingScaleAccordionItemProps = {
rating: RubricRating
scale: number
spacing: string
}
const RatingScaleAccordionItem = ({rating, scale, spacing}: RatingScaleAccordionItemProps) => {
return (
<View
as="div"
margin="small 0 0 0"
themeOverride={{marginSmall: spacing}}
data-testid="rating-scale-accordion-item"
>
<Flex>
<Flex.Item align="start">
<View
as="div"
width="2.25rem"
margin="0 0 0 small"
themeOverride={{marginSmall: '0.25rem'}}
>
<Text width="0.75rem">{scale}</Text>
</View>
</Flex.Item>
<Flex.Item align="start">
<View as="div" width="7.063rem">
<View as="div" maxWidth="5.563rem">
<Text>{rating.description}</Text>
</View>
</View>
</Flex.Item>
<Flex.Item shouldShrink={true} shouldGrow={true} align="start">
<View as="div">
<Text>{rating.longDescription}</Text>
</View>
</Flex.Item>
<Flex.Item align="start">
<View as="div" margin="0 0 0 medium" themeOverride={{marginMedium: '1.5rem'}}>
<Text>{possibleString(rating.points)}</Text>
</View>
</Flex.Item>
</Flex>
</View>
)
}

View File

@ -115,7 +115,7 @@ describe('RubricForm Tests', () => {
it('will navigate back to /rubrics after successfully saving', async () => {
jest
.spyOn(RubricFormQueries, 'saveRubric')
.mockImplementation(() => Promise.resolve({id: '1', title: 'Rubric 1'}))
.mockImplementation(() => Promise.resolve({id: '1', title: 'Rubric 1', pointsPossible: 10}))
const {getByTestId} = renderComponent()
const titleInput = getByTestId('rubric-form-title')
fireEvent.change(titleInput, {target: {value: 'Rubric 1'}})
@ -125,4 +125,72 @@ describe('RubricForm Tests', () => {
expect(getSRAlert()).toEqual('Rubric saved successfully')
})
})
describe('rubric criteria', () => {
beforeEach(() => {
jest.spyOn(Router, 'useParams').mockReturnValue({rubricId: '1'})
})
it('renders all criteria rows for a rubric', () => {
queryClient.setQueryData(['fetch-rubric-1'], RUBRICS_QUERY_RESPONSE)
const {criteria = []} = RUBRICS_QUERY_RESPONSE
const {queryAllByTestId} = renderComponent()
const criteriaRows = queryAllByTestId('rubric-criteria-row')
const criteriaRowDescriptions = queryAllByTestId('rubric-criteria-row-description')
const criteriaRowLongDescriptions = queryAllByTestId('rubric-criteria-row-long-description')
const criteriaRowPoints = queryAllByTestId('rubric-criteria-row-points')
const criteriaRowIndexes = queryAllByTestId('rubric-criteria-row-index')
expect(criteriaRows.length).toEqual(2)
expect(criteriaRowDescriptions[0]).toHaveTextContent(criteria[0].description)
expect(criteriaRowDescriptions[1]).toHaveTextContent(criteria[1].description)
expect(criteriaRowLongDescriptions[0]).toHaveTextContent(criteria[0].longDescription)
expect(criteriaRowLongDescriptions[1]).toHaveTextContent(criteria[1].longDescription)
expect(criteriaRowPoints[0]).toHaveTextContent(criteria[0].points.toString())
expect(criteriaRowPoints[1]).toHaveTextContent(criteria[1].points.toString())
expect(criteriaRowIndexes[0]).toHaveTextContent('1.')
expect(criteriaRowIndexes[1]).toHaveTextContent('2')
})
it('renders the criterion ratings accordion button', () => {
queryClient.setQueryData(['fetch-rubric-1'], RUBRICS_QUERY_RESPONSE)
const {criteria = []} = RUBRICS_QUERY_RESPONSE
const {queryAllByTestId} = renderComponent()
const ratingScaleAccordion = queryAllByTestId('criterion-row-rating-accordion')
expect(ratingScaleAccordion.length).toEqual(2)
expect(ratingScaleAccordion[0]).toHaveTextContent(
`Rating Scale: ${criteria[0].ratings.length}`
)
expect(ratingScaleAccordion[1]).toHaveTextContent(
`Rating Scale: ${criteria[0].ratings.length}`
)
})
it('renders the criterion ratings accordion items when button is clicked', () => {
queryClient.setQueryData(['fetch-rubric-1'], RUBRICS_QUERY_RESPONSE)
const {criteria = []} = RUBRICS_QUERY_RESPONSE
const {queryAllByTestId} = renderComponent()
const ratingScaleAccordion = queryAllByTestId('criterion-row-rating-accordion')
fireEvent.click(ratingScaleAccordion[0])
const ratingScaleAccordionItems = queryAllByTestId('rating-scale-accordion-item')
expect(ratingScaleAccordionItems.length).toEqual(criteria[0].ratings.length)
})
it('does not render the criterion ratings accordion items when accordion is closed', () => {
queryClient.setQueryData(['fetch-rubric-1'], RUBRICS_QUERY_RESPONSE)
const {queryAllByTestId} = renderComponent()
const ratingScaleAccordion = queryAllByTestId('criterion-row-rating-accordion')
fireEvent.click(ratingScaleAccordion[0])
fireEvent.click(ratingScaleAccordion[0])
const ratingScaleAccordionItems = queryAllByTestId('rating-scale-accordion-item')
expect(ratingScaleAccordionItems.length).toEqual(0)
})
})
})

View File

@ -16,7 +16,61 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export const RUBRICS_QUERY_RESPONSE = {
import type {Rubric} from '@canvas/rubrics/react/types/rubric'
export const RUBRICS_QUERY_RESPONSE: Rubric = {
id: '1',
title: 'Rubric 1',
criteriaCount: 2,
locations: [],
pointsPossible: 10,
workflowState: 'active',
criteria: [
{
id: '1',
points: 5,
description: 'Criterion 1',
longDescription: 'Long description for criterion 1',
ignoreForScoring: false,
masteryPoints: 3,
criterionUseRange: false,
ratings: [
{
id: '1',
description: 'Rating 1',
longDescription: 'Long description for rating 1',
points: 5,
},
{
id: '2',
description: 'Rating 2',
longDescription: 'Long description for rating 2',
points: 0,
},
],
},
{
id: '2',
points: 5,
description: 'Criterion 2',
longDescription: 'Long description for criterion 2',
ignoreForScoring: false,
masteryPoints: 3,
criterionUseRange: false,
ratings: [
{
id: '1',
description: 'Rating 1',
longDescription: 'Long description for rating 1',
points: 5,
},
{
id: '2',
description: 'Rating 2',
longDescription: 'Long description for rating 2',
points: 0,
},
],
},
],
}

View File

@ -50,6 +50,8 @@ const {Option: SimpleSelectOption} = SimpleSelect
const defaultRubricForm: RubricFormProps = {
title: '',
hidePoints: false,
criteria: [],
pointsPossible: 0,
}
const translateRubricData = (fields: RubricQueryResponse): RubricFormProps => {
@ -57,6 +59,8 @@ const translateRubricData = (fields: RubricQueryResponse): RubricFormProps => {
id: fields.id,
title: fields.title ?? '',
hidePoints: fields.hidePoints ?? false,
criteria: fields.criteria ?? [],
pointsPossible: fields.pointsPossible ?? 0,
}
}
@ -194,7 +198,7 @@ export const RubricForm = () => {
</Flex.Item>
<Flex.Item>
<Text weight="bold" size="xx-large" themeOverride={{fontWeightBold: 400}}>
10
{rubricForm.pointsPossible}
</Text>
<View as="span" margin="0 0 0 small">
<Text weight="light" size="x-large">
@ -213,9 +217,11 @@ export const RubricForm = () => {
overflowX="hidden"
as="main"
>
<RubricCriteriaRow />
{rubricForm.criteria.map((criterion, index) => (
<RubricCriteriaRow key={criterion.id} criterion={criterion} rowIndex={index + 1} />
))}
<NewCriteriaRow />
<NewCriteriaRow rowIndex={rubricForm.criteria.length + 1} />
</Flex.Item>
</Flex>

View File

@ -30,11 +30,26 @@ const RUBRIC_QUERY = gql`
title
hidePoints
workflowState
pointsPossible
criteria {
id: _id
ratings {
description
longDescription
points
}
points
longDescription
description
}
}
}
`
export type RubricQueryResponse = Pick<Rubric, 'id' | 'title' | 'criteria' | 'hidePoints'>
export type RubricQueryResponse = Pick<
Rubric,
'id' | 'title' | 'criteria' | 'hidePoints' | 'pointsPossible'
>
type FetchRubricResponse = {
rubric: RubricQueryResponse
@ -55,6 +70,22 @@ export const saveRubric = async (rubric: RubricFormProps): Promise<RubricQueryRe
const url = `${urlPrefix}/rubrics/${id ?? ''}`
const method = id ? 'PATCH' : 'POST'
const criteria = rubric.criteria.map(criterion => {
return {
id: criterion.id,
description: criterion.description,
long_description: criterion.longDescription,
points: criterion.points,
learning_outcome_id: criterion.learningOutcomeId,
ratings: criterion.ratings.map(rating => ({
description: rating.description,
long_description: rating.longDescription,
points: rating.points,
id: rating.id,
})),
}
})
const response = await fetch(url, {
method,
headers: {
@ -66,6 +97,7 @@ export const saveRubric = async (rubric: RubricFormProps): Promise<RubricQueryRe
rubric: {
title,
hide_points: hidePoints,
criteria,
},
rubric_association: {
association_id: accountId ?? courseId,
@ -89,5 +121,6 @@ export const saveRubric = async (rubric: RubricFormProps): Promise<RubricQueryRe
title: savedRubric.title,
hidePoints: savedRubric.hide_points,
criteria: savedRubric.criteria,
pointsPossible: savedRubric.points_possible,
}
}

View File

@ -16,12 +16,16 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import type {RubricCriterion} from '@canvas/rubrics/react/types/rubric'
export type RubricFormProps = {
id?: string
title: string
hidePoints: boolean
accountId?: string
courseId?: string
criteria: RubricCriterion[]
pointsPossible: number
}
export type RubricFormValueTypes = string | boolean

View File

@ -18,14 +18,7 @@
export type Rubric = {
id: string
criteria?: {
points: number
description: string
longDescription: string
ignoreForScoring: boolean
masteryPoints: number
criterionUseRange: boolean
}[]
criteria?: RubricCriterion[]
criteriaCount: number
hidePoints?: boolean
locations: string[]
@ -34,6 +27,18 @@ export type Rubric = {
workflowState?: string
}
export type RubricCriterion = {
id: string
points: number
description: string
longDescription: string
ignoreForScoring: boolean
masteryPoints: number
criterionUseRange: boolean
ratings: RubricRating[]
learningOutcomeId?: string
}
export type RubricRating = {
id: string
description: string