Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Store Rubric in IndexedDB #9840

Merged
merged 16 commits into from
Feb 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@srietkerk The changes in this file are responding to your comments in #9835. They're not related to the indexed db change, I actually forgot I'd made them in this branch, but I suppose here they are 😅

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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to await the db init here.

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
Loading