Skip to content

Commit

Permalink
Teacher Tool: Import and Export Rubric (#9845)
Browse files Browse the repository at this point in the history
This change enables exporting rubrics to a file and importing them again. I've tucked the options behind a new "Action Menu", which is just a meatball menu on the right side of the toolbar. I didn't spend a ton of time styling that, just wanted to get something fairly quick working.
  • Loading branch information
thsparks authored Feb 5, 2024
1 parent 7421dc3 commit 5937787
Show file tree
Hide file tree
Showing 24 changed files with 443 additions and 57 deletions.
5 changes: 5 additions & 0 deletions pxtlib/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,11 @@ namespace ts.pxtc.Util {
}
}

export function sanitizeFileName(name: string): string {
/* eslint-disable no-control-regex */
return name.replace(/[()\\\/.,?*^:<>!;'#$%^&|"@+=«»°{}\[\]¾½¼³²¦¬¤¢£~­¯¸`±\x00-\x1F]/g, '').trim().replace(/\s+/g, '-');
}

export function repeatMap<T>(n: number, fn: (index: number) => T): T[] {
n = n || 0;
let r: T[] = [];
Expand Down
2 changes: 2 additions & 0 deletions teachertool/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { postNotification } from "./transforms/postNotification";
import { loadCatalogAsync } from "./transforms/loadCatalogAsync";
import { loadValidatorPlansAsync } from "./transforms/loadValidatorPlansAsync";
import { tryLoadLastActiveRubricAsync } from "./transforms/tryLoadLastActiveRubricAsync";
import { ImportRubricModal } from "./components/ImportRubricModal";

export const App = () => {
const { state, dispatch } = useContext(AppStateContext);
Expand Down Expand Up @@ -58,6 +59,7 @@ export const App = () => {
<HeaderBar />
<MainPanel />
<CatalogModal />
<ImportRubricModal />
<Notifications />
</>
);
Expand Down
49 changes: 49 additions & 0 deletions teachertool/src/components/ActionsMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useContext } from "react";
import { MenuDropdown, MenuItem } from "react-common/components/controls/MenuDropdown";
import { writeRubricToFile } from "../services/fileSystemService";
import { AppStateContext } from "../state/appStateContext";
// eslint-disable-next-line import/no-internal-modules
import css from "./styling/ActionsMenu.module.scss";
import { showModal } from "../transforms/showModal";

export interface IProps {}

export const ActionsMenu: React.FC<IProps> = () => {
const { state: teacherTool } = useContext(AppStateContext);

function handleImportRubricClicked() {
showModal("import-rubric");
}

function handleExportRubricClicked() {
writeRubricToFile(teacherTool.rubric);
}

const menuItems: MenuItem[] = [
{
id: "import-rubric",
title: lf("Import Rubric"),
label: lf("Import Rubric"),
ariaLabel: lf("Import Rubric"),
onClick: handleImportRubricClicked,
},
{
id: "export-rubric",
title: lf("Export Rubric"),
label: lf("Export Rubric"),
ariaLabel: lf("Export Rubric"),
onClick: handleExportRubricClicked,
},
];

const dropdownLabel = <i className={"fas fa-ellipsis-v"} />;
return (
<MenuDropdown
id="actions-menu"
className={css["actions-menu"]}
items={menuItems}
title={"Actions"}
label={dropdownLabel}
/>
);
};
4 changes: 2 additions & 2 deletions teachertool/src/components/ActiveRubricDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { AppStateContext } from "../state/appStateContext";
import { getCatalogCriteriaWithId } from "../state/helpers";
import { Button } from "react-common/components/controls/Button";
import { removeCriteriaFromRubric } from "../transforms/removeCriteriaFromRubric";
import { showCatalogModal } from "../transforms/showCatalogModal";
import { showModal } from "../transforms/showModal";
import { setRubricName } from "../transforms/setRubricName";
import { DebouncedInput } from "./DebouncedInput";

Expand Down Expand Up @@ -45,7 +45,7 @@ export const ActiveRubricDisplay: React.FC<IProps> = ({}) => {
<Button
className="inline"
label={lf("+ Add Criteria")}
onClick={showCatalogModal}
onClick={() => showModal("catalog-display")}
title={lf("Add Criteria")}
/>
</div>
Expand Down
2 changes: 1 addition & 1 deletion teachertool/src/components/CatalogModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const CatalogModal: React.FC<IProps> = ({}) => {
}

function closeModal() {
hideModal("catalog-display");
hideModal();

// Clear for next open.
setCheckedCriteria(new Set<string>());
Expand Down
4 changes: 0 additions & 4 deletions teachertool/src/components/DebugInput.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
/// <reference path="../../../built/pxtblocks.d.ts"/>

import { useState } from "react";
import { Button } from "react-common/components/controls/Button";
import { Input } from "react-common/components/controls/Input";
import { Textarea } from "react-common/components/controls/Textarea";
import { loadProjectMetadataAsync } from "../transforms/loadProjectMetadataAsync";
import { runEvaluateAsync } from "../transforms/runEvaluateAsync";

interface IProps {}
Expand Down
99 changes: 99 additions & 0 deletions teachertool/src/components/ImportRubricModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { useContext, useEffect, useState } from "react";
import { AppStateContext } from "../state/appStateContext";
import { Modal } from "react-common/components/controls/Modal";
import { hideModal } from "../transforms/hideModal";
// eslint-disable-next-line import/no-internal-modules
import css from "./styling/ImportRubricModal.module.scss";
import { getRubricFromFileAsync } from "../transforms/getRubricFromFileAsync";
import { NoticeLabel } from "./NoticeLabel";
import { Rubric } from "../types/rubric";
import { RubricPreview } from "./RubricPreview";
import { setRubric } from "../transforms/setRubric";

export interface IProps {}

export const ImportRubricModal: React.FC<IProps> = () => {
const { state: teacherTool } = useContext(AppStateContext);
const [selectedFile, setSelectedFile] = useState<File | undefined>(undefined);
const [selectedRubric, setSelectedRubric] = useState<Rubric | undefined>(undefined);
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);

useEffect(() => {
async function updatePreview(file: File) {
const parsedRubric = await getRubricFromFileAsync(file, false /* allow partial */);
if (!parsedRubric) {
setErrorMessage(lf("Invalid rubric file."));
} else {
setErrorMessage(undefined);
}
setSelectedRubric(parsedRubric);
}

if (selectedFile) {
updatePreview(selectedFile);
} else {
setSelectedRubric(undefined);
setErrorMessage(undefined);
}
}, [selectedFile]);

function closeModal() {
setSelectedFile(undefined);
setErrorMessage(undefined);
setSelectedRubric(undefined);
hideModal();
}

function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
if (event.target.files && event.target.files.length > 0) {
setSelectedFile(event.target.files[0]);
} else {
setSelectedFile(undefined);
}
}

function handleImportClicked() {
if (selectedRubric) {
setRubric(selectedRubric);
}

closeModal();
}

const actions = [
{
label: lf("Cancel"),
className: "secondary",
onClick: closeModal,
},
{
label: lf("Import"),
className: "primary",
onClick: handleImportClicked,
disabled: !selectedRubric,
},
];

return teacherTool.modal === "import-rubric" ? (
<Modal title={lf("Select rubric to import")} actions={actions} onClose={closeModal}>
<div className={css["import-rubric"]}>
<NoticeLabel severity="warning">
{lf("Warning! Your current rubric will be overwritten by the imported rubric.")}
</NoticeLabel>
{errorMessage && <NoticeLabel severity="error">{errorMessage}</NoticeLabel>}
{selectedRubric && (
<div className={css["rubric-preview-container"]}>
<RubricPreview rubric={selectedRubric} />
</div>
)}
<input
type="file"
tabIndex={0}
autoFocus
aria-label={lf("Select rubric file.")}
onChange={handleFileChange}
/>
</div>
</Modal>
) : null;
};
37 changes: 37 additions & 0 deletions teachertool/src/components/NoticeLabel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { classList } from "react-common/components/util";
// eslint-disable-next-line import/no-internal-modules
import css from "./styling/NoticeLabel.module.scss";

export type NoticeLabelSeverity = "info" | "warning" | "error" | "neutral";

export interface INoticeLabelProps extends React.PropsWithChildren<{}> {
severity: NoticeLabelSeverity;
}

export const NoticeLabel: React.FC<INoticeLabelProps> = props => {
let iconClass = undefined;
switch (props.severity) {
case "info":
iconClass = "fas fa-exclamation-circle";
break;
case "warning":
iconClass = "fas fa-exclamation-triangle";
break;
case "error":
iconClass = "fas fa-times";
break;
case "neutral":
default:
// no icon
break;
}

return (
<div className={css["notice-label-background"]}>
<div className={classList(css["notice-label-container"], css[`${props.severity}-notice-label-container`])}>
{props.severity !== "neutral" && <i className={classList(iconClass, css["icon"])} />}
<label className="notice-label">{props.children}</label>
</div>
</div>
);
};
24 changes: 24 additions & 0 deletions teachertool/src/components/RubricPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { getCatalogCriteriaWithId } from "../state/helpers";
import { Rubric } from "../types/rubric";
// eslint-disable-next-line import/no-internal-modules
import css from "./styling/RubricPreview.module.scss";

export interface IRubricPreviewProps {
rubric: Rubric;
}

export const RubricPreview: React.FC<IRubricPreviewProps> = ({ rubric }) => {
return (
<div className={css["container"]}>
<div className={css["rubric-header"]}>{rubric.name}</div>
{rubric.criteria.map((c, i) => {
const template = getCatalogCriteriaWithId(c.catalogCriteriaId)?.template;
return template ? (
<div key={i} className={css["rubric-criteria"]}>
{template}
</div>
) : null;
})}
</div>
);
};
5 changes: 4 additions & 1 deletion teachertool/src/components/RubricWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { TabPanel } from "./TabPanel";
import { DebugInput } from "./DebugInput";
import { EvalResultDisplay } from "./EvalResultDisplay";
import { ActiveRubricDisplay } from "./ActiveRubricDisplay";
import { ActionsMenu } from "./ActionsMenu";

interface IProps {}

Expand All @@ -23,7 +24,9 @@ export const RubricWorkspace: React.FC<IProps> = () => {
{/* Center */}
<></>
{/* Right */}
<></>
<>
<ActionsMenu />
</>
</Toolbar>
<TabPanel name="rubric">
<DebugInput />
Expand Down
26 changes: 26 additions & 0 deletions teachertool/src/components/styling/ActionsMenu.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
.actions-menu {
button[class*="menu-button"] {
padding: 0rem 0.2rem;
color: var(--pxt-content-foreground);
}

button[class*="common-menu-dropdown-item"] {
color: var(--pxt-content-foreground);
}

ul {
background-color: var(--pxt-content-background);
}

li {
border-bottom: 1px solid var(--pxt-content-accent);

&:last-child {
border-bottom: none;
}

&:hover {
background-color: var(--pxt-content-accent);
}
}
}
13 changes: 13 additions & 0 deletions teachertool/src/components/styling/ImportRubricModal.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.import-rubric {
display: flex;
flex-direction: column;
gap: 0.5rem;

.rubric-preview-container {
max-height: 50vh;
overflow-y: auto;
border: 2px solid var(--pxt-content-foreground);
border-radius: 0.3rem;
background-color: var(--pxt-content-background-glass);
}
}
43 changes: 43 additions & 0 deletions teachertool/src/components/styling/NoticeLabel.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
.notice-label-background {
border-color: var(--pxt-content-foreground);
background-color: var(--pxt-content-background);
border-radius: 0.3rem;

.notice-label-container {
display: flex;
flex-direction: row;
align-items: center;
padding: 0.3rem;
border-radius: 0.3rem;
border: 2px solid
}

.error-notice-label-container {
border-color: rgba(red, 0.2);
background-color: rgba(red, 0.2);
justify-content: flex-start;
}

.warning-notice-label-container {
border-color: rgba(orange, 0.4);
background-color: rgba(orange, 0.4);
justify-content: flex-start;
}

.info-notice-label-container {
border-color: rgba(blue, 0.2);
background-color: rgba(blue, 0.2);
justify-content: flex-start;
}

.neutral-notice-label-container {
border-color: rgba(black, 0.1);
background-color: rgba(black, 0.1);
align-content: center;
justify-content: center;
}

.icon {
margin-right: 0.5rem;
}
}
Loading

0 comments on commit 5937787

Please sign in to comment.