diff --git a/css/portal-dashboard/feedback/rubric-table.less b/css/portal-dashboard/feedback/rubric-table.less index c2f9d77b2..fa9b39324 100644 --- a/css/portal-dashboard/feedback/rubric-table.less +++ b/css/portal-dashboard/feedback/rubric-table.less @@ -83,76 +83,111 @@ .rubricTable { - .rubricTableRow { + code { + font-family: monospace; + } + + .rubricTableGroup { display: flex; - border-top: solid 1.5px @cc-charcoal-light1; - - .rubricDescription { - padding: 5px 10px; - font-weight: normal; - flex: auto; - li { - margin-left: 20px; - list-style-type: disc; - } + border-top: solid 1.5px #979797; + + .rubricTableGroupLabel { + writing-mode: vertical-rl; + padding: 20px 40px; + border-left: solid 1.5px #979797; // left is really right due to rotation + transform: rotate(180deg); + text-align: center; + font-weight: bold; } - .ratingsGroup{ + .rubricTableRows { display: flex; - min-height: 45px; + flex-direction: column; + flex: 1; - .rubricScoreBox { + .rubricTableRow { display: flex; - align-items: center; - justify-content: center; - width: 92px; - flex-grow: 0; - flex-shrink: 0; - border-left: solid 1.5px @cc-charcoal-light1; + border-top: solid 1.5px @cc-charcoal-light1; + flex: 1; + align-items: stretch; - .rubricButton{ - display: flex; - align-items: center; - justify-content: center; - width: 30px; - height: 30px; - margin: 5px; - cursor: pointer; - - &:hover .outerCircle { - background-color: white; - - &:hover .innerCircle { - background-color: @feedback-green-light3B; - } + &:first-child { + border: none; + } + + .rubricDescription { + padding: 5px 10px; + font-weight: normal; + flex: auto; + + img { + padding-right: 10px; } - &:active .outerCircle { - background-color: @feedback-green; - &:active .innerCircle { - background-color: white; - } + li { + margin-left: 20px; + list-style-type: disc; } + } + + .ratingsGroup{ + display: flex; + min-height: 45px; - .outerCircle { + .rubricScoreBox { display: flex; align-items: center; justify-content: center; - width: 20px; - height: 20px; - border-radius: 50%; - border: solid 1.5px @cc-charcoal-light1; - background-color: white; - cursor: pointer; - - .innerCircle { - width: 12px; - height: 12px; - background-color: white; - border-radius: 50%; - - &.selected { + width: 92px; + flex-grow: 0; + flex-shrink: 0; + border-left: solid 1.5px @cc-charcoal-light1; + + .rubricButton{ + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + margin: 5px; + cursor: pointer; + + &:hover .outerCircle { + background-color: white; + + &:hover .innerCircle { + background-color: @feedback-green-light3B; + } + } + &:active .outerCircle { background-color: @feedback-green; + + &:active .innerCircle { + background-color: white; + } + } + + .outerCircle { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + border: solid 1.5px @cc-charcoal-light1; + background-color: white; + cursor: pointer; + + .innerCircle { + width: 12px; + height: 12px; + background-color: white; + border-radius: 50%; + + &.selected { + background-color: @feedback-green; + } + } } } } diff --git a/css/report/rubric-box-for-student.less b/css/report/rubric-box-for-student.less index 0f48cb47c..b3a61f4b0 100644 --- a/css/report/rubric-box-for-student.less +++ b/css/report/rubric-box-for-student.less @@ -2,13 +2,12 @@ border-collapse: collapse; color: #777; - td, th { - padding: 5px; - border: 1px solid #888; + table, th, td { + border: 1px solid #333; } - td { - width: 50%; + th, td { + padding: 5px; } th { @@ -16,16 +15,33 @@ color: #888; } - .rating-label { - text-transform: uppercase; - } + td { + vertical-align: top; - p { - padding: 5px; + div { + display: flex; + align-items: center; + gap: 5px; + + li { + margin-left: 20px; + list-style-type: disc; + } + } + + p { + padding: 5px; + } + + ul, ol { + padding-left: 2em; + } } - ul, ol { - padding-left: 2em; + td.groupLabel { + writing-mode: vertical-rl; + transform: rotate(180deg); + text-align: center; } } diff --git a/js/api.ts b/js/api.ts index 39d288ea1..5fa888b34 100644 --- a/js/api.ts +++ b/js/api.ts @@ -7,6 +7,7 @@ import fakeClassData from "./data/small-class-data.json"; import { parseUrl, validFsId, urlParam, urlHashParam } from "./util/misc"; import { getActivityStudentFeedbackKey } from "./util/activity-feedback-helper"; import { getFirebaseAppName, signInWithToken } from "./db"; +import migrate from "./core/rubric-migrations"; const PORTAL_AUTH_PATH = "/auth/oauth_authorize"; let accessToken: string | null = null; @@ -414,6 +415,7 @@ export function fetchRubric(rubricUrl: string) { .then(checkStatus) .then(response => response.json()) .then(newRubric => { + newRubric = migrate(newRubric); rubricUrlCache[rubricUrl] = newRubric; resolve({ url: rubricUrl, rubric: newRubric }); }) diff --git a/js/components/portal-dashboard/feedback/activity-level-feedback-student-rows.tsx b/js/components/portal-dashboard/feedback/activity-level-feedback-student-rows.tsx index 8aa30c199..a20c64bf3 100644 --- a/js/components/portal-dashboard/feedback/activity-level-feedback-student-rows.tsx +++ b/js/components/portal-dashboard/feedback/activity-level-feedback-student-rows.tsx @@ -23,6 +23,7 @@ interface IProps { feedbackSortByMethod: string; isAnonymous: boolean; rubric: Rubric; + rubricDocUrl: string; setFeedbackSortRefreshEnabled: (value: boolean) => void; students: Map; updateActivityFeedback: (activityId: string, activityIndex: number, platformStudentId: string, feedback: any) => void; @@ -33,7 +34,7 @@ interface IProps { export const ActivityLevelFeedbackStudentRows: React.FC = (props) => { const { activityId, activityIndex, feedbacks, feedbackSortByMethod, isAnonymous, rubric, setFeedbackSortRefreshEnabled, - students, trackEvent, updateActivityFeedback, scoringSettings, activity, isResearcher } = props; + students, trackEvent, updateActivityFeedback, scoringSettings, activity, isResearcher, rubricDocUrl } = props; const displayedFeedbacks = feedbackSortByMethod !== SORT_BY_FEEDBACK_PROGRESS ? feedbacks : students.map((student: any) => { @@ -75,6 +76,7 @@ export const ActivityLevelFeedbackStudentRows: React.FC = (props) => { activityId={activityId} activityIndex={activityIndex} rubric={rubric} + rubricDocUrl={rubricDocUrl} student={student} rubricFeedback={rubricFeedback} setFeedbackSortRefreshEnabled={setFeedbackSortRefreshEnabled} diff --git a/js/components/portal-dashboard/feedback/feedback-legend.tsx b/js/components/portal-dashboard/feedback/feedback-legend.tsx index 16bef3324..cdf78ffb2 100644 --- a/js/components/portal-dashboard/feedback/feedback-legend.tsx +++ b/js/components/portal-dashboard/feedback/feedback-legend.tsx @@ -23,6 +23,7 @@ interface IProps { activity: Map; scoringSettings: ScoringSettings; rubric: Rubric; + rubricDocUrl: string; avgScore: number; avgScoreMax: number; feedbacks: any; @@ -31,7 +32,7 @@ interface IProps { } const FeedbackLegend: React.FC = (props) => { - const { feedbackLevel, activity, scoringSettings, avgScore, avgScoreMax, rubric, feedbacks, trackEvent, isResearcher } = props; + const { feedbackLevel, activity, scoringSettings, avgScore, avgScoreMax, rubric, rubricDocUrl, feedbacks, trackEvent, isResearcher } = props; const { scoreType } = scoringSettings; const awaitingFeedbackIcon = feedbackLevel === "Activity" ? @@ -73,7 +74,7 @@ const FeedbackLegend: React.FC = (props) => { } {rubric &&
Rubric Summary: - +
} diff --git a/js/components/portal-dashboard/feedback/rubric-summary-icon.tsx b/js/components/portal-dashboard/feedback/rubric-summary-icon.tsx index de5e77150..417103e4c 100644 --- a/js/components/portal-dashboard/feedback/rubric-summary-icon.tsx +++ b/js/components/portal-dashboard/feedback/rubric-summary-icon.tsx @@ -9,6 +9,7 @@ import css from "../../../../css/portal-dashboard/feedback/rubric-summary-icon.l interface IProps { rubric: Rubric; + rubricDocUrl: string; feedbacks: any; activityId: string; scoringSettings: ScoringSettings; @@ -32,7 +33,7 @@ const maxIconHeight = Math.round(iconWidth / 1.66); // keep rectangular const defaultIconRowHeight = 18; export const RubricSummaryIcon: React.FC = (props) => { - const { rubric, feedbacks, activityId, scoringSettings, trackEvent } = props; + const { rubric, rubricDocUrl, feedbacks, activityId, scoringSettings, trackEvent } = props; const [modalOpen, setModalOpen] = useState(false); const handleToggleModal = () => setModalOpen(prev => { @@ -57,9 +58,13 @@ export const RubricSummaryIcon: React.FC = (props) => { .map((f: any) => f.get("rubricFeedback")) .toJS(); + const numCriteria = rubric.criteriaGroups.reduce((acc, cur) => { + return acc + cur.criteria.length; + }, 0); + const numCompletedRubrics = rubricFeedbacks.reduce((acc: number, cur: PartialRubricFeedback) => { const numNonZeroScores = getNumNonZeroScores(cur); - if (numNonZeroScores >= rubric.criteria.length) { + if (numNonZeroScores >= numCriteria) { acc++; } return acc; @@ -72,23 +77,25 @@ export const RubricSummaryIcon: React.FC = (props) => { return acc; }, {}); - rubric.criteria.forEach(criteria => { - const criteriaCount: ICriteriaCount = { - id: criteria.id, - numStudents: 0, - ratings: {...ratingsCounts}, - }; - criteriaCounts.push(criteriaCount); - - rubricFeedbacks.forEach((rubricFeedback: PartialRubricFeedback) => { - const numNonZeroScores = getNumNonZeroScores(rubricFeedback); - if (numNonZeroScores >= rubric.criteria.length) { - const criteriaFeedback = rubricFeedback[criteria.id]; - if (criteriaFeedback?.score > 0) { - criteriaCount.numStudents++; - criteriaCount.ratings[criteriaFeedback.id]++; + rubric.criteriaGroups.forEach(criteriaGroup => { + criteriaGroup.criteria.forEach(criteria => { + const criteriaCount: ICriteriaCount = { + id: criteria.id, + numStudents: 0, + ratings: {...ratingsCounts}, + }; + criteriaCounts.push(criteriaCount); + + rubricFeedbacks.forEach((rubricFeedback: PartialRubricFeedback) => { + const numNonZeroScores = getNumNonZeroScores(rubricFeedback); + if (numNonZeroScores >= numCriteria) { + const criteriaFeedback = rubricFeedback[criteria.id]; + if (criteriaFeedback?.score > 0) { + criteriaCount.numStudents++; + criteriaCount.ratings[criteriaFeedback.id]++; + } } - } + }); }); }); } @@ -137,6 +144,7 @@ export const RubricSummaryIcon: React.FC = (props) => { {modalOpen && } diff --git a/js/components/portal-dashboard/feedback/rubric-summary-modal.tsx b/js/components/portal-dashboard/feedback/rubric-summary-modal.tsx index a99632854..20eba8535 100644 --- a/js/components/portal-dashboard/feedback/rubric-summary-modal.tsx +++ b/js/components/portal-dashboard/feedback/rubric-summary-modal.tsx @@ -13,6 +13,7 @@ import tableCss from "../../../../css/portal-dashboard/feedback/rubric-table.les interface IProps { onClose: () => void; rubric: Rubric; + rubricDocUrl: string; criteriaCounts: ICriteriaCount[]; scoringSettings: ScoringSettings; } @@ -43,44 +44,52 @@ export class RubricSummaryModal extends PureComponent { } private renderTable() { - const { rubric } = this.props; - const { criteria } = rubric; + const { rubric, rubricDocUrl } = this.props; + const { criteriaGroups } = rubric; return (
- {this.renderColumnHeaders(rubric)} + {this.renderColumnHeaders(rubric, rubricDocUrl)}
- {criteria.map(crit => -
-
- {crit.description} + {criteriaGroups.map((criteriaGroup, index) => ( +
+ {criteriaGroup.label.length > 0 &&
{criteriaGroup.label}
} +
+ {criteriaGroup.criteria.map(criterion => +
+
+ {criterion.description} +
+ {this.renderRatings(criterion)} +
+ )}
- {this.renderRatings(crit)}
- )} + ))}
); } - private renderColumnHeaders = (rubric: Rubric) => { - const { referenceURL } = rubric; + private renderColumnHeaders = (rubric: Rubric, rubricDocUrl: string) => { + const hasRubricDocUrl = rubricDocUrl.trim().length > 0; + const showScore = this.props.scoringSettings.scoreType === RUBRIC_SCORE; return (
-
- + {hasRubricDocUrl &&
+ Scoring Guide -
+
}
{rubric.criteriaLabel}
{rubric.ratings.map((rating: any) =>
{rating.label}
- {rubric.scoreUsingPoints && showScore &&
({rating.score})
} + {showScore &&
({rating.score})
}
)}
@@ -97,12 +106,12 @@ export class RubricSummaryModal extends PureComponent { ); } - private renderRating = (crit: RubricCriterion, rating: RubricRating) => { - const critId = crit.id; + private renderRating = (criterion: RubricCriterion, rating: RubricRating) => { + const critId = criterion.id; const ratingId = rating.id; const key = `${critId}-${ratingId}`; - const ratingDescription = crit.ratingDescriptions?.[ratingId]; - const isApplicableRating = crit.nonApplicableRatings === undefined || crit.nonApplicableRatings.indexOf(ratingId) < 0; + const ratingDescription = criterion.ratingDescriptions?.[ratingId]; + const isApplicableRating = criterion.nonApplicableRatings === undefined || criterion.nonApplicableRatings.indexOf(ratingId) < 0; const style: React.CSSProperties = { color: getFeedbackTextColor({rubric: this.props.rubric, score: rating.score}), backgroundColor: getFeedbackColor({rubric: this.props.rubric, score: rating.score}) diff --git a/js/components/portal-dashboard/feedback/rubric-table.tsx b/js/components/portal-dashboard/feedback/rubric-table.tsx index 93f76d0ed..cbf7c75df 100644 --- a/js/components/portal-dashboard/feedback/rubric-table.tsx +++ b/js/components/portal-dashboard/feedback/rubric-table.tsx @@ -3,7 +3,7 @@ import { Map } from "immutable"; import Markdown from "markdown-to-jsx"; import ReactTooltip from "react-tooltip"; import LaunchIcon from "../../../../img/svg-icons/launch-icon.svg"; -import { Rubric, getFeedbackColor } from "./rubric-utils"; +import { Rubric, RubricCriterion, RubricRating, getFeedbackColor } from "./rubric-utils"; import { ScoringSettings } from "../../../util/scoring"; import { RUBRIC_SCORE } from "../../../util/scoring-constants"; @@ -11,6 +11,7 @@ import css from "../../../../css/portal-dashboard/feedback/rubric-table.less"; interface IProps { rubric: Rubric; + rubricDocUrl: string; student: Map; rubricFeedback: any; activityId: string; @@ -19,76 +20,86 @@ interface IProps { updateActivityFeedback: (activityId: string, activityIndex: number, platformStudentId: string, feedback: any) => void; scoringSettings: ScoringSettings; } + export class RubricTableContainer extends React.PureComponent { render() { - const { rubric, scoringSettings } = this.props; - const { criteria } = rubric; + const { rubric, rubricDocUrl, scoringSettings } = this.props; + const { criteriaGroups } = rubric; return (
- {this.renderColumnHeaders(rubric, scoringSettings)} + {this.renderColumnHeaders(rubric, rubricDocUrl, scoringSettings)}
- {criteria.map((crit: any) => -
-
- {crit.description} + {criteriaGroups.map((criteriaGroup, index) => ( +
+ {criteriaGroup.label.length > 0 &&
{criteriaGroup.label}
} +
+ {criteriaGroup.criteria.map(criterion => +
+
+ {criterion.iconUrl && } + {criterion.description} +
+ {this.renderRatings(criterion)} +
+ )}
- {this.renderRatings(crit)}
- )} + ))}
); } - private renderColumnHeaders = (rubric: Rubric, scoringSettings: ScoringSettings) => { - const { referenceURL } = rubric; + private renderColumnHeaders = (rubric: Rubric, rubricDocUrl: string, scoringSettings: ScoringSettings) => { + const hasRubricDocUrl = rubricDocUrl.trim().length > 0; + const showScore = scoringSettings.scoreType === RUBRIC_SCORE; return (
-
- + {hasRubricDocUrl &&
+ Scoring Guide -
+
}
{rubric.criteriaLabel}
{rubric.ratings.map((rating: any) =>
{rating.label}
- {rubric.scoreUsingPoints && showScore &&
({rating.score})
} + {showScore &&
({rating.score})
}
)}
); } - private renderRatings = (crit: any) => { + private renderRatings = (criterion: RubricCriterion) => { const { rubric } = this.props; const { ratings } = rubric; return (
- {ratings.map((rating: any, index: number) => this.renderStudentRating(crit, rating, index))} + {ratings.map((rating: any, index: number) => this.renderStudentRating(criterion, rating, index))}
); } - private renderStudentRating = (crit: any, rating: any, buttonIndex: number) => { + private renderStudentRating = (criterion: RubricCriterion, rating: RubricRating, buttonIndex: number) => { const { rubricFeedback } = this.props; - const critId = crit.id; + const critId = criterion.id; const ratingId = rating.id; const radioButtonKey = `${critId}-${ratingId}`; const selected = (rubricFeedback && rubricFeedback[critId] && rubricFeedback[critId].id === ratingId); // Tooltips displayed to teacher should actually show student description if it's available. const ratingDescription = - (crit.ratingDescriptionsForStudent && crit.ratingDescriptionsForStudent[ratingId]) || - (crit.ratingDescriptions && crit.ratingDescriptions[ratingId]) || + (criterion.ratingDescriptionsForStudent && criterion.ratingDescriptionsForStudent[ratingId]) || + (criterion.ratingDescriptions && criterion.ratingDescriptions[ratingId]) || null; - const isApplicableRating = crit.nonApplicableRatings === undefined || - crit.nonApplicableRatings.indexOf(ratingId) < 0; + const isApplicableRating = criterion.nonApplicableRatings === undefined || + criterion.nonApplicableRatings.indexOf(ratingId) < 0; const style: React.CSSProperties = selected ? {backgroundColor: getFeedbackColor({rubric: this.props.rubric, score: rubricFeedback[critId].score})} : {}; const key = `${critId}-${ratingId}`; @@ -97,29 +108,30 @@ export class RubricTableContainer extends React.PureComponent {
{ !isApplicableRating ? N/A - : this.renderButton(critId, selected, ratingId, buttonIndex) + : this.renderButton(criterion, selected, ratingId, buttonIndex) }
- {isApplicableRating && {ratingDescription}} + {isApplicableRating && ratingDescription && {ratingDescription}}
); } - private renderButton = (critId: string, selected: boolean, ratingId: string, buttonIndex: number) => { + private renderButton = (criterion: RubricCriterion, selected: boolean, ratingId: string, buttonIndex: number) => { + const critId = criterion.id; + const handleRatingChange = (buttonIndex: number) => () => { const { rubric, rubricFeedback } = this.props; const deselect = (rubricFeedback && rubricFeedback[critId]) && (rubric.ratings.findIndex((r: any) => r.id === (rubricFeedback[critId].id)) === buttonIndex); - updateSelection(critId, ratingId, deselect); + updateSelection(ratingId, deselect); }; - const updateSelection = (critId: any, ratingId: string, deselect: boolean) => { + const updateSelection = (ratingId: string, deselect: boolean) => { const { rubric, rubricFeedback, student, setFeedbackSortRefreshEnabled } = this.props; const newSelection: any = {}; const rating = rubric.ratings.find((r: any) => r.id === ratingId); - const criteria = rubric.criteria.find((c: any) => c.id === critId); - if ((rating === undefined) || (criteria === undefined)) { + if ((rating === undefined) || (criterion === undefined)) { return; } @@ -137,7 +149,7 @@ export class RubricTableContainer extends React.PureComponent { id: ratingId, score, label, - description: criteria.ratingDescriptions[ratingId], + description: criterion.ratingDescriptions[ratingId], }; const newFeedback = Object.assign({}, rubricFeedback, newSelection); this.rubricChange(newFeedback, studentId); diff --git a/js/components/portal-dashboard/feedback/rubric-utils.ts b/js/components/portal-dashboard/feedback/rubric-utils.ts index d81fb7944..543e92324 100644 --- a/js/components/portal-dashboard/feedback/rubric-utils.ts +++ b/js/components/portal-dashboard/feedback/rubric-utils.ts @@ -1,25 +1,33 @@ -export type RubricCriterion = { +export interface RubricCriteriaGroup { + label: string; + labelForStudent: string; + criteria: RubricCriterion[]; +} + +export interface RubricCriterion { id: string; description: string; + descriptionForStudent: string; nonApplicableRatings: string[]; ratingDescriptions: Record; -}; + ratingDescriptionsForStudent: Record; + iconUrl: string; +} -export type RubricRating = { +export interface RubricRating { id: string; label: string; score: number; -}; +} -export type Rubric = { +export interface RubricV110 { id: string; - version: string; + version: "1.0.0" | "1.1.0"; versionNumber: string; updatedMsUTC: number; originUrl: string; - referenceURL: string; showRatingDescriptions: boolean; - scoreUsingPoints: boolean; + hideRubricFromStudentsInStudentReport: boolean; criteriaLabel: string; criteriaLabelForStudent: string; feedbackLabelForStudent: string; @@ -27,6 +35,11 @@ export type Rubric = { ratings: RubricRating[]; } +export type Rubric = Omit & { + version: "1.2.0"; + criteriaGroups: RubricCriteriaGroup[]; +}; + // Utility function to convert hex color to HSL const hexToHSL = (hex: string): [number, number, number] => { const r = parseInt(hex.slice(1, 3), 16) / 255; diff --git a/js/components/report/rubric-box-for-student.js b/js/components/report/rubric-box-for-student.js deleted file mode 100644 index 1473aad49..000000000 --- a/js/components/report/rubric-box-for-student.js +++ /dev/null @@ -1,39 +0,0 @@ -import React, { PureComponent } from "react"; -import Markdown from "markdown-to-jsx"; -import { RubricHelper } from "../../util/rubric-helper"; -import "../../../css/report/rubric-box-for-student.less"; - -export default class RubricBoxForStudent extends PureComponent { - render() { - const { rubric, rubricFeedback } = this.props; - const helper = new RubricHelper(rubric, rubricFeedback); - - const feedbacks = helper.allFeedback("student").filter(f => !!f).map(f => - - {f.description} - - - { rubric.showRatingDescriptions - ? `${f.label.toUpperCase()} – ${f.ratingDescription}` - : f.label.toUpperCase() - } - - - , - ); - - if (feedbacks.length > 0) { - return ( - - - - - - { feedbacks } - -
{ helper.criteriaLabel("student") }{ helper.feedbackLabel("student") }
- ); - } - return null; - } -} diff --git a/js/components/report/rubric-box-for-student.tsx b/js/components/report/rubric-box-for-student.tsx new file mode 100644 index 000000000..bdabe5646 --- /dev/null +++ b/js/components/report/rubric-box-for-student.tsx @@ -0,0 +1,87 @@ +import React, { PureComponent } from "react"; +import Markdown from "markdown-to-jsx"; +import { Rubric } from "../portal-dashboard/feedback/rubric-utils"; + +import "../../../css/report/rubric-box-for-student.less"; + +interface Props { + rubric: Rubric; + rubricFeedback: any; +} + +const getStringValue = (value = "", fallbackValue = ""): string => { + return value.trim().length > 0 ? value : fallbackValue; +}; + +export default class RubricBoxForStudent extends PureComponent { + render() { + const { rubric, rubricFeedback } = this.props; + const hasGroupLabel = rubric.criteriaGroups.reduce((acc, cur) => { + return acc || getStringValue(cur.labelForStudent, cur.label).trim().length > 0; + }, false); + const criteriaLabel = getStringValue(rubric.criteriaLabelForStudent, rubric.criteriaLabel); + + if (!rubric || !rubricFeedback) { + return null; + } + + return ( + + + + + + + + {rubric.criteriaGroups.map((criteriaGroup) => { + return criteriaGroup.criteria.map((criteria, index) => { + const showLabel = index === 0 && hasGroupLabel; + const feedback = rubricFeedback[criteria.id]; + const ratingId = feedback?.id; + const label = getStringValue( + criteriaGroup.labelForStudent, + criteriaGroup.label + ); + const description = getStringValue( + criteria.descriptionForStudent, + criteria.description + ); + const ratingDescription = getStringValue( + criteria.ratingDescriptionsForStudent?.[ratingId], + criteria.ratingDescriptions?.[ratingId] + ); + const rating = rubric.ratings.find(r => r.id === ratingId); + const ratingLabel = rating?.label.toUpperCase() ?? ""; + + return ( + + {showLabel && + + } + + + + ); + }); + })} + +
+ { criteriaLabel }{ rubric.feedbackLabelForStudent } +
+ {label} + +
+ {criteria.iconUrl && } + {description} +
+
+ { rubric.showRatingDescriptions + ? `${ratingLabel} – ${ratingDescription}` + : ratingLabel + } +
+ ); + } +} diff --git a/js/components/report/rubric-box.js b/js/components/report/rubric-box.js index 00ea7e142..b8ade3857 100644 --- a/js/components/report/rubric-box.js +++ b/js/components/report/rubric-box.js @@ -15,14 +15,15 @@ export default class RubricBox extends PureComponent { const { rubric, rubricChange, rubricFeedback } = this.props; const change = {}; const rating = rubric.ratings.find((r) => r.id === ratingId); - const criteria = rubric.criteria.find((c) => c.id === critId); + const criteria = rubric.criteriaGroups.reduce((acc, cur) => acc.concat(cur.criteria), []); + const criterion = criteria.find((c) => c.id === critId); const score = rating.score; const label = rating.label; change[critId] = { id: ratingId, score, label, - description: criteria.ratingDescriptions[ratingId], + description: criterion.ratingDescriptions[ratingId], }; const newFeedback = Object.assign({}, rubricFeedback, change); rubricChange(newFeedback); @@ -103,7 +104,8 @@ export default class RubricBox extends PureComponent { if (!rubric) { return null; } const linkLabel = "Scoring Guide"; - const { ratings, criteria, referenceURL } = rubric; + const { ratings, criteriaGroups, referenceURL } = rubric; + const criteria = criteriaGroups.reduce((acc, cur) => acc.concat(cur.criteria), []); // learnerID indicates we are displaying a user (not a summary) const isSummaryView = !learnerId; const referenceLink = referenceURL diff --git a/js/components/report/rubric-summary.js b/js/components/report/rubric-summary.js index 76b2d1912..70d1e7daf 100644 --- a/js/components/report/rubric-summary.js +++ b/js/components/report/rubric-summary.js @@ -53,9 +53,12 @@ export default class RubricSummary extends PureComponent { } getRowMaps() { const {rubric, rubricFeedbacks} = this.props; - const { criteria, ratings } = rubric; - return criteria.map((crit) => { - const cid = crit.id; + const { criteriaGroups, ratings } = rubric; + const criteria = criteriaGroups.reduce((acc, cur) => { + return acc.concat(cur.criteria); + }, []); + return criteria.map((criterion) => { + const cid = criterion.id; const rowMap = ratings.map((r, i) => { const rid = r.id; return rubricFeedbacks.reduce((p, c) => { diff --git a/js/containers/portal-dashboard/feedback/activity-feedback-panel.tsx b/js/containers/portal-dashboard/feedback/activity-feedback-panel.tsx index 2eea50466..04542fed5 100644 --- a/js/containers/portal-dashboard/feedback/activity-feedback-panel.tsx +++ b/js/containers/portal-dashboard/feedback/activity-feedback-panel.tsx @@ -25,6 +25,7 @@ interface IProps { numFeedbacksGivenReview: number; numFeedbacksNeedingReview: number; rubric: Rubric; + rubricDocUrl: string; setFeedbackSortRefreshEnabled: (value: boolean) => void; settings: any; activityFeedbackStudents: Map; @@ -53,7 +54,7 @@ class ActivityFeedbackPanel extends React.PureComponent { render() { const { activity, activityIndex, feedbacks, feedbacksNeedingReview, feedbackSortByMethod, isAnonymous, rubric, - updateActivityFeedback, trackEvent, activityFeedbackStudents, scoringSettings, isResearcher } = this.props; + updateActivityFeedback, trackEvent, activityFeedbackStudents, scoringSettings, isResearcher, rubricDocUrl } = this.props; const currentActivityId = activity?.get("id"); return ( @@ -67,6 +68,7 @@ class ActivityFeedbackPanel extends React.PureComponent { feedbackSortByMethod={feedbackSortByMethod} isAnonymous={isAnonymous} rubric={rubric} + rubricDocUrl={rubricDocUrl} setFeedbackSortRefreshEnabled={this.props.setFeedbackSortRefreshEnabled} students={activityFeedbackStudents} updateActivityFeedback={updateActivityFeedback} @@ -110,6 +112,7 @@ function mapStateToProps() { feedbacksNotAnswered, computedMaxScore, autoScores, settings: state.getIn(["feedback", "settings"]), rubric: rubric && rubric.toJS(), + rubricDocUrl: state.getIn(["report", "rubricDocUrl"]), activityIndex: ownProps.activity.get("activityIndex"), }; }; diff --git a/js/core/rubric-migrations.js b/js/core/rubric-migrations.js index d96b68288..40c684c53 100644 --- a/js/core/rubric-migrations.js +++ b/js/core/rubric-migrations.js @@ -5,7 +5,15 @@ function setVersionNumber(rubric, versionString) { } function expandNonApplicableRatings(rubric) { - const {ratings, criteria} = rubric; + let {criteria} = rubric; + const {ratings, criteriaGroups} = rubric; + + if (criteriaGroups) { + criteria = criteriaGroups.reduce((acc, cur) => { + return acc.concat(cur.criteria); + }, []); + } + criteria.forEach(c => { c.nonApplicableRatings = c.nonApplicableRatings ? c.nonApplicableRatings @@ -20,6 +28,30 @@ function expandNonApplicableRatings(rubric) { }); } +function createCriteriaGroups(rubric) { + const {criteria, criteriaGroups} = rubric; + + if (criteriaGroups) { + return; + } + + rubric.criteriaGroups = [{ + label: "", + labelForStudent: "", + criteria + }]; + + criteria.forEach(c => { + c.iconUrl = ""; + }); + + rubric.hideRubricFromStudentsInStudentReport = false; + + delete rubric.criteria; + delete rubric.referenceURL; + delete rubric.scoreUsingPoints; +} + const migrations = [ { version: "1.0.0", migrations: [], @@ -30,6 +62,12 @@ const migrations = [ expandNonApplicableRatings, ], }, + { + version: "1.2.0", + migrations: [ + createCriteriaGroups, + ], + }, ]; function runMigration(rubric, migration) { diff --git a/js/core/transform-json-response.ts b/js/core/transform-json-response.ts index c3db47e73..5dc456505 100644 --- a/js/core/transform-json-response.ts +++ b/js/core/transform-json-response.ts @@ -57,6 +57,7 @@ export interface IPortalData { id: number; teacher: string; hasTeacherEdition: boolean; + rubricDocUrl: string; }; classInfo: { id: number; diff --git a/js/data/offering-data.json b/js/data/offering-data.json index d86bff9d7..5c005a3e5 100644 --- a/js/data/offering-data.json +++ b/js/data/offering-data.json @@ -2,6 +2,7 @@ "activity_url": "http://fake.authoring.system/sequences/1", "id": 1, "rubric_url": "sample-rubric.json", + "rubric_doc_url": "https://example.com/sample-rubric-doc-url", "teacher": "Kristen Teachername", "has_teacher_edition": true } diff --git a/js/data/old-report-format.json b/js/data/old-report-format.json index 3dbb58471..50126fd37 100644 --- a/js/data/old-report-format.json +++ b/js/data/old-report-format.json @@ -15,6 +15,7 @@ "score_type": "manual", "use_rubric": true, "rubric_url": "sample-rubric.json", + "rubric_doc_url": "https://example.com/sample-rubric-doc-url", "max_score": 10, "activity_feedback":[ { diff --git a/js/reducers/feedback-reducer.ts b/js/reducers/feedback-reducer.ts index d8759aa67..15f2dc2f2 100644 --- a/js/reducers/feedback-reducer.ts +++ b/js/reducers/feedback-reducer.ts @@ -9,6 +9,7 @@ import { RecordFactory } from "../util/record-factory"; import { normalizeResourceJSON } from "../core/transform-json-response"; import { getScoringSettings, hasFeedbackGivenScoreType } from "../util/scoring"; import { Rubric } from "../components/portal-dashboard/feedback/rubric-utils"; +import migrate from "../core/rubric-migrations"; export interface IFeedbackState { settings: Map; @@ -40,6 +41,9 @@ export default function feedback(state = new FeedbackState({}), action: any) { switch (action.type) { case RECEIVE_FEEDBACK_SETTINGS: + if (action.response.rubric) { + action.response.rubric = migrate(action.response.rubric); + } const settingsMap = fromJS(action.response) as Map; hasScoredQuestions = state.get("hasScoredQuestions"); const activityFeedbacks = state.get("activityFeedbacks"); diff --git a/js/reducers/report-reducer.ts b/js/reducers/report-reducer.ts index 9376d7ca5..dce669ec4 100644 --- a/js/reducers/report-reducer.ts +++ b/js/reducers/report-reducer.ts @@ -63,6 +63,7 @@ export interface IReportState { reportItemAnswersFull: Map; reportItemAnswersCompact: Map; reportItemMetadata: Map; + rubricDocUrl: string; } const INITIAL_REPORT_STATE = RecordFactory({ @@ -96,6 +97,7 @@ const INITIAL_REPORT_STATE = RecordFactory({ reportItemAnswersFull: Map(), reportItemAnswersCompact: Map(), reportItemMetadata: Map(), + rubricDocUrl: "", }); const reportItemIFramePhones: Record = {}; @@ -134,6 +136,7 @@ export class ReportState extends INITIAL_REPORT_STATE implements IReportState { reportItemAnswersFull: Map; reportItemAnswersCompact: Map; reportItemMetadata: Map; + rubricDocUrl: string; } // this exists to handle older interactives until they are updated to use the new report item api @@ -187,7 +190,8 @@ export default function report(state = new ReportState({}), action?: any) { .set("resourceLinkId", data.offering.id.toString()) .set("platformId", data.platformId) .set("sourceKey", data.sourceKey) - .set("hasTeacherEdition", data.offering.hasTeacherEdition); + .set("hasTeacherEdition", data.offering.hasTeacherEdition) + .set("rubricDocUrl", data.offering.rubricDocUrl ?? ""); return state; case RECEIVE_RESOURCE_STRUCTURE: data = normalizeResourceJSON(action.response); diff --git a/js/util/rubric-helper.js b/js/util/rubric-helper.js index f54cf087c..9fe9a2f22 100644 --- a/js/util/rubric-helper.js +++ b/js/util/rubric-helper.js @@ -1,6 +1,6 @@ import { fromJS } from "immutable"; -const NO_FEEDBACK = fromJS({}); +export const NO_FEEDBACK = fromJS({}); // Some parts of Rubric are designed for student specifically. // They use common suffix appended to the properties. diff --git a/js/util/scoring.ts b/js/util/scoring.ts index 8f63a1997..0ffff43a4 100644 --- a/js/util/scoring.ts +++ b/js/util/scoring.ts @@ -93,9 +93,15 @@ export const getCurrentScores = (feedbacks: any) => { }, []); }; +export const getNumCriteria = (rubric: Rubric) => { + return rubric.criteriaGroups.reduce((acc, cur) => { + return acc + cur.criteria.length; + }, 0); +}; + export const getCompletedRubricScores = (rubric: Rubric, feedbacks: any) => { let scores: Map = Map({}); - const numCriteria = rubric.criteria.length; + const numCriteria = getNumCriteria(rubric); feedbacks.feedbacks.forEach((feedback: any) => { const key = feedback.get("platformStudentId"); const rubricFeedback = feedback.get("rubricFeedback"); @@ -115,7 +121,7 @@ export const getRubricDisplayScore = (rubric: Rubric, rubricFeedback: any, maxSc if (rubricFeedback) { const scoredValues = Object.values(rubricFeedback).filter((v: any) => v.score > 0); - if (scoredValues.length === rubric.criteria.length) { + if (scoredValues.length === getNumCriteria(rubric)) { const totalScore = scoredValues.reduce((acc, cur: any) => acc + cur.score, 0); displayScore = String(totalScore); } @@ -137,7 +143,7 @@ export const hasFeedbackGivenScoreType = (options: {scoreType: ScoreType; textFe let hasFilledRubric = false; if (rubric && rubricFeedback) { const scoredValues = Object.values(rubricFeedback).filter((v: any) => v.score > 0); - hasFilledRubric = scoredValues.length === rubric.criteria.length; + hasFilledRubric = scoredValues.length === getNumCriteria(rubric); } switch (scoreType) { diff --git a/test/core/rubric-migration_spec.js b/test/core/rubric-migration_spec.js index 82b2f1180..530e4de6a 100644 --- a/test/core/rubric-migration_spec.js +++ b/test/core/rubric-migration_spec.js @@ -22,9 +22,19 @@ describe("the migrating rubric", () => { }); describe("the current rubric json should include values for all ratings", () => { - const na = migrated.criteria[0].nonApplicableRatings; + const na = migrated.criteriaGroups[0].criteria[0].nonApplicableRatings; const expectedLength = rubric.ratings.length; expect(na.length).toEqual(expectedLength); }); }); + + describe("criteriaGroups", () => { + + describe("the migration should create criteria groups", () => { + expect(rubric.criteriaGroups).toBe(undefined); + expect(migrated.criteriaGroups.length).toBe(1); + expect(migrated.criteriaGroups[0].criteria.length).toBe(rubric.criteria.length); + expect(migrated.criteria).toBe(undefined); + }); + }); });