Skip to content

Commit

Permalink
feat: Add rubric summary tag table [PT-188404153]
Browse files Browse the repository at this point in the history
Also:

1. Added debug:rubricUrlOverride parameter.
2. Added test rubrics.
3. Fixed non-version number change migration for iconPhrase.
  • Loading branch information
dougmartin committed Oct 28, 2024
1 parent d272820 commit 9fd8fa1
Show file tree
Hide file tree
Showing 13 changed files with 512 additions and 80 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ The following query params can be used during development or when debugging issu
- `debug:rubricSummaryTableOverride` - overrides the rubric summary table option for the loaded rubric. Possible values are:
`none`, `above`, `below`, and `onlySummary`. Useful for testing the placement of the summary table without having to create multiple rubrics.
- `debug:rubricUrlOverride` - overrides the rubric url that is loaded
## License
[MIT](https://github.com/concord-consortium/grasp-seasons/blob/master/LICENSE)
8 changes: 7 additions & 1 deletion css/portal-dashboard/feedback/rubric-summary-modal.less
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.35);
height: auto;
left: 50px;
top: 135px;
// top: 135px;
width: 777px;

.lightboxHeader {
Expand All @@ -30,6 +30,12 @@
.contentArea {
padding: 0 20px 10px 20px;

.contents {
display: flex;
gap: 10px;
flex-direction: column;
}

.modalOption {
margin: 15px 0 10px 0;

Expand Down
17 changes: 16 additions & 1 deletion css/portal-dashboard/feedback/rubric-table.less
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,13 @@

.rubricDescriptionTitle {
justify-content: center;
width: 75%
width: 75%;

&.fullWidth {
width: 100%;
text-align: left;
margin-left: 10px;
}
}
}
.rubricScoreHeader {
Expand Down Expand Up @@ -111,6 +117,10 @@
flex: 1;
align-items: stretch;

p {
margin-top: 0;
}

&:first-child {
border: none;
}
Expand All @@ -120,6 +130,11 @@
font-weight: normal;
flex: auto;

&.separateImage {
display: flex;
align-items: center;
}

img {
padding-right: 10px;
}
Expand Down
4 changes: 4 additions & 0 deletions js/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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";
import { rubricUrlOverride } from "./util/debug-flags";

const PORTAL_AUTH_PATH = "/auth/oauth_authorize";
let accessToken: string | null = null;
Expand Down Expand Up @@ -409,6 +410,9 @@ export function updateActivityFeedbacks(data: any, reportState: IStateReportPart
const rubricUrlCache: any = {};

export function fetchRubric(rubricUrl: string) {
// check for override
rubricUrl = rubricUrlOverride ?? rubricUrl;

return new Promise((resolve, reject) => {
if (!rubricUrlCache[rubricUrl]) {
fetch(rubricUrl)
Expand Down
136 changes: 73 additions & 63 deletions js/components/portal-dashboard/feedback/rubric-summary-modal.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import React, { PureComponent } from "react";
import { Modal } from "react-bootstrap";
import Markdown from "markdown-to-jsx";
import { Rubric, RubricCriterion, RubricRating, getFeedbackColor, getFeedbackTextColor } from "./rubric-utils";
import classNames from "classnames";
import { Rubric, getFeedbackColor, getFeedbackTextColor } from "./rubric-utils";
import { ICriteriaCount } from "./rubric-summary-icon";
import LaunchIcon from "../../../../img/svg-icons/launch-icon.svg";
import { ScoringSettings } from "../../../util/scoring";
import { RUBRIC_SCORE } from "../../../util/scoring-constants";
import { getRubricSummary, IRubricSummary, IRubricSummaryRow } from "../../../util/rubric-summary";

import css from "../../../../css/portal-dashboard/feedback/rubric-summary-modal.less";
import tableCss from "../../../../css/portal-dashboard/feedback/rubric-table.less";
Expand All @@ -21,6 +23,7 @@ interface IProps {
export class RubricSummaryModal extends PureComponent<IProps> {

render() {
const summary = getRubricSummary(this.props.rubric, this.props.criteriaCounts);

return (
<Modal animation={false} centered dialogClassName={css.lightbox} onHide={this.handleClose} show={true} data-cy="rubric-summary-modal">
Expand All @@ -31,7 +34,9 @@ export class RubricSummaryModal extends PureComponent<IProps> {
</Modal.Header>
<Modal.Body className={css.lightboxBody}>
<div className={css.contentArea} data-cy="rubric-summary-modal-content-area">
{this.renderTable()}
<div className={css.contents}>
{this.renderSummary(summary)}
</div>
<div className={css.buttonContainer}>
<div className={css.closeButton} onClick={this.handleClose} data-cy="rubric-summary-modal-close-button">
Close
Expand All @@ -43,49 +48,69 @@ export class RubricSummaryModal extends PureComponent<IProps> {
);
}

private renderTable() {
private renderSummary(summary: IRubricSummary) {
switch (this.props.rubric.tagSummaryDisplay) {
case "above":
return <>{this.renderTagSummary(summary)}{this.renderCriteriaTable(summary)}</>;
case "below":
return <>{this.renderCriteriaTable(summary)}{this.renderTagSummary(summary)}</>;
case "onlySummary":
return this.renderTagSummary(summary);
case "none":
default:
return this.renderCriteriaTable(summary);
}
}

private renderTagSummary({tagSummaryRows, tagSummaryTitle}: IRubricSummary) {
const { rubric, rubricDocUrl } = this.props;

return (
<div className={tableCss.rubricContainer} data-cy="rubric-table">
{this.renderColumnHeaders(rubric, rubricDocUrl, {title: tagSummaryTitle, hideScoringGuide: true})}
<div className={tableCss.rubricTable}>
<div className={tableCss.rubricTableGroup}>
{this.renderRows(tagSummaryRows)}
</div>
</div>
</div>
);
}

private renderCriteriaTable({tableRows}: IRubricSummary) {
const { rubric, rubricDocUrl } = this.props;
const { criteriaGroups } = rubric;

return (
<div className={tableCss.rubricContainer} data-cy="rubric-table">
{this.renderColumnHeaders(rubric, rubricDocUrl)}
{this.renderColumnHeaders(rubric, rubricDocUrl, {title: rubric.criteriaLabel})}
<div className={tableCss.rubricTable}>
{criteriaGroups.map((criteriaGroup, index) => (
<div className={tableCss.rubricTableGroup} key={index}>
{criteriaGroup.label.length > 0 && <div className={tableCss.rubricTableGroupLabel}>{criteriaGroup.label}</div>}
<div className={tableCss.rubricTableRows}>
{criteriaGroup.criteria.map(criterion =>
<div className={tableCss.rubricTableRow} key={criterion.id} id={criterion.id}>
<div className={tableCss.rubricDescription}>
{criterion.iconUrl && <img src={criterion.iconUrl} title={criterion.iconPhrase} />}
<Markdown>{criterion.description}</Markdown>
</div>
{this.renderRatings(criterion)}
</div>
)}
</div>
{this.renderRows(tableRows.filter(r => r.criteriaGroupIndex === index))}
</div>
))}
</div>
</div>
);
}

private renderColumnHeaders = (rubric: Rubric, rubricDocUrl: string) => {
private renderColumnHeaders = (rubric: Rubric, rubricDocUrl: string, options: {title: string; hideScoringGuide?: boolean}) => {
const hasRubricDocUrl = rubricDocUrl.trim().length > 0;

const showScore = this.props.scoringSettings.scoreType === RUBRIC_SCORE;
const titleClassName = classNames(tableCss.rubricDescriptionTitle, tableCss.fullWidth);

return (
<div className={tableCss.columnHeaders}>
<div className={tableCss.rubricDescriptionHeader}>
{hasRubricDocUrl && <div className={tableCss.scoringGuideArea}>
{hasRubricDocUrl && !options.hideScoringGuide && <div className={tableCss.scoringGuideArea}>
<a className={tableCss.launchButton} href={rubricDocUrl} target="_blank" data-cy="scoring-guide-launch-icon">
<LaunchIcon className={tableCss.icon} />
</a>
Scoring Guide
</div>}
<div className={tableCss.rubricDescriptionTitle}>{rubric.criteriaLabel}</div>
<div className={titleClassName}>{options.title}</div>
</div>
{rubric.ratings.map((rating: any) =>
<div className={tableCss.rubricScoreHeader} key={rating.id}>
Expand All @@ -97,55 +122,40 @@ export class RubricSummaryModal extends PureComponent<IProps> {
);
}

private renderRatings = (crit: RubricCriterion) => {
const { rubric } = this.props;
const { ratings } = rubric;
return (
<div className={tableCss.ratingsGroup}>
{ratings.map(rating => this.renderRating(crit, rating))}
</div>
);
}

private renderRating = (criterion: RubricCriterion, rating: RubricRating) => {
const critId = criterion.id;
const ratingId = rating.id;
const key = `${critId}-${ratingId}`;
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})
};
private renderRows(rows: IRubricSummaryRow[]) {
const {rubric} = this.props;

return (
<div className={tableCss.rubricScoreBox} style={style} key={key}>
<div className={tableCss.rubricButton} title={(isApplicableRating) ? ratingDescription : "Not Applicable"}>
{ !isApplicableRating
? <span className={tableCss.noRating}>N/A</span>
: this.renderPercentage(critId, ratingId)
}
</div>
<div className={tableCss.rubricTableRows}>
{rows.map(({text, ratings, iconUrl, iconPhrase}, index) => (
<div className={tableCss.rubricTableRow} key={index}>
<div className={classNames(tableCss.rubricDescription, tableCss.separateImage)}>
{iconUrl && <img src={iconUrl} title={iconPhrase} /> }
<Markdown>{text}</Markdown>
</div>
<div className={tableCss.ratingsGroup}>
{ratings.map(({score, isApplicableRating, description, percentage}, index) => {
const style: React.CSSProperties = {
color: getFeedbackTextColor({rubric, score}),
backgroundColor: getFeedbackColor({rubric, score})
};
return (
<div className={tableCss.rubricScoreBox} style={style} key={index}>
<div className={tableCss.rubricButton} title={description}>
{ !isApplicableRating
? <span className={tableCss.noRating}>N/A</span>
: <>{percentage}%</>
}
</div>
</div>
);
})}
</div>
</div>))}
</div>
);
}

private renderPercentage = (critId: string, ratingId: string) => {
let percentage = 0;

const criteriaCount = this.props.criteriaCounts.find(cc => cc.id === critId);
if (criteriaCount && criteriaCount?.numStudents > 0) {
const ratingCount = criteriaCount.ratings[ratingId];
if (ratingCount) {
percentage = Math.round((ratingCount / criteriaCount.numStudents) * 100);
}
}

return (
<>{percentage}%</>
);
}

private handleClose = (e: React.MouseEvent) => this.props.onClose();

}

3 changes: 2 additions & 1 deletion js/components/portal-dashboard/feedback/rubric-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from "react";
import { Map } from "immutable";
import Markdown from "markdown-to-jsx";
import ReactTooltip from "react-tooltip";
import classNames from "classnames";
import LaunchIcon from "../../../../img/svg-icons/launch-icon.svg";
import { Rubric, RubricCriterion, RubricRating, getFeedbackColor } from "./rubric-utils";
import { ScoringSettings } from "../../../util/scoring";
Expand Down Expand Up @@ -36,7 +37,7 @@ export class RubricTableContainer extends React.PureComponent<IProps> {
<div className={css.rubricTableRows}>
{criteriaGroup.criteria.map(criterion =>
<div className={css.rubricTableRow} key={criterion.id} id={criterion.id}>
<div className={css.rubricDescription}>
<div className={classNames(css.rubricDescription, css.separateImage)}>
{criterion.iconUrl && <img src={criterion.iconUrl} />}
<Markdown>{criterion.description}</Markdown>
</div>
Expand Down
19 changes: 8 additions & 11 deletions js/core/rubric-migrations.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import semver from "semver";
import queryString from "query-string";
import {rubricSummaryTableOverride} from "../util/debug-flags";

function getSummaryTableOverride(currentValue) {
const rubricSummaryTableOverride = queryString.parse(window.location.search)['debug:rubricSummaryTableOverride'];
return rubricSummaryTableOverride ?? currentValue;
}

Expand Down Expand Up @@ -58,14 +57,6 @@ function createCriteriaGroups(rubric) {
delete rubric.scoreUsingPoints;
}

function fixUndefinedIconPhrase(rubric) {
rubric.criteriaGroups.forEach(criteriaGroup => {
criteriaGroup.criteria.forEach(criteria => {
criteria.iconPhrase = criteria.iconPhrase ?? "";
});
});
}

const migrations = [
{ version: "1.0.0",
migrations: [],
Expand All @@ -80,7 +71,6 @@ const migrations = [
version: "1.2.0",
migrations: [
createCriteriaGroups,
fixUndefinedIconPhrase,
],
},
];
Expand Down Expand Up @@ -113,6 +103,13 @@ export default function migrate(rubric) {
}
}

// the iconPhrase was added in version 1.2.0 without a version bump
rubric.criteriaGroups.forEach(criteriaGroup => {
criteriaGroup.criteria.forEach(criteria => {
criteria.iconPhrase = criteria.iconPhrase ?? "";
});
});

// allow the user to set the summary table option via a query parameter
rubric.tagSummaryDisplay = getSummaryTableOverride(rubric.tagSummaryDisplay) ?? "none";

Expand Down
7 changes: 7 additions & 0 deletions js/util/debug-flags.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const params = new URLSearchParams(window.location.search);

export const disableRubric = params.get("debug:disableRubric") === "true";

export const rubricSummaryTableOverride = params.get("debug:rubricSummaryTableOverride");

export const rubricUrlOverride = params.get("debug:rubricUrlOverride");
3 changes: 0 additions & 3 deletions js/util/debug-flags.ts

This file was deleted.

Loading

0 comments on commit 9fd8fa1

Please sign in to comment.