Skip to content

Commit

Permalink
Store Rubric in IndexedDB (#9840)
Browse files Browse the repository at this point in the history
This change stores the rubric in the indexed db and loads the last-active rubric automatically when the page loads. Currently only one rubric is really stored at a time, but the schema can support multiple if we want to evolve this over time.

Indexed DB has two tables:
1. Rubrics - where we store the rubrics, with the key set to the rubric name
2. Metadata - internal settings we may want to store, currently just lastActiveRubricName, but I imagine we may want to use this later to preserve preferences like auto-save and auto-run.
  • Loading branch information
thsparks authored Feb 1, 2024
1 parent 4a2c1a1 commit 1b92ccc
Show file tree
Hide file tree
Showing 17 changed files with 331 additions and 49 deletions.
10 changes: 7 additions & 3 deletions pxtblocks/code-validation/runValidatorPlanAsync.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
namespace pxt.blocks {

const maxConcurrentChecks = 4;

export async function runValidatorPlanAsync(usedBlocks: Blockly.Block[], plan: ValidatorPlan): Promise<boolean> {
// Each plan can have multiple checks it needs to run.
// Run all of them in parallel, and then check if the number of successes is greater than the specified threshold.
// TBD if it's faster to run in parallel without short-circuiting once the threshold is reached, or if it's faster to run sequentially and short-circuit.
const startTime = Date.now();

const checkRuns = pxt.Util.promisePoolAsync(4, plan.checks, async (check: ValidatorCheckBase): Promise<boolean> => {
const checkRuns = pxt.Util.promisePoolAsync(maxConcurrentChecks, plan.checks, async (check: ValidatorCheckBase): Promise<boolean> => {
switch (check.validator) {
case "blocksExist":
return runBlocksExistValidation(usedBlocks, check as BlocksExistValidatorCheck);
Expand All @@ -18,14 +21,15 @@ namespace pxt.blocks {

const results = await checkRuns;
const successCount = results.filter((r) => r).length;
const passed = successCount >= plan.threshold;

pxt.tickEvent("validation.evaluation_complete", {
plan: plan.name,
durationMs: Date.now() - startTime,
passed: `${successCount >= plan.threshold}`,
passed: `${passed}`,
});

return successCount >= plan.threshold;
return passed;
}

function runBlocksExistValidation(usedBlocks: Blockly.Block[], inputs: BlocksExistValidatorCheck): boolean {
Expand Down
3 changes: 3 additions & 0 deletions teachertool/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { CatalogModal } from "./components/CatalogModal";
import { postNotification } from "./transforms/postNotification";
import { loadCatalogAsync } from "./transforms/loadCatalogAsync";
import { loadValidatorPlansAsync } from "./transforms/loadValidatorPlansAsync";
import { tryLoadLastActiveRubricAsync } from "./transforms/tryLoadLastActiveRubricAsync";

export const App = () => {
const { state, dispatch } = useContext(AppStateContext);
Expand All @@ -34,6 +35,8 @@ export const App = () => {
await loadCatalogAsync();
await loadValidatorPlansAsync();

await tryLoadLastActiveRubricAsync();

// TODO: Remove this. Delay app init to expose any startup race conditions.
setTimeout(() => {
// Test notification
Expand Down
17 changes: 13 additions & 4 deletions teachertool/src/components/ActiveRubricDisplay.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,30 @@
/// <reference path="../../../built/pxtblocks.d.ts"/>

import { useContext } from "react";
import { useContext, useState } 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";
import { setRubricName } from "../transforms/setRubricName";
import { DebouncedInput } from "./DebouncedInput";

interface IProps {}

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

return (
<div className="rubric-display">
<h3>{lf("Rubric")}</h3>
{teacherTool.selectedCriteria?.map(criteriaInstance => {
<DebouncedInput
label={lf("Rubric Name")}
ariaLabel={lf("Rubric Name")}
onChange={setRubricName}
placeholder={lf("Rubric Name")}
initialValue={teacherTool.rubric.name}
preserveValueOnBlur={true}
/>
{teacherTool.rubric.criteria?.map(criteriaInstance => {
if (!criteriaInstance) return null;

const catalogCriteria = getCatalogCriteriaWithId(criteriaInstance.catalogCriteriaId);
Expand Down
43 changes: 43 additions & 0 deletions teachertool/src/components/DebouncedInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useEffect, useRef } from "react";
import { Input, InputProps } from "react-common/components/controls/Input";

export interface DebouncedInputProps extends InputProps {
intervalMs?: number; // Default 500 ms
}

// This functions like the React Common Input, but debounces onChange calls,
// so if onChange is called multiple times in quick succession, it will only
// be executed once after a pause of the specified `interval` in milliseconds.
export const DebouncedInput: React.FC<DebouncedInputProps> = ({ intervalMs = 500, ...props }) => {
const timerId = useRef<NodeJS.Timeout | undefined>(undefined);
const latestValue = useRef<string>("");

const sendChange = () => {
if (props.onChange) {
props.onChange(latestValue.current);
}
};

// If the timer is pending and the component unmounts,
// clear the timer and fire the onChange event immediately.
useEffect(() => {
return () => {
if (timerId.current) {
clearTimeout(timerId.current);
sendChange();
}
};
}, []);

const onChangeDebounce = (newValue: string) => {
latestValue.current = newValue;

if (timerId.current) {
clearTimeout(timerId.current);
}

timerId.current = setTimeout(sendChange, intervalMs);
};

return <Input {...props} onChange={onChangeDebounce} />;
};
2 changes: 1 addition & 1 deletion teachertool/src/components/EvalResultDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const EvalResultDisplay: React.FC<IProps> = ({}) => {
const { state: teacherTool } = useContext(AppStateContext);

function getTemplateStringFromCriteriaInstanceId(instanceId: string): string {
const catalogCriteriaId = teacherTool.selectedCriteria?.find(
const catalogCriteriaId = teacherTool.rubric.criteria?.find(
criteria => criteria.instanceId === instanceId
)?.catalogCriteriaId;
if (!catalogCriteriaId) return "";
Expand Down
128 changes: 128 additions & 0 deletions teachertool/src/services/indexedDbService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { openDB, IDBPDatabase } from "idb";
import { ErrorCode } from "../types/errorCode";
import { logError } from "./loggingService";
import { Rubric } from "../types/rubric";

const teacherToolDbName = "makecode-project-insights";
const dbVersion = 1;
const rubricsStoreName = "rubrics";
const metadataStoreName = "metadata";
const metadataKeys = {
lastActiveRubricKey: "lastActiveRubricName",
};

type MetadataEntry = { key: string; value: any };

class TeacherToolDb {
db: IDBPDatabase | undefined;

public async initializeAsync() {
if (this.db) return;
this.db = await openDB(teacherToolDbName, dbVersion, {
upgrade(db) {
db.createObjectStore(rubricsStoreName, { keyPath: "name" });
db.createObjectStore(metadataStoreName, { keyPath: "key" });
},
});
}

private async getAsync<T>(storeName: string, key: string): Promise<T | undefined> {
if (!this.db) {
throw new Error("IndexedDb not initialized.");
}

try {
return await this.db.get(storeName, key);
} catch (e) {
// Not recording key, as it could contain user-input with sensitive information.
logError(ErrorCode.unableToGetIndexedDbRecord, e);
}
}

private async setAsync<T>(storeName: string, value: T): Promise<void> {
if (!this.db) {
throw new Error("IndexedDb not initialized.");
}

try {
await this.db.put(storeName, value);
} catch (e) {
// Not recording key, as it could contain user-input with sensitive information.
logError(ErrorCode.unableToSetIndexedDbRecord, e);
}
}

private async deleteAsync(storeName: string, key: string): Promise<void> {
if (!this.db) {
throw new Error("IndexedDb not initialized.");
}
try {
await this.db.delete(storeName, key);
} catch (e) {
// Not recording key, as it could contain user-input with sensitive information.
logError(ErrorCode.unableToDeleteIndexedDbRecord, e);
}
}

private async getMetadataEntryAsync(key: string): Promise<MetadataEntry | undefined> {
return this.getAsync<MetadataEntry>(metadataStoreName, key);
}

private async setMetadataEntryAsync(key: string, value: any): Promise<void> {
return this.setAsync<MetadataEntry>(metadataStoreName, { key, value });
}

private async deleteMetadataEntryAsync(key: string): Promise<void> {
return this.deleteAsync(metadataStoreName, key);
}

public async getLastActiveRubricNameAsync(): Promise<string | undefined> {
const metadataEntry = await this.getMetadataEntryAsync(metadataKeys.lastActiveRubricKey);
return metadataEntry?.value;
}

public saveLastActiveRubricNameAsync(name: string): Promise<void> {
return this.setMetadataEntryAsync(metadataKeys.lastActiveRubricKey, name);
}

public getRubric(name: string): Promise<Rubric | undefined> {
return this.getAsync<Rubric>(rubricsStoreName, name);
}

public saveRubric(rubric: Rubric): Promise<void> {
return this.setAsync(rubricsStoreName, rubric);
}

public deleteRubric(name: string): Promise<void> {
return this.deleteAsync(rubricsStoreName, name);
}
}

const getDb = (async () => {
const db = new TeacherToolDb();
await db.initializeAsync();
return db;
})();

export async function getLastActiveRubricAsync(): Promise<Rubric | undefined> {
const db = await getDb;

let rubric: Rubric | undefined = undefined;
const name = await db.getLastActiveRubricNameAsync();
if (name) {
rubric = await db.getRubric(name);
}

return rubric;
}

export async function saveRubricAsync(rubric: Rubric) {
const db = await getDb;
await db.saveRubric(rubric);
await db.saveLastActiveRubricNameAsync(rubric.name);
}

export async function deleteRubricAsync(name: string) {
const db = await getDb;
await db.deleteRubric(name);
}
31 changes: 10 additions & 21 deletions teachertool/src/state/actions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ModalType, NotificationWithId } from "../types";
import { CatalogCriteria, CriteriaEvaluationResult, CriteriaInstance } from "../types/criteria";
import { CatalogCriteria, CriteriaEvaluationResult } from "../types/criteria";
import { Rubric } from "../types/rubric";

// Changes to app state are performed by dispatching actions to the reducer
type ActionBase = {
Expand Down Expand Up @@ -49,14 +50,9 @@ type SetCatalog = ActionBase & {
catalog: CatalogCriteria[] | undefined;
};

type SetSelectedCriteria = ActionBase & {
type: "SET_SELECTED_CRITERIA";
criteria: CriteriaInstance[];
};

type RemoveCriteriaInstance = ActionBase & {
type: "REMOVE_CRITERIA_INSTANCE";
instanceId: string;
type SetRubric = ActionBase & {
type: "SET_RUBRIC";
rubric: Rubric;
};

type ShowModal = ActionBase & {
Expand Down Expand Up @@ -86,8 +82,7 @@ export type Action =
| ClearAllEvalResults
| SetTargetConfig
| SetCatalog
| SetSelectedCriteria
| RemoveCriteriaInstance
| SetRubric
| ShowModal
| HideModal
| SetValidatorPlans;
Expand Down Expand Up @@ -135,14 +130,9 @@ const setCatalog = (catalog: CatalogCriteria[] | undefined): SetCatalog => ({
catalog,
});

const setSelectedCriteria = (criteria: CriteriaInstance[]): SetSelectedCriteria => ({
type: "SET_SELECTED_CRITERIA",
criteria,
});

const removeCriteriaInstance = (instanceId: string): RemoveCriteriaInstance => ({
type: "REMOVE_CRITERIA_INSTANCE",
instanceId,
const setRubric = (rubric: Rubric): SetRubric => ({
type: "SET_RUBRIC",
rubric,
});

const showModal = (modal: ModalType): ShowModal => ({
Expand All @@ -168,8 +158,7 @@ export {
clearAllEvalResults,
setTargetConfig,
setCatalog,
setSelectedCriteria,
removeCriteriaInstance,
setRubric,
showModal,
hideModal,
setValidatorPlans,
Expand Down
12 changes: 4 additions & 8 deletions teachertool/src/state/reducer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AppState } from "./state";
import { Action } from "./actions";
import { updateStoredRubricAsync } from "../transforms/updateStoredRubric";

// The reducer's job is to apply state changes by creating a copy of the existing state with the change applied.
// The reducer must not create side effects. E.g. do not dispatch a state change from within the reducer.
Expand Down Expand Up @@ -59,16 +60,11 @@ export default function reducer(state: AppState, action: Action): AppState {
catalog: action.catalog,
};
}
case "SET_SELECTED_CRITERIA": {
case "SET_RUBRIC": {
/*await*/ updateStoredRubricAsync(state.rubric, action.rubric); // fire and forget, we don't need to wait for this to finish.
return {
...state,
selectedCriteria: [...action.criteria],
};
}
case "REMOVE_CRITERIA_INSTANCE": {
return {
...state,
selectedCriteria: state.selectedCriteria.filter(c => c.instanceId !== action.instanceId),
rubric: action.rubric,
};
}
case "SHOW_MODAL": {
Expand Down
5 changes: 3 additions & 2 deletions teachertool/src/state/state.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { ModalType, Notifications } from "../types";
import { CatalogCriteria, CriteriaEvaluationResult, CriteriaInstance } from "../types/criteria";
import { Rubric } from "../types/rubric";

export type AppState = {
targetConfig?: pxt.TargetConfig;
notifications: Notifications;
evalResults: pxt.Map<CriteriaEvaluationResult>; // Criteria Instance Id -> Result
projectMetadata: pxt.Cloud.JsonScript | undefined;
catalog: CatalogCriteria[] | undefined;
selectedCriteria: CriteriaInstance[];
rubric: Rubric;
modal: ModalType | undefined;
validatorPlans: pxt.blocks.ValidatorPlan[] | undefined;
flags: {
Expand All @@ -20,7 +21,7 @@ export const initialAppState: AppState = {
evalResults: {},
projectMetadata: undefined,
catalog: undefined,
selectedCriteria: [],
rubric: { name: "", criteria: [] },
modal: undefined,
validatorPlans: undefined,
flags: {
Expand Down
Loading

0 comments on commit 1b92ccc

Please sign in to comment.