Skip to content

Commit

Permalink
Have Teacher Tool Request Iframe to Run Evaluation for Specified Shar…
Browse files Browse the repository at this point in the history
…e Link (#9821)

At a high level, this change does the following:
1. Connects the iframe to the share link that has been provided (except in localhost, because cross-domain localhost <-> makecode.com messaging doesn't work)
2. Saves project metadata and rubric info in app state
3. Adds a request to the iframe to get evaluation results. This request includes the rubric json as a parameter.

A few notable points:
- Logging Service
I added a logging service for convenient, consistent output and telemetry. There's LogError, which sends a tick event and does a console error output, LogInfo which does a tick event and non-error console output, and LogDebug, which sends console output but only if you're running the app locally.

- State in MakeCode Frame
I went back and forth on this, but in the end, in order to be consistent with our other component usage, I pushed the state for the project metadata into the MakecodeFrame. I can pull this out and have it passed in as props instead if desired, but I wasn't sure if there was all that much value in doing so at the moment.

- Fix for Promise Resolution in makecodeEditorService
Without the fix, when a message got queued up by Caller A but not sent immediately (editor not ready), then another caller has to come in and clear the queue once the editor is ready (Caller B). This was working okay, except it would assign the message's handler to the resolve function from the promise associated with Caller B's request, not Caller A's. As such, when a response comes back for the message, we'd end up resolving Caller B's promise instead of Caller A's, so Caller A would never return. To fix this, I assign the message metadata (including the handler) when we queue the message rather than when we dequeue it. This way, the handler will always get set to the resolve function for the promise returned to the caller that tried to send the message initially.

- Renamed loadProjectAsync to loadProjectMetadataAsync
Wanted to avoid confusion between loading the metadata and the project "loading" in the iframe.

- Placeholders
The EvaluationResult class and editor-side handler for the runeval request are just placeholders until we have actual evaluation running. I expect these to change in the near future.

- Try it Here
Upload target: https://arcade.makecode.com/app/e620e5c33cf3af44b631781f582f4ebcc34223c7-c7c3f7a5ad--eval
  • Loading branch information
thsparks authored Jan 10, 2024
1 parent aa2975d commit 00c1c8f
Show file tree
Hide file tree
Showing 15 changed files with 270 additions and 70 deletions.
5 changes: 5 additions & 0 deletions pxtblocks/code-validation/evaluationResult.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace pxt.blocks {
export interface EvaluationResult {
passed: boolean;
}
}
17 changes: 17 additions & 0 deletions pxteditor/editorcontroller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ namespace pxt.editor {
| "info" // return info data`
| "tutorialevent"
| "editorcontentloaded"
| "runeval"

// package extension messasges
| ExtInitializeType
Expand Down Expand Up @@ -289,6 +290,11 @@ namespace pxt.editor {
layout?: pxt.blocks.BlockLayout;
}

export interface EditorMessageRunEvalRequest extends EditorMessageRequest {
action: "runeval";
rubric: string;
}

export interface EditorMessageRenderBlocksResponse {
svg: SVGSVGElement;
xml: Promise<any>;
Expand Down Expand Up @@ -538,6 +544,17 @@ namespace pxt.editor {
})
});
}
case "runeval": {
const evalmsg = data as EditorMessageRunEvalRequest;
const rubric = evalmsg.rubric;
return Promise.resolve()
.then(() => (
// TODO : call into evaluation function.
{ passed: true } as pxt.blocks.EvaluationResult))
.then (results => {
resp = results;
});
}
case "renderpython": {
const rendermsg = data as EditorMessageRenderPythonRequest;
return Promise.resolve()
Expand Down
16 changes: 7 additions & 9 deletions teachertool/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import HeaderBar from "./components/HeaderBar";
import Notifications from "./components/Notifications";
import * as NotificationService from "./services/notificationService";
import { postNotification } from "./transforms/postNotification";
import { createIFrameUrl, makeNotification } from "./utils";
import { makeNotification } from "./utils";
import DebugInput from "./components/DebugInput";
import { MakeCodeFrame } from "./components/MakecodeFrame";
import EvalResultDisplay from "./components/EvalResultDisplay";


function App() {
Expand All @@ -32,16 +33,13 @@ function App() {
}, [ready]);

return (
<>
<div className="app-container">
<HeaderBar />
<div className="app-container">
<DebugInput />
<MakeCodeFrame pageSourceUrl={createIFrameUrl()} />
</div>

<DebugInput />
<EvalResultDisplay />
<MakeCodeFrame />
<Notifications />

</>
</div>
);
}

Expand Down
8 changes: 3 additions & 5 deletions teachertool/src/components/DebugInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,18 @@ 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 { loadProjectAsync } from "../transforms/loadProjectAsync";
import { loadProjectMetadataAsync } from "../transforms/loadProjectMetadataAsync";
import { runEvaluateAsync } from "../transforms/runEvaluateAsync";

interface IProps {}

const DebugInput: React.FC<IProps> = ({}) => {
const [shareLink, setShareLink] = useState("https://arcade.makecode.com/S50644-45891-08403-36583");
const [rubric, setRubric] = useState("");
const [bools, setBools] = useState(true);

const evaluate = async () => {
setBools(!bools);
await runEvaluateAsync(shareLink, rubric);
await loadProjectAsync("hi", bools);
await loadProjectMetadataAsync(shareLink);
await runEvaluateAsync(rubric);
}

return (
Expand Down
26 changes: 26 additions & 0 deletions teachertool/src/components/EvalResultDisplay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/// <reference path="../../../built/pxtblocks.d.ts"/>

import { useContext, useState } from "react";
import { AppStateContext } from "../state/appStateContext";

interface IProps {}

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

return (
<>
{teacherTool.projectMetadata && (
<div className="eval-results-container">
<h3>{lf("Project: {0}", teacherTool.projectMetadata.name)}</h3>
{teacherTool.currentEvalResult === undefined && <div className="common-spinner" />}
{teacherTool.currentEvalResult?.passed === true && <h4 className="positive-text">Passed!</h4>}
{teacherTool.currentEvalResult?.passed === false && <h4 className="negative-text">Failed!</h4>}
</div>
)}
</>
);

};

export default EvalResultDisplay;
40 changes: 29 additions & 11 deletions teachertool/src/components/MakecodeFrame.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,39 @@
/// <reference path="../../../built/pxteditor.d.ts" />
import * as React from "react";
import { useContext } from "react";
import { setEditorRef } from "../services/makecodeEditorService";
interface MakeCodeFrameProps {
pageSourceUrl: string;
tutorialEventHandler?: (event: pxt.editor.EditorMessageTutorialEventRequest) => void;
}
import { AppStateContext } from "../state/appStateContext";
import { getEditorUrl } from "../utils";

interface MakeCodeFrameProps {}
export const MakeCodeFrame: React.FC<MakeCodeFrameProps> = () => {
const { state: teacherTool } = useContext(AppStateContext)

export const MakeCodeFrame: React.FC<MakeCodeFrameProps> =
( { pageSourceUrl} ) => {
function createIFrameUrl(shareId: string): string {
const editorUrl: string = pxt.BrowserUtils.isLocalHost() ? "http://localhost:3232/index.html" : getEditorUrl((window as any).pxtTargetBundle.appTheme.embedUrl);

let url = editorUrl;
if (editorUrl.charAt(editorUrl.length - 1) === "/" && !pxt.BrowserUtils.isLocalHost()) {
url = editorUrl.substr(0, editorUrl.length - 1);
}
url += `?controller=1&ws=browser&nocookiebanner=1#pub:${shareId}`;
return url;
}

const handleIFrameRef = (el: HTMLIFrameElement | null) => {
setEditorRef(el ?? undefined);
}

/* eslint-disable @microsoft/sdl/react-iframe-missing-sandbox */
return <div className="makecode-frame-outer" style={{ display: "block" }}>
<iframe className="makecode-frame" src={pageSourceUrl} title={"title"} ref={handleIFrameRef}></iframe>
</div>
return (
<div className="makecode-frame-outer" style={{ display: "block" }}>
{teacherTool.projectMetadata && (
<iframe
className="makecode-frame"
src={createIFrameUrl(teacherTool.projectMetadata.id)}
title={"title"}
ref={handleIFrameRef} />
)}
</div>
);
/* eslint-enable @microsoft/sdl/react-iframe-missing-sandbox */
}
}
24 changes: 24 additions & 0 deletions teachertool/src/services/loggingService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const formatMessageForConsole = (message: string) => {
const time = new Date();
return `[${time.getHours()}:${time.getMinutes()}:${time.getSeconds()}] ${message}`;
}

const formatName = (name: string) => {
return name.toLowerCase().replace(/ /g, "_");
}

export const logError = (name: string, details: string) => {
pxt.tickEvent("teachertool.error", { name: formatName(name), message: details });
console.error(formatMessageForConsole(`${name}: ${details}`));
}

export const logInfo = (name: string, message: string) => {
pxt.tickEvent(`teachertool.${formatName(name)}`, { message: message });
console.log(formatMessageForConsole(message));
}

export const logDebug = (message: string) => {
if (pxt.BrowserUtils.isLocalHost() || pxt.options.debug) {
console.log(formatMessageForConsole(message));
}
}
66 changes: 54 additions & 12 deletions teachertool/src/services/makecodeEditorService.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,75 @@
import { logDebug, logError } from "./loggingService";

interface PendingMessage {
original: pxt.editor.EditorMessageRequest;
handler: (response: any) => void;
}

let makecodeEditorRef: HTMLIFrameElement | undefined;
let readyForMessages: boolean;
const messageQueue: pxt.editor.EditorMessageRequest[] = [];
let nextId: number = 0;
let pendingMessages: {[index: string]: PendingMessage} = {};

function onMessageReceived(event: MessageEvent) {
logDebug(`Message received from iframe: ${JSON.stringify(event.data)}`);

const data = event.data as pxt.editor.EditorMessageRequest;
if (data.type === "pxteditor" && data.id && pendingMessages[data.id]) {
const pending = pendingMessages[data.id];
pending.handler(data);
delete pendingMessages[data.id];
return;
}
if (data.type === "pxteditor") {
if (data.action === "editorcontentloaded") {
readyForMessages = true;
sendMessageAsync(); // flush message queue.
}

console.log("Received message from iframe:", data);
if (data.id && pendingMessages[data.id]) {
const pending = pendingMessages[data.id];
pending.handler(data);
delete pendingMessages[data.id];
}
}
}

function sendMessageAsync(message?: any) {
return new Promise(resolve => {
const sendMessageCore = (message: any) => {
logDebug(`Sending message to iframe: ${JSON.stringify(message)}`);
makecodeEditorRef!.contentWindow!.postMessage(message, "*");
}

if (message) {
message.response = true;
message.id = nextId++ + "";
pendingMessages[message.id] = {
original: message,
handler: resolve
};
makecodeEditorRef!.contentWindow!.postMessage(message, "*");
messageQueue.push(message);
}

if (message) messageQueue.push(message);
if (makecodeEditorRef) {
if (readyForMessages) {
while (messageQueue.length) {
sendMessageCore(messageQueue.shift());
}
}
});
}

// Check if the result was successful and (if expected) has data.
// Logs errors and throws if the result was not successful.
function validateResponse(result: pxt.editor.EditorMessageResponse, expectResponseData: boolean) {
if (!result.success) {
throw new Error(`Server returned failed status.`);
}
if (expectResponseData && !result?.resp) {
throw new Error(`Missing response data.`);
}
}

export function setEditorRef(ref: HTMLIFrameElement | undefined) {
readyForMessages = false;
makecodeEditorRef = ref ?? undefined;
window.removeEventListener("message", onMessageReceived);
if (ref) {
window.addEventListener("message", onMessageReceived);
sendMessageAsync();
}
}

Expand All @@ -59,3 +82,22 @@ export async function setHighContrastAsync(on: boolean) {
});
console.log(result);
}

export async function runEvalInEditorAsync(serializedRubric: string): Promise<pxt.blocks.EvaluationResult | undefined> {
let evalResults = undefined;

try {
const response = await sendMessageAsync({
type: "pxteditor",
action: "runeval",
rubric: serializedRubric } as pxt.editor.EditorMessageRunEvalRequest
);
const result = response as pxt.editor.EditorMessageResponse;
validateResponse(result, true); // Throws on failure
evalResults = result.resp as pxt.blocks.EvaluationResult;
} catch (e: any) {
logError("runeval_error", e);
}

return evalResults;
}
27 changes: 25 additions & 2 deletions teachertool/src/state/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,25 @@ type RemoveNotification = ActionBase & {
notificationId: string;
};

type SetProjectMetadata = ActionBase & {
type: "SET_PROJECT_METADATA";
metadata: pxt.Cloud.JsonScript | undefined;
};

type SetEvalResult = ActionBase & {
type: "SET_EVAL_RESULT";
result: pxt.blocks.EvaluationResult | undefined;
};

/**
* Union of all actions
*/

export type Action =
| PostNotification
| RemoveNotification;
| RemoveNotification
| SetProjectMetadata
| SetEvalResult;

/**
* Action creators
Expand All @@ -41,8 +53,19 @@ const removeNotification = (notificationId: string): RemoveNotification => ({
notificationId,
});

const setProjectMetadata = (metadata: pxt.Cloud.JsonScript | undefined): SetProjectMetadata => ({
type: "SET_PROJECT_METADATA",
metadata,
});

const setEvalResult = (result: pxt.blocks.EvaluationResult | undefined): SetEvalResult => ({
type: "SET_EVAL_RESULT",
result,
});

export {
postNotification,
removeNotification
removeNotification,
setProjectMetadata,
setEvalResult,
};
12 changes: 12 additions & 0 deletions teachertool/src/state/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ export default function reducer(state: AppState, action: Action): AppState {
notifications,
};
}
case "SET_PROJECT_METADATA": {
return {
...state,
projectMetadata: action.metadata,
};
}
case "SET_EVAL_RESULT": {
return {
...state,
currentEvalResult: action.result,
};
}
}

return state;
Expand Down
6 changes: 5 additions & 1 deletion teachertool/src/state/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ import { Notifications } from "../types";
export type AppState = {
targetConfig?: pxt.TargetConfig;
notifications: Notifications;
currentEvalResult: pxt.blocks.EvaluationResult | undefined;
projectMetadata: pxt.Cloud.JsonScript | undefined;
};

export const initialAppState: AppState = {
notifications: []
notifications: [],
currentEvalResult: undefined,
projectMetadata: undefined,
};
Loading

0 comments on commit 00c1c8f

Please sign in to comment.