diff --git a/docs/teachertool/catalog-shared-test.json b/docs/teachertool/catalog-shared-test.json new file mode 100644 index 000000000000..fd1bd6b52308 --- /dev/null +++ b/docs/teachertool/catalog-shared-test.json @@ -0,0 +1,19 @@ +{ + "criteria": [ + { + "id": "7AE7EA2A-3AC8-42DC-89DB-65E3AE157156", + "use": "check_for_comments", + "template": "At least ${count} comments", + "description": "The project contains at least the specified number of comments.", + "docPath": "/teachertool", + "params": [ + { + "name": "count", + "type": "number", + "default": 1, + "path": "checks[0].count" + } + ] + } + ] +} \ No newline at end of file diff --git a/docs/teachertool/catalog-shared.json b/docs/teachertool/catalog-shared.json new file mode 100644 index 000000000000..9df4abc6afb0 --- /dev/null +++ b/docs/teachertool/catalog-shared.json @@ -0,0 +1,30 @@ +{ + "criteria": [ + { + "id": "A7C78825-51B5-45B0-9E42-2AAC767D4E02", + "use": "processes_and_displays_array_in_loop", + "template": "One kind of loop to process/display the information in an array", + "docPath": "/teachertool" + }, + { + "id": "59AAC5BA-B0B3-4389-AA90-1E767EFA8563", + "use": "block_used_n_times", + "template": "${block_id} used ${count} times", + "description": "This block was used the specified number of times in your project.", + "docPath": "/teachertool", + "params": [ + { + "name": "block_id", + "type": "block_id", + "path": "checks[0].blocks[:clear&:append]" + }, + { + "name": "count", + "type": "number", + "default": 1, + "path": "checks[0].count" + } + ] + } + ] +} \ No newline at end of file diff --git a/teachertool/src/App.tsx b/teachertool/src/App.tsx index 4ccba477196d..a1d134577511 100644 --- a/teachertool/src/App.tsx +++ b/teachertool/src/App.tsx @@ -3,17 +3,23 @@ import { useEffect, useContext, useState } from "react"; import "./teacherTool.css"; import { AppStateContext, AppStateReady } from "./state/appStateContext"; import { usePromise } from "./hooks"; +import { makeNotification } from "./utils"; +import * as Actions from "./state/actions"; +import * as NotificationService from "./services/notificationService"; +import { downloadTargetConfigAsync } from "./services/ackendRequests"; +import { logDebug } from "./services/loggingService"; + import HeaderBar from "./components/HeaderBar"; import Notifications from "./components/Notifications"; -import * as NotificationService from "./services/notificationService"; -import { postNotification } from "./transforms/postNotification"; -import { makeNotification } from "./utils"; import DebugInput from "./components/DebugInput"; import { MakeCodeFrame } from "./components/MakecodeFrame"; import EvalResultDisplay from "./components/EvalResultDisplay"; -import { downloadTargetConfigAsync } from "./services/ackendRequests"; -import * as Actions from "./state/actions"; -import { logDebug } from "./services/loggingService"; +import ActiveRubricDisplay from "./components/ActiveRubricDisplay"; +import CatalogModal from "./components/CatalogModal"; + +import { postNotification } from "./transforms/postNotification"; +import { loadCatalogAsync } from "./transforms/loadCatalogAsync"; + function App() { const { state, dispatch } = useContext(AppStateContext); @@ -28,6 +34,10 @@ function App() { const cfg = await downloadTargetConfigAsync(); dispatch(Actions.setTargetConfig(cfg || {})); pxt.BrowserUtils.initTheme(); + + // Load criteria catalog + await loadCatalogAsync(); + // TODO: Remove this. Delay app init to expose any startup race conditions. setTimeout(() => { // Test notification @@ -49,9 +59,11 @@ function App() {
+
+ ); diff --git a/teachertool/src/components/ActiveRubricDisplay.tsx b/teachertool/src/components/ActiveRubricDisplay.tsx new file mode 100644 index 000000000000..23014a837dee --- /dev/null +++ b/teachertool/src/components/ActiveRubricDisplay.tsx @@ -0,0 +1,39 @@ +/// + +import { useContext } from "react"; +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"; + + +interface IProps {} + +const ActiveRubricDisplay: React.FC = ({}) => { + const { state: teacherTool, dispatch } = useContext(AppStateContext); + + return ( +
+

{lf("Rubric")}

+ {teacherTool.selectedCriteria?.map(criteriaInstance => { + if (!criteriaInstance) return null; + + const catalogCriteria = getCatalogCriteriaWithId(criteriaInstance.catalogCriteriaId); + return criteriaInstance.catalogCriteriaId && ( +
+ {catalogCriteria?.template} +
+ ); + })} +
+ ); +}; + +export default ActiveRubricDisplay; diff --git a/teachertool/src/components/CatalogModal.tsx b/teachertool/src/components/CatalogModal.tsx new file mode 100644 index 000000000000..6db5c102fb80 --- /dev/null +++ b/teachertool/src/components/CatalogModal.tsx @@ -0,0 +1,73 @@ +/// + +import { useContext, useState } from "react"; +import { AppStateContext } from "../state/appStateContext"; +import { Checkbox } from "react-common/components/controls/Checkbox"; +import { Modal } from "react-common/components/controls/Modal"; +import { hideModal } from "../transforms/hideModal"; +import { addCriteriaToRubric } from "../transforms/addCriteriaToRubric"; +import { CatalogCriteria } from "../types/criteria"; + +interface IProps {} + +const CatalogModal: React.FC = ({}) => { + const { state: teacherTool } = useContext(AppStateContext); + const [ checkedCriteriaIds, setCheckedCriteria ] = useState>(new Set()); + + function handleCriteriaSelectedChange(criteria: CatalogCriteria, newValue: boolean) { + const newSet = new Set(checkedCriteriaIds); + if (newValue) { + newSet.add(criteria.id); + } else { + newSet.delete(criteria.id); // Returns false if criteria.id is not in the set, can be safely ignored. + } + setCheckedCriteria(newSet); + } + + function isCriteriaSelected(criteriaId: string): boolean { + return checkedCriteriaIds.has(criteriaId); + } + + function handleAddSelectedClicked() { + addCriteriaToRubric([...checkedCriteriaIds]); + closeModal(); + } + + function closeModal() { + hideModal("catalog-display"); + + // Clear for next open. + setCheckedCriteria(new Set()); + } + + const modalActions = [ + { + label: lf("Cancel"), + className: "secondary", + onClick: closeModal, + }, + { + label: lf("Add Selected"), + className: "primary", + onClick: handleAddSelectedClicked, + }, + ] + + return teacherTool.modal === "catalog-display" ? ( + + {teacherTool.catalog?.map(criteria => { + return criteria?.template && ( + handleCriteriaSelectedChange(criteria, newValue)} + isChecked={isCriteriaSelected(criteria.id)} /> + ); + })} + + ) : null; +}; + +export default CatalogModal; diff --git a/teachertool/src/components/DebugInput.tsx b/teachertool/src/components/DebugInput.tsx index 22d590730def..ba9527b34ef4 100644 --- a/teachertool/src/components/DebugInput.tsx +++ b/teachertool/src/components/DebugInput.tsx @@ -10,7 +10,7 @@ import { runEvaluateAsync } from "../transforms/runEvaluateAsync"; interface IProps {} const DebugInput: React.FC = ({}) => { - const [shareLink, setShareLink] = useState("https://arcade.makecode.com/S70821-26848-68192-30094"); + const [shareLink, setShareLink] = useState("https://makecode.microbit.org/S95591-52406-50965-65671"); const [rubric, setRubric] = useState(""); const evaluate = async () => { diff --git a/teachertool/src/services/ackendRequests.ts b/teachertool/src/services/ackendRequests.ts index 69a30bb318b9..e86b42514563 100644 --- a/teachertool/src/services/ackendRequests.ts +++ b/teachertool/src/services/ackendRequests.ts @@ -1,3 +1,4 @@ +import { ErrorCode } from "../types/errorCode"; import { logError } from "./loggingService"; export async function getProjectTextAsync( @@ -13,7 +14,7 @@ export async function getProjectTextAsync( return projectText; } } catch (e) { - logError("getProjectTextAsync", e); + logError(ErrorCode.getProjectTextAsync, e); } } @@ -30,7 +31,7 @@ export async function getProjectMetaAsync( return projectMeta; } } catch (e) { - logError("getProjectMetaAsync", e); + logError(ErrorCode.getProjectMetaAsync, e); } } @@ -40,6 +41,6 @@ export async function downloadTargetConfigAsync(): Promise< try { return await pxt.targetConfigAsync(); } catch (e) { - logError("downloadTargetConfigAsync", e); + logError(ErrorCode.downloadTargetConfigAsync, e); } } diff --git a/teachertool/src/services/loggingService.ts b/teachertool/src/services/loggingService.ts index 24e02ff6a7ac..5c2df766a7a7 100644 --- a/teachertool/src/services/loggingService.ts +++ b/teachertool/src/services/loggingService.ts @@ -1,18 +1,15 @@ +import { ErrorCode } from "../types/errorCode"; + const timestamp = () => { const time = new Date(); return `[${time.getHours()}:${time.getMinutes()}:${time.getSeconds()}]`; }; -const formatName = (name: string) => { - return name.toLowerCase().replace(/ /g, "_"); -}; - export const logError = ( - errorCode: string, + errorCode: ErrorCode, message?: any, data: pxt.Map = {} ) => { - errorCode = formatName(errorCode); let dataObj = { ...data }; if (message) { if (typeof message === "object") { diff --git a/teachertool/src/services/makecodeEditorService.ts b/teachertool/src/services/makecodeEditorService.ts index d1aa7cd9f4e9..a8e3d8465194 100644 --- a/teachertool/src/services/makecodeEditorService.ts +++ b/teachertool/src/services/makecodeEditorService.ts @@ -1,3 +1,4 @@ +import { ErrorCode } from "../types/errorCode"; import { logDebug, logError } from "./loggingService"; interface PendingMessage { @@ -96,7 +97,7 @@ export async function runEvalInEditorAsync(serializedRubric: string): Promise ({ config, }); +const setCatalog = (catalog: CatalogCriteria[] | undefined): SetCatalog => ({ + type: "SET_CATALOG", + catalog, +}); + +const setSelectedCriteria = (criteria: CriteriaInstance[]): SetSelectedCriteria => ({ + type: "SET_SELECTED_CRITERIA", + criteria, +}); + +const removeCriteriaInstance = (instanceId: string): RemoveCriteriaInstance => ({ + type: "REMOVE_CRITERIA_INSTANCE", + instanceId, +}); + +const showModal = (modal: ModalType): ShowModal => ({ + type: "SHOW_MODAL", + modal, +}); + +const hideModal = (): HideModal => ({ + type: "HIDE_MODAL", +}); + export { postNotification, removeNotification, setProjectMetadata, setEvalResult, setTargetConfig, + setCatalog, + setSelectedCriteria, + removeCriteriaInstance, + showModal, + hideModal }; diff --git a/teachertool/src/state/appStateContext.tsx b/teachertool/src/state/appStateContext.tsx index b04c99c4d256..1274e2f28f5f 100644 --- a/teachertool/src/state/appStateContext.tsx +++ b/teachertool/src/state/appStateContext.tsx @@ -40,9 +40,16 @@ export function AppStateProvider( ): React.ReactElement { // Read the URL parameters and set the initial state accordingly const url = window.location.href; + const testCatalog = !!/testcatalog(?:[:=])1/.test(url) || !!/tc(?:[:=])1/.test(url); // Create the application state and state change mechanism (dispatch) - const [state_, dispatch_] = useReducer(reducer, {...initialAppState}); + const [state_, dispatch_] = useReducer(reducer, { + ...initialAppState, + flags: { + ...initialAppState.flags, + testCatalog + } + }); // Make state and dispatch available outside the React context useEffect(() => { diff --git a/teachertool/src/state/helpers.ts b/teachertool/src/state/helpers.ts new file mode 100644 index 000000000000..75c4852d929b --- /dev/null +++ b/teachertool/src/state/helpers.ts @@ -0,0 +1,7 @@ +import { CatalogCriteria } from "../types/criteria"; +import { stateAndDispatch } from "./appStateContext"; + +export function getCatalogCriteriaWithId(id: string): CatalogCriteria | undefined { + const { state } = stateAndDispatch(); + return state.catalog?.find(c => c.id === id); +} \ No newline at end of file diff --git a/teachertool/src/state/reducer.ts b/teachertool/src/state/reducer.ts index f6aea646d39b..004230443e12 100644 --- a/teachertool/src/state/reducer.ts +++ b/teachertool/src/state/reducer.ts @@ -43,6 +43,36 @@ export default function reducer(state: AppState, action: Action): AppState { currentEvalResult: action.result, }; } + case "SET_CATALOG": { + return { + ...state, + catalog: action.catalog, + }; + } + case "SET_SELECTED_CRITERIA": { + return { + ...state, + selectedCriteria: [...action.criteria], + }; + } + case "REMOVE_CRITERIA_INSTANCE": { + return { + ...state, + selectedCriteria: state.selectedCriteria.filter(c => c.instanceId !== action.instanceId) + }; + } + case "SHOW_MODAL": { + return { + ...state, + modal: action.modal, + }; + } + case "HIDE_MODAL": { + return { + ...state, + modal: undefined, + }; + } case "SET_TARGET_CONFIG": { return { ...state, diff --git a/teachertool/src/state/state.ts b/teachertool/src/state/state.ts index a08bddee54dc..8e3abde58a02 100644 --- a/teachertool/src/state/state.ts +++ b/teachertool/src/state/state.ts @@ -1,14 +1,27 @@ -import { Notifications } from "../types"; +import { ModalType, Notifications } from "../types"; +import { CatalogCriteria, CriteriaInstance } from "../types/criteria"; export type AppState = { targetConfig?: pxt.TargetConfig; notifications: Notifications; currentEvalResult: pxt.blocks.EvaluationResult | undefined; projectMetadata: pxt.Cloud.JsonScript | undefined; + catalog: CatalogCriteria[] | undefined; + selectedCriteria: CriteriaInstance[]; + modal: ModalType | undefined; + flags: { + testCatalog: boolean; + } }; export const initialAppState: AppState = { notifications: [], currentEvalResult: undefined, projectMetadata: undefined, + catalog: undefined, + selectedCriteria: [], + modal: undefined, + flags: { + testCatalog: false + } }; diff --git a/teachertool/src/teacherTool.css b/teachertool/src/teacherTool.css index 96336e45c7de..36377f94b263 100644 --- a/teachertool/src/teacherTool.css +++ b/teachertool/src/teacherTool.css @@ -285,6 +285,64 @@ code { display: none; } +/*******************************/ +/******* CATALOG STYLING ******/ +/*******************************/ + +.catalog-modal .common-modal-footer { + display: flex; + justify-content: flex-end; +} + +.catalog-done { + align-self: flex-end; +} + +.catalog-item { + padding: 0.5rem; +} + +/*******************************/ +/******* RUBRIC STYLING *******/ +/*******************************/ + +.rubric-display { + border-radius: 0; + margin: 1rem 1rem; +} + +.rubric-display h3 { + border-bottom: 1px solid #ddd; + padding-bottom: 0.1rem; + margin-bottom: 0.5rem; +} + +.criteria-instance-display { + background-color: var(--white); + border: 1px solid #ddd; + border-radius: 0; + padding: 0.1rem 0.5rem; + margin-bottom: 0.5rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.criteria-btn-remove { + background-color: #f55c51; + color: var(--white); + border: none; + display: inline-block; + margin: 0.5rem; + padding: 0.5rem; + border-radius: 0; + transition: background-color 0.2s ease; +} + +.criteria-btn-remove:hover { + background-color: #da4949; +} + /*******************************/ /***** HIGH CONTRAST *****/ /*******************************/ diff --git a/teachertool/src/transforms/addCriteriaToRubric.ts b/teachertool/src/transforms/addCriteriaToRubric.ts new file mode 100644 index 000000000000..3c1e99fe9d01 --- /dev/null +++ b/teachertool/src/transforms/addCriteriaToRubric.ts @@ -0,0 +1,44 @@ +import { stateAndDispatch } from "../state"; +import * as Actions from "../state/actions"; +import { getCatalogCriteriaWithId } from "../state/helpers"; +import { logDebug, logError } from "../services/loggingService"; +import { CriteriaInstance, CriteriaParameterValue } from "../types/criteria"; +import { nanoid } from "nanoid"; +import { ErrorCode } from "../types/errorCode"; + +export function addCriteriaToRubric(catalogCriteriaIds: string[]) { + const { state: teacherTool, dispatch } = stateAndDispatch(); + + // Create instances for each of the catalog criteria. + const newSelectedCriteria = [...teacherTool.selectedCriteria ?? []] + for(const catalogCriteriaId of catalogCriteriaIds) { + const catalogCriteria = getCatalogCriteriaWithId(catalogCriteriaId); + if (!catalogCriteria) { + logError(ErrorCode.addingMissingCriteria, "Attempting to add criteria with unrecognized id", { id: catalogCriteriaId }); + continue; + } + + const params = catalogCriteria.parameters?.map( + param => + ({ + name: param.name, + value: undefined, + } as CriteriaParameterValue) + ); + + const instanceId = nanoid(); + + logDebug(`Adding criteria with Catalog ID '${catalogCriteriaId}' and Instance ID '${instanceId}'`); + const criteriaInstance = { + catalogCriteriaId, + instanceId, + params + } as CriteriaInstance; + + newSelectedCriteria.push(criteriaInstance); + } + + dispatch(Actions.setSelectedCriteria(newSelectedCriteria)); + + pxt.tickEvent("teachertool.addcriteria", { ids: JSON.stringify(catalogCriteriaIds) }); +} \ No newline at end of file diff --git a/teachertool/src/transforms/hideModal.ts b/teachertool/src/transforms/hideModal.ts new file mode 100644 index 000000000000..ce5c6d958e30 --- /dev/null +++ b/teachertool/src/transforms/hideModal.ts @@ -0,0 +1,14 @@ +import { logDebug } from "../services/loggingService"; +import { stateAndDispatch } from "../state"; +import * as Actions from "../state/actions"; +import { ModalType } from "../types"; + +export function hideModal(modal: ModalType) { + const { state: teacherTool, dispatch } = stateAndDispatch(); + + if (teacherTool.modal === modal) { + dispatch(Actions.hideModal()); + } else { + logDebug(`Trying to hide '${modal}' model when it was not active`); + } +} \ No newline at end of file diff --git a/teachertool/src/transforms/loadCatalogAsync.ts b/teachertool/src/transforms/loadCatalogAsync.ts new file mode 100644 index 000000000000..67fc996a8583 --- /dev/null +++ b/teachertool/src/transforms/loadCatalogAsync.ts @@ -0,0 +1,39 @@ +import { stateAndDispatch } from "../state"; +import * as Actions from "../state/actions"; +import { logError } from "../services/loggingService"; +import { CatalogCriteria } from "../types/criteria"; +import { ErrorCode } from "../types/errorCode"; + +const prodFiles = [ + "/teachertool/catalog.json", // target-specific catalog + "/teachertool/catalog-shared.json" // shared across all targets +]; + +// Catalog entries still being tested, will only appear when in debug mode (?dbg=1) +const testFiles = [ + "/teachertool/catalog-test.json", + "/teachertool/catalog-shared-test.json" +] + +interface CatalogInfo { + criteria: CatalogCriteria[]; +} + +export async function loadCatalogAsync() { + const { state: teacherTool, dispatch } = stateAndDispatch(); + const catalogFiles = teacherTool.flags.testCatalog ? prodFiles.concat(testFiles) : prodFiles; + + let fullCatalog: CatalogCriteria[] = []; + for (const catalogFile of catalogFiles) { + try { + const catalogResponse = await fetch(catalogFile); + const catalogContent = await catalogResponse.json() as CatalogInfo; + fullCatalog = fullCatalog.concat(catalogContent.criteria ?? []); + } catch (e) { + logError(ErrorCode.loadCatalogFailed, e, { catalogFile }); + continue; + } + } + + dispatch(Actions.setCatalog(fullCatalog)); +} \ No newline at end of file diff --git a/teachertool/src/transforms/removeCriteriaFromRubric.ts b/teachertool/src/transforms/removeCriteriaFromRubric.ts new file mode 100644 index 000000000000..285509f7f47b --- /dev/null +++ b/teachertool/src/transforms/removeCriteriaFromRubric.ts @@ -0,0 +1,12 @@ +import { stateAndDispatch } from "../state"; +import * as Actions from "../state/actions"; +import { logDebug } from "../services/loggingService"; +import { CriteriaInstance } from "../types/criteria"; + +export function removeCriteriaFromRubric(instance: CriteriaInstance) { + logDebug(`Removing criteria with id: ${instance.instanceId}`); + + const { dispatch } = stateAndDispatch(); + dispatch(Actions.removeCriteriaInstance(instance.instanceId)); + pxt.tickEvent("teachertool.removecriteria", { catalogCriteriaId: instance.catalogCriteriaId }); +} \ No newline at end of file diff --git a/teachertool/src/transforms/showCatalogModal.ts b/teachertool/src/transforms/showCatalogModal.ts new file mode 100644 index 000000000000..bbf53e20ac41 --- /dev/null +++ b/teachertool/src/transforms/showCatalogModal.ts @@ -0,0 +1,7 @@ +import { stateAndDispatch } from "../state"; +import * as Actions from "../state/actions"; + +export function showCatalogModal() { + const { dispatch } = stateAndDispatch(); + dispatch(Actions.showModal("catalog-display")); +} \ No newline at end of file diff --git a/teachertool/src/types/criteria.ts b/teachertool/src/types/criteria.ts new file mode 100644 index 000000000000..afddd662a209 --- /dev/null +++ b/teachertool/src/types/criteria.ts @@ -0,0 +1,29 @@ +// A criteria defined in the catalog of all possible criteria for the user to choose from when creating a rubric. +export interface CatalogCriteria { + id: string; // A unique id (GUID) for the catalog criteria + use: string; // Refers to the validator plan this criteria relies upon + template: string; // A (mostly) human-readable string describing the criteria. May contain parameters + description: string | undefined; // More detailed description + docPath: string | undefined; // Path to documentation + parameters: CriteriaParameter[] | undefined; // Any parameters that affect the criteria +} + +// An instance of a criteria in a rubric. +export interface CriteriaInstance { + catalogCriteriaId: string; + instanceId: string; + params: CriteriaParameterValue[] | undefined; +} + +// Represents a parameter definition in a catalog criteria. +export interface CriteriaParameter { + name: string; + type: string; + path: string; // The json path of the parameter in the catalog criteria. +} + +// Represents a parameter value in a criteria instance. +export interface CriteriaParameterValue { + name: string; + value: any; // Undefined if no value has been selected. +} \ No newline at end of file diff --git a/teachertool/src/types/errorCode.ts b/teachertool/src/types/errorCode.ts new file mode 100644 index 000000000000..bd9ff3732ee3 --- /dev/null +++ b/teachertool/src/types/errorCode.ts @@ -0,0 +1,8 @@ +export enum ErrorCode { + runEval = "runEval", + addingMissingCriteria = "addingMissingCriteria", + loadCatalogFailed = "loadCatalogFailed", + getProjectTextAsync = "getProjectTextAsync", + getProjectMetaAsync = "getProjectMetaAsync", + downloadTargetConfigAsync = "downloadTargetConfigAsync" +} \ No newline at end of file diff --git a/teachertool/src/types/index.ts b/teachertool/src/types/index.ts index 3be0d9b0e009..76dff186f9ce 100644 --- a/teachertool/src/types/index.ts +++ b/teachertool/src/types/index.ts @@ -8,4 +8,6 @@ export type NotificationWithId = Notification & { expiration: number; // Date.now() + duration }; -export type Notifications = NotificationWithId[]; \ No newline at end of file +export type Notifications = NotificationWithId[]; + +export type ModalType = "catalog-display"; \ No newline at end of file